Skip to content

Commit 57cc701

Browse files
Copilotddstreet
authored andcommitted
docs(agentskill): describe lock-file-free mode in agent skills
Emit agent skills and instruction files that match the mode azldev is running in. A catalog selects the content: the default mode keeps the lock-file workflow and the azldev-update-component skill, while lock-file-free mode documents the generated upstream-commit workflow and the azldev-refresh-upstream-commit skill. Skills, instruction wrappers, and the emitted layout are shared. Only the documents whose subject differs get a mode-specific template, layered over the default template of the same name, and a small registry replaces the skill entries and instruction trigger text that name the command. The bindings injected into the content now carry both the lock directory and the generated upstream-commit directory. 'docs agent install' and 'docs agent show' select the catalog from the environment, so the emitted files, the served skill documents, and the read-only MCP tool all describe the commands the user actually has. Refs: microsoft#323
1 parent 7b82ea0 commit 57cc701

17 files changed

Lines changed: 1199 additions & 71 deletions

.github/instructions/agent-skills.instructions.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,32 @@ Before shipping any skill/instruction:
6565
`TestOverlaysSkillCoversAllOverlayTypes` extracts the overlay-type enum from the jsonschema tag
6666
on `projectconfig.ComponentOverlay.Type` and fails if the skill omits a type.
6767

68+
## Mode-specific content
69+
70+
azldev has two modes, and the emitted content follows the one it runs in. A `Catalog`
71+
(`NewCatalog(withoutLockfile)`) resolves the skills, instruction wrappers, and templates;
72+
the package-level `Skills`, `Instructions`, `FindSkill`, `SkillDocument`, and `Files`
73+
helpers are the default (lock-file) mode.
74+
75+
- **Shared by default.** Registry entries and templates under `content/` describe the
76+
default mode and are used by both, so most edits need nothing extra.
77+
- **Replace only what differs.** `withoutLockfileSkills` replaces registry entries by the
78+
name of the default-mode skill it supersedes, and `withoutLockfileInstructions` replaces
79+
instruction descriptions. Pointers to a replaced skill are rewritten automatically.
80+
- **Templates layer.** A template under `content/withoutlockfile/` replaces the
81+
same-named default template for that mode; add one only when the document's content
82+
actually differs.
83+
84+
When you add or edit a skill, check whether its content names a command that exists in
85+
only one mode (for example `comp update` versus `comp refresh-upstream-commit`) and, if
86+
so, provide the mode-specific variant. Verify both with
87+
`./out/bin/azldev docs agent show --skill <name>` and
88+
`./out/bin/azldev --without-lockfile docs agent show --skill <name>`.
89+
6890
## Config-resolved bindings
6991

70-
Repo-specific values (lock dir, rendered-specs dir, work dir) are resolved from the target `azldev.toml` in
92+
Repo-specific values (lock dir, generated upstream-commit dir, rendered-specs dir, work
93+
dir) are resolved from the target `azldev.toml` in
7194
[cmds/docs/agent.go](../../internal/app/azldev/cmds/docs/agent.go) and degrade to azldev's defaults
7295
when no config is present. To add a binding, extend `Bindings`, resolve it in `resolveBindings`, and
7396
reference it in a template as `{{ .FieldName }}`.

internal/app/azldev/agentskill/agentskill.go

Lines changed: 180 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010
"path"
1111
"slices"
12+
"strings"
1213
"text/template"
1314
)
1415

@@ -31,16 +32,27 @@ const (
3132
)
3233

3334
// The embedded templates rendered into the emitted files and the served skill
34-
// documents.
35+
// documents. Templates under 'content/withoutlockfile' replace the same-named
36+
// template when lock-file-free mode is selected.
3537
//
36-
//go:embed content/*.tmpl
38+
//go:embed content/*.tmpl content/withoutlockfile/*.tmpl
3739
var content embed.FS
3840

39-
// templates holds all parsed templates, keyed by their base file name.
41+
// templates holds all parsed templates for azldev's default mode, keyed by their
42+
// base file name.
4043
//
4144
//nolint:gochecknoglobals // parsed templates are effectively constant and safe for concurrent use.
4245
var templates = template.Must(template.ParseFS(content, "content/*.tmpl"))
4346

47+
// withoutLockfileTemplates holds the same templates with the lock-file-free
48+
// variants layered on top: parsing a template whose base name already exists
49+
// replaces it, so only the differing documents need their own file.
50+
//
51+
//nolint:gochecknoglobals // parsed templates are effectively constant and safe for concurrent use.
52+
var withoutLockfileTemplates = template.Must(template.ParseFS(
53+
content, "content/*.tmpl", "content/withoutlockfile/*.tmpl",
54+
))
55+
4456
// Skill describes a single emitted Agent Skill.
4557
type Skill struct {
4658
// Name is the stable base identifier (lowercase, hyphen-delimited). It is the
@@ -156,14 +168,81 @@ var skills = []Skill{
156168
},
157169
}
158170

159-
// Skills returns the registered skills in emission order.
160-
func Skills() []Skill {
161-
return slices.Clone(skills)
171+
// withoutLockfileSkills replaces registry entries when lock-file-free mode is
172+
// selected, keyed by the name of the default-mode skill it replaces. Everything
173+
// else — order, remaining skills, and the emitted layout — is shared.
174+
//
175+
//nolint:gochecknoglobals // effectively a constant registry of the mode's skills.
176+
var withoutLockfileSkills = map[string]Skill{
177+
"azldev": {
178+
Name: "azldev",
179+
Description: "Read this before running azldev or editing azldev config, and whenever working " +
180+
"in a repo that contains an azldev.toml file; do not guess azldev's commands or config. " +
181+
"Explains how to use the azldev CLI to build a distro from TOML config, including the core " +
182+
"concepts (components, overlays, distros, rendered specs, upstream commit config), running " +
183+
"azldev (repo root or -C, plus the -q and -O json flags), the common commands, and where to " +
184+
"go for each workflow. Triggers include azldev, comp build, comp render, " +
185+
"comp refresh-upstream-commit, build a component, add a component, distro config.",
186+
bodyTemplate: "azldev.md.tmpl",
187+
},
188+
"azldev-update-component": {
189+
Name: "azldev-refresh-upstream-commit",
190+
Description: "Read this before finalizing a component change, changing source resolution, or " +
191+
"editing generated upstream commit TOML by hand. Explains how to refresh commits with " +
192+
"'azldev comp refresh-upstream-commit', covering when to refresh versus render, the " +
193+
"update/render/commit/re-render/amend workflow, and per-component versus -a refresh. " +
194+
"Triggers include comp refresh-upstream-commit, refresh upstream commit, bump pin, change " +
195+
"snapshot, upstream distro, commit drift, version bump, finalize component.",
196+
bodyTemplate: "refresh-upstream-commit.md.tmpl",
197+
},
162198
}
163199

164-
// FindSkill returns the registered skill with the given name.
165-
func FindSkill(name string) (Skill, error) {
166-
for _, skill := range skills {
200+
// updateComponentSkillName is the default mode's finalization skill; lock-file-free
201+
// mode replaces it with the refresh-upstream-commit skill.
202+
const updateComponentSkillName = "azldev-update-component"
203+
204+
// Catalog exposes the skills and instruction files for one of azldev's modes. The
205+
// registries are shared; only the documents and pointers that describe how resolved
206+
// component state is maintained differ.
207+
type Catalog struct {
208+
withoutLockfile bool
209+
}
210+
211+
// NewCatalog returns the catalog for the selected mode. Pass true to describe
212+
// lock-file-free mode, as selected by the global '--without-lockfile' flag.
213+
func NewCatalog(withoutLockfile bool) Catalog {
214+
return Catalog{withoutLockfile: withoutLockfile}
215+
}
216+
217+
// templates returns the parsed template set for the catalog's mode.
218+
func (c Catalog) templates() *template.Template {
219+
if c.withoutLockfile {
220+
return withoutLockfileTemplates
221+
}
222+
223+
return templates
224+
}
225+
226+
// Skills returns the catalog's skills in emission order.
227+
func (c Catalog) Skills() []Skill {
228+
result := slices.Clone(skills)
229+
230+
if !c.withoutLockfile {
231+
return result
232+
}
233+
234+
for idx := range result {
235+
if replacement, ok := withoutLockfileSkills[result[idx].Name]; ok {
236+
result[idx] = replacement
237+
}
238+
}
239+
240+
return result
241+
}
242+
243+
// FindSkill returns the catalog's skill with the given name.
244+
func (c Catalog) FindSkill(name string) (Skill, error) {
245+
for _, skill := range c.Skills() {
167246
if skill.Name == name {
168247
return skill, nil
169248
}
@@ -172,6 +251,48 @@ func FindSkill(name string) (Skill, error) {
172251
return Skill{}, fmt.Errorf("unknown skill %#q", name)
173252
}
174253

254+
// Instructions returns the catalog's instruction files in emission order.
255+
func (c Catalog) Instructions() []Instruction {
256+
result := slices.Clone(instructions)
257+
for idx := range result {
258+
result[idx].Skills = slices.Clone(result[idx].Skills)
259+
}
260+
261+
if !c.withoutLockfile {
262+
return result
263+
}
264+
265+
refresh := withoutLockfileSkills[updateComponentSkillName]
266+
267+
for instIdx := range result {
268+
if replacement, ok := withoutLockfileInstructions[result[instIdx].Name]; ok {
269+
result[instIdx].Description = replacement.Description
270+
}
271+
272+
for skillIdx := range result[instIdx].Skills {
273+
pointer := &result[instIdx].Skills[skillIdx]
274+
if pointer.Skill != updateComponentSkillName {
275+
continue
276+
}
277+
278+
pointer.Skill = refresh.Name
279+
pointer.Purpose = strings.ReplaceAll(pointer.Purpose, "lock", "upstream commit")
280+
}
281+
}
282+
283+
return result
284+
}
285+
286+
// Skills returns the registered skills in emission order for azldev's default mode.
287+
func Skills() []Skill {
288+
return NewCatalog(false).Skills()
289+
}
290+
291+
// FindSkill returns the registered skill with the given name in azldev's default mode.
292+
func FindSkill(name string) (Skill, error) {
293+
return NewCatalog(false).FindSkill(name)
294+
}
295+
175296
// SkillPointer names a skill an instruction file points at, together with a short
176297
// purpose describing when to read it ("read the `azldev-overlays` skill to add or change
177298
// overlays").
@@ -268,14 +389,24 @@ var instructions = []Instruction{
268389
},
269390
}
270391

271-
// Instructions returns the registered instruction files in emission order.
272-
func Instructions() []Instruction {
273-
result := slices.Clone(instructions)
274-
for i := range result {
275-
result[i].Skills = slices.Clone(result[i].Skills)
276-
}
392+
// withoutLockfileInstructions replaces instruction descriptions when lock-file-free
393+
// mode is selected, keyed by instruction name. Skill pointers are rewritten
394+
// automatically, so only the free-form trigger text lives here.
395+
//
396+
//nolint:gochecknoglobals // effectively a constant registry of the mode's instructions.
397+
var withoutLockfileInstructions = map[string]Instruction{
398+
SkillName: {
399+
Description: "This repo is an azldev distro project (azldev.toml present). Before running azldev " +
400+
"or editing its config, load the azldev skill; do not guess azldev's commands or config. " +
401+
"Triggers include azldev, comp build, comp render, comp refresh-upstream-commit, build a " +
402+
"component, add a component, distro config.",
403+
},
404+
}
277405

278-
return result
406+
// Instructions returns the registered instruction files in emission order for
407+
// azldev's default mode.
408+
func Instructions() []Instruction {
409+
return NewCatalog(false).Instructions()
279410
}
280411

281412
// Layout controls where emitted skill files are written in a target repository.
@@ -322,8 +453,13 @@ type Command struct {
322453
// accurate for a default project even with no configuration present.
323454
type Bindings struct {
324455
// LockDir is the repo-relative directory holding per-component lock files.
456+
// Only meaningful in azldev's default mode.
325457
LockDir string
326458

459+
// UpstreamCommitsDir is the repo-relative directory holding the generated
460+
// per-component commit TOMLs. Only meaningful in lock-file-free mode.
461+
UpstreamCommitsDir string
462+
327463
// RenderedSpecsDir is the repo-relative directory holding rendered component specs.
328464
RenderedSpecsDir string
329465

@@ -356,7 +492,7 @@ type EmittedFile struct {
356492
Content string `json:"-"`
357493
}
358494

359-
func renderSkill(templateName string, skill Skill, params Params) (string, error) {
495+
func (c Catalog) renderSkill(templateName string, skill Skill, params Params) (string, error) {
360496
var buf bytes.Buffer
361497

362498
data := struct {
@@ -369,16 +505,16 @@ func renderSkill(templateName string, skill Skill, params Params) (string, error
369505
ShowSkillToolName: ShowSkillToolName,
370506
}
371507

372-
err := templates.ExecuteTemplate(&buf, templateName, data)
508+
err := c.templates().ExecuteTemplate(&buf, templateName, data)
373509
if err != nil {
374510
return "", fmt.Errorf("failed to render agent skill template %#q:\n%w", templateName, err)
375511
}
376512

377513
return buf.String(), nil
378514
}
379515

380-
func renderInstruction(inst Instruction, params Params) (string, error) {
381-
if err := validateInstruction(inst); err != nil {
516+
func (c Catalog) renderInstruction(inst Instruction, params Params) (string, error) {
517+
if err := c.validateInstruction(inst); err != nil {
382518
return "", err
383519
}
384520

@@ -398,21 +534,21 @@ func renderInstruction(inst Instruction, params Params) (string, error) {
398534
Instruction: inst,
399535
}
400536

401-
err = templates.ExecuteTemplate(&buf, "instruction-wrapper.md.tmpl", data)
537+
err = c.templates().ExecuteTemplate(&buf, "instruction-wrapper.md.tmpl", data)
402538
if err != nil {
403539
return "", fmt.Errorf("failed to render instruction template for %#q:\n%w", inst.Name, err)
404540
}
405541

406542
return buf.String(), nil
407543
}
408544

409-
func validateInstruction(inst Instruction) error {
545+
func (c Catalog) validateInstruction(inst Instruction) error {
410546
if len(inst.Skills) == 0 {
411547
return fmt.Errorf("instruction %#q must reference at least one skill", inst.Name)
412548
}
413549

414550
for _, pointer := range inst.Skills {
415-
if _, err := FindSkill(pointer.Skill); err != nil {
551+
if _, err := c.FindSkill(pointer.Skill); err != nil {
416552
return fmt.Errorf("instruction %#q references unknown skill %#q:\n%w",
417553
inst.Name, pointer.Skill, err)
418554
}
@@ -442,39 +578,46 @@ func renderInline(name, text string, params Params) (string, error) {
442578
// SkillDocument renders the full document for the named skill. It is served
443579
// verbatim by the read-only MCP tool and by 'azldev docs agent show'. The default
444580
// layout is used since a served document has no on-disk directory.
445-
func SkillDocument(name string, params Params) (string, error) {
446-
skill, err := FindSkill(name)
581+
func (c Catalog) SkillDocument(name string, params Params) (string, error) {
582+
skill, err := c.FindSkill(name)
447583
if err != nil {
448584
return "", err
449585
}
450586

451-
return renderSkill(skill.bodyTemplate, skill, params)
587+
return c.renderSkill(skill.bodyTemplate, skill, params)
588+
}
589+
590+
// SkillDocument renders the named skill's document for azldev's default mode.
591+
func SkillDocument(name string, params Params) (string, error) {
592+
return NewCatalog(false).SkillDocument(name, params)
452593
}
453594

454595
// Files renders the set of agent files to write into a target repository using the
455596
// given layout. When full is true, each on-disk SKILL.md contains the complete
456597
// skill document instead of a light MCP wrapper (useful when the azldev MCP server
457598
// is not available in the target environment). Instruction files are always light
458599
// wrappers that point at the relevant skills.
459-
func Files(layout Layout, params Params, full bool) ([]EmittedFile, error) {
460-
files := make([]EmittedFile, 0, len(skills)+len(instructions))
600+
func (c Catalog) Files(layout Layout, params Params, full bool) ([]EmittedFile, error) {
601+
catalogSkills := c.Skills()
602+
catalogInstructions := c.Instructions()
603+
files := make([]EmittedFile, 0, len(catalogSkills)+len(catalogInstructions))
461604

462-
for _, skill := range skills {
605+
for _, skill := range catalogSkills {
463606
templateName := "skill-wrapper.md.tmpl"
464607
if full {
465608
templateName = skill.bodyTemplate
466609
}
467610

468-
rendered, err := renderSkill(templateName, skill, params)
611+
rendered, err := c.renderSkill(templateName, skill, params)
469612
if err != nil {
470613
return nil, err
471614
}
472615

473616
files = append(files, EmittedFile{RelPath: layout.SkillFile(skill), Content: rendered})
474617
}
475618

476-
for _, inst := range instructions {
477-
rendered, err := renderInstruction(inst, params)
619+
for _, inst := range catalogInstructions {
620+
rendered, err := c.renderInstruction(inst, params)
478621
if err != nil {
479622
return nil, err
480623
}
@@ -484,3 +627,8 @@ func Files(layout Layout, params Params, full bool) ([]EmittedFile, error) {
484627

485628
return files, nil
486629
}
630+
631+
// Files renders the agent files for azldev's default mode.
632+
func Files(layout Layout, params Params, full bool) ([]EmittedFile, error) {
633+
return NewCatalog(false).Files(layout, params, full)
634+
}

internal/app/azldev/agentskill/agentskill_internal_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func TestRenderInstructionRejectsInvalidSkillPointers(t *testing.T) {
3131

3232
for _, test := range tests {
3333
t.Run(test.name, func(t *testing.T) {
34-
_, err := renderInstruction(Instruction{
34+
_, err := NewCatalog(false).renderInstruction(Instruction{
3535
Name: "test-instruction",
3636
Skills: test.skills,
3737
}, Params{})

0 commit comments

Comments
 (0)