diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1b1653c5..d8e34aa9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -29,6 +29,8 @@ Project structure: `cmd/` (entry points), `internal/` (business logic), `magefil Documentation structure: `docs/user/reference/cli/` (auto-generated CLI docs, regenerated by `mage docs`), `docs/user/reference/config/` (hand-written TOML config reference), `docs/user/how-to/` (workflow guides), `docs/user/explanation/` (conceptual docs). +**Agent skills track tool behavior.** `internal/app/azldev/agentskill/` emits the AI-agent skills and instruction files that describe azldev's CLI, config, and workflows. After a behavioral change (command syntax, flags, config schema, overlay types, workflows), check whether these need updating — see [instructions/agent-skills.instructions.md](instructions/agent-skills.instructions.md). + The TOML config files in `defaultconfigs/` are loaded via `internal/projectconfig/`. **IMPORTANT**: Code generation runs automatically with build/test commands. `mage generate` (runs `go generate` for each package in parallel) is a prerequisite for building and runs automatically with `mage build` and `mage unit`. `mage docs` rebuilds the binary and updates the JSON schema (`schemas/azldev.schema.json`) and CLI docs (`docs/user/reference/cli/`). Run `mage docs` explicitly after changing config structs or Cobra command descriptions so that checked-in generated files stay current (checked by PR gates). diff --git a/.github/instructions/agent-skills.instructions.md b/.github/instructions/agent-skills.instructions.md new file mode 100644 index 00000000..fd5c6073 --- /dev/null +++ b/.github/instructions/agent-skills.instructions.md @@ -0,0 +1,82 @@ +--- +applyTo: "internal/app/azldev/agentskill/**" +description: "How to maintain azldev's emitted AI-agent skills and instruction files. Read before adding or editing a skill, an instruction wrapper, or the emit mechanism in the agentskill package." +--- + +# Maintaining the emitted agent skills and instruction files + +The `agentskill` package is the **single source of truth** for the AI-agent skill and +instruction files that `azldev docs agent install` writes into distro repositories, and +that `azldev docs agent show` (a read-only MCP tool) serves. Everything is embedded in +the binary so the on-disk files, the CLI output, and the MCP response stay version-matched. + +For the full architecture — the registries, the redirect-wrapper-vs-full-body rendering, +and how dynamic values are substituted — read the package doc in +[doc.go](../../internal/app/azldev/agentskill/doc.go). + +## Model + +- **Skills hold the content.** A `Skill` in the `skills` registry + ([agentskill.go](../../internal/app/azldev/agentskill/agentskill.go)) pairs a name with a + body template under [content/](../../internal/app/azldev/agentskill/content/). Skills are the + cross-agent medium — served via `docs agent show` / the `docs-agent-show` MCP tool, or inlined + on disk with `--full`. +- **Instruction files are lightweight wrappers.** An `Instruction` in the `instructions` registry + is selected by its `applyTo` glob and only *points at* skills — `SkillPointer{Skill, Purpose}` + renders as "You MUST read the `` skill ``". Put substantive guidance in a + **skill**, never in a wrapper. Do not make a wrapper reference the CLI or MCP tool — those are + unavailable in `--full` installs; naming the skill works in every mode. + +## Adding or editing a skill + +1. Add a `Skill{Name, Description, bodyTemplate}` to the `skills` registry. +2. Add a `content/.md.tmpl` body, named for the topic (e.g. `mock.md.tmpl`, + `azldev.md.tmpl`). The front-matter `name` must equal the skill's base name (and its on-disk + directory), lowercase-hyphen, ≤ 64 chars. The `description` drives discovery, ≤ 1024 chars. +3. Write the description inline in the registry entry. The description is the **load gate** + (an agent decides whether to open the skill from it), so **lead with a directive** — + "Read this before ; do not from memory." — then what the skill covers, + then a `Triggers include ...` keyword list. **Avoid a colon-space (`: `) in + descriptions** — it is ambiguous in YAML front matter. +4. Add a content test in + [agentskill_test.go](../../internal/app/azldev/agentskill/agentskill_test.go) (assert the + front-matter `name` and a distinctive, validated phrase). `TestSkillFrontmatterInvariants` + auto-covers the spec limits. + +## Adding or editing an instruction wrapper + +1. Add an `Instruction{Name, ApplyTo, Description, Title, Intro, Skills}` to the `instructions` + registry. `ApplyTo` may reference bindings (e.g. `{{ .RenderedSpecsDir }}/**/*`) — it is + rendered against `Params` at emit time. +2. `Skills` is a list of `SkillPointer` — name each skill with a short purpose ("to add or change + overlays"). +3. A little hand-written prose here is fine, if it helps direct agents to the right skill. Keep it short, and avoid + repeating the skill content. +3. Keep the count-based tests happy: `Files()` emits one file per skill plus one per instruction. + +## Content accuracy is the hard part + +Distilled content **drifts**. The azurelinux copies these are distilled from are frequently stale. +Before shipping any skill/instruction: + +- **Validate every CLI, flag, and config claim against the current code** (command trees under + `internal/app/azldev/cmds/`, config under `internal/projectconfig/`). Do not trust the source you + distilled from. Confirm with `./out/bin/azldev --help` and `azldev config generate-schema`. +- Prefer a **drift-guard test** whenever a code enum can back the content. Example: + `TestOverlaysSkillCoversAllOverlayTypes` extracts the overlay-type enum from the jsonschema tag + on `projectconfig.ComponentOverlay.Type` and fails if the skill omits a type. + +## Config-resolved bindings + +Repo-specific values (lock dir, rendered-specs dir, work dir) are resolved from the target `azldev.toml` in +[cmds/docs/agent.go](../../internal/app/azldev/cmds/docs/agent.go) and degrade to azldev's defaults +when no config is present. To add a binding, extend `Bindings`, resolve it in `resolveBindings`, and +reference it in a template as `{{ .FieldName }}`. + +## Before you commit + +- `mage unit` and `mage check all` pass. +- `mage docs` produces **no drift** (adding a registry skill/instruction should not change CLI docs, + the JSON schema, or the MCP snapshot). +- Sanity-check emitted output: `./out/bin/azldev docs agent install -o "$(mktemp -d)"` and + `./out/bin/azldev docs agent show --skill `. diff --git a/docs/developer/prds/agent-scaffolding.prd.md b/docs/developer/prds/agent-scaffolding.prd.md new file mode 100644 index 00000000..7795b3f8 --- /dev/null +++ b/docs/developer/prds/agent-scaffolding.prd.md @@ -0,0 +1,165 @@ +# PRD: Agent Scaffolding via `azldev docs agent` + +Title: PRD: Agent Scaffolding via `azldev docs agent` +Author: Daniel McIlvaney +Created: 2026-07-02 +Status: Draft + +## Overview + +`azldev docs agent` should be able to **initialize a new distro repository with the AI-agent +capabilities that the Azure Linux repo has today** — the skills, instruction files, schema, and MCP +wiring that let coding agents drive `azldev` effectively. `microsoft/azurelinux` is the reference +implementation we distill the generic, non-distro-specific content from. + +A v1 exists: `azldev docs agent install` emits a single light-wrapper skill +([.agents/skills/azldev/SKILL.md](internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl)) plus +a path-specific instructions file, and `azldev docs agent show` serves the full skill via a read-only +MCP tool. This PRD generalizes that into a **scaffolder for a distro repo's whole agent system**. + +## Problem Statement + +Azure Linux has a mature, PRD-governed agent system (`docs/prds/agent-instructions.prd.md` in +`microsoft/azurelinux`): repo-wide `copilot-instructions.md`, nested `AGENTS.md` guardrails, +`.github/instructions/*.instructions.md`, ~13 `.github/skills/skill-*`, prompts, and agents. The +azldev-facing subset of that content — the `build` / `add` / `fix-overlay` / `mock` / `update` skills, +the `comp-toml` / `distro-toml` instruction files, the CLI reference in `copilot-instructions.md`, and +the mirrored `external/schemas/azldev.schema.json` — is **almost entirely azldev-operation knowledge**: +command syntax, flags, semantics, overlay types, and the tool's recommended inner loop. That content: + +- **Drifts** every time azldev changes (the azurelinux PRD flags this in "Areas to Revisit Regularly" + and its Schema Reference note). +- Must be **hand-recreated** in every new distro repo that uses azldev. + +There is no way to bootstrap a new distro repo with this capability, and no single source of truth for +the azldev-generic portion. The azurelinux PRD explicitly anticipates this feature: + +> Open Question #4: "Should azldev have a command to install the resources into other repos?" +> Out of Scope (v1): "Automated regeneration of agent instructions via azldev." + +## Goals + +1. `azldev docs agent init` scaffolds a new distro repo with the azldev-generic agent substrate. +2. `azldev docs agent update` refreshes that substrate in place, without clobbering repo-authored + editorial content. +3. The generic content is authored/embedded **in azldev** (single source of truth = the binary), so + azurelinux and every new distro consume the same, version-matched material. +4. Content is bound to the **target repo's resolved config** (output dirs, upstream distro, dist tag) + at generation time. + +## Non-Goals + +- Generating distro **editorial/policy** (e.g. "never install RPMs on the host", hygiene rules, + canonical example components). Those stay human-authored in the target repo. +- Generating non-azldev skills (azurelinux's Koji/AKS ops skills, `azl-diagnose` agent, KQL/metrics). +- Owning the target repo's `azldev.toml` (project config). A repo must already have one — see Decisions. + +## Locked Decisions + +| # | Decision | Choice | +| --- | --- | --- | +| D1 | Source of truth for generic content | **Distill azurelinux's generic skills/instructions into azldev** (embedded); regenerate azurelinux + new repos from the binary. One source of truth = the tool. | +| D2 | Init scope | **azldev-mechanics substrate only** (skill set + config instructions + CLI reference + schema + MCP configs + version pin). Editorial is left as empty/stub slots for the repo to author. | +| D3 | Config binding | **Resolve bindings from the invoking project's `azldev.toml` when present; otherwise degrade gracefully to azldev's built-in defaults** (no hard requirement). Supersedes the earlier "require config" stance so that `show` / the MCP tool and greenfield repos still emit sensible, default-accurate content. | + +Implied: default emit layout flips from `.agents/skills/` to **`.github/skills/`** (matches azurelinux +and Copilot CLI / coding-agent discovery), configurable either way. Skill naming/prefix configurable to +match host convention (azurelinux uses `skill-*`). + +## Design + +### The ownership boundary + +| Emitted by azldev (generated, overwritten on `update`) | Human-owned (never touched) | +| --- | --- | +| Skill set: build / add / fix-overlay / mock / update / render / remove | Distro policy & hygiene rules | +| `comp-toml` / `distro-toml` / spec / rendered-specs `.instructions.md` | Canonical example components | +| `copilot-instructions` azldev section + CLI reference | Non-azldev skills (Koji/AKS), infra agents | +| `azldev.schema.json` (via `config generate-schema`) | Repo-specific prose, links | +| `.vscode/mcp.json` and `.mcp.json` (azldev MCP server registration), `.azldev-version` | — | + +### Content sources (why generation beats hand-maintenance) + +- **Command syntax / flags / semantics** → the Cobra command tree (already emitted by + [docs markdown](internal/app/azldev/cmds/docs/markdown.go)). +- **Overlay types + required fields** → the JSON schema (`config generate-schema`). +- **Resolved bindings** (output-dir / log-dir / work-dir paths, upstream distro, dist tag) → azldev + resolves these from the target repo's `azldev.toml`, so the "repo-specific" build-output table + azurelinux hand-wrote becomes generated per repo. +- **Recommended inner loop / workflow narrative** → authored once in azldev's embedded templates + (distilled from azurelinux's skills), rendered with the above. + +### Composition / no-clobber + +- Every generated file carries a "generated by azldev docs agent — do not hand-edit" marker (in-body + for `SKILL.md`, since YAML frontmatter must be first). +- `update` overwrites only generated files. Editorial lives in **separate** files the tool never + writes (e.g. a repo-authored `AGENTS.md` that links to the generated skills; a policy + `.instructions.md`). +- Open question: whether to also support marker-delimited regions inside a shared file (e.g. an + azldev section inside a repo-owned `copilot-instructions.md`) vs. keeping generated content in + discrete files. Prefer discrete files first (simplest, safest). + +### Command surface + +- `azldev docs agent init [-o dir]` — scaffold the substrate into a repo (requires `azldev.toml`). +- `azldev docs agent update [-o dir]` — refresh generated files in place. +- `azldev docs agent show --skill ` — print a skill (read-only MCP tool). Parameterized by skill name + once the set is multiple (per the earlier multi-skill decision). +- `azldev docs agent check [-o dir]` — (proposed) drift gate for CI: verify committed generated files + match what the pinned azldev version would emit. Closes the azurelinux PRD's sync concern. +- Layout/config flags: `--skills-dir` / `--layout` (`.github` | `.agents`), `--skill-prefix`. + +### MCP + +- The read-only `docs-agent-show` tool (already implemented) is how agents load skill bodies on + demand; the emitted wrappers reference it. `init` also writes `.vscode/mcp.json` for VS Code and + `.mcp.json` for Copilot CLI, registering the `azldev advanced mcp` server so the tool is available. + Read-only annotation enables auto-approval, which directly addresses azurelinux's "tool approvals + are difficult" note in `DEVELOPING.md`. + +## Relationship to v1 (already shipped) + +v1 = single `azldev` skill wrapper + one instructions file + read-only `docs-agent-show` tool, emitted +to `.agents/skills/`. This PRD generalizes it: a skill **set**, `init`/`update`/`check`, config-resolved +bindings, `.github/` default layout, schema + `mcp.json` emission, and a composition model. The v1 +shared package [internal/app/azldev/agentskill](internal/app/azldev/agentskill/agentskill.go) is the +natural home for the skill registry (the "small lift" refactor already scoped: singletons → `[]Skill`). + +## Companion Workstream: rework azurelinux + +Once azldev emits the substrate, azurelinux is reworked as a **two-way port**: + +1. Lift azurelinux's generic skill/instruction content up into azldev's embedded templates (they are the + distilled source). +2. Have azurelinux re-consume it via `azldev docs agent update`, keeping only editorial deltas in + files azldev never touches. +3. Regenerate `external/schemas/azldev.schema.json`; add Phase 6 `.vscode/mcp.json` and `.mcp.json`. + +## Implementation Plan (proposed) + +- **Phase 1 — Skill registry**: refactor `agentskill` singletons → `[]Skill`; parameterize `show`; + configurable layout + naming. (Foundational; independent of content.) +- **Phase 2 — Config-resolved bindings**: inject resolved paths (lock dir, rendered-specs + dir) from the invoking project's `azldev.toml` into templates, degrading to azldev's built-in + defaults when no configuration is available. (Upstream-distro / dist-tag bindings follow in + Phase 3 alongside the skills that consume them.) +- **Phase 3 — Content distillation**: port azurelinux's build / add / fix-overlay / mock / update + skill content + `comp-toml` / `distro-toml` instructions into embedded templates (real content + replaces the pie placeholder). +- **Phase 4 — Substrate emission**: schema (`config generate-schema`), `.vscode/mcp.json`, `.mcp.json`, + `.azldev-version`, `copilot-instructions`/`AGENTS` scaffold; `init` vs `update`. +- **Phase 5 — Drift gate**: `docs agent check` for CI. +- **Phase 6 — azurelinux rework**: the companion port above. + +## Open Questions + +1. **Composition mechanism**: discrete files only, or also marker-delimited regions inside repo-owned + files? (Lean: discrete first.) +2. **Skill naming**: adopt azurelinux's `skill-*`, or an `azldev-*` namespace, as the emitted default? +3. **Prompts / agents**: azurelinux's prompts (`.prompt.md`, VS Code only) and agents (`.agent.md`) — + in scope for generation, or left as repo-authored orchestration over the generated skills? +4. **Editorial stubs**: D2 says "empty/stub slots" — how much stub scaffolding (empty policy + `AGENTS.md`? a TODO checklist?) vs. nothing at all? +5. **`check` in CI**: exact contract for "matches the pinned azldev version" given the version stamp is + embedded in output. diff --git a/docs/user/README.md b/docs/user/README.md index 16428e3c..7db23b19 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -6,6 +6,7 @@ - [Add a Component](./how-to/add-component.md) — import or create a new package - [Build a Component](./how-to/build-component.md) — build RPMs from component definitions - [Build an Image](./how-to/build-image.md) — build and boot Azure Linux images +- [Set Up AI Coding Agents](./how-to/set-up-ai-agents.md) — emit agent skill and instruction files ## Explanation @@ -29,4 +30,3 @@ ### CLI Commands Auto-generated from `azldev --help`. See [reference/cli/](./reference/cli/azldev.md) for per-command documentation. - diff --git a/docs/user/how-to/set-up-ai-agents.md b/docs/user/how-to/set-up-ai-agents.md new file mode 100644 index 00000000..85893ba5 --- /dev/null +++ b/docs/user/how-to/set-up-ai-agents.md @@ -0,0 +1,80 @@ +# How To: Set Up AI Coding Agents + +This guide shows how to use `azldev docs agent` to teach AI coding agents (such as +GitHub Copilot) how to work with azldev in your repository. + +## Background + +azldev can emit two kinds of agent-facing files: + +- **Agent Skills** under `.agents/skills/` — on-demand capabilities, + in the tool-neutral [Agent Skills](https://agentskills.io/) format, that agents + load when a task involves azldev. +- **Path-specific instructions** under `.github/instructions/` that apply automatically + to azldev configuration and generated output. + +Every emitted file identifies itself as generated. Keep repository-specific policy and +editorial guidance in separate files rather than editing emitted files. + +The authoritative skill content ships inside the azldev binary. By default the +emitted `SKILL.md` is a light wrapper that points agents at a read-only MCP tool +(`docs-agent-show`) so they always load the guidance that matches the installed +azldev version. + +## Emit the Agent Files + +Run the command from the root of the target repository: + +```bash +azldev docs agent install +``` + +Or point it at another repository: + +```bash +azldev docs agent install -o ../other-repo +``` + +Use `--dry-run` / `-n` to preview which files would be written without changing +anything: + +```bash +azldev -n docs agent install +``` + +## Wire Up the MCP Server (Recommended) + +The emitted wrapper files reference the read-only `docs-agent-show` MCP tool. To +make that tool available to agents, register the azldev MCP server with your agent +tooling. The server is started with: + +```bash +azldev advanced mcp +``` + +With the server configured, an agent can call `docs-agent-show` (a read-only tool +that most agent frameworks can auto-approve) to load the full azldev skill on +demand. + +## Emit Without the MCP Server + +If the target environment will not run the azldev MCP server, inline the full skill +into `SKILL.md` instead of the light wrapper: + +```bash +azldev docs agent install --full +``` + +## Preview the Skill + +Run without `--skill` to list the available skills: + +```bash +azldev docs agent show +``` + +To print a full skill document to stdout — the same content the MCP tool serves — run: + +```bash +azldev docs agent show --skill azldev +``` diff --git a/docs/user/reference/cli/azldev_docs.md b/docs/user/reference/cli/azldev_docs.md index 4b82fab1..13b14804 100644 --- a/docs/user/reference/cli/azldev_docs.md +++ b/docs/user/reference/cli/azldev_docs.md @@ -36,5 +36,6 @@ command tree, suitable for inclusion in the user guide. ### SEE ALSO * [azldev](azldev.md) - 🐧 Azure Linux Dev Tool +* [azldev docs agent](azldev_docs_agent.md) - Emit AI agent skill and instruction files * [azldev docs markdown](azldev_docs_markdown.md) - Generates Markdown (.md) docs for this tool diff --git a/docs/user/reference/cli/azldev_docs_agent.md b/docs/user/reference/cli/azldev_docs_agent.md new file mode 100644 index 00000000..20d9c28c --- /dev/null +++ b/docs/user/reference/cli/azldev_docs_agent.md @@ -0,0 +1,43 @@ + + +## azldev docs agent + +Emit AI agent skill and instruction files + +### Synopsis + +Emit files that teach AI coding agents how to use azldev. + +The 'install' subcommand writes an Agent Skill and a path-specific instructions +file into a target repository. The 'show' subcommand prints the full azldev skill +to stdout and is exposed as a read-only MCP tool, which the emitted wrapper files +reference so that agents always load the guidance that ships with the binary. + +### Options + +``` + -h, --help help for agent +``` + +### Options inherited from parent commands + +``` + -y, --accept-all accept all prompts + --color mode output colorization mode {always, auto, never} (default auto) + --config-file stringArray additional TOML config file(s) to merge (may be repeated) + -n, --dry-run dry run only (do not take action) + --network-retries int maximum number of attempts for network operations (minimum 1) (default 3) + --no-default-config disable default configuration + -O, --output-format fmt output format {csv, json, markdown, table} (default table) + --permissive-config do not fail on unknown fields in TOML config files + -C, --project string path to Azure Linux project + -q, --quiet only enable minimal output + -v, --verbose enable verbose output +``` + +### SEE ALSO + +* [azldev docs](azldev_docs.md) - Documentation commands +* [azldev docs agent install](azldev_docs_agent_install.md) - Write azldev agent skill and instruction files into a repository +* [azldev docs agent show](azldev_docs_agent_show.md) - Print an azldev agent skill to stdout + diff --git a/docs/user/reference/cli/azldev_docs_agent_install.md b/docs/user/reference/cli/azldev_docs_agent_install.md new file mode 100644 index 00000000..794b9f49 --- /dev/null +++ b/docs/user/reference/cli/azldev_docs_agent_install.md @@ -0,0 +1,75 @@ + + +## azldev docs agent install + +Write azldev agent skill and instruction files into a repository + +### Synopsis + +Write AI agent files describing how to use azldev into a target repository. + +Creates (or overwrites) the generated agent substrate, relative to the output directory: + +.agents/skills//SKILL.md one Agent Skill per built-in skill +.github/instructions/.instructions.md path-specific instruction wrappers +.mcp.json registers the azldev MCP server for Copilot +.vscode/mcp.json registers the azldev MCP server + +Everything azldev writes is generated deterministically, so re-running install is a +no-op once committed — a CI check can run install and assert an empty diff. The +MCP entries are upserted, preserving any other servers already configured. + +By default the emitted SKILL.md is a light wrapper that points agents at the +read-only 'docs-agent-show' MCP tool for the full, always-current skill. Pass +--full to inline the complete skill instead, for environments without the azldev +MCP server. + +Directory paths in the emitted content (such as the lock and rendered-spec +directories) are resolved from the loaded azldev.toml, falling back to azldev's +built-in defaults when no configuration is found. The bindings reflect the project +azldev runs in, so pair --output-dir with -C pointing at the target repository when +scaffolding a different repo. + +``` +azldev docs agent install [flags] +``` + +### Examples + +``` + # Write agent files into the current repository + azldev docs agent install + + # Write into a specific repository, inlining the full skill + azldev docs agent install -o ../other-repo --full +``` + +### Options + +``` + --full inline the full skill into SKILL.md instead of a light MCP wrapper + -h, --help help for install + --layout string skill layout: 'agents' (.agents/skills) or 'github' (.github/skills) (default "agents") + -o, --output-dir string target repository directory to write agent files into (default ".") +``` + +### Options inherited from parent commands + +``` + -y, --accept-all accept all prompts + --color mode output colorization mode {always, auto, never} (default auto) + --config-file stringArray additional TOML config file(s) to merge (may be repeated) + -n, --dry-run dry run only (do not take action) + --network-retries int maximum number of attempts for network operations (minimum 1) (default 3) + --no-default-config disable default configuration + -O, --output-format fmt output format {csv, json, markdown, table} (default table) + --permissive-config do not fail on unknown fields in TOML config files + -C, --project string path to Azure Linux project + -q, --quiet only enable minimal output + -v, --verbose enable verbose output +``` + +### SEE ALSO + +* [azldev docs agent](azldev_docs_agent.md) - Emit AI agent skill and instruction files + diff --git a/docs/user/reference/cli/azldev_docs_agent_show.md b/docs/user/reference/cli/azldev_docs_agent_show.md new file mode 100644 index 00000000..be58c650 --- /dev/null +++ b/docs/user/reference/cli/azldev_docs_agent_show.md @@ -0,0 +1,58 @@ + + +## azldev docs agent show + +Print an azldev agent skill to stdout + +### Synopsis + +Print an azldev Agent Skill document to stdout. + +This is the authoritative skill content embedded in the binary. It is exposed as a +read-only MCP tool so that agents can load it on demand; the wrapper files written +by 'azldev docs agent install' reference this tool. + +Name the skill with --skill (shell completion lists the choices). Run with no +skill to list the available skills. + +``` +azldev docs agent show [flags] +``` + +### Examples + +``` + # List the available skills + azldev docs agent show + + # Print a skill (--skill tab-completes) + azldev docs agent show --skill azldev-overlays +``` + +### Options + +``` + -h, --help help for show + --skill string skill to print +``` + +### Options inherited from parent commands + +``` + -y, --accept-all accept all prompts + --color mode output colorization mode {always, auto, never} (default auto) + --config-file stringArray additional TOML config file(s) to merge (may be repeated) + -n, --dry-run dry run only (do not take action) + --network-retries int maximum number of attempts for network operations (minimum 1) (default 3) + --no-default-config disable default configuration + -O, --output-format fmt output format {csv, json, markdown, table} (default table) + --permissive-config do not fail on unknown fields in TOML config files + -C, --project string path to Azure Linux project + -q, --quiet only enable minimal output + -v, --verbose enable verbose output +``` + +### SEE ALSO + +* [azldev docs agent](azldev_docs_agent.md) - Emit AI agent skill and instruction files + diff --git a/internal/app/azldev/agentskill/agentskill.go b/internal/app/azldev/agentskill/agentskill.go new file mode 100644 index 00000000..272b8b02 --- /dev/null +++ b/internal/app/azldev/agentskill/agentskill.go @@ -0,0 +1,452 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package agentskill + +import ( + "bytes" + "embed" + "fmt" + "path" + "text/template" +) + +const ( + // SkillName is the stable base identifier of the primary azldev skill. It is the + // name passed to 'azldev docs agent show'. + SkillName = "azldev" + + // ShowSkillToolName is the name of the read-only MCP tool that returns a skill + // document. The emitted wrapper files point agents at this tool. + ShowSkillToolName = "docs-agent-show" + + // ConfigGlob is the 'applyTo' glob for the azldev project-config instructions file; + // it matches azldev project configuration files. + ConfigGlob = "**/azldev.toml" + + // instructionFileSuffix is appended to an instruction's name to form its emitted file + // name (e.g. "comp-toml" -> "comp-toml.instructions.md"). + instructionFileSuffix = ".instructions.md" +) + +// The embedded templates rendered into the emitted files and the served skill +// documents. +// +//go:embed content/*.tmpl +var content embed.FS + +// templates holds all parsed templates, keyed by their base file name. +// +//nolint:gochecknoglobals // parsed templates are effectively constant and safe for concurrent use. +var templates = template.Must(template.ParseFS(content, "content/*.tmpl")) + +// Skill describes a single emitted Agent Skill. +type Skill struct { + // Name is the stable base identifier (lowercase, hyphen-delimited). It is the + // argument to 'azldev docs agent show' and the on-disk skill directory name. + Name string + + // Description is the discovery text placed in the skill's front matter. + // NOTE: avoid a colon followed by a space; that sequence is ambiguous in YAML. + Description string + + // bodyTemplate is the embedded template name rendered as the full skill body. + bodyTemplate string +} + +// skills is the registry of emitted skills, in emission order. +// +//nolint:gochecknoglobals // effectively a constant registry of the built-in skills. +var skills = []Skill{ + { + Name: "azldev", + Description: "Read this before running azldev or editing azldev config, and whenever working " + + "in a repo that contains an azldev.toml file; do not guess azldev's commands or config. " + + "Explains how to use the azldev CLI to build a distro from TOML config, including the core " + + "concepts (components, overlays, distros, rendered specs, locks), running azldev (repo root or " + + "-C, plus the -q and -O json flags), the common commands, and where to go for each workflow. " + + "Triggers include azldev, comp build, comp render, comp update, build a component, add a " + + "component, distro config.", + bodyTemplate: "azldev.md.tmpl", + }, + { + Name: "azldev-mock", + Description: "Read this before testing or inspecting a built RPM; do not drive mock by hand from " + + "memory. Explains how to test and inspect built packages in a mock chroot with 'azldev adv " + + "mock shell', covering non-interactive (heredoc) and interactive chroot workflows, the " + + "-p/--add-package flag, and resetting stale chroot state. Triggers include test package, mock " + + "shell, inspect rpm, smoke test, chroot, verify build output.", + bodyTemplate: "mock.md.tmpl", + }, + { + Name: "azldev-update-component", + Description: "Read this before finalizing a component change, changing source resolution, or " + + "touching a lock file; lock edits are easy to get wrong. Explains how to refresh azldev " + + "component lock files with 'azldev comp update', covering when to run update versus render, the " + + "update/render/commit/re-render/amend workflow, and per-component versus -a refresh. Triggers " + + "include comp update, refresh lock, bump pin, change snapshot, upstream distro, lock drift, " + + "version bump, finalize component.", + bodyTemplate: "update-component.md.tmpl", + }, + { + Name: "azldev-remove-component", + Description: "Read this before deleting or dropping a component; there is no azldev remove " + + "command, so doing it wrong leaves dangling state. Explains the manual removal workflow for " + + "deleting component metadata, cleaning references, and validating any related output-affecting " + + "changes. Triggers include remove component, delete package, drop " + + "component, prune dependency.", + bodyTemplate: "remove-component.md.tmpl", + }, + { + Name: "azldev-overlays", + Description: "Read this before adding, changing, or diagnosing any overlay; never edit a spec or " + + "rendered file from memory. Explains how to modify a component's RPM spec or loose source files " + + "with azldev overlays (semantic patches applied at render time) instead of forking the spec, " + + "covering overlay types, the render-and-inspect loop, common failures, pitfalls, and metadata. " + + "Triggers include overlay, overlay failed, no match, spec-add-tag, spec-remove-tag, patch-add, " + + "fix spec, backport, disable test, prune subpackage, edit spec.", + bodyTemplate: "overlays.md.tmpl", + }, + { + Name: "azldev-comp-toml", + Description: "Read this before authoring, editing, or reviewing a *.comp.toml file; do not work " + + "from memory. Explains the azldev component definition format and review workflow, covering " + + "component structure, spec sources, build config, release calculation, render options, file " + + "organization, overlay hygiene, stale files, disabled tests, and testing verification. " + + "Triggers include comp.toml, component config, review component, component hygiene, spec " + + "source, upstream-distro, build defines, release calculation, includes.", + bodyTemplate: "comp-toml.md.tmpl", + }, + { + Name: "azldev-add-component", + Description: "Read this before adding or importing a component; follow the workflow instead of " + + "guessing. Explains how to add a new component to an azldev distro, covering inspecting the " + + "upstream spec, the inline-versus-dedicated-file decision, and validating with render, " + + "diff-sources, and build. Triggers include add component, new package, import package, create " + + "comp.toml, new component.", + bodyTemplate: "add-component.md.tmpl", + }, + { + Name: "azldev-build-component", + Description: "Read this before building a component or diagnosing a build failure; do not guess " + + "build flags or the inner loop. Explains how to build, iterate on, and debug an azldev " + + "component, covering comp build flags (local-repo, preserve-buildenv), the render/build/test " + + "inner loop, diff-sources, and disabling a failing %check via check.skip. Triggers include " + + "build component, build failed, build error, inner loop, preserve buildenv, local repo, disable " + + "check.", + bodyTemplate: "build-component.md.tmpl", + }, + { + Name: "azldev-image", + Description: "Read this before building, booting, or configuring an azldev image. Explains the " + + "azldev image commands (list, build, boot, test, customize) and the [images.] config " + + "(kiwi definition, capabilities, tests, publish); the kiwi XML format itself is upstream KIWI " + + "NG. Triggers include image build, image boot, kiwi, container image, VM image, images.toml.", + bodyTemplate: "image.md.tmpl", + }, +} + +// Skills returns the registered skills in emission order. +func Skills() []Skill { + return skills +} + +// FindSkill returns the registered skill with the given name. +func FindSkill(name string) (Skill, error) { + for _, skill := range skills { + if skill.Name == name { + return skill, nil + } + } + + return Skill{}, fmt.Errorf("unknown skill %#q", name) +} + +// SkillPointer names a skill an instruction file points at, together with a short +// purpose describing when to read it ("read the `azldev-overlays` skill to add or change +// overlays"). +type SkillPointer struct { + // Skill is the name of the skill to read. + Skill string + + // Purpose is a short phrase describing when to read the skill (e.g. "to add or + // change overlays"). It follows the skill name in the rendered wrapper. + Purpose string +} + +// Instruction describes a single emitted path-specific instruction file. Instruction +// files are lightweight wrappers, selected automatically by their 'applyTo' glob, that +// point agents at the relevant skill(s); the substantive, always-current guidance lives +// in the skills, keeping a single source of truth. An instruction only names the skills +// to read — how a skill's content is delivered (a thin wrapper served by docs-agent-show, +// or the full body inlined by '--full') is the skill's concern, not the instruction's. +type Instruction struct { + // Name is the file-name stem; the emitted file is ".instructions.md". + Name string + + // ApplyTo is the front-matter glob selecting the files this instruction applies to. + // It may reference binding fields (e.g. '{{ .RenderedSpecsDir }}') and is rendered + // against [Params] at emission time. + ApplyTo string + + // Description is the front-matter description. + // NOTE: avoid a colon followed by a space; that sequence is ambiguous in YAML. + Description string + + // Title is the body heading. + Title string + + // Intro is the body's opening sentence describing the file kind. + Intro string + + // Skills lists the skills this wrapper points agents at, in order, each with a purpose. + // The first skill is required for every matching file; remaining skills are loaded only when + // their purpose matches the task. + Skills []SkillPointer +} + +// instructions is the registry of emitted instruction files, in emission order. +// +//nolint:gochecknoglobals // effectively a constant registry of the built-in instruction files. +var instructions = []Instruction{ + { + Name: SkillName, + ApplyTo: ConfigGlob, + Description: "This repo is an azldev distro project (azldev.toml present). Before running azldev " + + "or editing its config, load the azldev skill; do not guess azldev's commands or config. " + + "Triggers include azldev, comp build, comp render, comp update, build a component, add a " + + "component, distro config.", + Title: "Working with azldev projects", + Intro: "This repository is an azldev distro project; its top-level configuration lives in `azldev.toml`.", + Skills: []SkillPointer{ + {Skill: SkillName, Purpose: "for how to use the azldev CLI"}, + }, + }, + { + Name: "comp-toml", + ApplyTo: "**/*.comp.toml,**/components.toml", + Description: "These are azldev component definition files (*.comp.toml). Before editing " + + "or reviewing one, load " + + "the azldev-comp-toml skill (and azldev-overlays for spec changes); do not hand-write " + + "component config from memory. Triggers include comp.toml, component config, spec source, " + + "build defines, release calculation, overlays, review component.", + Title: "Component definition files (`*.comp.toml`)", + Intro: "These files define a distro's components — each one's spec source and how azldev customizes it.", + Skills: []SkillPointer{ + {Skill: "azldev-comp-toml", Purpose: "for the component TOML format and review checklist"}, + {Skill: "azldev-add-component", Purpose: "to add a new component"}, + {Skill: "azldev-overlays", Purpose: "to add or change overlays"}, + {Skill: "azldev-update-component", Purpose: "to refresh a component's lock"}, + {Skill: "azldev-remove-component", Purpose: "to remove a component"}, + }, + }, + { + Name: "rendered-specs", + ApplyTo: "{{ .RenderedSpecsDir }}/**/*", + Description: "Rendered component files produced by 'azldev comp render'. They are build inputs and " + + "must not be hand-edited. Before changing one, load the azldev-overlays or azldev-comp-toml " + + "skill, edit the source, and re-render. Read this when viewing or tempted to edit generated " + + "output.", + Title: "Rendered component files", + Intro: "These files are generated by `azldev comp render` and are build inputs; do not edit them " + + "directly — change the component's `.comp.toml`, overlays, or source files and re-render.", + Skills: []SkillPointer{ + {Skill: "azldev-comp-toml", Purpose: "for the component TOML format"}, + {Skill: "azldev-overlays", Purpose: "to change generated output via overlays"}, + {Skill: "azldev-update-component", Purpose: "to refresh and finalize a component"}, + }, + }, +} + +// Instructions returns the registered instruction files in emission order. +func Instructions() []Instruction { + return instructions +} + +// Layout controls where emitted skill files are written in a target repository. +type Layout struct { + // SkillsDir is the repo-relative parent directory that holds skill directories. + SkillsDir string +} + +// DefaultLayout returns the default emission layout: skills under the tool-neutral +// '.agents/skills' location from the Agent Skills open standard, and instructions +// under '.github/instructions'. +func DefaultLayout() Layout { + return Layout{ + SkillsDir: ".agents/skills", + } +} + +// SkillDir returns the repo-relative directory for a skill under this layout. +func (l Layout) SkillDir(skill Skill) string { + return path.Join(l.SkillsDir, skill.Name) +} + +// SkillFile returns the repo-relative SKILL.md path for a skill under this layout. +func (l Layout) SkillFile(skill Skill) string { + return path.Join(l.SkillDir(skill), "SKILL.md") +} + +// InstructionFile returns the repo-relative file path for an instruction. +func InstructionFile(inst Instruction) string { + return path.Join(".github/instructions", inst.Name+instructionFileSuffix) +} + +// Command is a top-level azldev command with its one-line summary. The list is generated +// from the Cobra command tree so the overview skill's command list never goes stale. +type Command struct { + Name string + Short string +} + +// Bindings are the target-repo values resolved from the repo's azldev.toml and +// injected into skill content. The caller (the 'docs agent' command) is responsible +// for populating every field: from a loaded configuration when one is available, or +// from azldev's built-in defaults when it is not, so the emitted documentation stays +// accurate for a default project even with no configuration present. +type Bindings struct { + // LockDir is the repo-relative directory holding per-component lock files. + LockDir string + + // RenderedSpecsDir is the repo-relative directory holding rendered component specs. + RenderedSpecsDir string + + // WorkDir is the repo-relative temporary working directory. Skills use it for + // throwaway scratch output so agents stay within the project's configured layout + // instead of writing to /tmp. + WorkDir string +} + +// Params carries the dynamic values injected into the emitted and served content. +type Params struct { + // Version is the azldev version stamped into the generated content. + Version string + + // TopLevelCommands is the sorted list of top-level azldev commands with summaries. + TopLevelCommands []Command + + // Bindings are the target-repo values resolved from the repo's configuration + // (or azldev defaults when none is available). Embedded so templates can reference + // its fields directly (e.g. '{{ .LockDir }}'). + Bindings +} + +// EmittedFile is a single file to be written into a target repository. +type EmittedFile struct { + // RelPath is the repository-relative destination path, always forward-slash separated. + RelPath string `json:"relPath"` + + // Content is the fully rendered file content. + Content string `json:"-"` +} + +func renderSkill(templateName string, skill Skill, params Params) (string, error) { + var buf bytes.Buffer + + data := struct { + Params + Skill + ShowSkillToolName string + }{ + Params: params, + Skill: skill, + ShowSkillToolName: ShowSkillToolName, + } + + err := templates.ExecuteTemplate(&buf, templateName, data) + if err != nil { + return "", fmt.Errorf("failed to render agent skill template %#q:\n%w", templateName, err) + } + + return buf.String(), nil +} + +func renderInstruction(inst Instruction, params Params) (string, error) { + applyTo, err := renderInline("applyTo", inst.ApplyTo, params) + if err != nil { + return "", fmt.Errorf("failed to render applyTo for instruction %#q:\n%w", inst.Name, err) + } + + var buf bytes.Buffer + + inst.ApplyTo = applyTo + data := struct { + Params + Instruction + }{ + Params: params, + Instruction: inst, + } + + err = templates.ExecuteTemplate(&buf, "instruction-wrapper.md.tmpl", data) + if err != nil { + return "", fmt.Errorf("failed to render instruction template for %#q:\n%w", inst.Name, err) + } + + return buf.String(), nil +} + +// renderInline renders a short, trusted template string (such as an instruction's applyTo +// glob, which may reference binding fields like '{{ .RenderedSpecsDir }}') against params. +func renderInline(name, text string, params Params) (string, error) { + tmpl, err := template.New(name).Parse(text) + if err != nil { + return "", fmt.Errorf("failed to parse %s template %#q:\n%w", name, text, err) + } + + var buf bytes.Buffer + + err = tmpl.Execute(&buf, params) + if err != nil { + return "", fmt.Errorf("failed to execute %s template %#q:\n%w", name, text, err) + } + + return buf.String(), nil +} + +// SkillDocument renders the full document for the named skill. It is served +// verbatim by the read-only MCP tool and by 'azldev docs agent show'. The default +// layout is used since a served document has no on-disk directory. +func SkillDocument(name string, params Params) (string, error) { + skill, err := FindSkill(name) + if err != nil { + return "", err + } + + return renderSkill(skill.bodyTemplate, skill, params) +} + +// Files renders the set of agent files to write into a target repository using the +// given layout. When full is true, each on-disk SKILL.md contains the complete +// skill document instead of a light MCP wrapper (useful when the azldev MCP server +// is not available in the target environment). Instruction files are always light +// wrappers that point at the relevant skills. +func Files(layout Layout, params Params, full bool) ([]EmittedFile, error) { + files := make([]EmittedFile, 0, len(skills)+len(instructions)) + + for _, skill := range skills { + templateName := "skill-wrapper.md.tmpl" + if full { + templateName = skill.bodyTemplate + } + + rendered, err := renderSkill(templateName, skill, params) + if err != nil { + return nil, err + } + + files = append(files, EmittedFile{RelPath: layout.SkillFile(skill), Content: rendered}) + } + + for _, inst := range instructions { + rendered, err := renderInstruction(inst, params) + if err != nil { + return nil, err + } + + files = append(files, EmittedFile{RelPath: InstructionFile(inst), Content: rendered}) + } + + return files, nil +} diff --git a/internal/app/azldev/agentskill/agentskill_test.go b/internal/app/azldev/agentskill/agentskill_test.go new file mode 100644 index 00000000..a07a054b --- /dev/null +++ b/internal/app/azldev/agentskill/agentskill_test.go @@ -0,0 +1,301 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package agentskill_test + +import ( + "path" + "strings" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/agentskill" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func testParams() agentskill.Params { + return agentskill.Params{ + Version: "1.2.3-test", + TopLevelCommands: []agentskill.Command{ + {Name: "component", Short: "Manage components"}, + {Name: "config", Short: "Manage configuration"}, + {Name: "docs", Short: "Generate documentation"}, + }, + Bindings: agentskill.Bindings{ + LockDir: "locks", + RenderedSpecsDir: "specs", + WorkDir: "build/work", + }, + } +} + +// parseFrontmatter extracts the leading YAML front matter of a Markdown document into a map of +// top-level "key: value" pairs. It is intentionally minimal (no nested structures) since the +// emitted files only use flat scalar fields. +func parseFrontmatter(t *testing.T, doc string) map[string]string { + t.Helper() + + require.True(t, strings.HasPrefix(doc, "---\n"), "document must start with YAML front matter") + + rest := strings.TrimPrefix(doc, "---\n") + frontmatter, _, found := strings.Cut(rest, "\n---") + require.True(t, found, "front matter must be terminated by a '---' line") + + fields := map[string]string{} + require.NoError(t, yaml.Unmarshal([]byte(frontmatter), &fields)) + + return fields +} + +// primarySkill returns the built-in azldev skill. +func primarySkill(t *testing.T) agentskill.Skill { + t.Helper() + + skill, err := agentskill.FindSkill(agentskill.SkillName) + require.NoError(t, err) + + return skill +} + +func TestSkillDocument(t *testing.T) { + doc, err := agentskill.SkillDocument(agentskill.SkillName, testParams()) + require.NoError(t, err) + + fields := parseFrontmatter(t, doc) + + assert.Equal(t, agentskill.SkillName, fields["name"]) + assert.NotEmpty(t, fields["description"]) + + // The full document substitutes the dynamic version stamp and the generated command list. + assert.Contains(t, doc, "1.2.3-test") + assert.Contains(t, doc, "- `azldev component`") + assert.Contains(t, doc, "- `azldev docs`") +} + +func TestSkillDocumentUnknown(t *testing.T) { + _, err := agentskill.SkillDocument("not-a-real-skill", testParams()) + require.Error(t, err) +} + +func TestSkillDocumentUsesBindings(t *testing.T) { + params := testParams() + params.Bindings = agentskill.Bindings{ + LockDir: "build/locks", + RenderedSpecsDir: "build/specs", + } + + doc, err := agentskill.SkillDocument("azldev-remove-component", params) + require.NoError(t, err) + + // The resolved binding values, not azldev's defaults, appear in the rendered body. + assert.Contains(t, doc, "build/locks/.lock") + assert.Contains(t, doc, "build/specs/") +} + +func TestUpdateComponentSkillStagesRenderedOutputBeforeAmend(t *testing.T) { + doc, err := agentskill.SkillDocument("azldev-update-component", testParams()) + require.NoError(t, err) + + assert.Equal(t, 2, strings.Count(doc, "git add specs///"), + "both amend workflows must stage the post-commit render") +} + +func TestSkillFrontmatterInvariants(t *testing.T) { + layout := agentskill.DefaultLayout() + + // Every registered skill (current and future) must satisfy the Agent Skills spec. + for _, skill := range agentskill.Skills() { + t.Run(skill.Name, func(t *testing.T) { + doc, err := agentskill.SkillDocument(skill.Name, testParams()) + require.NoError(t, err) + + fields := parseFrontmatter(t, doc) + + assert.Equal(t, path.Base(layout.SkillDir(skill)), fields["name"], + "skill name must match its parent directory name") + assert.LessOrEqual(t, len(fields["name"]), 64, "skill name must be at most 64 characters") + assert.Regexp(t, `^[a-z0-9-]+$`, fields["name"], "skill name must be lowercase, digits, and hyphens") + assert.NotEmpty(t, fields["description"], "skill description must not be empty") + assert.LessOrEqual(t, len(fields["description"]), 1024, "skill description must be at most 1024 characters") + }) + } +} + +// fileByPath returns the emitted file with the given repo-relative path. +func fileByPath(t *testing.T, files []agentskill.EmittedFile, relPath string) agentskill.EmittedFile { + t.Helper() + + for _, file := range files { + if file.RelPath == relPath { + return file + } + } + + require.Failf(t, "missing emitted file", "no emitted file with path %q", relPath) + + return agentskill.EmittedFile{} +} + +func mockSkill(t *testing.T) agentskill.Skill { + t.Helper() + + skill, err := agentskill.FindSkill("azldev-mock") + require.NoError(t, err) + + return skill +} + +// instructionByName returns the registered instruction with the given name. +func instructionByName(t *testing.T, name string) agentskill.Instruction { + t.Helper() + + for _, inst := range agentskill.Instructions() { + if inst.Name == name { + return inst + } + } + + require.Failf(t, "missing instruction", "no instruction named %q", name) + + return agentskill.Instruction{} +} + +func TestInstructionsRegistry(t *testing.T) { + names := make([]string, 0, len(agentskill.Instructions())) + for _, inst := range agentskill.Instructions() { + names = append(names, inst.Name) + } + + assert.Contains(t, names, "azldev") + assert.Contains(t, names, "comp-toml") + assert.Contains(t, names, "rendered-specs") + + compToml := instructionByName(t, "comp-toml") + assert.Equal(t, "**/*.comp.toml,**/components.toml", compToml.ApplyTo) + + skillNames := make([]string, 0, len(compToml.Skills)) + for _, pointer := range compToml.Skills { + skillNames = append(skillNames, pointer.Skill) + } + + assert.Contains(t, skillNames, "azldev-comp-toml") + assert.Contains(t, skillNames, "azldev-overlays") +} + +func TestSkillsRegistry(t *testing.T) { + names := make([]string, 0, len(agentskill.Skills())) + for _, skill := range agentskill.Skills() { + names = append(names, skill.Name) + } + + assert.Contains(t, names, agentskill.SkillName) + assert.Contains(t, names, "azldev-mock") + assert.Contains(t, names, "azldev-update-component") + assert.Contains(t, names, "azldev-remove-component") + assert.Contains(t, names, "azldev-overlays") + assert.Contains(t, names, "azldev-comp-toml") + assert.Contains(t, names, "azldev-add-component") + assert.Contains(t, names, "azldev-build-component") + assert.Contains(t, names, "azldev-image") +} + +func TestFilesWrapper(t *testing.T) { + layout := agentskill.DefaultLayout() + + files, err := agentskill.Files(layout, testParams(), false) + require.NoError(t, err) + // One wrapper per skill, plus one instruction file per registered instruction. + require.Len(t, files, len(agentskill.Skills())+len(agentskill.Instructions())) + + for _, file := range files { + assert.Contains(t, file.Content, "Generated by `azldev docs agent`; do not hand-edit.", file.RelPath) + assert.False(t, strings.HasSuffix(file.Content, "\n\n"), "%s has a trailing blank line", file.RelPath) + } + + skill := fileByPath(t, files, layout.SkillFile(primarySkill(t))).Content + assert.Contains(t, skill, "name: "+agentskill.SkillName) + // The wrapper points at the read-only MCP tool and omits the full skill body. + assert.Contains(t, skill, agentskill.ShowSkillToolName) + assert.NotContains(t, skill, "Golden rules") + + // The mock wrapper also points at the tool but is not the full body. + mockWrapper := fileByPath(t, files, layout.SkillFile(mockSkill(t))).Content + assert.Contains(t, mockWrapper, agentskill.ShowSkillToolName) + assert.NotContains(t, mockWrapper, "Never install built RPMs") + + // The azldev instruction wrapper applies to azldev.toml and points at the azldev skill by + // name (never the CLI/MCP tool, which may be unavailable in --full installs). + azldevInstruction := instructionByName(t, "azldev") + instructions := fileByPath(t, files, agentskill.InstructionFile(azldevInstruction)).Content + assert.Contains(t, instructions, `applyTo: "`+agentskill.ConfigGlob+`"`) + assert.Contains(t, instructions, "`"+agentskill.SkillName+"`") + assert.NotContains(t, instructions, agentskill.ShowSkillToolName) + assert.NotContains(t, instructions, "docs agent show") + + // The comp-toml instruction wrapper applies to *.comp.toml and points at its skills by + // name and purpose. + compTomlInstruction := instructionByName(t, "comp-toml") + compTomlWrapper := fileByPath(t, files, agentskill.InstructionFile(compTomlInstruction)).Content + assert.Contains(t, compTomlWrapper, `applyTo: "**/*.comp.toml,**/components.toml"`) + assert.Contains(t, compTomlWrapper, "read the `azldev-comp-toml` skill") + assert.Contains(t, compTomlWrapper, "Read the `azldev-overlays` skill to add or change overlays") + // The file-format skill is required; mutually exclusive workflow skills are conditional. + assert.Contains(t, compTomlWrapper, "You MUST read the `azldev-comp-toml` skill") + assert.NotContains(t, compTomlWrapper, "You MUST read the `azldev-overlays` skill") + assert.NotContains(t, compTomlWrapper, "docs agent show") + + // The rendered-specs wrapper carries the do-not-edit guardrail and a binding-resolved glob. + renderedSpecsInstruction := instructionByName(t, "rendered-specs") + renderedSpecsWrapper := fileByPath(t, files, agentskill.InstructionFile(renderedSpecsInstruction)).Content + assert.Contains(t, renderedSpecsWrapper, `applyTo: "specs/**/*"`) + assert.Contains(t, renderedSpecsWrapper, "do not edit them directly") +} + +func TestRenderedSpecsInstructionApplyToTracksBindings(t *testing.T) { + layout := agentskill.DefaultLayout() + params := testParams() + params.RenderedSpecsDir = "SPECS" + + files, err := agentskill.Files(layout, params, false) + require.NoError(t, err) + + inst := instructionByName(t, "rendered-specs") + wrapper := fileByPath(t, files, agentskill.InstructionFile(inst)).Content + + // The applyTo glob tracks the configured rendered-specs directory. + assert.Contains(t, wrapper, `applyTo: "SPECS/**/*"`) +} + +func TestFilesFull(t *testing.T) { + layout := agentskill.DefaultLayout() + + files, err := agentskill.Files(layout, testParams(), true) + require.NoError(t, err) + require.Len(t, files, len(agentskill.Skills())+len(agentskill.Instructions())) + + for _, file := range files { + assert.Contains(t, file.Content, "Generated by `azldev docs agent`; do not hand-edit.", file.RelPath) + } + + // In full mode each on-disk SKILL.md inlines the complete skill document. + assert.Contains(t, fileByPath(t, files, layout.SkillFile(primarySkill(t))).Content, "overlay system") + assert.Contains(t, fileByPath(t, files, layout.SkillFile(mockSkill(t))).Content, "azldev adv mock shell") +} + +func TestFilesGitHubLayout(t *testing.T) { + layout := agentskill.Layout{ + SkillsDir: ".github/skills", + } + + files, err := agentskill.Files(layout, testParams(), false) + require.NoError(t, err) + require.Len(t, files, len(agentskill.Skills())+len(agentskill.Instructions())) + + // The github layout places skills under .github/skills with their plain (namespaced) names. + azldevSkill := fileByPath(t, files, ".github/skills/azldev/SKILL.md") + assert.Contains(t, azldevSkill.Content, "name: azldev") + + mockFile := fileByPath(t, files, ".github/skills/azldev-mock/SKILL.md") + assert.Contains(t, mockFile.Content, "name: azldev-mock") +} diff --git a/internal/app/azldev/agentskill/content/add-component.md.tmpl b/internal/app/azldev/agentskill/content/add-component.md.tmpl new file mode 100644 index 00000000..be94703f --- /dev/null +++ b/internal/app/azldev/agentskill/content/add-component.md.tmpl @@ -0,0 +1,93 @@ +--- +name: {{ .Name }} +description: {{ .Description }} +--- + +# Add a component + +## Before you start + +Confirm the component does not already exist: + +```sh +azldev comp list -p -q -O json +``` + +### Inspect the upstream spec first + +The reliable way to see what you are importing (direct web fetches of upstream +dist-git often fail bot detection): + +1. Add a bare entry so azldev can resolve the component: run `azldev comp add ` + (it appends `[components.]` to the root config file), or hand-add + `[components.]` to an included config file if you want it to live elsewhere. + A bare root entry is *perfect* for initial testing, but real distros will usually + segment the configuration into included files. Once the initial pass is done, + ensure the component is in the right place and remove the root entry. + +2. Create the initial lock so source resolution is pinned before inspection: + + ```sh + azldev comp update -p + ``` + +3. Pull the sources without overlays into a scratch dir under the work dir: + + ```sh + azldev comp prep-sources -p --skip-overlays --force -o {{ .WorkDir }}/scratch/ -q + ``` + +4. Read the spec and plan any overlays. + +## Inline vs dedicated file + +- **Inline** — a bare upstream import with no changes stays in a shared config file: + + ```toml + [components.jq] + ``` + +- **Dedicated** — anything that needs overlays, build config, or a local spec gets its + own `/.comp.toml`. Rule of thumb: more than `[components.]` earns a + dedicated file. An `includes = ["**/*.comp.toml"]` glob picks it up automatically. + +`azldev comp add [...]` adds bare `[components.]` entries that inherit +the distro defaults; it writes to the **root** config file and does not scaffold spec or +source files. If your distro keeps components in included or dedicated files, move the +entry there afterward. + +## Customize + +For spec source types and overlays, read the `azldev-comp-toml` and `azldev-overlays` skills. Key +points when adding a component: + +- Prefer **overlays** over forking the spec — overlays get upstream updates for free. + Forking a spec is a last resort and a long-term maintenance commitment. Get explicit + user sign-off first, document every change, and keep the delta minimal. +- Every overlay needs a `description` explaining why it is needed. +- Keep `%check` enabled. Disable it only as a last resort via `build.check.skip = true` + with a required `build.check.skip_reason` (see the `azldev-build-component` skill). + +## Validate + +The initial lock pins the upstream revision you inspected. Refresh it after the +component inputs settle, then validate: + +```sh +azldev comp update -p # resolve the upstream commit and write the lock +azldev comp render -p # apply overlays and write the rendered spec +azldev comp diff-sources -p # see exactly what the overlays change +azldev comp build -p # build the RPMs +``` + +Inspect the rendered spec under `{{ .RenderedSpecsDir }}/`. A new component always needs a +smoke test — see the `azldev-build-component` and `azldev-mock` skills. + +After all component inputs are final, run `azldev comp update -p ` again and +re-render. Stage the component definition and sources, `{{ .LockDir }}/.lock`, and +the rendered output before committing. Then re-render, stage +`{{ .RenderedSpecsDir }}///`, and amend the commit so `%changelog` and +`Release:` reflect it. See the `azldev-update-component` skill for the complete +finalization workflow. + +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/content/azldev.md.tmpl b/internal/app/azldev/agentskill/content/azldev.md.tmpl new file mode 100644 index 00000000..58f636a8 --- /dev/null +++ b/internal/app/azldev/agentskill/content/azldev.md.tmpl @@ -0,0 +1,76 @@ +--- +name: {{ .Name }} +description: {{ .Description }} +--- + +# Using azldev + +azldev builds a Linux distribution from TOML configuration. It imports RPM specs +from an upstream distro and customizes them with an overlay system — no spec +forking. Components render to RPMs; images assemble RPMs into bootable artifacts. + +## Orient yourself + +- Run azldev from the repo root (where `azldev.toml` lives), or pass `-C `. +- Global, agent-friendly flags: **`-q`** (quiet) and **`-O json`** (machine-readable + output). They work on every command. +- Config is a stitched TOML hierarchy: `azldev.toml` includes the distro and project + config, which include the component files (`**/*.comp.toml`) into one namespace. + +## Core concepts + +- **Component** — a unit of packaging that renders to one or more RPMs. Its spec + source is upstream (default), a pinned upstream distro/version, or a local spec. +- **Overlay** — a semantic patch applied to a spec or source file at render time, so + you customize upstream without forking it. +- **Distro** — a named build target (`*.distro.toml`) with upstream URIs, release + versions, and build inputs. +- **Rendered spec** — the generated `.spec` after overlays; a build input, never + hand-edited. +- **Lock** — a per-component file pinning the resolved upstream commit and an input + fingerprint. + +## Common commands + +Top-level commands in this build: + +{{ range .TopLevelCommands }}- `azldev {{ .Name }}` — {{ .Short }} +{{ end }} +Everyday tasks (add `-q -O json` when scripting): + +| Task | Command | +| --- | --- | +| List components | `azldev comp list -a` | +| Inspect one component | `azldev comp list -p ` | +| Add a component | `azldev comp add` | +| Build a component | `azldev comp build -p ` | +| Render specs | `azldev comp render -p ` (or `-a`) | +| Refresh a lock | `azldev comp update -p ` | +| List / build images | `azldev image list` / `azldev image build` | + +`comp` is an alias for `component`. Always confirm current syntax with +`azldev --help`. + +The hidden `advanced` group (`adv`) contains specialist integrations such as MCP and +mock helpers; it is intentionally omitted from normal help. + +## Where to go next + +- Add a new component — read the `azldev-add-component` skill. +- Edit or review a component's TOML — read the `azldev-comp-toml` skill. +- Add or change overlays — read the `azldev-overlays` skill. +- Build, iterate, and debug a component — read the `azldev-build-component` skill. +- Build, boot, and register images — read the `azldev-image` skill. +- Refresh a lock and finalize for a PR — read the `azldev-update-component` skill. +- Remove a component — read the `azldev-remove-component` skill. +- Test built RPMs in a chroot — read the `azldev-mock` skill. + +## Golden rules + +- **Never edit generated output** — rendered specs and the output/work/log dirs are + produced by azldev. Change the source config and re-render. +- **Re-run `azldev comp update` before opening a PR** — the lock fingerprint covers + the whole component config, and lock / rendered-spec CI checks run on committed state. +- **Every overlay needs a `description`** explaining why the change is needed. + +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/content/build-component.md.tmpl b/internal/app/azldev/agentskill/content/build-component.md.tmpl new file mode 100644 index 00000000..1accd46f --- /dev/null +++ b/internal/app/azldev/agentskill/content/build-component.md.tmpl @@ -0,0 +1,60 @@ +--- +name: {{ .Name }} +description: {{ .Description }} +--- + +# Build and debug a component + +**Never install built RPMs on your host** — they target the distro, not your dev +machine. Test them in a chroot with the `azldev-mock` skill. Building and testing are separate +steps: `azldev comp build` produces RPMs; it does not test them. + +## Build + +```sh +azldev comp build -p # one component +azldev comp build -p -p --local-repo-with-publish # chain deps via a local repo +azldev comp build -p --local-repo # rebuild against a populated repo +``` + +Build foundational packages before their dependents. RPMs land in the project's +configured output directory (`out` by default). `-q` quiets output but hides build +progress — use it only for inner-loop builds you expect to succeed. + +## The inner loop + +investigate → modify → render → build → test → inspect + +| Step | Command | +| --- | --- | +| Investigate | read the rendered spec under `{{ .RenderedSpecsDir }}/`, or `azldev comp diff-sources -p ` | +| Modify | edit the component's `.comp.toml` (see the `azldev-overlays` and `azldev-comp-toml` skills) | +| Verify | `azldev comp render -p ` (fast — skips source tarballs) | +| Build | `azldev comp build -p ` | +| Test | `azldev adv mock shell --add-package ` (see the `azldev-mock` skill) | +| Inspect | `azldev comp build -p --preserve-buildenv always`, then a mock shell | + +Prefer `comp render` for quick overlay verification; use `comp diff-sources` to see the +exact overlay effect (it fetches sources once, applies overlays to a copy, and diffs the +two trees). Builds can be slow — set generous timeouts. + +Finalize with `azldev comp update -p ` before opening a PR (see the +`azldev-update-component` skill). + +## Debugging build failures + +1. **Render error mentioning a non-standard `Release` tag** — a release-calculation + issue; see the `azldev-comp-toml` skill. +2. **Overlay did not apply as expected** — `azldev comp diff-sources -p ` shows + what the overlays actually change. +3. **Inspect the build environment** — `azldev comp build -p --preserve-buildenv + on-failure` (values `on-failure`, `always`, `never`), then enter a mock shell. +4. **Failing `%check`** — fix the tests first (root cause, upstream patches, targeted + fixes). Only as a last resort, disable with `build.check.skip = true` and a required + `build.check.skip_reason` explaining what fails, why it cannot be fixed, and whether it + is temporary. A transient `--no-check` build flag exists for one-off local builds. + +Per-component build tweaks (`build.defines`, `build.without`) live in the `.comp.toml` — +see the `azldev-comp-toml` skill. + +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/content/comp-toml.md.tmpl b/internal/app/azldev/agentskill/content/comp-toml.md.tmpl new file mode 100644 index 00000000..fdb91734 --- /dev/null +++ b/internal/app/azldev/agentskill/content/comp-toml.md.tmpl @@ -0,0 +1,141 @@ +--- +name: {{ .Name }} +description: {{ .Description }} +--- + +# Component definition files (`*.comp.toml`) + +A component definition tells azldev where a package's spec comes from and how to +customize it for your distro. Every component lives under `[components.]`. + +Get the authoritative, always-current field list from the schema: + +```sh +azldev config generate-schema +``` + +## Structure + +A bare entry inherits everything from your distro's defaults — most upstream packages +need nothing more: + +```toml +[components.curl] +``` + +Add sub-tables only for what you change. The fields you will reach for most: + +| Field | Purpose | +| --- | --- | +| `spec` | where the spec comes from (see below) | +| `overlays` / `overlay-files` | targeted spec/source edits (see the `azldev-overlays` skill) | +| `build.defines` / `build.with` / `build.without` | RPM macro and bcond build tweaks | +| `release.calculation` | how the `Release` tag is managed | +| `render.skip-file-filter` | rendering edge-case escape hatch | + +## Spec source + +The `spec` field selects where the spec is fetched from. When omitted, the component +inherits the distro default (normally an upstream import). + +```toml +# Upstream import (the usual case) — inherits the distro's upstream version +[components.curl] + +# Upstream, but pinned to a specific upstream distro/version +[components.curl] +spec = { type = "upstream", upstream-distro = { name = "fedora", version = "rawhide" } } + +# Upstream package whose name differs from the component name +[components.mydistro-rpm-config] +spec = { type = "upstream", upstream-name = "redhat-rpm-config" } + +# Local spec that lives in your repo (not imported from an upstream distro) +[components.mydistro-release] +spec = { type = "local", path = "mydistro-release.spec" } +``` + +## Build configuration + +```toml +[components.mypackage.build] +defines = { rhel = "11" } # override RPM macros +with = ["feature_x"] # enable %bcond_with conditionals +without = ["plugin_rhsm"] # disable %bcond_with conditionals +``` + +## Release calculation + +`release.calculation` controls the `Release:` tag. There are four modes: + +- `auto` (default) — auto-detect whether the spec uses `%autorelease` or a static + release and handle it accordingly. Correct for most packages. +- `autorelease` — force `%autorelease` handling (use when auto-detection misreads a + spec that wraps `%autorelease` in a conditional). +- `static` — force static-integer handling and bump the integer on render (the + inverse of `autorelease`). +- `manual` — you own the `Release:` value. Use this only when render fails with a + "non-standard Release tag" error. **A `manual` component is not bumped by the + render/commit/amend cycle, so increment its release yourself in the same change** + (see the `azldev-update-component` skill). + +```toml +[components.mypackage.release] +calculation = "manual" +``` + +## Render configuration + +`render.skip-file-filter = true` keeps all source and patch files during render. +azldev normally prunes files not referenced by the rendered spec; set this only for +the rare spec whose `Source`/`Patch` filenames use macros the filter cannot expand. + +## File organization + +- **Inline** — put simple, customization-free components directly in a shared config + file (e.g. `[components.jq]`). +- **Dedicated** — give a component its own `/.comp.toml` once it needs + overlays, build config, or a local spec. Rule of thumb: anything more than + `[components.]` earns a dedicated file. +- A parent config picks up dedicated files through an `includes` glob, for example + `includes = ["**/*.comp.toml"]`. + +## Review checklist + +Start with `azldev comp list -p -q -O json`, then use +`azldev comp query -p -q -O json` when the review needs parsed spec details. +For a change review, focus on the diff while checking enough surrounding context to +ensure it fits the component and repository conventions. + +- **Organization:** The component follows the repository's inline-versus-dedicated-file + convention; its name matches upstream or sets `spec.upstream-name`; no stale or + orphaned component files remain. +- **Spec source:** The default upstream source is preferred. Pins explain why they are + needed, and local specs are used only when overlays cannot express the change. +- **Overlays:** Every overlay explains why it exists. Prefer structured overlay types + over regex; scope unavoidable `spec-search-replace` expressions by section and, when + applicable, package, and use TOML literal strings. `spec-search-replace` cannot span + lines. Remove overlays that upstream has made unnecessary. See the `azldev-overlays` skill. +- **Build config:** Defines and bcond overrides are necessary and correspond to the + spec. If `build.check.skip = true`, require a specific `build.check.skip_reason` and + verify that fixing the tests is impractical; skipped `%check` is a last resort. +- **Release mode:** `auto` is the default. Force `autorelease` or `static` only when + auto-detection is wrong. Use `manual` only for a non-standard release tag, and verify + the component increments its release itself. +- **Generated state:** The lock matches the final component inputs, and rendered output + contains the intended changes without unrelated drift. +- **Testing:** Changes that can affect RPM output were built and smoke-tested in a mock + chroot. Organization, comment, or documentation-only metadata edits do not require a + rebuild when the resolved component inputs are unchanged. + +Report findings by severity: errors for correctness or required-policy violations, +warnings for maintainability risks, and info for optional improvements. Prefer small, +actionable fixes over unrelated cleanup. + +## Documenting changes + +Add a TOML comment explaining *why* a non-obvious field is set (a version pin, a +workaround), and link the upstream commit or bug when the change is based on one. For +overlays, use the overlay `metadata` table instead (see the `azldev-overlays` skill). + +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/content/image.md.tmpl b/internal/app/azldev/agentskill/content/image.md.tmpl new file mode 100644 index 00000000..3e3cf9e4 --- /dev/null +++ b/internal/app/azldev/agentskill/content/image.md.tmpl @@ -0,0 +1,60 @@ +--- +name: {{ .Name }} +description: {{ .Description }} +--- + +# Build and boot images + +An image assembles built RPMs into a deliverable — a container or a bootable VM. +azldev drives the build from a kiwi-ng definition; the `.kiwi` file itself is +**upstream KIWI NG** ([docs](https://osinside.github.io/kiwi/)) and azldev does not +own its schema. + +## Commands + +Images are selected by their **name as a positional argument** (not `-p`). + +| Task | Command | +| --- | --- | +| List images | `azldev image list` | +| Build an image (kiwi-ng) | `azldev image build ` | +| Boot an image in a QEMU VM | `azldev image boot ` | +| Run an image's tests | `azldev image test ` | +| Customize a pre-built image | `azldev image customize` | + +`azldev image build` takes `--local-repo` / `--remote-repo` to add package sources and +`--arch` to target an architecture. `azldev image boot` takes a built image name, or an +explicit `--image-path` / `--iso`. Confirm current flags with +`azldev image --help` (there are more subcommands, e.g. `inject-files`). + +## Registering an image + +Images are declared under `[images.]` (conventionally in an `images.toml`): + +```toml +[images.container-base] +description = "Container base image" +definition = { type = "kiwi", path = "container-base/container-base.kiwi", profile = "core" } + + [images.container-base.capabilities] + container = true + systemd = false + + [images.container-base.tests] + test-suites = ["smoke"] +``` + +- `definition.type` is `kiwi` (the only type today); `path` points at the `.kiwi` file; + `profile` selects a kiwi profile (optional). +- `capabilities` are tri-state flags describing the image — `machine-bootable`, + `container`, `systemd`, `runtime-package-management`. Set only the ones that apply. +- `tests.test-suites` lists the test suites `azldev image test` runs. +- `publish.channels` lists the channels the image publishes to. + +## The kiwi definition + +The `.kiwi` file is upstream KIWI NG XML — azldev does not own its schema. It selects the +image type (container vs. VM), package lists, repositories, and boot configuration. See +the [KIWI NG documentation](https://osinside.github.io/kiwi/) for the format and elements. + +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl b/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl new file mode 100644 index 00000000..0524ee15 --- /dev/null +++ b/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl @@ -0,0 +1,22 @@ +--- +description: {{ .Description }} +applyTo: "{{ .ApplyTo }}" +--- + +# {{ .Title }} + +{{ .Intro }} + +**Do not work from memory or guess.** Before authoring, editing, or reviewing content +in scope here, you MUST read the primary azldev skill below. It is the authoritative, +version-matched source for this file type. + +{{ with index .Skills 0 }}- You MUST read the `{{ .Skill }}` skill {{ .Purpose }}. +{{ end }} +{{ if gt (len .Skills) 1 -}} +Load only the additional workflow skill required by the task: + +{{ range $index, $pointer := .Skills }}{{ if $index }}- Read the `{{ $pointer.Skill }}` skill {{ $pointer.Purpose }}. +{{ end }}{{ end -}} +{{ end }} +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/content/mock.md.tmpl b/internal/app/azldev/agentskill/content/mock.md.tmpl new file mode 100644 index 00000000..a3e6d88c --- /dev/null +++ b/internal/app/azldev/agentskill/content/mock.md.tmpl @@ -0,0 +1,92 @@ +--- +name: {{ .Name }} +description: {{ .Description }} +--- + +# Test and inspect packages in a mock chroot + +> **Never install built RPMs on the host.** They target the distro you are +> building, not your dev machine. Use a mock chroot instead. + +azldev tests and inspects packages inside `mock` build roots via `mock shell`, +which lives under the hidden `advanced` group (`azldev adv` is an alias for +`azldev advanced`; it does not appear in `azldev --help`). + +## Non-interactive chroot (preferred for agents) + +Pipe commands in via a heredoc — fully autonomous, no user interaction needed: + +```sh +azldev adv mock shell --add-package /path/to/package.rpm <<'CMDS' + --version +echo "exit code: $?" +exit +CMDS +``` + +Use this for automated checks: running binaries, querying installed packages, +inspecting paths. + +## Inspect an RPM without installing it + +When `--add-package` fails to install, or you just want to look at an RPM's +contents, get the file inside the chroot and inspect it there rather than +installing on the host. If the RPM is not already reachable inside the build +root, copy it in first with `mock --copyin /tmp/` (using the project's +distro mock config), then: + +```sh +azldev adv mock shell <<'CMDS' +rpm -qip /tmp/*.rpm # package info +rpm -qlp /tmp/*.rpm # file list +rpm -qRp /tmp/*.rpm # dependencies +dnf install /tmp/*.rpm # retry the install to see the error details +exit +CMDS +``` + +This gives you the full distro environment for debugging dependency issues, file +conflicts, and failed installs, without touching the host. + +## Interactive chroot + +Use when you need to explore interactively or react to output between commands: + +```sh +azldev adv mock shell --add-package /path/to/package.rpm --enable-network +``` + +When driving an interactive shell as an agent: + +1. Run a simple command (e.g. `echo ready`) in a foreground terminal to establish a shared shell. +2. Ask the user to run the `azldev adv mock shell` command in that same terminal. +3. Wait for confirmation that they are inside the mock shell. +4. Run diagnostic commands in the same foreground terminal — they execute inside the chroot. +5. Run `exit` to leave the chroot. + +Prefer the non-interactive heredoc approach; use interactive only when you must +react to output between commands. + +## Key flags + +| Flag | Purpose | +| --- | --- | +| `-p`, `--add-package ` | Install a package into the chroot before entering. Accepts an RPM file path or a package name. May be repeated. | +| `--enable-network` | Allow network access (dependency resolution, downloads). | + +> **`-p` means `--add-package`, not a component name.** Unlike `azldev comp build -p `, +> `mock shell` has no component selector. `-p ` installs a package *by name* from the +> configured repos — it does not set up a chroot with that component's build dependencies. + +## Gotchas + +- **Don't mix `-p ` with `--add-package /path/to/.rpm` for the same package** — that + installs both the repo build and your local build, which conflict. Use one or the other. +- **The mock chroot is persistent** across `azldev adv mock shell` sessions — installed packages and + created files survive. Handy (install once, re-enter later), but stale state can confuse. azldev + has no built-in reset; clear it with `mock` directly (`--scrub=chroot`, or `--scrub=all` to also + drop the bootstrap), pointing at the project's distro mock config. +- **One mock session per terminal.** Running `azldev adv mock shell` while a terminal is already + inside a mock shell fails — run `exit` first. + +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/content/overlays.md.tmpl b/internal/app/azldev/agentskill/content/overlays.md.tmpl new file mode 100644 index 00000000..cd0abec3 --- /dev/null +++ b/internal/app/azldev/agentskill/content/overlays.md.tmpl @@ -0,0 +1,149 @@ +--- +name: {{ .Name }} +description: {{ .Description }} +--- + +# Working with overlays + +Overlays are **semantic patches** applied to a component's RPM spec and loose +source files at render time. They let you make targeted changes to an upstream +spec without forking it. Prefer an overlay over hand-editing a rendered spec: +overlays are re-applied on every render, so a manual edit to a rendered spec is +overwritten. + +## The inner loop + +Overlays live in the component's TOML config — inline `[[components..overlays]]` +entries, or per-file overlay documents referenced by the component's `overlay-files` +glob. They apply **in order** and are **non-atomic**: if one fails part-way, the +overlays before it stay applied. + +1. Add or edit the overlay in the component config. +2. Re-render and inspect the result: + + ```sh + azldev comp render -p + ``` + + Read the rendered spec (under `{{ .RenderedSpecsDir }}/`) to confirm the change + landed where you intended, and iterate until it is correct. +3. Finalize the lock and changelog with the normal end-of-work refresh (see the + `azldev-update-component` skill): update the lock, commit, then re-render and amend. + +Config errors reference the offending overlay by its `description`, so give every +overlay a short, specific `description`. + +## Diagnose common failures + +Start with `azldev comp diff-sources -p ` to see the exact overlay effect. +Use separate pre/post `prep-sources` directories only when you need persistent trees +for deeper inspection. + +| Symptom | Likely cause and fix | +| --- | --- | +| `spec-add-tag`: tag already exists | Upstream already has the tag. Use `spec-set-tag`, or `spec-update-tag` when its prior existence is an invariant. | +| `spec-search-replace`: no match | Inspect the current upstream line, check TOML regex quoting, and narrow the expression to the actual section/package. | +| Section or file not found | Inspect the upstream spec/source names; upstream may have renamed or removed the target. | +| Overlay applies but output/build is wrong | Inspect `diff-sources` for an over-broad match, malformed replacement, or a dependency/file change the overlay omitted. | + +## Choosing an overlay type + +Match the change to the narrowest overlay type. Required fields are enforced when +the config loads, so a missing field fails fast rather than at apply time. + +### Spec overlays (structured `.spec` edits) + +| Type | Use for | Required | +| --- | --- | --- | +| `spec-add-tag` | add a tag; fails if it already exists | `tag`, `value` | +| `spec-insert-tag` | add a tag next to its family (e.g. after the last `Source*`) | `tag`, `value` | +| `spec-set-tag` | set a tag, replacing it if present or adding it if not | `tag`, `value` | +| `spec-update-tag` | change an existing tag; fails if it is missing | `tag`, `value` | +| `spec-remove-tag` | delete tag instances; without `value`, deletes every instance | `tag` | +| `spec-prepend-lines` | insert lines at the top of a section (or the whole file) | `lines` | +| `spec-append-lines` | insert lines at the end of a section (or the whole file) | `lines` | +| `spec-search-replace` | regex replace within a section (or the whole spec) | `regex` | +| `spec-remove-section` | delete a whole section | `section` | +| `spec-remove-subpackage` | delete every section of a sub-package | `package` | +| `patch-add` | add a `.patch` file and register it in the spec | `source` | +| `patch-remove` | remove a patch and its spec references | `file` | + +### File overlays (loose non-spec files; never `.spec`) + +| Type | Use for | Required | +| --- | --- | --- | +| `file-prepend-lines` | prepend lines to a file | `file`, `lines` | +| `file-search-replace` | regex replace in a file | `file`, `regex` | +| `file-add` | copy in a new file; fails if it already exists | `file`, `source` | +| `file-remove` | delete a file | `file` | +| `file-rename` | rename a file in place | `file`, `replacement` | + +## Rules that trip people up + +- **`spec-remove-tag` without `value` removes every instance** of the named tag. + To remove one dependency, set both `tag` and the exact `value` to match: + + ```toml + [[components.mypackage.overlays]] + description = "Remove an unavailable build dependency" + type = "spec-remove-tag" + tag = "BuildRequires" + value = "unwanted-package" + ``` +- **`section` is optional only** for `spec-prepend-lines`, `spec-append-lines`, and + `spec-search-replace` (omit it to target the whole spec). It is **required** for + `spec-remove-section`. +- **`package` needs `section`** on the whole-file-capable overlays — a sub-package is + a sub-qualifier of a section. `spec-remove-subpackage` is the exception: it takes + `package` and rejects `section`. +- **`replacement` is literal** — `$1`-style capture-group references are not expanded; + omit it to delete matched text. +- **Quote `regex` as a TOML literal string** — write `regex = '\.so$'`, not + `regex = "\.so$"`. A basic (double-quoted) TOML string interprets backslash escapes, so + `\s`, `\.`, `\d` and friends are mangled before the regex engine ever sees them; single + quotes keep the pattern verbatim. +- **`spec-search-replace` matches one line at a time** — the pattern is applied to each + spec line independently, so it can never span a newline and `(?s)`/DOTALL does nothing. + For a multi-line change use a structured spec overlay (`spec-remove-section`, + `spec-prepend-lines`/`spec-append-lines`, etc.). `file-search-replace` is different: it + matches against the whole file, so multi-line patterns (and `(?s)`) work there. +- **`file` is a glob** (`**` supported) for the multi-file file overlays; for `file-add` + and `file-rename` it is a single name, and `file-rename`'s `replacement` is a + filename only (not a path). +- **`source` paths are relative** to the config that declares the overlay — the overlay + file when loaded via `overlay-files`, otherwise the component config. +- **`file-add` lands beside the spec**, in the dist-git sources root — not inside the + extracted upstream tree. Adding a file there does not make the build use it; wire it in + with a `SourceN` tag plus `%prep`/`%install` steps, or use `patch-add` to change tracked + sources. +- **Don't rename the `Name:` tag** with `spec-update-tag`/`spec-set-tag`. `%{name}` feeds + `Source*` URLs, `%setup -n`, and `%files` paths, so renaming it silently breaks those + references. Keep the spec `Name` aligned with the component instead. +- To add a real `.patch` file (rather than an inline edit), use `patch-add`; it copies + the `source` into the component sources and registers a `PatchN` tag or `%patchlist` + entry. + +## Document intent with `metadata` + +Give non-trivial overlays a `metadata` table. It is documentation only — excluded from +the component fingerprint, so editing it never invalidates the build cache — but it +records *why* the overlay exists and *when* it can be dropped. `category` is required; +pick the narrowest of: + +`backport-dist-git`, `azl-pruning`, `azl-compatibility`, `azl-dep-missing-workaround`, +`azl-branding-policy`, `azl-disable-flaky-tests`, `azl-disable-unsupported-tests`, +`azl-security-compliance`, `azl-release-management`, `azl-platform-adaptation`. + +Add `commits` (upstream URLs — required for `backport-dist-git`), `bugs` +(`{ url = ... }` entries), and `upstreamable` (`true`/`false`, omit if not yet +assessed) where they apply. When several overlays share one provenance, put them in a +per-file overlay document (`overlay-files`) with a single file-level `[metadata]`. + +## Full reference + +The tables above are the working subset. For the exhaustive field rules, metadata +constraints, and the per-file overlay format, generate the machine-readable schema +with `azldev config generate-schema` (see the `ComponentOverlay` definition), or read +azldev's overlays configuration reference. + +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/content/remove-component.md.tmpl b/internal/app/azldev/agentskill/content/remove-component.md.tmpl new file mode 100644 index 00000000..448e166f --- /dev/null +++ b/internal/app/azldev/agentskill/content/remove-component.md.tmpl @@ -0,0 +1,78 @@ +--- +name: {{ .Name }} +description: {{ .Description }} +--- + +# Remove a component + +There is **no `azldev` command to remove a component** — it is a manual edit. A +deletion-only change needs no package build, but changes to dependants, package/image +configuration, or other built inputs must follow the repository's normal validation +requirements. The steps below delete the component's definition, lock, and rendered +spec, then clean up references. + +## Before you start + +Confirm the component exists and see what it produces: + +```sh +azldev comp list -p -q -O json +``` + +Check for **reverse dependencies** first: if other components `BuildRequires` or +`Requires` this one, removing it breaks their builds. Search the tree for the +component and its binary subpackage names before deleting anything. + +## Steps + +1. **Remove the definition.** Delete the component's dedicated + `/.comp.toml` directory, or remove its inline `[components.]` + entry from whichever included TOML defines it. +2. **Remove publish / package config references.** If your project configures + publish channels or package groups, drop any references to the component or to + its binary subpackages. Publishing is component-scoped; per-binary exceptions + are binary-RPM-scoped — search for both. +3. **Remove the lock.** There is no azldev command for this: + + ```sh + rm {{ .LockDir }}/.lock + ``` + +4. **Remove the rendered spec.** Let azldev prune orphaned spec directories: + + ```sh + azldev comp render -a --clean-stale + ``` + + `--clean-stale` (only valid with `-a`) removes rendered-spec directories that no + longer correspond to a configured component. It re-renders everything, so it is + slow; for a targeted removal you can instead delete the component's rendered + spec under `{{ .RenderedSpecsDir }}/` by hand. +5. **Check other references.** Grep for the component (and its binary names) in + image definitions and any `*.kiwi` / package-list files, and remove or replace + them — an image that installs a now-removed package will not build. + +## Verify + +```sh +azldev comp list -p -q -O json # should report the component is not found +``` + +Also confirm the lock and rendered spec directory are gone and that no image or +package configuration still references the component. + +If the removal required changes to dependants or package/image configuration, build +and test the affected outputs according to the repository's normal validation policy. + +## Notes + +- **Dependants with manual release.** If removing this component forces a change + in a *dependant* (e.g. dropping a `BuildRequires`), and that dependant sets + `release.calculation = "manual"`, bump its release counter yourself in the same + change. Components with automatic release calculation (`auto`, `autorelease`, + `static`) are handled by the normal commit/render/amend cycle. +- **Remove exclusive dependencies together.** If a package is only needed by the + component you are dropping, remove it in the same change to keep the tree + consistent. + +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl b/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl new file mode 100644 index 00000000..b69275e6 --- /dev/null +++ b/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl @@ -0,0 +1,19 @@ +--- +name: {{ .Name }} +description: {{ .Description }} +--- + +# {{ .Name }} + +For the full, always-up-to-date content of this skill, call the read-only MCP tool +**`{{ .ShowSkillToolName }}`** with skill `{{ .Name }}` (provided by the azldev +MCP server). + +If the azldev MCP server is not configured, run `azldev docs agent show --skill {{ .Name }}` +to print the same content, or regenerate this file with the full skill inlined: + +```sh +azldev docs agent install --full +``` + +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/content/update-component.md.tmpl b/internal/app/azldev/agentskill/content/update-component.md.tmpl new file mode 100644 index 00000000..934a663e --- /dev/null +++ b/internal/app/azldev/agentskill/content/update-component.md.tmpl @@ -0,0 +1,72 @@ +--- +name: {{ .Name }} +description: {{ .Description }} +--- + +# Update component lock files + +`azldev comp update` (`comp` is an alias for `component`) refreshes one or more +component lock files under `{{ .LockDir }}/`. A lock pins the resolved upstream commit plus +an input fingerprint computed from the component's render inputs — its TOML config, +overlays, the pinned upstream commit, and the distro release version. If any of +those change, the lock is stale. + +## When to run `update` + +| Situation | Run `update`? | +| --- | --- | +| Adding a new upstream component (no lock yet) | **Yes** — first, to create the lock before `render`/`build` can resolve it | +| Finalizing a component change for a PR | **Yes** — once at the end | +| Changing source resolution (commit pin, upstream distro/version, or snapshot) | **Yes** — also mid-workflow (see below) | +| Iterating on overlays / build config / metadata | No — once the lock exists, `render` alone is enough while iterating | +| Just reading or building existing components | No | + +Refresh a single component with `-p `. Use `-a` (all components) only for +coordinated mass refreshes (e.g. a new distro snapshot) or when investigating +lock drift across many components — it is slow. For day-to-day work use `-p`. Add +`-O json` for machine-readable lock output when debugging. + +## End-of-work refresh (the common case) + +For most edits — overlays, build flags, metadata — run `update` once at the end, +then re-render *after committing* so the generated changelog and release reflect +your new commit: + +```sh +azldev comp update -p +azldev comp render -p +# stage the component config, its lock (`{{ .LockDir }}/.lock`), and the rendered spec +git commit -m "fix(): ..." + +# Re-render and amend so the changelog / Release: track the new commit. +azldev comp render -p +git add {{ .RenderedSpecsDir }}/// +git commit --amend --no-edit +``` + +### Why the second render-and-amend? + +Unless a component sets `release.calculation = "manual"`, azldev derives its +`%changelog` and `Release:` from the component's **git history**, not the working +tree — the renderer walks the commit log every time. The first render happens +before your commit exists, so a fresh render *after* committing adds a new +changelog entry and bumps `Release:`. Amending folds that into a single clean +commit and keeps any rendered-spec / lock CI gate (which runs against committed +state) green. + +For a `release.calculation = "manual"` component the renderer does **not** bump +`Release:` for you — increment the release counter yourself in the same change. + +## Changing source resolution + +A source-resolution change follows the same rule, using one commit followed by a +post-render amend: + +1. Change the commit pin, upstream distro/version, or snapshot, then `azldev comp update -p `; sanity-check `{{ .LockDir }}/.lock`. +2. `azldev comp render -p ` — the spec body now tracks the newly resolved source. `%changelog` / `Release:` still reflect the previous source; that is expected until you commit. +3. Iterate on overlays / patches / build config as the new source requires, re-rendering after each change. Re-run `update` only if you change a source-resolution input again. +4. `azldev comp update -p `, then stage all component inputs changed above (source config, component config, overlays, and patches), `{{ .LockDir }}/.lock`, and the rendered output. Commit them together. +5. `azldev comp render -p ` — `%changelog` / `Release:` now reflect the new lock. +6. `git add {{ .RenderedSpecsDir }}///`, then `git commit --amend --no-edit` so the source change and rendered output land together. + +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/doc.go b/internal/app/azldev/agentskill/doc.go new file mode 100644 index 00000000..18e3816c --- /dev/null +++ b/internal/app/azldev/agentskill/doc.go @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package agentskill is the single source of truth for the AI-agent skill and +// instruction files that describe how to use the azldev CLI. Everything is embedded +// in the binary, so the files written into a repo, the CLI output, and the MCP tool +// response are always in lock-step with the azldev version that produced them. +// +// # Mental model +// +// At heart this is "a few text files with find/replace": the text lives in +// content/*.md.tmpl, and the "replace" is Go text/template substitution of a small set +// of dynamic values (see Params). Two Go registries name the files and carry the bits +// that can't live in a single shared template: +// +// - skills — each Skill is {Name, Description, bodyTemplate}. The body template +// under content/ holds the real, substantive guidance for one topic. +// - instructions — each Instruction is {Name, ApplyTo, Description, Title, Intro, +// Skills}. An instruction is a lightweight, path-scoped pointer: its applyTo glob +// decides which files it covers, and it only tells the agent which skill(s) to read. +// +// Put substantive content in a skill; keep instructions thin. +// +// # Two renderings of every skill +// +// A skill can be emitted two ways from the same registry entry: +// +// - full body — the skill's own bodyTemplate (e.g. azldev.md.tmpl), the complete +// content. Used by 'docs agent show', the docs-agent-show MCP tool, and +// 'docs agent install --full'. +// - redirect wrapper — one generic skill-wrapper.md.tmpl for all skills, a thin +// SKILL.md that points the agent at the docs-agent-show MCP tool for always-fresh +// content. This is the default on-disk file 'docs agent install' writes. +// +// Because the redirect wrapper is generic, it cannot hard-code each skill's name and +// description in its front matter — they are handed to it as data. That is the only +// reason Skill.Description exists as a field rather than living in the template front +// matter: holding it once in the registry lets both the full body and +// the shared wrapper render the same name/description without duplicating the text. +// Instruction files work the same way: one shared instruction-wrapper.md.tmpl renders +// every instruction, so each instruction's Description/ApplyTo/etc. are data too. +// +// # Where a Description ends up +// +// renderSkill copies Skill.Description into the template data; the template (full body +// or shared wrapper) emits it as the YAML front-matter "description:" line, which agent +// runtimes read to decide whether to load the skill. renderInstruction does the same via +// the instruction wrapper. That front-matter line is the description's only destination. +// +// # Substituted values (Params) +// +// The {{ .Field }} placeholders in the templates are filled from Params, resolved by the +// 'docs agent' command: +// +// - Version — the azldev version stamped into every file. +// - TopLevelCommands — generated from the Cobra command tree, so the overview skill's +// command list never goes stale. +// - Bindings — repo-specific paths (LockDir, RenderedSpecsDir, WorkDir) read from +// the target azldev.toml, degrading to azldev's defaults when no config is present. +// +// # Outputs (three sinks, one registry) +// +// skills[] / instructions[] --render(Params)--> install --> write files into a repo +// + content/*.md.tmpl show --> print to stdout +// MCP --> docs-agent-show returns text +// +// All three enumerate the same registries, so the on-disk files, the CLI, and the MCP +// tool cannot drift from one another. +// +// # Maintenance +// +// The rules for adding or editing a skill/instruction — front-matter limits, drift-guard +// tests, and the mandatory "validate every claim against the current code" step — live in +// .github/instructions/agent-skills.instructions.md. +package agentskill diff --git a/internal/app/azldev/cmds/docs/agent.go b/internal/app/azldev/cmds/docs/agent.go new file mode 100644 index 00000000..3e519652 --- /dev/null +++ b/internal/app/azldev/cmds/docs/agent.go @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package docs + +import ( + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/agentskill" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/spf13/cobra" + "go.szostok.io/version" +) + +// AgentInstallOptions holds the options for the 'docs agent install' command. +type AgentInstallOptions struct { + OutputDir string + Full bool + Layout string +} + +// InstalledAgentFile describes a single agent file written (or, in dry-run mode, that would be +// written) by the 'docs agent install' command. +type InstalledAgentFile struct { + Path string `json:"path"` + Written bool `json:"written"` +} + +// Called once when the app is initialized; registers the 'agent' command tree under 'docs'. +func agentOnAppInit(_ *azldev.App, parentCmd *cobra.Command) { + parentCmd.AddCommand(newAgentCmd()) +} + +func newAgentCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "agent", + Short: "Emit AI agent skill and instruction files", + Long: `Emit files that teach AI coding agents how to use azldev. + +The 'install' subcommand writes an Agent Skill and a path-specific instructions +file into a target repository. The 'show' subcommand prints the full azldev skill +to stdout and is exposed as a read-only MCP tool, which the emitted wrapper files +reference so that agents always load the guidance that ships with the binary.`, + } + + cmd.AddCommand(newAgentInstallCmd()) + cmd.AddCommand(newAgentShowCmd()) + + return cmd +} + +func newAgentInstallCmd() *cobra.Command { + var options AgentInstallOptions + + cmd := &cobra.Command{ + Use: "install", + Aliases: []string{"write", "emit"}, + Short: "Write azldev agent skill and instruction files into a repository", + Long: `Write AI agent files describing how to use azldev into a target repository. + +Creates (or overwrites) the generated agent substrate, relative to the output directory: + +.agents/skills//SKILL.md one Agent Skill per built-in skill +.github/instructions/.instructions.md path-specific instruction wrappers +.mcp.json registers the azldev MCP server for Copilot +.vscode/mcp.json registers the azldev MCP server + +Everything azldev writes is generated deterministically, so re-running install is a +no-op once committed — a CI check can run install and assert an empty diff. The +MCP entries are upserted, preserving any other servers already configured. + +By default the emitted SKILL.md is a light wrapper that points agents at the +read-only 'docs-agent-show' MCP tool for the full, always-current skill. Pass +--full to inline the complete skill instead, for environments without the azldev +MCP server. + +Directory paths in the emitted content (such as the lock and rendered-spec +directories) are resolved from the loaded azldev.toml, falling back to azldev's +built-in defaults when no configuration is found. The bindings reflect the project +azldev runs in, so pair --output-dir with -C pointing at the target repository when +scaffolding a different repo.`, + Example: ` # Write agent files into the current repository + azldev docs agent install + + # Write into a specific repository, inlining the full skill + azldev docs agent install -o ../other-repo --full`, + } + + cmd.RunE = azldev.RunFuncWithoutRequiredConfig( + func(env *azldev.Env) (interface{}, error) { + return InstallAgentFiles(env, cmd.Root(), &options) + }) + + cmd.Flags().StringVarP(&options.OutputDir, "output-dir", "o", ".", + "target repository directory to write agent files into") + cmd.Flags().BoolVar(&options.Full, "full", false, + "inline the full skill into SKILL.md instead of a light MCP wrapper") + cmd.Flags().StringVar(&options.Layout, "layout", "agents", + "skill layout: 'agents' (.agents/skills) or 'github' (.github/skills)") + + _ = cmd.MarkFlagDirname("output-dir") + + // Not exposed as an MCP tool: 'install' writes files into the target repository. Only the + // read-only 'show' subcommand is exposed to agents; they can regenerate files via the CLI. + + return cmd +} + +func newAgentShowCmd() *cobra.Command { + var skillName string + + completeSkillNames := cobra.FixedCompletions(skillNames(), cobra.ShellCompDirectiveNoFileComp) + + cmd := &cobra.Command{ + Use: "show", + Aliases: []string{"skill", "print"}, + Short: "Print an azldev agent skill to stdout", + Long: `Print an azldev Agent Skill document to stdout. + +This is the authoritative skill content embedded in the binary. It is exposed as a +read-only MCP tool so that agents can load it on demand; the wrapper files written +by 'azldev docs agent install' reference this tool. + +Name the skill with --skill (shell completion lists the choices). Run with no +skill to list the available skills.`, + Example: ` # List the available skills + azldev docs agent show + + # Print a skill (--skill tab-completes) + azldev docs agent show --skill azldev-overlays`, + Args: cobra.NoArgs, + } + + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + name, list, err := resolveShowSkill(skillName) + if err != nil { + return err + } + + if name == "" { + // No skill named and several available: list them instead of failing. + for _, n := range list { + fmt.Println(n) + } + + return nil + } + + env, err := azldev.GetEnvFromCommand(cmd) + if err != nil { + return fmt.Errorf("failed to get command environment:\n%w", err) + } + + doc, err := agentskill.SkillDocument(name, agentSkillParams(env, cmd.Root())) + if err != nil { + return fmt.Errorf("failed to render azldev skill:\n%w", err) + } + + fmt.Println(doc) + + return nil + } + + cmd.Flags().StringVar(&skillName, "skill", "", "skill to print") + _ = cmd.RegisterFlagCompletionFunc("skill", completeSkillNames) + + azldev.ExportAsReadOnlyMCPTool(cmd) + + return cmd +} + +// InstallAgentFiles renders and writes (or, in dry-run mode, reports) the azldev agent files into +// the output directory named by options. +func InstallAgentFiles( + env *azldev.Env, rootCmd *cobra.Command, options *AgentInstallOptions, +) ([]InstalledAgentFile, error) { + layout, err := resolveLayout(options.Layout) + if err != nil { + return nil, err + } + + files, err := agentskill.Files(layout, agentSkillParams(env, rootCmd), options.Full) + if err != nil { + return nil, fmt.Errorf("failed to render azldev agent files:\n%w", err) + } + + fs := env.FS() + results := make([]InstalledAgentFile, 0, len(files)) + + for _, file := range files { + destPath := filepath.Join(options.OutputDir, filepath.FromSlash(file.RelPath)) + + if env.DryRun() { + results = append(results, InstalledAgentFile{Path: destPath, Written: false}) + + continue + } + + err := fileutils.MkdirAll(fs, filepath.Dir(destPath)) + if err != nil { + return nil, fmt.Errorf("failed to create directory for %#q:\n%w", destPath, err) + } + + err = fileutils.WriteFile(fs, destPath, []byte(file.Content), fileperms.PublicFile) + if err != nil { + return nil, fmt.Errorf("failed to write agent file %#q:\n%w", destPath, err) + } + + results = append(results, InstalledAgentFile{Path: destPath, Written: true}) + } + + vscodeMCPResult, err := emitMCPConfig( + env, options.OutputDir, vscodeMCPConfigRelPath, "servers", false, + ) + if err != nil { + return nil, err + } + + results = append(results, vscodeMCPResult) + + copilotMCPResult, err := emitMCPConfig( + env, options.OutputDir, copilotMCPConfigRelPath, "mcpServers", true, + ) + if err != nil { + return nil, err + } + + results = append(results, copilotMCPResult) + + return results, nil +} + +const ( + // vscodeMCPConfigRelPath is the repo-relative path of the VS Code MCP server configuration. + vscodeMCPConfigRelPath = ".vscode/mcp.json" + // copilotMCPConfigRelPath is the repo-relative path of the Copilot MCP server configuration. + copilotMCPConfigRelPath = ".mcp.json" +) + +// emitMCPConfig upserts the azldev MCP server into one target-repo MCP configuration file, +// preserving any other servers already configured and writing canonical (key-sorted, indented) +// JSON so that re-running install is a no-op once the file is committed. +func emitMCPConfig( + env *azldev.Env, outputDir string, relPath string, serversKey string, exposeAllTools bool, +) (InstalledAgentFile, error) { + fs := env.FS() + destPath := filepath.Join(outputDir, filepath.FromSlash(relPath)) + + config := map[string]any{} + + exists, err := fileutils.Exists(fs, destPath) + if err != nil { + return InstalledAgentFile{}, fmt.Errorf("failed to stat %#q:\n%w", destPath, err) + } + + if exists { + raw, err := fileutils.ReadFile(fs, destPath) + if err != nil { + return InstalledAgentFile{}, fmt.Errorf("failed to read %#q:\n%w", destPath, err) + } + + // ponytail: plain JSON only. A JSONC file with comments fails here with a clear error + // rather than being silently rewritten and losing the comments. + if err := json.Unmarshal(raw, &config); err != nil { + return InstalledAgentFile{}, fmt.Errorf( + "existing %#q is not valid JSON (remove comments so azldev can manage it):\n%w", destPath, err) + } + } + + servers, _ := config[serversKey].(map[string]any) + if servers == nil { + servers = map[string]any{} + } + + azldevServer := map[string]any{ + "type": "stdio", + "command": "azldev", + "args": []any{"advanced", "mcp"}, + } + if exposeAllTools { + azldevServer["tools"] = []any{"*"} + } + + servers["azldev"] = azldevServer + config[serversKey] = servers + + content, err := json.MarshalIndent(config, "", " ") + if err != nil { + return InstalledAgentFile{}, fmt.Errorf("failed to marshal MCP config:\n%w", err) + } + + content = append(content, '\n') + + if env.DryRun() { + return InstalledAgentFile{Path: destPath, Written: false}, nil + } + + if err := fileutils.MkdirAll(fs, filepath.Dir(destPath)); err != nil { + return InstalledAgentFile{}, fmt.Errorf("failed to create directory for %#q:\n%w", destPath, err) + } + + if err := fileutils.WriteFile(fs, destPath, content, fileperms.PublicFile); err != nil { + return InstalledAgentFile{}, fmt.Errorf("failed to write %#q:\n%w", destPath, err) + } + + return InstalledAgentFile{Path: destPath, Written: true}, nil +} + +// agentSkillParams gathers the dynamic values injected into the emitted and served agent content, +// including the target-repo bindings resolved from the loaded project configuration. +func agentSkillParams(env *azldev.Env, rootCmd *cobra.Command) agentskill.Params { + return agentskill.Params{ + Version: version.Get().Version, + TopLevelCommands: topLevelCommands(rootCmd), + Bindings: resolveBindings(env), + } +} + +// resolveBindings resolves the target-repo skill bindings from the loaded project configuration, +// degrading gracefully to azldev's built-in defaults for any value the configuration does not +// provide (including when there is no 'azldev.toml' at all). The bindings reflect the project +// azldev was invoked in; when writing into a different repository with '--output-dir', run azldev +// with '-C' pointing at that repository so the emitted paths match it. +func resolveBindings(env *azldev.Env) agentskill.Bindings { + bindings := agentskill.Bindings{ + LockDir: projectconfig.DefaultLockDir, + RenderedSpecsDir: projectconfig.DefaultRenderedSpecsDir, + WorkDir: projectconfig.DefaultWorkDir, + } + + if env == nil || env.Config() == nil { + return bindings + } + + cfg := env.Config() + projectDir := env.ProjectDir() + + if dir := repoRelativeDir(projectDir, cfg.Project.LockDir); dir != "" { + bindings.LockDir = dir + } + + if dir := repoRelativeDir(projectDir, cfg.Project.RenderedSpecsDir); dir != "" { + bindings.RenderedSpecsDir = dir + } + + if dir := repoRelativeDir(projectDir, cfg.Project.WorkDir); dir != "" { + bindings.WorkDir = dir + } + + return bindings +} + +// repoRelativeDir converts an absolute project path to a clean, forward-slash, repository-relative +// directory. It returns "" when the path is empty or resolves to the project root or outside the +// project tree, so the caller keeps its default. +func repoRelativeDir(projectDir, absPath string) string { + if projectDir == "" || absPath == "" { + return "" + } + + rel, err := filepath.Rel(projectDir, absPath) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") { + return "" + } + + return filepath.ToSlash(rel) +} + +// resolveLayout builds an [agentskill.Layout] from the command-line layout name. +func resolveLayout(layoutName string) (agentskill.Layout, error) { + layout := agentskill.DefaultLayout() + + switch layoutName { + case "", "agents": + // Default: skills under '.agents/skills'. + case "github": + layout.SkillsDir = ".github/skills" + default: + return agentskill.Layout{}, fmt.Errorf( + "unknown layout %#q; expected 'agents' or 'github'", layoutName) + } + + return layout, nil +} + +// resolveShowSkill decides what 'docs agent show' should do for the requested skill. +// It returns the skill name to print; or, when no skill is named, an empty name plus +// the list of skill names for the caller to display. +// An unknown name is an error that names the valid choices. +func resolveShowSkill(requested string) (name string, list []string, err error) { + names := skillNames() + + if requested == "" { + return "", names, nil + } + + if _, findErr := agentskill.FindSkill(requested); findErr != nil { + return "", nil, fmt.Errorf("unknown skill %q; choose one of: %s", + requested, strings.Join(names, ", ")) + } + + return requested, nil, nil +} + +// skillNames returns the registered skill names in emission order. +func skillNames() []string { + skills := agentskill.Skills() + names := make([]string, len(skills)) + + for i, skill := range skills { + names[i] = skill.Name + } + + return names +} + +// topLevelCommands returns the non-hidden, available top-level commands with their summaries, +// sorted by name. +func topLevelCommands(rootCmd *cobra.Command) []agentskill.Command { + cmds := make([]agentskill.Command, 0, len(rootCmd.Commands())) + + for _, child := range rootCmd.Commands() { + if child.Hidden || !child.IsAvailableCommand() { + continue + } + + cmds = append(cmds, agentskill.Command{Name: child.Name(), Short: child.Short}) + } + + sort.Slice(cmds, func(i, j int) bool { return cmds[i].Name < cmds[j].Name }) + + return cmds +} diff --git a/internal/app/azldev/cmds/docs/agent_internal_test.go b/internal/app/azldev/cmds/docs/agent_internal_test.go new file mode 100644 index 00000000..a8a12388 --- /dev/null +++ b/internal/app/azldev/cmds/docs/agent_internal_test.go @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package docs + +import ( + "reflect" + "strings" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/agentskill" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepoRelativeDir(t *testing.T) { + tests := []struct { + name string + projectDir string + absPath string + want string + }{ + {name: "immediate subdir", projectDir: "/project", absPath: "/project/locks", want: "locks"}, + {name: "nested subdir", projectDir: "/project", absPath: "/project/build/locks", want: "build/locks"}, + {name: "project root maps to empty", projectDir: "/project", absPath: "/project", want: ""}, + {name: "outside tree maps to empty", projectDir: "/project", absPath: "/elsewhere/locks", want: ""}, + {name: "empty project dir", projectDir: "", absPath: "/project/locks", want: ""}, + {name: "empty path", projectDir: "/project", absPath: "", want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, repoRelativeDir(tc.projectDir, tc.absPath)) + }) + } +} + +func TestResolveBindingsDegradesToDefaults(t *testing.T) { + // A nil env (no loaded configuration) yields azldev's built-in defaults so the + // emitted documentation stays accurate for a default project. + bindings := resolveBindings(nil) + + assert.Equal(t, projectconfig.DefaultLockDir, bindings.LockDir) + assert.Equal(t, projectconfig.DefaultRenderedSpecsDir, bindings.RenderedSpecsDir) + assert.Equal(t, projectconfig.DefaultWorkDir, bindings.WorkDir) +} + +func TestResolveBindingsFromConfig(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + // Override the project directories to prove they are resolved (not just defaulted). + cfg := *testEnv.Config + cfg.Project.LockDir = "/project/build/locks" + cfg.Project.RenderedSpecsDir = "/project/build/specs" + cfg.Project.WorkDir = "/project/build/work" + + options := azldev.NewEnvOptions() + options.Interfaces = testEnv.TestInterfaces + options.DryRunnable = testEnv.DryRunnable + options.EventListener = testEnv.EventListener + options.ProjectDir = "/project" + options.Config = &cfg + + env := azldev.NewEnv(t.Context(), options) + + bindings := resolveBindings(env) + + assert.Equal(t, "build/locks", bindings.LockDir) + assert.Equal(t, "build/specs", bindings.RenderedSpecsDir) + assert.Equal(t, "build/work", bindings.WorkDir) +} + +func TestResolveShowSkill(t *testing.T) { + // A known skill resolves to itself, with nothing to list. + name, list, err := resolveShowSkill("azldev") + require.NoError(t, err) + assert.Equal(t, "azldev", name) + assert.Nil(t, list) + + // No skill named, with several registered, returns the names to display. + name, list, err = resolveShowSkill("") + require.NoError(t, err) + assert.Empty(t, name) + assert.Equal(t, skillNames(), list) + assert.Greater(t, len(list), 1, "test assumes more than one skill is registered") + + // An unknown skill errors and names the valid choices. + _, _, err = resolveShowSkill("not-a-real-skill") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown skill") + assert.Contains(t, err.Error(), "azldev") +} + +func TestAgentShowCmdUsesSkillFlag(t *testing.T) { + cmd := newAgentShowCmd() + + assert.Equal(t, "show", cmd.Use) + require.NoError(t, cmd.Args(cmd, nil)) + require.Error(t, cmd.Args(cmd, []string{"azldev"})) + + complete, ok := cmd.GetFlagCompletionFunc("skill") + require.True(t, ok, "--skill must have shell completion") + + choices, directive := complete(cmd, nil, "") + assert.Equal(t, skillNames(), choices) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) +} + +// overlayTypeEnum extracts the overlay type strings from the authoritative +// jsonschema enum tag on projectconfig.ComponentOverlay.Type. This is the same +// source that drives the generated schema, so it stays in lock-step with the code. +func overlayTypeEnum(t *testing.T) []string { + t.Helper() + + field, ok := reflect.TypeOf(projectconfig.ComponentOverlay{}).FieldByName("Type") + require.True(t, ok, "ComponentOverlay must have a Type field") + + var types []string + + for _, part := range strings.Split(field.Tag.Get("jsonschema"), ",") { + if value, found := strings.CutPrefix(part, "enum="); found { + types = append(types, value) + } + } + + require.NotEmpty(t, types, "expected overlay type enum values in the jsonschema tag") + + return types +} + +// TestOverlaysSkillCoversAllOverlayTypes is a drift guard: the azldev-overlays skill must +// document every overlay type defined in code. If a new overlay type is added, this fails +// until the skill is updated, preventing the reference from silently going stale. +func TestOverlaysSkillCoversAllOverlayTypes(t *testing.T) { + doc, err := agentskill.SkillDocument("azldev-overlays", agentskill.Params{}) + require.NoError(t, err) + + for _, overlayType := range overlayTypeEnum(t) { + assert.Containsf(t, doc, "`"+overlayType+"`", + "azldev-overlays skill must document overlay type %q", overlayType) + } +} diff --git a/internal/app/azldev/cmds/docs/agent_test.go b/internal/app/azldev/cmds/docs/agent_test.go new file mode 100644 index 00000000..d7d0fd6a --- /dev/null +++ b/internal/app/azldev/cmds/docs/agent_test.go @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package docs_test + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/agentskill" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/docs" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newAgentTestRoot builds a minimal root command with a couple of stub top-level commands so that +// the injected command list is deterministic. +func newAgentTestRoot() *cobra.Command { + root := &cobra.Command{Use: "azldev"} + root.AddCommand(&cobra.Command{Use: "component", Short: "components"}) + root.AddCommand(&cobra.Command{Use: "docs", Short: "docs"}) + + return root +} + +// primarySkillFile returns the repo-relative SKILL.md path for the built-in skill under the default +// layout. +func primarySkillFile(t *testing.T) string { + t.Helper() + + skill, err := agentskill.FindSkill(agentskill.SkillName) + require.NoError(t, err) + + return agentskill.DefaultLayout().SkillFile(skill) +} + +// writtenPaths returns the set of paths reported as written by an install. +func writtenPaths(results []docs.InstalledAgentFile) map[string]bool { + written := make(map[string]bool, len(results)) + for _, result := range results { + if result.Written { + written[result.Path] = true + } + } + + return written +} + +func TestInstallAgentFiles(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + const outputDir = "/target" + + results, err := docs.InstallAgentFiles(testEnv.Env, newAgentTestRoot(), &docs.AgentInstallOptions{ + OutputDir: outputDir, + }) + require.NoError(t, err) + // One skill file per registered skill, one instruction file per registered instruction, + // plus the VS Code and Copilot MCP server configs. + require.Len(t, results, len(agentskill.Skills())+len(agentskill.Instructions())+2) + + skillPath := filepath.Join(outputDir, filepath.FromSlash(primarySkillFile(t))) + + azldevInstruction := agentskill.Instructions()[0] + instructionsPath := filepath.Join(outputDir, + filepath.FromSlash(agentskill.InstructionFile(azldevInstruction))) + + written := writtenPaths(results) + assert.True(t, written[skillPath], "expected the azldev skill to be written") + assert.True(t, written[instructionsPath], "expected the instructions file to be written") + assert.True(t, written[filepath.Join(outputDir, ".vscode/mcp.json")], + "expected the VS Code MCP config to be written") + assert.True(t, written[filepath.Join(outputDir, ".mcp.json")], + "expected the Copilot MCP config to be written") + + // The files must actually exist on the (in-memory) filesystem with wrapper content. + skill, err := fileutils.ReadFile(testEnv.TestFS, skillPath) + require.NoError(t, err) + assert.Contains(t, string(skill), "name: "+agentskill.SkillName) + assert.Contains(t, string(skill), agentskill.ShowSkillToolName) + + instructions, err := fileutils.ReadFile(testEnv.TestFS, instructionsPath) + require.NoError(t, err) + assert.Contains(t, string(instructions), agentskill.ConfigGlob) +} + +func TestInstallAgentFilesWritesMCPConfig(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + const outputDir = "/target" + + _, err := docs.InstallAgentFiles(testEnv.Env, newAgentTestRoot(), &docs.AgentInstallOptions{OutputDir: outputDir}) + require.NoError(t, err) + + raw, err := fileutils.ReadFile(testEnv.TestFS, filepath.Join(outputDir, ".vscode/mcp.json")) + require.NoError(t, err) + + var config struct { + Servers map[string]struct { + Type string `json:"type"` + Command string `json:"command"` + Args []string `json:"args"` + } `json:"servers"` + } + require.NoError(t, json.Unmarshal(raw, &config)) + + azldevSrv, ok := config.Servers["azldev"] + require.True(t, ok, "expected an azldev MCP server entry") + assert.Equal(t, "stdio", azldevSrv.Type) + assert.Equal(t, "azldev", azldevSrv.Command) + assert.Equal(t, []string{"advanced", "mcp"}, azldevSrv.Args) +} + +func TestInstallAgentFilesCopilotMCPConfigPreservesOtherServers(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + const outputDir = "/target" + + mcpPath := filepath.Join(outputDir, ".mcp.json") + require.NoError(t, fileutils.MkdirAll(testEnv.TestFS, outputDir)) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, mcpPath, + []byte(`{"mcpServers":{"fedora-distgit":{"command":"python3"}}}`), fileperms.PublicFile)) + + _, err := docs.InstallAgentFiles(testEnv.Env, newAgentTestRoot(), &docs.AgentInstallOptions{OutputDir: outputDir}) + require.NoError(t, err) + + raw, err := fileutils.ReadFile(testEnv.TestFS, mcpPath) + require.NoError(t, err) + + var config struct { + Servers map[string]struct { + Type string `json:"type"` + Command string `json:"command"` + Args []string `json:"args"` + Tools []string `json:"tools"` + } `json:"mcpServers"` + } + require.NoError(t, json.Unmarshal(raw, &config)) + assert.Contains(t, config.Servers, "fedora-distgit", "existing server must be preserved") + + azldevSrv, ok := config.Servers["azldev"] + require.True(t, ok, "expected an azldev MCP server entry") + assert.Equal(t, "stdio", azldevSrv.Type) + assert.Equal(t, "azldev", azldevSrv.Command) + assert.Equal(t, []string{"advanced", "mcp"}, azldevSrv.Args) + assert.Equal(t, []string{"*"}, azldevSrv.Tools) +} + +func TestInstallAgentFilesMCPConfigIdempotent(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + const outputDir = "/target" + + opts := &docs.AgentInstallOptions{OutputDir: outputDir} + mcpPaths := []string{ + filepath.Join(outputDir, ".vscode/mcp.json"), + filepath.Join(outputDir, ".mcp.json"), + } + + _, err := docs.InstallAgentFiles(testEnv.Env, newAgentTestRoot(), opts) + require.NoError(t, err) + + first := make(map[string][]byte, len(mcpPaths)) + for _, mcpPath := range mcpPaths { + first[mcpPath], err = fileutils.ReadFile(testEnv.TestFS, mcpPath) + require.NoError(t, err) + } + + _, err = docs.InstallAgentFiles(testEnv.Env, newAgentTestRoot(), opts) + require.NoError(t, err) + + for _, mcpPath := range mcpPaths { + second, err := fileutils.ReadFile(testEnv.TestFS, mcpPath) + require.NoError(t, err) + // Re-running install must leave MCP config byte-identical so a CI check sees an empty diff. + assert.Equal(t, string(first[mcpPath]), string(second), "%s changed", mcpPath) + } +} + +func TestInstallAgentFilesMCPConfigPreservesOtherServers(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + const outputDir = "/target" + + mcpPath := filepath.Join(outputDir, ".vscode/mcp.json") + require.NoError(t, fileutils.MkdirAll(testEnv.TestFS, filepath.Join(outputDir, ".vscode"))) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, mcpPath, + []byte(`{"servers":{"other":{"command":"foo"}}}`), fileperms.PublicFile)) + + _, err := docs.InstallAgentFiles(testEnv.Env, newAgentTestRoot(), &docs.AgentInstallOptions{OutputDir: outputDir}) + require.NoError(t, err) + + raw, err := fileutils.ReadFile(testEnv.TestFS, mcpPath) + require.NoError(t, err) + + var config struct { + Servers map[string]json.RawMessage `json:"servers"` + } + require.NoError(t, json.Unmarshal(raw, &config)) + assert.Contains(t, config.Servers, "other", "existing server must be preserved") + assert.Contains(t, config.Servers, "azldev", "azldev server must be added") +} + +func TestInstallAgentFilesFull(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + const outputDir = "/target" + + _, err := docs.InstallAgentFiles(testEnv.Env, newAgentTestRoot(), &docs.AgentInstallOptions{ + OutputDir: outputDir, + Full: true, + }) + require.NoError(t, err) + + skillPath := filepath.Join(outputDir, filepath.FromSlash(primarySkillFile(t))) + + skill, err := fileutils.ReadFile(testEnv.TestFS, skillPath) + require.NoError(t, err) + // In full mode the complete skill body is inlined. + assert.Contains(t, string(skill), "overlay system") +} + +func TestInstallAgentFilesGitHubLayout(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + const outputDir = "/target" + + _, err := docs.InstallAgentFiles(testEnv.Env, newAgentTestRoot(), &docs.AgentInstallOptions{ + OutputDir: outputDir, + Layout: "github", + }) + require.NoError(t, err) + + skillPath := filepath.Join(outputDir, filepath.FromSlash(".github/skills/azldev/SKILL.md")) + + exists, err := fileutils.Exists(testEnv.TestFS, skillPath) + require.NoError(t, err) + assert.True(t, exists, "expected skill at the github layout path") +} + +func TestInstallAgentFilesUnknownLayout(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + _, err := docs.InstallAgentFiles(testEnv.Env, newAgentTestRoot(), &docs.AgentInstallOptions{ + OutputDir: "/target", + Layout: "bogus", + }) + require.Error(t, err) +} diff --git a/internal/app/azldev/cmds/docs/docs.go b/internal/app/azldev/cmds/docs/docs.go index 86a739b9..74a5c7b4 100644 --- a/internal/app/azldev/cmds/docs/docs.go +++ b/internal/app/azldev/cmds/docs/docs.go @@ -21,4 +21,5 @@ command tree, suitable for inclusion in the user guide.`, app.AddTopLevelCommand(cmd) mdOnAppInit(app, cmd) + agentOnAppInit(app, cmd) } diff --git a/internal/app/azldev/cmds/docs/markdown.go b/internal/app/azldev/cmds/docs/markdown.go index 37b17510..ad856947 100644 --- a/internal/app/azldev/cmds/docs/markdown.go +++ b/internal/app/azldev/cmds/docs/markdown.go @@ -62,7 +62,8 @@ definitions in Go source code instead, then re-run this command.`, _ = cmd.MarkFlagRequired("output-dir") _ = cmd.MarkFlagDirname("output-dir") - azldev.ExportAsMCPTool(cmd) + // Not exposed as an MCP tool: doc generation is a maintainer/build task (run via 'mage docs'), + // not something an AI agent should invoke. return cmd } diff --git a/internal/app/azldev/command.go b/internal/app/azldev/command.go index 1880b332..5b979817 100644 --- a/internal/app/azldev/command.go +++ b/internal/app/azldev/command.go @@ -36,6 +36,15 @@ var ErrInvalidUsage = errors.New("invalid usage") // should be enabled in MCP server mode. The value associated with the key is ignored. const CmdAnnotationMCPEnabled = "azldev.mcp.enabled" +// CmdAnnotationMCPReadOnly is the [cobra.Command] annotation key used to indicate that a command, +// when exposed as an MCP tool, is read-only (does not mutate any state). MCP clients may use this +// hint to auto-approve the tool. The value associated with the key is ignored. +const CmdAnnotationMCPReadOnly = "azldev.mcp.readonly" + +// cmdMCPAnnotationValue is the placeholder value stored for MCP command annotations; only the +// presence of the key matters. +const cmdMCPAnnotationValue = "true" + type ( cobraRunFuncType = func(command *cobra.Command, args []string) error @@ -154,7 +163,7 @@ func ExportAsMCPTool(cmd *cobra.Command) { } // The value doesn't matter. - cmd.Annotations[CmdAnnotationMCPEnabled] = "true" + cmd.Annotations[CmdAnnotationMCPEnabled] = cmdMCPAnnotationValue // If the command has subcommands, then recursively opt them in as well. for _, subCmd := range cmd.Commands() { @@ -162,6 +171,24 @@ func ExportAsMCPTool(cmd *cobra.Command) { } } +// ExportAsReadOnlyMCPTool is like [ExportAsMCPTool], but additionally marks the command (and all +// descendant commands) as read-only so that MCP clients can advertise and auto-approve them as +// non-mutating tools. +func ExportAsReadOnlyMCPTool(cmd *cobra.Command) { + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) + } + + // The values don't matter. + cmd.Annotations[CmdAnnotationMCPEnabled] = cmdMCPAnnotationValue + cmd.Annotations[CmdAnnotationMCPReadOnly] = cmdMCPAnnotationValue + + // If the command has subcommands, then recursively opt them in as well. + for _, subCmd := range cmd.Commands() { + ExportAsReadOnlyMCPTool(subCmd) + } +} + // Displays the results of a command in the appropriate format to stdout. func reportResults(env *Env, results interface{}) error { switch env.defaultReportFormat { diff --git a/internal/app/azldev/core/mcp/mcpserver.go b/internal/app/azldev/core/mcp/mcpserver.go index 51948319..d96beb70 100644 --- a/internal/app/azldev/core/mcp/mcpserver.go +++ b/internal/app/azldev/core/mcp/mcpserver.go @@ -47,6 +47,13 @@ func RunMCPServer(env *azldev.Env, cmd *cobra.Command) error { stdioServer := server.NewStdioServer(srv) + // handleToolCall runs the shared cobra command tree and swaps the process-global + // os.Stdout to capture output, so two handlers must not run at once. mcp-go + // dispatches tool calls to a worker pool (default 5) -- an agent firing parallel + // skill/tool calls would hit it -- so pin the pool to a single worker and let calls + // serialize instead of corrupting each other's output. + server.WithWorkerPoolSize(1)(stdioServer) + slog.Info("Starting MCP server") // Run the server until canceled. @@ -79,6 +86,12 @@ func addToolForCmd(env *azldev.Env, srv *server.MCPServer, leaf *cobra.Command) toolOptions = append(toolOptions, mcp.WithDescription(toolDesc)) + // Mirror our read-only annotation (set by [azldev.ExportAsReadOnlyMCPTool]) into the MCP tool + // schema so that clients can treat and auto-approve the tool as non-mutating. + if _, readOnly := leaf.Annotations[azldev.CmdAnnotationMCPReadOnly]; readOnly { + toolOptions = append(toolOptions, mcp.WithReadOnlyHintAnnotation(true)) + } + flags := getAllFlagDefs(leaf) for _, flag := range flags { var propOptions []mcp.PropertyOption @@ -175,6 +188,13 @@ func handleToolCall( env.SetReportFile(writer) + // LIMITATION: the [azldev.Env] here is created once when the MCP server starts and is reused + // for every tool call. Per-call global flags parsed by this Execute (e.g. '--dry-run', + // '--project') update the App's flag fields but are NOT re-threaded into this reused Env, so + // env.DryRun() and the loaded config still reflect the server's startup values. As a result a + // mutating tool invoked with '--dry-run=true' would still write. Until the MCP rework gives + // each call its own Env, only expose read-only commands as MCP tools (do not add + // [azldev.ExportAsMCPTool] to commands that mutate state). err = cmd.Root().Execute() os.Stdout = origStdout diff --git a/scenario/__snapshots__/TestMCPServerMode_1.snap.json b/scenario/__snapshots__/TestMCPServerMode_1.snap.json index 0972acfe..3374671b 100755 --- a/scenario/__snapshots__/TestMCPServerMode_1.snap.json +++ b/scenario/__snapshots__/TestMCPServerMode_1.snap.json @@ -525,9 +525,9 @@ "destructiveHint": true, "idempotentHint": false, "openWorldHint": true, - "readOnlyHint": false + "readOnlyHint": true }, - "description": "Generates Markdown (.md) docs for this tool", + "description": "Print an azldev agent skill to stdout", "inputSchema": { "properties": { "accept-all": { @@ -548,16 +548,6 @@ "description": "dry run only (do not take action)", "type": "boolean" }, - "force": { - "default": false, - "description": "delete and recreate the output directory if it already exists", - "type": "boolean" - }, - "include-hidden": { - "default": false, - "description": "include hidden commands in the generated docs", - "type": "boolean" - }, "network-retries": { "default": 3, "description": "maximum number of attempts for network operations (minimum 1)", @@ -568,11 +558,6 @@ "description": "disable default configuration", "type": "boolean" }, - "output-dir": { - "default": "", - "description": "directory markdown files will be written to", - "type": "string" - }, "output-format": { "description": "output format {csv, json, markdown, table}", "type": "string" @@ -592,18 +577,21 @@ "description": "only enable minimal output", "type": "boolean" }, + "skill": { + "default": "", + "description": "skill to print", + "type": "string" + }, "verbose": { "default": false, "description": "enable verbose output", "type": "boolean" } }, - "required": [ - "output-dir" - ], + "required": [], "type": "object" }, - "name": "docs-markdown" + "name": "docs-agent-show" }, { "annotations": {