diff --git a/.claude/skills/devstack-templates/SKILL.md b/.claude/skills/devstack-templates/SKILL.md new file mode 100644 index 0000000..a65ab9e --- /dev/null +++ b/.claude/skills/devstack-templates/SKILL.md @@ -0,0 +1,178 @@ +--- +name: devstack-templates +description: >- + Use when authoring or editing a devstack service template — a directory + holding template.yaml, an optional build/ tree and golden.yaml — or when + asked to add support for a new database, engine, language or framework to + devstack. Covers the engine-versus-app split, the [[ ]] delimiters and the + deterministic FuncMap, params/extends/provides/exports, the rule that + metadata keys are never templated, list-merge semantics, and the template + new → lint → test loop. +license: Apache-2.0 +allowed-tools: Bash(devstack:*) +--- + +Commands below are written as `devstack`. If this machine installed an alias +(`rq`, `uranus`), substitute it. + +## What a template is + +A template is a **directory** that renders one compose service. Every service in +a devstack workspace comes from one. + +``` +/ + template.yaml REQUIRED — metadata plus the compose service fragment + build/ optional — Dockerfile, entrypoint.sh, nginx.conf, rendered verbatim + golden.yaml optional — a byte-for-byte fixture asserted by `devstack template test` + post_init.yaml optional — merged after the whole extends chain +``` + +The directory name is the template ref. Dots are allowed and are how families are +named: `php.nginx`, `php.laravel.nginx`. + +Templates resolve from three sources, first match wins: an OCI-pinned remote +template, then `~/.devstack/templates//`, then the built-ins compiled into +the binary. Dropping a directory into `~/.devstack/templates/postgres/` therefore +shadows the built-in `postgres` for every workspace on the machine. + +## Engines and apps are different things + +This is a hard branch, not a style preference. + +**An engine** is shared infrastructure (postgres, redis, minio, kafka). It uses +`image:`, declares `provides:` and `exports:` and `defaultPort:`, and usually +declares a named volume. It must **never** have a `build:` key — generation +rejects a shared service that tries to build. + +**An app** is a project service (node.next, php.laravel.nginx). It uses +`build: { context: build, dockerfile: Dockerfile }` with a `build/` tree, and +must **never** declare `provides:` — `provides:` is what marks a template as +usable in `workspace.yaml`'s `shared:` block. + +## Read a real one first + +The built-ins are correct, current, and the best possible reference. Before +authoring, read one of the same kind: + +```bash +devstack template list --json # every template with its metadata +devstack ai docs guide/templates # the full authoring guide +``` + +## The manifest + +```yaml +schemaVersion: 1 +extends: php.nginx # optional parent ref +description: "One line describing the service." +provides: postgres # ENGINES ONLY — the capability it satisfies +exports: [host, port, user, password, database] # attrs consumers may import +defaultPort: 5432 # the in-network port ${ref:...port} resolves to +params: + version: + type: string # string | int | bool (advisory in v1) + default: "18" + required: false + description: "Image tag." +service: # the compose service fragment + image: "postgres:[[ .params.version ]]" +volumes: # top-level named volumes + pgdata: {} +``` + +## Templating: `[[ ]]`, not `{{ }}` + +The engine is Go's `text/template` with the delimiters changed to `[[` and `]]`. +That is deliberate: it lets shell `${VAR}`, Dockerfile `$TAG` and compose +`${VAR:-default}` pass through untouched, so a `build/Dockerfile` can use both +syntaxes at once. + +The only data in scope is `.params`: + +```yaml +image: "postgres:[[ .params.version ]]" +``` + +`missingkey=error` is set, so referencing an undeclared param is a hard failure, +never a silent empty string. + +**Three rules that will bite you:** + +1. **Metadata keys are parsed UNRENDERED.** `schemaVersion`, `extends`, + `description`, `provides`, `exports`, `defaultPort` and `params` are read + before any templating runs. A `[[ ]]` action in any of them is silently + meaningless — so the linter makes it a hard error. Only `service:` and + `volumes:` are rendered. +2. **The FuncMap is deterministic on purpose.** There is no `now`, no `uuid`, no + `randAlphaNum`, no environment access, because byte-identical output is a + CI-asserted requirement. +3. **Argument order is pipeline-style — the data comes last**, which is the + opposite of the `strings` package: `trimPrefix "v" .params.tag`, + `replace "-" "_" .params.name`, `contains "alpine" .params.image`, + `join "," .params.list`, `indent 4 .params.block`. + +Available functions: `default` `coalesce` · `upper` `lower` `title` · `trim` +`trimPrefix` `trimSuffix` · `replace` `contains` `hasPrefix` `hasSuffix` · +`join` `split` · `quote` `squote` · `indent` `nindent` `repeat` · `atoi`. + +`atoi` parses the *leading* integer (`"9.6"` → 9), which is what makes +version-conditional fragments work with the builtin `lt`/`ge`: + +```yaml +volumes: + - "pgdata:[[ if lt (atoi .params.version) 18 ]]/var/lib/postgresql/data[[ else ]]/var/lib/postgresql[[ end ]]" +``` + +## extends and the merge + +`extends` renders the parent, then deep-merges the child over it. Order is: +parent → child `template.yaml` → child `post_init.yaml` → the project's overrides. + +**Lists REPLACE by default.** A child declaring `volumes:` replaces the parent's +list entirely. Opt into appending with `$merge: append`. This is the single most +surprising merge behavior; check it whenever a parent's list vanishes. + +`provides`, `exports` and `defaultPort` inherit leaf-wins. Files in `build/` +merge by path, so a child's `build/Dockerfile` replaces the parent's. + +## The authoring loop + +Use the builder rather than hand-writing the directory — it emits a deterministic, +correct skeleton for the kind you pick: + +```bash +devstack template new mysvc --kind engine --print-spec > spec.yaml # inspect the plan +devstack template new mysvc --kind engine --from spec.yaml # materialize it +devstack template lint --show # lints + rendered compose +devstack template test # compare against golden.yaml +``` + +`--print-spec` → `--from` round-trips byte-stably, so an agent can generate the +spec, show it to a human, and materialize exactly what was reviewed. + +`lint` runs three checks and then validates the rendered service through +`compose-go`: + +| Check | Severity | Meaning | +|---|---|---| +| meta-templating | **error** | a `[[ ]]` action outside `service:`/`volumes:` | +| param-type | warning | a `default` that does not parse as its declared `type` | +| delimiter-collision | warning | a `build/` file containing a literal `[[` that is not a valid action | + +## Using a template + +```yaml +# workspace.yaml — engines only (templates that declare provides:) +shared: + postgres: { template: postgres, params: { version: "18" } } +``` + +```yaml +# devstack.yaml — apps +services: + api: + template: node.next + params: { nodeVersion: "22" } + uses: [workspace.shared.postgres] +``` diff --git a/.claude/skills/devstack-troubleshooting/SKILL.md b/.claude/skills/devstack-troubleshooting/SKILL.md new file mode 100644 index 0000000..6eb1dd0 --- /dev/null +++ b/.claude/skills/devstack-troubleshooting/SKILL.md @@ -0,0 +1,66 @@ +--- +name: devstack-troubleshooting +description: >- + Use when a devstack command fails or a service is unhealthy — the Docker + daemon is unreachable, the devstack_shared network is missing, a host port + is in use, generate --check reports drift, config errors point at + file:line:col, secret:// resolution fails, shared-service ref counts look + wrong, or file watching misbehaves on WSL2. Maps each symptom to the + diagnostic command and the fix. +license: Apache-2.0 +allowed-tools: Bash(devstack:*) +--- + +Commands below are written as `devstack`. If this machine installed an alias +(`rq`, `uranus`), substitute it. + +## Start here + +```bash +devstack doctor # the host preflight matrix: docker, compose, git, ports, paths +devstack doctor --fix # repair what is safely repairable +devstack status # per-service health + the shared-service ref graph +devstack logs # the actual error, usually +``` + +Add `--debug` to any command for structured logs on stderr, including the exact +external command devstack ran and its exit code. + +## Symptom → diagnose → fix + +| Symptom | Diagnose | Fix | +|---|---|---| +| `Cannot connect to the Docker daemon` | `devstack doctor` | Start Docker. On WSL2 confirm which daemon you mean — Desktop and an in-distro `dockerd` are separate contexts with separate ledgers. | +| `network devstack_shared not found` | `docker network ls` | `devstack up` recreates it. Never `docker network rm` it yourself. | +| A host port is already in use | `devstack ports` | `devstack expose --off`, or let devstack allocate a different port. On Windows, an excluded port range can also be the cause. | +| `generate --check` reports drift | `devstack generate --check` | Run `devstack generate`. If drift returns immediately, something is editing `.devstack/` by hand. | +| A config error with `file:line:col` | `devstack config validate` | Read the position — it points at the exact YAML node. `devstack config schema` gives the full field contract. | +| `unknown interpolation ${...}` | — | `${ref:...}` takes a **colon**; `${env.NAME}` and `${self.attr}` take a **dot**. | +| A shared service will not start | `devstack logs shared-` | Often a volume from an older major version. Check the template's `params.version`. | +| A service cannot reach Postgres | `devstack status` | Connect to the alias `shared-postgres`, not `localhost` and not the bare service name. Both containers must be on the shared network. | +| Ref counts look wrong | `devstack shared status` | `devstack shared doctor` reconciles from live containers; `devstack shared gc` releases orphans. | +| A secret will not resolve | `devstack secrets status` | Confirm the provider is declared in `workspace.yaml` and that you are logged in (`devstack secrets login `). | +| A template change has no effect | `devstack template lint --show` | A `[[ ]]` action in a metadata key is a hard lint error — only `service:` and `volumes:` are rendered. | +| A parent template's list disappeared | — | Deep-merge **replaces** lists. Use `$merge: append`. | +| File watching does not fire on WSL2 | — | The app templates set polling env vars for this. Confirm the repo is on the Linux filesystem — `/mnt/*` working directories are refused. | +| Everything is confusing | `devstack doctor --json` | Escalate: `doctor --fix` → `shared doctor` → `shared gc` → as a last resort `workspace destroy` (destructive, needs `--yes`). | + +## Never do these + +- **Do not `docker compose ...` against a devstack stack.** The project name, + labels and external network are tool-owned; you will fork a parallel stack. +- **Do not hand-edit `.devstack/`.** It is generated output. +- **Do not `docker network rm devstack_shared`.** +- **Do not pass `--yes` to a destructive verb on the user's behalf** without + asking. `workspace destroy`, `db drop`, `db reset`, `s3 rb` and friends are not + reversible. +- **Do not run two mutating devstack commands concurrently.** They coordinate + through a cross-process lock; racing them is what corrupts ref counts. + +## Reading further + +```bash +devstack ai docs guide/recovery # doctor --fix, gc, teardown +devstack ai docs troubleshooting # the top-level troubleshooting page +devstack ai docs --search "" +``` diff --git a/.claude/skills/devstack/SKILL.md b/.claude/skills/devstack/SKILL.md new file mode 100644 index 0000000..52a69cb --- /dev/null +++ b/.claude/skills/devstack/SKILL.md @@ -0,0 +1,127 @@ +--- +name: devstack +description: >- + Use when working in a repository that contains workspace.yaml or + devstack.yaml, or when asked to start, stop or inspect a local development + environment, shared Postgres/Redis/MinIO/Kafka/NATS, tenant databases, + buckets, queues or topics. devstack runs many project stacks against one + warm shared infrastructure stack on a tool-owned Docker network. Covers the + two-file config model, the up/down/status lifecycle, and the rules for + driving the CLI safely — including that compose files under .devstack/ are + generated and must never be hand-edited. +license: Apache-2.0 +allowed-tools: Bash(devstack:*) +--- + +Commands below are written as `devstack`. If this machine installed an alias +(`rq`, `uranus`), substitute it — the command tree is identical. + +## What devstack is + +devstack runs Docker development environments where **infrastructure is shared +across projects**. One warm Postgres, one Redis, one MinIO on a tool-owned Docker +network serve every repo in the workspace, and each project still gets its own +database, role and bucket. That is the whole point: eight microservices, one +Postgres container, eight isolated databases. + +Two committed files describe everything: + +- **`workspace.yaml`** at the workspace root — what infrastructure is *provided*, + and which repos belong to the workspace. +- **`devstack.yaml`**, one per repo — what that project *consumes*. + +Everything else is generated. `devstack up` renders the compose files, starts the +shared engines once, provisions each project's isolated data, and brings the +project stacks up on the shared network. + +## Orient before acting + +In an unfamiliar workspace, run these first. They are read-only and fast. + +```bash +devstack context # active workspace, project, docker context +devstack status # service health + the shared-service ref graph +devstack config show --json # the resolved configuration +devstack ai commands # every command this binary has +``` + +If `devstack config validate` reports an error it will be a `file:line:col` +pointing at the exact node. Read it; do not guess. + +## The rules that matter + +Violating any of these produces a broken or confusing workspace, and most are not +guessable from Docker experience alone. + +1. **Never hand-edit anything under `.devstack/`.** Those compose files and + Dockerfiles are generated and are overwritten on the next `generate` or `up`. + Change `workspace.yaml` / `devstack.yaml` and regenerate. +2. **Never run `docker compose` against a devstack stack.** The project name, + labels and the external network are tool-owned; running compose directly forks + a second, parallel stack that shares nothing with the workspace. +3. **Never `docker network rm devstack_shared`.** Compose refuses to manage + `external: true` networks, so devstack owns creating and removing it. +4. **Shared services are reached by DNS alias** — `shared-postgres`, + `shared-redis`, `shared-minio` — never by bare service name. By default + nothing publishes a host port; run `devstack expose` when a GUI client on the + host needs one. +5. **Let devstack perform mutations.** Anything that changes the ledger or the + shared stack takes a cross-process lock. Do not run two `up`s concurrently. +6. **Destructive verbs need `--yes` under `--json`** (`workspace destroy`, + `uninstall`, `db drop`/`reset`/`restore`/`gc`, `resource rm`/`gc`, `s3 rb`, + `queue`/`topic`/`stream rm`). Never pass `--yes` on the user's behalf without + asking them first. +7. **`secret://` values never land in a generated file.** A secret reaches a + container because the compose file lists the variable name with no value and + devstack passes the value through the process environment. Do not try to + inline a resolved secret. +8. **`${ref:...}` uses a colon. `${env.NAME}` and `${self.attr}` use a dot.** + This asymmetry is the single most common config typo. +9. **Deep-merge replaces lists by default.** Opt into appending with + `$merge: append`. + +## The commands that cover most work + +```bash +devstack up [project...] # network → shared engines → provision → compose up +devstack down [project...] # stop this workspace's stacks, keep the data +devstack status # health + ref graph +devstack logs [service...] # streamed across project and shared stacks +devstack shell [service] # a shell inside a service container +devstack run # the project's tasks: graph, dependency-ordered +devstack generate --check # is anything stale? (exit non-zero if so) +``` + +Every headline command supports `--json` and `--quiet`. Use `--json` when you +need to parse the result. + +## Common tasks + +| Goal | Do this | +|---|---| +| Add a repo to the workspace | `devstack project new --path ` | +| Add a service to a repo | add an entry under `services:` in its `devstack.yaml`, then `devstack generate` | +| Add a shared engine | add an entry under `shared:` in `workspace.yaml`, then `devstack up` | +| Give a project a database | declare it under `resources:`, or `devstack db create ` | +| Reach a shared engine from the host | `devstack expose` then `devstack ports` | +| Set an env var on a service | `devstack env set KEY=VALUE --service ` | +| See why a service is unhealthy | `devstack status`, then `devstack logs ` | +| Check the host is set up | `devstack doctor` (add `--fix` to repair) | + +## Reading further + +The whole documentation corpus is compiled into the binary. Do not guess at +behavior — read it: + +```bash +devstack ai docs # list every document +devstack ai docs guide/templates # authoring service templates +devstack ai docs guide/config-reference # every config field and grammar +devstack ai docs guide/lifecycle # up / down / status / logs / shell +devstack ai docs guide/shared-services # the shared engines and host access +devstack ai docs guide/databases # the db group +devstack ai docs guide/secrets # secret:// and .env ingestion +devstack ai docs --search "" # search titles and bodies +``` + +`reference.md` next to this file is a condensed config and flag reference. diff --git a/.claude/skills/devstack/reference.md b/.claude/skills/devstack/reference.md new file mode 100644 index 0000000..fe12169 --- /dev/null +++ b/.claude/skills/devstack/reference.md @@ -0,0 +1,96 @@ +# devstack reference + +A condensed field and flag reference. The authoritative versions are compiled +into the binary: `devstack ai docs guide/config-reference` and +`devstack config schema`. + +## workspace.yaml + +| Field | Type | Notes | +|---|---|---| +| `apiVersion` | string | Required. Always `devstack/v1`. | +| `kind` | string | Required. `Workspace`. | +| `name` | string | Required. Lowercase, starts with a letter, `[a-z0-9_-]`, ≤63 chars. | +| `aliases` | list | Alternate argv[0] names. | +| `profiles.default` | string | The env overlay name. Default `dev`. Readable as `${profile}`. | +| `defaultProfile` | string | The service slice `up` activates with no `--profile`. | +| `groups` | map | Named service slices: `{services: [...], memoryHintMB: N}`. | +| `memoryBudgetMB` | int | Warn above this total. | +| `secrets.providers` | list | `{name, kind, env, projectId, region}`. | +| `network.proxy` | object | `{engine: caddy\|traefik\|nginx, httpsLocal: bool}`. | +| `network.tunnel` | object | `{provider, hostname}`. | +| `backend` | object | `{context}` XOR `{host}`. Omit for the local daemon. | +| `shared` | map | `: {template, params, resources, platform}`. Template must declare `provides:`. | +| `projects` | list | `{name, path, git}`. | +| `hooks` | object | See hooks below. | + +## devstack.yaml + +| Field | Type | Notes | +|---|---|---| +| `apiVersion` / `kind` / `name` | string | Required. `kind: Project`. | +| `services` | map | **Required.** `: {…}` — see below. | +| `resources` | list | `{uses, kind, name, engine, params, credentials}`. | +| `tasks` | map | `: {command, run, service, deps, workdir, env, watch}`. | +| `hooks` | object | See hooks below. | + +### services.\ + +| Field | Type | Notes | +|---|---|---| +| `template` | string | **Required.** e.g. `node.next`. | +| `params` | map | Template parameters. | +| `uses` | list | `workspace.shared.` entries. | +| `env.raw` / `env.prefixed` | map | Literal vars, with `${...}` interpolation. | +| `env.import` | list | `{from, vars}` — pull exported attrs from another service. | +| `ports` | map | `{http: 3000}` — in-container ports. | +| `profiles` | list | Compose profile tags. | +| `memoryMB` | int | Shorthand for `resources.memoryMB`. | +| `resources` | object | `{cpus, memoryMB, memoryReserveMB, pidsLimit}`. | +| `platform` | string | `linux/amd64`, `linux/arm64/v8`. | +| `healthcheck` | object | `{kind, port, path, expectStatus, host, command, user, db, auth, interval, timeout, retries, startPeriod}`. `kind` ∈ tcp, http, https, exec, pg_isready, redis. | +| `dependsOn` | list | `{service, condition}` — condition ∈ healthy (default), started. | + +### hooks.\ + +Phases: `preUp`, `firstRun`, `postUp`, `postPull`, `preDown`. Each is a list of +`{name, run, service, command, workdir, env, timeout, retries, onFailure, once}`. +`run` ∈ host, exec (`service` required for exec). `command` is an argv array, +never shell-split. `onFailure` ∈ abort, warn, continue. + +Hook and task lists **replace** on overlay merge unless the YAML opts into +`$merge: append`. + +## Interpolation grammar + +| Form | Resolves to | +|---|---| +| `${profile}` | The active profile name. | +| `${workspace.name}` | The workspace name. | +| `${env.NAME}` | A host environment variable. **Hard error if unset.** | +| `${self.}` | An attribute of the service being rendered. | +| `${ref:workspace.shared.[.]}` | A shared service's attribute. | +| `${ref:workspace..[.]}` | Another service's attribute. | +| `$$` | A literal `$`. | + +Note the asymmetry: `env.` and `self.` use a **dot**; `ref:` uses a **colon**. + +Secrets are referenced as `secret:///#`. Values are never +written to a generated file — the compose file lists the variable name with no +value and devstack supplies it through the process environment. + +## Global flags + +| Flag | Effect | +|---|---| +| `--json` | Machine-readable output on stdout. | +| `--quiet` | Suppress human output; errors still go to stderr. | +| `--verbose` / `--debug` | Info / debug logging on stderr (`--debug` adds source positions). | +| `--as ` | Pre-parsed argv[0] override. | + +`--check` (on `generate`, `ide`, `ws status`) reports drift and exits non-zero +without writing — the CI form. + +`--yes` is **required** for destructive verbs under `--json`: `workspace destroy`, +`uninstall`, `db drop`/`reset`/`restore`/`gc`, `resource rm`/`gc`, `s3 rb`, +`queue`/`topic`/`stream rm`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5174021..113b1ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,14 @@ name: CI +# docs/ is a BUILD INPUT: docs/embed.go compiles the whole corpus into the binary +# (spec 32), so a doc-only change can ship a broken cross-link inside a release. +# Only files that are genuinely not compiled in may be skipped here. on: push: branches: [main] - paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"] + paths-ignore: ["README.md", "PROGRESS.md", "LICENSE", "NOTICE"] pull_request: - paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"] + paths-ignore: ["README.md", "PROGRESS.md", "LICENSE", "NOTICE"] permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4990176..a660040 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ on: push: branches: [main] tags: ["v*"] - paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"] + paths-ignore: ["README.md", "PROGRESS.md", "LICENSE", "NOTICE"] workflow_dispatch: {} permissions: diff --git a/.gitignore b/.gitignore index 3f2ee73..4d2ba24 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ coverage.txt # be re-included before its files, so negate the directories explicitly). !internal/ide/testdata/golden/**/.vscode/ !internal/ide/testdata/golden/**/.vscode/** + +# Per-user Claude Code settings (MCP-server approvals, local permissions). The +# generated .claude/skills/ tree IS tracked — it is what teaches every teammate's +# agent to drive devstack (spec 32). +.claude/settings.local.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..ce359f7 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "devstack": { + "command": "devstack", + "args": ["ai", "mcp"] + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d3a4f93 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ + +## devstack + +This repository uses [devstack](https://github.com/open-source-cloud/devstack) to +run its local development environment. devstack shares infrastructure across +projects: one warm Postgres/Redis/MinIO on a tool-owned Docker network serves +every repo in the workspace, and each project gets its own database, role and +bucket. + +Two committed files describe everything — `workspace.yaml` at the workspace root +(what infrastructure is provided) and one `devstack.yaml` per repo (what that +project consumes). **Everything under `.devstack/` is generated output.** + +### Commands + +```bash +devstack up # start: network → shared engines → provision → compose up +devstack down # stop this workspace's stacks (data is preserved) +devstack status # service health and the shared-service ref graph +devstack logs [svc] # streamed logs across project and shared stacks +devstack shell [svc] # a shell inside a service container +devstack run # the project's tasks: graph, dependency-ordered +devstack generate # re-render compose + build artifacts after a config change +devstack doctor # check the host is set up correctly +``` + +Every headline command supports `--json` and `--quiet`. + +### Rules + +1. **Never hand-edit anything under `.devstack/`** — it is generated and will be + overwritten. Edit `workspace.yaml` or `devstack.yaml` and run + `devstack generate`. +2. **Never run `docker compose` against these stacks**, and never + `docker network rm devstack_shared`. The project name, labels and the external + network are tool-owned; using compose directly forks a parallel stack. +3. **Shared services are reached by DNS alias** (`shared-postgres`, + `shared-redis`), never by bare service name, and publish no host port by + default. Use `devstack expose` when a host client needs one. +4. **Destructive verbs require `--yes` under `--json`.** Do not pass `--yes` on + the user's behalf without asking. +5. **`secret://` values never belong in a committed or generated file.** +6. **`${ref:...}` uses a colon; `${env.NAME}` and `${self.attr}` use a dot.** + +### Learning more + +devstack documents itself — the whole corpus is compiled into the binary: + +```bash +devstack ai docs # list every document +devstack ai docs guide/templates # how to author a service template +devstack ai docs guide/config-reference # every config field and grammar +devstack ai docs --search "" # search titles and bodies +devstack ai commands --json # the command surface as data +devstack config schema # the JSON Schema for devstack.yaml +``` + diff --git a/CLAUDE.md b/CLAUDE.md index e88dde1..cd3fb87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -215,3 +215,10 @@ Anything that mutates shared state goes through `internal/lock` from its first c `doctor --json` is the existing example. - Migrations in `internal/state` are append-only and forward-only; never edit a released one. + + +@AGENTS.md + +The devstack section of AGENTS.md above is generated by `devstack ai install`. +Run `devstack ai docs` to read devstack's full documentation from the binary. + diff --git a/Makefile b/Makefile index 5af968b..b3b87c2 100644 --- a/Makefile +++ b/Makefile @@ -69,9 +69,9 @@ cross: ## Cross-compile the 4 CGO-free release targets (build-only, output disca GOOS=$${t%/*} GOARCH=$${t#*/} CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o /dev/null ./cmd/devstack; \ done -ci: fmt-check vet build test-race ## What CI runs +ci: fmt-check vet build test-race ai-check ## What CI runs -nightly: fmt-check vet cross test-race determinism ## Full nightly gate (make ci + 4-target cross-build + determinism) +nightly: fmt-check vet cross test-race determinism ai-check ## Full nightly gate (make ci + 4-target cross-build + determinism) determinism: build ## Assert generation is byte-identical across runs/paths (M1, spec 02) @set -eu; \ @@ -89,6 +89,21 @@ determinism: build ## Assert generation is byte-identical across runs/paths (M1, fi; \ DEVSTACK_WORKSPACE="$$a" "$$bin" generate --check --quiet || { echo "FAIL: --check reports drift after generate"; exit 1; } +ai-check: build ## Assert the emitted agent files are current + deterministic (spec 32) + @set -eu; \ + bin="$$PWD/dist/$(BINARY)"; \ + "$$bin" ai check || { echo "FAIL: this repo's own agent artifacts are stale; run \`$(BINARY) ai install\`"; exit 1; }; \ + a="$$(mktemp -d)"; b="$$(mktemp -d)"; \ + trap 'rm -rf "$$a" "$$b"' EXIT; \ + (cd "$$a" && "$$bin" ai install --quiet); \ + (cd "$$b" && "$$bin" ai install --quiet); \ + if diff -r "$$a" "$$b" >/dev/null; then \ + printf '\033[32mok\033[0m agent artifacts are byte-deterministic across paths\n'; \ + else \ + echo "FAIL: emitted agent artifacts differ between runs:"; diff -r "$$a" "$$b"; exit 1; \ + fi; \ + (cd "$$a" && "$$bin" ai check --quiet) || { echo "FAIL: --check reports drift right after install"; exit 1; } + install: build ## Install the binary into $(BINDIR) (override with PREFIX= or XDG_BIN_HOME=) @install -d "$(BINDIR)" @install -m 0755 dist/$(BINARY) "$(BINDIR)/$(BINARY)" @@ -133,6 +148,14 @@ smoke: build ## Exercise the built binary end-to-end in an isolated XDG sandbox DEVSTACK_WORKSPACE="$$ws" "$$bin" generate --check --quiet || { echo "FAIL: generate --check stale after generate"; exit 1; }; \ echo "-> template list shows the built-ins"; \ "$$bin" template list | grep -q 'php.laravel.nginx' || { echo "FAIL: template list missing built-in"; exit 1; }; \ + "$$bin" template list | grep -q 'php.laravel.nginx' || { echo "FAIL: template list missing built-in"; exit 1; }; \ + echo "-> the agent surface answers with no workspace (spec 32)"; \ + "$$bin" ai docs | grep -q 'guide/templates' || { echo "FAIL: ai docs does not list the corpus"; exit 1; }; \ + "$$bin" ai docs guide/templates | grep -q '# Templates' || { echo "FAIL: ai docs cannot print a document"; exit 1; }; \ + "$$bin" --json ai commands | grep -q '"path": "up"' || { echo "FAIL: ai commands missing the command tree"; exit 1; }; \ + "$$bin" config schema --kind workspace | grep -q '"shared"' || { echo "FAIL: config schema is not the workspace schema"; exit 1; }; \ + echo "-> ai install is idempotent in a fresh repo"; \ + ( cd "$$sandbox" && "$$bin" ai install --quiet && "$$bin" ai check --quiet ) || { echo "FAIL: ai install/check round-trip"; exit 1; }; \ printf '\n\033[32mok\033[0m smoke passed\n' clean: diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 710c2b3..32d18be 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -128,5 +128,14 @@ Project-wide constraints that gate everything: --- +## D20. AI-agent surface — one embedded corpus, three surfaces, the CLI as the tool API +**Decision:** devstack describes itself to AI coding agents through **one** source of truth served three ways: the whole `docs/` tree is `go:embed`ed and served by `devstack ai docs`, by `devstack://docs/…` MCP resources, and by links from the files `devstack ai install` emits. The **MCP tools are the cobra tree** — each handler builds a fresh `cli.NewRootCmd`, sets `--json` plus argv, and captures stdout — so there is no parallel API to keep in sync and each call takes and releases the flock inside its own `RunE`, leaving the long-lived server stateless. Emitted files (`.claude/skills/devstack*/`, a fenced `AGENTS.md` block, an `@AGENTS.md` import in `CLAUDE.md`, the `mcpServers.devstack` key in `.mcp.json`) are **workspace-independent navigation**, never copies of documentation or snapshots of the current project list. Write tools are exposed by default; irreversible verbs are **absent** unless `--allow-destructive`, and the `secrets` group is never registered. Detail: [spec 32](specs/32-ai-agent-integration.md). + +**Why:** the observed failure is an agent reaching for `docker compose` in a devstack repo, or putting a `[[ ]]` action in a template metadata key. Both are cheap to prevent and expensive to debug. Serving the real docs rather than an authored "agent" set removes the second thing to keep true; deriving the tool list from the live command tree removes the third. Keeping emitted files workspace-independent is what stops them going stale the instant someone adds a project — these files get committed, and no CI would catch that. + +**⚠️ Verified corrections:** Claude Code **merged custom commands into skills** — `.claude/commands/x.md` and `.claude/skills/x/SKILL.md` both yield `/x` — so only skills are emitted. Only **six** frontmatter fields (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`) are portable to the Agent Skills spec; any other key is a hard error on claude.ai upload, so `when_to_use` folds into `description`. **AGENTS.md** is the Linux Foundation cross-tool standard (Codex/Cursor/Copilot/Gemini CLI/Windsurf/Zed/Aider) but **Claude Code reads `CLAUDE.md`**, hence the import line rather than a second copy. An MCP stdio server must emit **nothing** on stdout but framed JSON-RPC — tool output is captured to a buffer and quiet mode is forced, with an e2e test parsing a whole real session. `internal/ide`'s whole-file `writeIfChanged` is **wrong** for user-owned files, so the `/etc/hosts` marker-fence idiom from `internal/dns` is generalized into a second merge mode and a JSON-key mode. The JSON Schema is **hand-authored per [D16](#d16)**, not derived: validator/v10's `dsname`/`duration`/`cpus`/`platform`/`oneof`/`dive` vocabulary does not round-trip through tag introspection. + +--- + ## Dependency-risk register (pin everything; wrap risky ones) -`fang` (experimental, vanity v2, Go 1.25) · `gonja` (single-maintainer, *if chosen*) · `mkcert` (external binary) · `infisical-sdk` (v0.x) · `go-git` (alpha v6 + 2026 CVEs, fallback-only) · `validator/v10` ("call for maintainers", Go 1.25) · `moby/moby/client` (post-deprecation migration) · `1Password`/`Doppler` SDKs (v0/archived). Mitigation: pin all; wrap each behind an internal interface; run `govulncheck` + Renovate/Dependabot in CI; enforce the Go 1.25 floor. +`fang` (experimental, vanity v2, Go 1.25) · `gonja` (single-maintainer, *if chosen*) · `mkcert` (external binary) · `infisical-sdk` (v0.x) · `go-git` (alpha v6 + 2026 CVEs, fallback-only) · `validator/v10` ("call for maintainers", Go 1.25) · `moby/moby/client` (post-deprecation migration) · `1Password`/`Doppler` SDKs (v0/archived). `modelcontextprotocol/go-sdk` (0.x → 1.x within months; wrapped by `internal/mcpserve`). Mitigation: pin all; wrap each behind an internal interface; run `govulncheck` + Renovate/Dependabot in CI; enforce the Go 1.25 floor. diff --git a/docs/OPEN-QUESTIONS.md b/docs/OPEN-QUESTIONS.md index 72f97d5..7353ac3 100644 --- a/docs/OPEN-QUESTIONS.md +++ b/docs/OPEN-QUESTIONS.md @@ -137,6 +137,31 @@ On a shared cluster, what identifies a tenant for role/db/bucket naming and `db - **Recommendation:** an explicit config-declared/`--as` team identity (stable, collision-free, decoupled from local OS accounts); fall back to OS username only as a default seed. - **Decision (RESOLVED, later):** an explicit **config-declared / `--as` team identity** (stable, collision-free, decoupled from local OS accounts) keys role/db/bucket naming and `db gc` ownership; OS username is only a default seed. +## Q-AI-SCOPE — repo-scoped agent files only, or a global install too? +`devstack ai install` writes into the repository, so the guidance is committed and +the whole team's tooling picks it up. A `--global` variant writing into +`~/.claude/skills/` would make every project on the machine devstack-aware, +including ones that have no workspace yet. + +- **Recommendation:** repo-scoped for v1. Global is a second install location with + its own uninstall story and a `devstack uninstall` interaction, and the + repo-scoped files already cover the case that matters (a teammate cloning the + repo). Revisit in v1.1 on concrete demand. +- **Decision (RESOLVED, M11):** repo-scoped only; `--global` deferred. + +## Q-AI-PLUGIN — ship a Claude Code plugin + marketplace from this repo? +A `plugin/` tree plus `.claude-plugin/marketplace.json` would let a user run +`/plugin marketplace add open-source-cloud/devstack` and get the skills and the +MCP server with zero files in their own repository. + +- **Recommendation:** defer. It is a second distribution channel with its own + versioning and release surface, and its audience — people who want devstack's + agent integration — by definition already have the binary installed, which is + all `ai install` needs. Revisit once the emitted skills have stabilized against + real usage. +- **Decision (OPEN, revisit v1.1):** deferred; the emitter would generate the + plugin tree from the same pack, so nothing here forecloses it. + ## Decisions already made (recorded from our conversation) - Ambition: **open-source product**. - v1 scope: **all four pillars** (with the M0–M3 "core 1.0" phasing recommended in [ROADMAP](ROADMAP.md)). diff --git a/docs/embed.go b/docs/embed.go new file mode 100644 index 0000000..04afb1b --- /dev/null +++ b/docs/embed.go @@ -0,0 +1,21 @@ +// Package docs embeds devstack's documentation corpus into the binary via +// go:embed (spec 32). The same markdown that renders on the repo is what +// `devstack ai docs` prints and what the MCP server serves as devstack://docs/… +// resources, so an AI agent working in a devstack workspace reads exactly the +// documentation a human would — no separately-authored, separately-drifting +// "agent docs". +// +// Consequence for CI: docs/ is now a BUILD INPUT. .github/workflows/ci.yml must +// not skip checks for doc-only changes, or a broken cross-link could ship inside +// a binary without a single check running. internal/aidocs owns the index, slug +// scheme and search over this FS. +package docs + +import "embed" + +//go:embed *.md guide/*.md specs/*.md +var corpus embed.FS + +// FS is the embedded documentation root: the top-level guides (ARCHITECTURE.md, +// DECISIONS.md, …) plus the guide/ book and the specs/ set. +var FS = corpus diff --git a/docs/guide/README.md b/docs/guide/README.md index 1b46d48..61df645 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -71,20 +71,21 @@ top-to-bottom, or jump to the page you need. | 18 | [Recovery, teardown & housekeeping](recovery.md) | `doctor --fix`, `shared doctor`, `gc`, `workspace destroy`, `uninstall`. | | 19 | [The global store (~/.devstack)](store.md) | The `store` group — config, templates, shared, snapshots. | | 20 | [Aliases & argv[0] dispatch](aliases.md) | The `alias` group and `--as`. | +| 21 | [AI agents](ai-agents.md) | The `ai` group — docs, the command catalog, JSON Schemas, and the rules an agent must follow. | ### Reference | # | Page | What it covers | |---|---|---| -| 21 | [Full config reference](config-reference.md) | Every `workspace.yaml` / `devstack.yaml` field + the grammars. | -| 22 | [Command reference](command-reference.md) | Every command, terse, grouped by area. | -| 23 | [Global flags & scripting](global-flags.md) | `--json`/`--quiet`/`--debug`/`--verbose`, the `--yes` rule, `--check`, CI recipes. | +| 22 | [Full config reference](config-reference.md) | Every `workspace.yaml` / `devstack.yaml` field + the grammars. | +| 23 | [Command reference](command-reference.md) | Every command, terse, grouped by area. | +| 24 | [Global flags & scripting](global-flags.md) | `--json`/`--quiet`/`--debug`/`--verbose`, the `--yes` rule, `--check`, CI recipes. | ### Roadmap | # | Page | What it covers | |---|---|---| -| 24 | [What's next: roadmap & current gaps](whats-next.md) | What ships today vs. what's on the roadmap or still a gap. | +| 25 | [What's next: roadmap & current gaps](whats-next.md) | What ships today vs. what's on the roadmap or still a gap. | --- diff --git a/docs/guide/ai-agents.md b/docs/guide/ai-agents.md new file mode 100644 index 0000000..f70fade --- /dev/null +++ b/docs/guide/ai-agents.md @@ -0,0 +1,228 @@ +# AI agents + +[devstack](../../README.md) · [Guide](./README.md) › AI agents + +devstack ships everything an AI coding agent needs to drive it correctly: the +whole documentation corpus is compiled into the binary, the command surface is +available as data, and the config file formats have published JSON Schemas. An +agent working in your repository can therefore learn devstack from devstack +itself, instead of guessing. + +This page covers the `ai` command group. For the config contract see +[config-reference.md](config-reference.md); for template authoring see +[templates.md](templates.md). + +## Why this exists + +A coding agent dropped into a devstack workspace has a specific failure mode: it +recognises Docker, does not recognise devstack, and reaches for +`docker compose up` or hand-writes a `docker-compose.yaml`. Both are wrong here — +compose files under `.devstack/` are generated and get overwritten, and the +compose project name, labels and external network are tool-owned, so running +compose directly forks a parallel stack that shares nothing with your workspace. + +The `ai` group closes that gap by making devstack self-describing. + +## Installing the integration into your repo + +```bash +devstack ai install # write the files +devstack ai check # CI gate: exits non-zero if they are stale +``` + +`ai install` writes seven files: + +| Path | Ownership | +|---|---| +| `.claude/skills/devstack/SKILL.md` + `reference.md` | generated — overwritten | +| `.claude/skills/devstack-templates/SKILL.md` | generated — overwritten | +| `.claude/skills/devstack-troubleshooting/SKILL.md` | generated — overwritten | +| `AGENTS.md` | **yours** — only devstack's fenced block is replaced | +| `CLAUDE.md` | **yours** — only devstack's fenced block is replaced | +| `.mcp.json` | **yours** — only the `mcpServers.devstack` key is set | + +The last three use a marker fence or a single JSON key, so your own instructions +and your other MCP servers survive every regeneration. Commit all seven: they are +what makes the whole team's tooling devstack-aware. + +`AGENTS.md` is the portable surface — Codex, Cursor, Copilot, Gemini CLI, +Windsurf, Zed and Aider read it natively. Claude Code reads `CLAUDE.md`, which is +why devstack's block there is an `@AGENTS.md` import rather than a second copy. +There is deliberately no `.cursorrules`, `GEMINI.md` or +`.github/copilot-instructions.md`: five near-duplicate files is exactly the drift +this design avoids. + +Select a subset with `--target skills,agents,mcp`. Two caveats worth telling your +team: they need `devstack` on `PATH`, and in Claude Code an MCP server from +`.mcp.json` has to be approved once per user. + +The emitted files are deliberately **workspace-independent** — they describe +devstack, not your current project list, so they never go stale when someone adds +a project. Live facts come from `devstack status --json` and +`devstack config show --json`, which the agent runs. + +## Reading the documentation from the binary + +```bash +devstack ai docs # every document, with a one-line summary +devstack ai docs guide/templates # print one document as markdown +devstack ai docs 23 # specs are addressable by number +devstack ai docs --search "port conflict" +devstack ai docs --section specs +``` + +Slugs mirror the repository layout: + +| Slug form | Example | Covers | +|---|---|---| +| `guide/` | `guide/lifecycle` | the task-oriented book | +| `` | `architecture`, `decisions` | the top-level design documents | +| `specs/-` or `` | `specs/02-templating-and-generation`, `02` | the per-component specs | + +The corpus is the same markdown that renders in the repository — there is no +separately-authored set of "agent docs" to drift out of date. Because it is +compiled in, `devstack ai docs` works offline and without a checkout, and it +always describes *the binary you are running*. + +`--json` returns the document plus its metadata, which is the form to use from a +script: + +```bash +devstack --json ai docs guide/templates | jq -r .body +devstack --json ai docs --search "shared network" | jq -r '.hits[].doc.slug' +``` + +## The command surface as data + +```bash +devstack ai commands # every command, one line each +devstack --json ai commands # paths, summaries, arg specs, flags +devstack ai commands --runnable # skip the group commands +``` + +The catalog is derived from the live command tree at call time, so it can never +name a verb this binary does not have. Use it instead of scraping `--help`: + +```bash +# what can I do with databases? +devstack --json ai commands | jq -r '.commands[] | select(.path | startswith("db ")) | "\(.path) — \(.short)"' +``` + +Global flags (`--json`, `--quiet`, `--debug`, `--verbose`) are reported once under +`globalFlags` rather than repeated on every command. + +## The MCP server + +```bash +devstack ai mcp # stdio; started by your agent, not by you +devstack ai mcp --read-only # only tools that cannot change anything +devstack ai mcp --allow-destructive # additionally expose the irreversible verbs +``` + +`ai install` registers this in `.mcp.json`, so an MCP-capable agent starts it +automatically. It exposes three things: + +**Tools** are the devstack commands, run exactly as the CLI runs them — a fresh +command tree per call, with `--json`. There is no parallel API to drift, tool +behavior matches the CLI by construction, and the cross-process lock is taken and +released inside each call, so the long-lived server never holds it and devstack +remains the stateless, no-daemon CLI it has always been. + +| Class | Default | Examples | +|---|---|---| +| Read | on | `status`, `context`, `doctor`, `config_show`, `config_validate`, `config_schema`, `generate_check`, `template_list`, `template_lint`, `shared_status`, `ports`, `logs`, `docs`, `commands` | +| Write | on | `up`, `down`, `generate`, `run`, `db_create`, `s3_mb`, `project_new`, `expose`, `ai_install` | +| Destructive | **absent** | `db_drop`, `db_reset`, `workspace_destroy` — only with `--allow-destructive` | +| Never | — | the entire `secrets` group, `aws --`, `shell` | + +Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`, +`idempotentHint`) so the host can gate it. Because MCP has no terminal, an allowed +mutating tool injects `--yes` — which is exactly why the irreversible verbs are +*absent from the tool list* by default rather than merely flagged. The `secrets` +group is never registered at any setting, so provider material cannot reach a +model's context. + +**Resources** are pulled without spending a tool call: + +``` +devstack://docs/index devstack://template/{name} +devstack://docs/ devstack://schema/project.json +devstack://commands.json devstack://schema/workspace.json +``` + +`devstack://template/{name}` is the most useful one: "write me a template like +postgres" returns the real, currently-shipping `postgres/template.yaml` instead of +a plausible invention. + +**Prompts** are guided workflows. In Claude Code they appear as +`/mcp__devstack__`: + +| Prompt | Does | +|---|---| +| `onboard-repo` | Add an existing repository to the workspace and bring it up | +| `add-service` | Add a service wired to the shared infrastructure | +| `write-template` | Author a template, with the lint rules up front | +| `debug-up-failure` | Work through a failing workspace in the right order | +| `migrate-from-compose` | Convert a docker-compose.yaml to the two-file model | + +## Config schemas + +```bash +devstack config schema # devstack.yaml (the default) +devstack config schema --kind workspace # workspace.yaml +``` + +These are published draft-2020-12 JSON Schemas, hand-authored and round-trip +tested against the Go structs in CI. `devstack ide` already points +`yaml-language-server` at them, so an editor gives you completion and inline +validation for both files; an agent can use the same document as an exact field +contract. + +The Go validator remains the source of truth — the schema describes structure, +while the cross-reference, cycle and interpolation rules are checked by +`devstack config validate`, which reports problems as `file:line:col`. + +## Rules an agent should follow + +These are the mistakes that actually happen, in rough order of cost: + +1. **Never hand-edit anything under `.devstack/`.** It is generated. Edit + `workspace.yaml` or `devstack.yaml` and run `devstack generate`. +2. **Never run `docker compose` against a devstack stack**, and never + `docker network rm devstack_shared` — devstack owns the external network's + creation and cleanup. +3. **Shared services are reached by DNS alias** (`shared-postgres`), never the + bare service name, and by default publish no host ports. Use + `devstack expose` when a GUI client needs one. +4. **Route mutations through devstack**, which takes a cross-process lock; do not + run two `up`s in parallel. +5. **Destructive verbs require `--yes` under `--json`.** Do not pass `--yes` on a + user's behalf without asking. +6. **`secret://` values never land in a generated file.** Do not try to inline + them. +7. **`${ref:...}` uses a colon; `${env.NAME}` and `${self.attr}` use a dot.** +8. **Deep-merge replaces lists by default** — opt into `$merge: append`. +9. **Template metadata keys are parsed unrendered**, so a `[[ ]]` action in + `description`, `provides` or `params` is a hard lint error. +10. **An engine template uses `image:` plus `provides:`/`exports:` and never + `build:`; an app template uses `build:` and never `provides:`.** + +## Orienting in an unfamiliar workspace + +```bash +devstack context # active workspace, project, docker context +devstack status # service health and the shared-service ref graph +devstack config show # the resolved configuration +devstack generate --check # is anything stale? +``` + +## See also + +- [Concepts & the mental model](concepts.md) — the four nouns and the two-file model. +- [Templates](templates.md) — authoring a `template.yaml`. +- [Full config reference](config-reference.md) — every field and grammar. +- [Global flags & scripting](global-flags.md) — the `--json`/`--quiet`/`--yes` contract. + +--- + +◀ [Aliases & argv[0] dispatch](aliases.md) · [Guide index](./README.md) · [Full config reference](config-reference.md) ▶ diff --git a/docs/guide/aliases.md b/docs/guide/aliases.md index 5be4362..5360554 100644 --- a/docs/guide/aliases.md +++ b/docs/guide/aliases.md @@ -113,4 +113,4 @@ machine-global teardown — see [recovery.md](recovery.md). --- -◀ [The global store (~/.devstack)](store.md) · [Guide index](./README.md) · [Full config reference](config-reference.md) ▶ +◀ [The global store (~/.devstack)](store.md) · [Guide index](./README.md) · [AI agents](ai-agents.md) ▶ diff --git a/docs/guide/command-reference.md b/docs/guide/command-reference.md index 555e359..bc3f644 100644 --- a/docs/guide/command-reference.md +++ b/docs/guide/command-reference.md @@ -38,10 +38,12 @@ See [projects.md](projects.md), [templates.md](templates.md). | `init` | Author a `workspace.yaml` (pick shared services + params); wizard on a bare TTY. | `--name`, `--profile`, `--service`, `--param`, `--alias`, `--project`, `--from-store`, `--out`, `--dry-run`, `--force`, `--no-input` | | `use [name]` | Set the active project (or switch workspace); bare + TTY opens a fuzzy picker. | `--project`, `--print` | | `context` | Show the active workspace/project/role/docker-context/version. | `--json`, `--prompt` | +| `shell-init ` | Print the shell hook that enables context switching, completions and the prompt segment. | — | | `project list` / `project new ` | List projects / scaffold a `devstack.yaml` + register it. | `--path`, `--template`, `--uses`, `--git` | | `env list` / `env set KEY=VALUE` / `env unset KEY` | View/edit a service's local env vars (comment-preserving). | `--project`, `--service` | | `config validate` | Validate workspace + project config (errors as `file:line:col`). | — | | `config show` | Print a summary of the resolved workspace config. | — | +| `config schema` | Print the published JSON Schema for `devstack.yaml` / `workspace.yaml`. | `--kind` | | `generate` | Render compose + build artifacts from config and templates. | `--project`, `--profile`, `--check` | | `ide` | Generate devcontainer / `.code-workspace` / editor configs. | `--devcontainer`, `--vscode`, `--all`, `--check` | | `import ` | Convert a legacy devdock `project.yaml` into workspace + per-repo config. | `--dry-run`, `--out`, `--force` | @@ -210,6 +212,18 @@ See [store.md](store.md), [aliases.md](aliases.md). | `alias remove ` (alias `rm`) | Remove an alias symlink + registry entry. | — | | `alias list` | List installed aliases. | — | +## AI agents + +See [ai-agents.md](ai-agents.md). + +| Command | Does | Key flags | +|---|---|---| +| `ai install` | Write the skills, the AGENTS.md block and the MCP registration into this repo. | `--target`, `--check` | +| `ai check` | Report whether those emitted files are up to date (exits non-zero on drift). | — | +| `ai mcp` | Serve devstack over the Model Context Protocol on stdio (tools, resources, prompts). | `--read-only`, `--allow-destructive` | +| `ai docs [slug]` | List, print or search the documentation corpus compiled into the binary. | `--search`, `--section`, `--limit` | +| `ai commands` | The whole command surface as machine-readable data, derived from the live tree. | `--runnable` | + ## Binary & meta See [installation.md](installation.md). diff --git a/docs/guide/config-reference.md b/docs/guide/config-reference.md index 9226259..3d476c5 100644 --- a/docs/guide/config-reference.md +++ b/docs/guide/config-reference.md @@ -344,4 +344,4 @@ See [secrets.md](secrets.md) for the full workflow. --- -◀ [Aliases & argv[0] dispatch](aliases.md) · [Guide index](./README.md) · [Command reference](command-reference.md) ▶ +◀ [AI agents](ai-agents.md) · [Guide index](./README.md) · [Command reference](command-reference.md) ▶ diff --git a/docs/specs/24-env-ingestion.md b/docs/specs/24-env-ingestion.md index 260e7de..7850d7d 100644 --- a/docs/specs/24-env-ingestion.md +++ b/docs/specs/24-env-ingestion.md @@ -17,7 +17,7 @@ Get committed `.env` files out of the repo. One command reads an existing `.env` - **Interactive wizard is a Bubble Tea v2 model behind `internal/prompt`**, reusing the shared `internal/prompt`/`internal/tui` theme and components from [spec 22](22-init-wizard.md)/[spec 23](23-template-authoring.md) so it looks like one product. The per-key classification UI is a `charm.land/bubbles/v2` **`table`** (one row per `.env` key: name · class · reason · emitted ref/literal) with inline secret/config toggles, over a `charm.land/lipgloss/v2` theme; `charm.land/huh/v2` is embedded for the destination/recipient sub-form. The wizard is **never the only path**: `--yes`/`--json`/`--quiet`/non-TTY/`CI` all bypass it and use the computed classification + flag overrides. `internal/prompt` gates on `term.IsTerminal` and falls back to non-interactive **before** the Bubble Tea runtime ever starts. - **`devstack.yaml` rewrite is position/comment-preserving** via the goccy AST (config already parses with `goccy/go-yaml`, which exposes positions): only the touched `environment` values are replaced; the `apiVersion: devstack/v1` header, key order, and comments survive. This is a committed, hand-edited file, so the rewrite obeys no-clobber + backup like `import`. - **Ensure the destination provider is declared.** A `secret://sops/…` ref only resolves if a provider instance named `sops` exists in `workspace.yaml` (`secrets.providers[]`). Ingest checks for it and, if absent, **scaffolds a default `sops` provider entry** (pointing at the `$DEVSTACK_HOME` age key file) into `workspace.yaml` — backing that file up too — so the round-trip resolve in step 7 can succeed. If the provider exists but its name differs, ingest uses the declared name in the emitted refs. -- **No flock is taken in any mode.** The #1 rule governs mutations of the **ledger** or the **shared stack** ([CLAUDE.md](../CLAUDE.md)); a project-local ingest (write `secrets.enc.yaml` + rewrite `devstack.yaml`/`workspace.yaml`) touches neither, and a remote `--to aws-sm|infisical` push touches neither (it mutates an external secrets store, which a local advisory flock cannot protect anyway). Remote-push idempotency relies on the provider's put/overwrite semantics, not a lock. +- **No flock is taken in any mode.** The #1 rule governs mutations of the **ledger** or the **shared stack** ([CLAUDE.md](../../CLAUDE.md)); a project-local ingest (write `secrets.enc.yaml` + rewrite `devstack.yaml`/`workspace.yaml`) touches neither, and a remote `--to aws-sm|infisical` push touches neither (it mutates an external secrets store, which a local advisory flock cannot protect anyway). Remote-push idempotency relies on the provider's put/overwrite semantics, not a lock. - **`.env` is fenced before it is removed.** Ingest appends `.env` to `.gitignore` (marker-fenced), **refuses to proceed if `.env` is already git-tracked** (the plaintext is already in history — a different remediation), re-resolves every newly written ref to prove round-trip, and only then deletes `.env` (or, with `--keep-env`, leaves it and just prints the deletion command). The age **private** key lives under `$DEVSTACK_HOME` (outside the repo tree), so it needs no repo `.gitignore` entry; the fence covers an in-repo key file only if one was created there. ## Reference & data shapes diff --git a/docs/specs/25-release-automation.md b/docs/specs/25-release-automation.md index d89d2fc..791501e 100644 --- a/docs/specs/25-release-automation.md +++ b/docs/specs/25-release-automation.md @@ -8,7 +8,7 @@ Releases today are 100% manual: a human picks a tag and pushes it; `release.yml` ## Decisions - **Version computation = `caarlos0/svu` (pure-Go, by the goreleaser author), always `--v0`.** CI runs `svu next --v0` to derive the next tag from conventional-commit history. Rejected: release-please (Node action, opens a "release PR", owns the tag — reshapes the flow off goreleaser-on-tag), go-semantic-release (wants to own publishing, overlaps goreleaser), git-cliff (Rust, changelog-only, no version decision — goreleaser already does conventional changelogs), node semantic-release (heaviest, full Node toolchain, duplicates goreleaser). svu is the idiomatic goreleaser pairing and a single static binary. - **Stay on 0.x via `--v0` (KeepV0), enforced twice.** `svu --v0` makes a BREAKING change bump the minor (0.1.x → 0.2.0), never 1.0.0. A CI **guard step fails the run** if the computed/about-to-push tag has `MAJOR != 0`. `.svu.yaml` sets `v0: true` so the flag can never be forgotten. -- **Single combined workflow, built-in `GITHUB_TOKEN`, no PAT/App token (owner decision).** One `release.yml` triggers on `push: main` (automated) **and** `push: tags: ["v*"]` (a human hand-cut tag) **and** `workflow_dispatch`. On a main push it runs `svu next --v0`, applies the 0.x guard, and — gated by the repo **variable** `RELEASE_ENABLED == 'true'` — tags **and** runs goreleaser **in the same job**. Compute and release run together *on purpose*: a tag pushed with the default `GITHUB_TOKEN` does **not** re-trigger workflows (Actions suppresses events from `GITHUB_TOKEN`), so the only way to avoid a separate token is to never depend on a tag-push event re-firing a second workflow — fold them into one run. goreleaser needs only `contents: write` (which `GITHUB_TOKEN` grants) to create the Release. **Kill-switch = the `RELEASE_ENABLED` repo variable** (default unset ⇒ compute + log, never release), honoring the owner-only release-flip ([PROGRESS](../PROGRESS.md) decision #4) without a managed secret. A human `git tag vX.Y.Z && git push` still releases (the same workflow's tag trigger; human pushes are not suppressed and are ungated — explicit intent). *Rejected the two-workflow + `RELEASE_TOKEN` split: it keeps `release.yml` perfectly reusable but costs a no-expiry `contents:write` secret to manage — not worth it for a solo/beta project.* +- **Single combined workflow, built-in `GITHUB_TOKEN`, no PAT/App token (owner decision).** One `release.yml` triggers on `push: main` (automated) **and** `push: tags: ["v*"]` (a human hand-cut tag) **and** `workflow_dispatch`. On a main push it runs `svu next --v0`, applies the 0.x guard, and — gated by the repo **variable** `RELEASE_ENABLED == 'true'` — tags **and** runs goreleaser **in the same job**. Compute and release run together *on purpose*: a tag pushed with the default `GITHUB_TOKEN` does **not** re-trigger workflows (Actions suppresses events from `GITHUB_TOKEN`), so the only way to avoid a separate token is to never depend on a tag-push event re-firing a second workflow — fold them into one run. goreleaser needs only `contents: write` (which `GITHUB_TOKEN` grants) to create the Release. **Kill-switch = the `RELEASE_ENABLED` repo variable** (default unset ⇒ compute + log, never release), honoring the owner-only release-flip ([PROGRESS](../../PROGRESS.md) decision #4) without a managed secret. A human `git tag vX.Y.Z && git push` still releases (the same workflow's tag trigger; human pushes are not suppressed and are ungated — explicit intent). *Rejected the two-workflow + `RELEASE_TOKEN` split: it keeps `release.yml` perfectly reusable but costs a no-expiry `contents:write` secret to manage — not worth it for a solo/beta project.* - **Single changelog source of truth = goreleaser, grouped by conventional type.** Upgrade `.goreleaser.yaml` `changelog` to `groups:` (Features/Fixes/Performance/…) with `filters.exclude` for `chore|docs|test|ci|build|style`. Grouping/filtering apply with `use: github` (and `use: git`) but are **ignored** under `use: github-native`, so the config must stay on `github`. `@semantic-release/release-notes-generator` is **not** added — two changelog generators is two sources of truth. goreleaser remains the sole creator of the GitHub Release + notes. - **Fix the ldflags v-prefix (load-bearing).** Change `.goreleaser.yaml` ldflags from `version.Version={{.Version}}` to `version.Version=v{{ .Version }}` so the stamped `internal/version.Version` is a clean, **v-prefixed** semver that `x/mod/semver` accepts. The archive `name_template` stays on the **v-stripped** `{{ .Version }}` to match `assetName`'s `TrimPrefix` ([spec 14](14-self-update-and-migration.md), `update.go:78`). A CI step asserts the built binary's `version --short` output is `semver.IsValid`. - **Conventional-commit input is guarded.** Squash-merge makes the PR title the commit subject, so commit analysis is only as reliable as PR titles. Add a pure-shell **PR-title lint** (`pull_request` types `opened|edited|synchronize`) — no Node dep — that fails on a non-`type(scope)?:` subject. Repo setting: **squash-merge only**, "PR title" as the squash commit message. @@ -128,7 +128,7 @@ A **human-cut tag** (`git tag vX.Y.Z && git push`) takes the *other* branch of t - **The v-stripped ldflags is a silent muter, not cosmetic.** goreleaser's `{{ .Version }}` is the tag *without* the leading `v` (`0.2.0`), but `x/mod/semver` requires the `v` and treats malformed input as lowest. Confirmed against the code: `internal/selfupdate/selfupdate.go` `IsDevBuild(v)` returns true when `!semver.IsValid(v)`, and `notify.go`/`update.go` short-circuit on `IsDevBuild`. A goreleaser binary stamped `0.2.0` → `IsDevBuild=true` → **the notifier goes silent and `self update` never sees an update**. Fix the ldflags to `v{{ .Version }}`; keep the archive `name_template` v-stripped (it must match `assetName = devstack___.tar.gz`, `update.go:78`). These two opposite conventions are both load-bearing — do not "unify" them. - **A tag pushed with `GITHUB_TOKEN` does NOT trigger any workflow — this is *why* tag + release live in one job.** GitHub deliberately suppresses workflow events from the default token to prevent recursion (exceptions: `workflow_dispatch`/`repository_dispatch`). `contents: write` is enough to *push* a tag, but the push won't *wake* a separate tag-triggered `release.yml` — those are two different things. The chosen design dodges the problem entirely by running `svu`-compute → tag → goreleaser **in the same job**, so nothing depends on a re-trigger and the built-in `GITHUB_TOKEN` suffices (no PAT/App token). The alternative — a separate `tag.yml` pushing with a PAT/App `RELEASE_TOKEN` so the push *does* fire `release.yml` — is the standard "release bot" pattern but costs a managed `contents:write` secret; rejected here for a solo/beta repo. - **Kill-switch is a repo *variable*, not a secret.** Gate the automated tag/release steps on `vars.RELEASE_ENABLED == 'true'` (default unset ⇒ compute + log, never release). It is a `vars.*` (not `secrets.*`) value, readable directly in `run:`/`if:`, with no token to rotate. A human-cut tag (`git tag && git push`) is ungated by design — explicit intent — and reaches goreleaser via the same workflow's `refs/tags/` branch. -- **Only ever push `v` tags.** A non-semver tag (a channel/range/build-meta suffix, or a milestone label like `m2-done`) breaks the `release-dryrun` job's `goreleaser` `git describe` for *every* PR (ci.yml job `release-dryrun` runs `release --snapshot --clean`; [PROGRESS](../PROGRESS.md) decision #1). svu's default `tagFormat` `v${version}` satisfies this; `.svu.yaml` must never add a suffix. +- **Only ever push `v` tags.** A non-semver tag (a channel/range/build-meta suffix, or a milestone label like `m2-done`) breaks the `release-dryrun` job's `goreleaser` `git describe` for *every* PR (ci.yml job `release-dryrun` runs `release --snapshot --clean`; [PROGRESS](../../PROGRESS.md) decision #1). svu's default `tagFormat` `v${version}` satisfies this; `.svu.yaml` must never add a suffix. - **`--v0` is not the default — forget it and BETA breaks.** Plain `svu next` bumps a BREAKING change to 1.0.0. Always pass `--v0` (and set `v0: true` in `.svu.yaml`) **and** keep the CI `v0.*` guard as defense-in-depth. (Note the release-please trap if you ever switch tools: with a `0.0.0` manifest its `bump-*-pre-major` flags are ignored and it recommends 1.0.0 — you must seed at `0.1.0`. svu reading the existing `v0.1.0` tag avoids this entirely.) - **`x/mod/semver` orders 0.x and pre-releases correctly** (repo vendors v0.37.0): `Compare("v0.2.0","v1.0.0")<0`, `Compare("v0.2.0-beta.1","v0.2.0")<0`, dotted identifiers numeric (`beta.2 < beta.10`). Staying `MAJOR=0` keeps every notifier/self-update comparison valid; tag any beta as `vX.Y.Z-beta.N` so it sorts *before* the final. - **huh v2 must be the v2/charm.land line, never v1.** Add `charm.land/huh/v2` (latest v2.0.x, e.g. v2.0.3) + its transitive `charm.land/bubbletea/v2` and `charm.land/bubbles/v2`, byte-aligned with the already-vendored `charm.land/lipgloss/v2 v2.0.1` + `charm.land/fang/v2 v2.0.1`. `github.com/charmbracelet/huh` (v1) pulls bubbletea v1 / lipgloss v1 and **double-vendors a conflicting charm stack**. The whole family is pure-Go terminal I/O (x/term, x/ansi, ultraviolet — already vendored) → **CGO_ENABLED=0 safe, no build tags**; re-run `make vuln` after adding. *(See residual risks: a stdlib y/n prompt is a viable zero-dep alternative for a confirm this simple.)* diff --git a/docs/specs/32-ai-agent-integration.md b/docs/specs/32-ai-agent-integration.md new file mode 100644 index 0000000..91b14a0 --- /dev/null +++ b/docs/specs/32-ai-agent-integration.md @@ -0,0 +1,257 @@ +# Spec 32 — AI-agent integration (MCP, skills & self-describing docs) + +**Module(s):** `docs` (embed) · `internal/aidocs` · `internal/ai` · `internal/mcpserve` · `internal/config` (schema) · `internal/cli` (`ai`, `config schema`) · **Milestone:** M11 · **Effort:** ~2w · **ADR:** [D20](../DECISIONS.md) + +> **What this is not.** It is not an agent, an LLM integration, or a devstack that +> calls a model. Nothing here sends anything anywhere. It is the inverse: devstack +> describing itself precisely enough that an AI coding agent already sitting in the +> user's repository can drive it correctly. + +## Purpose + +devstack has 39 top-level commands, a 22-template engine with its own authoring +grammar, a two-file config model with four interpolation grammars, and ~9,400 +lines of documentation — none of it reachable by the coding agents that now sit +in most developers' editors. + +The failure mode is specific and repeatable. An agent dropped into a devstack +workspace recognizes Docker, does not recognize devstack, and reaches for +`docker compose up` or hand-writes a `docker-compose.yaml`. Both are wrong here: +compose files under `.devstack/` are generated and get overwritten, and the +compose project name, labels and external network are tool-owned, so driving +compose directly forks a parallel stack that shares nothing with the workspace. +The same agent will put a `[[ ]]` action in a template's `description:` (a hard +lint error, because metadata keys are parsed unrendered), or write `${ref.foo}` +instead of `${ref:foo}`. + +Every ingredient to prevent that already exists — `template list --json` is a +catalog, `Describe` reads metadata without rendering, `scaffold.Spec` is a +byte-stable authoring IR, every headline command has `--json`. What was missing is +**discovery**. + +## Decisions + +### The shape + +- **One corpus, three surfaces, nothing re-authored.** `docs/` is compiled into + the binary and served identically through `ai docs` (for agents that only have a + shell), MCP resources (for MCP clients), and links from the emitted files. + *Rejected a separately-authored "agent docs" set*: a second corpus is a second + thing to keep true, and the drift would be invisible. + +- **The MCP tools ARE the CLI.** Every tool handler builds a fresh + `cli.NewRootCmd`, sets `--json` plus argv, and captures stdout — the same + harness the CLI test suite already uses. *Rejected a parallel tool API*: it + would duplicate 39 commands' semantics, and every future flag would have to be + added twice. The reuse also buys the two properties below for free. + +- **A fresh command tree per call, so the lock discipline is inherited.** Each + call takes and releases the cross-process flock inside its own `RunE`. The + long-lived MCP process never holds it, so ARCHITECTURE's "stateless CLI, no + daemon" model survives having a server in front of it, and + [Q-DAEMON](../OPEN-QUESTIONS.md) is not reopened. + +- **Emitted files are workspace-independent.** They describe devstack, never a + snapshot of the current projects and shared services. *Rejected embedding live + workspace facts*: these files get committed, so a snapshot goes stale the moment + someone adds a project, with no CI anywhere to catch it. Live facts come from + `status --json` and `config show --json`, which the agent runs. The happy + consequence is that the guidance is useful *before* a workspace exists — + precisely when an agent has to learn `devstack init` — and the goldens are + hermetic. + +- **Emitted files carry navigation, not documentation.** A SKILL.md body links to + `devstack ai docs ` rather than copying prose. The corpus is therefore + always the running binary's, `devstack self update` produces a zero-line diff in + the user's repo, and there is no version skew between a committed `.claude/` and + an upgraded binary. + +### Ownership and merging + +- **Three merge modes, not one.** `internal/ide` writes whole files, which is + right for artifacts devstack owns and *wrong* for `AGENTS.md`, `CLAUDE.md` and + `.mcp.json`, which belong to the user. `MergeWhole` covers + `.claude/skills/devstack*/`; `MergeFence` replaces only a marker-fenced block; + `MergeJSONKey` sets only `mcpServers.devstack`. Spec 17 described managed blocks + but `internal/ide` never implemented them, so the fence generalizes the idiom + that has been in production in [`internal/dns/hosts.go`](../../internal/dns/hosts.go) + for `/etc/hosts`. + +- **`AGENTS.md` is the portable surface; `CLAUDE.md` imports it.** AGENTS.md is + the Linux Foundation cross-tool standard read natively by Codex, Cursor, + Copilot, Gemini CLI, Windsurf, Zed and Aider. Claude Code reads `CLAUDE.md`, so + devstack's block there is an `@AGENTS.md` import rather than a second copy. + *Rejected per-tool rule files* (`.cursorrules`, `GEMINI.md`, + `.github/copilot-instructions.md`, `.windsurfrules`): five near-duplicates is + exactly the drift this design exists to avoid. + +- **`.mcp.json` names the bare binary, never an absolute path.** An absolute path + is correct on the machine that ran `ai install` and wrong for every teammate who + checks the file out — which is the whole point of committing it. + +### Safety + +- **Write tools on by default; irreversible verbs absent.** MCP has no terminal, + so an allowed mutating tool must inject `--yes`. That is acceptable for + recoverable operations (`up`, `generate`, `db create`) and unacceptable for + `workspace destroy`, `db drop` and `db reset` — so those are **not registered** + unless `--allow-destructive`, rather than merely annotated. *Rejected annotating + them and relying on the host to prompt*: absence is a guarantee, an annotation is + a hint. + +- **The `secrets` group is never registered at any setting**, along with `aws --` + and `shell`. The first would put provider material into a model's context; the + other two are arbitrary command execution that the agent's own shell already + offers with a per-command permission prompt. + +- **Every tool carries MCP annotations** (`readOnlyHint`, `destructiveHint`, + `idempotentHint`, `openWorldHint`) so a host can gate what devstack cannot. + +- **Skill frontmatter is restricted to the six Agent Skills spec fields** + (`name`, `description`, `license`, `compatibility`, `metadata`, + `allowed-tools`). Claude Code accepts many more, but any of them is a hard error + when the same file is uploaded to claude.ai or packaged with the Agent Skills + tooling. One artifact set that works everywhere beats two behind a flag, so + "when to use" folds into `description` — which is what the router reads anyway. + +- **No `` !`cmd` `` dynamic context injection** in emitted skills: it runs a + command on every skill load (latency, a Docker touch, a surprise permission + prompt) and is Claude-Code-only, breaking portability. + +### The JSON Schema + +- **Hand-authored, per [D16](../DECISIONS.md), not derived from validator tags.** + `dsname`/`duration`/`cpus`/`platform`/`dockerhost`, `oneof`, `dive`, the + cross-field resolvers and the `${env./self./ref:/profile}` grammar do not + round-trip through tag introspection. The Go validator stays the source of + truth; two tests keep the schema honest — one validates every fixture through + both paths, the other walks the structs by reflection and asserts every + `yaml:` field is a schema property **and vice versa**. + +- **This fixes a live defect.** `internal/ide/ide.go` emitted a `$schema` URL + pointing at `schemas/devstack.schema.json`, a path that existed at no tag, so + every `.vscode/settings.json` and `.code-workspace` devstack has ever generated + pointed at a 404. It also mapped `workspace.yaml` to the *project* schema; the + two are now distinct, and a dev build falls back to `main` rather than emitting + `v/`. + +## CLI surface (working) + +``` +devstack ai install [--target skills,agents,mcp] [--check] # emit the integration files +devstack ai check # drift gate for CI +devstack ai mcp [--read-only] [--allow-destructive] # MCP server over stdio +devstack ai docs [slug] [--search Q] [--section S] [--limit N] +devstack ai commands [--runnable] # the command tree as data +devstack config schema [--kind project|workspace] # the published JSON Schema +``` + +`config schema` lives with `config validate`/`config show` rather than under `ai` +because the schema is an editor and LSP artifact as much as an agent one. + +## Emitted artifacts + +| Path | Kind | Merge | +|---|---|---| +| `.claude/skills/devstack/SKILL.md` + `reference.md` | skill | Whole | +| `.claude/skills/devstack-templates/SKILL.md` | skill | Whole | +| `.claude/skills/devstack-troubleshooting/SKILL.md` | skill | Whole | +| `AGENTS.md` | agents-md | **Fence** | +| `CLAUDE.md` | claude-md | **Fence** | +| `.mcp.json` | mcp-config | **JSONKey** | + +Three skills, not one and not ten: skills route on their `description`, and +"operate devstack", "author a template" and "something is broken" have disjoint +triggers. Per-group skills (`devstack-db`, `devstack-s3`, …) would blur that +routing; `reference.md` plus `--help` covers the detail. + +## Behavior + +1. `ai install` resolves the root: the workspace root when one is discoverable, + otherwise the current directory — it never requires a workspace. +2. `Build` assembles every artifact **without touching the disk**, which is what + makes the goldens hermetic. For the fenced and JSON modes, `Data` carries the + block or the value, not a finished file. +3. `Write` composes each artifact against what is on disk according to its merge + mode, then performs the same atomic `writeIfChanged` (temp → fsync → chmod → + rename) `internal/generate` and `internal/ide` use. +4. `--check` runs the identical merge and compares, so drift detection can never + disagree with what a write would produce. +5. `ai mcp` forces quiet mode, builds the server from injected `Deps`, and serves + stdio. A client closing the connection is a normal shutdown and exits 0. + +## Verified constraints & gotchas + +- **stdout purity is the whole ballgame.** An MCP stdio server must emit nothing + on stdout but framed JSON-RPC. Tool output is captured into a buffer, quiet mode + is forced, and an e2e test parses an entire real session as JSON-RPC — a stray + banner or log line fails it. This is the most common way a Go MCP server ships + broken. +- **`docs/` is now a BUILD INPUT.** `.github/workflows/ci.yml` previously skipped + every check for doc-only changes, which would let a broken cross-link ship + inside a release binary. `paths-ignore` is narrowed to files that genuinely are + not compiled in (`README.md`, `PROGRESS.md`, `LICENSE`, `NOTICE`), so `docs/**`, + `AGENTS.md`, `CLAUDE.md` and `.claude/**` all trigger the full gate. No separate + always-on lane is needed once the list is narrowed this way. + +- **Workspace discovery walks UP, which is wrong for a command that writes.** A + single stray `workspace.yaml` in a home directory silently redirected an entire + install there — `AGENTS.md`, `CLAUDE.md` and `.mcp.json` scattered across `$HOME` + and skills installed machine-wide. `ai install` therefore refuses the home + directory and any ancestor of it, and ignores a discovered workspace root that + fails that check. Two regression tests cover it. +- **The pack must never go through `internal/template`.** The template-authoring + content contains literal `[[ .params.version ]]` examples, and `text/template` + with `[[ ]]` delimiters and `missingkey=error` would try to execute them. Only + Go-built wrappers carry the binary name. +- **Binary growth: ~2.6 MiB stripped** (45.2 → 47.9 MiB), of which ~845 KiB is + embedded content (docs, schemas, pack) and ~1.8 MiB is the MCP SDK and its + dependencies (`google/jsonschema-go`, `yosida95/uritemplate/v3`, + `golang.org/x/oauth2`, `segmentio/encoding`). All pure Go — `CGO_ENABLED=0` + holds. Not gated behind a build tag: the emitted `.mcp.json` points at + `devstack ai mcp`, so a build without it would ship a broken registration. +- **MCP SDK volatility.** The Go SDK went 0.x → 1.x recently. It is pinned, and + `internal/mcpserve` is the single seam, per the wrap-risky-dependencies rule. +- **`.mcp.json` merging re-marshals the file**, reformatting a user's hand + formatting. Documented and visible through `--check`; the alternative (refusing + to touch an existing file) is worse. +- **A bare `devstack` skill claims `/devstack`** in the user's project. Intentional; + everything else is `devstack-` prefixed. + +## Acceptance criteria + +- [x] `docs/` is embedded; `ai docs` lists, prints and searches it with no workspace. +- [x] Every relative link in the corpus resolves (1,188 checked); CI covers doc-only changes. +- [x] `ai commands` is derived from the live cobra tree and is byte-deterministic. +- [x] `docs/guide/command-reference.md` is asserted against the tree in both directions. +- [x] `schemas/*.json` are published, embedded, and asserted against the structs by fixture round-trip **and** by reflection over every field. +- [x] The `$schema` URL resolves, is per-kind, and falls back to `main` for dev builds. +- [x] `ai install` is idempotent; a second run changes nothing. +- [x] Hand-written content in `AGENTS.md`/`CLAUDE.md` and other servers in `.mcp.json` survive regeneration. +- [x] `ai check` exits non-zero on drift and names the stale files. +- [x] Emitted skill frontmatter uses only the six Agent Skills spec fields. +- [x] An argv[0] alias (`rq`) is reflected in every emitted command and in `.mcp.json`. +- [x] The MCP tool set is pinned by test for each of the three option modes. +- [x] Destructive tools are absent by default; `secrets` is never registered. +- [x] An allowed destructive tool injects `--yes`. +- [x] A real `ai mcp` session's stdout is 100% JSON-RPC, and a client disconnect exits 0. +- [x] `devstack://template/{name}` returns a real built-in template's source. +- [x] `ai install` refuses the home directory and ignores a workspace root discovered above it. +- [x] devstack's own repository commits its emitted files; `make ai-check` is part of `make ci`. + +## Dependencies + +Builds on spec 01 (config schema), spec 02 (templates), spec 07 (CLI structure) +and spec 17 (`internal/ide`, whose generation-sink shape this mirrors). Adds +`github.com/modelcontextprotocol/go-sdk`. + +## Open questions + +- [Q-AI-SCOPE](../OPEN-QUESTIONS.md) — repo-scoped skills only, or also + `ai install --global` into `~/.claude/skills/`? Global is genuinely useful and + is the leading v1.1 candidate, but it is a second install location with its own + uninstall story and a `devstack uninstall` interaction. +- [Q-AI-PLUGIN](../OPEN-QUESTIONS.md) — ship a Claude Code plugin plus a + marketplace manifest from this repo, so users can install with zero files in + their own repo? It is a second distribution channel with its own versioning, + targeting people who by definition already have the binary installed. diff --git a/go.mod b/go.mod index 0ad14ef..9254097 100644 --- a/go.mod +++ b/go.mod @@ -26,15 +26,18 @@ require ( github.com/jackc/pgx/v5 v5.10.0 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/nats-io/nats.go v1.52.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 github.com/twmb/franz-go v1.21.4 github.com/twmb/franz-go/pkg/kadm v1.18.0 github.com/zalando/go-keyring v0.2.8 golang.org/x/mod v0.37.0 - golang.org/x/sync v0.20.0 + golang.org/x/sync v0.21.0 golang.org/x/term v0.44.0 modernc.org/sqlite v1.52.0 oras.land/oras-go/v2 v2.6.1 @@ -79,6 +82,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -101,12 +105,13 @@ require ( github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect @@ -114,8 +119,10 @@ require ( go.opentelemetry.io/otel/trace v1.35.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/crypto v0.52.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/text v0.39.0 // indirect + golang.org/x/time v0.15.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 5958e65..4c1eb82 100644 --- a/go.sum +++ b/go.sum @@ -133,8 +133,12 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -171,6 +175,8 @@ github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= @@ -204,6 +210,10 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -227,6 +237,8 @@ github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -252,18 +264,22 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/ai/agentsmd.go b/internal/ai/agentsmd.go new file mode 100644 index 0000000..9fe7c9f --- /dev/null +++ b/internal/ai/agentsmd.go @@ -0,0 +1,41 @@ +package ai + +import ( + "bytes" + "fmt" +) + +// claudeImportBlock is what goes inside CLAUDE.md's fence. Claude Code reads +// CLAUDE.md and does not read AGENTS.md, so the import line is what makes the one +// authored block serve both. Everything else in the user's CLAUDE.md is untouched. +const claudeImportBlock = `@AGENTS.md + +The devstack section of AGENTS.md above is generated by ` + "`%s ai install`" + `. +Run ` + "`%s ai docs`" + ` to read devstack's full documentation from the binary.` + +// buildAgentsMD renders the AGENTS.md block and the CLAUDE.md import. +// +// AGENTS.md is the portable surface: Codex, Cursor, Copilot, Gemini CLI, +// Windsurf, Zed and Aider read it natively and read none of .claude/skills/. It +// therefore has to stand alone rather than defer to the skills. +// +// Both files use MergeFence, so they are created if absent and otherwise have +// only devstack's block replaced — a user's own instructions survive every +// regeneration. Emitting five near-duplicate per-tool rule files +// (.cursorrules, .github/copilot-instructions.md, GEMINI.md, …) is deliberately +// NOT done: AGENTS.md is the cross-tool standard, and five copies is precisely +// the drift this design exists to avoid. +func (g *Generator) buildAgentsMD() ([]Artifact, error) { + body, err := packBody(PackAgents) + if err != nil { + return nil, err + } + arts := []Artifact{ + g.artifact("agents-md", MergeFence, g.rewriteBinary(body), "AGENTS.md"), + } + + var claude bytes.Buffer + fmt.Fprintf(&claude, claudeImportBlock, g.binary, g.binary) + arts = append(arts, g.artifact("claude-md", MergeFence, claude.Bytes(), "CLAUDE.md")) + return arts, nil +} diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go new file mode 100644 index 0000000..9a73199 --- /dev/null +++ b/internal/ai/ai_test.go @@ -0,0 +1,586 @@ +package ai + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// testGen builds a Generator with a pinned version so goldens never drift with +// the build stamp. +func testGen(root string) *Generator { + return New(WithRoot(root), WithBinary("devstack"), WithVersion("1.2.3")) +} + +func buildAll(t *testing.T, root string) []Artifact { + t.Helper() + arts, err := testGen(root).Build(All()) + if err != nil { + t.Fatalf("Build: %v", err) + } + return arts +} + +func TestBuildEmitsTheExpectedArtifacts(t *testing.T) { + arts := buildAll(t, "/repo") + var got []string + for _, a := range arts { + got = append(got, a.Rel+" ["+a.Kind+"]") + } + want := []string{ + ".claude/skills/devstack/SKILL.md [skill]", + ".claude/skills/devstack/reference.md [skill-support]", + ".claude/skills/devstack-templates/SKILL.md [skill]", + ".claude/skills/devstack-troubleshooting/SKILL.md [skill]", + "AGENTS.md [agents-md]", + "CLAUDE.md [claude-md]", + ".mcp.json [mcp-config]", + } + if len(got) != len(want) { + t.Fatalf("emitted %d artifacts, want %d:\n%s", len(got), len(want), strings.Join(got, "\n")) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("artifact %d = %q, want %q", i, got[i], want[i]) + } + } +} + +// TestMergeModesAreCorrectlyAssigned pins the safety property that matters most: +// only files devstack fully owns may be written whole. Getting this wrong +// destroys a user's AGENTS.md on every regeneration. +func TestMergeModesAreCorrectlyAssigned(t *testing.T) { + for _, a := range buildAll(t, "/repo") { + var want MergeMode + switch { + case strings.HasPrefix(a.Rel, ".claude/skills/"): + want = MergeWhole + case a.Rel == "AGENTS.md", a.Rel == "CLAUDE.md": + want = MergeFence + case a.Rel == ".mcp.json": + want = MergeJSONKey + default: + t.Errorf("unexpected artifact %s", a.Rel) + continue + } + if a.Merge != want { + t.Errorf("%s has merge mode %d, want %d", a.Rel, a.Merge, want) + } + } +} + +func TestBuildIsPureAndDeterministic(t *testing.T) { + // Build must not touch the disk, so a nonexistent root is fine. + a := buildAll(t, filepath.Join(t.TempDir(), "does-not-exist")) + b := buildAll(t, filepath.Join(t.TempDir(), "also-missing")) + if len(a) != len(b) { + t.Fatalf("artifact count differs between runs: %d vs %d", len(a), len(b)) + } + for i := range a { + if a[i].Rel != b[i].Rel { + t.Fatalf("artifact %d order differs: %q vs %q", i, a[i].Rel, b[i].Rel) + } + if !bytes.Equal(a[i].Data, b[i].Data) { + t.Errorf("%s is not byte-deterministic across runs", a[i].Rel) + } + } +} + +func TestWriteIsIdempotent(t *testing.T) { + dir := t.TempDir() + arts := buildAll(t, dir) + + first, err := Write(arts) + if err != nil { + t.Fatalf("Write: %v", err) + } + for _, r := range first { + if !r.Changed { + t.Errorf("first write reported %s unchanged", r.Path) + } + } + + second, err := Write(buildAll(t, dir)) + if err != nil { + t.Fatalf("second Write: %v", err) + } + for _, r := range second { + if r.Changed { + t.Errorf("second write changed %s; emission is not idempotent", r.Path) + } + } + + ok, err := UpToDate(buildAll(t, dir)) + if err != nil { + t.Fatalf("UpToDate: %v", err) + } + if !ok { + t.Error("UpToDate is false immediately after Write") + } + stale, err := Stale(buildAll(t, dir)) + if err != nil { + t.Fatalf("Stale: %v", err) + } + if len(stale) != 0 { + t.Errorf("Stale reported %d artifacts right after Write", len(stale)) + } +} + +// TestFencePreservesUserContent is the core promise of MergeFence: a user's own +// prose survives regeneration, above and below the block. +func TestFencePreservesUserContent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "AGENTS.md") + original := "# My Project\n\nMy own notes.\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := Write(buildAll(t, dir)); err != nil { + t.Fatalf("Write: %v", err) + } + got := readFile(t, path) + if !strings.HasPrefix(got, original) { + t.Errorf("user content at the top was not preserved:\n%s", got) + } + if !strings.Contains(got, markerBegin) || !strings.Contains(got, markerEnd) { + t.Error("fence markers are missing") + } + + // Append below the block, regenerate, and confirm both halves survive. + trailer := "\n## Added later\n\nStill mine.\n" + if err := os.WriteFile(path, []byte(got+trailer), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Write(buildAll(t, dir)); err != nil { + t.Fatalf("second Write: %v", err) + } + got = readFile(t, path) + if !strings.HasPrefix(got, original) { + t.Error("leading user content lost on regeneration") + } + if !strings.Contains(got, "Still mine.") { + t.Error("trailing user content lost on regeneration") + } + if strings.Count(got, markerBegin) != 1 { + t.Errorf("expected exactly one fence, found %d", strings.Count(got, markerBegin)) + } +} + +// TestFenceReplacesStaleBlock proves regeneration updates the block in place +// rather than appending a second one. +func TestFenceReplacesStaleBlock(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "AGENTS.md") + stale := "# Doc\n\n" + markerBegin + "\nOLD CONTENT\n" + markerEnd + "\n\nAfter.\n" + if err := os.WriteFile(path, []byte(stale), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Write(buildAll(t, dir)); err != nil { + t.Fatalf("Write: %v", err) + } + got := readFile(t, path) + if strings.Contains(got, "OLD CONTENT") { + t.Error("stale block was not replaced") + } + if !strings.Contains(got, "After.") { + t.Error("content after the block was lost") + } + if strings.Count(got, markerBegin) != 1 { + t.Errorf("expected exactly one fence, found %d", strings.Count(got, markerBegin)) + } +} + +// TestJSONKeyPreservesOtherServers is the equivalent promise for .mcp.json: a +// teammate's other MCP servers must survive. +func TestJSONKeyPreservesOtherServers(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".mcp.json") + original := `{ + "mcpServers": { + "playwright": { "command": "npx", "args": ["@playwright/mcp"] } + }, + "someOtherTopLevelKey": {"keep": true} +}` + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Write(buildAll(t, dir)); err != nil { + t.Fatalf("Write: %v", err) + } + + var got map[string]any + if err := json.Unmarshal([]byte(readFile(t, path)), &got); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + servers, _ := got["mcpServers"].(map[string]any) + if _, ok := servers["playwright"]; !ok { + t.Error("an existing MCP server was dropped") + } + if _, ok := got["someOtherTopLevelKey"]; !ok { + t.Error("an unrelated top-level key was dropped") + } + ds, ok := servers["devstack"].(map[string]any) + if !ok { + t.Fatal("the devstack server was not registered") + } + if ds["command"] != "devstack" { + t.Errorf("command = %v, want the bare binary name (an absolute path breaks for teammates)", ds["command"]) + } +} + +func TestJSONKeyCreatesFileWhenAbsent(t *testing.T) { + dir := t.TempDir() + if _, err := Write(buildAll(t, dir)); err != nil { + t.Fatalf("Write: %v", err) + } + var got map[string]any + if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, ".mcp.json"))), &got); err != nil { + t.Fatalf("not valid JSON: %v", err) + } + if _, ok := got["mcpServers"]; !ok { + t.Error("mcpServers was not created") + } +} + +func TestJSONKeyRejectsMalformedExistingFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Write(buildAll(t, dir)); err == nil { + t.Fatal("expected an error rather than silently overwriting a malformed file") + } +} + +// TestSkillFrontmatterIsSpecPortable pins the constraint that lets these files be +// uploaded to claude.ai and packaged with the Agent Skills tooling: only the six +// spec fields are allowed, and any other key is a hard error there. +func TestSkillFrontmatterIsSpecPortable(t *testing.T) { + allowed := map[string]bool{ + "name": true, "description": true, "license": true, + "compatibility": true, "metadata": true, "allowed-tools": true, + } + keyRE := regexp.MustCompile(`^([a-zA-Z][a-zA-Z0-9_-]*):`) + + for _, a := range buildAll(t, "/repo") { + if a.Kind != "skill" { + continue + } + body := string(a.Data) + if !strings.HasPrefix(body, "---\n") { + t.Errorf("%s does not start with frontmatter", a.Rel) + continue + } + end := strings.Index(body[4:], "\n---\n") + if end < 0 { + t.Errorf("%s has an unterminated frontmatter block", a.Rel) + continue + } + fm := body[4 : 4+end] + var sawName, sawDesc bool + for _, line := range strings.Split(fm, "\n") { + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") || line == "" { + continue // continuation of a folded scalar or a nested map + } + m := keyRE.FindStringSubmatch(line) + if m == nil { + continue + } + key := m[1] + if !allowed[key] { + t.Errorf("%s: frontmatter key %q is not in the Agent Skills spec "+ + "(allowed: allowed-tools, compatibility, description, license, metadata, name)", a.Rel, key) + } + switch key { + case "name": + sawName = true + case "description": + sawDesc = true + } + } + if !sawName || !sawDesc { + t.Errorf("%s must declare both name and description", a.Rel) + } + } +} + +// TestSkillDescriptionsFitTheRouterBudget: Claude Code truncates the combined +// description text at 1,536 characters in the skill listing, so an over-long +// description silently loses its tail — including the trigger phrases. +func TestSkillDescriptionsFitTheRouterBudget(t *testing.T) { + for _, s := range skills { + if n := len(s.Description); n > 1536 { + t.Errorf("skill %s description is %d chars; the listing truncates at 1536", s.Name, n) + } + if len(s.Description) < 80 { + t.Errorf("skill %s description is only %d chars; too vague to route on", s.Name, len(s.Description)) + } + } +} + +// credentialRE matches literals that are shaped like real credentials. Note it +// does NOT match the string "secret://" on its own: the pack teaches agents about +// the secret:// scheme, and documenting a grammar is not leaking a value. What +// must never appear is a concrete credential or a resolved assignment. +var credentialRE = regexp.MustCompile( + `BEGIN [A-Z ]*PRIVATE KEY` + + `|AKIA[0-9A-Z]{16}` + + `|gh[pousr]_[A-Za-z0-9]{20,}` + + `|xox[baprs]-[A-Za-z0-9-]{10,}` + + `|secret://\S+\s*=\s*\S`) + +// TestNoSecretsInEmittedFiles extends the project-wide rule that no secret value +// may reach a generated file. These files get committed, so the bar is absolute. +func TestNoSecretsInEmittedFiles(t *testing.T) { + for _, a := range buildAll(t, "/repo") { + if m := credentialRE.FindString(string(a.Data)); m != "" { + t.Errorf("%s contains something credential-shaped: %q", a.Rel, m) + } + } +} + +// TestSecretGuidanceIsPresent is the positive half: the pack must actually tell +// an agent the rule, since "do not inline a resolved secret" is exactly the +// mistake a model makes when a variable will not resolve. +func TestSecretGuidanceIsPresent(t *testing.T) { + var mentions int + for _, a := range buildAll(t, "/repo") { + if strings.Contains(string(a.Data), "secret://") { + mentions++ + } + } + if mentions == 0 { + t.Error("no emitted file explains the secret:// rule") + } +} + +// TestBinaryRewriteFollowsTheAlias: an installation invoked through an argv[0] +// alias must document its own name, or every command in the emitted files is +// wrong for that user. +func TestBinaryRewriteFollowsTheAlias(t *testing.T) { + arts, err := New(WithRoot("/repo"), WithBinary("rq"), WithVersion("1.2.3")).Build(All()) + if err != nil { + t.Fatalf("Build: %v", err) + } + var sawSkill, sawMCP bool + for _, a := range arts { + body := string(a.Data) + switch a.Rel { + case ".claude/skills/devstack/SKILL.md": + sawSkill = true + if !strings.Contains(body, "rq ai docs") { + t.Error("skill body was not rewritten to the alias") + } + if strings.Contains(body, "devstack ai docs") { + t.Error("skill body still names the default binary") + } + if !strings.Contains(body, "allowed-tools: Bash(rq:*)") { + t.Error("allowed-tools was not rewritten to the alias") + } + case ".mcp.json": + sawMCP = true + if !strings.Contains(body, `"command": "rq"`) { + t.Errorf(".mcp.json should invoke the alias, got:\n%s", body) + } + } + } + if !sawSkill || !sawMCP { + t.Fatal("expected both a skill and the MCP config") + } +} + +func TestParseTargets(t *testing.T) { + for _, tc := range []struct { + in string + want Targets + }{ + {"skills", Targets{Skills: true}}, + {"agents", Targets{AgentsMD: true}}, + {"mcp", Targets{MCP: true}}, + {"skills,mcp", Targets{Skills: true, MCP: true}}, + {"all", All()}, + {" Skills , AGENTS ", Targets{Skills: true, AgentsMD: true}}, + } { + got, err := ParseTargets(tc.in) + if err != nil { + t.Errorf("ParseTargets(%q): %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("ParseTargets(%q) = %+v, want %+v", tc.in, got, tc.want) + } + } + if _, err := ParseTargets("nope"); err == nil { + t.Error("expected an error for an unknown target") + } +} + +func TestTargetsSelectSubsets(t *testing.T) { + only, err := testGen("/repo").Build(Targets{MCP: true}) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(only) != 1 || only[0].Rel != ".mcp.json" { + t.Errorf("--target mcp should emit only .mcp.json, got %d artifacts", len(only)) + } + none, err := testGen("/repo").Build(Targets{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(none) != 0 { + t.Errorf("a zero Targets should emit nothing, got %d", len(none)) + } +} + +// TestPackMentionsOnlyRealTemplateFunctions guards the most detail-dense claim in +// the pack: the FuncMap list. A function removed from internal/template must not +// keep being advertised to agents. +func TestPackMentionsOnlyRealTemplateFunctions(t *testing.T) { + body, err := packBody(PackTemplates) + if err != nil { + t.Fatal(err) + } + // The pack lists the functions in one backticked run; collect them. + listed := map[string]bool{} + for _, m := range regexp.MustCompile("`([a-zA-Z]+)`").FindAllStringSubmatch(string(body), -1) { + listed[m[1]] = true + } + for _, want := range []string{"default", "coalesce", "upper", "lower", "title", "trim", + "trimPrefix", "trimSuffix", "replace", "contains", "hasPrefix", "hasSuffix", + "join", "split", "quote", "squote", "indent", "nindent", "repeat", "atoi"} { + if !listed[want] { + t.Errorf("the templates pack no longer documents the %q template function", want) + } + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} + +// TestSkillDirsAreNamespaced keeps the /devstack command claim intentional and +// everything else prefixed, so the skills cannot collide with a user's own. +func TestSkillDirsAreNamespaced(t *testing.T) { + dirs := SkillDirs() + sort.Strings(dirs) + for _, d := range dirs { + if d != "devstack" && !strings.HasPrefix(d, "devstack-") { + t.Errorf("skill dir %q is neither devstack nor devstack-prefixed", d) + } + } +} + +// TestOutputDoesNotDependOnVersion is the guard that keeps `ai check` usable as a +// CI gate. These files are committed by users; if their content varied with the +// binary's version stamp, every release would mark every repository's files stale +// and `devstack self update` would produce a diff in everyone's working tree. +func TestOutputDoesNotDependOnVersion(t *testing.T) { + build := func(version string) []Artifact { + arts, err := New(WithRoot("/repo"), WithBinary("devstack"), WithVersion(version)).Build(All()) + if err != nil { + t.Fatalf("Build(%s): %v", version, err) + } + return arts + } + a, b := build("dev"), build("9.9.9") + if len(a) != len(b) { + t.Fatalf("artifact count differs: %d vs %d", len(a), len(b)) + } + for i := range a { + if !bytes.Equal(a[i].Data, b[i].Data) { + t.Errorf("%s changes with the version stamp; committed files would go stale on every release", a[i].Rel) + } + } +} + +// TestJSONKeyIgnoresFormatting is a regression test for a real failure: another +// tool reformatted .mcp.json (same JSON, different whitespace) and `ai check` +// reported it stale — which, with ai-check gating CI, would fail the build on a +// purely cosmetic change and make devstack fight the other formatter on every +// commit. devstack owns ONE KEY in that file, not its formatting. +func TestJSONKeyIgnoresFormatting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".mcp.json") + + // Write it once the normal way. + if _, err := Write(buildAll(t, dir)); err != nil { + t.Fatalf("Write: %v", err) + } + + // Now reformat the file the way a different tool would: identical JSON, + // compact array, extra key ordering churn. + reformatted := `{ + "mcpServers": { + "devstack": { "command": "devstack", "args": ["ai", "mcp"] } + } +} +` + if err := os.WriteFile(path, []byte(reformatted), 0o644); err != nil { + t.Fatal(err) + } + + stale, err := Stale(buildAll(t, dir)) + if err != nil { + t.Fatalf("Stale: %v", err) + } + for _, a := range stale { + if a.Rel == ".mcp.json" { + t.Error(".mcp.json reported stale after a cosmetic reformat; devstack owns the key, not the formatting") + } + } + + // And a write must leave the reformatted file alone. + results, err := Write(buildAll(t, dir)) + if err != nil { + t.Fatalf("Write: %v", err) + } + for _, r := range results { + if r.Path == ".mcp.json" && r.Changed { + t.Error("devstack rewrote .mcp.json purely to reformat it") + } + } + if got := readFile(t, path); got != reformatted { + t.Errorf("the user's formatting was not preserved:\n%s", got) + } +} + +// TestJSONKeyDetectsARealChange is the other half: a genuinely wrong value must +// still be corrected. +func TestJSONKeyDetectsARealChange(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".mcp.json") + if err := os.WriteFile(path, []byte( + `{"mcpServers":{"devstack":{"command":"WRONG","args":["ai","mcp"]}}}`), 0o644); err != nil { + t.Fatal(err) + } + stale, err := Stale(buildAll(t, dir)) + if err != nil { + t.Fatalf("Stale: %v", err) + } + var found bool + for _, a := range stale { + if a.Rel == ".mcp.json" { + found = true + } + } + if !found { + t.Fatal("a wrong command value should be reported stale") + } + if _, err := Write(buildAll(t, dir)); err != nil { + t.Fatalf("Write: %v", err) + } + if strings.Contains(readFile(t, path), "WRONG") { + t.Error("the wrong value was not corrected") + } +} diff --git a/internal/ai/catalog.go b/internal/ai/catalog.go new file mode 100644 index 0000000..bab5cfd --- /dev/null +++ b/internal/ai/catalog.go @@ -0,0 +1,122 @@ +// Package ai builds the machine-readable views of devstack that AI agents +// consume, and emits the skill/instruction files a repository commits (spec 32). +// +// It never imports internal/cli: the cobra tree is passed IN as a parameter, so +// the CLI can depend on this package without a cycle. It touches no Docker, no +// ledger and no flock — like internal/ide, it is a pure derivation-and-emission +// sink, which is what lets every output be golden-tested and deterministic. +package ai + +import ( + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// CommandCatalog is the whole command surface as data: what an agent needs to +// answer "what can devstack do and how do I invoke it" without shelling out to +// --help 39 times. +// +// It is DERIVED from the live cobra tree rather than authored, so it cannot drift +// from the binary that produced it. That is the same reason the emitted skills +// carry navigation instead of a copy of this list. +type CommandCatalog struct { + Binary string `json:"binary"` + Version string `json:"version"` + GlobalFlags []Flag `json:"globalFlags"` + Commands []Command `json:"commands"` +} + +// Command is one node of the tree, flattened. Path is the invocation without the +// binary name ("db user create"), which is also the key the docs drift test uses. +type Command struct { + Path string `json:"path"` + Use string `json:"use"` + Short string `json:"short"` + Args string `json:"args,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Group bool `json:"group"` // has subcommands + Runnable bool `json:"runnable"` // has a Run/RunE of its own + Flags []Flag `json:"flags,omitempty"` +} + +// Flag is one command-local or global flag. +type Flag struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Usage string `json:"usage"` + Type string `json:"type"` + Default string `json:"default,omitempty"` +} + +// Catalog walks a cobra tree into a CommandCatalog. Hidden commands and cobra's +// own `help` are omitted; everything else is reported in stable path order. +// +// Global flags are reported once at the top level rather than repeated on every +// command, because they live on the root as persistent flags and repeating them +// 100+ times would triple the payload an agent has to read. +func Catalog(root *cobra.Command, version string) CommandCatalog { + cat := CommandCatalog{ + Binary: root.Name(), + Version: version, + GlobalFlags: collectFlags(root.PersistentFlags()), + } + var walk func(c *cobra.Command, prefix []string) + walk = func(c *cobra.Command, prefix []string) { + for _, sub := range c.Commands() { + if sub.Hidden || sub.Name() == "help" || sub.Name() == "completion" { + continue + } + path := append(append([]string{}, prefix...), sub.Name()) + cat.Commands = append(cat.Commands, describe(sub, path)) + walk(sub, path) + } + } + walk(root, nil) + sort.Slice(cat.Commands, func(i, j int) bool { return cat.Commands[i].Path < cat.Commands[j].Path }) + return cat +} + +func describe(c *cobra.Command, path []string) Command { + return Command{ + Path: strings.Join(path, " "), + Use: c.Use, + Short: c.Short, + Args: argSpec(c.Use), + Aliases: append([]string{}, c.Aliases...), + Group: c.HasSubCommands(), + Runnable: c.Runnable(), + Flags: collectFlags(c.LocalNonPersistentFlags()), + } +} + +// argSpec extracts the argument portion of a cobra Use line: "run " +// yields "". Empty when the command takes no arguments. +func argSpec(use string) string { + _, rest, found := strings.Cut(strings.TrimSpace(use), " ") + if !found { + return "" + } + return strings.TrimSpace(rest) +} + +// collectFlags renders a flag set as sorted data, skipping hidden flags. +func collectFlags(fs *pflag.FlagSet) []Flag { + var out []Flag + fs.VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + out = append(out, Flag{ + Name: f.Name, + Shorthand: f.Shorthand, + Usage: f.Usage, + Type: f.Value.Type(), + Default: f.DefValue, + }) + }) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} diff --git a/internal/ai/emit.go b/internal/ai/emit.go new file mode 100644 index 0000000..58a433c --- /dev/null +++ b/internal/ai/emit.go @@ -0,0 +1,157 @@ +package ai + +import ( + "fmt" + "path/filepath" + "strings" +) + +// Targets selects which families of agent-integration files to emit. A zero +// Targets emits nothing; the CLI defaults an empty selection to All. +type Targets struct { + Skills bool // .claude/skills/** — Claude Code skills + AgentsMD bool // AGENTS.md + CLAUDE.md — the cross-tool instruction files + MCP bool // .mcp.json — the MCP server registration +} + +// All is the target set produced by `ai install` with no target flag. +func All() Targets { return Targets{Skills: true, AgentsMD: true, MCP: true} } + +// Any reports whether any target is selected. +func (t Targets) Any() bool { return t.Skills || t.AgentsMD || t.MCP } + +// Generator authors the agent-integration files for one repository. +// +// It deliberately takes NO *config.Model, which is where it parts company with +// internal/ide. These files get committed, so encoding a snapshot of the current +// projects and shared services would go stale the moment someone adds a project, +// with no CI anywhere to catch it. Live facts belong to `devstack status --json`, +// which the agent runs. The happy consequence is that the emitted content is +// useful BEFORE a workspace exists — exactly when an agent is learning +// `devstack init` — and that the golden tests are trivially hermetic. +type Generator struct { + root string + binary string + version string +} + +// Option configures a Generator. +type Option func(*Generator) + +// WithRoot sets the directory the files are written under (the workspace root, +// or the current directory when there is no workspace). +func WithRoot(dir string) Option { + return func(g *Generator) { + if dir != "" { + g.root = dir + } + } +} + +// WithBinary sets the command name written into the emitted files, so an +// installation invoked through an argv[0] alias (rq, uranus) documents itself +// correctly. +func WithBinary(name string) Option { + return func(g *Generator) { + if name != "" { + g.binary = name + } + } +} + +// WithVersion pins the version stamp recorded in the skill frontmatter. Tests +// inject a fixed value so goldens do not drift with the build stamp. +func WithVersion(v string) Option { + return func(g *Generator) { + if v != "" { + g.version = v + } + } +} + +// New builds a Generator. Defaults: the current directory, the binary name +// "devstack", and an unset version. +func New(opts ...Option) *Generator { + g := &Generator{root: ".", binary: "devstack", version: "dev"} + for _, o := range opts { + o(g) + } + return g +} + +// Build assembles every selected artifact. It never touches the disk — the two +// merge modes that preserve user content compose against the file at Write time. +func (g *Generator) Build(t Targets) ([]Artifact, error) { + var arts []Artifact + if t.Skills { + skills, err := g.buildSkills() + if err != nil { + return nil, err + } + arts = append(arts, skills...) + } + if t.AgentsMD { + md, err := g.buildAgentsMD() + if err != nil { + return nil, err + } + arts = append(arts, md...) + } + if t.MCP { + mcp, err := g.buildMCPConfig() + if err != nil { + return nil, err + } + arts = append(arts, mcp...) + } + return arts, nil +} + +// artifact builds one Artifact with its repo-relative label filled in. +func (g *Generator) artifact(kind string, merge MergeMode, data []byte, parts ...string) Artifact { + abs := filepath.Join(append([]string{g.root}, parts...)...) + return Artifact{ + Path: abs, + Rel: strings.Join(parts, "/"), + Kind: kind, + Data: data, + Merge: merge, + } +} + +// rewriteBinary substitutes the invoked binary name into authored content. The +// pack is written for "devstack"; an aliased installation gets its own name so +// every command in the emitted files is copy-pasteable. +func (g *Generator) rewriteBinary(body []byte) []byte { + if g.binary == "" || g.binary == "devstack" { + return body + } + return []byte(strings.ReplaceAll(string(body), "devstack ", g.binary+" ")) +} + +// errUnknownTarget reports a target name the CLI does not recognize. +func errUnknownTarget(name string) error { + return fmt.Errorf("unknown target %q (available: skills, agents, mcp)", name) +} + +// ParseTargets resolves a comma-separated --target selection. +func ParseTargets(spec string) (Targets, error) { + var t Targets + for _, raw := range strings.Split(spec, ",") { + switch strings.TrimSpace(strings.ToLower(raw)) { + case "": + continue + case "all": + t = All() + case "skills", "claude": + t.Skills = true + case "agents", "agents.md", "agentsmd": + t.AgentsMD = true + case "mcp": + t.MCP = true + default: + return Targets{}, errUnknownTarget(strings.TrimSpace(raw)) + } + } + return t, nil +} diff --git a/internal/ai/mcpconfig.go b/internal/ai/mcpconfig.go new file mode 100644 index 0000000..d3e68c4 --- /dev/null +++ b/internal/ai/mcpconfig.go @@ -0,0 +1,31 @@ +package ai + +// mcpServerKey is the name devstack registers itself under in .mcp.json. +const mcpServerKey = "devstack" + +// mcpServer is one entry in .mcp.json's mcpServers map. +type mcpServer struct { + Command string `json:"command"` + Args []string `json:"args"` +} + +// buildMCPConfig renders the devstack entry for the project's .mcp.json. +// +// The whole file is NOT owned: MergeJSONKey sets only mcpServers.devstack and +// preserves every other server a teammate has configured. +// +// Command is the bare binary name, never an absolute path. An absolute path is +// correct on the machine that ran `ai install` and wrong for everyone else who +// checks the file out — which is the entire point of committing it. +func (g *Generator) buildMCPConfig() ([]Artifact, error) { + entry, err := marshalJSON(mcpServer{ + Command: g.binary, + Args: []string{"ai", "mcp"}, + }) + if err != nil { + return nil, err + } + a := g.artifact("mcp-config", MergeJSONKey, entry, ".mcp.json") + a.JSONPath = []string{"mcpServers", mcpServerKey} + return []Artifact{a}, nil +} diff --git a/internal/ai/pack.go b/internal/ai/pack.go new file mode 100644 index 0000000..21f753d --- /dev/null +++ b/internal/ai/pack.go @@ -0,0 +1,43 @@ +package ai + +import ( + "embed" + "fmt" +) + +// pack holds the authored agent-facing content: the bodies of the emitted skills +// and the AGENTS.md block. +// +// It lives under internal/ so editing it touches internal/**, which CI always +// builds and tests — the same reason docs/ had to be removed from the workflow's +// paths-ignore list once it became a build input. +// +// The content is emitted VERBATIM. It is deliberately never run through +// internal/template: the template-authoring page must contain literal +// `[[ .params.version ]]` examples, and text/template with `[[ ]]` delimiters and +// missingkey=error would try to execute them. +// +//go:embed pack/*.md +var pack embed.FS + +// Pack names one authored document. +type Pack string + +// The authored documents. devstack/templates/troubleshooting become skills; +// reference is a supporting file loaded on demand; agents is the AGENTS.md block. +const ( + PackDevstack Pack = "devstack" + PackTemplates Pack = "templates" + PackTroubleshooting Pack = "troubleshooting" + PackReference Pack = "reference" + PackAgents Pack = "agents" +) + +// packBody returns one authored document's markdown. +func packBody(p Pack) ([]byte, error) { + b, err := pack.ReadFile("pack/" + string(p) + ".md") + if err != nil { + return nil, fmt.Errorf("read embedded pack %s: %w", p, err) + } + return b, nil +} diff --git a/internal/ai/pack/agents.md b/internal/ai/pack/agents.md new file mode 100644 index 0000000..d59f9c7 --- /dev/null +++ b/internal/ai/pack/agents.md @@ -0,0 +1,55 @@ +## devstack + +This repository uses [devstack](https://github.com/open-source-cloud/devstack) to +run its local development environment. devstack shares infrastructure across +projects: one warm Postgres/Redis/MinIO on a tool-owned Docker network serves +every repo in the workspace, and each project gets its own database, role and +bucket. + +Two committed files describe everything — `workspace.yaml` at the workspace root +(what infrastructure is provided) and one `devstack.yaml` per repo (what that +project consumes). **Everything under `.devstack/` is generated output.** + +### Commands + +```bash +devstack up # start: network → shared engines → provision → compose up +devstack down # stop this workspace's stacks (data is preserved) +devstack status # service health and the shared-service ref graph +devstack logs [svc] # streamed logs across project and shared stacks +devstack shell [svc] # a shell inside a service container +devstack run # the project's tasks: graph, dependency-ordered +devstack generate # re-render compose + build artifacts after a config change +devstack doctor # check the host is set up correctly +``` + +Every headline command supports `--json` and `--quiet`. + +### Rules + +1. **Never hand-edit anything under `.devstack/`** — it is generated and will be + overwritten. Edit `workspace.yaml` or `devstack.yaml` and run + `devstack generate`. +2. **Never run `docker compose` against these stacks**, and never + `docker network rm devstack_shared`. The project name, labels and the external + network are tool-owned; using compose directly forks a parallel stack. +3. **Shared services are reached by DNS alias** (`shared-postgres`, + `shared-redis`), never by bare service name, and publish no host port by + default. Use `devstack expose` when a host client needs one. +4. **Destructive verbs require `--yes` under `--json`.** Do not pass `--yes` on + the user's behalf without asking. +5. **`secret://` values never belong in a committed or generated file.** +6. **`${ref:...}` uses a colon; `${env.NAME}` and `${self.attr}` use a dot.** + +### Learning more + +devstack documents itself — the whole corpus is compiled into the binary: + +```bash +devstack ai docs # list every document +devstack ai docs guide/templates # how to author a service template +devstack ai docs guide/config-reference # every config field and grammar +devstack ai docs --search "" # search titles and bodies +devstack ai commands --json # the command surface as data +devstack config schema # the JSON Schema for devstack.yaml +``` diff --git a/internal/ai/pack/devstack.md b/internal/ai/pack/devstack.md new file mode 100644 index 0000000..01562d1 --- /dev/null +++ b/internal/ai/pack/devstack.md @@ -0,0 +1,112 @@ +Commands below are written as `devstack`. If this machine installed an alias +(`rq`, `uranus`), substitute it — the command tree is identical. + +## What devstack is + +devstack runs Docker development environments where **infrastructure is shared +across projects**. One warm Postgres, one Redis, one MinIO on a tool-owned Docker +network serve every repo in the workspace, and each project still gets its own +database, role and bucket. That is the whole point: eight microservices, one +Postgres container, eight isolated databases. + +Two committed files describe everything: + +- **`workspace.yaml`** at the workspace root — what infrastructure is *provided*, + and which repos belong to the workspace. +- **`devstack.yaml`**, one per repo — what that project *consumes*. + +Everything else is generated. `devstack up` renders the compose files, starts the +shared engines once, provisions each project's isolated data, and brings the +project stacks up on the shared network. + +## Orient before acting + +In an unfamiliar workspace, run these first. They are read-only and fast. + +```bash +devstack context # active workspace, project, docker context +devstack status # service health + the shared-service ref graph +devstack config show --json # the resolved configuration +devstack ai commands # every command this binary has +``` + +If `devstack config validate` reports an error it will be a `file:line:col` +pointing at the exact node. Read it; do not guess. + +## The rules that matter + +Violating any of these produces a broken or confusing workspace, and most are not +guessable from Docker experience alone. + +1. **Never hand-edit anything under `.devstack/`.** Those compose files and + Dockerfiles are generated and are overwritten on the next `generate` or `up`. + Change `workspace.yaml` / `devstack.yaml` and regenerate. +2. **Never run `docker compose` against a devstack stack.** The project name, + labels and the external network are tool-owned; running compose directly forks + a second, parallel stack that shares nothing with the workspace. +3. **Never `docker network rm devstack_shared`.** Compose refuses to manage + `external: true` networks, so devstack owns creating and removing it. +4. **Shared services are reached by DNS alias** — `shared-postgres`, + `shared-redis`, `shared-minio` — never by bare service name. By default + nothing publishes a host port; run `devstack expose` when a GUI client on the + host needs one. +5. **Let devstack perform mutations.** Anything that changes the ledger or the + shared stack takes a cross-process lock. Do not run two `up`s concurrently. +6. **Destructive verbs need `--yes` under `--json`** (`workspace destroy`, + `uninstall`, `db drop`/`reset`/`restore`/`gc`, `resource rm`/`gc`, `s3 rb`, + `queue`/`topic`/`stream rm`). Never pass `--yes` on the user's behalf without + asking them first. +7. **`secret://` values never land in a generated file.** A secret reaches a + container because the compose file lists the variable name with no value and + devstack passes the value through the process environment. Do not try to + inline a resolved secret. +8. **`${ref:...}` uses a colon. `${env.NAME}` and `${self.attr}` use a dot.** + This asymmetry is the single most common config typo. +9. **Deep-merge replaces lists by default.** Opt into appending with + `$merge: append`. + +## The commands that cover most work + +```bash +devstack up [project...] # network → shared engines → provision → compose up +devstack down [project...] # stop this workspace's stacks, keep the data +devstack status # health + ref graph +devstack logs [service...] # streamed across project and shared stacks +devstack shell [service] # a shell inside a service container +devstack run # the project's tasks: graph, dependency-ordered +devstack generate --check # is anything stale? (exit non-zero if so) +``` + +Every headline command supports `--json` and `--quiet`. Use `--json` when you +need to parse the result. + +## Common tasks + +| Goal | Do this | +|---|---| +| Add a repo to the workspace | `devstack project new --path ` | +| Add a service to a repo | add an entry under `services:` in its `devstack.yaml`, then `devstack generate` | +| Add a shared engine | add an entry under `shared:` in `workspace.yaml`, then `devstack up` | +| Give a project a database | declare it under `resources:`, or `devstack db create ` | +| Reach a shared engine from the host | `devstack expose` then `devstack ports` | +| Set an env var on a service | `devstack env set KEY=VALUE --service ` | +| See why a service is unhealthy | `devstack status`, then `devstack logs ` | +| Check the host is set up | `devstack doctor` (add `--fix` to repair) | + +## Reading further + +The whole documentation corpus is compiled into the binary. Do not guess at +behavior — read it: + +```bash +devstack ai docs # list every document +devstack ai docs guide/templates # authoring service templates +devstack ai docs guide/config-reference # every config field and grammar +devstack ai docs guide/lifecycle # up / down / status / logs / shell +devstack ai docs guide/shared-services # the shared engines and host access +devstack ai docs guide/databases # the db group +devstack ai docs guide/secrets # secret:// and .env ingestion +devstack ai docs --search "" # search titles and bodies +``` + +`reference.md` next to this file is a condensed config and flag reference. diff --git a/internal/ai/pack/reference.md b/internal/ai/pack/reference.md new file mode 100644 index 0000000..fe12169 --- /dev/null +++ b/internal/ai/pack/reference.md @@ -0,0 +1,96 @@ +# devstack reference + +A condensed field and flag reference. The authoritative versions are compiled +into the binary: `devstack ai docs guide/config-reference` and +`devstack config schema`. + +## workspace.yaml + +| Field | Type | Notes | +|---|---|---| +| `apiVersion` | string | Required. Always `devstack/v1`. | +| `kind` | string | Required. `Workspace`. | +| `name` | string | Required. Lowercase, starts with a letter, `[a-z0-9_-]`, ≤63 chars. | +| `aliases` | list | Alternate argv[0] names. | +| `profiles.default` | string | The env overlay name. Default `dev`. Readable as `${profile}`. | +| `defaultProfile` | string | The service slice `up` activates with no `--profile`. | +| `groups` | map | Named service slices: `{services: [...], memoryHintMB: N}`. | +| `memoryBudgetMB` | int | Warn above this total. | +| `secrets.providers` | list | `{name, kind, env, projectId, region}`. | +| `network.proxy` | object | `{engine: caddy\|traefik\|nginx, httpsLocal: bool}`. | +| `network.tunnel` | object | `{provider, hostname}`. | +| `backend` | object | `{context}` XOR `{host}`. Omit for the local daemon. | +| `shared` | map | `: {template, params, resources, platform}`. Template must declare `provides:`. | +| `projects` | list | `{name, path, git}`. | +| `hooks` | object | See hooks below. | + +## devstack.yaml + +| Field | Type | Notes | +|---|---|---| +| `apiVersion` / `kind` / `name` | string | Required. `kind: Project`. | +| `services` | map | **Required.** `: {…}` — see below. | +| `resources` | list | `{uses, kind, name, engine, params, credentials}`. | +| `tasks` | map | `: {command, run, service, deps, workdir, env, watch}`. | +| `hooks` | object | See hooks below. | + +### services.\ + +| Field | Type | Notes | +|---|---|---| +| `template` | string | **Required.** e.g. `node.next`. | +| `params` | map | Template parameters. | +| `uses` | list | `workspace.shared.` entries. | +| `env.raw` / `env.prefixed` | map | Literal vars, with `${...}` interpolation. | +| `env.import` | list | `{from, vars}` — pull exported attrs from another service. | +| `ports` | map | `{http: 3000}` — in-container ports. | +| `profiles` | list | Compose profile tags. | +| `memoryMB` | int | Shorthand for `resources.memoryMB`. | +| `resources` | object | `{cpus, memoryMB, memoryReserveMB, pidsLimit}`. | +| `platform` | string | `linux/amd64`, `linux/arm64/v8`. | +| `healthcheck` | object | `{kind, port, path, expectStatus, host, command, user, db, auth, interval, timeout, retries, startPeriod}`. `kind` ∈ tcp, http, https, exec, pg_isready, redis. | +| `dependsOn` | list | `{service, condition}` — condition ∈ healthy (default), started. | + +### hooks.\ + +Phases: `preUp`, `firstRun`, `postUp`, `postPull`, `preDown`. Each is a list of +`{name, run, service, command, workdir, env, timeout, retries, onFailure, once}`. +`run` ∈ host, exec (`service` required for exec). `command` is an argv array, +never shell-split. `onFailure` ∈ abort, warn, continue. + +Hook and task lists **replace** on overlay merge unless the YAML opts into +`$merge: append`. + +## Interpolation grammar + +| Form | Resolves to | +|---|---| +| `${profile}` | The active profile name. | +| `${workspace.name}` | The workspace name. | +| `${env.NAME}` | A host environment variable. **Hard error if unset.** | +| `${self.}` | An attribute of the service being rendered. | +| `${ref:workspace.shared.[.]}` | A shared service's attribute. | +| `${ref:workspace..[.]}` | Another service's attribute. | +| `$$` | A literal `$`. | + +Note the asymmetry: `env.` and `self.` use a **dot**; `ref:` uses a **colon**. + +Secrets are referenced as `secret:///#`. Values are never +written to a generated file — the compose file lists the variable name with no +value and devstack supplies it through the process environment. + +## Global flags + +| Flag | Effect | +|---|---| +| `--json` | Machine-readable output on stdout. | +| `--quiet` | Suppress human output; errors still go to stderr. | +| `--verbose` / `--debug` | Info / debug logging on stderr (`--debug` adds source positions). | +| `--as ` | Pre-parsed argv[0] override. | + +`--check` (on `generate`, `ide`, `ws status`) reports drift and exits non-zero +without writing — the CI form. + +`--yes` is **required** for destructive verbs under `--json`: `workspace destroy`, +`uninstall`, `db drop`/`reset`/`restore`/`gc`, `resource rm`/`gc`, `s3 rb`, +`queue`/`topic`/`stream rm`. diff --git a/internal/ai/pack/templates.md b/internal/ai/pack/templates.md new file mode 100644 index 0000000..790f3f7 --- /dev/null +++ b/internal/ai/pack/templates.md @@ -0,0 +1,164 @@ +Commands below are written as `devstack`. If this machine installed an alias +(`rq`, `uranus`), substitute it. + +## What a template is + +A template is a **directory** that renders one compose service. Every service in +a devstack workspace comes from one. + +``` +/ + template.yaml REQUIRED — metadata plus the compose service fragment + build/ optional — Dockerfile, entrypoint.sh, nginx.conf, rendered verbatim + golden.yaml optional — a byte-for-byte fixture asserted by `devstack template test` + post_init.yaml optional — merged after the whole extends chain +``` + +The directory name is the template ref. Dots are allowed and are how families are +named: `php.nginx`, `php.laravel.nginx`. + +Templates resolve from three sources, first match wins: an OCI-pinned remote +template, then `~/.devstack/templates//`, then the built-ins compiled into +the binary. Dropping a directory into `~/.devstack/templates/postgres/` therefore +shadows the built-in `postgres` for every workspace on the machine. + +## Engines and apps are different things + +This is a hard branch, not a style preference. + +**An engine** is shared infrastructure (postgres, redis, minio, kafka). It uses +`image:`, declares `provides:` and `exports:` and `defaultPort:`, and usually +declares a named volume. It must **never** have a `build:` key — generation +rejects a shared service that tries to build. + +**An app** is a project service (node.next, php.laravel.nginx). It uses +`build: { context: build, dockerfile: Dockerfile }` with a `build/` tree, and +must **never** declare `provides:` — `provides:` is what marks a template as +usable in `workspace.yaml`'s `shared:` block. + +## Read a real one first + +The built-ins are correct, current, and the best possible reference. Before +authoring, read one of the same kind: + +```bash +devstack template list --json # every template with its metadata +devstack ai docs guide/templates # the full authoring guide +``` + +## The manifest + +```yaml +schemaVersion: 1 +extends: php.nginx # optional parent ref +description: "One line describing the service." +provides: postgres # ENGINES ONLY — the capability it satisfies +exports: [host, port, user, password, database] # attrs consumers may import +defaultPort: 5432 # the in-network port ${ref:...port} resolves to +params: + version: + type: string # string | int | bool (advisory in v1) + default: "18" + required: false + description: "Image tag." +service: # the compose service fragment + image: "postgres:[[ .params.version ]]" +volumes: # top-level named volumes + pgdata: {} +``` + +## Templating: `[[ ]]`, not `{{ }}` + +The engine is Go's `text/template` with the delimiters changed to `[[` and `]]`. +That is deliberate: it lets shell `${VAR}`, Dockerfile `$TAG` and compose +`${VAR:-default}` pass through untouched, so a `build/Dockerfile` can use both +syntaxes at once. + +The only data in scope is `.params`: + +```yaml +image: "postgres:[[ .params.version ]]" +``` + +`missingkey=error` is set, so referencing an undeclared param is a hard failure, +never a silent empty string. + +**Three rules that will bite you:** + +1. **Metadata keys are parsed UNRENDERED.** `schemaVersion`, `extends`, + `description`, `provides`, `exports`, `defaultPort` and `params` are read + before any templating runs. A `[[ ]]` action in any of them is silently + meaningless — so the linter makes it a hard error. Only `service:` and + `volumes:` are rendered. +2. **The FuncMap is deterministic on purpose.** There is no `now`, no `uuid`, no + `randAlphaNum`, no environment access, because byte-identical output is a + CI-asserted requirement. +3. **Argument order is pipeline-style — the data comes last**, which is the + opposite of the `strings` package: `trimPrefix "v" .params.tag`, + `replace "-" "_" .params.name`, `contains "alpine" .params.image`, + `join "," .params.list`, `indent 4 .params.block`. + +Available functions: `default` `coalesce` · `upper` `lower` `title` · `trim` +`trimPrefix` `trimSuffix` · `replace` `contains` `hasPrefix` `hasSuffix` · +`join` `split` · `quote` `squote` · `indent` `nindent` `repeat` · `atoi`. + +`atoi` parses the *leading* integer (`"9.6"` → 9), which is what makes +version-conditional fragments work with the builtin `lt`/`ge`: + +```yaml +volumes: + - "pgdata:[[ if lt (atoi .params.version) 18 ]]/var/lib/postgresql/data[[ else ]]/var/lib/postgresql[[ end ]]" +``` + +## extends and the merge + +`extends` renders the parent, then deep-merges the child over it. Order is: +parent → child `template.yaml` → child `post_init.yaml` → the project's overrides. + +**Lists REPLACE by default.** A child declaring `volumes:` replaces the parent's +list entirely. Opt into appending with `$merge: append`. This is the single most +surprising merge behavior; check it whenever a parent's list vanishes. + +`provides`, `exports` and `defaultPort` inherit leaf-wins. Files in `build/` +merge by path, so a child's `build/Dockerfile` replaces the parent's. + +## The authoring loop + +Use the builder rather than hand-writing the directory — it emits a deterministic, +correct skeleton for the kind you pick: + +```bash +devstack template new mysvc --kind engine --print-spec > spec.yaml # inspect the plan +devstack template new mysvc --kind engine --from spec.yaml # materialize it +devstack template lint --show # lints + rendered compose +devstack template test # compare against golden.yaml +``` + +`--print-spec` → `--from` round-trips byte-stably, so an agent can generate the +spec, show it to a human, and materialize exactly what was reviewed. + +`lint` runs three checks and then validates the rendered service through +`compose-go`: + +| Check | Severity | Meaning | +|---|---|---| +| meta-templating | **error** | a `[[ ]]` action outside `service:`/`volumes:` | +| param-type | warning | a `default` that does not parse as its declared `type` | +| delimiter-collision | warning | a `build/` file containing a literal `[[` that is not a valid action | + +## Using a template + +```yaml +# workspace.yaml — engines only (templates that declare provides:) +shared: + postgres: { template: postgres, params: { version: "18" } } +``` + +```yaml +# devstack.yaml — apps +services: + api: + template: node.next + params: { nodeVersion: "22" } + uses: [workspace.shared.postgres] +``` diff --git a/internal/ai/pack/troubleshooting.md b/internal/ai/pack/troubleshooting.md new file mode 100644 index 0000000..46da362 --- /dev/null +++ b/internal/ai/pack/troubleshooting.md @@ -0,0 +1,53 @@ +Commands below are written as `devstack`. If this machine installed an alias +(`rq`, `uranus`), substitute it. + +## Start here + +```bash +devstack doctor # the host preflight matrix: docker, compose, git, ports, paths +devstack doctor --fix # repair what is safely repairable +devstack status # per-service health + the shared-service ref graph +devstack logs # the actual error, usually +``` + +Add `--debug` to any command for structured logs on stderr, including the exact +external command devstack ran and its exit code. + +## Symptom → diagnose → fix + +| Symptom | Diagnose | Fix | +|---|---|---| +| `Cannot connect to the Docker daemon` | `devstack doctor` | Start Docker. On WSL2 confirm which daemon you mean — Desktop and an in-distro `dockerd` are separate contexts with separate ledgers. | +| `network devstack_shared not found` | `docker network ls` | `devstack up` recreates it. Never `docker network rm` it yourself. | +| A host port is already in use | `devstack ports` | `devstack expose --off`, or let devstack allocate a different port. On Windows, an excluded port range can also be the cause. | +| `generate --check` reports drift | `devstack generate --check` | Run `devstack generate`. If drift returns immediately, something is editing `.devstack/` by hand. | +| A config error with `file:line:col` | `devstack config validate` | Read the position — it points at the exact YAML node. `devstack config schema` gives the full field contract. | +| `unknown interpolation ${...}` | — | `${ref:...}` takes a **colon**; `${env.NAME}` and `${self.attr}` take a **dot**. | +| A shared service will not start | `devstack logs shared-` | Often a volume from an older major version. Check the template's `params.version`. | +| A service cannot reach Postgres | `devstack status` | Connect to the alias `shared-postgres`, not `localhost` and not the bare service name. Both containers must be on the shared network. | +| Ref counts look wrong | `devstack shared status` | `devstack shared doctor` reconciles from live containers; `devstack shared gc` releases orphans. | +| A secret will not resolve | `devstack secrets status` | Confirm the provider is declared in `workspace.yaml` and that you are logged in (`devstack secrets login `). | +| A template change has no effect | `devstack template lint --show` | A `[[ ]]` action in a metadata key is a hard lint error — only `service:` and `volumes:` are rendered. | +| A parent template's list disappeared | — | Deep-merge **replaces** lists. Use `$merge: append`. | +| File watching does not fire on WSL2 | — | The app templates set polling env vars for this. Confirm the repo is on the Linux filesystem — `/mnt/*` working directories are refused. | +| Everything is confusing | `devstack doctor --json` | Escalate: `doctor --fix` → `shared doctor` → `shared gc` → as a last resort `workspace destroy` (destructive, needs `--yes`). | + +## Never do these + +- **Do not `docker compose ...` against a devstack stack.** The project name, + labels and external network are tool-owned; you will fork a parallel stack. +- **Do not hand-edit `.devstack/`.** It is generated output. +- **Do not `docker network rm devstack_shared`.** +- **Do not pass `--yes` to a destructive verb on the user's behalf** without + asking. `workspace destroy`, `db drop`, `db reset`, `s3 rb` and friends are not + reversible. +- **Do not run two mutating devstack commands concurrently.** They coordinate + through a cross-process lock; racing them is what corrupts ref counts. + +## Reading further + +```bash +devstack ai docs guide/recovery # doctor --fix, gc, teardown +devstack ai docs troubleshooting # the top-level troubleshooting page +devstack ai docs --search "" +``` diff --git a/internal/ai/skills.go b/internal/ai/skills.go new file mode 100644 index 0000000..055335f --- /dev/null +++ b/internal/ai/skills.go @@ -0,0 +1,164 @@ +package ai + +import ( + "bytes" + "fmt" + "strings" +) + +// skillSpec describes one emitted Claude Code skill. +// +// Frontmatter is restricted to the six fields the Agent Skills spec allows — +// name, description, license, compatibility, metadata, allowed-tools. Claude Code +// accepts many more (when_to_use, argument-hint, disable-model-invocation, +// context: fork, model, effort, paths…), but any of them is a HARD ERROR when the +// same file is uploaded to claude.ai or packaged with the Agent Skills tooling. +// One artifact set that works everywhere beats two sets behind a flag, so the +// "when to use" guidance is folded into description — which is what the skill +// router actually reads anyway. +type skillSpec struct { + Dir string // .claude/skills//SKILL.md + Name string + Description string + Body Pack + Support []supportFile +} + +// supportFile is an extra document in a skill directory, loaded only when the +// skill decides it needs it. Keeping bulk out of SKILL.md is what makes the +// progressive disclosure work. +type supportFile struct { + Name string + Body Pack +} + +// skills is the emitted set. Three, not one and not ten: skills are routed by +// their description, and "operate devstack", "author a devstack template" and +// "something is broken" have disjoint triggers. Ten near-identical per-group +// skills would blur that routing; reference.md plus `--help` covers the detail. +var skills = []skillSpec{ + { + Dir: "devstack", + Name: "devstack", + Description: "Use when working in a repository that contains workspace.yaml or devstack.yaml, " + + "or when asked to start, stop or inspect a local development environment, shared " + + "Postgres/Redis/MinIO/Kafka/NATS, tenant databases, buckets, queues or topics. devstack " + + "runs many project stacks against one warm shared infrastructure stack on a tool-owned " + + "Docker network. Covers the two-file config model, the up/down/status lifecycle, and the " + + "rules for driving the CLI safely — including that compose files under .devstack/ are " + + "generated and must never be hand-edited.", + Body: PackDevstack, + Support: []supportFile{{Name: "reference.md", Body: PackReference}}, + }, + { + Dir: "devstack-templates", + Name: "devstack-templates", + Description: "Use when authoring or editing a devstack service template — a directory holding " + + "template.yaml, an optional build/ tree and golden.yaml — or when asked to add support for " + + "a new database, engine, language or framework to devstack. Covers the engine-versus-app " + + "split, the [[ ]] delimiters and the deterministic FuncMap, params/extends/provides/exports, " + + "the rule that metadata keys are never templated, list-merge semantics, and the " + + "template new → lint → test loop.", + Body: PackTemplates, + }, + { + Dir: "devstack-troubleshooting", + Name: "devstack-troubleshooting", + Description: "Use when a devstack command fails or a service is unhealthy — the Docker daemon is " + + "unreachable, the devstack_shared network is missing, a host port is in use, generate --check " + + "reports drift, config errors point at file:line:col, secret:// resolution fails, shared-service " + + "ref counts look wrong, or file watching misbehaves on WSL2. Maps each symptom to the " + + "diagnostic command and the fix.", + Body: PackTroubleshooting, + }, +} + +// buildSkills renders every skill file. +func (g *Generator) buildSkills() ([]Artifact, error) { + var arts []Artifact + for _, s := range skills { + body, err := packBody(s.Body) + if err != nil { + return nil, err + } + doc, err := g.renderSkill(s, body) + if err != nil { + return nil, err + } + arts = append(arts, g.artifact("skill", MergeWhole, doc, + ".claude", "skills", s.Dir, "SKILL.md")) + + for _, sup := range s.Support { + supBody, err := packBody(sup.Body) + if err != nil { + return nil, err + } + arts = append(arts, g.artifact("skill-support", MergeWhole, g.rewriteBinary(supBody), + ".claude", "skills", s.Dir, sup.Name)) + } + } + return arts, nil +} + +// renderSkill assembles frontmatter plus body. +func (g *Generator) renderSkill(s skillSpec, body []byte) ([]byte, error) { + if strings.Contains(s.Description, "\n") { + return nil, fmt.Errorf("skill %s: description must be a single line", s.Name) + } + var buf bytes.Buffer + buf.WriteString("---\n") + fmt.Fprintf(&buf, "name: %s\n", s.Name) + // A folded scalar keeps the long description readable in the file while + // remaining one logical line for the skill router. + buf.WriteString("description: >-\n") + for _, line := range wrapText(s.Description, 76) { + fmt.Fprintf(&buf, " %s\n", line) + } + buf.WriteString("license: Apache-2.0\n") + fmt.Fprintf(&buf, "allowed-tools: Bash(%s:*)\n", g.binary) + // Deliberately NO version stamp. Claude Code does not act on `metadata`, and + // embedding the binary version would make every user's committed files stale + // on every release — turning `ai check` into a CI failure and `self update` + // into a diff in everyone's repository. The emitted content depends only on + // the pack and the binary name; TestOutputDoesNotDependOnVersion pins that. + buf.WriteString("---\n\n") + buf.Write(g.rewriteBinary(body)) + return buf.Bytes(), nil +} + +// wrapText greedily wraps a single-line string to a width, for frontmatter +// readability. Deterministic: the same input always yields the same lines. +func wrapText(s string, width int) []string { + words := strings.Fields(s) + if len(words) == 0 { + return nil + } + var ( + lines []string + cur strings.Builder + ) + for _, w := range words { + if cur.Len() > 0 && cur.Len()+1+len(w) > width { + lines = append(lines, cur.String()) + cur.Reset() + } + if cur.Len() > 0 { + cur.WriteByte(' ') + } + cur.WriteString(w) + } + if cur.Len() > 0 { + lines = append(lines, cur.String()) + } + return lines +} + +// SkillDirs returns the skill directory names, for callers that need to describe +// or clean up what was emitted. +func SkillDirs() []string { + out := make([]string, 0, len(skills)) + for _, s := range skills { + out = append(out, s.Dir) + } + return out +} diff --git a/internal/ai/writeio.go b/internal/ai/writeio.go new file mode 100644 index 0000000..74babc7 --- /dev/null +++ b/internal/ai/writeio.go @@ -0,0 +1,367 @@ +package ai + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" +) + +// Fence markers delimiting the devstack-owned block inside a file devstack does +// not own. Kept stable forever — changing them would orphan every block already +// committed in a user's repository. HTML comments so they render invisibly in +// markdown, adapting the /etc/hosts idiom from internal/dns. +const ( + markerBegin = "" + markerEnd = "" +) + +// MergeMode says how Write reconciles an Artifact with what is already on disk. +// +// This is the one real deviation from internal/ide, which only ever writes whole +// files. Two of the three emitted target families land in files the USER owns — +// AGENTS.md, CLAUDE.md and .mcp.json — where clobbering the whole file would +// destroy hand-written content on every regeneration. +type MergeMode int + +const ( + // MergeWhole replaces the file. Only for files devstack fully owns, i.e. + // everything under .claude/skills/devstack*/. + MergeWhole MergeMode = iota + // MergeFence replaces only the marker-fenced block, leaving everything + // outside it untouched. For user-owned markdown. + MergeFence + // MergeJSONKey sets a single key path in a JSON document, preserving every + // other key. For user-owned JSON such as .mcp.json. + MergeJSONKey +) + +// Artifact is one file devstack emits. For MergeFence and MergeJSONKey, Data is +// the BLOCK or the VALUE — not the finished file — because Build must not read +// the disk: keeping Build pure is what makes the golden tests hermetic. +type Artifact struct { + Path string `json:"-"` + Rel string `json:"path"` + Kind string `json:"kind"` + Data []byte `json:"-"` + Merge MergeMode `json:"-"` + JSONPath []string `json:"-"` // MergeJSONKey only, e.g. {"mcpServers", "devstack"} +} + +// WriteResult reports what Write changed for one artifact. +type WriteResult struct { + Path string `json:"path"` + Kind string `json:"kind"` + Changed bool `json:"changed"` +} + +// Write materializes every artifact and reports what changed. Re-running with an +// unchanged pack writes nothing, so `devstack ai install` is safe to run in a +// hook or a loop. +func Write(arts []Artifact) ([]WriteResult, error) { + out := make([]WriteResult, 0, len(arts)) + for _, a := range arts { + // Nothing to do when the artifact's own content already matches. Checking + // first — rather than relying on writeIfChanged's byte compare — is what + // keeps devstack from reformatting a user-owned JSON file whose devstack + // key is already correct. + ok, err := satisfied(a) + if err != nil { + return out, err + } + if ok { + out = append(out, WriteResult{Path: a.Rel, Kind: a.Kind, Changed: false}) + continue + } + want, err := merged(a) + if err != nil { + return out, err + } + changed, err := writeIfChanged(a.Path, want) + if err != nil { + return out, err + } + out = append(out, WriteResult{Path: a.Rel, Kind: a.Kind, Changed: changed}) + } + return out, nil +} + +// UpToDate reports whether every artifact is already satisfied on disk. +func UpToDate(arts []Artifact) (bool, error) { + stale, err := Stale(arts) + return len(stale) == 0, err +} + +// Stale returns the artifacts that are not satisfied, so --check can name them +// instead of just failing. +func Stale(arts []Artifact) ([]Artifact, error) { + var out []Artifact + for _, a := range arts { + ok, err := satisfied(a) + if err != nil { + return nil, err + } + if !ok { + out = append(out, a) + } + } + return out, nil +} + +// satisfied reports whether an artifact's contribution is already present on +// disk. What counts as "present" depends on how much of the file devstack owns. +// +// For MergeWhole and MergeFence devstack owns the bytes it writes (the whole file +// or the fenced block, with everything outside preserved verbatim), so a byte +// comparison of the merged result is exactly right. +// +// For MergeJSONKey devstack owns ONE KEY, not the file's formatting. Comparing +// bytes there would mean any reformat by an editor, a formatter or another tool +// marks the artifact stale forever — and since `ai check` gates CI, a purely +// cosmetic change would fail the build and re-running `ai install` would fight +// the other tool on every commit. So the key is compared semantically, and the +// file is rewritten only when the key's VALUE actually differs. +func satisfied(a Artifact) (bool, error) { + existing, err := os.ReadFile(a.Path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read %s: %w", a.Path, err) + } + if a.Merge == MergeJSONKey { + return jsonKeyMatches(existing, a.JSONPath, a.Data) + } + want, err := merged(a) + if err != nil { + return false, err + } + return bytes.Equal(existing, want), nil +} + +// jsonKeyMatches reports whether the value at path already equals want, compared +// as JSON values rather than as text. +func jsonKeyMatches(existing []byte, path []string, want []byte) (bool, error) { + if len(bytes.TrimSpace(existing)) == 0 { + return false, nil + } + var doc any + if err := json.Unmarshal(existing, &doc); err != nil { + // A malformed file is not "satisfied"; Write surfaces the parse error. + return false, nil + } + cur := doc + for _, key := range path { + obj, ok := cur.(map[string]any) + if !ok { + return false, nil + } + cur, ok = obj[key] + if !ok { + return false, nil + } + } + var wantVal any + if err := json.Unmarshal(want, &wantVal); err != nil { + return false, fmt.Errorf("decode desired value for %s: %w", strings.Join(path, "."), err) + } + return reflect.DeepEqual(cur, wantVal), nil +} + +// merged computes the full file content an artifact should produce, reading the +// current file for the two merge modes that preserve user content. +func merged(a Artifact) ([]byte, error) { + switch a.Merge { + case MergeWhole: + return a.Data, nil + case MergeFence: + existing, err := readFileOrEmpty(a.Path) + if err != nil { + return nil, err + } + return applyFence(existing, a.Data), nil + case MergeJSONKey: + existing, err := readFileOrEmpty(a.Path) + if err != nil { + return nil, err + } + return applyJSONKey(existing, a.JSONPath, a.Data) + } + return nil, fmt.Errorf("artifact %s: unknown merge mode %d", a.Rel, a.Merge) +} + +func readFileOrEmpty(path string) ([]byte, error) { + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + return b, nil +} + +// applyFence replaces the devstack block in existing, or appends one when there +// is none. Content outside the fence is preserved byte for byte. +func applyFence(existing, block []byte) []byte { + fenced := markerBegin + "\n" + strings.TrimRight(string(block), "\n") + "\n" + markerEnd + "\n" + before, after, had := splitFence(string(existing)) + if had { + return []byte(before + fenced + after) + } + if len(existing) == 0 { + return []byte(fenced) + } + // Append, guaranteeing exactly one blank line before the block. + return []byte(strings.TrimRight(string(existing), "\n") + "\n\n" + fenced) +} + +// splitFence returns the content before the begin marker, after the end marker, +// and whether a complete fence was found, so before+block+after round-trips. +// Mirrors internal/dns/hosts.go, which has carried this idiom in production for +// /etc/hosts. +func splitFence(s string) (before, after string, had bool) { + bi := strings.Index(s, markerBegin) + if bi < 0 { + return s, "", false + } + ei := strings.Index(s, markerEnd) + if ei < 0 || ei < bi { + return s, "", false + } + end := ei + len(markerEnd) + if end < len(s) && s[end] == '\n' { + end++ + } + return s[:bi], s[end:], true +} + +// FenceContent returns what is currently inside the devstack block of a file, so +// callers can diff or report it without re-deriving the markers. +func FenceContent(existing []byte) (string, bool) { + s := string(existing) + bi := strings.Index(s, markerBegin) + ei := strings.Index(s, markerEnd) + if bi < 0 || ei < bi { + return "", false + } + return strings.TrimSpace(s[bi+len(markerBegin) : ei]), true +} + +// applyJSONKey sets one key path in a JSON document, preserving every other key +// and its value verbatim. An absent or empty document becomes a new object. +// +// It intentionally re-marshals the whole file, which reformats a user's +// hand-formatting. That is documented, visible through --check, and better than +// the alternative of refusing to touch a file that already exists. +func applyJSONKey(existing []byte, path []string, value []byte) ([]byte, error) { + if len(path) == 0 { + return nil, fmt.Errorf("applyJSONKey: empty key path") + } + root := map[string]json.RawMessage{} + if len(bytes.TrimSpace(existing)) > 0 { + if err := json.Unmarshal(existing, &root); err != nil { + return nil, fmt.Errorf("existing JSON is malformed: %w", err) + } + } + if err := setRawPath(root, path, value); err != nil { + return nil, err + } + return marshalJSON(root) +} + +// setRawPath walks (creating as needed) the object path and sets the leaf. +func setRawPath(node map[string]json.RawMessage, path []string, value []byte) error { + key := path[0] + if len(path) == 1 { + node[key] = json.RawMessage(bytes.TrimSpace(value)) + return nil + } + child := map[string]json.RawMessage{} + if raw, ok := node[key]; ok && len(bytes.TrimSpace(raw)) > 0 { + if err := json.Unmarshal(raw, &child); err != nil { + return fmt.Errorf("existing key %q is not an object: %w", key, err) + } + } + if err := setRawPath(child, path[1:], value); err != nil { + return err + } + encoded, err := json.Marshal(child) + if err != nil { + return err + } + node[key] = encoded + return nil +} + +// marshalJSON renders v as stable, 2-space-indented JSON with a trailing newline. +// HTML escaping is off so URLs survive verbatim; map keys are sorted by +// encoding/json, which is what keeps the output deterministic. +func marshalJSON(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +const tempPrefix = ".devstack-ai-tmp-" + +// writeIfChanged writes data to path only when the on-disk content differs, +// returning whether a write occurred. The write is atomic: a temp file in the +// same directory is fsync'd, chmod'd, then renamed over the target, so a crash +// leaves either the old file or the new one, never a half-written one. +func writeIfChanged(path string, data []byte) (bool, error) { + if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, data) { + return false, nil + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return false, fmt.Errorf("create %s: %w", dir, err) + } + sweepTemp(dir) + tmp, err := os.CreateTemp(dir, tempPrefix+"*") + if err != nil { + return false, fmt.Errorf("create temp in %s: %w", dir, err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return false, fmt.Errorf("write temp: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return false, fmt.Errorf("sync temp: %w", err) + } + if err := tmp.Close(); err != nil { + return false, fmt.Errorf("close temp: %w", err) + } + if err := os.Chmod(tmpName, 0o644); err != nil { + return false, fmt.Errorf("chmod temp: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return false, fmt.Errorf("rename %s -> %s: %w", tmpName, path, err) + } + return true, nil +} + +// sweepTemp removes stale temp files a previously-killed run left in dir. +func sweepTemp(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if !e.IsDir() && strings.HasPrefix(e.Name(), tempPrefix) { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + } +} diff --git a/internal/aidocs/aidocs.go b/internal/aidocs/aidocs.go new file mode 100644 index 0000000..d8558f9 --- /dev/null +++ b/internal/aidocs/aidocs.go @@ -0,0 +1,317 @@ +// Package aidocs indexes the embedded documentation corpus (docs.FS) for machine +// consumption: a stable slug per document, a title and summary lifted from the +// markdown, full-text search, and the raw body (spec 32). +// +// It is the single retrieval layer behind all three agent surfaces — the +// `devstack ai docs` CLI for agents that only have a shell, the devstack://docs/… +// MCP resources, and the slug index the emitted skills point at. Keeping one +// layer is what lets the emitted skills stay tiny: they carry navigation, not +// copies of the documentation, so an upgraded binary serves upgraded docs with no +// churn in the user's repo. +// +// Pure and read-only: no Docker, no ledger, no flock, no filesystem access beyond +// the embedded FS. Every listing is deterministically ordered. +package aidocs + +import ( + "fmt" + "io/fs" + "path" + "sort" + "strings" + "sync" + + "github.com/open-source-cloud/devstack/docs" +) + +// Section groups the corpus by the role a document plays. +type Section string + +// The corpus sections, in the order List reports them: the task-oriented book +// first (what a newcomer or an agent should read), then the top-level design +// documents, then the per-component specs. +const ( + SectionGuide Section = "guide" // docs/guide/** — the task-oriented book + SectionRoot Section = "root" // docs/*.md — ARCHITECTURE, DECISIONS, ROADMAP, … + SectionSpec Section = "specs" // docs/specs/** — the per-component specs +) + +var sectionRank = map[Section]int{SectionGuide: 0, SectionRoot: 1, SectionSpec: 2} + +// Doc is one indexed document. Body is not included: callers that want the text +// call Read, so listing the whole corpus stays cheap. +type Doc struct { + Slug string `json:"slug"` // stable retrieval key, e.g. guide/templates + Path string `json:"path"` // repo-relative path, e.g. docs/guide/templates.md + Title string `json:"title"` // first H1, or a humanized file name + Summary string `json:"summary"` // first prose paragraph, single-line + Section Section `json:"section"` + Lines int `json:"lines"` +} + +// index is the lazily-built corpus index. The embedded FS is immutable, so it is +// built exactly once per process. +var index struct { + once sync.Once + docs []Doc + bySlug map[string]Doc + err error +} + +func build() { + index.bySlug = make(map[string]Doc) + err := fs.WalkDir(docs.FS, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(p, ".md") { + return nil + } + body, err := docs.FS.ReadFile(p) + if err != nil { + return fmt.Errorf("read %s: %w", p, err) + } + doc := describe(p, body) + index.docs = append(index.docs, doc) + index.bySlug[doc.Slug] = doc + for _, alias := range aliases(doc) { + // A real slug always wins over an alias, so a future docs/specs/23.md + // could never be shadowed by the 23-… alias. + if _, taken := index.bySlug[alias]; !taken { + index.bySlug[alias] = doc + } + } + return nil + }) + if err != nil { + index.err = err + return + } + sort.Slice(index.docs, func(i, j int) bool { + a, b := index.docs[i], index.docs[j] + if ra, rb := sectionRank[a.Section], sectionRank[b.Section]; ra != rb { + return ra < rb + } + return a.Slug < b.Slug + }) +} + +func load() error { + index.once.Do(build) + return index.err +} + +// describe derives a Doc from one corpus file. +func describe(p string, body []byte) Doc { + slug := strings.TrimSuffix(p, ".md") + section := SectionRoot + switch { + case strings.HasPrefix(p, "guide/"): + section = SectionGuide + case strings.HasPrefix(p, "specs/"): + section = SectionSpec + default: + // Top-level docs are ALL-CAPS on disk (ARCHITECTURE.md); lowercase the + // slug so retrieval is not shift-key-sensitive. + slug = strings.ToLower(slug) + } + title, summary := titleAndSummary(body) + if title == "" { + title = humanize(path.Base(slug)) + } + return Doc{ + Slug: slug, + Path: "docs/" + p, + Title: title, + Summary: summary, + Section: section, + Lines: countLines(body), + } +} + +// aliases returns extra retrieval keys for a document. Specs get their number +// ("specs/23", "23") because that is how every cross-reference in the corpus and +// in CLAUDE.md names them. +func aliases(d Doc) []string { + if d.Section != SectionSpec { + return nil + } + num, _, ok := strings.Cut(path.Base(d.Slug), "-") + if !ok || num == "" { + return nil + } + return []string{"specs/" + num, num} +} + +// titleAndSummary lifts the first H1 and the first prose paragraph after it, +// skipping the badge/blockquote/link furniture the docs open with. +func titleAndSummary(body []byte) (title, summary string) { + var para []string + inFence := false + for _, raw := range strings.Split(string(body), "\n") { + line := strings.TrimSpace(raw) + if strings.HasPrefix(line, "```") { + inFence = !inFence + continue + } + if inFence { + continue + } + if title == "" { + if strings.HasPrefix(line, "# ") { + title = strings.TrimSpace(strings.TrimPrefix(line, "# ")) + } + continue + } + if isFurniture(line) { + if len(para) > 0 { + break + } + continue + } + if line == "" { + if len(para) > 0 { + break + } + continue + } + para = append(para, line) + } + return title, cleanSummary(strings.Join(para, " ")) +} + +// isFurniture reports whether a line is navigation, a badge row, an HTML wrapper, +// a heading, a blockquote callout or a horizontal rule — never prose. +func isFurniture(line string) bool { + switch { + case line == "---", line == "***", line == "___": + return true + case strings.HasPrefix(line, "#"), strings.HasPrefix(line, ">"): + return true + case strings.HasPrefix(line, "<"): + return true + case strings.HasPrefix(line, "|"), strings.HasPrefix(line, "["), strings.HasPrefix(line, "!["): + return true + case strings.HasPrefix(line, "* "), strings.HasPrefix(line, "- "), strings.HasPrefix(line, "* "): + return true + } + return false +} + +// cleanSummary strips inline markdown emphasis and link syntax so the summary +// reads as plain text in a table or a JSON field, and caps it to one line. +func cleanSummary(s string) string { + s = strings.NewReplacer("**", "", "`", "", "__", "").Replace(s) + // [text](url) → text + for { + open := strings.Index(s, "](") + if open < 0 { + break + } + start := strings.LastIndex(s[:open], "[") + end := strings.Index(s[open:], ")") + if start < 0 || end < 0 { + break + } + s = s[:start] + s[start+1:open] + s[open+end+1:] + } + s = strings.Join(strings.Fields(s), " ") + const max = 240 + if len(s) > max { + if cut := strings.LastIndex(s[:max], " "); cut > 0 { + return s[:cut] + "…" + } + return s[:max] + "…" + } + return s +} + +func humanize(base string) string { + return strings.ToUpper(base[:1]) + strings.ReplaceAll(base[1:], "-", " ") +} + +func countLines(body []byte) int { + n := strings.Count(string(body), "\n") + if len(body) > 0 && !strings.HasSuffix(string(body), "\n") { + n++ + } + return n +} + +// List returns every indexed document in stable order: the guide first, then the +// top-level design docs, then the specs. +func List() ([]Doc, error) { + if err := load(); err != nil { + return nil, err + } + out := make([]Doc, len(index.docs)) + copy(out, index.docs) + return out, nil +} + +// Lookup resolves a slug (or a spec-number alias) to its Doc. +func Lookup(slug string) (Doc, error) { + if err := load(); err != nil { + return Doc{}, err + } + if d, ok := index.bySlug[normalizeSlug(slug)]; ok { + return d, nil + } + return Doc{}, notFound(slug) +} + +// Read returns a document's raw markdown. +func Read(slug string) (Doc, []byte, error) { + d, err := Lookup(slug) + if err != nil { + return Doc{}, nil, err + } + body, err := docs.FS.ReadFile(strings.TrimPrefix(d.Path, "docs/")) + if err != nil { + return Doc{}, nil, fmt.Errorf("read %s: %w", d.Path, err) + } + return d, body, nil +} + +// normalizeSlug accepts the forms a human or a model is likely to type: a bare +// slug, the repo-relative path, a trailing .md, or a leading slash. +func normalizeSlug(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "./") + s = strings.TrimPrefix(s, "/") + s = strings.TrimPrefix(s, "docs/") + s = strings.TrimSuffix(s, ".md") + if !strings.Contains(s, "/") { + // Top-level docs are indexed lowercase; specs and guide pages already are. + return strings.ToLower(s) + } + return s +} + +// notFound builds an error that names the closest slugs, so a wrong guess costs +// one round trip instead of a listing call. +func notFound(slug string) error { + near := suggest(slug, 5) + if len(near) == 0 { + return fmt.Errorf("no such doc %q (run `devstack ai docs` to list them)", slug) + } + return fmt.Errorf("no such doc %q (did you mean: %s?)", slug, strings.Join(near, ", ")) +} + +// suggest returns slugs sharing a substring with the query, closest first. +func suggest(slug string, limit int) []string { + q := strings.ToLower(normalizeSlug(slug)) + if q == "" { + return nil + } + var out []string + for _, d := range index.docs { + if strings.Contains(strings.ToLower(d.Slug), q) || strings.Contains(q, path.Base(d.Slug)) { + out = append(out, d.Slug) + if len(out) == limit { + break + } + } + } + return out +} diff --git a/internal/aidocs/aidocs_test.go b/internal/aidocs/aidocs_test.go new file mode 100644 index 0000000..3579f83 --- /dev/null +++ b/internal/aidocs/aidocs_test.go @@ -0,0 +1,189 @@ +package aidocs + +import ( + "strings" + "testing" +) + +func TestListCoversTheWholeCorpus(t *testing.T) { + got, err := List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) < 60 { + t.Fatalf("expected the full corpus (66 files at time of writing), got %d", len(got)) + } + var guide, root, spec int + for _, d := range got { + switch d.Section { + case SectionGuide: + guide++ + case SectionRoot: + root++ + case SectionSpec: + spec++ + default: + t.Errorf("%s has an unknown section %q", d.Slug, d.Section) + } + if d.Title == "" { + t.Errorf("%s has no title", d.Slug) + } + if d.Lines == 0 { + t.Errorf("%s reports 0 lines", d.Slug) + } + if !strings.HasPrefix(d.Path, "docs/") || !strings.HasSuffix(d.Path, ".md") { + t.Errorf("%s has a malformed path %q", d.Slug, d.Path) + } + } + if guide == 0 || root == 0 || spec == 0 { + t.Errorf("expected all three sections populated; guide=%d root=%d specs=%d", guide, root, spec) + } +} + +func TestListIsDeterministicAndSectionOrdered(t *testing.T) { + first, err := List() + if err != nil { + t.Fatalf("List: %v", err) + } + second, _ := List() + for i := range first { + if first[i].Slug != second[i].Slug { + t.Fatalf("List is not deterministic at %d: %q vs %q", i, first[i].Slug, second[i].Slug) + } + } + // The guide must come first — it is what a cold agent should read. + if first[0].Section != SectionGuide { + t.Errorf("first doc is %s (%s), want a guide page", first[0].Slug, first[0].Section) + } + lastRank := -1 + for _, d := range first { + r := sectionRank[d.Section] + if r < lastRank { + t.Fatalf("section order broken at %s", d.Slug) + } + lastRank = r + } +} + +func TestLookupAcceptsTheFormsAModelWillType(t *testing.T) { + for _, in := range []string{ + "guide/templates", + "guide/templates.md", + "docs/guide/templates.md", + "/docs/guide/templates", + "./guide/templates", + } { + d, err := Lookup(in) + if err != nil { + t.Errorf("Lookup(%q): %v", in, err) + continue + } + if d.Slug != "guide/templates" { + t.Errorf("Lookup(%q) = %q, want guide/templates", in, d.Slug) + } + } + // Top-level docs are ALL-CAPS on disk but must resolve lowercase. + for _, in := range []string{"architecture", "ARCHITECTURE", "ARCHITECTURE.md"} { + if d, err := Lookup(in); err != nil || d.Slug != "architecture" { + t.Errorf("Lookup(%q) = %q, %v; want architecture", in, d.Slug, err) + } + } +} + +func TestSpecNumberAliases(t *testing.T) { + // Specs are cross-referenced by number everywhere in the corpus, so the + // number alone must resolve. + for _, in := range []string{"23", "specs/23", "specs/23-template-authoring"} { + d, err := Lookup(in) + if err != nil { + t.Fatalf("Lookup(%q): %v", in, err) + } + if !strings.HasPrefix(d.Slug, "specs/23-") { + t.Errorf("Lookup(%q) = %q, want the spec 23 page", in, d.Slug) + } + } +} + +func TestLookupUnknownSuggests(t *testing.T) { + _, err := Lookup("guide/template") + if err == nil { + t.Fatal("expected an error for an unknown slug") + } + if !strings.Contains(err.Error(), "guide/templates") { + t.Errorf("error should suggest the near miss, got: %v", err) + } +} + +func TestReadReturnsRealMarkdown(t *testing.T) { + d, body, err := Read("guide/templates") + if err != nil { + t.Fatalf("Read: %v", err) + } + if !strings.Contains(string(body), "[[") { + t.Error("the templates guide should document the [[ ]] delimiters") + } + if d.Lines != countLines(body) { + t.Errorf("indexed line count %d != actual %d", d.Lines, countLines(body)) + } +} + +func TestSearchNarrowsWithEveryTerm(t *testing.T) { + broad, err := Search("template", 50) + if err != nil { + t.Fatalf("Search: %v", err) + } + narrow, err := Search("template lint golden", 50) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(broad) == 0 || len(narrow) == 0 { + t.Fatalf("expected hits for both queries; broad=%d narrow=%d", len(broad), len(narrow)) + } + if len(narrow) >= len(broad) { + t.Errorf("adding terms should narrow: broad=%d narrow=%d", len(broad), len(narrow)) + } + for _, h := range narrow { + if len(h.Matches) == 0 { + t.Errorf("%s matched but returned no excerpt", h.Doc.Slug) + } + if len(h.Matches) > maxMatchesPerDoc { + t.Errorf("%s returned %d excerpts, cap is %d", h.Doc.Slug, len(h.Matches), maxMatchesPerDoc) + } + } +} + +func TestSearchRanksTheObviousPageFirst(t *testing.T) { + hits, err := Search("template authoring", 5) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(hits) == 0 { + t.Fatal("expected hits") + } + if !strings.Contains(hits[0].Doc.Slug, "template") { + t.Errorf("top hit for %q is %q; expected a templates page", "template authoring", hits[0].Doc.Slug) + } +} + +func TestSearchIsDeterministic(t *testing.T) { + a, _ := Search("shared postgres", 10) + b, _ := Search("shared postgres", 10) + if len(a) != len(b) { + t.Fatalf("non-deterministic result count: %d vs %d", len(a), len(b)) + } + for i := range a { + if a[i].Doc.Slug != b[i].Doc.Slug || a[i].Score != b[i].Score { + t.Fatalf("non-deterministic at %d: %v vs %v", i, a[i].Doc.Slug, b[i].Doc.Slug) + } + } +} + +func TestSearchEmptyQuery(t *testing.T) { + hits, err := Search(" ", 10) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(hits) != 0 { + t.Errorf("empty query should return nothing, got %d", len(hits)) + } +} diff --git a/internal/aidocs/links_test.go b/internal/aidocs/links_test.go new file mode 100644 index 0000000..aa50356 --- /dev/null +++ b/internal/aidocs/links_test.go @@ -0,0 +1,122 @@ +package aidocs + +import ( + "os" + "path" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// mdLinkRE matches inline markdown links: [text](target). Reference-style links +// and bare URLs are deliberately out of scope. +var mdLinkRE = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)`) + +// repoRoot locates the repository root from the package directory, or returns "" +// when the test is not running inside the source tree. +func repoRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + return "" + } + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + return "" + } + return root +} + +// TestCorpusLinksResolve walks every embedded document and checks that each +// relative link points at something that exists. +// +// This is the test that pays for compiling docs/ into the binary. Documentation +// used to be a build-time no-op, so a broken cross-link was cosmetic; now the +// corpus ships inside the binary and is what `devstack ai docs` and the MCP +// resources serve, so a dangling link is a product defect an agent will follow. +// It is also why .github/workflows/ci.yml must not skip checks for doc-only +// changes. +func TestCorpusLinksResolve(t *testing.T) { + root := repoRoot(t) + list, err := List() + if err != nil { + t.Fatalf("List: %v", err) + } + var checked int + for _, d := range list { + _, body, err := Read(d.Slug) + if err != nil { + t.Fatalf("Read(%s): %v", d.Slug, err) + } + // Directory of this doc, repo-relative: docs/guide for docs/guide/x.md. + docDir := path.Dir(d.Path) + for _, m := range mdLinkRE.FindAllStringSubmatch(string(body), -1) { + target := m[1] + if skipLink(target) { + continue + } + // Drop any #anchor; we verify the file, not the heading. + file, _, _ := strings.Cut(target, "#") + if file == "" { + continue // pure in-page anchor + } + resolved := path.Clean(path.Join(docDir, file)) + checked++ + if err := existsInRepo(root, resolved); err != nil { + t.Errorf("%s links to %q which %v", d.Path, target, err) + } + } + } + if checked < 100 { + t.Fatalf("only checked %d links; the corpus is heavily cross-linked, so the matcher is probably broken", checked) + } + t.Logf("checked %d relative links across %d documents", checked, len(list)) +} + +// skipLink filters out targets this test cannot or should not resolve. +func skipLink(target string) bool { + switch { + case target == "": + return true + case strings.HasPrefix(target, "#"): // in-page anchor + return true + case strings.HasPrefix(target, "mailto:"): + return true + case strings.Contains(target, "://"): // absolute URL + return true + case strings.HasPrefix(target, "<"): // autolink / template placeholder + return true + } + return false +} + +// existsInRepo checks a repo-relative path: inside docs/ it must be in the +// EMBEDDED corpus (a link the binary itself will serve), anywhere else it only +// has to exist on disk. +func existsInRepo(root, rel string) error { + if strings.HasPrefix(rel, "docs/") { + inCorpus := strings.TrimPrefix(rel, "docs/") + if strings.HasSuffix(inCorpus, ".md") { + if _, err := Lookup(strings.TrimSuffix(inCorpus, ".md")); err != nil { + return errNotEmbedded + } + return nil + } + } + if root == "" { + return nil // not running in the source tree; nothing to check against + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil { + return errMissing + } + return nil +} + +type linkErr string + +func (e linkErr) Error() string { return string(e) } + +const ( + errNotEmbedded linkErr = "is under docs/ but is not in the embedded corpus" + errMissing linkErr = "does not exist in the repository" +) diff --git a/internal/aidocs/search.go b/internal/aidocs/search.go new file mode 100644 index 0000000..da49bda --- /dev/null +++ b/internal/aidocs/search.go @@ -0,0 +1,124 @@ +package aidocs + +import ( + "sort" + "strings" +) + +// Hit is one search result: the document plus the matching lines that justify it, +// so a caller can decide whether to spend context on the full document. +type Hit struct { + Doc Doc `json:"doc"` + Score int `json:"score"` + Matches []string `json:"matches"` +} + +// maxMatchesPerDoc caps the excerpt list so a query matching a large reference +// page cannot flood a model's context. +const maxMatchesPerDoc = 4 + +// Search scores every document against a whitespace-separated query and returns +// the best matches, highest first. Scoring is deliberately simple and +// deterministic — a title hit outweighs a summary hit, which outweighs body hits, +// and ties break on slug — because the corpus is small enough that ranking +// sophistication buys nothing and non-determinism would break golden tests. +// +// A document must match EVERY term to be returned, so adding a term always +// narrows: "template lint" finds the lint section of the templates guide rather +// than every page mentioning templates. +func Search(query string, limit int) ([]Hit, error) { + if err := load(); err != nil { + return nil, err + } + terms := tokenize(query) + if len(terms) == 0 { + return nil, nil + } + var hits []Hit + for _, d := range index.docs { + _, body, err := Read(d.Slug) + if err != nil { + return nil, err + } + if h, ok := score(d, string(body), terms); ok { + hits = append(hits, h) + } + } + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].Score != hits[j].Score { + return hits[i].Score > hits[j].Score + } + if ri, rj := sectionRank[hits[i].Doc.Section], sectionRank[hits[j].Doc.Section]; ri != rj { + return ri < rj + } + return hits[i].Doc.Slug < hits[j].Doc.Slug + }) + if limit > 0 && len(hits) > limit { + hits = hits[:limit] + } + return hits, nil +} + +// score rates one document, returning ok=false unless every term appears. +func score(d Doc, body string, terms []string) (Hit, bool) { + lowTitle := strings.ToLower(d.Title) + lowSlug := strings.ToLower(d.Slug) + lowSummary := strings.ToLower(d.Summary) + lowBody := strings.ToLower(body) + + total := 0 + for _, term := range terms { + n := strings.Count(lowBody, term) + termScore := 0 + if strings.Contains(lowSlug, term) { + termScore += 40 + } + if strings.Contains(lowTitle, term) { + termScore += 25 + } + if strings.Contains(lowSummary, term) { + termScore += 10 + } + termScore += min(n, 10) + if termScore == 0 { + return Hit{}, false + } + total += termScore + } + return Hit{Doc: d, Score: total, Matches: excerpts(body, terms)}, true +} + +// excerpts returns the first few trimmed lines containing any term, so the caller +// sees why a document matched. +func excerpts(body string, terms []string) []string { + var out []string + for _, raw := range strings.Split(body, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "```") { + continue + } + low := strings.ToLower(line) + for _, term := range terms { + if strings.Contains(low, term) { + out = append(out, cleanSummary(line)) + break + } + } + if len(out) == maxMatchesPerDoc { + break + } + } + return out +} + +// tokenize lowercases and splits a query, dropping punctuation-only fragments. +func tokenize(q string) []string { + var out []string + for _, f := range strings.Fields(strings.ToLower(q)) { + f = strings.Trim(f, ".,:;!?\"'()[]{}") + if f != "" { + out = append(out, f) + } + } + return out +} diff --git a/internal/cli/ai.go b/internal/cli/ai.go new file mode 100644 index 0000000..5facd58 --- /dev/null +++ b/internal/cli/ai.go @@ -0,0 +1,174 @@ +package cli + +import ( + "fmt" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/aidocs" +) + +// newAiCmd wires `devstack ai` — the AI-agent integration surface (spec 32). +// +// The group serves one embedded corpus through the surfaces different agents can +// reach: `ai docs` for an agent that only has a shell, `ai mcp` for MCP clients, +// and `ai install` for the skills/AGENTS.md files a repo commits. Everything here +// is read-only with respect to the ledger and the shared stack; nothing in this +// group takes the flock. +func newAiCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "ai", + Short: "Teach AI coding agents to use devstack (docs, MCP, skills)", + Long: "ai exposes devstack to AI coding agents.\n\n" + + " docs read the documentation corpus compiled into this binary\n" + + " commands the whole command surface as machine-readable data\n\n" + + "The corpus is the same markdown that renders in the repository, so an agent\n" + + "reads exactly what a human would — there is no separately-authored, separately-\n" + + "drifting set of \"agent docs\".", + } + cmd.AddCommand(newAiInstallCmd(g), newAiCheckCmd(g), newAiMcpCmd(g), newAiDocsCmd(g), newAiCommandsCmd(g)) + return cmd +} + +// newAiDocsCmd wires `ai docs` — list the corpus, print one document, or search. +// +// This is the retrieval channel for agents that have a shell but no MCP client, +// which is most of them. It deliberately prints raw markdown: the caller is a +// model, and markdown is what it reads best. +func newAiDocsCmd(g *GlobalOpts) *cobra.Command { + var ( + search string + section string + limit int + ) + cmd := &cobra.Command{ + Use: "docs [slug]", + Short: "Read devstack's documentation from inside the binary", + Long: "docs lists the embedded documentation corpus, prints one document, or searches it.\n\n" + + " devstack ai docs list every document with a one-line summary\n" + + " devstack ai docs guide/templates print that document as markdown\n" + + " devstack ai docs 23 specs are addressable by number\n" + + " devstack ai docs --search \"lint\" search titles and bodies\n\n" + + "Slugs mirror the repository layout: guide/, specs/-, and the\n" + + "lowercased name of each top-level document (architecture, decisions, roadmap…).", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + switch { + case len(args) == 1: + return runAiDocsShow(cmd, g, args[0]) + case search != "": + return runAiDocsSearch(cmd, g, search, limit) + default: + return runAiDocsList(cmd, g, section) + } + }, + } + cmd.Flags().StringVar(&search, "search", "", "search titles and bodies instead of listing") + cmd.Flags().StringVar(§ion, "section", "", "limit the listing to one section (guide|root|specs)") + cmd.Flags().IntVar(&limit, "limit", 10, "maximum search results") + return cmd +} + +func runAiDocsShow(cmd *cobra.Command, g *GlobalOpts, slug string) error { + doc, body, err := aidocs.Read(slug) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"doc": doc, "body": string(body)}) + } + if g.Quiet { + return nil + } + _, err = cmd.OutOrStdout().Write(body) + return err +} + +func runAiDocsList(cmd *cobra.Command, g *GlobalOpts, section string) error { + list, err := aidocs.List() + if err != nil { + return err + } + if section != "" { + want := aidocs.Section(strings.ToLower(section)) + filtered := list[:0:0] + for _, d := range list { + if d.Section == want { + filtered = append(filtered, d) + } + } + if len(filtered) == 0 { + return fmt.Errorf("unknown section %q (available: guide, root, specs)", section) + } + list = filtered + } + if g.JSON { + return writeJSON(cmd, map[string]any{"docs": list}) + } + if g.Quiet { + for _, d := range list { + fmt.Fprintln(cmd.OutOrStdout(), d.Slug) + } + return nil + } + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + var current aidocs.Section + for _, d := range list { + if d.Section != current { + if current != "" { + fmt.Fprintln(w) + } + current = d.Section + fmt.Fprintf(w, "%s\n", strings.ToUpper(string(current))) + } + fmt.Fprintf(w, " %s\t%s\n", d.Slug, truncate(d.Title, 60)) + } + if err := w.Flush(); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "\n%d documents. Read one with `%s ai docs `.\n", + len(list), rootName(cmd)) + return nil +} + +func runAiDocsSearch(cmd *cobra.Command, g *GlobalOpts, query string, limit int) error { + hits, err := aidocs.Search(query, limit) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"query": query, "hits": hits}) + } + if g.Quiet { + for _, h := range hits { + fmt.Fprintln(cmd.OutOrStdout(), h.Doc.Slug) + } + return nil + } + out := cmd.OutOrStdout() + if len(hits) == 0 { + fmt.Fprintf(out, "no documents match %q\n", query) + return nil + } + for _, h := range hits { + fmt.Fprintf(out, "%s — %s\n", h.Doc.Slug, h.Doc.Title) + for _, m := range h.Matches { + fmt.Fprintf(out, " %s\n", truncate(m, 100)) + } + fmt.Fprintln(out) + } + fmt.Fprintf(out, "Read one with `%s ai docs `.\n", rootName(cmd)) + return nil +} + +// truncate shortens a string for column output, using a single-rune ellipsis so +// the width math stays predictable. +func truncate(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max-1]) + "…" +} diff --git a/internal/cli/ai_commands.go b/internal/cli/ai_commands.go new file mode 100644 index 0000000..8335c05 --- /dev/null +++ b/internal/cli/ai_commands.go @@ -0,0 +1,89 @@ +package cli + +import ( + "fmt" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/ai" + "github.com/open-source-cloud/devstack/internal/version" +) + +// newAiCommandsCmd wires `ai commands` — the whole command surface as data. +// +// It is derived from the live cobra tree at call time, so it can never describe a +// verb this binary does not have. That is deliberately the opposite of +// docs/guide/command-reference.md, which is hand-maintained; a drift test keeps +// the two honest against each other. +func newAiCommandsCmd(g *GlobalOpts) *cobra.Command { + var runnableOnly bool + cmd := &cobra.Command{ + Use: "commands", + Short: "List every command as machine-readable data", + Long: "commands walks this binary's command tree and prints it as data: every path,\n" + + "its summary, argument spec and local flags, plus the global flags once.\n\n" + + "Derived from the live tree, so it always matches the binary you are running —\n" + + "use it instead of scraping --help.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cat := ai.Catalog(cmd.Root(), version.Version) + if runnableOnly { + kept := cat.Commands[:0:0] + for _, c := range cat.Commands { + if c.Runnable { + kept = append(kept, c) + } + } + cat.Commands = kept + } + if g.JSON { + return writeJSON(cmd, cat) + } + if g.Quiet { + for _, c := range cat.Commands { + fmt.Fprintln(cmd.OutOrStdout(), c.Path) + } + return nil + } + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + for _, c := range cat.Commands { + name := c.Path + if c.Args != "" { + name += " " + c.Args + } + fmt.Fprintf(w, " %s\t%s\n", name, truncate(c.Short, 70)) + } + if err := w.Flush(); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "\n%d commands. Full detail: `%s --json ai commands`.\n", + len(cat.Commands), rootName(cmd)) + return nil + }, + } + cmd.Flags().BoolVar(&runnableOnly, "runnable", false, "omit group commands that only hold subcommands") + return cmd +} + +// aiCommandPaths returns the set of invocable command paths in a tree, including +// alias spellings, so a documentation drift test can check names in both +// directions without re-walking cobra itself. +func aiCommandPaths(root *cobra.Command, runnableOnly bool) map[string]bool { + out := map[string]bool{} + for _, c := range ai.Catalog(root, "").Commands { + if runnableOnly && !c.Runnable { + continue + } + out[c.Path] = true + // An alias is invocable too, so a doc naming one is not drift. + segs := strings.Split(c.Path, " ") + for _, a := range c.Aliases { + aliased := append([]string{}, segs...) + aliased[len(aliased)-1] = a + out[strings.Join(aliased, " ")] = true + } + } + return out +} diff --git a/internal/cli/ai_commands_test.go b/internal/cli/ai_commands_test.go new file mode 100644 index 0000000..1a0dc7f --- /dev/null +++ b/internal/cli/ai_commands_test.go @@ -0,0 +1,268 @@ +package cli + +import ( + "bytes" + "encoding/json" + "regexp" + "sort" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/aidocs" +) + +func TestAiCommandsCatalogMatchesTheTree(t *testing.T) { + root := NewRootCmd(Options{}) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--json", "ai", "commands"}) + if err := root.Execute(); err != nil { + t.Fatalf("ai commands --json: %v\n%s", err, out.String()) + } + var cat struct { + Binary string `json:"binary"` + GlobalFlags []struct { + Name string `json:"name"` + } `json:"globalFlags"` + Commands []struct { + Path string `json:"path"` + Short string `json:"short"` + Group bool `json:"group"` + Runnable bool `json:"runnable"` + } `json:"commands"` + } + if err := json.Unmarshal(out.Bytes(), &cat); err != nil { + t.Fatalf("not JSON: %v\n%s", err, out.String()) + } + if cat.Binary != "devstack" { + t.Errorf("binary = %q, want devstack", cat.Binary) + } + // The four documented global flags must be reported once, at the top level, + // rather than repeated on all 100+ commands. + global := map[string]bool{} + for _, f := range cat.GlobalFlags { + global[f.Name] = true + } + for _, want := range []string{"json", "quiet", "debug", "verbose"} { + if !global[want] { + t.Errorf("global flag --%s missing from the catalog", want) + } + } + paths := map[string]bool{} + for _, c := range cat.Commands { + paths[c.Path] = true + if c.Short == "" { + t.Errorf("%s has no Short summary", c.Path) + } + } + // Spot-check breadth and depth, including a three-level path. + for _, want := range []string{"up", "db", "db user create", "template lint", "ai docs", "config schema"} { + if !paths[want] { + t.Errorf("catalog is missing %q", want) + } + } + if len(cat.Commands) < 80 { + t.Errorf("expected the full tree (39 top-level plus subcommands), got %d", len(cat.Commands)) + } +} + +func TestAiCommandsIsDeterministic(t *testing.T) { + run := func() string { + root := NewRootCmd(Options{}) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--json", "ai", "commands"}) + if err := root.Execute(); err != nil { + t.Fatalf("ai commands: %v", err) + } + return out.String() + } + if a, b := run(), run(); a != b { + t.Error("ai commands --json is not byte-deterministic") + } +} + +// docCommandRE pulls every backticked span out of a line. +var docCommandRE = regexp.MustCompile("`([^`]+)`") + +// argTokenRE matches the argument/flag furniture that follows a command name. +var argTokenRE = regexp.MustCompile(`^([<\[]|--|-\w)`) + +// TestCommandReferenceMatchesTheTree retires docs/guide/command-reference.md as a +// standing drift source. It is hand-maintained, so nothing previously stopped it +// naming a verb that no longer exists, or omitting one that does. Renaming a +// command now fails here — in the PR that renamed it. +// +// The parser follows the conventions the reference actually uses: a table row +// names commands in its first cell; a row may abbreviate siblings as +// `dns setup` / `status` / `remove`, where the bare names inherit the preceding +// group; and a prose or blockquote line naming a command counts as documenting it +// (that is how the top-level expose/ports aliases are covered). +func TestCommandReferenceMatchesTheTree(t *testing.T) { + _, body, err := aidocs.Read("guide/command-reference") + if err != nil { + t.Fatalf("read command-reference: %v", err) + } + root := NewRootCmd(Options{}) + real := aiCommandPaths(root, false) + documented := map[string]bool{} + + for _, raw := range strings.Split(string(body), "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + var group string + for _, m := range docCommandRE.FindAllStringSubmatch(cellOf(line), -1) { + path, prefix := commandPathFrom(m[1], group, real) + if path == "" { + continue + } + documented[path] = true + if prefix != "" { + group = prefix + } + } + } + + if len(documented) < 60 { + t.Fatalf("only parsed %d commands out of the reference; the matcher is broken", len(documented)) + } + + // Forward: every invocable command must appear somewhere in the reference. + var undocumented []string + for c := range aiCommandPaths(root, true) { + if !documented[c] && !exemptFromReference(c) { + undocumented = append(undocumented, c) + } + } + sort.Strings(undocumented) + for _, c := range undocumented { + t.Errorf("command %q is not documented in docs/guide/command-reference.md", c) + } + + // Reverse: the reference must not name a command that no longer exists. + // commandPathFrom only ever yields real paths, so a stale name simply fails to + // resolve; assert that every command-shaped row resolves at least one. + for _, raw := range strings.Split(string(body), "\n") { + line := strings.TrimSpace(raw) + if !strings.HasPrefix(line, "| `") { + continue + } + cell := cellOf(line) + spans := docCommandRE.FindAllStringSubmatch(cell, -1) + if len(spans) == 0 { + continue + } + var group string + resolved := false + for _, m := range spans { + if p, prefix := commandPathFrom(m[1], group, real); p != "" { + resolved = true + if prefix != "" { + group = prefix + } + } + } + if !resolved && !rowIsFangBuiltins(spans) { + t.Errorf("command-reference row names a command that does not exist: %s", strings.TrimSpace(cell)) + } + } +} + +// cellOf returns the first table cell of a row, or the whole line when it is not +// a table row (so prose and blockquotes are scanned too). +func cellOf(line string) string { + if !strings.HasPrefix(line, "|") { + return line + } + // A cell may contain an escaped pipe, as in `s3 versioning on\|off`. + // Protect those before splitting on the real column separators. + const esc = "\x00PIPE\x00" + protected := strings.ReplaceAll(line, `\|`, esc) + cells := strings.Split(protected, "|") + if len(cells) < 3 { + return "" + } + return strings.ReplaceAll(cells[1], esc, "|") +} + +// commandPathFrom turns a backticked span into the longest prefix that is a real +// command path. group carries the last resolved group so a bare sibling name +// resolves: in `dns setup` / `status` / `remove`, "status" must read as +// "dns status" and NOT as the unrelated top-level "status" — which is why the +// group-qualified reading is tried first. +func commandPathFrom(span, group string, real map[string]bool) (path, prefix string) { + fields := strings.Fields(strings.TrimSpace(span)) + if len(fields) == 0 { + return "", "" + } + // Both readings can resolve: for `project new ` under group "project", + // the qualified reading degrades to the bare group "project" while the + // standalone reading gives the better "project new". Take whichever names + // more segments, so the most specific command always wins. + standalone := longestRealPrefix(fields, real) + qualified := "" + if group != "" { + qualified = longestRealPrefix(append(strings.Fields(group), fields...), real) + } + if segments(qualified) > segments(standalone) { + return qualified, group + } + if standalone == "" { + return "", "" + } + // Only a multi-segment path establishes a group; a top-level command has no + // siblings that could inherit it. + if i := strings.LastIndex(standalone, " "); i > 0 { + return standalone, standalone[:i] + } + return standalone, "" +} + +// segments counts the words in a command path; "" counts as zero. +func segments(path string) int { + if path == "" { + return 0 + } + return len(strings.Fields(path)) +} + +// longestRealPrefix returns the longest leading run of fields that names a real +// command, stopping at the first argument or flag token. +func longestRealPrefix(fields []string, real map[string]bool) string { + best := "" + for i := range fields { + if argTokenRE.MatchString(fields[i]) { + break + } + if candidate := strings.Join(fields[:i+1], " "); real[candidate] { + best = candidate + } + } + return best +} + +// exemptFromReference lists commands the reference does not tabulate as their own +// row. +func exemptFromReference(path string) bool { + return fangBuiltin(path) +} + +// fangBuiltin reports whether a name is one of the commands fang adds to every +// binary. They are documented as a pair and Catalog skips them. +func fangBuiltin(name string) bool { + return name == "man" || name == "completion" || strings.HasPrefix(name, "completion ") +} + +// rowIsFangBuiltins reports whether a row documents only fang's own commands. +func rowIsFangBuiltins(spans [][]string) bool { + for _, m := range spans { + if !fangBuiltin(strings.TrimSpace(m[1])) { + return false + } + } + return len(spans) > 0 +} diff --git a/internal/cli/ai_docs_test.go b/internal/cli/ai_docs_test.go new file mode 100644 index 0000000..7797937 --- /dev/null +++ b/internal/cli/ai_docs_test.go @@ -0,0 +1,139 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func runAi(t *testing.T, args ...string) (string, error) { + t.Helper() + root := NewRootCmd(Options{}) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(append([]string{"ai"}, args...)) + err := root.Execute() + return out.String(), err +} + +// TestAiDocsIsRegistered is the house registration check: the verb must be a real +// RunE command, not a stub. +func TestAiDocsIsRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + cmd, _, err := root.Find([]string{"ai", "docs"}) + if err != nil { + t.Fatalf("ai docs not registered: %v", err) + } + if cmd.RunE == nil { + t.Fatal("ai docs has no RunE") + } +} + +func TestAiDocsListsTheCorpus(t *testing.T) { + out, err := runAi(t, "docs") + if err != nil { + t.Fatalf("ai docs: %v\n%s", err, out) + } + for _, want := range []string{"GUIDE", "SPECS", "guide/templates", "documents"} { + if !strings.Contains(out, want) { + t.Errorf("listing is missing %q:\n%s", want, out) + } + } +} + +func TestAiDocsListJSON(t *testing.T) { + root := NewRootCmd(Options{}) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--json", "ai", "docs"}) + if err := root.Execute(); err != nil { + t.Fatalf("ai docs --json: %v\n%s", err, out.String()) + } + var got struct { + Docs []struct { + Slug string `json:"slug"` + Path string `json:"path"` + Title string `json:"title"` + Section string `json:"section"` + Lines int `json:"lines"` + } `json:"docs"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out.String()) + } + if len(got.Docs) < 60 { + t.Fatalf("expected the whole corpus, got %d docs", len(got.Docs)) + } + if got.Docs[0].Section != "guide" { + t.Errorf("first doc section = %q, want guide", got.Docs[0].Section) + } +} + +func TestAiDocsPrintsMarkdown(t *testing.T) { + out, err := runAi(t, "docs", "guide/templates") + if err != nil { + t.Fatalf("ai docs guide/templates: %v", err) + } + if !strings.HasPrefix(out, "# Templates") { + t.Errorf("expected raw markdown starting with the H1, got:\n%s", truncate(out, 200)) + } + if !strings.Contains(out, "[[") { + t.Error("the templates guide should document the [[ ]] delimiters") + } +} + +func TestAiDocsSectionFilter(t *testing.T) { + out, err := runAi(t, "docs", "--section", "specs") + if err != nil { + t.Fatalf("ai docs --section specs: %v\n%s", err, out) + } + if strings.Contains(out, "guide/") { + t.Errorf("--section specs should not list guide pages:\n%s", out) + } + if !strings.Contains(out, "specs/01-config-schema") { + t.Errorf("--section specs should list the specs:\n%s", out) + } + if _, err := runAi(t, "docs", "--section", "nope"); err == nil { + t.Error("expected an error for an unknown section") + } +} + +func TestAiDocsSearch(t *testing.T) { + out, err := runAi(t, "docs", "--search", "shared network", "--limit", "3") + if err != nil { + t.Fatalf("ai docs --search: %v\n%s", err, out) + } + if strings.Contains(out, "no documents match") { + t.Errorf("expected hits for a core concept:\n%s", out) + } + if !strings.Contains(out, "ai docs ") { + t.Errorf("search output should tell the caller how to read a hit:\n%s", out) + } +} + +func TestAiDocsUnknownSlugSuggests(t *testing.T) { + out, err := runAi(t, "docs", "guide/template") + if err == nil { + t.Fatalf("expected an error for an unknown slug, got:\n%s", out) + } + if !strings.Contains(err.Error(), "guide/templates") { + t.Errorf("error should suggest the near miss, got: %v", err) + } +} + +// TestAiDocsNeedsNoWorkspace matters because an agent often reaches for the docs +// BEFORE a workspace exists — that is exactly when it is learning `devstack init`. +func TestAiDocsNeedsNoWorkspace(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + t.Setenv("DEVSTACK_WORKSPACE", "") + if _, err := runAi(t, "docs"); err != nil { + t.Fatalf("ai docs must not require a workspace: %v", err) + } + if _, err := runAi(t, "docs", "guide/templates"); err != nil { + t.Fatalf("ai docs must not require a workspace: %v", err) + } +} diff --git a/internal/cli/ai_install.go b/internal/cli/ai_install.go new file mode 100644 index 0000000..a7c6c43 --- /dev/null +++ b/internal/cli/ai_install.go @@ -0,0 +1,220 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/ai" + "github.com/open-source-cloud/devstack/internal/version" +) + +// newAiInstallCmd wires `ai install` — it materializes the agent-integration +// files into the repository so any AI tool working here knows devstack exists and +// how to drive it. +// +// It is pure file authorship: no Docker, no ledger, no flock, and no workspace +// required. That last part is deliberate — the most valuable moment to install +// these files is often before `devstack init` has been run, when an agent still +// has to be told what devstack is. +func newAiInstallCmd(g *GlobalOpts) *cobra.Command { + var ( + target string + check bool + ) + cmd := &cobra.Command{ + Use: "install", + Short: "Write the skills, AGENTS.md block and MCP registration into this repo", + Long: "install writes the files that teach AI coding tools to use devstack:\n\n" + + " .claude/skills/devstack*/ Claude Code skills (devstack, templates, troubleshooting)\n" + + " AGENTS.md a marker-fenced block read by Codex, Cursor, Copilot,\n" + + " Gemini CLI, Windsurf, Zed and Aider\n" + + " CLAUDE.md an @AGENTS.md import, since Claude Code reads CLAUDE.md\n" + + " .mcp.json registers `devstack ai mcp` as an MCP server\n\n" + + "AGENTS.md, CLAUDE.md and .mcp.json are USER-owned: only devstack's own block or key is\n" + + "replaced, so your content survives every regeneration. The skill files are fully\n" + + "generated and are overwritten.\n\n" + + "Output is deterministic; --check reports drift without writing (CI-friendly).", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + targets := ai.All() + if target != "" { + parsed, err := ai.ParseTargets(target) + if err != nil { + return err + } + if !parsed.Any() { + return fmt.Errorf("--target selected nothing (available: skills, agents, mcp)") + } + targets = parsed + } + root, err := aiInstallRoot() + if err != nil { + return err + } + gen := ai.New( + ai.WithRoot(root), + ai.WithBinary(rootName(cmd)), + ai.WithVersion(version.Version), + ) + arts, err := gen.Build(targets) + if err != nil { + return err + } + if check { + return reportAiCheck(cmd, g, arts) + } + results, err := ai.Write(arts) + if err != nil { + return err + } + return reportAiWrite(cmd, g, results) + }, + } + cmd.Flags().StringVar(&target, "target", "", + "comma-separated families to emit: skills, agents, mcp (default: all)") + cmd.Flags().BoolVar(&check, "check", false, "report drift without writing (CI)") + return cmd +} + +// newAiCheckCmd is the drift gate, spelled as its own verb because that is how it +// reads in a CI file. +func newAiCheckCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "check", + Short: "Report whether the emitted agent files are up to date", + Long: "check reports whether the files `ai install` writes match what this binary would\n" + + "emit, without writing anything. Exits non-zero on drift, so it works as a CI gate.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + root, err := aiInstallRoot() + if err != nil { + return err + } + gen := ai.New( + ai.WithRoot(root), + ai.WithBinary(rootName(cmd)), + ai.WithVersion(version.Version), + ) + arts, err := gen.Build(ai.All()) + if err != nil { + return err + } + return reportAiCheck(cmd, g, arts) + }, + } +} + +// aiInstallRoot picks where the files land: the workspace root when there is a +// plausible one, otherwise the current directory. Falling back rather than +// failing is what lets an agent install the guidance before a workspace exists. +// +// Workspace discovery walks UP from the current directory, which is right for +// `generate` but dangerous for a command that writes files: one stray +// workspace.yaml in a home directory would silently redirect the whole install +// there, scattering AGENTS.md, CLAUDE.md and .mcp.json across $HOME. So a +// discovered root is only honored when isSafeInstallRoot accepts it. +func aiInstallRoot() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("determine the current directory: %w", err) + } + if m, lerr := loadWorkspace(); lerr == nil && m.Root != "" && isSafeInstallRoot(m.Root) { + return m.Root, nil + } + if !isSafeInstallRoot(wd) { + return "", fmt.Errorf( + "refusing to install agent files into %s: that is your home directory, "+ + "not a project. Run this from inside a repository", wd) + } + return wd, nil +} + +// isSafeInstallRoot refuses the user's home directory and any ancestor of it. +// These files are meant to be committed alongside a project; installing them +// machine-wide is a separate, deliberate feature (see Q-AI-SCOPE) and must never +// happen as a side effect of where a workspace.yaml happens to sit. +func isSafeInstallRoot(dir string) bool { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return true + } + abs, err := filepath.Abs(dir) + if err != nil { + return true + } + abs = filepath.Clean(abs) + home = filepath.Clean(home) + if abs == home { + return false + } + // Also refuse anything above home (/, /home, …). + if rel, err := filepath.Rel(abs, home); err == nil && + rel != "." && !strings.HasPrefix(rel, "..") { + return false + } + return true +} + +func reportAiWrite(cmd *cobra.Command, g *GlobalOpts, results []ai.WriteResult) error { + if g.JSON { + return writeJSON(cmd, map[string]any{"ok": true, "artifacts": results}) + } + if g.Quiet { + return nil + } + w := cmd.OutOrStdout() + changed := 0 + for _, r := range results { + state := "unchanged" + if r.Changed { + state = "wrote" + changed++ + } + fmt.Fprintf(w, " %-9s %s\n", state, r.Path) + } + fmt.Fprintf(w, "\n%d of %d artifact(s) changed.\n", changed, len(results)) + if changed > 0 { + fmt.Fprintf(w, "Commit these so your whole team's AI tools pick them up.\n"+ + "Teammates need `%s` on PATH; in Claude Code the MCP server also has to be\n"+ + "approved once (.claude/settings.local.json → enabledMcpjsonServers).\n", + rootName(cmd)) + } + return nil +} + +func reportAiCheck(cmd *cobra.Command, g *GlobalOpts, arts []ai.Artifact) error { + stale, err := ai.Stale(arts) + if err != nil { + return err + } + if g.JSON { + paths := make([]string, 0, len(stale)) + for _, a := range stale { + paths = append(paths, a.Rel) + } + if err := writeJSON(cmd, map[string]any{"ok": len(stale) == 0, "stale": paths}); err != nil { + return err + } + if len(stale) > 0 { + return fmt.Errorf("agent artifacts are stale; run `%s ai install`", rootName(cmd)) + } + return nil + } + if len(stale) == 0 { + if !g.Quiet { + fmt.Fprintf(cmd.OutOrStdout(), "ok: %d artifact(s) up to date\n", len(arts)) + } + return nil + } + if !g.Quiet { + w := cmd.OutOrStdout() + for _, a := range stale { + fmt.Fprintf(w, " stale %s\n", a.Rel) + } + } + return fmt.Errorf("%d agent artifact(s) are stale; run `%s ai install`", len(stale), rootName(cmd)) +} diff --git a/internal/cli/ai_install_test.go b/internal/cli/ai_install_test.go new file mode 100644 index 0000000..da2f687 --- /dev/null +++ b/internal/cli/ai_install_test.go @@ -0,0 +1,264 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// aiScratch gives each test an isolated repo with no workspace, which is also the +// case `ai install` has to support: an agent installing the guidance before +// `devstack init` has ever run. +func aiScratch(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Chdir(dir) + t.Setenv("DEVSTACK_WORKSPACE", "") + t.Setenv("DEVSTACK_HOME", filepath.Join(dir, ".devstack-home")) + return dir +} + +func TestAiInstallIsRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + for _, path := range [][]string{{"ai", "install"}, {"ai", "check"}} { + cmd, _, err := root.Find(path) + if err != nil { + t.Fatalf("%v not registered: %v", path, err) + } + if cmd.RunE == nil { + t.Errorf("%v has no RunE", path) + } + } +} + +func TestAiInstallWritesAndIsIdempotent(t *testing.T) { + dir := aiScratch(t) + + out, err := runAi(t, "install") + if err != nil { + t.Fatalf("ai install: %v\n%s", err, out) + } + for _, want := range []string{ + ".claude/skills/devstack/SKILL.md", + ".claude/skills/devstack-templates/SKILL.md", + ".claude/skills/devstack-troubleshooting/SKILL.md", + "AGENTS.md", "CLAUDE.md", ".mcp.json", + } { + if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(want))); err != nil { + t.Errorf("%s was not written: %v", want, err) + } + } + if !strings.Contains(out, "7 of 7 artifact(s) changed") { + t.Errorf("unexpected summary:\n%s", out) + } + + out, err = runAi(t, "install") + if err != nil { + t.Fatalf("second ai install: %v", err) + } + if !strings.Contains(out, "0 of 7 artifact(s) changed") { + t.Errorf("second run should change nothing:\n%s", out) + } +} + +func TestAiCheckReportsDriftAndExitsNonZero(t *testing.T) { + dir := aiScratch(t) + + // Nothing installed yet: check must fail and say so. + out, err := runAi(t, "check") + if err == nil { + t.Fatalf("ai check should fail before install; got:\n%s", out) + } + if !strings.Contains(err.Error(), "ai install") { + t.Errorf("the error should name the fix, got: %v", err) + } + + if _, err := runAi(t, "install"); err != nil { + t.Fatalf("ai install: %v", err) + } + if out, err := runAi(t, "check"); err != nil { + t.Fatalf("ai check should pass after install: %v\n%s", err, out) + } + + // Corrupt one generated file; check must notice. + skill := filepath.Join(dir, ".claude", "skills", "devstack", "SKILL.md") + if err := os.WriteFile(skill, []byte("tampered\n"), 0o644); err != nil { + t.Fatal(err) + } + out, err = runAi(t, "check") + if err == nil { + t.Fatalf("ai check should detect a tampered artifact; got:\n%s", out) + } + if !strings.Contains(out, "SKILL.md") { + t.Errorf("check should name the stale file:\n%s", out) + } +} + +func TestAiCheckJSON(t *testing.T) { + aiScratch(t) + if _, err := runAi(t, "install"); err != nil { + t.Fatalf("ai install: %v", err) + } + root := NewRootCmd(Options{}) + var buf strings.Builder + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"--json", "ai", "check"}) + if err := root.Execute(); err != nil { + t.Fatalf("ai check --json: %v\n%s", err, buf.String()) + } + var got struct { + OK bool `json:"ok"` + Stale []string `json:"stale"` + } + if err := json.Unmarshal([]byte(buf.String()), &got); err != nil { + t.Fatalf("not JSON: %v\n%s", err, buf.String()) + } + if !got.OK || len(got.Stale) != 0 { + t.Errorf("expected ok with no stale entries, got %+v", got) + } +} + +func TestAiInstallTargetSubset(t *testing.T) { + dir := aiScratch(t) + if _, err := runAi(t, "install", "--target", "mcp"); err != nil { + t.Fatalf("ai install --target mcp: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".mcp.json")); err != nil { + t.Errorf(".mcp.json was not written: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); !os.IsNotExist(err) { + t.Error("--target mcp should not have written AGENTS.md") + } + if _, err := runAi(t, "install", "--target", "nope"); err == nil { + t.Error("expected an error for an unknown target") + } +} + +// TestAiInstallPreservesUserContent is the property a user actually cares about: +// running install again must not eat their notes or their other MCP servers. +func TestAiInstallPreservesUserContent(t *testing.T) { + dir := aiScratch(t) + agents := filepath.Join(dir, "AGENTS.md") + mcp := filepath.Join(dir, ".mcp.json") + + if err := os.WriteFile(agents, []byte("# Mine\n\nHand-written.\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mcp, []byte(`{"mcpServers":{"other":{"command":"x"}}}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := runAi(t, "install"); err != nil { + t.Fatalf("ai install: %v", err) + } + + body, err := os.ReadFile(agents) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(body), "# Mine\n\nHand-written.\n") { + t.Errorf("hand-written AGENTS.md content was lost:\n%s", body) + } + + raw, err := os.ReadFile(mcp) + if err != nil { + t.Fatal(err) + } + var cfg struct { + Servers map[string]json.RawMessage `json:"mcpServers"` + } + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("not JSON: %v", err) + } + if _, ok := cfg.Servers["other"]; !ok { + t.Error("an existing MCP server was dropped") + } + if _, ok := cfg.Servers["devstack"]; !ok { + t.Error("the devstack MCP server was not registered") + } +} + +// TestAiInstallUsesTheInvokedName covers argv[0] aliasing end to end: an +// installation run as `rq` must emit files that say rq. +func TestAiInstallUsesTheInvokedName(t *testing.T) { + dir := aiScratch(t) + root := NewRootCmd(Options{InvokedAs: "rq"}) + var buf strings.Builder + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"ai", "install"}) + if err := root.Execute(); err != nil { + t.Fatalf("rq ai install: %v\n%s", err, buf.String()) + } + raw, err := os.ReadFile(filepath.Join(dir, ".mcp.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"command": "rq"`) { + t.Errorf(".mcp.json should invoke rq:\n%s", raw) + } +} + +// TestAiInstallRefusesHomeDirectory is a regression test for a real incident: +// workspace discovery walks UP from the current directory, so a single stray +// workspace.yaml sitting in a home directory silently redirected the whole +// install there, scattering AGENTS.md, CLAUDE.md and .mcp.json across $HOME and +// installing skills machine-wide. Writing files is not `generate`; the discovered +// root has to be sanity-checked. +func TestAiInstallRefusesHomeDirectory(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) // Windows + t.Chdir(home) + t.Setenv("DEVSTACK_WORKSPACE", "") + + out, err := runAi(t, "install") + if err == nil { + t.Fatalf("expected ai install to refuse the home directory, got:\n%s", out) + } + if !strings.Contains(err.Error(), "home directory") { + t.Errorf("the error should explain why, got: %v", err) + } + for _, name := range []string{"AGENTS.md", "CLAUDE.md", ".mcp.json"} { + if _, statErr := os.Stat(filepath.Join(home, name)); !os.IsNotExist(statErr) { + t.Errorf("%s was written into the home directory anyway", name) + } + } +} + +// TestAiInstallIgnoresAWorkspaceAtHome covers the exact discovered-root path: a +// project directory whose ancestor happens to contain a workspace.yaml must +// install into the project, not into that ancestor. +func TestAiInstallIgnoresAWorkspaceAtHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + // A stray workspace.yaml in the home directory, exactly as `devstack init` + // run from $HOME would leave behind. + if err := os.WriteFile(filepath.Join(home, "workspace.yaml"), []byte( + "apiVersion: devstack/v1\nkind: Workspace\nname: stray\n"), 0o644); err != nil { + t.Fatal(err) + } + project := filepath.Join(home, "code", "myrepo") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(project) + t.Setenv("DEVSTACK_WORKSPACE", "") + t.Setenv("DEVSTACK_HOME", filepath.Join(home, ".devstack-home")) + + if out, err := runAi(t, "install"); err != nil { + t.Fatalf("ai install: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(project, "AGENTS.md")); err != nil { + t.Errorf("AGENTS.md was not written into the project: %v", err) + } + if _, err := os.Stat(filepath.Join(home, "AGENTS.md")); !os.IsNotExist(err) { + t.Error("AGENTS.md leaked into the home directory") + } + if _, err := os.Stat(filepath.Join(home, ".claude", "skills")); !os.IsNotExist(err) { + t.Error("skills leaked into the home directory") + } +} diff --git a/internal/cli/ai_mcp.go b/internal/cli/ai_mcp.go new file mode 100644 index 0000000..fefc787 --- /dev/null +++ b/internal/cli/ai_mcp.go @@ -0,0 +1,178 @@ +package cli + +import ( + "bytes" + "context" + "fmt" + "io" + "io/fs" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/aidocs" + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/mcpserve" + "github.com/open-source-cloud/devstack/internal/version" + "github.com/open-source-cloud/devstack/templates" +) + +// newAiMcpCmd wires `ai mcp` — devstack as a Model Context Protocol server over +// stdio. +// +// Two properties are load-bearing: +// +// stdout purity. An MCP stdio server must write nothing to stdout but framed +// JSON-RPC. Every tool therefore runs its devstack command with output captured +// into a buffer, never the process's real stdout, and the command forces quiet +// mode so the self-update notifier and any human chatter stay silent. A test +// asserts that a session's stdout carries only protocol bytes. +// +// No daemon semantics. Each tool call builds a FRESH command tree and runs it to +// completion, exactly as a shell invocation would. The cross-process flock is +// taken and released inside that call, so this long-lived process never holds it +// and devstack stays the stateless CLI its architecture describes. +func newAiMcpCmd(g *GlobalOpts) *cobra.Command { + var ( + readOnly bool + allowDestructive bool + ) + cmd := &cobra.Command{ + Use: "mcp", + Short: "Serve devstack to AI agents over the Model Context Protocol", + Long: "mcp runs devstack as an MCP server on stdin/stdout, so an MCP-capable agent can\n" + + "inspect and drive this workspace directly.\n\n" + + "It exposes:\n" + + " tools the devstack commands, run exactly as the CLI runs them\n" + + " resources the documentation corpus, the built-in template sources, the config\n" + + " JSON Schemas, and the command catalog\n" + + " prompts guided workflows (onboard-repo, add-service, write-template,\n" + + " debug-up-failure, migrate-from-compose)\n\n" + + "Read and write tools are registered by default. Irreversible verbs — workspace\n" + + "destroy, db drop, db reset — are ABSENT unless --allow-destructive, because MCP\n" + + "has no terminal to confirm on. The secrets group is never exposed at any setting.\n\n" + + "You normally do not run this by hand: `devstack ai install` registers it in\n" + + ".mcp.json and your agent starts it.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + // Anything the process itself prints would corrupt the protocol + // stream, so silence the human surface for this command. + g.Quiet = true + g.JSON = false + + deps, err := mcpDeps(cmd) + if err != nil { + return err + } + return mcpserve.Serve(cmd.Context(), deps, mcpserve.Options{ + ReadOnly: readOnly, + AllowDestructive: allowDestructive, + }) + }, + } + cmd.Flags().BoolVar(&readOnly, "read-only", false, + "expose only tools that cannot change anything") + cmd.Flags().BoolVar(&allowDestructive, "allow-destructive", false, + "additionally expose irreversible verbs (workspace destroy, db drop, db reset)") + return cmd +} + +// mcpDeps wires the server against this binary's own command tree and embedded +// assets. It is the single place internal/cli and internal/mcpserve meet, which +// is what keeps the dependency one-directional. +func mcpDeps(cmd *cobra.Command) (mcpserve.Deps, error) { + binary := rootName(cmd) + return mcpserve.Deps{ + Version: version.Version, + Binary: binary, + Run: runDevstackCaptured, + Docs: mcpDocs{}, + Templates: func() fs.FS { return templates.FS }, + Schema: mcpSchema, + SchemaKinds: mcpSchemaKinds, + }, nil +} + +// runDevstackCaptured executes one devstack command line in-process and returns +// its stdout. +// +// A fresh root command is built per call so no flag state leaks between requests, +// which is the same isolation a separate process would give — and the same +// harness the CLI test suite already uses. +func runDevstackCaptured(ctx context.Context, argv []string) ([]byte, error) { + root := NewRootCmd(Options{}) + var out, errOut bytes.Buffer + root.SetOut(&out) + root.SetErr(&errOut) + root.SetIn(nopReader{}) + root.SetArgs(argv) + root.SilenceUsage = true + root.SilenceErrors = true + + err := root.ExecuteContext(ctx) + if err != nil { + // devstack's errors already carry the command, exit code and a + // remediation; append stderr so the model sees the whole picture. + if errOut.Len() > 0 { + return out.Bytes(), fmt.Errorf("%w\n%s", err, errOut.String()) + } + return out.Bytes(), err + } + return out.Bytes(), nil +} + +// nopReader stands in for stdin so a prompt can never block the server waiting +// for input that will never arrive. +type nopReader struct{} + +func (nopReader) Read([]byte) (int, error) { return 0, io.EOF } + +// mcpDocs adapts internal/aidocs to the narrow interface mcpserve declares, so +// that package does not depend on devstack's document model. +type mcpDocs struct{} + +func (mcpDocs) List() ([]mcpserve.DocMeta, error) { + list, err := aidocs.List() + if err != nil { + return nil, err + } + out := make([]mcpserve.DocMeta, 0, len(list)) + for _, d := range list { + out = append(out, mcpserve.DocMeta{ + Slug: d.Slug, + Path: d.Path, + Title: d.Title, + Summary: d.Summary, + Section: string(d.Section), + Lines: d.Lines, + }) + } + return out, nil +} + +func (mcpDocs) Read(slug string) (mcpserve.DocMeta, []byte, error) { + d, body, err := aidocs.Read(slug) + if err != nil { + return mcpserve.DocMeta{}, nil, err + } + return mcpserve.DocMeta{ + Slug: d.Slug, Path: d.Path, Title: d.Title, + Summary: d.Summary, Section: string(d.Section), Lines: d.Lines, + }, body, nil +} + +func mcpSchema(kind string) ([]byte, error) { + k, err := config.ParseSchemaKind(kind) + if err != nil { + return nil, err + } + return config.Schema(k) +} + +func mcpSchemaKinds() []string { + kinds := config.SchemaKinds() + out := make([]string, 0, len(kinds)) + for _, k := range kinds { + out = append(out, string(k)) + } + return out +} diff --git a/internal/cli/ai_mcp_test.go b/internal/cli/ai_mcp_test.go new file mode 100644 index 0000000..8a10c2f --- /dev/null +++ b/internal/cli/ai_mcp_test.go @@ -0,0 +1,119 @@ +package cli + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestAiMcpIsRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + cmd, _, err := root.Find([]string{"ai", "mcp"}) + if err != nil { + t.Fatalf("ai mcp not registered: %v", err) + } + if cmd.RunE == nil { + t.Fatal("ai mcp has no RunE") + } + for _, flag := range []string{"read-only", "allow-destructive"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("ai mcp is missing --%s", flag) + } + } +} + +// TestRunDevstackCapturedIsIsolated pins the property the whole MCP design rests +// on: every tool call runs a FRESH command tree whose output is captured, so +// nothing leaks between calls and nothing reaches the real stdout. +func TestRunDevstackCapturedIsIsolated(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + t.Setenv("DEVSTACK_WORKSPACE", "") + t.Setenv("DEVSTACK_HOME", dir) + + out, err := runDevstackCaptured(context.Background(), []string{"--json", "ai", "commands"}) + if err != nil { + t.Fatalf("run: %v", err) + } + var cat struct { + Commands []struct { + Path string `json:"path"` + } `json:"commands"` + } + if err := json.Unmarshal(out, &cat); err != nil { + t.Fatalf("captured output is not the JSON the tool promised: %v\n%s", err, out) + } + if len(cat.Commands) == 0 { + t.Fatal("captured output is empty") + } + + // A second call must not inherit the first call's flag state. + plain, err := runDevstackCaptured(context.Background(), []string{"ai", "docs", "--section", "guide"}) + if err != nil { + t.Fatalf("second run: %v", err) + } + if json.Valid(plain) { + t.Error("the second call rendered JSON; --json leaked from the previous command tree") + } +} + +// TestRunDevstackCapturedReportsFailures: a failing command must surface its +// message, since that message is what the model shows the user. +func TestRunDevstackCapturedReportsFailures(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + t.Setenv("DEVSTACK_WORKSPACE", "") + + _, err := runDevstackCaptured(context.Background(), []string{"--json", "ai", "docs", "no/such/doc"}) + if err == nil { + t.Fatal("expected an error for an unknown document") + } + if !strings.Contains(err.Error(), "no such doc") { + t.Errorf("the error should carry the command's message, got: %v", err) + } +} + +// TestMcpDepsWire checks the adapters actually resolve against the real embedded +// assets, not just compile. +func TestMcpDepsWire(t *testing.T) { + root := NewRootCmd(Options{}) + cmd, _, err := root.Find([]string{"ai", "mcp"}) + if err != nil { + t.Fatal(err) + } + deps, err := mcpDeps(cmd) + if err != nil { + t.Fatalf("mcpDeps: %v", err) + } + + list, err := deps.Docs.List() + if err != nil { + t.Fatalf("Docs.List: %v", err) + } + if len(list) < 60 { + t.Errorf("expected the whole corpus, got %d documents", len(list)) + } + + if _, body, err := deps.Docs.Read("guide/templates"); err != nil || len(body) == 0 { + t.Errorf("Docs.Read(guide/templates) = %d bytes, %v", len(body), err) + } + + kinds := deps.SchemaKinds() + if len(kinds) == 0 { + t.Fatal("no schema kinds") + } + for _, k := range kinds { + doc, err := deps.Schema(k) + if err != nil || !json.Valid(doc) { + t.Errorf("Schema(%s) = %d bytes, %v", k, len(doc), err) + } + } + + // The resource template must reach a real, shipping template. + f, err := deps.Templates().Open("postgres/template.yaml") + if err != nil { + t.Fatalf("the built-in postgres template is not reachable: %v", err) + } + _ = f.Close() +} diff --git a/internal/cli/config.go b/internal/cli/config.go index c51872d..ecb77cb 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -23,7 +23,7 @@ func newConfigCmd(g *GlobalOpts) *cobra.Command { "and every referenced project's devstack.yaml, validates structure and\n" + "cross-references against the workspace graph, and reports errors as file:line:col.", } - cmd.AddCommand(newConfigValidateCmd(g), newConfigShowCmd(g)) + cmd.AddCommand(newConfigValidateCmd(g), newConfigShowCmd(g), newConfigSchemaCmd(g)) return cmd } diff --git a/internal/cli/config_schema.go b/internal/cli/config_schema.go new file mode 100644 index 0000000..75b209f --- /dev/null +++ b/internal/cli/config_schema.go @@ -0,0 +1,60 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/config" +) + +// newConfigSchemaCmd wires `config schema` — it prints the published JSON Schema +// for a config file kind (spec 01, DECISIONS D16). The schemas are hand-authored +// and embedded, so what this prints is byte-identical to what the $schema URL in +// the generated editor configs resolves to. +// +// It takes no workspace: the schema describes the file format, not any particular +// workspace, so `config schema` works in an empty directory — which is exactly +// when someone (or an agent) is about to author their first devstack.yaml. +func newConfigSchemaCmd(_ *GlobalOpts) *cobra.Command { + var kind string + cmd := &cobra.Command{ + Use: "schema", + Short: "Print the JSON Schema for a config file kind", + Long: "schema prints the published draft-2020-12 JSON Schema for devstack.yaml or\n" + + "workspace.yaml. Point an editor at it for completion and inline validation\n" + + "(the generated .vscode/settings.json already does), or feed it to a tool that\n" + + "needs the exact config contract.\n\n" + + "The Go validator remains the source of truth; a CI round-trip test keeps the\n" + + "schema and the structs aligned.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + k, err := config.ParseSchemaKind(kind) + if err != nil { + return err + } + doc, err := config.Schema(k) + if err != nil { + return err + } + // The document is already indented JSON with a trailing newline; write + // it verbatim rather than through writeJSON so the bytes match the + // committed file (and the published URL) exactly. + _, err = cmd.OutOrStdout().Write(doc) + return err + }, + } + cmd.Flags().StringVar(&kind, "kind", string(config.SchemaProject), + fmt.Sprintf("config file kind to describe (%s)", strings.Join(schemaKindNames(), "|"))) + return cmd +} + +func schemaKindNames() []string { + kinds := config.SchemaKinds() + out := make([]string, 0, len(kinds)) + for _, k := range kinds { + out = append(out, string(k)) + } + return out +} diff --git a/internal/cli/config_schema_test.go b/internal/cli/config_schema_test.go new file mode 100644 index 0000000..308d677 --- /dev/null +++ b/internal/cli/config_schema_test.go @@ -0,0 +1,82 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +// runSchema drives `config schema` through the real cobra tree. +func runSchema(t *testing.T, args ...string) (string, error) { + t.Helper() + root := NewRootCmd(Options{}) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(append([]string{"config", "schema"}, args...)) + err := root.Execute() + return out.String(), err +} + +// TestConfigSchemaDefaultsToProject pins the default kind and asserts the output +// is a usable JSON Schema document, not a summary. +func TestConfigSchemaDefaultsToProject(t *testing.T) { + out, err := runSchema(t) + if err != nil { + t.Fatalf("config schema: %v\n%s", err, out) + } + var doc map[string]any + if err := json.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out) + } + if doc["$schema"] != "https://json-schema.org/draft/2020-12/schema" { + t.Errorf("$schema = %v, want draft 2020-12", doc["$schema"]) + } + props, _ := doc["properties"].(map[string]any) + if _, ok := props["services"]; !ok { + t.Errorf("project schema should describe `services`; got properties %v", props) + } +} + +// TestConfigSchemaWorkspaceKind covers the other kind and the filename alias. +func TestConfigSchemaWorkspaceKind(t *testing.T) { + for _, kind := range []string{"workspace", "workspace.yaml"} { + out, err := runSchema(t, "--kind", kind) + if err != nil { + t.Fatalf("config schema --kind %s: %v\n%s", kind, err, out) + } + var doc map[string]any + if err := json.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("--kind %s: output is not JSON: %v", kind, err) + } + props, _ := doc["properties"].(map[string]any) + if _, ok := props["shared"]; !ok { + t.Errorf("--kind %s: workspace schema should describe `shared`", kind) + } + } +} + +// TestConfigSchemaUnknownKind asserts the error names the available kinds rather +// than failing opaquely. +func TestConfigSchemaUnknownKind(t *testing.T) { + out, err := runSchema(t, "--kind", "nope") + if err == nil { + t.Fatalf("expected an error for an unknown kind; got:\n%s", out) + } + if !strings.Contains(err.Error(), "project") || !strings.Contains(err.Error(), "workspace") { + t.Errorf("error should list the available kinds, got: %v", err) + } +} + +// TestConfigSchemaNeedsNoWorkspace is the property that makes this useful to an +// agent: the schema describes the file format, so it must work in an empty dir — +// exactly when someone is about to author their first devstack.yaml. +func TestConfigSchemaNeedsNoWorkspace(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + t.Setenv("DEVSTACK_WORKSPACE", "") + if _, err := runSchema(t); err != nil { + t.Fatalf("config schema must not require a workspace: %v", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f8f30b9..3f0f56d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -119,6 +119,7 @@ func NewRootCmd(opts Options) *cobra.Command { newStoreCmd(g), newAliasCmd(g), newTelemetryCmd(g), + newAiCmd(g), newVersionCmd(), ) addStubCommands(root, g) diff --git a/internal/config/schema.go b/internal/config/schema.go new file mode 100644 index 0000000..69666df --- /dev/null +++ b/internal/config/schema.go @@ -0,0 +1,81 @@ +package config + +import ( + "fmt" + "sort" + + "github.com/open-source-cloud/devstack/schemas" +) + +// SchemaKind names one published JSON Schema document. The values double as the +// `devstack config schema --kind` flag vocabulary and as the MCP resource slugs. +type SchemaKind string + +// The schema kinds devstack publishes. Store config (~/.devstack/config.yaml) is +// deliberately absent: it is machine-global tool state, not a file users author +// by hand, and `store show` is its inspection surface. +const ( + SchemaProject SchemaKind = "project" // devstack.yaml + SchemaWorkspace SchemaKind = "workspace" // workspace.yaml +) + +// schemaFiles maps each kind to its embedded document. +var schemaFiles = map[SchemaKind]string{ + SchemaProject: "devstack.schema.json", + SchemaWorkspace: "workspace.schema.json", +} + +// SchemaKinds returns the published kinds in stable order. +func SchemaKinds() []SchemaKind { + out := make([]SchemaKind, 0, len(schemaFiles)) + for k := range schemaFiles { + out = append(out, k) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// SchemaFilename returns the published file name for a kind (the basename under +// schemas/ in the repo, and the last path segment of the published URL). +func SchemaFilename(kind SchemaKind) (string, bool) { + name, ok := schemaFiles[kind] + return name, ok +} + +// Schema returns the raw JSON Schema document for a kind. The bytes are the +// committed file verbatim, so the printed output and the published URL can never +// disagree. +func Schema(kind SchemaKind) ([]byte, error) { + name, ok := schemaFiles[kind] + if !ok { + return nil, fmt.Errorf("unknown schema kind %q (available: %s)", kind, joinKinds(SchemaKinds())) + } + b, err := schemas.FS.ReadFile(name) + if err != nil { + return nil, fmt.Errorf("read embedded schema %s: %w", name, err) + } + return b, nil +} + +// ParseSchemaKind resolves a user-supplied --kind value, accepting the file kind +// as well as the file name it is stored under so `--kind devstack.yaml` works. +func ParseSchemaKind(s string) (SchemaKind, error) { + switch s { + case "project", "devstack", "devstack.yaml": + return SchemaProject, nil + case "workspace", "workspace.yaml": + return SchemaWorkspace, nil + } + return "", fmt.Errorf("unknown schema kind %q (available: %s)", s, joinKinds(SchemaKinds())) +} + +func joinKinds(kinds []SchemaKind) string { + out := "" + for i, k := range kinds { + if i > 0 { + out += ", " + } + out += string(k) + } + return out +} diff --git a/internal/config/schema_coverage_test.go b/internal/config/schema_coverage_test.go new file mode 100644 index 0000000..4159f08 --- /dev/null +++ b/internal/config/schema_coverage_test.go @@ -0,0 +1,161 @@ +package config + +import ( + "bytes" + "encoding/json" + "reflect" + "sort" + "strings" + "testing" +) + +// schemaBinding ties one Go struct to the object in a published schema that is +// supposed to describe it. `at` is a slash path through the decoded schema +// document (properties/… , $defs/… , items, additionalProperties). +type schemaBinding struct { + at string + typ reflect.Type +} + +func t7[T any]() reflect.Type { var z T; return reflect.TypeOf(z) } + +// workspaceBindings covers every struct reachable from config.Workspace. +var workspaceBindings = []schemaBinding{ + {"", t7[Workspace]()}, + {"properties/profiles", t7[Profiles]()}, + {"properties/groups/additionalProperties", t7[Group]()}, + {"properties/secrets", t7[Secrets]()}, + {"properties/secrets/properties/providers/items", t7[SecretProvider]()}, + {"properties/network", t7[Network]()}, + {"properties/network/properties/proxy", t7[Proxy]()}, + {"properties/network/properties/tunnel", t7[Tunnel]()}, + {"properties/backend", t7[BackendConfig]()}, + {"properties/shared/additionalProperties", t7[SharedSvc]()}, + {"properties/projects/items", t7[ProjectRef]()}, + {"$defs/resources", t7[Resources]()}, + {"$defs/hooks", t7[Hooks]()}, + {"$defs/hook", t7[Hook]()}, +} + +// projectBindings covers every struct reachable from config.Project. +var projectBindings = []schemaBinding{ + {"", t7[Project]()}, + {"$defs/service", t7[Service]()}, + {"$defs/env", t7[Env]()}, + {"$defs/env/properties/import/items", t7[Import]()}, + {"$defs/healthcheck", t7[Healthcheck]()}, + {"$defs/dependsOn", t7[DependsOn]()}, + {"$defs/resourceDecl", t7[ResourceDecl]()}, + {"$defs/task", t7[Task]()}, + {"$defs/resources", t7[Resources]()}, + {"$defs/hooks", t7[Hooks]()}, + {"$defs/hook", t7[Hook]()}, +} + +// TestSchemaCoversEveryField is the structural half of the D16 guard. The +// round-trip test only exercises fields the fixtures happen to use; this walks +// the structs themselves, so adding a `yaml:` field without adding it to the +// schema fails here — in the same PR that changed the struct. +func TestSchemaCoversEveryField(t *testing.T) { + for _, tc := range []struct { + kind SchemaKind + bindings []schemaBinding + }{ + {SchemaWorkspace, workspaceBindings}, + {SchemaProject, projectBindings}, + } { + raw, err := Schema(tc.kind) + if err != nil { + t.Fatalf("Schema(%s): %v", tc.kind, err) + } + var doc map[string]any + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&doc); err != nil { + t.Fatalf("decode %s: %v", tc.kind, err) + } + for _, b := range tc.bindings { + node, ok := walkSchema(doc, b.at) + if !ok { + t.Errorf("%s: no schema object at %q (for %s)", tc.kind, b.at, b.typ) + continue + } + assertFieldsCovered(t, string(tc.kind), b, node) + } + } +} + +// assertFieldsCovered checks the binding in both directions: every yaml field is +// a schema property, and every schema property is a yaml field. The second half +// catches a field that was renamed or deleted in Go but left behind in the schema. +func assertFieldsCovered(t *testing.T, kind string, b schemaBinding, node map[string]any) { + t.Helper() + props, _ := node["properties"].(map[string]any) + if props == nil { + t.Errorf("%s at %q: object has no properties block (for %s)", kind, b.at, b.typ) + return + } + // additionalProperties:false is what gives the schema its teeth. + if ap, ok := node["additionalProperties"]; !ok || ap != false { + t.Errorf("%s at %q: expected additionalProperties:false (for %s)", kind, b.at, b.typ) + } + + want := yamlFields(b.typ) + for _, f := range want { + if _, ok := props[f]; !ok { + t.Errorf("%s at %q: field %q of %s is missing from the schema", kind, b.at, f, b.typ) + } + } + have := make(map[string]bool, len(want)) + for _, f := range want { + have[f] = true + } + extra := make([]string, 0) + for p := range props { + if !have[p] { + extra = append(extra, p) + } + } + sort.Strings(extra) + for _, p := range extra { + t.Errorf("%s at %q: schema property %q has no matching field on %s", kind, b.at, p, b.typ) + } +} + +// yamlFields returns the yaml key of every exported field on a struct. +func yamlFields(typ reflect.Type) []string { + out := make([]string, 0, typ.NumField()) + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if !f.IsExported() { + continue + } + tag := f.Tag.Get("yaml") + if tag == "" || tag == "-" { + continue + } + name, _, _ := strings.Cut(tag, ",") + if name != "" { + out = append(out, name) + } + } + sort.Strings(out) + return out +} + +// walkSchema resolves a slash path through a decoded schema document. An empty +// path returns the root. +func walkSchema(doc map[string]any, path string) (map[string]any, bool) { + node := doc + if path == "" { + return node, true + } + for _, seg := range strings.Split(path, "/") { + next, ok := node[seg].(map[string]any) + if !ok { + return nil, false + } + node = next + } + return node, true +} diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go new file mode 100644 index 0000000..15ac39c --- /dev/null +++ b/internal/config/schema_test.go @@ -0,0 +1,145 @@ +package config + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goccy/go-yaml" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// compileSchema loads one embedded schema through jsonschema/v6, which also +// proves the committed document is itself valid draft-2020-12. +func compileSchema(t *testing.T, kind SchemaKind) *jsonschema.Schema { + t.Helper() + raw, err := Schema(kind) + if err != nil { + t.Fatalf("Schema(%s): %v", kind, err) + } + name, _ := SchemaFilename(kind) + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("%s is not valid JSON: %v", name, err) + } + c := jsonschema.NewCompiler() + if err := c.AddResource(name, doc); err != nil { + t.Fatalf("AddResource(%s): %v", name, err) + } + s, err := c.Compile(name) + if err != nil { + t.Fatalf("%s is not a valid JSON Schema: %v", name, err) + } + return s +} + +// yamlToJSONValue reads a YAML file and re-encodes it through encoding/json so +// the value carries the exact Go types jsonschema/v6 expects (float64 numbers, +// map[string]any objects) rather than goccy's decode types. +func yamlToJSONValue(t *testing.T, path string) any { + t.Helper() + src, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var loose any + if err := yaml.Unmarshal(src, &loose); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + encoded, err := json.Marshal(loose) + if err != nil { + t.Fatalf("re-encode %s: %v", path, err) + } + v, err := jsonschema.UnmarshalJSON(bytes.NewReader(encoded)) + if err != nil { + t.Fatalf("decode %s: %v", path, err) + } + return v +} + +// TestSchemaRoundTrip is the D16 guard: every fixture the Go validator accepts +// must also validate against the published JSON Schema. If a struct gains a field +// and the schema does not, additionalProperties:false fails here — in the same PR +// that changed the struct. +func TestSchemaRoundTrip(t *testing.T) { + // The Go path: this must load cleanly, or the fixture itself is broken. + if _, err := LoadAt(filepath.Join("testdata", "valid")); err != nil { + t.Fatalf("LoadAt(testdata/valid): %v", err) + } + + wsSchema := compileSchema(t, SchemaWorkspace) + projSchema := compileSchema(t, SchemaProject) + + var checked int + root := filepath.Join("testdata", "valid") + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + var schema *jsonschema.Schema + switch filepath.Base(path) { + case "workspace.yaml": + schema = wsSchema + case "devstack.yaml": + schema = projSchema + default: + return nil + } + checked++ + if err := schema.Validate(yamlToJSONValue(t, path)); err != nil { + t.Errorf("%s does not validate against the published schema:\n%v", path, err) + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + if checked < 3 { + t.Fatalf("expected at least 3 fixtures (1 workspace + 2 projects), checked %d", checked) + } +} + +// TestSchemaRejectsUnknownKeys pins additionalProperties:false, which is what +// makes the round-trip test above able to detect a struct that grew a field. +func TestSchemaRejectsUnknownKeys(t *testing.T) { + projSchema := compileSchema(t, SchemaProject) + doc, err := jsonschema.UnmarshalJSON(strings.NewReader(`{ + "apiVersion": "devstack/v1", + "kind": "Project", + "name": "api", + "services": {"api": {"template": "node.next"}}, + "totallyNotAField": true + }`)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if err := projSchema.Validate(doc); err == nil { + t.Fatal("expected an unknown top-level key to be rejected") + } +} + +// TestSchemaKindsResolve asserts every published kind has a readable document and +// that ParseSchemaKind accepts both the kind name and the file name. +func TestSchemaKindsResolve(t *testing.T) { + for _, kind := range SchemaKinds() { + if _, err := Schema(kind); err != nil { + t.Errorf("Schema(%s): %v", kind, err) + } + got, err := ParseSchemaKind(string(kind)) + if err != nil || got != kind { + t.Errorf("ParseSchemaKind(%q) = %q, %v; want %q", kind, got, err, kind) + } + } + if _, err := ParseSchemaKind("nope"); err == nil { + t.Error("expected an unknown kind to error") + } + if got, err := ParseSchemaKind("devstack.yaml"); err != nil || got != SchemaProject { + t.Errorf("ParseSchemaKind(devstack.yaml) = %q, %v; want project", got, err) + } +} diff --git a/internal/ide/ide.go b/internal/ide/ide.go index f12dc96..66e2866 100644 --- a/internal/ide/ide.go +++ b/internal/ide/ide.go @@ -20,7 +20,9 @@ package ide import ( "fmt" "path/filepath" + "regexp" "sort" + "strings" "github.com/open-source-cloud/devstack/internal/config" "github.com/open-source-cloud/devstack/internal/version" @@ -135,13 +137,36 @@ func (g *Generator) rel(abs string) string { return filepath.ToSlash(r) } -// schemaURL is the published JSON-Schema URL pinned to the binary's schema -// version, used as the editor authoring aid (yaml-language-server / yaml.schemas). -// The Go validator remains the source of truth (spec 17, DECISIONS D16). -func (g *Generator) schemaURL() string { +// schemaRef is the git ref the published schema URLs point at. A release binary +// pins its own tag so the schema can never drift from the binary that wrote the +// modeline; a dev/snapshot build has no such tag on GitHub, so it falls back to +// main rather than emitting a URL that 404s (the bug this replaced: every +// generated settings.json pointed at v/schemas/, a path that did not +// exist at any tag). +func (g *Generator) schemaRef() string { + if semverTagRE.MatchString(g.schemaVersion) { + return "v" + strings.TrimPrefix(g.schemaVersion, "v") + } + return "main" +} + +// semverTagRE matches a released version stamp (with or without a leading v), so +// only builds that correspond to a real git tag pin themselves to it. +var semverTagRE = regexp.MustCompile(`^v?\d+\.\d+\.\d+`) + +// schemaURL is the published JSON-Schema URL for one config file kind, used as +// the editor authoring aid (yaml-language-server / yaml.schemas). The Go +// validator remains the source of truth (spec 17, DECISIONS D16); the schemas +// themselves are hand-authored under schemas/ and round-trip tested against the +// config structs. +func (g *Generator) schemaURL(kind config.SchemaKind) string { + name, ok := config.SchemaFilename(kind) + if !ok { + name, _ = config.SchemaFilename(config.SchemaProject) + } return fmt.Sprintf( - "https://raw.githubusercontent.com/open-source-cloud/devstack/v%s/schemas/devstack.schema.json", - g.schemaVersion, + "https://raw.githubusercontent.com/open-source-cloud/devstack/%s/schemas/%s", + g.schemaRef(), name, ) } diff --git a/internal/ide/schemaurl_test.go b/internal/ide/schemaurl_test.go new file mode 100644 index 0000000..3860b17 --- /dev/null +++ b/internal/ide/schemaurl_test.go @@ -0,0 +1,66 @@ +package ide + +import ( + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/schemas" +) + +// TestSchemaURLResolvesToARealFile is the anti-404 guard. Every URL the editor +// configs emit must end in a schema document that actually exists in this repo — +// the previous implementation pointed at schemas/devstack.schema.json at a time +// when no schemas/ directory existed at any tag. +func TestSchemaURLResolvesToARealFile(t *testing.T) { + g := &Generator{schemaVersion: "1.2.3"} + for _, kind := range config.SchemaKinds() { + url := g.schemaURL(kind) + idx := strings.LastIndex(url, "/") + if idx < 0 { + t.Fatalf("malformed schema URL %q", url) + } + name := url[idx+1:] + if _, err := schemas.FS.ReadFile(name); err != nil { + t.Errorf("schema URL for %s points at %q, which is not in schemas/: %v", kind, name, err) + } + } +} + +// TestSchemaRefPinsReleasesAndFallsBackOtherwise: a release build pins its own +// tag so the schema can never drift from the binary that wrote the modeline; any +// other stamp (the "dev" default, a snapshot) has no tag on GitHub and must fall +// back to main instead of emitting a URL that 404s. +func TestSchemaRefPinsReleasesAndFallsBackOtherwise(t *testing.T) { + for _, tc := range []struct{ version, want string }{ + {"1.2.3", "v1.2.3"}, + {"v1.2.3", "v1.2.3"}, + {"0.26.0", "v0.26.0"}, + {"1.2.3-rc.1", "v1.2.3-rc.1"}, + {"dev", "main"}, + {"", "main"}, + {"snapshot", "main"}, + } { + g := &Generator{schemaVersion: tc.version} + if got := g.schemaRef(); got != tc.want { + t.Errorf("schemaRef(%q) = %q, want %q", tc.version, got, tc.want) + } + } +} + +// TestSchemaURLPerKind pins that workspace.yaml and devstack.yaml get DIFFERENT +// schemas — they did not before, so workspace.yaml was validated against the +// project schema and every field would have reported as unknown. +func TestSchemaURLPerKind(t *testing.T) { + g := &Generator{schemaVersion: "1.2.3"} + ws, proj := g.schemaURL(config.SchemaWorkspace), g.schemaURL(config.SchemaProject) + if ws == proj { + t.Fatalf("workspace and project must not share a schema URL (both %q)", ws) + } + if !strings.HasSuffix(ws, "/workspace.schema.json") { + t.Errorf("workspace URL = %q, want it to end in workspace.schema.json", ws) + } + if !strings.HasSuffix(proj, "/devstack.schema.json") { + t.Errorf("project URL = %q, want it to end in devstack.schema.json", proj) + } +} diff --git a/internal/ide/testdata/golden/acme.code-workspace b/internal/ide/testdata/golden/acme.code-workspace index 890d5e7..885ca5e 100644 --- a/internal/ide/testdata/golden/acme.code-workspace +++ b/internal/ide/testdata/golden/acme.code-workspace @@ -16,8 +16,10 @@ "settings": { "yaml.schemas": { "https://raw.githubusercontent.com/open-source-cloud/devstack/v1.2.3/schemas/devstack.schema.json": [ - "workspace.yaml", "**/devstack.yaml" + ], + "https://raw.githubusercontent.com/open-source-cloud/devstack/v1.2.3/schemas/workspace.schema.json": [ + "workspace.yaml" ] } }, diff --git a/internal/ide/workspace.go b/internal/ide/workspace.go index 85dd25e..bbfd054 100644 --- a/internal/ide/workspace.go +++ b/internal/ide/workspace.go @@ -46,7 +46,8 @@ func (g *Generator) buildCodeWorkspace() (Artifact, error) { Folders: folders, Settings: cwSettings{ YAMLSchemas: map[string][]string{ - g.schemaURL(): {"workspace.yaml", "**/devstack.yaml"}, + g.schemaURL(config.SchemaWorkspace): {"workspace.yaml"}, + g.schemaURL(config.SchemaProject): {"**/devstack.yaml"}, }, }, Extensions: cwExtensions{Recommendations: []string{devContainersExtension}}, @@ -93,7 +94,7 @@ type vscodeSettings struct { func (g *Generator) buildSettings(dir string) (Artifact, error) { vs := vscodeSettings{ YAMLSchemas: map[string][]string{ - g.schemaURL(): {"devstack.yaml"}, + g.schemaURL(config.SchemaProject): {"devstack.yaml"}, }, } data, err := marshalJSON(vs) diff --git a/internal/mcpserve/mcpserve.go b/internal/mcpserve/mcpserve.go new file mode 100644 index 0000000..5db5c53 --- /dev/null +++ b/internal/mcpserve/mcpserve.go @@ -0,0 +1,176 @@ +// Package mcpserve exposes devstack over the Model Context Protocol (spec 32). +// +// It is the ONLY package that imports the MCP SDK, per the project-wide rule that +// a fast-moving external dependency sits behind one internal seam. Everything it +// needs from the rest of devstack arrives through Deps as injected function +// values, so it never imports internal/cli — there is no import cycle, and the +// whole surface is testable with hand-written fakes and no subprocess. +// +// # Why the tools are the CLI +// +// Every tool handler runs the ordinary devstack command tree with --json and +// captures stdout. That is not a shortcut: it means there is no parallel API to +// keep in sync, tool semantics match the CLI exactly, and — critically — the +// lock discipline is inherited unchanged. Each call takes and releases the +// cross-process flock inside the command's own RunE, so this long-lived server +// process never holds it, and ARCHITECTURE's "stateless CLI, no daemon" model +// survives having an MCP server in front of it. +// +// # stdio hygiene +// +// An MCP stdio server must emit nothing on stdout but framed JSON-RPC. Deps.Run +// therefore captures the command's stdout into a buffer rather than letting it +// reach the real one, and the CLI forces quiet mode for this command. A test +// asserts stdout carries only protocol bytes. +package mcpserve + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "net" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Deps are the capabilities the server needs from the rest of devstack. Every +// field is required unless noted; New validates them so a wiring mistake fails at +// startup rather than on the first tool call. +type Deps struct { + // Version stamps the server identity reported to the client. + Version string + // Binary is the invoked command name, used in human-facing text. + Binary string + // Run executes a devstack command line and returns what it wrote to stdout. + // The implementation runs a FRESH command tree per call so no state leaks + // between requests. + Run func(ctx context.Context, argv []string) ([]byte, error) + // Docs lists, reads and searches the embedded documentation corpus. + Docs DocsSource + // Templates exposes the built-in template sources so an agent can read a + // real, currently-shipping template before writing one. + Templates func() fs.FS + // Schema returns a published JSON Schema document by kind name. + Schema func(kind string) ([]byte, error) + // SchemaKinds lists the published schema kinds. + SchemaKinds func() []string +} + +// DocsSource is the documentation retrieval the resources and the docs tool need. +type DocsSource interface { + List() ([]DocMeta, error) + Read(slug string) (DocMeta, []byte, error) +} + +// DocMeta is the subset of a document's metadata this package surfaces. +type DocMeta struct { + Slug string `json:"slug"` + Path string `json:"path"` + Title string `json:"title"` + Summary string `json:"summary"` + Section string `json:"section"` + Lines int `json:"lines"` +} + +// Options control which tools are registered. +type Options struct { + // ReadOnly registers only tools that cannot change anything. + ReadOnly bool + // AllowDestructive additionally registers the irreversible verbs. Off by + // default: those tools are ABSENT rather than merely annotated, because MCP + // has no TTY, so a mutating tool has to inject --yes and an irreversible one + // would then run with no confirmation anywhere in the chain. + AllowDestructive bool +} + +// Built is a constructed server together with a description of what it exposes, +// so callers and tests can report the surface without asking the SDK to +// enumerate it. +type Built struct { + Server *mcp.Server + Tools []string + Prompts []string +} + +// Build constructs the server with the tools, resources and prompts selected by +// opts. +func Build(d Deps, opts Options) (Built, error) { + if err := d.validate(); err != nil { + return Built{}, err + } + s := mcp.NewServer(&mcp.Implementation{ + Name: "devstack", + Title: "devstack", + Version: d.Version, + }, nil) + + tools := registerTools(s, d, opts) + registerResources(s, d) + registerPrompts(s, d) + return Built{Server: s, Tools: tools, Prompts: PromptNames()}, nil +} + +// New builds the server and returns just the SDK handle. +func New(d Deps, opts Options) (*mcp.Server, error) { + built, err := Build(d, opts) + if err != nil { + return nil, err + } + return built.Server, nil +} + +// Serve runs the server over stdio until the context is cancelled or the client +// disconnects. +// +// A client closing the connection is a NORMAL shutdown, not a failure: an MCP +// host starts this process, talks to it, and closes stdin when the user is done. +// Reporting that as an error would make every clean session exit non-zero and +// print a scary error block, so EOF and context cancellation return nil. +func Serve(ctx context.Context, d Deps, opts Options) error { + s, err := New(d, opts) + if err != nil { + return err + } + if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil && !isCleanShutdown(err) { + return err + } + return nil +} + +// isCleanShutdown reports whether an error is just the peer going away. +func isCleanShutdown(err error) bool { + switch { + case errors.Is(err, io.EOF), + errors.Is(err, context.Canceled), + errors.Is(err, net.ErrClosed): + return true + } + // The SDK wraps the transport error in prose ("Server is closing: EOF"), so + // fall back to a string check for the wrapped form. + msg := err.Error() + return strings.HasSuffix(msg, "EOF.") || strings.HasSuffix(msg, "EOF") +} + +func (d Deps) validate() error { + switch { + case d.Run == nil: + return fmt.Errorf("mcpserve: Deps.Run is required") + case d.Docs == nil: + return fmt.Errorf("mcpserve: Deps.Docs is required") + case d.Templates == nil: + return fmt.Errorf("mcpserve: Deps.Templates is required") + case d.Schema == nil || d.SchemaKinds == nil: + return fmt.Errorf("mcpserve: Deps.Schema and Deps.SchemaKinds are required") + } + return nil +} + +func (d Deps) binary() string { + if d.Binary == "" { + return "devstack" + } + return d.Binary +} diff --git a/internal/mcpserve/mcpserve_test.go b/internal/mcpserve/mcpserve_test.go new file mode 100644 index 0000000..d366b40 --- /dev/null +++ b/internal/mcpserve/mcpserve_test.go @@ -0,0 +1,453 @@ +package mcpserve + +import ( + "context" + "io/fs" + "strings" + "testing" + "testing/fstest" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// fakeRun records every command line a tool asked for and returns canned output, +// so the whole surface is exercised without Docker, a ledger or a subprocess. +type fakeRun struct { + calls []string + err error + out string +} + +func (f *fakeRun) run(_ context.Context, argv []string) ([]byte, error) { + f.calls = append(f.calls, strings.Join(argv, " ")) + if f.err != nil { + return []byte(f.out), f.err + } + if f.out == "" { + return []byte(`{"ok":true}`), nil + } + return []byte(f.out), nil +} + +type fakeDocs struct{} + +func (fakeDocs) List() ([]DocMeta, error) { + return []DocMeta{ + {Slug: "guide/templates", Path: "docs/guide/templates.md", Title: "Templates", Summary: "Authoring", Section: "guide", Lines: 249}, + {Slug: "architecture", Path: "docs/ARCHITECTURE.md", Title: "Architecture", Summary: "Design", Section: "root", Lines: 207}, + }, nil +} + +func (fakeDocs) Read(slug string) (DocMeta, []byte, error) { + list, _ := fakeDocs{}.List() + for _, d := range list { + if d.Slug == slug { + return d, []byte("# " + d.Title + "\n\nbody\n"), nil + } + } + return DocMeta{}, nil, errNotFound(slug) +} + +type errNotFound string + +func (e errNotFound) Error() string { return "no such doc " + string(e) } + +func testDeps(run *fakeRun) Deps { + return Deps{ + Version: "1.2.3", + Binary: "devstack", + Run: run.run, + Docs: fakeDocs{}, + Templates: func() fs.FS { + return fstest.MapFS{ + "postgres/template.yaml": &fstest.MapFile{Data: []byte("provides: postgres\n")}, + } + }, + Schema: func(kind string) ([]byte, error) { return []byte(`{"title":"` + kind + `"}`), nil }, + SchemaKinds: func() []string { return []string{"project", "workspace"} }, + } +} + +// connect wires a client to the server over the SDK's in-memory transports, so +// the protocol layer is genuinely exercised with no subprocess and no stdio. +func connect(t *testing.T, d Deps, opts Options) *mcp.ClientSession { + t.Helper() + built, err := Build(d, opts) + if err != nil { + t.Fatalf("Build: %v", err) + } + ctx := context.Background() + clientTr, serverTr := mcp.NewInMemoryTransports() + + go func() { + ss, err := built.Server.Connect(ctx, serverTr, nil) + if err != nil { + return + } + _ = ss.Wait() + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil) + cs, err := client.Connect(ctx, clientTr, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + t.Cleanup(func() { _ = cs.Close() }) + return cs +} + +func TestDepsValidation(t *testing.T) { + if _, err := New(Deps{}, Options{}); err == nil { + t.Fatal("expected New to reject empty Deps") + } + run := &fakeRun{} + d := testDeps(run) + d.Docs = nil + if _, err := New(d, Options{}); err == nil { + t.Error("expected New to require Docs") + } +} + +// TestToolSetIsPinned makes changing what a model can do to someone's machine a +// deliberate, test-breaking act. +func TestToolSetIsPinned(t *testing.T) { + run := &fakeRun{} + + readOnly, err := ToolNames(testDeps(run), Options{ReadOnly: true}) + if err != nil { + t.Fatal(err) + } + wantRead := []string{ + "devstack_commands", "devstack_config_schema", "devstack_config_show", + "devstack_config_validate", "devstack_context", "devstack_docs", + "devstack_doctor", "devstack_env_list", "devstack_generate_check", + "devstack_logs", "devstack_ports", "devstack_project_list", + "devstack_shared_status", "devstack_status", "devstack_template_lint", + "devstack_template_list", + } + assertSameSet(t, "--read-only", readOnly, wantRead) + + def, err := ToolNames(testDeps(run), Options{}) + if err != nil { + t.Fatal(err) + } + wantDefault := append(append([]string{}, wantRead...), + "devstack_ai_install", "devstack_db_create", "devstack_down", + "devstack_expose", "devstack_generate", "devstack_project_new", + "devstack_run", "devstack_s3_mb", "devstack_up", + ) + assertSameSet(t, "default", def, wantDefault) + + all, err := ToolNames(testDeps(run), Options{AllowDestructive: true}) + if err != nil { + t.Fatal(err) + } + wantAll := append(append([]string{}, wantDefault...), + "devstack_db_drop", "devstack_db_reset", "devstack_workspace_destroy", + ) + assertSameSet(t, "--allow-destructive", all, wantAll) +} + +// TestDestructiveToolsAreAbsentByDefault is the safety property that matters +// most: absent, not merely annotated. MCP has no TTY, so an exposed destructive +// tool would run with --yes and no confirmation anywhere in the chain. +func TestDestructiveToolsAreAbsentByDefault(t *testing.T) { + run := &fakeRun{} + for _, opts := range []Options{{}, {ReadOnly: true}} { + names, err := ToolNames(testDeps(run), opts) + if err != nil { + t.Fatal(err) + } + for _, n := range names { + switch n { + case "devstack_db_drop", "devstack_db_reset", "devstack_workspace_destroy": + t.Errorf("%+v exposes the destructive tool %s", opts, n) + } + } + } +} + +// TestSecretsAreNeverExposed: no tool may reach the secrets group, or provider +// material lands in a model's context. +func TestSecretsAreNeverExposed(t *testing.T) { + run := &fakeRun{} + for _, opts := range []Options{{}, {ReadOnly: true}, {AllowDestructive: true}} { + names, err := ToolNames(testDeps(run), opts) + if err != nil { + t.Fatal(err) + } + for _, n := range names { + for _, banned := range NeverRegistered() { + if strings.Contains(n, banned) { + t.Errorf("%+v exposes %q, which touches the never-registered group %q", opts, n, banned) + } + } + } + } +} + +func TestToolsListOverTheWire(t *testing.T) { + run := &fakeRun{} + cs := connect(t, testDeps(run), Options{}) + res, err := cs.ListTools(context.Background(), nil) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + if len(res.Tools) < 20 { + t.Fatalf("expected the full tool set, got %d", len(res.Tools)) + } + byName := map[string]*mcp.Tool{} + for _, tool := range res.Tools { + byName[tool.Name] = tool + if tool.Description == "" { + t.Errorf("%s has no description; the model routes on it", tool.Name) + } + if tool.Annotations == nil { + t.Errorf("%s has no annotations; the host cannot gate it", tool.Name) + } + } + if a := byName["devstack_status"].Annotations; a == nil || !a.ReadOnlyHint { + t.Error("devstack_status must be annotated readOnlyHint") + } + if a := byName["devstack_up"].Annotations; a == nil || a.ReadOnlyHint { + t.Error("devstack_up must not be annotated readOnlyHint") + } +} + +// TestToolCallRunsTheRightCommand is the heart of "the tools are the CLI": the +// handler must build the exact command line a human would type, with --json. +func TestToolCallRunsTheRightCommand(t *testing.T) { + for _, tc := range []struct { + tool string + args map[string]any + want string + }{ + {"devstack_status", nil, "--json status"}, + {"devstack_context", nil, "--json context"}, + {"devstack_config_validate", nil, "--json config validate"}, + {"devstack_config_schema", map[string]any{"kind": "workspace"}, "--json config schema --kind workspace"}, + {"devstack_config_schema", nil, "--json config schema --kind project"}, + {"devstack_generate_check", map[string]any{"project": "api"}, "--json generate --check --project api"}, + {"devstack_logs", map[string]any{"service": "api", "tail": 50}, "--json logs api --tail 50"}, + {"devstack_logs", nil, "--json logs --tail 200"}, + {"devstack_docs", map[string]any{"slug": "guide/templates"}, "--json ai docs guide/templates"}, + {"devstack_docs", map[string]any{"search": "port"}, "--json ai docs --search port"}, + {"devstack_up", map[string]any{"project": "api"}, "--json up api"}, + {"devstack_db_create", map[string]any{"name": "shop", "project": "api"}, "--json db create shop --project api"}, + } { + run := &fakeRun{} + cs := connect(t, testDeps(run), Options{}) + _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tc.tool, Arguments: tc.args, + }) + if err != nil { + t.Errorf("CallTool(%s): %v", tc.tool, err) + continue + } + if len(run.calls) != 1 { + t.Errorf("%s ran %d commands, want 1: %v", tc.tool, len(run.calls), run.calls) + continue + } + if run.calls[0] != tc.want { + t.Errorf("%s ran %q, want %q", tc.tool, run.calls[0], tc.want) + } + } +} + +// TestDestructiveToolsInjectYes: MCP has no TTY, so an allowed destructive tool +// must pass --yes or it would hang forever waiting for a confirmation. +func TestDestructiveToolsInjectYes(t *testing.T) { + run := &fakeRun{} + cs := connect(t, testDeps(run), Options{AllowDestructive: true}) + _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "devstack_db_drop", Arguments: map[string]any{"name": "shop"}, + }) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if len(run.calls) != 1 || !strings.Contains(run.calls[0], "--yes") { + t.Errorf("a destructive tool must inject --yes, got %v", run.calls) + } +} + +// TestToolFailureIsReportedAsContent: a failing command should reach the model as +// readable tool output, not as a protocol error it cannot interpret. +func TestToolFailureIsReportedAsContent(t *testing.T) { + run := &fakeRun{err: errNotFound("boom"), out: "partial"} + cs := connect(t, testDeps(run), Options{}) + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "devstack_status"}) + if err != nil { + t.Fatalf("CallTool returned a protocol error: %v", err) + } + if !res.IsError { + t.Error("expected IsError to be set") + } + var text string + for _, c := range res.Content { + if tc, ok := c.(*mcp.TextContent); ok { + text += tc.Text + } + } + if !strings.Contains(text, "boom") { + t.Errorf("the failure message should reach the model, got: %q", text) + } +} + +func TestResources(t *testing.T) { + run := &fakeRun{} + cs := connect(t, testDeps(run), Options{}) + ctx := context.Background() + + res, err := cs.ListResources(ctx, nil) + if err != nil { + t.Fatalf("ListResources: %v", err) + } + got := map[string]bool{} + for _, r := range res.Resources { + got[r.URI] = true + } + for _, want := range []string{ + "devstack://docs/index", + "devstack://docs/guide/templates", + "devstack://schema/project.json", + "devstack://schema/workspace.json", + "devstack://commands.json", + } { + if !got[want] { + t.Errorf("resource %s is not published", want) + } + } + + doc, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: "devstack://docs/guide/templates"}) + if err != nil { + t.Fatalf("ReadResource(docs): %v", err) + } + if len(doc.Contents) == 0 || !strings.Contains(doc.Contents[0].Text, "Templates") { + t.Errorf("unexpected document content: %+v", doc.Contents) + } + + // The resource TEMPLATE: a real, currently-shipping template source. + tmpl, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: "devstack://template/postgres"}) + if err != nil { + t.Fatalf("ReadResource(template): %v", err) + } + if len(tmpl.Contents) == 0 || !strings.Contains(tmpl.Contents[0].Text, "provides: postgres") { + t.Errorf("unexpected template content: %+v", tmpl.Contents) + } + + // A traversal attempt must not escape the template source. + if _, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: "devstack://template/../../etc/passwd"}); err == nil { + t.Error("expected a path-traversal template name to be rejected") + } +} + +func TestPrompts(t *testing.T) { + run := &fakeRun{} + cs := connect(t, testDeps(run), Options{}) + ctx := context.Background() + + list, err := cs.ListPrompts(ctx, nil) + if err != nil { + t.Fatalf("ListPrompts: %v", err) + } + got := map[string]bool{} + for _, p := range list.Prompts { + got[p.Name] = true + if p.Description == "" { + t.Errorf("prompt %s has no description", p.Name) + } + } + for _, want := range PromptNames() { + if !got[want] { + t.Errorf("prompt %s is not published", want) + } + } + + res, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{ + Name: "write-template", + Arguments: map[string]string{"name": "python.fastapi", "kind": "app"}, + }) + if err != nil { + t.Fatalf("GetPrompt: %v", err) + } + if len(res.Messages) == 0 { + t.Fatal("expected a prompt message") + } + text := res.Messages[0].Content.(*mcp.TextContent).Text + for _, want := range []string{"python.fastapi", "[[ ]]", "template lint", "UNRENDERED"} { + if !strings.Contains(text, want) { + t.Errorf("the write-template prompt should mention %q", want) + } + } + + // A required argument must be enforced. + if _, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "write-template"}); err == nil { + t.Error("expected a missing required argument to be rejected") + } +} + +// TestPromptsWarnAgainstTheClassicMistakes: the prompts exist to stop a model +// reaching for docker compose or editing generated files. +func TestPromptsWarnAgainstTheClassicMistakes(t *testing.T) { + run := &fakeRun{} + d := testDeps(run) + var all strings.Builder + for _, p := range prompts { + args := map[string]string{} + for _, a := range p.Args { + args[a.Name] = "x" + } + all.WriteString(p.Text(d, args)) + } + body := all.String() + for _, want := range []string{"docker-compose", ".devstack/", "shared-postgres"} { + if !strings.Contains(body, want) { + t.Errorf("no prompt warns about %q", want) + } + } +} + +// TestPromptsFollowTheAliasedBinary keeps generated instructions correct for an +// installation invoked as rq or uranus. +func TestPromptsFollowTheAliasedBinary(t *testing.T) { + run := &fakeRun{} + d := testDeps(run) + d.Binary = "rq" + for _, p := range prompts { + args := map[string]string{} + for _, a := range p.Args { + args[a.Name] = "x" + } + text := p.Text(d, args) + if !strings.Contains(text, "`rq ") { + t.Errorf("prompt %s issues no command as the invoked binary", p.Name) + } + // The product NAME may still appear as prose ("this devstack workspace"); + // what must not appear is a runnable command naming the wrong binary. + if strings.Contains(text, "`devstack ") { + t.Errorf("prompt %s hard-codes a `devstack ...` command while aliased", p.Name) + } + } +} + +func assertSameSet(t *testing.T, label string, got, want []string) { + t.Helper() + gotSet := map[string]bool{} + for _, g := range got { + gotSet[g] = true + } + wantSet := map[string]bool{} + for _, w := range want { + wantSet[w] = true + } + for w := range wantSet { + if !gotSet[w] { + t.Errorf("%s: tool %q is missing", label, w) + } + } + for g := range gotSet { + if !wantSet[g] { + t.Errorf("%s: unexpected tool %q — if this is intentional, update the pinned set", label, g) + } + } +} diff --git a/internal/mcpserve/prompts.go b/internal/mcpserve/prompts.go new file mode 100644 index 0000000..a70d07d --- /dev/null +++ b/internal/mcpserve/prompts.go @@ -0,0 +1,265 @@ +package mcpserve + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// promptSpec is one guided workflow. Prompts are the cross-client equivalent of +// slash commands — in Claude Code they surface as /mcp__devstack__ — which +// makes them the "commands" surface for every MCP client, not just Claude Code. +type promptSpec struct { + Name string + Title string + Description string + Args []*mcp.PromptArgument + Text func(d Deps, args map[string]string) string +} + +func arg(name, desc string, required bool) *mcp.PromptArgument { + return &mcp.PromptArgument{Name: name, Description: desc, Required: required} +} + +// prompts is the registered set. Each one encodes a workflow whose correct order +// is not guessable — the failure mode they prevent is a model doing the right +// steps in the wrong order, or reaching for docker compose. +var prompts = []promptSpec{ + { + Name: "onboard-repo", + Title: "Onboard a repository into devstack", + Description: "Add an existing repository to a devstack workspace and bring it up for the first time.", + Args: []*mcp.PromptArgument{arg("path", "path to the repository, relative to the workspace root", false)}, + Text: func(d Deps, a map[string]string) string { + b := d.binary() + path := a["path"] + if path == "" { + path = "" + } + return join( + "Onboard the repository at `"+path+"` into this devstack workspace.", + "", + "Follow this order and stop if a step fails:", + "", + "1. Read the current state: run `"+b+" context` and `"+b+" config show --json`.", + " If there is no workspace yet, run `"+b+" init` first.", + "2. Inspect the repository to determine its stack (package.json, composer.json,", + " go.mod, requirements.txt) and which backing services it actually needs.", + "3. List the available templates with `"+b+" template list --json` and pick the", + " closest match. Do not invent a template name.", + "4. Register the project: `"+b+" project new --path "+path+"`.", + "5. Edit its `devstack.yaml` to declare the services, their `uses:` entries for", + " the shared engines, and any `env.import` blocks it needs. Never write a", + " docker-compose.yaml by hand — devstack generates those.", + "6. Validate with `"+b+" config validate`, then `"+b+" generate --check`.", + "7. Bring it up with `"+b+" up` and confirm with `"+b+" status`.", + "", + "If you need to know how a field behaves, read the docs rather than guessing:", + "`"+b+" ai docs guide/projects` and `"+b+" ai docs guide/config-reference`.", + ) + }, + }, + { + Name: "add-service", + Title: "Add a service to a project", + Description: "Add a new container service to an existing devstack project, wired to the shared infrastructure.", + Args: []*mcp.PromptArgument{ + arg("service", "what the service should be, e.g. a Redis-backed worker", true), + arg("project", "which project to add it to; defaults to the active one", false), + }, + Text: func(d Deps, a map[string]string) string { + b := d.binary() + return join( + "Add this service to the devstack project: "+a["service"], + projectLine(a["project"]), + "", + "1. Run `"+b+" config show --json` to see the project's existing services and", + " the shared engines the workspace provides.", + "2. Run `"+b+" template list --json` and choose an existing template. Only author", + " a new one if nothing fits — and if so, read `"+b+" ai docs guide/templates` first.", + "3. Add the service under `services:` in the project's `devstack.yaml`. Declare", + " `uses:` for each shared engine it needs, and `env.import` to pull connection", + " attributes rather than hard-coding a host or password.", + "4. Remember: shared engines are reached by their DNS alias (`shared-postgres`),", + " never `localhost` and never the bare service name.", + "5. `"+b+" config validate`, then `"+b+" generate`, then `"+b+" up`.", + "6. Confirm with `"+b+" status`; if the service is unhealthy, `"+b+" logs `.", + "", + "Do not edit anything under `.devstack/` — it is generated output.", + ) + }, + }, + { + Name: "write-template", + Title: "Author a devstack service template", + Description: "Write a new devstack service template — the template.yaml, its build/ tree and a golden fixture.", + Args: []*mcp.PromptArgument{ + arg("name", "the template name, e.g. python.fastapi", true), + arg("kind", "engine (shared infrastructure) or app (a project service)", false), + }, + Text: func(d Deps, a map[string]string) string { + b := d.binary() + kind := a["kind"] + if kind == "" { + kind = "" + } + return join( + "Author a devstack template named `"+a["name"]+"` of kind `"+kind+"`.", + "", + "Read these first — the rules are not guessable:", + "- `"+b+" ai docs guide/templates` — the authoring guide.", + "- `"+b+" ai docs specs/23` — the authoring spec and its lints.", + "- The MCP resource `devstack://template/postgres` (an engine) or", + " `devstack://template/node.next` (an app) for a real, working example.", + "", + "The rules that will bite you:", + "- An ENGINE uses `image:`, declares `provides:`/`exports:`/`defaultPort:`, and", + " must never have `build:`. An APP uses `build:` and must never declare", + " `provides:`.", + "- Delimiters are `[[ ]]`, not `{{ }}`, so shell and Dockerfile `${VAR}` pass", + " through untouched. The only data in scope is `.params`.", + "- Metadata keys are parsed UNRENDERED. A `[[ ]]` action in `description`,", + " `provides`, `exports` or `params` is a hard lint error. Only `service:` and", + " `volumes:` are rendered.", + "- The FuncMap is deterministic: no now, no uuid, no randomness. Argument order", + " is pipeline-style, with the data last.", + "- Deep-merge REPLACES lists; use `$merge: append` to add to a parent's list.", + "", + "Then follow the loop:", + "1. `"+b+" template new "+a["name"]+" --kind "+kind+" --print-spec` and show the spec.", + "2. `"+b+" template new "+a["name"]+" --kind "+kind+" --from ` to materialize it.", + "3. `"+b+" template lint --show` until clean.", + "4. `"+b+" template test ` against the golden fixture.", + ) + }, + }, + { + Name: "debug-up-failure", + Title: "Diagnose a failing devstack up", + Description: "Work through a failing or unhealthy devstack workspace methodically.", + Args: []*mcp.PromptArgument{arg("symptom", "what went wrong, in the user's words", false)}, + Text: func(d Deps, a map[string]string) string { + b := d.binary() + out := []string{"Diagnose this devstack workspace."} + if s := a["symptom"]; s != "" { + out = append(out, "", "Reported symptom: "+s) + } + return join(append(out, + "", + "Work in this order and report what each step showed:", + "", + "1. `"+b+" doctor --json` — is the host itself sound (Docker, compose, git, ports)?", + "2. `"+b+" status --json` — which service is unhealthy, and what do the reference", + " counts look like?", + "3. `"+b+" logs --tail 200` — the actual error is almost always here.", + "4. `"+b+" config validate` — configuration errors report file:line:col.", + "5. `"+b+" generate --check` — are the generated artifacts stale?", + "6. `"+b+" shared status` — are the shared engines up and correctly ref-counted?", + "", + "Escalate only as far as needed: `"+b+" doctor --fix`, then `"+b+" shared doctor`,", + "then `"+b+" shared gc`. Do NOT run `"+b+" workspace destroy` or any other", + "destructive verb without asking the user first.", + "", + "Never work around a problem with `docker compose` or by editing `.devstack/`:", + "that forks a parallel stack and the edit is overwritten on the next generate.", + "", + "`"+b+" ai docs guide/recovery` has the full triage table.", + )...) + }, + }, + { + Name: "migrate-from-compose", + Title: "Migrate a docker-compose project to devstack", + Description: "Convert an existing docker-compose.yaml into devstack's two-file model, moving backing services to the shared stack.", + Args: []*mcp.PromptArgument{arg("path", "path to the existing docker-compose.yaml", false)}, + Text: func(d Deps, a map[string]string) string { + b := d.binary() + path := a["path"] + if path == "" { + path = "the existing docker-compose.yaml" + } + return join( + "Migrate "+path+" onto devstack.", + "", + "1. Read the compose file and classify every service into two buckets:", + " - BACKING SERVICES (postgres, mysql, redis, minio, kafka, nats, rabbitmq).", + " These become SHARED — one instance for the whole workspace, declared once", + " under `shared:` in workspace.yaml. Do not give each project its own.", + " - APPLICATION SERVICES. These stay per-project, under `services:` in the", + " repo's devstack.yaml.", + "2. `"+b+" template list --json` — map each service to a template. Backing", + " services almost always have one already.", + "3. Try `"+b+" import ` first; it does the mechanical conversion.", + "4. Translate connection settings: a service reaches the shared Postgres at the", + " DNS alias `shared-postgres`, not `localhost` and not `db`. Prefer", + " `env.import` from the shared service over hard-coded values.", + "5. Any per-project database, user or bucket becomes a `resources:` entry, so", + " `up` provisions it idempotently.", + "6. `"+b+" config validate`, `"+b+" generate`, `"+b+" up`, `"+b+" status`.", + "7. Once it works, the old docker-compose.yaml can go. Explain to the user that", + " `.devstack/` is now generated and must not be edited by hand.", + "", + "`"+b+" ai docs migration` covers the whole path in detail.", + ) + }, + }, +} + +// registerPrompts installs every prompt. +func registerPrompts(s *mcp.Server, d Deps) { + for _, p := range prompts { + s.AddPrompt(&mcp.Prompt{ + Name: p.Name, + Title: p.Title, + Description: p.Description, + Arguments: p.Args, + }, func(_ context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + spec, ok := promptByName(req.Params.Name) + if !ok { + return nil, fmt.Errorf("unknown prompt %q", req.Params.Name) + } + for _, a := range spec.Args { + if a.Required && req.Params.Arguments[a.Name] == "" { + return nil, fmt.Errorf("prompt %q requires the %q argument", spec.Name, a.Name) + } + } + return &mcp.GetPromptResult{ + Description: spec.Description, + Messages: []*mcp.PromptMessage{{ + Role: "user", + Content: &mcp.TextContent{Text: spec.Text(d, req.Params.Arguments)}, + }}, + }, nil + }) + } +} + +func promptByName(name string) (promptSpec, bool) { + for _, p := range prompts { + if p.Name == name { + return p, true + } + } + return promptSpec{}, false +} + +// PromptNames returns the registered prompt names, for tests and for the CLI's +// startup summary. +func PromptNames() []string { + out := make([]string, 0, len(prompts)) + for _, p := range prompts { + out = append(out, p.Name) + } + return out +} + +func join(lines ...string) string { return strings.Join(lines, "\n") } + +func projectLine(project string) string { + if project == "" { + return "Target the active project." + } + return "Target the project `" + project + "`." +} diff --git a/internal/mcpserve/resources.go b/internal/mcpserve/resources.go new file mode 100644 index 0000000..621abe4 --- /dev/null +++ b/internal/mcpserve/resources.go @@ -0,0 +1,143 @@ +package mcpserve + +import ( + "context" + "encoding/json" + "fmt" + "io/fs" + "path" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// URI scheme and prefixes for everything this server publishes. +const ( + uriDocsIndex = "devstack://docs/index" + uriDocsPre = "devstack://docs/" + uriTemplate = "devstack://template/{name}" + uriTemplPre = "devstack://template/" + uriSchemaPre = "devstack://schema/" + uriCommands = "devstack://commands.json" +) + +// registerResources publishes the read-only material a client can pull without +// spending a tool call: the documentation corpus, the real built-in templates, +// the config schemas and the command catalog. +func registerResources(s *mcp.Server, d Deps) { + s.AddResource(&mcp.Resource{ + URI: uriDocsIndex, + Name: "devstack documentation index", + Description: "Every document in devstack's documentation corpus, with its slug, title and summary.", + MIMEType: "application/json", + }, func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + list, err := d.Docs.List() + if err != nil { + return nil, err + } + body, err := json.MarshalIndent(map[string]any{"docs": list}, "", " ") + if err != nil { + return nil, err + } + return jsonResource(uriDocsIndex, body), nil + }) + + // One resource per document, so a client can browse rather than guess slugs. + list, err := d.Docs.List() + if err == nil { + for _, doc := range list { + uri := uriDocsPre + doc.Slug + s.AddResource(&mcp.Resource{ + URI: uri, + Name: doc.Title, + Description: doc.Summary, + MIMEType: "text/markdown", + }, func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + slug := strings.TrimPrefix(req.Params.URI, uriDocsPre) + _, body, err := d.Docs.Read(slug) + if err != nil { + return nil, err + } + return textResource(req.Params.URI, "text/markdown", body), nil + }) + } + } + + // The single highest-value resource: the source of a REAL, currently-shipping + // template. "Write me a template like postgres" should return the actual + // postgres template, not a plausible-looking invention. + s.AddResourceTemplate(&mcp.ResourceTemplate{ + URITemplate: uriTemplate, + Name: "devstack template source", + Description: "The template.yaml of a built-in devstack template, by name (postgres, redis, node.next, php.laravel.nginx, …). Read one before authoring your own.", + MIMEType: "text/yaml", + }, func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + name := strings.TrimPrefix(req.Params.URI, uriTemplPre) + body, err := readTemplate(d.Templates(), name) + if err != nil { + return nil, err + } + return textResource(req.Params.URI, "text/yaml", body), nil + }) + + for _, kind := range d.SchemaKinds() { + uri := uriSchemaPre + kind + ".json" + s.AddResource(&mcp.Resource{ + URI: uri, + Name: "devstack " + kind + " schema", + Description: "The published JSON Schema for " + schemaFileFor(kind) + ".", + MIMEType: "application/schema+json", + }, func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + k := strings.TrimSuffix(strings.TrimPrefix(req.Params.URI, uriSchemaPre), ".json") + body, err := d.Schema(k) + if err != nil { + return nil, err + } + return textResource(req.Params.URI, "application/schema+json", body), nil + }) + } + + s.AddResource(&mcp.Resource{ + URI: uriCommands, + Name: "devstack command catalog", + Description: "Every devstack command as data: path, summary, argument spec and flags, derived from the running binary.", + MIMEType: "application/json", + }, func(ctx context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + body, err := d.Run(ctx, []string{"--json", "ai", "commands"}) + if err != nil { + return nil, err + } + return jsonResource(uriCommands, body), nil + }) +} + +// readTemplate finds a template's manifest in the built-in source. +func readTemplate(src fs.FS, name string) ([]byte, error) { + if name == "" || strings.Contains(name, "/") || strings.Contains(name, "..") { + return nil, fmt.Errorf("invalid template name %q", name) + } + body, err := fs.ReadFile(src, path.Join(name, "template.yaml")) + if err != nil { + return nil, fmt.Errorf("no built-in template %q (list them with the devstack_template_list tool)", name) + } + return body, nil +} + +// schemaFileFor names the config file a schema kind describes, for the human +// description shown in a client's resource list. +func schemaFileFor(kind string) string { + if kind == "workspace" { + return "workspace.yaml" + } + return "devstack.yaml" +} + +func textResource(uri, mime string, body []byte) *mcp.ReadResourceResult { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{URI: uri, MIMEType: mime, Text: string(body)}}, + } +} + +func jsonResource(uri string, body []byte) *mcp.ReadResourceResult { + return textResource(uri, "application/json", body) +} diff --git a/internal/mcpserve/tools.go b/internal/mcpserve/tools.go new file mode 100644 index 0000000..64576f1 --- /dev/null +++ b/internal/mcpserve/tools.go @@ -0,0 +1,364 @@ +package mcpserve + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// toolClass decides whether a tool is registered, and with what annotations. +type toolClass int + +const ( + // classRead cannot change anything. Always registered. + classRead toolClass = iota + // classWrite mutates the workspace but is recoverable — bring a stack up, + // regenerate artifacts, create a database. Registered by default. + classWrite + // classDestructive is irreversible: it deletes data or tears a workspace + // down. ABSENT unless --allow-destructive, because MCP has no TTY and a + // mutating tool must inject --yes, which would leave nothing between a model + // and permanent data loss. + classDestructive +) + +// A tool that is never registered at any setting. The secrets group would put +// provider material into a model's context; `aws --` and `shell` are arbitrary +// command execution wearing a devstack hat, which the agent's own shell already +// provides with a per-command permission prompt. +var neverRegistered = []string{"secrets", "aws", "shell"} + +// Argument shapes. A small set covers the whole surface, and the SDK infers each +// tool's input schema from the struct, so there is no hand-written JSON Schema to +// drift. + +type noArgs struct{} + +type projectArgs struct { + Project string `json:"project,omitempty" jsonschema:"limit the operation to one project by name"` +} + +type nameProjectArgs struct { + Name string `json:"name" jsonschema:"the resource name to create"` + Project string `json:"project,omitempty" jsonschema:"the owning project; defaults to the active one"` +} + +type serviceArgs struct { + Service string `json:"service,omitempty" jsonschema:"a single service name; omit for all services"` + Project string `json:"project,omitempty" jsonschema:"the owning project; defaults to the active one"` + Tail int `json:"tail,omitempty" jsonschema:"how many trailing log lines to return (default 200)"` +} + +type pathArgs struct { + Path string `json:"path" jsonschema:"a filesystem path to a template directory"` +} + +type taskArgs struct { + Task string `json:"task" jsonschema:"the task name from the project's tasks: block"` + Project string `json:"project,omitempty" jsonschema:"the owning project; defaults to the active one"` +} + +type docsArgs struct { + Slug string `json:"slug,omitempty" jsonschema:"a document slug such as guide/templates, or a spec number such as 23"` + Search string `json:"search,omitempty" jsonschema:"search the corpus instead of reading one document"` + Limit int `json:"limit,omitempty" jsonschema:"maximum search results (default 10)"` +} + +type kindArgs struct { + Kind string `json:"kind,omitempty" jsonschema:"which config file to describe: project or workspace"` +} + +// result is the uniform output shape: the command's stdout, plus the argv so a +// model can see (and tell the user) exactly what ran. +type result struct { + Command string `json:"command" jsonschema:"the devstack command line that produced this"` + Output string `json:"output" jsonschema:"the command's JSON output"` +} + +// allows reports whether a class is registered under the given options. +func allows(c toolClass, opts Options) bool { + switch c { + case classRead: + return true + case classWrite: + return !opts.ReadOnly + case classDestructive: + return !opts.ReadOnly && opts.AllowDestructive + } + return false +} + +// registrar decides what gets registered and remembers what did, so the tool set +// can be reported without asking the SDK to enumerate it. +type registrar struct { + opts Options + registered []string +} + +func (r *registrar) admit(name string, c toolClass) bool { + if !allows(c, r.opts) { + return false + } + r.registered = append(r.registered, name) + return true +} + +// registerTools installs the tool set selected by opts and returns the names it +// registered, in registration order. +func registerTools(s *mcp.Server, d Deps, opts Options) []string { + reg := ®istrar{opts: opts} + + // --- read --------------------------------------------------------------- + + addCmd(s, d, reg, classRead, false, "devstack_status", + "Service health, the last saga outcome and the shared-service reference graph for the current workspace. Start here when asked what is running.", + func(noArgs) []string { return []string{"status"} }) + + addCmd(s, d, reg, classRead, false, "devstack_context", + "The active workspace, project, role, Docker context and version. The cheapest way to find out where you are.", + func(noArgs) []string { return []string{"context"} }) + + addCmd(s, d, reg, classRead, false, "devstack_config_show", + "The resolved workspace configuration: every project, its services and the shared engines they consume.", + func(noArgs) []string { return []string{"config", "show"} }) + + addCmd(s, d, reg, classRead, false, "devstack_config_validate", + "Validate workspace.yaml and every devstack.yaml, including cross-references and cycles. Errors report the exact file:line:col.", + func(noArgs) []string { return []string{"config", "validate"} }) + + addCmd(s, d, reg, classRead, false, "devstack_config_schema", + "The published JSON Schema for devstack.yaml or workspace.yaml — the exact field contract for authoring either file.", + func(a kindArgs) []string { + kind := a.Kind + if kind == "" { + kind = "project" + } + return []string{"config", "schema", "--kind", kind} + }) + + addCmd(s, d, reg, classRead, false, "devstack_doctor", + "The host preflight matrix: Docker, compose, git, ports and paths. Run this first when a command fails for an unclear reason.", + func(noArgs) []string { return []string{"doctor"} }) + + addCmd(s, d, reg, classRead, false, "devstack_generate_check", + "Report whether the generated compose and build artifacts are stale, without writing anything.", + func(a projectArgs) []string { return withProject([]string{"generate", "--check"}, a.Project) }) + + addCmd(s, d, reg, classRead, false, "devstack_template_list", + "Every available service template with its metadata: what it provides, what it exports, its default port and its parameters. Read this before writing a template.", + func(noArgs) []string { return []string{"template", "list"} }) + + addCmd(s, d, reg, classRead, false, "devstack_template_lint", + "Lint a template directory: the authoring lints plus compose-go validation of the rendered service.", + func(a pathArgs) []string { return []string{"template", "lint", a.Path} }) + + addCmd(s, d, reg, classRead, false, "devstack_shared_status", + "The shared engines: which are running, their reference counts and which projects hold them.", + func(noArgs) []string { return []string{"shared", "status"} }) + + addCmd(s, d, reg, classRead, false, "devstack_ports", + "Host ports currently published for shared services, with connection strings.", + func(noArgs) []string { return []string{"ports"} }) + + addCmd(s, d, reg, classRead, false, "devstack_project_list", + "Every project registered in this workspace.", + func(noArgs) []string { return []string{"project", "list"} }) + + addCmd(s, d, reg, classRead, false, "devstack_env_list", + "The local environment variables declared for a service.", + func(a serviceArgs) []string { + argv := []string{"env", "list"} + argv = withProject(argv, a.Project) + if a.Service != "" { + argv = append(argv, "--service", a.Service) + } + return argv + }) + + addCmd(s, d, reg, classRead, false, "devstack_logs", + "Recent logs across the project and shared stacks. Use this to find out WHY a service is unhealthy.", + func(a serviceArgs) []string { + argv := []string{"logs"} + if a.Service != "" { + argv = append(argv, a.Service) + } + tail := a.Tail + if tail <= 0 { + tail = 200 + } + return append(withProject(argv, a.Project), "--tail", fmt.Sprint(tail)) + }) + + addCmd(s, d, reg, classRead, false, "devstack_docs", + "Read or search devstack's documentation, which is compiled into the binary. Prefer this over guessing how a feature behaves.", + func(a docsArgs) []string { + argv := []string{"ai", "docs"} + switch { + case a.Search != "": + argv = append(argv, "--search", a.Search) + if a.Limit > 0 { + argv = append(argv, "--limit", fmt.Sprint(a.Limit)) + } + case a.Slug != "": + argv = append(argv, a.Slug) + } + return argv + }) + + addCmd(s, d, reg, classRead, false, "devstack_commands", + "The whole devstack command surface as data: every path, its summary, argument spec and flags.", + func(noArgs) []string { return []string{"ai", "commands"} }) + + // --- write -------------------------------------------------------------- + + addCmd(s, d, reg, classWrite, true, "devstack_up", + "Bring the workspace up: ensure the shared network, start the shared engines, provision each project's isolated data, then compose up. Idempotent.", + func(a projectArgs) []string { return withProjectArg([]string{"up"}, a.Project) }) + + addCmd(s, d, reg, classWrite, true, "devstack_down", + "Stop this workspace's project stacks and release their references. Data is preserved.", + func(a projectArgs) []string { return withProjectArg([]string{"down"}, a.Project) }) + + addCmd(s, d, reg, classWrite, true, "devstack_generate", + "Re-render the compose and build artifacts from the current configuration and templates.", + func(a projectArgs) []string { return withProject([]string{"generate"}, a.Project) }) + + addCmd(s, d, reg, classWrite, false, "devstack_run", + "Run a task from the project's tasks: graph, in dependency order.", + func(a taskArgs) []string { return withProject([]string{"run", a.Task}, a.Project) }) + + addCmd(s, d, reg, classWrite, true, "devstack_db_create", + "Create a tenant database on the shared Postgres. Idempotent.", + func(a nameProjectArgs) []string { + return withProject([]string{"db", "create", a.Name}, a.Project) + }) + + addCmd(s, d, reg, classWrite, true, "devstack_s3_mb", + "Create a tenant bucket on the shared object store. Idempotent.", + func(a nameProjectArgs) []string { + return withProject([]string{"s3", "mb", a.Name}, a.Project) + }) + + addCmd(s, d, reg, classWrite, false, "devstack_project_new", + "Scaffold a devstack.yaml for a new project and register it in workspace.yaml.", + func(a nameProjectArgs) []string { return []string{"project", "new", a.Name} }) + + addCmd(s, d, reg, classWrite, true, "devstack_expose", + "Publish the shared services on stable localhost ports so host tools can reach them.", + func(noArgs) []string { return []string{"expose"} }) + + addCmd(s, d, reg, classWrite, true, "devstack_ai_install", + "Write or refresh the agent-integration files (skills, the AGENTS.md block, the MCP registration) in this repository.", + func(noArgs) []string { return []string{"ai", "install"} }) + + // --- destructive (absent unless explicitly allowed) ---------------------- + + addCmd(s, d, reg, classDestructive, false, "devstack_db_drop", + "Permanently drop a tenant database. This deletes data and cannot be undone.", + func(a nameProjectArgs) []string { + return withProject([]string{"db", "drop", a.Name, "--yes"}, a.Project) + }) + + addCmd(s, d, reg, classDestructive, false, "devstack_db_reset", + "Drop and recreate a tenant database. This deletes data and cannot be undone.", + func(a projectArgs) []string { + return withProject([]string{"db", "reset", "--yes"}, a.Project) + }) + + addCmd(s, d, reg, classDestructive, false, "devstack_workspace_destroy", + "Tear down this workspace's stacks and release its references and ports. Irreversible.", + func(noArgs) []string { return []string{"workspace", "destroy", "--yes"} }) + + return reg.registered +} + +// addCmd registers one CLI-backed tool, if its class is allowed. +// +// The handler builds argv, prepends --json, and runs the ordinary command tree. +// Mutating tools additionally get --yes injected by their argv builder, because +// MCP has no TTY to confirm on — which is exactly why the irreversible verbs are +// gated behind a separate flag rather than merely annotated. +func addCmd[In any]( + s *mcp.Server, + d Deps, + reg *registrar, + class toolClass, + idempotent bool, + name, description string, + argv func(In) []string, +) { + if !reg.admit(name, class) { + return + } + closedWorld := false + destructive := class == classDestructive + tool := &mcp.Tool{ + Name: name, + Description: description, + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: class == classRead, + DestructiveHint: &destructive, + IdempotentHint: idempotent, + OpenWorldHint: &closedWorld, + }, + } + mcp.AddTool(s, tool, func(ctx context.Context, _ *mcp.CallToolRequest, in In) (*mcp.CallToolResult, result, error) { + args := append([]string{"--json"}, argv(in)...) + out, err := d.Run(ctx, args) + line := d.binary() + " " + strings.Join(args, " ") + if err != nil { + // Surface the failure to the model as tool content rather than a + // protocol error: the command's own message is the useful part, and + // devstack's errors carry the command, exit code and remediation. + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{ + Text: fmt.Sprintf("%s\n\n%v\n%s", line, err, out), + }}, + }, result{}, nil + } + return nil, result{Command: line, Output: string(out)}, nil + }) +} + +// withProject appends --project when a project was named. +func withProject(argv []string, project string) []string { + if project != "" { + return append(argv, "--project", project) + } + return argv +} + +// withProjectArg appends a positional project name, which up/down take instead of +// a flag. +func withProjectArg(argv []string, project string) []string { + if project != "" { + return append(argv, project) + } + return argv +} + +// ToolNames returns the registered tool names for a given option set, sorted. It +// exists so a test can pin the surface: adding or removing a tool should be a +// deliberate, test-breaking act rather than a silent change in what a model can +// do to someone's machine. +func ToolNames(d Deps, opts Options) ([]string, error) { + built, err := Build(d, opts) + if err != nil { + return nil, err + } + names := append([]string{}, built.Tools...) + sort.Strings(names) + return names, nil +} + +// NeverRegistered lists the command groups that are not exposed as tools at any +// setting. +func NeverRegistered() []string { + out := make([]string, len(neverRegistered)) + copy(out, neverRegistered) + return out +} diff --git a/schemas/devstack.schema.json b/schemas/devstack.schema.json new file mode 100644 index 0000000..54943a9 --- /dev/null +++ b/schemas/devstack.schema.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/open-source-cloud/devstack/main/schemas/devstack.schema.json", + "title": "devstack project (devstack.yaml)", + "description": "One repo's devstack.yaml — the portable project layer: services rendered from templates, the shared services they consume, declarative data-plane resources, lifecycle hooks and the task graph. Hand-authored per DECISIONS D16; the Go validator in internal/config is the source of truth and a CI round-trip test keeps the two aligned.", + "type": "object", + "required": ["apiVersion", "kind", "name", "services"], + "additionalProperties": false, + "properties": { + "apiVersion": { "const": "devstack/v1" }, + "kind": { "const": "Project" }, + "name": { "$ref": "#/$defs/dsname", "description": "Project name. Must match the name workspace.yaml lists for this path." }, + "services": { + "type": "object", + "description": "Containers in this project's stack, keyed by service name.", + "minProperties": 1, + "propertyNames": { "$ref": "#/$defs/dsname" }, + "additionalProperties": { "$ref": "#/$defs/service" } + }, + "resources": { + "type": "array", + "description": "Declarative data-plane resources this project needs INSIDE a shared engine (spec 27). Provisioned idempotently by `up`. Removing an entry never auto-drops the resource — teardown is always explicit.", + "items": { "$ref": "#/$defs/resourceDecl" } + }, + "tasks": { + "type": "object", + "description": "The non-container task graph run by `devstack run` (spec 31).", + "propertyNames": { "$ref": "#/$defs/dsname" }, + "additionalProperties": { "$ref": "#/$defs/task" } + }, + "hooks": { "$ref": "#/$defs/hooks" } + }, + "$defs": { + "dsname": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,62}$", + "description": "Compose/DNS-safe identifier: lowercase, starts with a letter, up to 63 chars of [a-z0-9_-]." + }, + "duration": { + "type": "string", + "pattern": "^([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$", + "description": "A Go/Compose duration string, e.g. \"5s\", \"1m30s\"." + }, + "cpus": { + "type": "string", + "pattern": "^[0-9]*\\.?[0-9]+$", + "description": "Fractional cores as a string, e.g. \"1.5\". Must be greater than zero." + }, + "platform": { + "type": "string", + "pattern": "^[a-z0-9]+/[a-z0-9]+(/[a-z0-9]+)?$", + "description": "An os/arch[/variant] selector, e.g. linux/amd64, linux/arm64/v8." + }, + "envMap": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Literal environment variables. Values support ${env.NAME}, ${self.attr}, ${ref:...}, ${profile} and ${workspace.name} interpolation, plus secret://provider/path#key refs." + }, + "params": { + "type": "object", + "description": "Template parameters. Accepted keys and types are declared by the template's params: block (see `devstack template list --json`).", + "additionalProperties": true + }, + "resources": { + "type": "object", + "title": "Resource limits", + "description": "CPU/memory/PID limits (spec 18). Lowered to BOTH deploy.resources.limits.* and the legacy top-level cpus/mem_limit/pids_limit.", + "additionalProperties": false, + "properties": { + "cpus": { "$ref": "#/$defs/cpus" }, + "memoryMB": { "type": "integer", "minimum": 0, "description": "Hard memory limit in MB." }, + "memoryReserveMB": { "type": "integer", "minimum": 0, "description": "Soft memory reservation in MB (scheduling hint)." }, + "pidsLimit": { "type": "integer", "minimum": 0, "description": "Maximum number of PIDs." } + } + }, + "hook": { + "type": "object", + "title": "Lifecycle hook", + "description": "One declarative command run at a saga phase (spec 11).", + "required": ["name", "run", "command"], + "additionalProperties": false, + "properties": { + "name": { "$ref": "#/$defs/dsname" }, + "run": { "enum": ["host", "exec"], "description": "host runs via os/exec on the host; exec shells into a running service with `compose exec -T`." }, + "service": { "type": "string", "description": "Target service. Required when run: exec." }, + "command": { "type": "array", "minItems": 1, "items": { "type": "string" }, "description": "argv array — never shell-split by devstack." }, + "workdir": { "type": "string" }, + "env": { "$ref": "#/$defs/envMap" }, + "timeout": { "$ref": "#/$defs/duration" }, + "retries": { "type": "integer", "minimum": 0 }, + "onFailure": { "enum": ["abort", "warn", "continue"] }, + "once": { "type": "boolean", "description": "Run at most once per workspace (firstRun ledger)." } + } + }, + "hooks": { + "type": "object", + "title": "Lifecycle hooks", + "description": "Hooks grouped by saga phase (spec 11). Lists REPLACE on overlay merge unless the YAML opts into `$merge: append`.", + "additionalProperties": false, + "properties": { + "preUp": { "type": "array", "items": { "$ref": "#/$defs/hook" } }, + "firstRun": { "type": "array", "items": { "$ref": "#/$defs/hook" } }, + "postUp": { "type": "array", "items": { "$ref": "#/$defs/hook" } }, + "postPull": { "type": "array", "items": { "$ref": "#/$defs/hook" } }, + "preDown": { "type": "array", "items": { "$ref": "#/$defs/hook" } } + } + } +, + "service": { + "type": "object", + "title": "Service", + "description": "One container in the project stack, rendered from a template.", + "required": ["template"], + "additionalProperties": false, + "properties": { + "template": { "type": "string", "description": "Template ref, e.g. node.next, php.laravel.nginx. See `devstack template list`." }, + "params": { "$ref": "#/$defs/params" }, + "uses": { + "type": "array", + "items": { "type": "string", "pattern": "^workspace\\.shared\\.[a-z][a-z0-9_-]*$" }, + "description": "Shared services this service consumes, as workspace.shared.." + }, + "env": { "$ref": "#/$defs/env" }, + "ports": { + "type": "object", + "description": "Named in-container ports, e.g. { http: 3000 }.", + "additionalProperties": { "type": "integer", "minimum": 1, "maximum": 65535 } + }, + "profiles": { "type": "array", "items": { "type": "string" }, "description": "Compose profile membership tags (spec 12)." }, + "memoryMB": { "type": "integer", "minimum": 0, "description": "Shorthand for resources.memoryMB; also feeds the workspace memory budget." }, + "resources": { "$ref": "#/$defs/resources" }, + "platform": { "$ref": "#/$defs/platform" }, + "healthcheck": { "$ref": "#/$defs/healthcheck" }, + "dependsOn": { "type": "array", "items": { "$ref": "#/$defs/dependsOn" } } + } + }, + "env": { + "type": "object", + "title": "Environment", + "description": "Container environment. raw/prefixed are literal (with ${...} interpolation); import pulls exported vars from another service.", + "additionalProperties": false, + "properties": { + "raw": { "$ref": "#/$defs/envMap" }, + "prefixed": { "$ref": "#/$defs/envMap" }, + "import": { + "type": "array", + "items": { + "type": "object", + "required": ["from"], + "additionalProperties": false, + "properties": { + "from": { "type": "string", "description": "Reference path, e.g. workspace.shared.postgres or workspace..." }, + "vars": { "type": "array", "items": { "type": "string" }, "description": "Exported attribute names to import. Omit for all exports." } + } + } + } + } + }, + "healthcheck": { + "type": "object", + "title": "Healthcheck", + "description": "Readiness probe (spec 10). Compiles to both a Compose healthcheck: block and a tool-side prober.", + "required": ["kind"], + "additionalProperties": false, + "properties": { + "kind": { "enum": ["tcp", "http", "https", "exec", "pg_isready", "redis"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "path": { "type": "string", "description": "http/https only." }, + "expectStatus": { "type": "string", "description": "http/https only: \"200\" or a range like \"200-399\"." }, + "host": { "type": "string", "description": "http/https Host header." }, + "command": { "type": "array", "items": { "type": "string" }, "description": "exec kind: argv; exit 0 means healthy." }, + "user": { "type": "string", "description": "pg_isready only." }, + "db": { "type": "string", "description": "pg_isready only." }, + "auth": { "type": "string", "description": "redis only; may be a secret:// ref." }, + "interval": { "$ref": "#/$defs/duration" }, + "timeout": { "$ref": "#/$defs/duration" }, + "retries": { "type": "integer", "minimum": 0 }, + "startPeriod": { "$ref": "#/$defs/duration" } + } + }, + "dependsOn": { + "type": "object", + "title": "Ordering edge", + "description": "A readiness-ordering edge (spec 10). A healthy edge requires the target to declare a healthcheck.", + "required": ["service"], + "additionalProperties": false, + "properties": { + "service": { "type": "string", "description": "An intra-project service name, or workspace.shared.." }, + "condition": { "enum": ["healthy", "started"], "description": "Default: healthy." } + } + }, + "resourceDecl": { + "type": "object", + "title": "Declarative resource", + "required": ["uses", "kind"], + "additionalProperties": false, + "properties": { + "uses": { "type": "string", "pattern": "^workspace\\.shared\\.[a-z][a-z0-9_-]*$", "description": "The shared engine that hosts this resource." }, + "kind": { "enum": ["database", "user", "bucket", "lifecycle", "queue", "stream", "topic"] }, + "name": { "type": "string", "description": "Engine-level identifier. Defaults to the project name." }, + "engine": { "type": "string", "description": "Optional; inferred from the uses target's template." }, + "params": { "$ref": "#/$defs/params" }, + "credentials": { "enum": ["predictable", "generated"] } + } + }, + "task": { + "type": "object", + "title": "Task", + "description": "One node in the task graph (spec 31). A short-lived command, not a container.", + "required": ["command"], + "additionalProperties": false, + "properties": { + "command": { "type": "array", "minItems": 1, "items": { "type": "string" }, "description": "argv array — never shell-split by devstack." }, + "run": { "enum": ["host", "exec"], "description": "Default: host." }, + "service": { "type": "string", "description": "Target service for run: exec." }, + "deps": { "type": "array", "items": { "type": "string" }, "description": "Task names that must complete first. Cycles are rejected at run time." }, + "workdir": { "type": "string" }, + "env": { "$ref": "#/$defs/envMap" }, + "watch": { "type": "boolean", "description": "Long-running dev-server task that --watch keeps alive." } + } + } + } +} diff --git a/schemas/embed.go b/schemas/embed.go new file mode 100644 index 0000000..5a576b0 --- /dev/null +++ b/schemas/embed.go @@ -0,0 +1,21 @@ +// Package schemas embeds the published JSON Schema documents compiled into the +// binary via go:embed (spec 01, DECISIONS D16). They are the editor/agent +// authoring aid — yaml-language-server picks them up through the $schema modeline +// internal/ide writes, and `devstack config schema` prints them for any other +// consumer. +// +// The schemas are HAND-AUTHORED, not generated: validator/v10's tag vocabulary +// (dsname/duration/cpus/platform/dockerhost, oneof, dive) plus the cross-field +// resolvers and the ${env./self./ref:/profile} grammar do not round-trip through +// tag introspection. The Go validator in internal/config stays the source of +// truth; TestSchemaRoundTrip keeps the two aligned by validating every fixture +// through both paths. +package schemas + +import "embed" + +//go:embed devstack.schema.json workspace.schema.json +var files embed.FS + +// FS is the embedded schema root: one .schema.json per config file kind. +var FS = files diff --git a/schemas/workspace.schema.json b/schemas/workspace.schema.json new file mode 100644 index 0000000..72895b1 --- /dev/null +++ b/schemas/workspace.schema.json @@ -0,0 +1,218 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/open-source-cloud/devstack/main/schemas/workspace.schema.json", + "title": "devstack workspace (workspace.yaml)", + "description": "The workspace root file — the shared layer: shared infrastructure services, secret providers, network/proxy/tunnel, the Docker backend, service slices, and the project list. Hand-authored per DECISIONS D16; the Go validator in internal/config is the source of truth and a CI round-trip test keeps the two aligned.", + "type": "object", + "required": ["apiVersion", "kind", "name"], + "additionalProperties": false, + "properties": { + "apiVersion": { "const": "devstack/v1" }, + "kind": { "const": "Workspace" }, + "name": { "$ref": "#/$defs/dsname", "description": "Workspace name. Used for the compose project prefix and the ledger key." }, + "aliases": { + "type": "array", + "items": { "$ref": "#/$defs/dsname" }, + "description": "Alternate argv[0] names this workspace answers to." + }, + "profiles": { + "type": "object", + "description": "The env OVERLAY selector, distinct from the service slices in `groups`.", + "additionalProperties": false, + "properties": { + "default": { "type": "string", "description": "Default overlay name. Defaults to dev. Available as ${profile}." } + } + }, + "defaultProfile": { + "type": "string", + "description": "The service slice `up` activates when no --profile is given (spec 12)." + }, + "groups": { + "type": "object", + "description": "Named workspace-level service slices (spec 12).", + "propertyNames": { "$ref": "#/$defs/dsname" }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "services": { "type": "array", "items": { "type": "string" } }, + "memoryHintMB": { "type": "integer", "minimum": 0 } + } + } + }, + "memoryBudgetMB": { + "type": "integer", + "minimum": 0, + "description": "Warn when the active services' memoryMB sum exceeds this." + }, + "secrets": { + "type": "object", + "description": "Providers that resolve secret:// refs (spec 04). Values are never written to generated files.", + "additionalProperties": false, + "properties": { + "providers": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "kind"], + "additionalProperties": false, + "properties": { + "name": { "$ref": "#/$defs/dsname" }, + "kind": { "type": "string", "description": "Provider backend, e.g. sops, aws, infisical." }, + "env": { "type": "string" }, + "projectId": { "type": "string" }, + "region": { "type": "string" } + } + } + } + } + }, + "network": { + "type": "object", + "description": "Reverse proxy and public-tunnel config (spec 05).", + "additionalProperties": false, + "properties": { + "proxy": { + "type": "object", + "additionalProperties": false, + "properties": { + "engine": { "enum": ["caddy", "traefik", "nginx"] }, + "httpsLocal": { "type": "boolean", "description": "Opt in to local HTTPS at https://..localhost." } + } + }, + "tunnel": { + "type": "object", + "additionalProperties": false, + "properties": { + "provider": { "type": "string" }, + "hostname": { "type": "string" } + } + } + } + }, + "backend": { + "type": "object", + "title": "Docker backend", + "description": "Where the shared stack runs (spec 21). Omit for the local daemon. Set exactly one of context or host.", + "additionalProperties": false, + "properties": { + "context": { "type": "string", "description": "A `docker context` name, typically an ssh:// one." }, + "host": { "$ref": "#/$defs/dockerhost" } + }, + "not": { "required": ["context", "host"] } + }, + "shared": { + "type": "object", + "description": "Shared infrastructure services, keyed by name. Reached over the shared network by the DNS alias shared-, never the bare service name.", + "propertyNames": { "$ref": "#/$defs/dsname" }, + "additionalProperties": { + "type": "object", + "required": ["template"], + "additionalProperties": false, + "properties": { + "template": { "type": "string", "description": "An engine template ref (one that declares provides:), e.g. postgres, redis, minio." }, + "params": { "$ref": "#/$defs/params" }, + "resources": { "$ref": "#/$defs/resources" }, + "platform": { "$ref": "#/$defs/platform" } + } + } + }, + "projects": { + "type": "array", + "description": "Repos containing a devstack.yaml.", + "items": { + "type": "object", + "required": ["name", "path"], + "additionalProperties": false, + "properties": { + "name": { "$ref": "#/$defs/dsname", "description": "Must match the name inside that repo's devstack.yaml." }, + "path": { "type": "string", "description": "Path to the repo, relative to the workspace root." }, + "git": { "type": "string", "description": "Clone URL for `devstack ws clone`. Shorthand like owner/repo is expanded." } + } + } + }, + "hooks": { "$ref": "#/$defs/hooks" } + }, + "$defs": { + "dsname": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,62}$", + "description": "Compose/DNS-safe identifier: lowercase, starts with a letter, up to 63 chars of [a-z0-9_-]." + }, + "duration": { + "type": "string", + "pattern": "^([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$", + "description": "A Go/Compose duration string, e.g. \"5s\", \"1m30s\"." + }, + "cpus": { + "type": "string", + "pattern": "^[0-9]*\\.?[0-9]+$", + "description": "Fractional cores as a string, e.g. \"1.5\". Must be greater than zero." + }, + "platform": { + "type": "string", + "pattern": "^[a-z0-9]+/[a-z0-9]+(/[a-z0-9]+)?$", + "description": "An os/arch[/variant] selector, e.g. linux/amd64, linux/arm64/v8." + }, + "envMap": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Literal environment variables. Values support ${env.NAME}, ${self.attr}, ${ref:...}, ${profile} and ${workspace.name} interpolation, plus secret://provider/path#key refs." + }, + "params": { + "type": "object", + "description": "Template parameters. Accepted keys and types are declared by the template's params: block (see `devstack template list --json`).", + "additionalProperties": true + }, + "resources": { + "type": "object", + "title": "Resource limits", + "description": "CPU/memory/PID limits (spec 18). Lowered to BOTH deploy.resources.limits.* and the legacy top-level cpus/mem_limit/pids_limit.", + "additionalProperties": false, + "properties": { + "cpus": { "$ref": "#/$defs/cpus" }, + "memoryMB": { "type": "integer", "minimum": 0, "description": "Hard memory limit in MB." }, + "memoryReserveMB": { "type": "integer", "minimum": 0, "description": "Soft memory reservation in MB (scheduling hint)." }, + "pidsLimit": { "type": "integer", "minimum": 0, "description": "Maximum number of PIDs." } + } + }, + "hook": { + "type": "object", + "title": "Lifecycle hook", + "description": "One declarative command run at a saga phase (spec 11).", + "required": ["name", "run", "command"], + "additionalProperties": false, + "properties": { + "name": { "$ref": "#/$defs/dsname" }, + "run": { "enum": ["host", "exec"], "description": "host runs via os/exec on the host; exec shells into a running service with `compose exec -T`." }, + "service": { "type": "string", "description": "Target service. Required when run: exec." }, + "command": { "type": "array", "minItems": 1, "items": { "type": "string" }, "description": "argv array — never shell-split by devstack." }, + "workdir": { "type": "string" }, + "env": { "$ref": "#/$defs/envMap" }, + "timeout": { "$ref": "#/$defs/duration" }, + "retries": { "type": "integer", "minimum": 0 }, + "onFailure": { "enum": ["abort", "warn", "continue"] }, + "once": { "type": "boolean", "description": "Run at most once per workspace (firstRun ledger)." } + } + }, + "hooks": { + "type": "object", + "title": "Lifecycle hooks", + "description": "Hooks grouped by saga phase (spec 11). Lists REPLACE on overlay merge unless the YAML opts into `$merge: append`.", + "additionalProperties": false, + "properties": { + "preUp": { "type": "array", "items": { "$ref": "#/$defs/hook" } }, + "firstRun": { "type": "array", "items": { "$ref": "#/$defs/hook" } }, + "postUp": { "type": "array", "items": { "$ref": "#/$defs/hook" } }, + "postPull": { "type": "array", "items": { "$ref": "#/$defs/hook" } }, + "preDown": { "type": "array", "items": { "$ref": "#/$defs/hook" } } + } + } +, + "dockerhost": { + "type": "string", + "pattern": "^(ssh|tcp|unix|npipe|fd)://.+", + "description": "A DOCKER_HOST endpoint. ssh:// is the primary remote path (inherits your SSH config/agent/ProxyJump)." + } + } +} diff --git a/tests/e2e/mcp_test.go b/tests/e2e/mcp_test.go new file mode 100644 index 0000000..2a239e4 --- /dev/null +++ b/tests/e2e/mcp_test.go @@ -0,0 +1,224 @@ +//go:build e2e + +package e2e + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "os/exec" + "strings" + "testing" + "time" +) + +// mcpSession drives the real binary as an MCP stdio server, writing the given +// JSON-RPC lines and returning every response. +// +// This is the only place the protocol is exercised against the actual process, +// which is what makes the stdout-purity assertion meaningful: the in-process +// tests can prove the handlers behave, but only a real process can prove that +// nothing else — a banner, a log line, the self-update notifier — reaches stdout +// and corrupts the stream. +func mcpSession(t *testing.T, args []string, requests []string) ([]map[string]any, string) { + t.Helper() + s := newSandbox(t, nil) + cmd := exec.Command(bin, args...) + cmd.Env = s.env + cmd.Dir = s.ws + + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatalf("stdin pipe: %v", err) + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + t.Fatalf("start: %v", err) + } + for _, r := range requests { + if _, err := io.WriteString(stdin, r+"\n"); err != nil { + t.Fatalf("write request: %v", err) + } + } + // Give the server time to answer before closing the stream. + time.Sleep(2 * time.Second) + _ = stdin.Close() + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + // A client closing stdin is a normal shutdown, so this must exit 0. + if err != nil { + t.Errorf("ai mcp exited with %v; a client disconnect is not a failure\nstderr:\n%s", err, stderr.String()) + } + case <-time.After(20 * time.Second): + _ = cmd.Process.Kill() + t.Fatal("ai mcp did not exit after stdin closed") + } + + var msgs []map[string]any + sc := bufio.NewScanner(bytes.NewReader(stdout.Bytes())) + sc.Buffer(make([]byte, 0, 1024*1024), 8*1024*1024) + for sc.Scan() { + line := sc.Text() + if strings.TrimSpace(line) == "" { + continue + } + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("stdout carried a non-JSON line, which corrupts the protocol stream:\n%.200q", line) + } + if m["jsonrpc"] != "2.0" { + t.Fatalf("stdout carried a non-JSON-RPC message: %.200q", line) + } + msgs = append(msgs, m) + } + return msgs, stderr.String() +} + +const ( + mcpInit = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"e2e","version":"1"}}}` + mcpInitialized = `{"jsonrpc":"2.0","method":"notifications/initialized"}` +) + +// TestMcpStdoutIsPureProtocol is the anti-footgun test. A Go MCP server most +// commonly ships broken because something else writes to stdout; here the whole +// session is parsed as JSON-RPC and any stray byte fails the test. +func TestMcpStdoutIsPureProtocol(t *testing.T) { + msgs, _ := mcpSession(t, []string{"ai", "mcp"}, []string{ + mcpInit, + mcpInitialized, + `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`, + `{"jsonrpc":"2.0","id":3,"method":"prompts/list"}`, + `{"jsonrpc":"2.0","id":4,"method":"resources/list"}`, + }) + if len(msgs) < 4 { + t.Fatalf("expected a response per request, got %d", len(msgs)) + } + byID := map[float64]map[string]any{} + for _, m := range msgs { + if id, ok := m["id"].(float64); ok { + byID[id] = m + } + } + + info := digMap(t, byID[1], "result", "serverInfo") + if info["name"] != "devstack" { + t.Errorf("serverInfo.name = %v, want devstack", info["name"]) + } + + tools := digSlice(t, byID[2], "result", "tools") + if len(tools) < 20 { + t.Errorf("expected the full tool set, got %d", len(tools)) + } + prompts := digSlice(t, byID[3], "result", "prompts") + if len(prompts) != 5 { + t.Errorf("expected 5 prompts, got %d", len(prompts)) + } + resources := digSlice(t, byID[4], "result", "resources") + if len(resources) < 60 { + t.Errorf("expected the documentation corpus to be published, got %d resources", len(resources)) + } +} + +// TestMcpReadOnlyOmitsMutatingTools proves --read-only actually narrows the +// surface in the shipped binary, not just in the unit tests. +func TestMcpReadOnlyOmitsMutatingTools(t *testing.T) { + msgs, _ := mcpSession(t, []string{"ai", "mcp", "--read-only"}, []string{ + mcpInit, mcpInitialized, + `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`, + }) + var names []string + for _, m := range msgs { + if id, _ := m["id"].(float64); id != 2 { + continue + } + for _, raw := range digSlice(t, m, "result", "tools") { + tool, _ := raw.(map[string]any) + name, _ := tool["name"].(string) + names = append(names, name) + if ann, ok := tool["annotations"].(map[string]any); ok { + if ro, _ := ann["readOnlyHint"].(bool); !ro { + t.Errorf("--read-only exposed %s without readOnlyHint", name) + } + } + } + } + if len(names) == 0 { + t.Fatal("no tools listed") + } + for _, n := range names { + switch n { + case "devstack_up", "devstack_down", "devstack_generate", + "devstack_db_create", "devstack_workspace_destroy", "devstack_db_drop": + t.Errorf("--read-only exposed the mutating tool %s", n) + } + if strings.Contains(n, "secret") { + t.Errorf("the secrets group must never be exposed, found %s", n) + } + } +} + +// TestMcpToolCallReturnsRealOutput closes the loop: a tool call must return the +// same content the CLI would print. +func TestMcpToolCallReturnsRealOutput(t *testing.T) { + msgs, _ := mcpSession(t, []string{"ai", "mcp"}, []string{ + mcpInit, mcpInitialized, + `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"devstack_docs","arguments":{"slug":"guide/templates"}}}`, + }) + for _, m := range msgs { + if id, _ := m["id"].(float64); id != 2 { + continue + } + result, _ := m["result"].(map[string]any) + structured, _ := result["structuredContent"].(map[string]any) + out, _ := structured["output"].(string) + if !strings.Contains(out, "Templates") { + t.Errorf("devstack_docs did not return the templates guide, got %.200q", out) + } + cmdLine, _ := structured["command"].(string) + if !strings.Contains(cmdLine, "ai docs guide/templates") { + t.Errorf("the tool should report the command it ran, got %q", cmdLine) + } + return + } + t.Fatal("no response to the tool call") +} + +func digMap(t *testing.T, m map[string]any, path ...string) map[string]any { + t.Helper() + cur := m + for _, p := range path { + next, ok := cur[p].(map[string]any) + if !ok { + t.Fatalf("no object at %v in %v", path, m) + } + cur = next + } + return cur +} + +func digSlice(t *testing.T, m map[string]any, path ...string) []any { + t.Helper() + cur := m + for i, p := range path { + if i == len(path)-1 { + s, ok := cur[p].([]any) + if !ok { + t.Fatalf("no array at %v", path) + } + return s + } + next, ok := cur[p].(map[string]any) + if !ok { + t.Fatalf("no object at %v", path) + } + cur = next + } + return nil +}