From 00f0d2211416d67e3f9869fcbdedf2d1f2f1b457 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Mon, 13 Jul 2026 15:38:40 -0700 Subject: [PATCH 1/6] feat(ai): add agent skill rendering core --- .github/copilot-instructions.md | 2 + .../instructions/agent-skills.instructions.md | 82 +++++ internal/app/azldev/agentskill/agentskill.go | 343 ++++++++++++++++++ .../app/azldev/agentskill/agentskill_test.go | 206 +++++++++++ .../azldev/agentskill/content/azldev.md.tmpl | 69 ++++ .../content/instruction-wrapper.md.tmpl | 22 ++ .../agentskill/content/skill-wrapper.md.tmpl | 19 + internal/app/azldev/agentskill/doc.go | 75 ++++ 8 files changed, 818 insertions(+) create mode 100644 .github/instructions/agent-skills.instructions.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/azldev.md.tmpl create mode 100644 internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl create mode 100644 internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl create mode 100644 internal/app/azldev/agentskill/doc.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1b1653c5e..d8e34aa9c 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 000000000..fd5c60735 --- /dev/null +++ b/.github/instructions/agent-skills.instructions.md @@ -0,0 +1,82 @@ +--- +applyTo: "internal/app/azldev/agentskill/**" +description: "How to maintain azldev's emitted AI-agent skills and instruction files. Read before adding or editing a skill, an instruction wrapper, or the emit mechanism in the agentskill package." +--- + +# Maintaining the emitted agent skills and instruction files + +The `agentskill` package is the **single source of truth** for the AI-agent skill and +instruction files that `azldev docs agent install` writes into distro repositories, and +that `azldev docs agent show` (a read-only MCP tool) serves. Everything is embedded in +the binary so the on-disk files, the CLI output, and the MCP response stay version-matched. + +For the full architecture — the registries, the redirect-wrapper-vs-full-body rendering, +and how dynamic values are substituted — read the package doc in +[doc.go](../../internal/app/azldev/agentskill/doc.go). + +## Model + +- **Skills hold the content.** A `Skill` in the `skills` registry + ([agentskill.go](../../internal/app/azldev/agentskill/agentskill.go)) pairs a name with a + body template under [content/](../../internal/app/azldev/agentskill/content/). Skills are the + cross-agent medium — served via `docs agent show` / the `docs-agent-show` MCP tool, or inlined + on disk with `--full`. +- **Instruction files are lightweight wrappers.** An `Instruction` in the `instructions` registry + is selected by its `applyTo` glob and only *points at* skills — `SkillPointer{Skill, Purpose}` + renders as "You MUST read the `` skill ``". Put substantive guidance in a + **skill**, never in a wrapper. Do not make a wrapper reference the CLI or MCP tool — those are + unavailable in `--full` installs; naming the skill works in every mode. + +## Adding or editing a skill + +1. Add a `Skill{Name, Description, bodyTemplate}` to the `skills` registry. +2. Add a `content/.md.tmpl` body, named for the topic (e.g. `mock.md.tmpl`, + `azldev.md.tmpl`). The front-matter `name` must equal the skill's base name (and its on-disk + directory), lowercase-hyphen, ≤ 64 chars. The `description` drives discovery, ≤ 1024 chars. +3. Write the description inline in the registry entry. The description is the **load gate** + (an agent decides whether to open the skill from it), so **lead with a directive** — + "Read this before ; do not from memory." — then what the skill covers, + then a `Triggers include ...` keyword list. **Avoid a colon-space (`: `) in + descriptions** — it is ambiguous in YAML front matter. +4. Add a content test in + [agentskill_test.go](../../internal/app/azldev/agentskill/agentskill_test.go) (assert the + front-matter `name` and a distinctive, validated phrase). `TestSkillFrontmatterInvariants` + auto-covers the spec limits. + +## Adding or editing an instruction wrapper + +1. Add an `Instruction{Name, ApplyTo, Description, Title, Intro, Skills}` to the `instructions` + registry. `ApplyTo` may reference bindings (e.g. `{{ .RenderedSpecsDir }}/**/*`) — it is + rendered against `Params` at emit time. +2. `Skills` is a list of `SkillPointer` — name each skill with a short purpose ("to add or change + overlays"). +3. A little hand-written prose here is fine, if it helps direct agents to the right skill. Keep it short, and avoid + repeating the skill content. +3. Keep the count-based tests happy: `Files()` emits one file per skill plus one per instruction. + +## Content accuracy is the hard part + +Distilled content **drifts**. The azurelinux copies these are distilled from are frequently stale. +Before shipping any skill/instruction: + +- **Validate every CLI, flag, and config claim against the current code** (command trees under + `internal/app/azldev/cmds/`, config under `internal/projectconfig/`). Do not trust the source you + distilled from. Confirm with `./out/bin/azldev --help` and `azldev config generate-schema`. +- Prefer a **drift-guard test** whenever a code enum can back the content. Example: + `TestOverlaysSkillCoversAllOverlayTypes` extracts the overlay-type enum from the jsonschema tag + on `projectconfig.ComponentOverlay.Type` and fails if the skill omits a type. + +## Config-resolved bindings + +Repo-specific values (lock dir, rendered-specs dir, work dir) are resolved from the target `azldev.toml` in +[cmds/docs/agent.go](../../internal/app/azldev/cmds/docs/agent.go) and degrade to azldev's defaults +when no config is present. To add a binding, extend `Bindings`, resolve it in `resolveBindings`, and +reference it in a template as `{{ .FieldName }}`. + +## Before you commit + +- `mage unit` and `mage check all` pass. +- `mage docs` produces **no drift** (adding a registry skill/instruction should not change CLI docs, + the JSON schema, or the MCP snapshot). +- Sanity-check emitted output: `./out/bin/azldev docs agent install -o "$(mktemp -d)"` and + `./out/bin/azldev docs agent show --skill `. diff --git a/internal/app/azldev/agentskill/agentskill.go b/internal/app/azldev/agentskill/agentskill.go new file mode 100644 index 000000000..a42d5f072 --- /dev/null +++ b/internal/app/azldev/agentskill/agentskill.go @@ -0,0 +1,343 @@ +// 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", + }, +} + +// Skills returns the registered skills in emission order. +func Skills() []Skill { + return skills +} + +// FindSkill returns the registered skill with the given name. +func FindSkill(name string) (Skill, error) { + for _, skill := range skills { + if skill.Name == name { + return skill, nil + } + } + + return Skill{}, fmt.Errorf("unknown skill %#q", name) +} + +// SkillPointer names a skill an instruction file points at, together with a short +// purpose describing when to read it ("read the `azldev-overlays` skill to add or change +// overlays"). +type SkillPointer struct { + // Skill is the name of the skill to read. + Skill string + + // Purpose is a short phrase describing when to read the skill (e.g. "to add or + // change overlays"). It follows the skill name in the rendered wrapper. + Purpose string +} + +// Instruction describes a single emitted path-specific instruction file. Instruction +// files are lightweight wrappers, selected automatically by their 'applyTo' glob, that +// point agents at the relevant skill(s); the substantive, always-current guidance lives +// in the skills, keeping a single source of truth. An instruction only names the skills +// to read — how a skill's content is delivered (a thin wrapper served by docs-agent-show, +// or the full body inlined by '--full') is the skill's concern, not the instruction's. +type Instruction struct { + // Name is the file-name stem; the emitted file is ".instructions.md". + Name string + + // ApplyTo is the front-matter glob selecting the files this instruction applies to. + // It may reference binding fields (e.g. '{{ .RenderedSpecsDir }}') and is rendered + // against [Params] at emission time. + ApplyTo string + + // Description is the front-matter description. + // NOTE: avoid a colon followed by a space; that sequence is ambiguous in YAML. + Description string + + // Title is the body heading. + Title string + + // Intro is the body's opening sentence describing the file kind. + Intro string + + // Skills lists the skills this wrapper points agents at, in order, each with a purpose. + // The first skill is required for every matching file; remaining skills are loaded only when + // their purpose matches the task. + Skills []SkillPointer +} + +// instructions is the registry of emitted instruction files, in emission order. +// +//nolint:gochecknoglobals // effectively a constant registry of the built-in instruction files. +var instructions = []Instruction{ + { + Name: SkillName, + ApplyTo: ConfigGlob, + Description: "This repo is an azldev distro project (azldev.toml present). Before running azldev " + + "or editing its config, load the azldev skill; do not guess azldev's commands or config. " + + "Triggers include azldev, comp build, comp render, comp update, build a component, add a " + + "component, distro config.", + Title: "Working with azldev projects", + Intro: "This repository is an azldev distro project; its top-level configuration lives in `azldev.toml`.", + Skills: []SkillPointer{ + {Skill: SkillName, Purpose: "for how to use the azldev CLI"}, + }, + }, +} + +// Instructions returns the registered instruction files in emission order. +func Instructions() []Instruction { + return instructions +} + +// Layout controls where emitted skill files are written in a target repository. +type Layout struct { + // SkillsDir is the repo-relative parent directory that holds skill directories. + SkillsDir string +} + +// DefaultLayout returns the default emission layout: skills under the tool-neutral +// '.agents/skills' location from the Agent Skills open standard, and instructions +// under '.github/instructions'. +func DefaultLayout() Layout { + return Layout{ + SkillsDir: ".agents/skills", + } +} + +// SkillDir returns the repo-relative directory for a skill under this layout. +func (l Layout) SkillDir(skill Skill) string { + return path.Join(l.SkillsDir, skill.Name) +} + +// SkillFile returns the repo-relative SKILL.md path for a skill under this layout. +func (l Layout) SkillFile(skill Skill) string { + return path.Join(l.SkillDir(skill), "SKILL.md") +} + +// InstructionFile returns the repo-relative file path for an instruction. +func InstructionFile(inst Instruction) string { + return path.Join(".github/instructions", inst.Name+instructionFileSuffix) +} + +// Command is a top-level azldev command with its one-line summary. The list is generated +// from the Cobra command tree so the overview skill's command list never goes stale. +type Command struct { + Name string + Short string +} + +// Bindings are the target-repo values resolved from the repo's azldev.toml and +// injected into skill content. The caller (the 'docs agent' command) is responsible +// for populating every field: from a loaded configuration when one is available, or +// from azldev's built-in defaults when it is not, so the emitted documentation stays +// accurate for a default project even with no configuration present. +type Bindings struct { + // LockDir is the repo-relative directory holding per-component lock files. + LockDir string + + // RenderedSpecsDir is the repo-relative directory holding rendered component specs. + RenderedSpecsDir string + + // WorkDir is the repo-relative temporary working directory. Skills use it for + // throwaway scratch output so agents stay within the project's configured layout + // instead of writing to /tmp. + WorkDir string +} + +// Params carries the dynamic values injected into the emitted and served content. +type Params struct { + // Version is the azldev version stamped into the generated content. + Version string + + // TopLevelCommands is the sorted list of top-level azldev commands with summaries. + TopLevelCommands []Command + + // Bindings are the target-repo values resolved from the repo's configuration + // (or azldev defaults when none is available). Embedded so templates can reference + // its fields directly (e.g. '{{ .LockDir }}'). + Bindings +} + +// EmittedFile is a single file to be written into a target repository. +type EmittedFile struct { + // RelPath is the repository-relative destination path, always forward-slash separated. + RelPath string `json:"relPath"` + + // Content is the fully rendered file content. + Content string `json:"-"` +} + +func renderSkill(templateName string, skill Skill, params Params) (string, error) { + var buf bytes.Buffer + + data := struct { + Params + Skill + ShowSkillToolName string + }{ + Params: params, + Skill: skill, + ShowSkillToolName: ShowSkillToolName, + } + + err := templates.ExecuteTemplate(&buf, templateName, data) + if err != nil { + return "", fmt.Errorf("failed to render agent skill template %#q:\n%w", templateName, err) + } + + return buf.String(), nil +} + +func renderInstruction(inst Instruction, params Params) (string, error) { + applyTo, err := renderInline("applyTo", inst.ApplyTo, params) + if err != nil { + return "", fmt.Errorf("failed to render applyTo for instruction %#q:\n%w", inst.Name, err) + } + + var buf bytes.Buffer + + inst.ApplyTo = applyTo + data := struct { + Params + Instruction + }{ + Params: params, + Instruction: inst, + } + + err = templates.ExecuteTemplate(&buf, "instruction-wrapper.md.tmpl", data) + if err != nil { + return "", fmt.Errorf("failed to render instruction template for %#q:\n%w", inst.Name, err) + } + + return buf.String(), nil +} + +// renderInline renders a short, trusted template string (such as an instruction's applyTo +// glob, which may reference binding fields like '{{ .RenderedSpecsDir }}') against params. +func renderInline(name, text string, params Params) (string, error) { + tmpl, err := template.New(name).Parse(text) + if err != nil { + return "", fmt.Errorf("failed to parse %s template %#q:\n%w", name, text, err) + } + + var buf bytes.Buffer + + err = tmpl.Execute(&buf, params) + if err != nil { + return "", fmt.Errorf("failed to execute %s template %#q:\n%w", name, text, err) + } + + return buf.String(), nil +} + +// SkillDocument renders the full document for the named skill. It is served +// verbatim by the read-only MCP tool and by 'azldev docs agent show'. The default +// layout is used since a served document has no on-disk directory. +func SkillDocument(name string, params Params) (string, error) { + skill, err := FindSkill(name) + if err != nil { + return "", err + } + + return renderSkill(skill.bodyTemplate, skill, params) +} + +// Files renders the set of agent files to write into a target repository using the +// given layout. When full is true, each on-disk SKILL.md contains the complete +// skill document instead of a light MCP wrapper (useful when the azldev MCP server +// is not available in the target environment). Instruction files are always light +// wrappers that point at the relevant skills. +func Files(layout Layout, params Params, full bool) ([]EmittedFile, error) { + files := make([]EmittedFile, 0, len(skills)+len(instructions)) + + for _, skill := range skills { + templateName := "skill-wrapper.md.tmpl" + if full { + templateName = skill.bodyTemplate + } + + rendered, err := renderSkill(templateName, skill, params) + if err != nil { + return nil, err + } + + files = append(files, EmittedFile{RelPath: layout.SkillFile(skill), Content: rendered}) + } + + for _, inst := range instructions { + rendered, err := renderInstruction(inst, params) + if err != nil { + return nil, err + } + + files = append(files, EmittedFile{RelPath: InstructionFile(inst), Content: rendered}) + } + + return files, nil +} diff --git a/internal/app/azldev/agentskill/agentskill_test.go b/internal/app/azldev/agentskill/agentskill_test.go new file mode 100644 index 000000000..051919370 --- /dev/null +++ b/internal/app/azldev/agentskill/agentskill_test.go @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package agentskill_test + +import ( + "path" + "strings" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/agentskill" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func testParams() agentskill.Params { + return agentskill.Params{ + Version: "1.2.3-test", + TopLevelCommands: []agentskill.Command{ + {Name: "component", Short: "Manage components"}, + {Name: "config", Short: "Manage configuration"}, + {Name: "docs", Short: "Generate documentation"}, + }, + Bindings: agentskill.Bindings{ + LockDir: "locks", + RenderedSpecsDir: "specs", + WorkDir: "build/work", + }, + } +} + +// parseFrontmatter extracts the leading YAML front matter of a Markdown document into a map of +// top-level "key: value" pairs. It is intentionally minimal (no nested structures) since the +// emitted files only use flat scalar fields. +func parseFrontmatter(t *testing.T, doc string) map[string]string { + t.Helper() + + require.True(t, strings.HasPrefix(doc, "---\n"), "document must start with YAML front matter") + + rest := strings.TrimPrefix(doc, "---\n") + frontmatter, _, found := strings.Cut(rest, "\n---") + require.True(t, found, "front matter must be terminated by a '---' line") + + fields := map[string]string{} + require.NoError(t, yaml.Unmarshal([]byte(frontmatter), &fields)) + + return fields +} + +// primarySkill returns the built-in azldev skill. +func primarySkill(t *testing.T) agentskill.Skill { + t.Helper() + + skill, err := agentskill.FindSkill(agentskill.SkillName) + require.NoError(t, err) + + return skill +} + +func TestSkillDocument(t *testing.T) { + doc, err := agentskill.SkillDocument(agentskill.SkillName, testParams()) + require.NoError(t, err) + + fields := parseFrontmatter(t, doc) + + assert.Equal(t, agentskill.SkillName, fields["name"]) + assert.NotEmpty(t, fields["description"]) + + // The full document substitutes the dynamic version stamp and the generated command list. + assert.Contains(t, doc, "1.2.3-test") + assert.Contains(t, doc, "- `azldev component`") + assert.Contains(t, doc, "- `azldev docs`") +} + +func TestSkillDocumentUnknown(t *testing.T) { + _, err := agentskill.SkillDocument("not-a-real-skill", testParams()) + require.Error(t, err) +} + +func 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{} +} + +// 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") +} + +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) +} + +func TestFilesWrapper(t *testing.T) { + layout := agentskill.DefaultLayout() + + files, err := agentskill.Files(layout, testParams(), false) + require.NoError(t, err) + // One wrapper per skill, plus one instruction file per registered instruction. + require.Len(t, files, len(agentskill.Skills())+len(agentskill.Instructions())) + + for _, file := range files { + assert.Contains(t, file.Content, "Generated by `azldev docs agent`; do not hand-edit.", file.RelPath) + assert.False(t, strings.HasSuffix(file.Content, "\n\n"), "%s has a trailing blank line", file.RelPath) + } + + skill := fileByPath(t, files, layout.SkillFile(primarySkill(t))).Content + assert.Contains(t, skill, "name: "+agentskill.SkillName) + // The wrapper points at the read-only MCP tool and omits the full skill body. + assert.Contains(t, skill, agentskill.ShowSkillToolName) + assert.NotContains(t, skill, "Golden rules") + + // The azldev instruction wrapper applies to azldev.toml and points at the azldev skill by + // name (never the CLI/MCP tool, which may be unavailable in --full installs). + azldevInstruction := instructionByName(t, "azldev") + instructions := fileByPath(t, files, agentskill.InstructionFile(azldevInstruction)).Content + assert.Contains(t, instructions, `applyTo: "`+agentskill.ConfigGlob+`"`) + assert.Contains(t, instructions, "`"+agentskill.SkillName+"`") + assert.NotContains(t, instructions, agentskill.ShowSkillToolName) + assert.NotContains(t, instructions, "docs agent show") +} + +func TestFilesFull(t *testing.T) { + layout := agentskill.DefaultLayout() + + files, err := agentskill.Files(layout, testParams(), true) + require.NoError(t, err) + require.Len(t, files, len(agentskill.Skills())+len(agentskill.Instructions())) + + for _, file := range files { + assert.Contains(t, file.Content, "Generated by `azldev docs agent`; do not hand-edit.", file.RelPath) + } + + // In full mode each on-disk SKILL.md inlines the complete skill document. + assert.Contains(t, fileByPath(t, files, layout.SkillFile(primarySkill(t))).Content, "overlay system") +} + +func TestFilesGitHubLayout(t *testing.T) { + layout := agentskill.Layout{ + SkillsDir: ".github/skills", + } + + files, err := agentskill.Files(layout, testParams(), false) + require.NoError(t, err) + require.Len(t, files, len(agentskill.Skills())+len(agentskill.Instructions())) + + // The github layout places skills under .github/skills with their plain (namespaced) names. + azldevSkill := fileByPath(t, files, ".github/skills/azldev/SKILL.md") + assert.Contains(t, azldevSkill.Content, "name: azldev") +} 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 000000000..4f86eaaa7 --- /dev/null +++ b/internal/app/azldev/agentskill/content/azldev.md.tmpl @@ -0,0 +1,69 @@ +--- +name: {{ .Name }} +description: {{ .Description }} +--- + +# Using azldev + +azldev builds a Linux distribution from TOML configuration. It imports RPM specs +from an upstream distro and customizes them with an overlay system — no spec +forking. Components render to RPMs; images assemble RPMs into bootable artifacts. + +## Orient yourself + +- Run azldev from the repo root (where `azldev.toml` lives), or pass `-C `. +- Global, agent-friendly flags: **`-q`** (quiet) and **`-O json`** (machine-readable + output). They work on every command. +- Config is a stitched TOML hierarchy: `azldev.toml` includes the distro and project + config, which include the component files (`**/*.comp.toml`) into one namespace. + +## Core concepts + +- **Component** — a unit of packaging that renders to one or more RPMs. Its spec + source is upstream (default), a pinned upstream distro/version, or a local spec. +- **Overlay** — a semantic patch applied to a spec or source file at render time, so + you customize upstream without forking it. +- **Distro** — a named build target (`*.distro.toml`) with upstream URIs, release + versions, and build inputs. +- **Rendered spec** — the generated `.spec` after overlays; a build input, never + hand-edited. +- **Lock** — a per-component file pinning the resolved upstream commit and an input + fingerprint. + +## Common commands + +Top-level commands in this build: + +{{ range .TopLevelCommands }}- `azldev {{ .Name }}` — {{ .Short }} +{{ end }} +Everyday tasks (add `-q -O json` when scripting): + +| Task | Command | +| --- | --- | +| List components | `azldev comp list -a` | +| Inspect one component | `azldev comp list -p ` | +| Add a component | `azldev comp add` | +| Build a component | `azldev comp build -p ` | +| Render specs | `azldev comp render -p ` (or `-a`) | +| Refresh a lock | `azldev comp update -p ` | +| List / build images | `azldev image list` / `azldev image build` | + +`comp` is an alias for `component`. Always confirm current syntax with +`azldev --help`. + +The hidden `advanced` group (`adv`) contains specialist integrations such as MCP and +mock helpers; it is intentionally omitted from normal help. + +## Where to go next + +Start with `azldev --help` for the current command syntax and flags. + +## Golden rules + +- **Never edit generated output** — rendered specs and the output/work/log dirs are + produced by azldev. Change the source config and re-render. +- **Re-run `azldev comp update` before opening a PR** — the lock fingerprint covers + the whole component config, and lock / rendered-spec CI checks run on committed state. +- **Every overlay needs a `description`** explaining why the change is needed. + +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl b/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl new file mode 100644 index 000000000..0524ee152 --- /dev/null +++ b/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl @@ -0,0 +1,22 @@ +--- +description: {{ .Description }} +applyTo: "{{ .ApplyTo }}" +--- + +# {{ .Title }} + +{{ .Intro }} + +**Do not work from memory or guess.** Before authoring, editing, or reviewing content +in scope here, you MUST read the primary azldev skill below. It is the authoritative, +version-matched source for this file type. + +{{ with index .Skills 0 }}- You MUST read the `{{ .Skill }}` skill {{ .Purpose }}. +{{ end }} +{{ if gt (len .Skills) 1 -}} +Load only the additional workflow skill required by the task: + +{{ range $index, $pointer := .Skills }}{{ if $index }}- Read the `{{ $pointer.Skill }}` skill {{ $pointer.Purpose }}. +{{ end }}{{ end -}} +{{ end }} +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl b/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl new file mode 100644 index 000000000..b69275e68 --- /dev/null +++ b/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl @@ -0,0 +1,19 @@ +--- +name: {{ .Name }} +description: {{ .Description }} +--- + +# {{ .Name }} + +For the full, always-up-to-date content of this skill, call the read-only MCP tool +**`{{ .ShowSkillToolName }}`** with skill `{{ .Name }}` (provided by the azldev +MCP server). + +If the azldev MCP server is not configured, run `azldev docs agent show --skill {{ .Name }}` +to print the same content, or regenerate this file with the full skill inlined: + +```sh +azldev docs agent install --full +``` + +Generated by `azldev docs agent`; do not hand-edit. Generated for azldev version `{{ .Version }}`. diff --git a/internal/app/azldev/agentskill/doc.go b/internal/app/azldev/agentskill/doc.go new file mode 100644 index 000000000..18e3816c2 --- /dev/null +++ b/internal/app/azldev/agentskill/doc.go @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package agentskill is the single source of truth for the AI-agent skill and +// instruction files that describe how to use the azldev CLI. Everything is embedded +// in the binary, so the files written into a repo, the CLI output, and the MCP tool +// response are always in lock-step with the azldev version that produced them. +// +// # Mental model +// +// At heart this is "a few text files with find/replace": the text lives in +// content/*.md.tmpl, and the "replace" is Go text/template substitution of a small set +// of dynamic values (see Params). Two Go registries name the files and carry the bits +// that can't live in a single shared template: +// +// - skills — each Skill is {Name, Description, bodyTemplate}. The body template +// under content/ holds the real, substantive guidance for one topic. +// - instructions — each Instruction is {Name, ApplyTo, Description, Title, Intro, +// Skills}. An instruction is a lightweight, path-scoped pointer: its applyTo glob +// decides which files it covers, and it only tells the agent which skill(s) to read. +// +// Put substantive content in a skill; keep instructions thin. +// +// # Two renderings of every skill +// +// A skill can be emitted two ways from the same registry entry: +// +// - full body — the skill's own bodyTemplate (e.g. azldev.md.tmpl), the complete +// content. Used by 'docs agent show', the docs-agent-show MCP tool, and +// 'docs agent install --full'. +// - redirect wrapper — one generic skill-wrapper.md.tmpl for all skills, a thin +// SKILL.md that points the agent at the docs-agent-show MCP tool for always-fresh +// content. This is the default on-disk file 'docs agent install' writes. +// +// Because the redirect wrapper is generic, it cannot hard-code each skill's name and +// description in its front matter — they are handed to it as data. That is the only +// reason Skill.Description exists as a field rather than living in the template front +// matter: holding it once in the registry lets both the full body and +// the shared wrapper render the same name/description without duplicating the text. +// Instruction files work the same way: one shared instruction-wrapper.md.tmpl renders +// every instruction, so each instruction's Description/ApplyTo/etc. are data too. +// +// # Where a Description ends up +// +// renderSkill copies Skill.Description into the template data; the template (full body +// or shared wrapper) emits it as the YAML front-matter "description:" line, which agent +// runtimes read to decide whether to load the skill. renderInstruction does the same via +// the instruction wrapper. That front-matter line is the description's only destination. +// +// # Substituted values (Params) +// +// The {{ .Field }} placeholders in the templates are filled from Params, resolved by the +// 'docs agent' command: +// +// - Version — the azldev version stamped into every file. +// - TopLevelCommands — generated from the Cobra command tree, so the overview skill's +// command list never goes stale. +// - Bindings — repo-specific paths (LockDir, RenderedSpecsDir, WorkDir) read from +// the target azldev.toml, degrading to azldev's defaults when no config is present. +// +// # Outputs (three sinks, one registry) +// +// skills[] / instructions[] --render(Params)--> install --> write files into a repo +// + content/*.md.tmpl show --> print to stdout +// MCP --> docs-agent-show returns text +// +// All three enumerate the same registries, so the on-disk files, the CLI, and the MCP +// tool cannot drift from one another. +// +// # Maintenance +// +// The rules for adding or editing a skill/instruction — front-matter limits, drift-guard +// tests, and the mandatory "validate every claim against the current code" step — live in +// .github/instructions/agent-skills.instructions.md. +package agentskill From e772dcd6cf33fb88eaa43488bb8aefd2a16c586b Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Tue, 14 Jul 2026 13:18:24 -0700 Subject: [PATCH 2/6] fixup! feat(ai): add agent skill rendering core --- internal/app/azldev/agentskill/content/azldev.md.tmpl | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/app/azldev/agentskill/content/azldev.md.tmpl b/internal/app/azldev/agentskill/content/azldev.md.tmpl index 4f86eaaa7..4d757a198 100644 --- a/internal/app/azldev/agentskill/content/azldev.md.tmpl +++ b/internal/app/azldev/agentskill/content/azldev.md.tmpl @@ -7,7 +7,8 @@ description: {{ .Description }} 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. +forking. Components render to specs and sidecar build inputs, then build into RPMs; +images assemble RPMs into bootable artifacts. ## Orient yourself @@ -19,8 +20,9 @@ forking. Components render to RPMs; images assemble RPMs into bootable artifacts ## 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. +- **Component** — a unit of packaging that renders to a spec and sidecar build inputs, + then builds into 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 From 5e69bd95aa68900bde359b76f040c7787e88cbd8 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Wed, 15 Jul 2026 16:07:51 -0700 Subject: [PATCH 3/6] fixup! feat(ai): add agent skill rendering core --- .../instructions/agent-skills.instructions.md | 7 ++--- internal/app/azldev/agentskill/agentskill.go | 12 ++++--- .../app/azldev/agentskill/agentskill_test.go | 31 +++++++++++++++++++ .../azldev/agentskill/content/azldev.md.tmpl | 2 +- .../content/instruction-wrapper.md.tmpl | 4 +-- .../agentskill/content/skill-wrapper.md.tmpl | 2 +- 6 files changed, 46 insertions(+), 12 deletions(-) diff --git a/.github/instructions/agent-skills.instructions.md b/.github/instructions/agent-skills.instructions.md index fd5c60735..226977759 100644 --- a/.github/instructions/agent-skills.instructions.md +++ b/.github/instructions/agent-skills.instructions.md @@ -36,8 +36,7 @@ and how dynamic values are substituted — read the package doc in 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. + then a `Triggers include ...` keyword list. 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` @@ -50,9 +49,9 @@ and how dynamic values are substituted — read the package doc in 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 +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. +4. Keep the count-based tests happy: `Files()` emits one file per skill plus one per instruction. ## Content accuracy is the hard part diff --git a/internal/app/azldev/agentskill/agentskill.go b/internal/app/azldev/agentskill/agentskill.go index a42d5f072..a9cb89b9f 100644 --- a/internal/app/azldev/agentskill/agentskill.go +++ b/internal/app/azldev/agentskill/agentskill.go @@ -8,6 +8,7 @@ import ( "embed" "fmt" "path" + "slices" "text/template" ) @@ -47,7 +48,6 @@ type Skill struct { 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. @@ -73,7 +73,7 @@ var skills = []Skill{ // Skills returns the registered skills in emission order. func Skills() []Skill { - return skills + return slices.Clone(skills) } // FindSkill returns the registered skill with the given name. @@ -115,7 +115,6 @@ type Instruction struct { 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. @@ -151,7 +150,12 @@ var instructions = []Instruction{ // Instructions returns the registered instruction files in emission order. func Instructions() []Instruction { - return instructions + result := slices.Clone(instructions) + for i := range result { + result[i].Skills = slices.Clone(result[i].Skills) + } + + return result } // Layout controls where emitted skill files are written in a target repository. diff --git a/internal/app/azldev/agentskill/agentskill_test.go b/internal/app/azldev/agentskill/agentskill_test.go index 051919370..4274c2cee 100644 --- a/internal/app/azldev/agentskill/agentskill_test.go +++ b/internal/app/azldev/agentskill/agentskill_test.go @@ -66,6 +66,7 @@ func TestSkillDocument(t *testing.T) { assert.Equal(t, agentskill.SkillName, fields["name"]) assert.NotEmpty(t, fields["description"]) + assert.Contains(t, doc, `description: "`) // The full document substitutes the dynamic version stamp and the generated command list. assert.Contains(t, doc, "1.2.3-test") @@ -147,6 +148,34 @@ func TestSkillsRegistry(t *testing.T) { assert.Contains(t, names, agentskill.SkillName) } +func TestRegistryAccessorsReturnCopies(t *testing.T) { + const mutated = "mutated" + + skills := agentskill.Skills() + require.NotEmpty(t, skills) + originalSkillName := skills[0].Name + skills[0].Name = mutated + actualSkillName := agentskill.Skills()[0].Name + skills[0].Name = originalSkillName + assert.Equal(t, originalSkillName, actualSkillName) + + instructions := agentskill.Instructions() + require.NotEmpty(t, instructions) + require.NotEmpty(t, instructions[0].Skills) + originalInstructionName := instructions[0].Name + originalPointerSkill := instructions[0].Skills[0].Skill + instructions[0].Name = mutated + instructions[0].Skills[0].Skill = mutated + actualInstructions := agentskill.Instructions() + actualInstructionName := actualInstructions[0].Name + actualPointerSkill := actualInstructions[0].Skills[0].Skill + instructions[0].Name = originalInstructionName + instructions[0].Skills[0].Skill = originalPointerSkill + + assert.Equal(t, originalInstructionName, actualInstructionName) + assert.Equal(t, originalPointerSkill, actualPointerSkill) +} + func TestFilesWrapper(t *testing.T) { layout := agentskill.DefaultLayout() @@ -162,6 +191,7 @@ func TestFilesWrapper(t *testing.T) { skill := fileByPath(t, files, layout.SkillFile(primarySkill(t))).Content assert.Contains(t, skill, "name: "+agentskill.SkillName) + assert.Contains(t, skill, `description: "`) // 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") @@ -170,6 +200,7 @@ func TestFilesWrapper(t *testing.T) { // name (never the CLI/MCP tool, which may be unavailable in --full installs). azldevInstruction := instructionByName(t, "azldev") instructions := fileByPath(t, files, agentskill.InstructionFile(azldevInstruction)).Content + assert.Contains(t, instructions, `description: "`) assert.Contains(t, instructions, `applyTo: "`+agentskill.ConfigGlob+`"`) assert.Contains(t, instructions, "`"+agentskill.SkillName+"`") assert.NotContains(t, instructions, agentskill.ShowSkillToolName) diff --git a/internal/app/azldev/agentskill/content/azldev.md.tmpl b/internal/app/azldev/agentskill/content/azldev.md.tmpl index 4d757a198..341745326 100644 --- a/internal/app/azldev/agentskill/content/azldev.md.tmpl +++ b/internal/app/azldev/agentskill/content/azldev.md.tmpl @@ -1,6 +1,6 @@ --- name: {{ .Name }} -description: {{ .Description }} +description: {{ printf "%q" .Description }} --- # Using azldev diff --git a/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl b/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl index 0524ee152..65f58e002 100644 --- a/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl +++ b/internal/app/azldev/agentskill/content/instruction-wrapper.md.tmpl @@ -1,6 +1,6 @@ --- -description: {{ .Description }} -applyTo: "{{ .ApplyTo }}" +description: {{ printf "%q" .Description }} +applyTo: {{ printf "%q" .ApplyTo }} --- # {{ .Title }} diff --git a/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl b/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl index b69275e68..752bbf097 100644 --- a/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl +++ b/internal/app/azldev/agentskill/content/skill-wrapper.md.tmpl @@ -1,6 +1,6 @@ --- name: {{ .Name }} -description: {{ .Description }} +description: {{ printf "%q" .Description }} --- # {{ .Name }} From d0f9fc483892d0bc60a595e1160db51a0cce91c6 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Thu, 16 Jul 2026 09:13:30 -0700 Subject: [PATCH 4/6] fixup! feat(ai): add agent skill rendering core --- internal/app/azldev/agentskill/agentskill.go | 19 ++++++++ .../agentskill/agentskill_internal_test.go | 43 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 internal/app/azldev/agentskill/agentskill_internal_test.go diff --git a/internal/app/azldev/agentskill/agentskill.go b/internal/app/azldev/agentskill/agentskill.go index a9cb89b9f..d6eb9c18f 100644 --- a/internal/app/azldev/agentskill/agentskill.go +++ b/internal/app/azldev/agentskill/agentskill.go @@ -258,6 +258,10 @@ func renderSkill(templateName string, skill Skill, params Params) (string, error } func renderInstruction(inst Instruction, params Params) (string, error) { + if err := validateInstruction(inst); err != nil { + return "", err + } + 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) @@ -282,6 +286,21 @@ func renderInstruction(inst Instruction, params Params) (string, error) { return buf.String(), nil } +func validateInstruction(inst Instruction) error { + if len(inst.Skills) == 0 { + return fmt.Errorf("instruction %#q must reference at least one skill", inst.Name) + } + + for _, pointer := range inst.Skills { + if _, err := FindSkill(pointer.Skill); err != nil { + return fmt.Errorf("instruction %#q references unknown skill %#q:\n%w", + inst.Name, pointer.Skill, err) + } + } + + return 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) { diff --git a/internal/app/azldev/agentskill/agentskill_internal_test.go b/internal/app/azldev/agentskill/agentskill_internal_test.go new file mode 100644 index 000000000..1231690c6 --- /dev/null +++ b/internal/app/azldev/agentskill/agentskill_internal_test.go @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package agentskill + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderInstructionRejectsInvalidSkillPointers(t *testing.T) { + tests := []struct { + name string + skills []SkillPointer + errorContains string + }{ + { + name: "no skill pointers", + errorContains: "must reference at least one skill", + }, + { + name: "unknown skill", + skills: []SkillPointer{ + {Skill: "not-a-real-skill", Purpose: "for testing"}, + }, + errorContains: "references unknown skill `not-a-real-skill`", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := renderInstruction(Instruction{ + Name: "test-instruction", + Skills: test.skills, + }, Params{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), test.errorContains) + }) + } +} From 4f167b1d0b4da853f5cbb10c04af7506f11f4575 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Thu, 16 Jul 2026 16:27:12 -0700 Subject: [PATCH 5/6] fixup! feat(ai): add agent skill rendering core --- internal/app/azldev/agentskill/agentskill_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/app/azldev/agentskill/agentskill_test.go b/internal/app/azldev/agentskill/agentskill_test.go index 4274c2cee..b6b34c804 100644 --- a/internal/app/azldev/agentskill/agentskill_test.go +++ b/internal/app/azldev/agentskill/agentskill_test.go @@ -66,7 +66,6 @@ func TestSkillDocument(t *testing.T) { assert.Equal(t, agentskill.SkillName, fields["name"]) assert.NotEmpty(t, fields["description"]) - assert.Contains(t, doc, `description: "`) // The full document substitutes the dynamic version stamp and the generated command list. assert.Contains(t, doc, "1.2.3-test") @@ -87,6 +86,7 @@ func TestSkillFrontmatterInvariants(t *testing.T) { t.Run(skill.Name, func(t *testing.T) { doc, err := agentskill.SkillDocument(skill.Name, testParams()) require.NoError(t, err) + assert.Contains(t, doc, `description: "`, "skill description must be quoted YAML") fields := parseFrontmatter(t, doc) From 98808497652cc9faaff5ba07fbd252339ca9e187 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Thu, 16 Jul 2026 16:45:57 -0700 Subject: [PATCH 6/6] fixup! feat(ai): add agent skill rendering core --- internal/app/azldev/agentskill/agentskill_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/app/azldev/agentskill/agentskill_test.go b/internal/app/azldev/agentskill/agentskill_test.go index b6b34c804..cd5ef9417 100644 --- a/internal/app/azldev/agentskill/agentskill_test.go +++ b/internal/app/azldev/agentskill/agentskill_test.go @@ -86,7 +86,7 @@ func TestSkillFrontmatterInvariants(t *testing.T) { t.Run(skill.Name, func(t *testing.T) { doc, err := agentskill.SkillDocument(skill.Name, testParams()) require.NoError(t, err) - assert.Contains(t, doc, `description: "`, "skill description must be quoted YAML") + assert.Contains(t, doc, `description: "`, "skill description must be quoted YAML") fields := parseFrontmatter(t, doc)