From 405e17a07911cf04f70b5919fd1ec3ab79bdf2c7 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Wed, 8 Jul 2026 15:05:02 -0700 Subject: [PATCH 1/8] feat(ai): add agent mode to emit instructions and skills --- .github/copilot-instructions.md | 2 + .../instructions/agent-skills.instructions.md | 82 +++ docs/developer/prds/agent-scaffolding.prd.md | 165 ++++++ docs/user/README.md | 2 +- docs/user/how-to/set-up-ai-agents.md | 72 +++ docs/user/reference/cli/azldev_docs.md | 1 + docs/user/reference/cli/azldev_docs_agent.md | 43 ++ .../cli/azldev_docs_agent_install.md | 74 +++ .../reference/cli/azldev_docs_agent_show.md | 56 ++ internal/app/azldev/agentskill/agentskill.go | 481 ++++++++++++++++++ .../app/azldev/agentskill/agentskill_test.go | 292 +++++++++++ .../agentskill/content/add-component.md.tmpl | 83 +++ .../azldev/agentskill/content/azldev.md.tmpl | 73 +++ .../content/build-component.md.tmpl | 60 +++ .../agentskill/content/comp-toml.md.tmpl | 109 ++++ .../azldev/agentskill/content/image.md.tmpl | 60 +++ .../content/instruction-wrapper.md.tmpl | 15 + .../azldev/agentskill/content/mock.md.tmpl | 92 ++++ .../agentskill/content/overlays.md.tmpl | 126 +++++ .../content/remove-component.md.tmpl | 73 +++ .../agentskill/content/skill-wrapper.md.tmpl | 19 + .../content/update-component.md.tmpl | 70 +++ internal/app/azldev/agentskill/doc.go | 75 +++ internal/app/azldev/cmds/docs/agent.go | 395 ++++++++++++++ .../azldev/cmds/docs/agent_internal_test.go | 106 ++++ internal/app/azldev/cmds/docs/agent_test.go | 206 ++++++++ internal/app/azldev/cmds/docs/docs.go | 1 + internal/app/azldev/cmds/docs/markdown.go | 3 +- internal/app/azldev/command.go | 29 +- internal/app/azldev/core/mcp/mcpserver.go | 20 + .../TestMCPServerMode_1.snap.json | 30 +- 31 files changed, 2891 insertions(+), 24 deletions(-) create mode 100644 .github/instructions/agent-skills.instructions.md create mode 100644 docs/developer/prds/agent-scaffolding.prd.md create mode 100644 docs/user/how-to/set-up-ai-agents.md create mode 100644 docs/user/reference/cli/azldev_docs_agent.md create mode 100644 docs/user/reference/cli/azldev_docs_agent_install.md create mode 100644 docs/user/reference/cli/azldev_docs_agent_show.md create mode 100644 internal/app/azldev/agentskill/agentskill.go create mode 100644 internal/app/azldev/agentskill/agentskill_test.go create mode 100644 internal/app/azldev/agentskill/content/add-component.md.tmpl create mode 100644 internal/app/azldev/agentskill/content/azldev.md.tmpl create mode 100644 internal/app/azldev/agentskill/content/build-component.md.tmpl create mode 100644 internal/app/azldev/agentskill/content/comp-toml.md.tmpl create mode 100644 internal/app/azldev/agentskill/content/image.md.tmpl create mode 100644 internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl create mode 100644 internal/app/azldev/agentskill/content/mock.md.tmpl create mode 100644 internal/app/azldev/agentskill/content/overlays.md.tmpl create mode 100644 internal/app/azldev/agentskill/content/remove-component.md.tmpl create mode 100644 internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl create mode 100644 internal/app/azldev/agentskill/content/update-component.md.tmpl create mode 100644 internal/app/azldev/agentskill/doc.go create mode 100644 internal/app/azldev/cmds/docs/agent.go create mode 100644 internal/app/azldev/cmds/docs/agent_internal_test.go create mode 100644 internal/app/azldev/cmds/docs/agent_test.go 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..5449692f --- /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 }}/**/*.spec`) — 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) 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..83990b2c --- /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.json` + 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` (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 [name]` — 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 a `.vscode/mcp.json` 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` via azldev; add the still-unchecked Phase 6 + `.vscode/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`, + `.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..3e4fb7d1 --- /dev/null +++ b/docs/user/how-to/set-up-ai-agents.md @@ -0,0 +1,72 @@ +# 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: + +- An **Agent Skill** at `.agents/skills/azldev/SKILL.md` — an on-demand capability, + in the tool-neutral [Agent Skills](https://agentskills.io/) format, that agents + load when a task involves azldev. +- A **path-specific instructions** file at `.github/instructions/azldev.instructions.md` + that applies automatically when an agent edits an `azldev.toml` configuration file. + +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 + +To print the full skill document to stdout — the same content the MCP tool +serves — run: + +```bash +azldev docs agent show +``` 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..a9844a0e --- /dev/null +++ b/docs/user/reference/cli/azldev_docs_agent_install.md @@ -0,0 +1,74 @@ + + +## 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 + .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 +.vscode/mcp.json entry is 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..e211ae3c --- /dev/null +++ b/docs/user/reference/cli/azldev_docs_agent_show.md @@ -0,0 +1,56 @@ + + +## 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. Use --skill to select a skill +when more than one is available. + +``` +azldev docs agent show [flags] +``` + +### Examples + +``` + # Print the azldev skill + azldev docs agent show + + # Print a specific skill by name + azldev docs agent show --skill azldev +``` + +### Options + +``` + -h, --help help for show + --skill string name of the skill to print (defaults to the only skill) +``` + +### 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..3973cafe --- /dev/null +++ b/internal/app/azldev/agentskill/agentskill.go @@ -0,0 +1,481 @@ +// 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, bumping an upstream pin, 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, 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 how to remove a component from an " + + "azldev distro as a manual, metadata-only edit (delete the component config, its lock, and " + + "rendered spec, then clean references). Triggers include remove component, delete package, drop " + + "component, prune dependency.", + bodyTemplate: "remove-component.md.tmpl", + }, + { + Name: "azldev-overlays", + Description: "Read this before adding or changing 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 " + + "choosing the overlay type, required fields, the render-and-inspect loop, common pitfalls, and " + + "metadata categories. Triggers include overlay, spec-add-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 or editing a *.comp.toml file; do not hand-write " + + "component config from memory. Explains the azldev component definition format, covering the " + + "component structure, choosing a spec source (upstream, pinned upstream-distro, upstream-name, " + + "or local), build config (defines/with/without), release calculation modes " + + "(auto/autorelease/static/manual), render options, and file organization (inline versus " + + "dedicated files and includes). Triggers include comp.toml, component config, 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. + 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", + Description: "These are azldev component definition files (*.comp.toml). Before editing 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.", + 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"}, + {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 }}/**/*.spec", + Description: "Rendered RPM spec 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 a generated " + + "spec.", + Title: "Rendered spec files", + Intro: "These `.spec` files are generated by `azldev comp render` and are build inputs; " + + "do not edit them directly — change the component's `.comp.toml` or its overlays and re-render.", + Skills: []SkillPointer{ + {Skill: "azldev-comp-toml", Purpose: "for the component TOML format"}, + {Skill: "azldev-overlays", Purpose: "to change a spec 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 files are written and how skills are named in a +// target repository. +type Layout struct { + // SkillsDir is the repo-relative parent directory that holds skill directories. + SkillsDir string + + // InstructionsDir is the repo-relative directory that holds instruction files. + InstructionsDir 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", + InstructionsDir: ".github/instructions", + } +} + +// identifier returns the on-disk / front-matter name for a skill under this layout. +// Per the Agent Skills specification this must match the skill's directory name. +func (l Layout) identifier(skill Skill) string { + return skill.Name +} + +// SkillDir returns the repo-relative directory for a skill under this layout. +func (l Layout) SkillDir(skill Skill) string { + return path.Join(l.SkillsDir, l.identifier(skill)) +} + +// 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 under this layout. +func (l Layout) InstructionFile(inst Instruction) string { + return path.Join(l.InstructionsDir, 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 +} + +// 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:"-"` +} + +// templateData is the value passed to templates. It augments [Params] with the +// per-skill and shared fields the templates reference. +type templateData struct { + Params + + // SkillID is the on-disk / front-matter skill name. + SkillID string + + // SkillName is the stable base skill name (the 'docs agent show' argument). + SkillName string + + Description string + ShowSkillToolName string +} + +func renderWithData(templateName string, data templateData) (string, error) { + var buf bytes.Buffer + + 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 renderSkill(templateName string, skill Skill, layout Layout, params Params) (string, error) { + return renderWithData(templateName, templateData{ + Params: params, + SkillID: layout.identifier(skill), + SkillName: skill.Name, + Description: skill.Description, + ShowSkillToolName: ShowSkillToolName, + }) +} + +// instructionData is the value passed to the instruction-wrapper template. +type instructionData struct { + Params + + Description string + ApplyTo string + Title string + Intro string + Skills []SkillPointer +} + +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 + + data := instructionData{ + Params: params, + Description: inst.Description, + ApplyTo: applyTo, + Title: inst.Title, + Intro: inst.Intro, + Skills: inst.Skills, + } + + 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, DefaultLayout(), 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, layout, 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: layout.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..5f41f20a --- /dev/null +++ b/internal/app/azldev/agentskill/agentskill_test.go @@ -0,0 +1,292 @@ +// 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" +) + +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", + }, + } +} + +// 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") + end := strings.Index(rest, "\n---") + require.GreaterOrEqual(t, end, 0, "front matter must be terminated by a '---' line") + + fields := map[string]string{} + + for _, line := range strings.Split(rest[:end], "\n") { + if strings.TrimSpace(line) == "" { + continue + } + + key, value, found := strings.Cut(line, ": ") + require.True(t, found, "front matter line %#q must be a 'key: value' pair", line) + + fields[key] = strings.Trim(strings.TrimSpace(value), `"`) + } + + 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 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", 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())) + + 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, layout.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, layout.InstructionFile(compTomlInstruction)).Content + assert.Contains(t, compTomlWrapper, `applyTo: "**/*.comp.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 wrapper is prescriptive: agents must read the skill, not treat it as optional. + assert.Contains(t, compTomlWrapper, "You MUST read the `azldev-comp-toml` 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, layout.InstructionFile(renderedSpecsInstruction)).Content + assert.Contains(t, renderedSpecsWrapper, `applyTo: "specs/**/*.spec"`) + 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, layout.InstructionFile(inst)).Content + + // The applyTo glob tracks the configured rendered-specs directory. + assert.Contains(t, wrapper, `applyTo: "SPECS/**/*.spec"`) +} + +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())) + + // 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", + InstructionsDir: ".github/instructions", + } + + 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..7fceb216 --- /dev/null +++ b/internal/app/azldev/agentskill/content/add-component.md.tmpl @@ -0,0 +1,83 @@ +--- +name: {{ .SkillID }} +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. Pull the sources without overlays into a scratch dir: + + ```sh + azldev comp prep-sources -p --skip-overlays --force -o "$(mktemp -d)" -q + ``` + +3. 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 + +A new upstream component has no lock yet, and `render`, `diff-sources`, and `build` all +resolve it first — which fails lock validation until the lock exists. Create the lock, +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 committing, +re-run `azldev comp update -p ` to finalize the lock before a PR (see the +`azldev-update-component` skill). + +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..0338cb1d --- /dev/null +++ b/internal/app/azldev/agentskill/content/azldev.md.tmpl @@ -0,0 +1,73 @@ +--- +name: {{ .SkillID }} +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`. + +## Where to go next + +- Add a new component — read the `azldev-add-component` skill. +- Edit 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 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..2a273935 --- /dev/null +++ b/internal/app/azldev/agentskill/content/build-component.md.tmpl @@ -0,0 +1,60 @@ +--- +name: {{ .SkillID }} +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 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..addbee99 --- /dev/null +++ b/internal/app/azldev/agentskill/content/comp-toml.md.tmpl @@ -0,0 +1,109 @@ +--- +name: {{ .SkillID }} +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"]`. + +## 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 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..72f43986 --- /dev/null +++ b/internal/app/azldev/agentskill/content/image.md.tmpl @@ -0,0 +1,60 @@ +--- +name: {{ .SkillID }} +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 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..ed6d23b4 --- /dev/null +++ b/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl @@ -0,0 +1,15 @@ +--- +description: {{ .Description }} +applyTo: "{{ .ApplyTo }}" +--- + +# {{ .Title }} + +{{ .Intro }} + +**Do not work from memory or guess.** Before you add, edit, or remove anything in scope +here, you MUST first read the relevant azldev skill(s) below. They are the authoritative, +version-matched source and override any prior assumptions: + +{{ range .Skills }}- You MUST read the `{{ .Skill }}` skill {{ .Purpose }}. +{{ end }} 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..c8b85f35 --- /dev/null +++ b/internal/app/azldev/agentskill/content/mock.md.tmpl @@ -0,0 +1,92 @@ +--- +name: {{ .SkillID }} +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 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..ea6fe5e5 --- /dev/null +++ b/internal/app/azldev/agentskill/content/overlays.md.tmpl @@ -0,0 +1,126 @@ +--- +name: {{ .SkillID }} +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`. + +## 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 a tag; fails if it is missing | `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 + +- **`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 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..2e273a23 --- /dev/null +++ b/internal/app/azldev/agentskill/content/remove-component.md.tmpl @@ -0,0 +1,73 @@ +--- +name: {{ .SkillID }} +description: {{ .Description }} +--- + +# Remove a component + +There is **no `azldev` command to remove a component** — it is a manual, +metadata-only edit (no builds or tests needed). 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. + +## 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 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..4c26b544 --- /dev/null +++ b/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl @@ -0,0 +1,19 @@ +--- +name: {{ .SkillID }} +description: {{ .Description }} +--- + +# {{ .SkillID }} + +For the full, always-up-to-date content of this skill, call the read-only MCP tool +**`{{ .ShowSkillToolName }}`** with skill `{{ .SkillName }}` (provided by the azldev +MCP server). + +If the azldev MCP server is not configured, run `azldev docs agent show --skill {{ .SkillName }}` +to print the same content, or regenerate this file with the full skill inlined: + +```sh +azldev docs agent install --full +``` + +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..1016f399 --- /dev/null +++ b/internal/app/azldev/agentskill/content/update-component.md.tmpl @@ -0,0 +1,70 @@ +--- +name: {{ .SkillID }} +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 | +| Bumping an upstream commit pin | **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 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. + +## Bumping an upstream commit pin + +A pin bump follows the same rule, split across two commits: + +1. Move the pin (edit the pinned commit or snapshot time), then `azldev comp update -p `; sanity-check `{{ .LockDir }}/.lock`. +2. `azldev comp render -p ` — the spec body now tracks the new pin. `%changelog` / `Release:` still show the old pin; that is expected until you commit. +3. Iterate on overlays / patches / build config as the new version requires, re-rendering after each change. Re-run `update` only if you change the pin itself again. +4. `azldev comp update -p ` and commit the lock. +5. `azldev comp render -p ` — `%changelog` / `Release:` now reflect the new lock. +6. `git commit --amend --no-edit` so the lock bump and rendered spec land together. + +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..17eb1012 --- /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 (and the SkillID) exist as fields rather than living in the +// template front matter: holding them 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) 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..f3f712d3 --- /dev/null +++ b/internal/app/azldev/cmds/docs/agent.go @@ -0,0 +1,395 @@ +// 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 + .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 +.vscode/mcp.json entry is 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 + + 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. Use --skill to select a skill +when more than one is available.`, + Example: ` # Print the azldev skill + azldev docs agent show + + # Print a specific skill by name + azldev docs agent show --skill azldev`, + } + + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + name, err := resolveShowSkill(skillName) + if err != nil { + return err + } + + 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", "", + "name of the skill to print (defaults to the only skill)") + + 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}) + } + + mcpResult, err := emitMCPConfig(env, options.OutputDir) + if err != nil { + return nil, err + } + + results = append(results, mcpResult) + + return results, nil +} + +// mcpConfigRelPath is the repo-relative path of the VS Code MCP server configuration. +const mcpConfigRelPath = ".vscode/mcp.json" + +// emitMCPConfig upserts the azldev MCP server into the target repo's .vscode/mcp.json, +// 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. This lets a CI check run +// install and assert an empty diff. +func emitMCPConfig(env *azldev.Env, outputDir string) (InstalledAgentFile, error) { + fs := env.FS() + destPath := filepath.Join(outputDir, filepath.FromSlash(mcpConfigRelPath)) + + 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["servers"].(map[string]any) + if servers == nil { + servers = map[string]any{} + } + + servers["azldev"] = map[string]any{ + "type": "stdio", + "command": "azldev", + "args": []any{"advanced", "mcp"}, + } + config["servers"] = 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 := defaultBindings() + + 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 + } + + return bindings +} + +// defaultBindings returns the bindings used when no project configuration is available. They are +// azldev's built-in directory defaults, which remain accurate for any repository that has not +// overridden them. +func defaultBindings() agentskill.Bindings { + return agentskill.Bindings{ + LockDir: projectconfig.DefaultLockDir, + RenderedSpecsDir: projectconfig.DefaultRenderedSpecsDir, + } +} + +// 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 returns the skill name to print. When unset, it defaults to the only registered +// skill, or errors listing the choices when several exist. +func resolveShowSkill(name string) (string, error) { + if name != "" { + return name, nil + } + + registered := agentskill.Skills() + if len(registered) == 1 { + return registered[0].Name, nil + } + + names := make([]string, len(registered)) + for i, skill := range registered { + names[i] = skill.Name + } + + return "", fmt.Errorf("multiple skills available; pass --skill (one of: %s)", strings.Join(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..e347e4c9 --- /dev/null +++ b/internal/app/azldev/cmds/docs/agent_internal_test.go @@ -0,0 +1,106 @@ +// 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/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) +} + +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" + + 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) +} + +// 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..00a8227c --- /dev/null +++ b/internal/app/azldev/cmds/docs/agent_test.go @@ -0,0 +1,206 @@ +// 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 .vscode/mcp.json server config. + require.Len(t, results, len(agentskill.Skills())+len(agentskill.Instructions())+1) + + skillPath := filepath.Join(outputDir, filepath.FromSlash(primarySkillFile(t))) + + azldevInstruction := agentskill.Instructions()[0] + instructionsPath := filepath.Join(outputDir, + filepath.FromSlash(agentskill.DefaultLayout().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") + + // 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 TestInstallAgentFilesMCPConfigIdempotent(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + const outputDir = "/target" + + opts := &docs.AgentInstallOptions{OutputDir: outputDir} + mcpPath := filepath.Join(outputDir, ".vscode/mcp.json") + + _, err := docs.InstallAgentFiles(testEnv.Env, newAgentTestRoot(), opts) + require.NoError(t, err) + first, err := fileutils.ReadFile(testEnv.TestFS, mcpPath) + require.NoError(t, err) + + _, err = docs.InstallAgentFiles(testEnv.Env, newAgentTestRoot(), opts) + require.NoError(t, err) + second, err := fileutils.ReadFile(testEnv.TestFS, mcpPath) + require.NoError(t, err) + + // Re-running install must leave mcp.json byte-identical so a CI check sees an empty diff. + assert.Equal(t, string(first), string(second)) +} + +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..f4efedb0 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": "name of the skill to print (defaults to the only skill)", + "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": { From 8e8290c2527a2e73607aaf1a96d8e132ea9b3ab3 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Wed, 8 Jul 2026 17:20:46 -0700 Subject: [PATCH 2/8] feat(docs): make agent show skill discoverable Accept the skill as a positional argument with shell completion, so `azldev docs agent show ` lists the valid skills instead of forcing you to guess and hit an error. Running with no skill now lists the available skills (browse) rather than erroring, and an unknown name reports the valid choices. The --skill flag is kept (it gives the docs-agent-show MCP tool a typed parameter and preserves back-compat) and also completes. --- .../instructions/agent-skills.instructions.md | 2 +- .../reference/cli/azldev_docs_agent_show.md | 16 ++-- internal/app/azldev/cmds/docs/agent.go | 86 +++++++++++++++---- .../azldev/cmds/docs/agent_internal_test.go | 21 +++++ 4 files changed, 99 insertions(+), 26 deletions(-) diff --git a/.github/instructions/agent-skills.instructions.md b/.github/instructions/agent-skills.instructions.md index 5449692f..e9e0ea03 100644 --- a/.github/instructions/agent-skills.instructions.md +++ b/.github/instructions/agent-skills.instructions.md @@ -79,4 +79,4 @@ reference it in a template as `{{ .FieldName }}`. - `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 `. + `./out/bin/azldev docs agent show `. diff --git a/docs/user/reference/cli/azldev_docs_agent_show.md b/docs/user/reference/cli/azldev_docs_agent_show.md index e211ae3c..346150d1 100644 --- a/docs/user/reference/cli/azldev_docs_agent_show.md +++ b/docs/user/reference/cli/azldev_docs_agent_show.md @@ -10,28 +10,30 @@ 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. Use --skill to select a skill -when more than one is available. +by 'azldev docs agent install' reference this tool. + +Name the skill as a positional argument (shell completion lists the choices) or with +--skill. Run with no skill to list the available skills. ``` -azldev docs agent show [flags] +azldev docs agent show [skill] [flags] ``` ### Examples ``` - # Print the azldev skill + # List the available skills azldev docs agent show - # Print a specific skill by name - azldev docs agent show --skill azldev + # Print a skill (positional; tab-completes) + azldev docs agent show azldev-overlays ``` ### Options ``` -h, --help help for show - --skill string name of the skill to print (defaults to the only skill) + --skill string skill to print (or pass it as a positional argument) ``` ### Options inherited from parent commands diff --git a/internal/app/azldev/cmds/docs/agent.go b/internal/app/azldev/cmds/docs/agent.go index f3f712d3..05caddc2 100644 --- a/internal/app/azldev/cmds/docs/agent.go +++ b/internal/app/azldev/cmds/docs/agent.go @@ -116,28 +116,46 @@ func newAgentShowCmd() *cobra.Command { var skillName string cmd := &cobra.Command{ - Use: "show", + Use: "show [skill]", 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. Use --skill to select a skill -when more than one is available.`, - Example: ` # Print the azldev skill +by 'azldev docs agent install' reference this tool. + +Name the skill as a positional argument (shell completion lists the choices) or with +--skill. Run with no skill to list the available skills.`, + Example: ` # List the available skills azldev docs agent show - # Print a specific skill by name - azldev docs agent show --skill azldev`, + # Print a skill (positional; tab-completes) + azldev docs agent show azldev-overlays`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: completeSkillNames, } - cmd.RunE = func(cmd *cobra.Command, _ []string) error { - name, err := resolveShowSkill(skillName) + cmd.RunE = func(cmd *cobra.Command, args []string) error { + requested := skillName + if len(args) > 0 { + requested = args[0] + } + + name, list, err := resolveShowSkill(requested) 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) @@ -154,7 +172,8 @@ when more than one is available.`, } cmd.Flags().StringVar(&skillName, "skill", "", - "name of the skill to print (defaults to the only skill)") + "skill to print (or pass it as a positional argument)") + _ = cmd.RegisterFlagCompletionFunc("skill", completeSkillNames) azldev.ExportAsReadOnlyMCPTool(cmd) @@ -358,22 +377,53 @@ func resolveLayout(layoutName string) (agentskill.Layout, error) { // resolveShowSkill returns the skill name to print. When unset, it defaults to the only registered // skill, or errors listing the choices when several exist. -func resolveShowSkill(name string) (string, error) { - if name != "" { - return name, 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 and several are +// registered, 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 == "" { + if len(names) == 1 { + return names[0], nil, nil + } + + return "", names, nil } - registered := agentskill.Skills() - if len(registered) == 1 { - return registered[0].Name, nil + if _, findErr := agentskill.FindSkill(requested); findErr != nil { + return "", nil, fmt.Errorf("unknown skill %q; choose one of: %s", + requested, strings.Join(names, ", ")) } - names := make([]string, len(registered)) - for i, skill := range registered { + 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 "", fmt.Errorf("multiple skills available; pass --skill (one of: %s)", strings.Join(names, ", ")) + return names +} + +// completeSkillNames is the shell-completion function for the skill argument/flag; it +// offers the registered skill names matching the prefix being typed. +func completeSkillNames(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var names []string + + for _, name := range skillNames() { + if strings.HasPrefix(name, toComplete) { + names = append(names, name) + } + } + + return names, cobra.ShellCompDirectiveNoFileComp } // topLevelCommands returns the non-hidden, available top-level commands with their summaries, diff --git a/internal/app/azldev/cmds/docs/agent_internal_test.go b/internal/app/azldev/cmds/docs/agent_internal_test.go index e347e4c9..703fc639 100644 --- a/internal/app/azldev/cmds/docs/agent_internal_test.go +++ b/internal/app/azldev/cmds/docs/agent_internal_test.go @@ -70,6 +70,27 @@ func TestResolveBindingsFromConfig(t *testing.T) { assert.Equal(t, "build/specs", bindings.RenderedSpecsDir) } +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") +} + // 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. From a55c40f4fd98d68c867b7b495b6b596cc82ce53c Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Wed, 8 Jul 2026 17:32:23 -0700 Subject: [PATCH 3/8] fix(agents): use the configured work dir for scratch, not mktemp The add-component skill told agents to write prep-sources inspection output to `$(mktemp -d)`, which lands in /tmp and conflicts with repos (e.g. azurelinux) whose conventions forbid bare mktemp and require an in-project scratch dir. Add a WorkDir binding resolved from Project.WorkDir (default build/work) and template the scratch path as `{{ .WorkDir }}/scratch/`, so the skill follows each repo's configured, gitignored work directory instead of steering agents off it. --- .github/instructions/agent-skills.instructions.md | 2 +- internal/app/azldev/agentskill/agentskill.go | 5 +++++ internal/app/azldev/agentskill/agentskill_test.go | 1 + internal/app/azldev/agentskill/content/add-component.md.tmpl | 4 ++-- internal/app/azldev/agentskill/doc.go | 4 ++-- internal/app/azldev/cmds/docs/agent.go | 5 +++++ internal/app/azldev/cmds/docs/agent_internal_test.go | 3 +++ 7 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/instructions/agent-skills.instructions.md b/.github/instructions/agent-skills.instructions.md index e9e0ea03..80e79277 100644 --- a/.github/instructions/agent-skills.instructions.md +++ b/.github/instructions/agent-skills.instructions.md @@ -68,7 +68,7 @@ Before shipping any skill/instruction: ## Config-resolved bindings -Repo-specific values (lock dir, rendered-specs dir) are resolved from the target `azldev.toml` in +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 }}`. diff --git a/internal/app/azldev/agentskill/agentskill.go b/internal/app/azldev/agentskill/agentskill.go index 3973cafe..0d643c3d 100644 --- a/internal/app/azldev/agentskill/agentskill.go +++ b/internal/app/azldev/agentskill/agentskill.go @@ -319,6 +319,11 @@ type Bindings struct { // 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. diff --git a/internal/app/azldev/agentskill/agentskill_test.go b/internal/app/azldev/agentskill/agentskill_test.go index 5f41f20a..f151e92b 100644 --- a/internal/app/azldev/agentskill/agentskill_test.go +++ b/internal/app/azldev/agentskill/agentskill_test.go @@ -24,6 +24,7 @@ func testParams() agentskill.Params { Bindings: agentskill.Bindings{ LockDir: "locks", RenderedSpecsDir: "specs", + WorkDir: "build/work", }, } } diff --git a/internal/app/azldev/agentskill/content/add-component.md.tmpl b/internal/app/azldev/agentskill/content/add-component.md.tmpl index 7fceb216..1d44aaa8 100644 --- a/internal/app/azldev/agentskill/content/add-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/add-component.md.tmpl @@ -25,10 +25,10 @@ dist-git often fail bot detection): 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. Pull the sources without overlays into a scratch dir: +2. Pull the sources without overlays into a scratch dir under the work dir: ```sh - azldev comp prep-sources -p --skip-overlays --force -o "$(mktemp -d)" -q + azldev comp prep-sources -p --skip-overlays --force -o {{ .WorkDir }}/scratch/ -q ``` 3. Read the spec and plan any overlays. diff --git a/internal/app/azldev/agentskill/doc.go b/internal/app/azldev/agentskill/doc.go index 17eb1012..16b2270b 100644 --- a/internal/app/azldev/agentskill/doc.go +++ b/internal/app/azldev/agentskill/doc.go @@ -55,8 +55,8 @@ // - 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) read from the -// target azldev.toml, degrading to azldev's defaults when no config is present. +// - 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) // diff --git a/internal/app/azldev/cmds/docs/agent.go b/internal/app/azldev/cmds/docs/agent.go index 05caddc2..5f70d35e 100644 --- a/internal/app/azldev/cmds/docs/agent.go +++ b/internal/app/azldev/cmds/docs/agent.go @@ -329,6 +329,10 @@ func resolveBindings(env *azldev.Env) agentskill.Bindings { bindings.RenderedSpecsDir = dir } + if dir := repoRelativeDir(projectDir, cfg.Project.WorkDir); dir != "" { + bindings.WorkDir = dir + } + return bindings } @@ -339,6 +343,7 @@ func defaultBindings() agentskill.Bindings { return agentskill.Bindings{ LockDir: projectconfig.DefaultLockDir, RenderedSpecsDir: projectconfig.DefaultRenderedSpecsDir, + WorkDir: projectconfig.DefaultWorkDir, } } diff --git a/internal/app/azldev/cmds/docs/agent_internal_test.go b/internal/app/azldev/cmds/docs/agent_internal_test.go index 703fc639..a8bfbe0d 100644 --- a/internal/app/azldev/cmds/docs/agent_internal_test.go +++ b/internal/app/azldev/cmds/docs/agent_internal_test.go @@ -45,6 +45,7 @@ func TestResolveBindingsDegradesToDefaults(t *testing.T) { 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) { @@ -54,6 +55,7 @@ func TestResolveBindingsFromConfig(t *testing.T) { 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 @@ -68,6 +70,7 @@ func TestResolveBindingsFromConfig(t *testing.T) { 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) { From 39f3bc2d15818631a0f197808b6b23ab4ee5a3af Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Thu, 9 Jul 2026 14:56:30 -0700 Subject: [PATCH 4/8] feedback --- .../instructions/agent-skills.instructions.md | 2 +- docs/developer/prds/agent-scaffolding.prd.md | 2 +- .../reference/cli/azldev_docs_agent_show.md | 12 +-- internal/app/azldev/agentskill/agentskill.go | 89 ++++++------------- .../app/azldev/agentskill/agentskill_test.go | 28 ++---- .../agentskill/content/add-component.md.tmpl | 2 +- .../azldev/agentskill/content/azldev.md.tmpl | 2 +- .../content/build-component.md.tmpl | 2 +- .../agentskill/content/comp-toml.md.tmpl | 2 +- .../azldev/agentskill/content/image.md.tmpl | 2 +- .../azldev/agentskill/content/mock.md.tmpl | 2 +- .../agentskill/content/overlays.md.tmpl | 2 +- .../content/remove-component.md.tmpl | 2 +- .../agentskill/content/skill-wrapper.md.tmpl | 8 +- .../content/update-component.md.tmpl | 2 +- internal/app/azldev/agentskill/doc.go | 4 +- internal/app/azldev/cmds/docs/agent.go | 68 ++++---------- .../azldev/cmds/docs/agent_internal_test.go | 16 ++++ internal/app/azldev/cmds/docs/agent_test.go | 2 +- 19 files changed, 93 insertions(+), 156 deletions(-) diff --git a/.github/instructions/agent-skills.instructions.md b/.github/instructions/agent-skills.instructions.md index 80e79277..a8de4430 100644 --- a/.github/instructions/agent-skills.instructions.md +++ b/.github/instructions/agent-skills.instructions.md @@ -79,4 +79,4 @@ reference it in a template as `{{ .FieldName }}`. - `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 `. + `./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 index 83990b2c..741e7a66 100644 --- a/docs/developer/prds/agent-scaffolding.prd.md +++ b/docs/developer/prds/agent-scaffolding.prd.md @@ -104,7 +104,7 @@ match host convention (azurelinux uses `skill-*`). - `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 [name]` — print a skill (read-only MCP tool). Parameterized by skill name +- `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. diff --git a/docs/user/reference/cli/azldev_docs_agent_show.md b/docs/user/reference/cli/azldev_docs_agent_show.md index 346150d1..be58c650 100644 --- a/docs/user/reference/cli/azldev_docs_agent_show.md +++ b/docs/user/reference/cli/azldev_docs_agent_show.md @@ -12,11 +12,11 @@ This is the authoritative skill content embedded in the binary. It is exposed as 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 as a positional argument (shell completion lists the choices) or with ---skill. Run with no skill to list the available skills. +Name the skill with --skill (shell completion lists the choices). Run with no +skill to list the available skills. ``` -azldev docs agent show [skill] [flags] +azldev docs agent show [flags] ``` ### Examples @@ -25,15 +25,15 @@ azldev docs agent show [skill] [flags] # List the available skills azldev docs agent show - # Print a skill (positional; tab-completes) - azldev docs agent show azldev-overlays + # Print a skill (--skill tab-completes) + azldev docs agent show --skill azldev-overlays ``` ### Options ``` -h, --help help for show - --skill string skill to print (or pass it as a positional argument) + --skill string skill to print ``` ### Options inherited from parent commands diff --git a/internal/app/azldev/agentskill/agentskill.go b/internal/app/azldev/agentskill/agentskill.go index 0d643c3d..88adfd8f 100644 --- a/internal/app/azldev/agentskill/agentskill.go +++ b/internal/app/azldev/agentskill/agentskill.go @@ -260,14 +260,10 @@ func Instructions() []Instruction { return instructions } -// Layout controls where emitted files are written and how skills are named in a -// target repository. +// 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 - - // InstructionsDir is the repo-relative directory that holds instruction files. - InstructionsDir string } // DefaultLayout returns the default emission layout: skills under the tool-neutral @@ -275,20 +271,13 @@ type Layout struct { // under '.github/instructions'. func DefaultLayout() Layout { return Layout{ - SkillsDir: ".agents/skills", - InstructionsDir: ".github/instructions", + SkillsDir: ".agents/skills", } } -// identifier returns the on-disk / front-matter name for a skill under this layout. -// Per the Agent Skills specification this must match the skill's directory name. -func (l Layout) identifier(skill Skill) string { - return skill.Name -} - // SkillDir returns the repo-relative directory for a skill under this layout. func (l Layout) SkillDir(skill Skill) string { - return path.Join(l.SkillsDir, l.identifier(skill)) + return path.Join(l.SkillsDir, skill.Name) } // SkillFile returns the repo-relative SKILL.md path for a skill under this layout. @@ -296,9 +285,9 @@ 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 under this layout. -func (l Layout) InstructionFile(inst Instruction) string { - return path.Join(l.InstructionsDir, inst.Name+instructionFileSuffix) +// 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 @@ -349,24 +338,19 @@ type EmittedFile struct { Content string `json:"-"` } -// templateData is the value passed to templates. It augments [Params] with the -// per-skill and shared fields the templates reference. -type templateData struct { - Params - - // SkillID is the on-disk / front-matter skill name. - SkillID string - - // SkillName is the stable base skill name (the 'docs agent show' argument). - SkillName string - - Description string - ShowSkillToolName string -} - -func renderWithData(templateName string, data templateData) (string, error) { +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) @@ -375,27 +359,6 @@ func renderWithData(templateName string, data templateData) (string, error) { return buf.String(), nil } -func renderSkill(templateName string, skill Skill, layout Layout, params Params) (string, error) { - return renderWithData(templateName, templateData{ - Params: params, - SkillID: layout.identifier(skill), - SkillName: skill.Name, - Description: skill.Description, - ShowSkillToolName: ShowSkillToolName, - }) -} - -// instructionData is the value passed to the instruction-wrapper template. -type instructionData struct { - Params - - Description string - ApplyTo string - Title string - Intro string - Skills []SkillPointer -} - func renderInstruction(inst Instruction, params Params) (string, error) { applyTo, err := renderInline("applyTo", inst.ApplyTo, params) if err != nil { @@ -404,13 +367,13 @@ func renderInstruction(inst Instruction, params Params) (string, error) { var buf bytes.Buffer - data := instructionData{ + inst.ApplyTo = applyTo + data := struct { + Params + Instruction + }{ Params: params, - Description: inst.Description, - ApplyTo: applyTo, - Title: inst.Title, - Intro: inst.Intro, - Skills: inst.Skills, + Instruction: inst, } err = templates.ExecuteTemplate(&buf, "instruction-wrapper.md.tmpl", data) @@ -448,7 +411,7 @@ func SkillDocument(name string, params Params) (string, error) { return "", err } - return renderSkill(skill.bodyTemplate, skill, DefaultLayout(), params) + return renderSkill(skill.bodyTemplate, skill, params) } // Files renders the set of agent files to write into a target repository using the @@ -465,7 +428,7 @@ func Files(layout Layout, params Params, full bool) ([]EmittedFile, error) { templateName = skill.bodyTemplate } - rendered, err := renderSkill(templateName, skill, layout, params) + rendered, err := renderSkill(templateName, skill, params) if err != nil { return nil, err } @@ -479,7 +442,7 @@ func Files(layout Layout, params Params, full bool) ([]EmittedFile, error) { return nil, err } - files = append(files, EmittedFile{RelPath: layout.InstructionFile(inst), Content: rendered}) + 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 index f151e92b..a5523072 100644 --- a/internal/app/azldev/agentskill/agentskill_test.go +++ b/internal/app/azldev/agentskill/agentskill_test.go @@ -11,6 +11,7 @@ import ( "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 { @@ -38,21 +39,11 @@ func parseFrontmatter(t *testing.T, doc string) map[string]string { require.True(t, strings.HasPrefix(doc, "---\n"), "document must start with YAML front matter") rest := strings.TrimPrefix(doc, "---\n") - end := strings.Index(rest, "\n---") - require.GreaterOrEqual(t, end, 0, "front matter must be terminated by a '---' line") + frontmatter, _, found := strings.Cut(rest, "\n---") + require.True(t, found, "front matter must be terminated by a '---' line") fields := map[string]string{} - - for _, line := range strings.Split(rest[:end], "\n") { - if strings.TrimSpace(line) == "" { - continue - } - - key, value, found := strings.Cut(line, ": ") - require.True(t, found, "front matter line %#q must be a 'key: value' pair", line) - - fields[key] = strings.Trim(strings.TrimSpace(value), `"`) - } + require.NoError(t, yaml.Unmarshal([]byte(frontmatter), &fields)) return fields } @@ -223,7 +214,7 @@ func TestFilesWrapper(t *testing.T) { // 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, layout.InstructionFile(azldevInstruction)).Content + 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) @@ -232,7 +223,7 @@ func TestFilesWrapper(t *testing.T) { // 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, layout.InstructionFile(compTomlInstruction)).Content + compTomlWrapper := fileByPath(t, files, agentskill.InstructionFile(compTomlInstruction)).Content assert.Contains(t, compTomlWrapper, `applyTo: "**/*.comp.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") @@ -242,7 +233,7 @@ func TestFilesWrapper(t *testing.T) { // The rendered-specs wrapper carries the do-not-edit guardrail and a binding-resolved glob. renderedSpecsInstruction := instructionByName(t, "rendered-specs") - renderedSpecsWrapper := fileByPath(t, files, layout.InstructionFile(renderedSpecsInstruction)).Content + renderedSpecsWrapper := fileByPath(t, files, agentskill.InstructionFile(renderedSpecsInstruction)).Content assert.Contains(t, renderedSpecsWrapper, `applyTo: "specs/**/*.spec"`) assert.Contains(t, renderedSpecsWrapper, "do not edit them directly") } @@ -256,7 +247,7 @@ func TestRenderedSpecsInstructionApplyToTracksBindings(t *testing.T) { require.NoError(t, err) inst := instructionByName(t, "rendered-specs") - wrapper := fileByPath(t, files, layout.InstructionFile(inst)).Content + wrapper := fileByPath(t, files, agentskill.InstructionFile(inst)).Content // The applyTo glob tracks the configured rendered-specs directory. assert.Contains(t, wrapper, `applyTo: "SPECS/**/*.spec"`) @@ -276,8 +267,7 @@ func TestFilesFull(t *testing.T) { func TestFilesGitHubLayout(t *testing.T) { layout := agentskill.Layout{ - SkillsDir: ".github/skills", - InstructionsDir: ".github/instructions", + SkillsDir: ".github/skills", } files, err := agentskill.Files(layout, testParams(), false) diff --git a/internal/app/azldev/agentskill/content/add-component.md.tmpl b/internal/app/azldev/agentskill/content/add-component.md.tmpl index 1d44aaa8..fd530cf4 100644 --- a/internal/app/azldev/agentskill/content/add-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/add-component.md.tmpl @@ -1,5 +1,5 @@ --- -name: {{ .SkillID }} +name: {{ .Name }} description: {{ .Description }} --- diff --git a/internal/app/azldev/agentskill/content/azldev.md.tmpl b/internal/app/azldev/agentskill/content/azldev.md.tmpl index 0338cb1d..5d24c50d 100644 --- a/internal/app/azldev/agentskill/content/azldev.md.tmpl +++ b/internal/app/azldev/agentskill/content/azldev.md.tmpl @@ -1,5 +1,5 @@ --- -name: {{ .SkillID }} +name: {{ .Name }} description: {{ .Description }} --- diff --git a/internal/app/azldev/agentskill/content/build-component.md.tmpl b/internal/app/azldev/agentskill/content/build-component.md.tmpl index 2a273935..0ff3a1ee 100644 --- a/internal/app/azldev/agentskill/content/build-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/build-component.md.tmpl @@ -1,5 +1,5 @@ --- -name: {{ .SkillID }} +name: {{ .Name }} description: {{ .Description }} --- diff --git a/internal/app/azldev/agentskill/content/comp-toml.md.tmpl b/internal/app/azldev/agentskill/content/comp-toml.md.tmpl index addbee99..6a35098f 100644 --- a/internal/app/azldev/agentskill/content/comp-toml.md.tmpl +++ b/internal/app/azldev/agentskill/content/comp-toml.md.tmpl @@ -1,5 +1,5 @@ --- -name: {{ .SkillID }} +name: {{ .Name }} description: {{ .Description }} --- diff --git a/internal/app/azldev/agentskill/content/image.md.tmpl b/internal/app/azldev/agentskill/content/image.md.tmpl index 72f43986..9a71a3f5 100644 --- a/internal/app/azldev/agentskill/content/image.md.tmpl +++ b/internal/app/azldev/agentskill/content/image.md.tmpl @@ -1,5 +1,5 @@ --- -name: {{ .SkillID }} +name: {{ .Name }} description: {{ .Description }} --- diff --git a/internal/app/azldev/agentskill/content/mock.md.tmpl b/internal/app/azldev/agentskill/content/mock.md.tmpl index c8b85f35..604f82ac 100644 --- a/internal/app/azldev/agentskill/content/mock.md.tmpl +++ b/internal/app/azldev/agentskill/content/mock.md.tmpl @@ -1,5 +1,5 @@ --- -name: {{ .SkillID }} +name: {{ .Name }} description: {{ .Description }} --- diff --git a/internal/app/azldev/agentskill/content/overlays.md.tmpl b/internal/app/azldev/agentskill/content/overlays.md.tmpl index ea6fe5e5..dcf41722 100644 --- a/internal/app/azldev/agentskill/content/overlays.md.tmpl +++ b/internal/app/azldev/agentskill/content/overlays.md.tmpl @@ -1,5 +1,5 @@ --- -name: {{ .SkillID }} +name: {{ .Name }} description: {{ .Description }} --- diff --git a/internal/app/azldev/agentskill/content/remove-component.md.tmpl b/internal/app/azldev/agentskill/content/remove-component.md.tmpl index 2e273a23..41cf1f1b 100644 --- a/internal/app/azldev/agentskill/content/remove-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/remove-component.md.tmpl @@ -1,5 +1,5 @@ --- -name: {{ .SkillID }} +name: {{ .Name }} description: {{ .Description }} --- diff --git a/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl b/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl index 4c26b544..bcebfd4a 100644 --- a/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl +++ b/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl @@ -1,15 +1,15 @@ --- -name: {{ .SkillID }} +name: {{ .Name }} description: {{ .Description }} --- -# {{ .SkillID }} +# {{ .Name }} For the full, always-up-to-date content of this skill, call the read-only MCP tool -**`{{ .ShowSkillToolName }}`** with skill `{{ .SkillName }}` (provided by the azldev +**`{{ .ShowSkillToolName }}`** with skill `{{ .Name }}` (provided by the azldev MCP server). -If the azldev MCP server is not configured, run `azldev docs agent show --skill {{ .SkillName }}` +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 diff --git a/internal/app/azldev/agentskill/content/update-component.md.tmpl b/internal/app/azldev/agentskill/content/update-component.md.tmpl index 1016f399..28851171 100644 --- a/internal/app/azldev/agentskill/content/update-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/update-component.md.tmpl @@ -1,5 +1,5 @@ --- -name: {{ .SkillID }} +name: {{ .Name }} description: {{ .Description }} --- diff --git a/internal/app/azldev/agentskill/doc.go b/internal/app/azldev/agentskill/doc.go index 16b2270b..18e3816c 100644 --- a/internal/app/azldev/agentskill/doc.go +++ b/internal/app/azldev/agentskill/doc.go @@ -34,8 +34,8 @@ // // 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 (and the SkillID) exist as fields rather than living in the -// template front matter: holding them once in the registry lets both the full body and +// 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. diff --git a/internal/app/azldev/cmds/docs/agent.go b/internal/app/azldev/cmds/docs/agent.go index 5f70d35e..e907bed3 100644 --- a/internal/app/azldev/cmds/docs/agent.go +++ b/internal/app/azldev/cmds/docs/agent.go @@ -115,8 +115,10 @@ scaffolding a different repo.`, func newAgentShowCmd() *cobra.Command { var skillName string + completeSkillNames := cobra.FixedCompletions(skillNames(), cobra.ShellCompDirectiveNoFileComp) + cmd := &cobra.Command{ - Use: "show [skill]", + Use: "show", Aliases: []string{"skill", "print"}, Short: "Print an azldev agent skill to stdout", Long: `Print an azldev Agent Skill document to stdout. @@ -125,24 +127,18 @@ This is the authoritative skill content embedded in the binary. It is exposed as 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 as a positional argument (shell completion lists the choices) or with ---skill. Run with no skill to list the available skills.`, +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 (positional; tab-completes) - azldev docs agent show azldev-overlays`, - Args: cobra.MaximumNArgs(1), - ValidArgsFunction: completeSkillNames, + # Print a skill (--skill tab-completes) + azldev docs agent show --skill azldev-overlays`, + Args: cobra.NoArgs, } - cmd.RunE = func(cmd *cobra.Command, args []string) error { - requested := skillName - if len(args) > 0 { - requested = args[0] - } - - name, list, err := resolveShowSkill(requested) + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + name, list, err := resolveShowSkill(skillName) if err != nil { return err } @@ -171,8 +167,7 @@ Name the skill as a positional argument (shell completion lists the choices) or return nil } - cmd.Flags().StringVar(&skillName, "skill", "", - "skill to print (or pass it as a positional argument)") + cmd.Flags().StringVar(&skillName, "skill", "", "skill to print") _ = cmd.RegisterFlagCompletionFunc("skill", completeSkillNames) azldev.ExportAsReadOnlyMCPTool(cmd) @@ -312,7 +307,11 @@ func agentSkillParams(env *azldev.Env, rootCmd *cobra.Command) agentskill.Params // 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 := defaultBindings() + bindings := agentskill.Bindings{ + LockDir: projectconfig.DefaultLockDir, + RenderedSpecsDir: projectconfig.DefaultRenderedSpecsDir, + WorkDir: projectconfig.DefaultWorkDir, + } if env == nil || env.Config() == nil { return bindings @@ -336,17 +335,6 @@ func resolveBindings(env *azldev.Env) agentskill.Bindings { return bindings } -// defaultBindings returns the bindings used when no project configuration is available. They are -// azldev's built-in directory defaults, which remain accurate for any repository that has not -// overridden them. -func defaultBindings() agentskill.Bindings { - return agentskill.Bindings{ - LockDir: projectconfig.DefaultLockDir, - RenderedSpecsDir: projectconfig.DefaultRenderedSpecsDir, - WorkDir: projectconfig.DefaultWorkDir, - } -} - // 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. @@ -380,20 +368,14 @@ func resolveLayout(layoutName string) (agentskill.Layout, error) { return layout, nil } -// resolveShowSkill returns the skill name to print. When unset, it defaults to the only registered -// skill, or errors listing the choices when several exist. // 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 and several are -// registered, an empty name plus the list of skill names for the caller to display. +// 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 == "" { - if len(names) == 1 { - return names[0], nil, nil - } - return "", names, nil } @@ -417,20 +399,6 @@ func skillNames() []string { return names } -// completeSkillNames is the shell-completion function for the skill argument/flag; it -// offers the registered skill names matching the prefix being typed. -func completeSkillNames(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { - var names []string - - for _, name := range skillNames() { - if strings.HasPrefix(name, toComplete) { - names = append(names, name) - } - } - - return names, cobra.ShellCompDirectiveNoFileComp -} - // topLevelCommands returns the non-hidden, available top-level commands with their summaries, // sorted by name. func topLevelCommands(rootCmd *cobra.Command) []agentskill.Command { diff --git a/internal/app/azldev/cmds/docs/agent_internal_test.go b/internal/app/azldev/cmds/docs/agent_internal_test.go index a8bfbe0d..a8a12388 100644 --- a/internal/app/azldev/cmds/docs/agent_internal_test.go +++ b/internal/app/azldev/cmds/docs/agent_internal_test.go @@ -12,6 +12,7 @@ import ( "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" ) @@ -94,6 +95,21 @@ func TestResolveShowSkill(t *testing.T) { 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. diff --git a/internal/app/azldev/cmds/docs/agent_test.go b/internal/app/azldev/cmds/docs/agent_test.go index 00a8227c..8731c580 100644 --- a/internal/app/azldev/cmds/docs/agent_test.go +++ b/internal/app/azldev/cmds/docs/agent_test.go @@ -68,7 +68,7 @@ func TestInstallAgentFiles(t *testing.T) { azldevInstruction := agentskill.Instructions()[0] instructionsPath := filepath.Join(outputDir, - filepath.FromSlash(agentskill.DefaultLayout().InstructionFile(azldevInstruction))) + filepath.FromSlash(agentskill.InstructionFile(azldevInstruction))) written := writtenPaths(results) assert.True(t, written[skillPath], "expected the azldev skill to be written") From 7a6521f4afe23b1ab018173c82db8924d1f5f7bc Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Thu, 9 Jul 2026 17:28:13 -0700 Subject: [PATCH 5/8] feedback2 --- .../instructions/agent-skills.instructions.md | 2 +- docs/user/how-to/set-up-ai-agents.md | 18 +++++++++++----- internal/app/azldev/agentskill/agentskill.go | 16 +++++++------- .../app/azldev/agentskill/agentskill_test.go | 21 +++++++++++++++++-- .../agentskill/content/add-component.md.tmpl | 2 +- .../azldev/agentskill/content/azldev.md.tmpl | 2 +- .../content/build-component.md.tmpl | 2 +- .../agentskill/content/comp-toml.md.tmpl | 2 +- .../azldev/agentskill/content/image.md.tmpl | 2 +- .../content/instruction-wrapper.md.tmpl | 4 +++- .../azldev/agentskill/content/mock.md.tmpl | 2 +- .../agentskill/content/overlays.md.tmpl | 2 +- .../content/remove-component.md.tmpl | 2 +- .../agentskill/content/skill-wrapper.md.tmpl | 2 +- .../content/update-component.md.tmpl | 5 +++-- .../TestMCPServerMode_1.snap.json | 2 +- 16 files changed, 57 insertions(+), 29 deletions(-) diff --git a/.github/instructions/agent-skills.instructions.md b/.github/instructions/agent-skills.instructions.md index a8de4430..fd5c6073 100644 --- a/.github/instructions/agent-skills.instructions.md +++ b/.github/instructions/agent-skills.instructions.md @@ -46,7 +46,7 @@ and how dynamic values are substituted — read the package doc in ## 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 }}/**/*.spec`) — it is + 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"). diff --git a/docs/user/how-to/set-up-ai-agents.md b/docs/user/how-to/set-up-ai-agents.md index 3e4fb7d1..85893ba5 100644 --- a/docs/user/how-to/set-up-ai-agents.md +++ b/docs/user/how-to/set-up-ai-agents.md @@ -7,11 +7,14 @@ GitHub Copilot) how to work with azldev in your repository. azldev can emit two kinds of agent-facing files: -- An **Agent Skill** at `.agents/skills/azldev/SKILL.md` — an on-demand capability, +- **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. -- A **path-specific instructions** file at `.github/instructions/azldev.instructions.md` - that applies automatically when an agent edits an `azldev.toml` configuration file. +- **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 @@ -64,9 +67,14 @@ azldev docs agent install --full ## Preview the Skill -To print the full skill document to stdout — the same content the MCP tool -serves — run: +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/internal/app/azldev/agentskill/agentskill.go b/internal/app/azldev/agentskill/agentskill.go index 88adfd8f..4b22542d 100644 --- a/internal/app/azldev/agentskill/agentskill.go +++ b/internal/app/azldev/agentskill/agentskill.go @@ -239,17 +239,17 @@ var instructions = []Instruction{ }, { Name: "rendered-specs", - ApplyTo: "{{ .RenderedSpecsDir }}/**/*.spec", - Description: "Rendered RPM spec files produced by 'azldev comp render'. They are build inputs and " + + 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 a generated " + - "spec.", - Title: "Rendered spec files", - Intro: "These `.spec` files are generated by `azldev comp render` and are build inputs; " + - "do not edit them directly — change the component's `.comp.toml` or its overlays and re-render.", + "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 a spec via overlays"}, + {Skill: "azldev-overlays", Purpose: "to change generated output via overlays"}, {Skill: "azldev-update-component", Purpose: "to refresh and finalize a component"}, }, }, diff --git a/internal/app/azldev/agentskill/agentskill_test.go b/internal/app/azldev/agentskill/agentskill_test.go index a5523072..cd9783a8 100644 --- a/internal/app/azldev/agentskill/agentskill_test.go +++ b/internal/app/azldev/agentskill/agentskill_test.go @@ -93,6 +93,14 @@ func TestSkillDocumentUsesBindings(t *testing.T) { 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() @@ -200,6 +208,11 @@ func TestFilesWrapper(t *testing.T) { // 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. @@ -234,7 +247,7 @@ func TestFilesWrapper(t *testing.T) { // 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/**/*.spec"`) + assert.Contains(t, renderedSpecsWrapper, `applyTo: "specs/**/*"`) assert.Contains(t, renderedSpecsWrapper, "do not edit them directly") } @@ -250,7 +263,7 @@ func TestRenderedSpecsInstructionApplyToTracksBindings(t *testing.T) { wrapper := fileByPath(t, files, agentskill.InstructionFile(inst)).Content // The applyTo glob tracks the configured rendered-specs directory. - assert.Contains(t, wrapper, `applyTo: "SPECS/**/*.spec"`) + assert.Contains(t, wrapper, `applyTo: "SPECS/**/*"`) } func TestFilesFull(t *testing.T) { @@ -260,6 +273,10 @@ func TestFilesFull(t *testing.T) { 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") diff --git a/internal/app/azldev/agentskill/content/add-component.md.tmpl b/internal/app/azldev/agentskill/content/add-component.md.tmpl index fd530cf4..cc395c9a 100644 --- a/internal/app/azldev/agentskill/content/add-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/add-component.md.tmpl @@ -80,4 +80,4 @@ smoke test — see the `azldev-build-component` and `azldev-mock` skills. After re-run `azldev comp update -p ` to finalize the lock before a PR (see the `azldev-update-component` skill). -Generated for azldev version `{{ .Version }}`. +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 index 5d24c50d..2d6aca1d 100644 --- a/internal/app/azldev/agentskill/content/azldev.md.tmpl +++ b/internal/app/azldev/agentskill/content/azldev.md.tmpl @@ -70,4 +70,4 @@ Everyday tasks (add `-q -O json` when scripting): 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 for azldev version `{{ .Version }}`. +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 index 0ff3a1ee..1accd46f 100644 --- a/internal/app/azldev/agentskill/content/build-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/build-component.md.tmpl @@ -57,4 +57,4 @@ Finalize with `azldev comp update -p ` before opening a PR (see the Per-component build tweaks (`build.defines`, `build.without`) live in the `.comp.toml` — see the `azldev-comp-toml` skill. -Generated for azldev version `{{ .Version }}`. +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 index 6a35098f..cc6e8089 100644 --- a/internal/app/azldev/agentskill/content/comp-toml.md.tmpl +++ b/internal/app/azldev/agentskill/content/comp-toml.md.tmpl @@ -106,4 +106,4 @@ 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 for azldev version `{{ .Version }}`. +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 index 9a71a3f5..3e3cf9e4 100644 --- a/internal/app/azldev/agentskill/content/image.md.tmpl +++ b/internal/app/azldev/agentskill/content/image.md.tmpl @@ -57,4 +57,4 @@ The `.kiwi` file is upstream KIWI NG XML — azldev does not own its schema. It 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 for azldev version `{{ .Version }}`. +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 index ed6d23b4..46788a7f 100644 --- a/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl +++ b/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl @@ -12,4 +12,6 @@ here, you MUST first read the relevant azldev skill(s) below. They are the autho version-matched source and override any prior assumptions: {{ range .Skills }}- You MUST read the `{{ .Skill }}` skill {{ .Purpose }}. -{{ 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 index 604f82ac..a3e6d88c 100644 --- a/internal/app/azldev/agentskill/content/mock.md.tmpl +++ b/internal/app/azldev/agentskill/content/mock.md.tmpl @@ -89,4 +89,4 @@ react to output between commands. - **One mock session per terminal.** Running `azldev adv mock shell` while a terminal is already inside a mock shell fails — run `exit` first. -Generated for azldev version `{{ .Version }}`. +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 index dcf41722..d543d530 100644 --- a/internal/app/azldev/agentskill/content/overlays.md.tmpl +++ b/internal/app/azldev/agentskill/content/overlays.md.tmpl @@ -123,4 +123,4 @@ constraints, and the per-file overlay format, generate the machine-readable sche with `azldev config generate-schema` (see the `ComponentOverlay` definition), or read azldev's overlays configuration reference. -Generated for azldev version `{{ .Version }}`. +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 index 41cf1f1b..830276df 100644 --- a/internal/app/azldev/agentskill/content/remove-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/remove-component.md.tmpl @@ -70,4 +70,4 @@ package configuration still references the component. component you are dropping, remove it in the same change to keep the tree consistent. -Generated for azldev version `{{ .Version }}`. +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 index bcebfd4a..b69275e6 100644 --- a/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl +++ b/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl @@ -16,4 +16,4 @@ to print the same content, or regenerate this file with the full skill inlined: azldev docs agent install --full ``` -Generated for azldev version `{{ .Version }}`. +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 index 28851171..a9351573 100644 --- a/internal/app/azldev/agentskill/content/update-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/update-component.md.tmpl @@ -40,6 +40,7 @@ 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 ``` @@ -65,6 +66,6 @@ A pin bump follows the same rule, split across two commits: 3. Iterate on overlays / patches / build config as the new version requires, re-rendering after each change. Re-run `update` only if you change the pin itself again. 4. `azldev comp update -p ` and commit the lock. 5. `azldev comp render -p ` — `%changelog` / `Release:` now reflect the new lock. -6. `git commit --amend --no-edit` so the lock bump and rendered spec land together. +6. `git add {{ .RenderedSpecsDir }}///`, then `git commit --amend --no-edit` so the lock bump and rendered output land together. -Generated for azldev version `{{ .Version }}`. +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/scenario/__snapshots__/TestMCPServerMode_1.snap.json b/scenario/__snapshots__/TestMCPServerMode_1.snap.json index f4efedb0..3374671b 100755 --- a/scenario/__snapshots__/TestMCPServerMode_1.snap.json +++ b/scenario/__snapshots__/TestMCPServerMode_1.snap.json @@ -579,7 +579,7 @@ }, "skill": { "default": "", - "description": "name of the skill to print (defaults to the only skill)", + "description": "skill to print", "type": "string" }, "verbose": { From cceb37bf35db012aa49a9fd947dbd445a2c37369 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Fri, 10 Jul 2026 11:38:00 -0700 Subject: [PATCH 6/8] feedback3 --- docs/developer/prds/agent-scaffolding.prd.md | 16 ++--- .../cli/azldev_docs_agent_install.md | 9 +-- internal/app/azldev/cmds/docs/agent.go | 54 ++++++++++----- internal/app/azldev/cmds/docs/agent_test.go | 66 ++++++++++++++++--- 4 files changed, 108 insertions(+), 37 deletions(-) diff --git a/docs/developer/prds/agent-scaffolding.prd.md b/docs/developer/prds/agent-scaffolding.prd.md index 741e7a66..7795b3f8 100644 --- a/docs/developer/prds/agent-scaffolding.prd.md +++ b/docs/developer/prds/agent-scaffolding.prd.md @@ -59,7 +59,7 @@ the azldev-generic portion. The azurelinux PRD explicitly anticipates this featu | # | 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.json` + version pin). Editorial is left as empty/stub slots for the repo to author. | +| 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 @@ -76,7 +76,7 @@ match host convention (azurelinux uses `skill-*`). | `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` (azldev MCP server registration), `.azldev-version` | — | +| `.vscode/mcp.json` and `.mcp.json` (azldev MCP server registration), `.azldev-version` | — | ### Content sources (why generation beats hand-maintenance) @@ -113,9 +113,10 @@ match host convention (azurelinux uses `skill-*`). ### 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 a `.vscode/mcp.json` 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`. + 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) @@ -133,8 +134,7 @@ Once azldev emits the substrate, azurelinux is reworked as a **two-way port**: 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` via azldev; add the still-unchecked Phase 6 - `.vscode/mcp.json`. +3. Regenerate `external/schemas/azldev.schema.json`; add Phase 6 `.vscode/mcp.json` and `.mcp.json`. ## Implementation Plan (proposed) @@ -147,7 +147,7 @@ Once azldev emits the substrate, azurelinux is reworked as a **two-way port**: - **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`, +- **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. diff --git a/docs/user/reference/cli/azldev_docs_agent_install.md b/docs/user/reference/cli/azldev_docs_agent_install.md index a9844a0e..794b9f49 100644 --- a/docs/user/reference/cli/azldev_docs_agent_install.md +++ b/docs/user/reference/cli/azldev_docs_agent_install.md @@ -10,13 +10,14 @@ 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 - .vscode/mcp.json registers the azldev MCP server +.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 -.vscode/mcp.json entry is upserted, preserving any other servers already configured. +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 diff --git a/internal/app/azldev/cmds/docs/agent.go b/internal/app/azldev/cmds/docs/agent.go index e907bed3..3e519652 100644 --- a/internal/app/azldev/cmds/docs/agent.go +++ b/internal/app/azldev/cmds/docs/agent.go @@ -67,13 +67,14 @@ func newAgentInstallCmd() *cobra.Command { 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 - .vscode/mcp.json registers the azldev MCP server +.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 -.vscode/mcp.json entry is upserted, preserving any other servers already configured. +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 @@ -215,26 +216,42 @@ func InstallAgentFiles( results = append(results, InstalledAgentFile{Path: destPath, Written: true}) } - mcpResult, err := emitMCPConfig(env, options.OutputDir) + vscodeMCPResult, err := emitMCPConfig( + env, options.OutputDir, vscodeMCPConfigRelPath, "servers", false, + ) if err != nil { return nil, err } - results = append(results, mcpResult) + 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 } -// mcpConfigRelPath is the repo-relative path of the VS Code MCP server configuration. -const mcpConfigRelPath = ".vscode/mcp.json" +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 the target repo's .vscode/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. This lets a CI check run -// install and assert an empty diff. -func emitMCPConfig(env *azldev.Env, outputDir string) (InstalledAgentFile, error) { +// 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(mcpConfigRelPath)) + destPath := filepath.Join(outputDir, filepath.FromSlash(relPath)) config := map[string]any{} @@ -257,17 +274,22 @@ func emitMCPConfig(env *azldev.Env, outputDir string) (InstalledAgentFile, error } } - servers, _ := config["servers"].(map[string]any) + servers, _ := config[serversKey].(map[string]any) if servers == nil { servers = map[string]any{} } - servers["azldev"] = map[string]any{ + azldevServer := map[string]any{ "type": "stdio", "command": "azldev", "args": []any{"advanced", "mcp"}, } - config["servers"] = servers + if exposeAllTools { + azldevServer["tools"] = []any{"*"} + } + + servers["azldev"] = azldevServer + config[serversKey] = servers content, err := json.MarshalIndent(config, "", " ") if err != nil { diff --git a/internal/app/azldev/cmds/docs/agent_test.go b/internal/app/azldev/cmds/docs/agent_test.go index 8731c580..d7d0fd6a 100644 --- a/internal/app/azldev/cmds/docs/agent_test.go +++ b/internal/app/azldev/cmds/docs/agent_test.go @@ -61,8 +61,8 @@ func TestInstallAgentFiles(t *testing.T) { }) require.NoError(t, err) // One skill file per registered skill, one instruction file per registered instruction, - // plus the .vscode/mcp.json server config. - require.Len(t, results, len(agentskill.Skills())+len(agentskill.Instructions())+1) + // 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))) @@ -73,6 +73,10 @@ func TestInstallAgentFiles(t *testing.T) { 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) @@ -112,26 +116,70 @@ func TestInstallAgentFilesWritesMCPConfig(t *testing.T) { 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} - mcpPath := filepath.Join(outputDir, ".vscode/mcp.json") + 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, err := fileutils.ReadFile(testEnv.TestFS, mcpPath) - 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) - second, err := fileutils.ReadFile(testEnv.TestFS, mcpPath) - require.NoError(t, err) - // Re-running install must leave mcp.json byte-identical so a CI check sees an empty diff. - assert.Equal(t, string(first), string(second)) + 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) { From 98842ba3a5c73bc013b27548808b71b17962ff22 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Fri, 10 Jul 2026 13:59:27 -0700 Subject: [PATCH 7/8] feedback4 --- internal/app/azldev/agentskill/agentskill.go | 20 ++++++------ .../agentskill/content/add-component.md.tmpl | 11 +++++-- .../azldev/agentskill/content/azldev.md.tmpl | 2 +- .../agentskill/content/comp-toml.md.tmpl | 32 +++++++++++++++++++ .../content/update-component.md.tmpl | 4 +-- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/internal/app/azldev/agentskill/agentskill.go b/internal/app/azldev/agentskill/agentskill.go index 4b22542d..0ed66ebb 100644 --- a/internal/app/azldev/agentskill/agentskill.go +++ b/internal/app/azldev/agentskill/agentskill.go @@ -108,13 +108,12 @@ var skills = []Skill{ }, { Name: "azldev-comp-toml", - Description: "Read this before authoring or editing a *.comp.toml file; do not hand-write " + - "component config from memory. Explains the azldev component definition format, covering the " + - "component structure, choosing a spec source (upstream, pinned upstream-distro, upstream-name, " + - "or local), build config (defines/with/without), release calculation modes " + - "(auto/autorelease/static/manual), render options, and file organization (inline versus " + - "dedicated files and includes). Triggers include comp.toml, component config, spec source, " + - "upstream-distro, build defines, release calculation, includes.", + 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", }, { @@ -223,14 +222,15 @@ var instructions = []Instruction{ { Name: "comp-toml", ApplyTo: "**/*.comp.toml", - Description: "These are azldev component definition files (*.comp.toml). Before editing one, load " + + 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.", + "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"}, + {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"}, diff --git a/internal/app/azldev/agentskill/content/add-component.md.tmpl b/internal/app/azldev/agentskill/content/add-component.md.tmpl index cc395c9a..e7654745 100644 --- a/internal/app/azldev/agentskill/content/add-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/add-component.md.tmpl @@ -76,8 +76,13 @@ 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 committing, -re-run `azldev comp update -p ` to finalize the lock before a PR (see the -`azldev-update-component` skill). +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 index 2d6aca1d..19dffa39 100644 --- a/internal/app/azldev/agentskill/content/azldev.md.tmpl +++ b/internal/app/azldev/agentskill/content/azldev.md.tmpl @@ -54,7 +54,7 @@ Everyday tasks (add `-q -O json` when scripting): ## Where to go next - Add a new component — read the `azldev-add-component` skill. -- Edit a component's TOML — read the `azldev-comp-toml` 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. diff --git a/internal/app/azldev/agentskill/content/comp-toml.md.tmpl b/internal/app/azldev/agentskill/content/comp-toml.md.tmpl index cc6e8089..fdb91734 100644 --- a/internal/app/azldev/agentskill/content/comp-toml.md.tmpl +++ b/internal/app/azldev/agentskill/content/comp-toml.md.tmpl @@ -100,6 +100,38 @@ the rare spec whose `Source`/`Patch` filenames use macros the filter cannot expa - 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 diff --git a/internal/app/azldev/agentskill/content/update-component.md.tmpl b/internal/app/azldev/agentskill/content/update-component.md.tmpl index a9351573..bd69a50e 100644 --- a/internal/app/azldev/agentskill/content/update-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/update-component.md.tmpl @@ -59,12 +59,12 @@ For a `release.calculation = "manual"` component the renderer does **not** bump ## Bumping an upstream commit pin -A pin bump follows the same rule, split across two commits: +A pin bump follows the same rule, using one commit followed by a post-render amend: 1. Move the pin (edit the pinned commit or snapshot time), then `azldev comp update -p `; sanity-check `{{ .LockDir }}/.lock`. 2. `azldev comp render -p ` — the spec body now tracks the new pin. `%changelog` / `Release:` still show the old pin; that is expected until you commit. 3. Iterate on overlays / patches / build config as the new version requires, re-rendering after each change. Re-run `update` only if you change the pin itself again. -4. `azldev comp update -p ` and commit the lock. +4. `azldev comp update -p `, then stage all component inputs changed above (the pin, 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 lock bump and rendered output land together. From ce7c74fefc9c59463d5898d6151b5af7f3974c87 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Fri, 10 Jul 2026 17:01:07 -0700 Subject: [PATCH 8/8] feedback5 --- internal/app/azldev/agentskill/agentskill.go | 31 ++++++++++--------- .../app/azldev/agentskill/agentskill_test.go | 9 +++--- .../agentskill/content/add-component.md.tmpl | 15 ++++++--- .../azldev/agentskill/content/azldev.md.tmpl | 3 ++ .../content/instruction-wrapper.md.tmpl | 15 ++++++--- .../agentskill/content/overlays.md.tmpl | 25 ++++++++++++++- .../content/remove-component.md.tmpl | 11 +++++-- .../content/update-component.md.tmpl | 17 +++++----- 8 files changed, 86 insertions(+), 40 deletions(-) diff --git a/internal/app/azldev/agentskill/agentskill.go b/internal/app/azldev/agentskill/agentskill.go index 0ed66ebb..272b8b02 100644 --- a/internal/app/azldev/agentskill/agentskill.go +++ b/internal/app/azldev/agentskill/agentskill.go @@ -80,30 +80,31 @@ var skills = []Skill{ }, { Name: "azldev-update-component", - Description: "Read this before finalizing a component change, bumping an upstream pin, 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 " + + 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, lock drift, version bump, finalize component.", + "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 how to remove a component from an " + - "azldev distro as a manual, metadata-only edit (delete the component config, its lock, and " + - "rendered spec, then clean references). Triggers include remove component, delete package, drop " + + "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 or changing 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 " + - "choosing the overlay type, required fields, the render-and-inspect loop, common pitfalls, and " + - "metadata categories. Triggers include overlay, spec-add-tag, patch-add, fix spec, backport, " + - "disable test, prune subpackage, edit spec.", + 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", }, { @@ -199,6 +200,8 @@ type Instruction struct { 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 } @@ -221,7 +224,7 @@ var instructions = []Instruction{ }, { Name: "comp-toml", - ApplyTo: "**/*.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 " + diff --git a/internal/app/azldev/agentskill/agentskill_test.go b/internal/app/azldev/agentskill/agentskill_test.go index cd9783a8..a07a054b 100644 --- a/internal/app/azldev/agentskill/agentskill_test.go +++ b/internal/app/azldev/agentskill/agentskill_test.go @@ -172,7 +172,7 @@ func TestInstructionsRegistry(t *testing.T) { assert.Contains(t, names, "rendered-specs") compToml := instructionByName(t, "comp-toml") - assert.Equal(t, "**/*.comp.toml", compToml.ApplyTo) + assert.Equal(t, "**/*.comp.toml,**/components.toml", compToml.ApplyTo) skillNames := make([]string, 0, len(compToml.Skills)) for _, pointer := range compToml.Skills { @@ -237,11 +237,12 @@ func TestFilesWrapper(t *testing.T) { // name and purpose. compTomlInstruction := instructionByName(t, "comp-toml") compTomlWrapper := fileByPath(t, files, agentskill.InstructionFile(compTomlInstruction)).Content - assert.Contains(t, compTomlWrapper, `applyTo: "**/*.comp.toml"`) + 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 wrapper is prescriptive: agents must read the skill, not treat it as optional. + 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. diff --git a/internal/app/azldev/agentskill/content/add-component.md.tmpl b/internal/app/azldev/agentskill/content/add-component.md.tmpl index e7654745..be94703f 100644 --- a/internal/app/azldev/agentskill/content/add-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/add-component.md.tmpl @@ -25,13 +25,19 @@ dist-git often fail bot detection): 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. Pull the sources without overlays into a scratch dir under the work dir: +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 ``` -3. Read the spec and plan any overlays. +4. Read the spec and plan any overlays. ## Inline vs dedicated file @@ -64,9 +70,8 @@ points when adding a component: ## Validate -A new upstream component has no lock yet, and `render`, `diff-sources`, and `build` all -resolve it first — which fails lock validation until the lock exists. Create the lock, -then 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 diff --git a/internal/app/azldev/agentskill/content/azldev.md.tmpl b/internal/app/azldev/agentskill/content/azldev.md.tmpl index 19dffa39..58f636a8 100644 --- a/internal/app/azldev/agentskill/content/azldev.md.tmpl +++ b/internal/app/azldev/agentskill/content/azldev.md.tmpl @@ -51,6 +51,9 @@ Everyday tasks (add `-q -O json` when scripting): `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. diff --git a/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl b/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl index 46788a7f..0524ee15 100644 --- a/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl +++ b/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl @@ -7,11 +7,16 @@ applyTo: "{{ .ApplyTo }}" {{ .Intro }} -**Do not work from memory or guess.** Before you add, edit, or remove anything in scope -here, you MUST first read the relevant azldev skill(s) below. They are the authoritative, -version-matched source and override any prior assumptions: +**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. -{{ range .Skills }}- You MUST read the `{{ .Skill }}` skill {{ .Purpose }}. -{{ end -}} +{{ 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/overlays.md.tmpl b/internal/app/azldev/agentskill/content/overlays.md.tmpl index d543d530..cd0abec3 100644 --- a/internal/app/azldev/agentskill/content/overlays.md.tmpl +++ b/internal/app/azldev/agentskill/content/overlays.md.tmpl @@ -33,6 +33,19 @@ overlays before it stay applied. 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 @@ -46,7 +59,7 @@ the config loads, so a missing field fails fast rather than at apply time. | `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 a tag; fails if it is missing | `tag` | +| `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` | @@ -67,6 +80,16 @@ the config loads, so a missing field fails fast rather than at apply time. ## 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`. diff --git a/internal/app/azldev/agentskill/content/remove-component.md.tmpl b/internal/app/azldev/agentskill/content/remove-component.md.tmpl index 830276df..448e166f 100644 --- a/internal/app/azldev/agentskill/content/remove-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/remove-component.md.tmpl @@ -5,9 +5,11 @@ description: {{ .Description }} # Remove a component -There is **no `azldev` command to remove a component** — it is a manual, -metadata-only edit (no builds or tests needed). The steps below delete the -component's definition, lock, and rendered spec, then clean up references. +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 @@ -59,6 +61,9 @@ azldev comp list -p -q -O json # should report the component is not fou 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 diff --git a/internal/app/azldev/agentskill/content/update-component.md.tmpl b/internal/app/azldev/agentskill/content/update-component.md.tmpl index bd69a50e..934a663e 100644 --- a/internal/app/azldev/agentskill/content/update-component.md.tmpl +++ b/internal/app/azldev/agentskill/content/update-component.md.tmpl @@ -17,7 +17,7 @@ those change, the lock is stale. | --- | --- | | 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 | -| Bumping an upstream commit pin | **Yes** — also mid-workflow (see below) | +| 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 | @@ -57,15 +57,16 @@ 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. -## Bumping an upstream commit pin +## Changing source resolution -A pin bump follows the same rule, using one commit followed by a post-render amend: +A source-resolution change follows the same rule, using one commit followed by a +post-render amend: -1. Move the pin (edit the pinned commit or snapshot time), then `azldev comp update -p `; sanity-check `{{ .LockDir }}/.lock`. -2. `azldev comp render -p ` — the spec body now tracks the new pin. `%changelog` / `Release:` still show the old pin; that is expected until you commit. -3. Iterate on overlays / patches / build config as the new version requires, re-rendering after each change. Re-run `update` only if you change the pin itself again. -4. `azldev comp update -p `, then stage all component inputs changed above (the pin, config, overlays, and patches), `{{ .LockDir }}/.lock`, and the rendered output. Commit them together. +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 lock bump and rendered output land together. +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 }}`.