From 93f69da8d04f0772870276dd181f42222ae7f2fd Mon Sep 17 00:00:00 2001 From: samzong Date: Mon, 20 Jul 2026 11:46:38 -0400 Subject: [PATCH] feat(starter): scaffold cli-first python app with appctl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a minimal Python task API, OpenAPI contract, Lathe-generated appctl CLI, acceptance tests through the CLI, and make check pipeline. Signed-off-by: samzong ## Considered and deferred - openapi/openapi.yaml:3 [BOT-NIT]: info.title still says "CLI-first Node application"; metadata-only mismatch with this Python starter, not a runtime contract break. - tests/test_cli.py:77 [BOT-NIT]: request helper closes HTTPConnection without try/finally; failure paths are assertion-local and not a production leak. - go.mod [BOT-TASTE]: example.com module path is acceptable for a template starter. - src/app.py [BOT-SCOPE]: no auth/rate-limit — intentional minimal starter surface, out of this scaffold commit. --- .github/workflows/ci.yml | 29 ++++ .gitignore | 10 ++ AGENTS.md | 17 +++ LICENSE | 21 +++ Makefile | 17 +++ README.md | 24 ++++ cli.yaml | 4 + cmd/appctl/cli.yaml | 4 + cmd/appctl/main.go | 20 +++ go.mod | 16 +++ go.sum | 19 +++ internal/generated/app/app_gen.go | 113 ++++++++++++++++ internal/generated/modules_gen.go | 19 +++ openapi/openapi.yaml | 169 ++++++++++++++++++++++++ pyproject.toml | 9 ++ skills/appctl/.lathe-skill | 1 + skills/appctl/SKILL.md | 42 ++++++ skills/appctl/agents/openai.yaml | 4 + skills/appctl/references/catalog.md | 57 ++++++++ skills/appctl/references/modules/app.md | 69 ++++++++++ specs/sources.yaml | 8 ++ src/app.py | 148 +++++++++++++++++++++ src/server.py | 12 ++ test/empty.json | 1 + tests/test_cli.py | 91 +++++++++++++ uv.lock | 8 ++ 26 files changed, 932 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cli.yaml create mode 100644 cmd/appctl/cli.yaml create mode 100644 cmd/appctl/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/generated/app/app_gen.go create mode 100644 internal/generated/modules_gen.go create mode 100644 openapi/openapi.yaml create mode 100644 pyproject.toml create mode 100644 skills/appctl/.lathe-skill create mode 100644 skills/appctl/SKILL.md create mode 100644 skills/appctl/agents/openai.yaml create mode 100644 skills/appctl/references/catalog.md create mode 100644 skills/appctl/references/modules/app.md create mode 100644 specs/sources.yaml create mode 100644 src/app.py create mode 100644 src/server.py create mode 100644 test/empty.json create mode 100644 tests/test_cli.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..05d661d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.14" + - uses: astral-sh/setup-uv@v8 + with: + enable-cache: true + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + - run: uv sync --locked + - run: make cli-sync + - name: Verify generated CLI is current + run: git diff --exit-code -- cmd/appctl/cli.yaml internal/generated skills/appctl go.mod go.sum + - run: make test + - run: uv run --locked python -m compileall -q src tests + - run: go vet ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c70bdbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.cache/ +.local/ +.venv/ +__pycache__/ +*.py[cod] +bin/ +coverage/ +.env +.DS_Store + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f7cf940 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# Agent Development Contract + +This repository is CLI-first. The generated `appctl` CLI is the primary acceptance surface for application behavior. + +For every API-facing change: + +1. Update the Python application under `src/`. +2. Update `openapi/openapi.yaml` in the same change. It is the API contract and CLI source of truth. +3. Run `make check` to regenerate, build, and test through `appctl`. +4. Commit the matching changes under `internal/generated/`, `skills/appctl/`, and `cmd/appctl/cli.yaml`. + +Do not hand-edit generated files. Use direct HTTP only for transport-level checks that the generated CLI cannot express. + +Before guessing a command or flag, use `appctl search "" --json`, then `appctl commands show --json`. + +Keep the Python application on the standard library until a concrete requirement needs a package. Use `uv` for Python commands. Do not add database, authentication, deployment, or compatibility scaffolding without an explicit requirement. + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a1d2b41 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 lathe-cli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bfb42f0 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: cli-sync cli-build test check + +cli-sync: + cp cli.yaml cmd/appctl/cli.yaml + go run github.com/lathe-cli/lathe/cmd/lathe@v0.4.4 bootstrap + go mod tidy + +cli-build: + go build -o bin/appctl ./cmd/appctl + +test: cli-build + uv run --locked python -m unittest discover -s tests + +check: cli-sync test + uv run --locked python -m compileall -q src tests + go vet ./... + diff --git a/README.md b/README.md new file mode 100644 index 0000000..58b8d4a --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# CLI-first Python app + +A minimal Python application where the CLI is ready on day one. Change the application, update OpenAPI, and test through the generated `appctl` CLI. + +Requires Python 3.14+, uv, and Go 1.25+. + +```sh +uv sync +make check +uv run --locked python -m src.server +``` + +In another terminal: + +```sh +./bin/appctl search "create task" --json +./bin/appctl commands show tasks create --json +./bin/appctl tasks create --set title="Ship from the CLI" -o json +``` + +`make check` regenerates the CLI from `openapi/openapi.yaml`, builds it, and runs acceptance tests through CLI commands. `make test` only builds and tests the checked-in generated CLI. + +Generated output under `internal/generated/` and `skills/appctl/` is committed with API changes. The pipeline uses [Lathe](https://github.com/lathe-cli/lathe), pinned by version. + diff --git a/cli.yaml b/cli.yaml new file mode 100644 index 0000000..a705932 --- /dev/null +++ b/cli.yaml @@ -0,0 +1,4 @@ +cli: + name: appctl + short: "CLI for the application API" + diff --git a/cmd/appctl/cli.yaml b/cmd/appctl/cli.yaml new file mode 100644 index 0000000..a705932 --- /dev/null +++ b/cmd/appctl/cli.yaml @@ -0,0 +1,4 @@ +cli: + name: appctl + short: "CLI for the application API" + diff --git a/cmd/appctl/main.go b/cmd/appctl/main.go new file mode 100644 index 0000000..3a594fb --- /dev/null +++ b/cmd/appctl/main.go @@ -0,0 +1,20 @@ +package main + +import ( + _ "embed" + "os" + + "github.com/lathe-cli/lathe/pkg/lathe" + + "example.com/cli-first-python-app/internal/generated" +) + +//go:embed cli.yaml +var manifestBytes []byte + +func main() { + os.Exit(lathe.Run(lathe.RunOptions{ + Manifest: manifestBytes, + Mount: generated.MountModules, + })) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b4f9342 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module example.com/cli-first-python-app + +go 1.25.7 + +require ( + github.com/lathe-cli/lathe v0.4.4 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3b0743f --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lathe-cli/lathe v0.4.4 h1:5TNnLpLccH7YAxt3n1Lv5GOrf4i+U264BGvb3v2Haws= +github.com/lathe-cli/lathe v0.4.4/go.mod h1:R7LnEOMjrGHDutF3l2VyY5yc7AAoKLsFSj4JeSuLJRc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/generated/app/app_gen.go b/internal/generated/app/app_gen.go new file mode 100644 index 0000000..a64d62d --- /dev/null +++ b/internal/generated/app/app_gen.go @@ -0,0 +1,113 @@ +// Code generated by lathe codegen. DO NOT EDIT. + +package app + +import ( + "github.com/spf13/cobra" + + "github.com/lathe-cli/lathe/pkg/runtime" +) + +const generatedSchemaVersion = 8 + +func Mount(root *cobra.Command) error { + if err := runtime.AssertSchema(generatedSchemaVersion); err != nil { + return err + } + runtime.Build(root, "app", Specs) + return nil +} + +func MountFlat(root *cobra.Command) error { + if err := runtime.AssertSchema(generatedSchemaVersion); err != nil { + return err + } + return runtime.BuildFlat(root, "app", Specs) +} + +var Specs = []runtime.CommandSpec{ + { + Group: "Health", + Use: "get", + Short: "Check application health", + OperationID: "Health_Get", + Method: "GET", + PathTpl: "/health", + DefaultHostname: "http://127.0.0.1:3000", + Output: runtime.OutputHints{ResponseMediaType: "application/json"}, + Security: &runtime.SecurityHint{Public: true}, + }, + { + Group: "Tasks", + Use: "create", + Short: "Create a task", + OperationID: "Task_Create", + Method: "POST", + PathTpl: "/tasks", + DefaultHostname: "http://127.0.0.1:3000", + RequestBody: &runtime.RequestBody{ + Required: true, + MediaType: "application/json", + Schema: &runtime.SchemaSpec{Type: "object", Properties: map[string]*runtime.SchemaSpec{"title": &runtime.SchemaSpec{Type: "string"}}, Required: []string{"title"}}, + }, + Security: &runtime.SecurityHint{Public: true}, + }, + { + Group: "Tasks", + Use: "delete", + Short: "Delete a task", + OperationID: "Task_Delete", + Method: "DELETE", + PathTpl: "/tasks/{id}", + DefaultHostname: "http://127.0.0.1:3000", + Params: []runtime.ParamSpec{ + {Name: "id", Flag: "id", In: "path", GoType: "string", Help: "id (path, required)", Required: true}, + }, + Security: &runtime.SecurityHint{Public: true}, + }, + { + Group: "Tasks", + Use: "get", + Short: "Get a task", + OperationID: "Task_Get", + Method: "GET", + PathTpl: "/tasks/{id}", + DefaultHostname: "http://127.0.0.1:3000", + Params: []runtime.ParamSpec{ + {Name: "id", Flag: "id", In: "path", GoType: "string", Help: "id (path, required)", Required: true}, + }, + Output: runtime.OutputHints{ResponseMediaType: "application/json"}, + Security: &runtime.SecurityHint{Public: true}, + }, + { + Group: "Tasks", + Use: "list", + Short: "List tasks", + OperationID: "Task_List", + Method: "GET", + PathTpl: "/tasks", + DefaultHostname: "http://127.0.0.1:3000", + Output: runtime.OutputHints{DefaultColumns: []string{"id", "completed", "title"}, ResponseMediaType: "application/json", + }, + Security: &runtime.SecurityHint{Public: true}, + }, + { + Group: "Tasks", + Use: "update", + Short: "Update a task", + OperationID: "Task_Update", + Method: "PATCH", + PathTpl: "/tasks/{id}", + DefaultHostname: "http://127.0.0.1:3000", + Params: []runtime.ParamSpec{ + {Name: "id", Flag: "id", In: "path", GoType: "string", Help: "id (path, required)", Required: true}, + }, + RequestBody: &runtime.RequestBody{ + Required: true, + MediaType: "application/json", + Schema: &runtime.SchemaSpec{Type: "object", Properties: map[string]*runtime.SchemaSpec{"completed": &runtime.SchemaSpec{Type: "boolean"}, "title": &runtime.SchemaSpec{Type: "string"}}}, + }, + Output: runtime.OutputHints{ResponseMediaType: "application/json"}, + Security: &runtime.SecurityHint{Public: true}, + }, +} diff --git a/internal/generated/modules_gen.go b/internal/generated/modules_gen.go new file mode 100644 index 0000000..5a7816d --- /dev/null +++ b/internal/generated/modules_gen.go @@ -0,0 +1,19 @@ +// Code generated by lathe codegen. DO NOT EDIT. + +package generated + +import ( + app "example.com/cli-first-python-app/internal/generated/app" + "github.com/spf13/cobra" +) + +// MountModules mounts every module declared in sources.yaml under root. +// The import list above is the single source of truth for which modules +// are compiled into this binary. main.go wires this call after +// app.NewApp() so the framework package never imports downstream code. +func MountModules(root *cobra.Command) error { + if err := app.MountFlat(root); err != nil { + return err + } + return nil +} diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml new file mode 100644 index 0000000..1f1b30c --- /dev/null +++ b/openapi/openapi.yaml @@ -0,0 +1,169 @@ +openapi: 3.0.3 +info: + title: CLI-first Node application + version: 0.1.0 +servers: + - url: http://127.0.0.1:3000 +paths: + /health: + get: + operationId: Health_Get + tags: [Health] + summary: Check application health + security: [] + responses: + "200": + description: Application is healthy + content: + application/json: + schema: + $ref: "#/components/schemas/Health" + /tasks: + get: + operationId: Task_List + tags: [Tasks] + summary: List tasks + security: [] + responses: + "200": + description: Tasks + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Task" + post: + operationId: Task_Create + tags: [Tasks] + summary: Create a task + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateTask" + responses: + "201": + description: Task created + content: + application/json: + schema: + $ref: "#/components/schemas/Task" + "400": + description: Invalid request body + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /tasks/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + operationId: Task_Get + tags: [Tasks] + summary: Get a task + security: [] + responses: + "200": + description: Task + content: + application/json: + schema: + $ref: "#/components/schemas/Task" + "404": + description: Task not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + patch: + operationId: Task_Update + tags: [Tasks] + summary: Update a task + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateTask" + responses: + "200": + description: Task updated + content: + application/json: + schema: + $ref: "#/components/schemas/Task" + "404": + description: Task not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "400": + description: Invalid request body + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + delete: + operationId: Task_Delete + tags: [Tasks] + summary: Delete a task + security: [] + responses: + "204": + description: Task deleted + "404": + description: Task not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Health: + type: object + required: [status] + properties: + status: + type: string + example: ok + Task: + type: object + required: [id, title, completed] + properties: + id: + type: string + title: + type: string + completed: + type: boolean + CreateTask: + type: object + required: [title] + properties: + title: + type: string + minLength: 1 + UpdateTask: + type: object + minProperties: 1 + properties: + title: + type: string + minLength: 1 + completed: + type: boolean + Error: + type: object + required: [error] + properties: + error: + type: string diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8307b73 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "cli-first-python-app" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = [] + +[tool.uv] +package = false + diff --git a/skills/appctl/.lathe-skill b/skills/appctl/.lathe-skill new file mode 100644 index 0000000..509d1f3 --- /dev/null +++ b/skills/appctl/.lathe-skill @@ -0,0 +1 @@ +generated by lathe codegen; safe to remove and regenerate diff --git a/skills/appctl/SKILL.md b/skills/appctl/SKILL.md new file mode 100644 index 0000000..aa5ba7d --- /dev/null +++ b/skills/appctl/SKILL.md @@ -0,0 +1,42 @@ +--- +name: appctl +description: > + Use when operating the appctl generated CLI. Discover commands, inspect parameters, + check auth state, and execute API operations safely. +--- + +# appctl CLI + +Use this skill when a user asks you to operate `appctl`, inspect its API commands, or find the right generated command for an API task. + +## Workflow + +1. Search for candidates with `appctl search "" --json`; use `--limit` when needed. Search is only candidate discovery. +2. Inspect the exact command with `appctl commands show --json` before executing an unfamiliar command. +3. If the command detail has `auth.required=true`, run `appctl auth status --hostname ` before execution. Use `http.default_hostname` when present unless the user provides `--hostname` or `$APPCTL_HOST`. +4. Execute only after flags, body, auth, HTTP path, and output hints are clear from `commands show`. + +## General Commands + +- `appctl commands --json`: full generated command catalog. +- `appctl commands --include-hidden --json`: include hidden generated commands. +- `appctl commands show --json`: source of truth for one command. +- `appctl commands schema --json`: catalog schema version for parser compatibility. +- `appctl search "" --json`: ranked candidate commands. + +## Maintenance Commands + +- `appctl --version` or `appctl -v`: print CLI build version. + +## References + +- Read `references/catalog.md` for the command discovery protocol and catalog field meanings. +- Read `references/modules/app.md` for the `app` module command index. + +## Rules + +- Do not guess flags or request body shape from command names. +- Do not execute directly from search results; confirm with `commands show` first. +- Prefer `-o json` for machine-readable command output unless the user asks for human-readable output. +- Use `--file`, `--set`, or `--set-str` for JSON request bodies according to `commands show` body requirements. +- For sensitive flags, prefer safe modes from `flags[].input_modes`: `---env`, `---file`, or `---stdin`. diff --git a/skills/appctl/agents/openai.yaml b/skills/appctl/agents/openai.yaml new file mode 100644 index 0000000..bfa0596 --- /dev/null +++ b/skills/appctl/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "appctl CLI" + short_description: "Use the appctl generated CLI" + default_prompt: "Use $appctl to find and run the right appctl command." diff --git a/skills/appctl/references/catalog.md b/skills/appctl/references/catalog.md new file mode 100644 index 0000000..8269664 --- /dev/null +++ b/skills/appctl/references/catalog.md @@ -0,0 +1,57 @@ +# Catalog Protocol + +Use the runtime catalog as the source of truth. Generated references are a fast index; command execution details come from the CLI itself. + +## Search + +Run `appctl search "" --json` to find candidate commands. Use `--limit` to control result count. Treat search output as candidates only. + +## Full Catalog + +Run `appctl commands --json` to inspect the generated command catalog. Use `--include-hidden` only when hidden commands are relevant. + +Key fields: + +- `path`: command path to pass to `commands show` or execute after the CLI name. +- `shortcuts`: root-level commands that execute the same operation with preset flag values. +- `http`: HTTP method and path template. +- `http.default_hostname`: optional source-level host selected after explicit `--hostname` and `$APPCTL_HOST`; when present it is used before the single-host fallback from `hosts.yml`. +- `flags`: CLI flags, parameter location, type, required state, defaults, enum values, format, input modes, and help. +- `body`: request body requirement and media type. +- `auth`: whether auth is required and which scopes are declared. +- `examples`: runnable examples with optional body shape, output hints, and follow-up commands. +- `output`: list path, default columns, response media type, pagination, and streaming hints. +- `notes`, `prerequisites`, and `known_errors`: overlay-provided operation context that is not inferred from the API spec. + +## Command Detail + +Run `appctl commands show --json` before executing an unfamiliar command. This is the source of truth for flags, body, auth, HTTP path, and output hints. + +## Schema + +Run `appctl commands schema --json` to read the catalog schema version before parsing catalog JSON with durable tooling. + +## Sensitive Flags + +When a flag entry has `input_modes`, prefer safe modes over putting secrets directly in shell arguments. + +- `flag`: pass the direct `--` value; keep this for compatibility or non-secret values. +- `env`: pass `---env NAME` to read the value from an environment variable. +- `file`: pass `---file path` to read the value from a file. +- `stdin`: pass `---stdin` to read the value from stdin. +- Use only one input mode for the same flag. + +## Request Bodies + +- `--file path`: read a JSON body from a file. +- `--file -`: read a JSON body from stdin. +- `--set key.path=value`: build JSON with type inference for booleans, null, integers, and floats. +- `--set-str key.path=value`: build JSON while forcing the value to remain a string. + +## Output + +Use `-o json` for machine-readable command output. Other supported formats are `table`, `yaml`, and `raw`. + +## Auth + +If command detail returns `auth.required=true`, run `appctl auth status --hostname ` before execution. Use `http.default_hostname` when present unless the user provides `--hostname` or `$APPCTL_HOST`; if no matching host is logged in, stop and ask the user to authenticate. diff --git a/skills/appctl/references/modules/app.md b/skills/appctl/references/modules/app.md new file mode 100644 index 0000000..2cf52e5 --- /dev/null +++ b/skills/appctl/references/modules/app.md @@ -0,0 +1,69 @@ +# Module `app` + +## Source + +- Backend: `openapi3` +- Default hostname: `http://127.0.0.1:3000` +- Repository: `unknown` +- Pinned tag: ``unknown`` +- Files: `openapi.yaml` + +## Health + +### `appctl health get` + +- Summary: Check application health +- HTTP: `GET /health` +- Auth: public +- Body: none +- Flags: none +- Output: response media `application/json` + +## Tasks + +### `appctl tasks create` + +- Summary: Create a task +- HTTP: `POST /tasks` +- Auth: public +- Body: required; media type `application/json` +- Flags: none + +### `appctl tasks delete` + +- Summary: Delete a task +- HTTP: `DELETE /tasks/{id}` +- Auth: public +- Body: none +- Flags: + - `--id` (path, required): id + +### `appctl tasks get` + +- Summary: Get a task +- HTTP: `GET /tasks/{id}` +- Auth: public +- Body: none +- Flags: + - `--id` (path, required): id +- Output: response media `application/json` + +### `appctl tasks list` + +- Summary: List tasks +- HTTP: `GET /tasks` +- Auth: public +- Body: none +- Flags: none +- Output: columns `id`, `completed`, `title`; response media `application/json` + +### `appctl tasks update` + +- Summary: Update a task +- HTTP: `PATCH /tasks/{id}` +- Auth: public +- Body: required; media type `application/json` +- Flags: + - `--id` (path, required): id +- Output: response media `application/json` + diff --git a/specs/sources.yaml b/specs/sources.yaml new file mode 100644 index 0000000..2c19306 --- /dev/null +++ b/specs/sources.yaml @@ -0,0 +1,8 @@ +sources: + app: + local_path: ../openapi + default_hostname: http://127.0.0.1:3000 + backend: openapi3 + openapi3: + files: [openapi.yaml] + diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..0f69325 --- /dev/null +++ b/src/app.py @@ -0,0 +1,148 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import unquote, urlsplit + +MAX_BODY_SIZE = 1_000_000 + + +class AppHandler(BaseHTTPRequestHandler): + def do_GET(self): + path = urlsplit(self.path).path + if path == "/health": + self._send(200, {"status": "ok"}) + return + if path == "/tasks": + with self.server.lock: + tasks = list(self.server.tasks.values()) + self._send(200, tasks) + return + task_id = self._task_id(path) + if task_id is None: + self._send(404, {"error": "not found"}) + return + with self.server.lock: + task = self.server.tasks.get(task_id) + self._send(200, task) if task else self._send(404, {"error": "task not found"}) + + def do_POST(self): + if urlsplit(self.path).path != "/tasks": + self._unsupported_or_missing() + return + try: + body = self._read_json() + except ValueError as error: + self._send(400, {"error": str(error)}) + return + title = body.get("title") + if not isinstance(title, str) or not title.strip(): + self._send(400, {"error": "title is required"}) + return + with self.server.lock: + task_id = str(self.server.next_id) + self.server.next_id += 1 + task = {"id": task_id, "title": title.strip(), "completed": False} + self.server.tasks[task_id] = task + self._send(201, task) + + def do_PATCH(self): + task_id = self._task_id(urlsplit(self.path).path) + if task_id is None: + self._unsupported_or_missing() + return + try: + body = self._read_json() + except ValueError as error: + self._send(400, {"error": str(error)}) + return + if "title" not in body and "completed" not in body: + self._send(400, {"error": "title or completed is required"}) + return + if "title" in body and (not isinstance(body["title"], str) or not body["title"].strip()): + self._send(400, {"error": "title must be a non-empty string"}) + return + if "completed" in body and not isinstance(body["completed"], bool): + self._send(400, {"error": "completed must be a boolean"}) + return + with self.server.lock: + task = self.server.tasks.get(task_id) + if task: + if "title" in body: + task["title"] = body["title"].strip() + if "completed" in body: + task["completed"] = body["completed"] + self._send(200, task) if task else self._send(404, {"error": "task not found"}) + + def do_DELETE(self): + task_id = self._task_id(urlsplit(self.path).path) + if task_id is None: + self._unsupported_or_missing() + return + with self.server.lock: + task = self.server.tasks.pop(task_id, None) + self._send(204) if task else self._send(404, {"error": "task not found"}) + + def do_PUT(self): + self._unsupported_or_missing() + + def _unsupported_or_missing(self): + path = urlsplit(self.path).path + if path == "/tasks": + self._send(405, {"error": "method not allowed"}, {"Allow": "GET, POST"}) + elif self._task_id(path) is not None: + self._send(405, {"error": "method not allowed"}, {"Allow": "GET, PATCH, DELETE"}) + else: + self._send(404, {"error": "not found"}) + + def _read_json(self): + if self.headers.get_content_type() != "application/json": + raise ValueError("content-type must be application/json") + try: + length = int(self.headers.get("content-length", "0")) + except ValueError as error: + raise ValueError("invalid content-length") from error + if length < 0: + raise ValueError("invalid content-length") + if length > MAX_BODY_SIZE: + remaining = length + while remaining: + chunk = self.rfile.read(min(remaining, 64 * 1024)) + if not chunk: + break + remaining -= len(chunk) + raise ValueError("request body exceeds 1 MB") + try: + body = json.loads(self.rfile.read(length)) + except (json.JSONDecodeError, UnicodeDecodeError) as error: + detail = error.msg if isinstance(error, json.JSONDecodeError) else str(error) + raise ValueError(f"invalid JSON: {detail}") from error + if not isinstance(body, dict): + raise ValueError("request body must be a JSON object") + return body + + @staticmethod + def _task_id(path): + parts = path.split("/") + return unquote(parts[2]) if len(parts) == 3 and parts[1] == "tasks" and parts[2] else None + + def _send(self, status, body=None, headers=None): + data = b"" if body is None else json.dumps(body, separators=(",", ":")).encode() + self.send_response(status) + if body is not None: + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + for name, value in (headers or {}).items(): + self.send_header(name, value) + self.end_headers() + self.wfile.write(data) + + def log_message(self, *_): + pass + + +def create_server(host="127.0.0.1", port=3000): + server = ThreadingHTTPServer((host, port), AppHandler) + server.tasks = {} + server.next_id = 1 + server.lock = threading.Lock() + return server diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..5142333 --- /dev/null +++ b/src/server.py @@ -0,0 +1,12 @@ +import os + +from src.app import create_server + +port = int(os.environ.get("PORT", "3000")) +if not 1 <= port <= 65535: + raise ValueError("PORT must be an integer between 1 and 65535") + +server = create_server(port=port) +print(f"API listening on http://127.0.0.1:{port}") +server.serve_forever() + diff --git a/test/empty.json b/test/empty.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test/empty.json @@ -0,0 +1 @@ +{} diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a8a4df8 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,91 @@ +import http.client +import json +import subprocess +import threading +import unittest +from pathlib import Path + +from src.app import create_server + +ROOT = Path(__file__).resolve().parents[1] + + +class CLITest(unittest.TestCase): + def setUp(self): + self.server = create_server(port=0) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.hostname = f"http://127.0.0.1:{self.server.server_port}" + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join() + + def cli(self, *args): + result = subprocess.run( + ["./bin/appctl", "--hostname", self.hostname, *args, "-o", "json"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ) + return json.loads(result.stdout) if result.stdout else None + + def cli_failure(self, *args): + result = subprocess.run( + ["./bin/appctl", "--hostname", self.hostname, *args, "-o", "json"], + cwd=ROOT, + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + return json.loads(result.stderr)["error"]["message"] + + def test_generated_cli_is_the_application_acceptance_surface(self): + self.assertEqual(self.cli("health", "get"), {"status": "ok"}) + created = self.cli("tasks", "create", "--set", "title=Ship from the CLI") + self.assertEqual(created, {"id": "1", "title": "Ship from the CLI", "completed": False}) + self.assertEqual(self.cli("tasks", "list"), [created]) + self.assertEqual(self.cli("tasks", "get", "--id", created["id"]), created) + updated = self.cli("tasks", "update", "--id", created["id"], "--set", "completed=true") + self.assertEqual(updated, {**created, "completed": True}) + self.cli("tasks", "delete", "--id", created["id"]) + self.assertEqual(self.cli("tasks", "list"), []) + + def test_generated_cli_surfaces_api_errors(self): + self.assertIn("HTTP 400", self.cli_failure("tasks", "create", "--set-str", "title=")) + self.assertIn("HTTP 404", self.cli_failure("tasks", "get", "--id", "missing")) + task = self.cli("tasks", "create", "--set", "title=Keep the contract honest") + error = self.cli_failure("tasks", "update", "--id", task["id"], "--file", "test/empty.json") + self.assertIn("title or completed is required", error) + + def test_http_boundary_rejects_unsupported_input(self): + status, headers, _ = self.request("PUT", "/tasks") + self.assertEqual(status, 405) + self.assertEqual(headers["allow"], "GET, POST") + + status, _, body = self.request("POST", "/tasks", "text/plain", {"title": "wrong media type"}) + self.assertEqual(status, 400) + self.assertEqual(body, {"error": "content-type must be application/json"}) + + status, _, body = self.request("POST", "/tasks", "application/json", {"title": "x" * 1_000_001}) + self.assertEqual(status, 400) + self.assertEqual(body, {"error": "request body exceeds 1 MB"}) + + def request(self, method, path, content_type=None, body=None): + connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port) + data = None if body is None else json.dumps(body) + headers = {} if content_type is None else {"content-type": content_type} + connection.request(method, path, data, headers) + response = connection.getresponse() + payload = response.read() + result = None if not payload else json.loads(payload) + headers = {name.lower(): value for name, value in response.getheaders()} + connection.close() + return response.status, headers, result + + +if __name__ == "__main__": + unittest.main() + diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..90e9027 --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "cli-first-python-app" +version = "0.1.0" +source = { virtual = "." }