From ac8cb19be31fc98a17fac31f0e10d48b912f0024 Mon Sep 17 00:00:00 2001 From: samzong Date: Thu, 25 Jun 2026 09:20:02 -0400 Subject: [PATCH] feat(skill): add include file policies Signed-off-by: samzong --- docs/cli-usage.md | 38 ++- internal/codegen/render/skill.go | 353 ++++++++++++++++++++++---- internal/codegen/render/skill_test.go | 100 -------- internal/lathecmd/lathecmd.go | 103 ++++++-- 4 files changed, 403 insertions(+), 191 deletions(-) diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 02de115..97aff59 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -85,8 +85,19 @@ skill: The optional `skill.root` value controls where generated Skill files are written; set it to `""` to disable Skill generation. The optional `skill.include` value -points at repo-local Skill resources merged into generated Skill files. Keep the -include directory outside `skill.root`. +points at repo-local Skill resources merged into generated Skill files. The +include path must stay outside `skill.root`. For per-file policy, use object +form: + +```yaml +skill: + root: skills + include: + path: internal/skill-include + files: + agents/openai.yaml: replace + references/modules/acme.md: omit +``` Generated CLIs expose `--version` and `-v`. The release version is build metadata, not `cli.yaml` data: pass `Version`, `Commit`, and `Date` through @@ -228,15 +239,20 @@ lathe codegen -skill-include internal/skill-include ``` `-skill-root` and `-skill-include` override the `skill.root` and `skill.include` -values from `cli.yaml` for one run. Prefer `cli.yaml` for reproducible generated -Skill output. When `skill.include` or `-skill-include` is set, the directory must -already exist and must be outside `skill.root`. Files under the include directory -are mapped by relative path onto `//`. Include files -may target `SKILL.md`, `references/`, `scripts/`, `assets/`, and `agents/`. -`SKILL.md` and existing `references/**/*.md` targets are appended after a blank -line. New files under the allowed Skill resource directories are created. Existing -generated non-markdown files, such as `agents/openai.yaml`, are not overwritten -or appended. +path from `cli.yaml` for one run. Prefer `cli.yaml` for reproducible generated +Skill output. When an include path is set, the directory must already exist and +must be outside `skill.root`. Files under the include directory are mapped by +relative path onto `//`. Include files may target +`SKILL.md`, `references/`, `scripts/`, `assets/`, and `agents/`. +By default, `SKILL.md` and existing `references/**/*.md` targets are appended +after a blank line; new files under the allowed Skill resource directories are +created. Object-form `skill.include.files` can set a path to `append`, `create`, +`replace`, or `omit`. `replace` requires a same-path include file and an existing +generated target. `omit` removes eligible generated files such as +`agents/openai.yaml` or `references/modules/*.md`; omitted module references are +also removed from generated `SKILL.md`. `SKILL.md` may be appended or replaced, +but not omitted. `.lathe-skill`, dotfiles, path traversal, and symlinks are +rejected. Generated outputs: diff --git a/internal/codegen/render/skill.go b/internal/codegen/render/skill.go index f870ae5..07e0e39 100644 --- a/internal/codegen/render/skill.go +++ b/internal/codegen/render/skill.go @@ -3,6 +3,7 @@ package render import ( "encoding/json" "fmt" + "io/fs" "os" "path/filepath" "sort" @@ -28,7 +29,34 @@ type moduleRef struct { const skillOwnerFile = ".lathe-skill" +type SkillFileAction string + +const ( + SkillFileAppend SkillFileAction = "append" + SkillFileCreate SkillFileAction = "create" + SkillFileReplace SkillFileAction = "replace" + SkillFileOmit SkillFileAction = "omit" +) + +type SkillInclude struct { + Path string + Files map[string]SkillFileAction +} + +type skillFile struct { + body []byte + mode fs.FileMode +} + func RenderSkillDirectory(root string, manifest *config.Manifest, modules []SkillModule) error { + return renderSkillDirectory(root, manifest, modules, SkillInclude{}) +} + +func RenderSkillDirectoryWithInclude(root string, manifest *config.Manifest, modules []SkillModule, include SkillInclude) error { + return renderSkillDirectory(root, manifest, modules, include) +} + +func renderSkillDirectory(root string, manifest *config.Manifest, modules []SkillModule, include SkillInclude) error { clean := filepath.Clean(root) if root == "" || clean == "." || clean == string(filepath.Separator) { return fmt.Errorf("invalid skill output directory %q", root) @@ -36,32 +64,28 @@ func RenderSkillDirectory(root string, manifest *config.Manifest, modules []Skil if err := verifySkillDirectoryOwned(clean); err != nil { return err } - refs, err := moduleReferences(manifest.CLI.CommandPath, modules) + include, err := normalizeSkillInclude(include) if err != nil { return err } - if err := os.RemoveAll(clean); err != nil { + if err := ValidateSkillIncludeRoot(filepath.Dir(clean), include.Path); err != nil { return err } - if err := os.MkdirAll(filepath.Join(clean, "agents"), 0o755); err != nil { + allRefs, err := moduleReferences(manifest.CLI.CommandPath, modules) + if err != nil { return err } - if err := os.MkdirAll(filepath.Join(clean, "references", "modules"), 0o755); err != nil { + allGenerated := renderSkillFiles(manifest, allRefs) + if err := validateSkillIncludePolicy(include, allGenerated); err != nil { return err } - files := map[string]string{ - filepath.Join(clean, "SKILL.md"): renderSkillMD(manifest, refs), - filepath.Join(clean, skillOwnerFile): "generated by lathe codegen; safe to remove and regenerate\n", - filepath.Join(clean, "agents", "openai.yaml"): renderOpenAIYAML(manifest), - filepath.Join(clean, "references", "catalog.md"): renderCatalogReference(manifest), - } - for _, ref := range refs { - files[filepath.Join(clean, "references", "modules", ref.File)] = renderModuleReference(manifest, ref.Module, ref.Flat) + refs := filterOmittedModuleRefs(allRefs, include) + files := renderSkillFiles(manifest, refs) + if err := mergeSkillIncludes(files, allGenerated, include); err != nil { + return err } - for path, body := range files { - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - return err - } + if err := writeSkillDirectory(clean, files); err != nil { + return err } fmt.Fprintf(os.Stderr, "wrote %s: %d modules\n", clean, len(refs)) return nil @@ -81,6 +105,9 @@ func ValidateSkillIncludeRoot(skillRoot, includeRoot string) error { if !info.IsDir() { return fmt.Errorf("skill include root %q is not a directory", includeRoot) } + if skillRoot == "" { + return nil + } absRoot, err := filepath.Abs(skillRoot) if err != nil { return err @@ -95,26 +122,186 @@ func ValidateSkillIncludeRoot(skillRoot, includeRoot string) error { return nil } -func ApplySkillIncludes(skillDir, includeRoot string) error { - if includeRoot == "" { - return nil +func renderSkillFiles(manifest *config.Manifest, refs []moduleRef) map[string]skillFile { + files := map[string]skillFile{ + "SKILL.md": newSkillFile(renderSkillMD(manifest, refs)), + skillOwnerFile: newSkillFile("generated by lathe codegen; safe to remove and regenerate\n"), + "agents/openai.yaml": newSkillFile(renderOpenAIYAML(manifest)), + "references/catalog.md": newSkillFile(renderCatalogReference(manifest)), + } + for _, ref := range refs { + files["references/modules/"+ref.File] = newSkillFile(renderModuleReference(manifest, ref.Module, ref.Flat)) + } + return files +} + +func newSkillFile(body string) skillFile { + return skillFile{body: []byte(body), mode: 0o644} +} + +func normalizeSkillInclude(include SkillInclude) (SkillInclude, error) { + if len(include.Files) == 0 { + return include, nil + } + out := SkillInclude{Path: include.Path, Files: map[string]SkillFileAction{}} + for raw, action := range include.Files { + rel, err := normalizeSkillRel(raw) + if err != nil { + return SkillInclude{}, err + } + if _, ok := out.Files[rel]; ok { + return SkillInclude{}, fmt.Errorf("duplicate skill include policy for %q", rel) + } + out.Files[rel] = action + } + return out, nil +} + +func normalizeSkillRel(rel string) (string, error) { + clean := filepath.Clean(rel) + slash := filepath.ToSlash(clean) + if slash == "" || slash == "." || !filepath.IsLocal(clean) { + return "", fmt.Errorf("invalid skill file path %q", rel) + } + if slash == skillOwnerFile { + return "", fmt.Errorf("skill file %q is reserved", rel) + } + for _, part := range strings.Split(slash, "/") { + if strings.HasPrefix(part, ".") { + return "", fmt.Errorf("skill file path %q must not contain dotfile segments", rel) + } + } + if _, err := defaultSkillFileAction(slash); err != nil { + return "", err + } + return slash, nil +} + +func defaultSkillFileAction(rel string) (SkillFileAction, error) { + switch { + case rel == "SKILL.md": + return SkillFileAppend, nil + case strings.HasPrefix(rel, "references/") && strings.HasSuffix(rel, ".md"): + return SkillFileAppend, nil + case strings.HasPrefix(rel, "agents/"), + strings.HasPrefix(rel, "assets/"), + strings.HasPrefix(rel, "references/"), + strings.HasPrefix(rel, "scripts/"): + return SkillFileCreate, nil + default: + return "", fmt.Errorf("skill include file %q must target SKILL.md, agents/, assets/, references/, or scripts/", rel) + } +} + +func validateSkillIncludePolicy(include SkillInclude, generated map[string]skillFile) error { + for rel, action := range include.Files { + switch action { + case SkillFileReplace: + if !canReplaceGeneratedSkillFile(rel) { + return fmt.Errorf("skill include file %q cannot replace a generated Skill control file", rel) + } + if _, ok := generated[rel]; !ok { + return fmt.Errorf("skill include file %q uses replace but no generated file exists at that path", rel) + } + case SkillFileOmit: + if !canOmitGeneratedSkillFile(rel) { + return fmt.Errorf("skill include file %q cannot be omitted", rel) + } + if _, ok := generated[rel]; !ok { + return fmt.Errorf("skill include file %q uses omit but no generated file exists at that path", rel) + } + case SkillFileAppend: + if !canAppendSkillFile(rel) { + return fmt.Errorf("skill include file %q cannot be appended", rel) + } + case SkillFileCreate: + if _, ok := generated[rel]; ok { + return fmt.Errorf("skill include file %q uses create but a generated file already exists at that path", rel) + } + default: + return fmt.Errorf("skill include file %q has invalid action %q", rel, action) + } + } + return nil +} + +func canAppendSkillFile(rel string) bool { + return rel == "SKILL.md" || strings.HasPrefix(rel, "references/") && strings.HasSuffix(rel, ".md") +} + +func canReplaceGeneratedSkillFile(rel string) bool { + return rel == "SKILL.md" || + rel == "agents/openai.yaml" || + rel == "references/catalog.md" || + strings.HasPrefix(rel, "references/modules/") && strings.HasSuffix(rel, ".md") +} + +func canOmitGeneratedSkillFile(rel string) bool { + return rel == "agents/openai.yaml" || + strings.HasPrefix(rel, "references/modules/") && strings.HasSuffix(rel, ".md") +} + +func filterOmittedModuleRefs(refs []moduleRef, include SkillInclude) []moduleRef { + if len(include.Files) == 0 { + return refs } - if err := ValidateSkillIncludeRoot(filepath.Dir(skillDir), includeRoot); err != nil { + out := refs[:0] + for _, ref := range refs { + if include.Files["references/modules/"+ref.File] == SkillFileOmit { + continue + } + out = append(out, ref) + } + return out +} + +func mergeSkillIncludes(files map[string]skillFile, generated map[string]skillFile, include SkillInclude) error { + includeFiles, err := loadSkillIncludeFiles(include.Path) + if err != nil { return err } - return filepath.WalkDir(includeRoot, func(path string, d os.DirEntry, walkErr error) error { + covered := map[string]bool{} + for rel, action := range include.Files { + covered[rel] = true + if err := applySkillIncludeAction(files, generated, includeFiles, rel, action); err != nil { + return err + } + } + for rel := range includeFiles { + if covered[rel] { + continue + } + action, err := defaultSkillFileAction(rel) + if err != nil { + return err + } + if err := applySkillIncludeAction(files, generated, includeFiles, rel, action); err != nil { + return err + } + } + return nil +} + +func loadSkillIncludeFiles(includeRoot string) (map[string]skillFile, error) { + out := map[string]skillFile{} + if includeRoot == "" { + return out, nil + } + if err := ValidateSkillIncludeRoot("", includeRoot); err != nil { + return nil, err + } + err := filepath.WalkDir(includeRoot, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if d.IsDir() { return nil } - rel, err := filepath.Rel(includeRoot, path) + rawRel, err := filepath.Rel(includeRoot, path) if err != nil { return err } - rel = filepath.Clean(rel) - canAppend, err := skillIncludeFileCanAppend(rel) + rel, err := normalizeSkillRel(rawRel) if err != nil { return err } @@ -125,49 +312,103 @@ func ApplySkillIncludes(skillDir, includeRoot string) error { if !info.Mode().IsRegular() { return fmt.Errorf("skill include file %q is not a regular file", path) } - add, err := os.ReadFile(path) + body, err := os.ReadFile(path) if err != nil { return err } - target := filepath.Join(skillDir, rel) - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err + if _, ok := out[rel]; ok { + return fmt.Errorf("duplicate skill include file %q", rel) } - existing, err := os.ReadFile(target) - if err != nil && !os.IsNotExist(err) { - return err + mode := fs.FileMode(0o644) + if strings.HasPrefix(rel, "scripts/") && info.Mode().Perm()&0o111 != 0 { + mode = 0o755 + } + out[rel] = skillFile{body: body, mode: mode} + return nil + }) + return out, err +} + +func applySkillIncludeAction(files map[string]skillFile, generated map[string]skillFile, includeFiles map[string]skillFile, rel string, action SkillFileAction) error { + included, hasIncluded := includeFiles[rel] + switch action { + case SkillFileOmit: + if hasIncluded { + return fmt.Errorf("skill include file %q uses omit but an include file exists at that path", rel) } - if len(existing) > 0 && !canAppend { - return fmt.Errorf("skill include file %q targets an existing non-appendable generated file", rel) + delete(files, rel) + return nil + case SkillFileReplace: + if !hasIncluded { + return fmt.Errorf("skill include file %q uses replace but no include file exists at that path", rel) } - var merged []byte - if len(existing) > 0 { - merged = append(merged, existing...) - if existing[len(existing)-1] != '\n' { - merged = append(merged, '\n') - } + files[rel] = included + return nil + case SkillFileAppend: + if !hasIncluded { + return fmt.Errorf("skill include file %q uses append but no include file exists at that path", rel) + } + existing, ok := files[rel] + if !ok { + files[rel] = included + return nil + } + merged := append([]byte(nil), existing.body...) + if len(merged) > 0 && merged[len(merged)-1] != '\n' { merged = append(merged, '\n') } - merged = append(merged, add...) - return os.WriteFile(target, merged, 0o644) - }) + merged = append(merged, '\n') + merged = append(merged, included.body...) + files[rel] = skillFile{body: merged, mode: existing.mode} + return nil + case SkillFileCreate: + if !hasIncluded { + return fmt.Errorf("skill include file %q uses create but no include file exists at that path", rel) + } + if _, ok := generated[rel]; ok { + return fmt.Errorf("skill include file %q targets an existing generated file", rel) + } + if _, ok := files[rel]; ok { + return fmt.Errorf("skill include file %q targets an existing file", rel) + } + files[rel] = included + return nil + } + return fmt.Errorf("skill include file %q has invalid action %q", rel, action) } -func skillIncludeFileCanAppend(rel string) (bool, error) { - slash := filepath.ToSlash(rel) - switch { - case slash == "SKILL.md": - return true, nil - case strings.HasPrefix(slash, "references/") && strings.HasSuffix(slash, ".md"): - return true, nil - case strings.HasPrefix(slash, "agents/"), - strings.HasPrefix(slash, "assets/"), - strings.HasPrefix(slash, "references/"), - strings.HasPrefix(slash, "scripts/"): - return false, nil - default: - return false, fmt.Errorf("skill include file %q must target SKILL.md, agents/, assets/, references/, or scripts/", rel) +func writeSkillDirectory(root string, files map[string]skillFile) error { + parent := filepath.Dir(root) + if err := os.MkdirAll(parent, 0o755); err != nil { + return err + } + tmp, err := os.MkdirTemp(parent, "."+filepath.Base(root)+".tmp-") + if err != nil { + return err + } + removeTmp := true + defer func() { + if removeTmp { + _ = os.RemoveAll(tmp) + } + }() + for rel, file := range files { + target := filepath.Join(tmp, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := os.WriteFile(target, file.body, file.mode); err != nil { + return err + } + } + if err := os.RemoveAll(root); err != nil { + return err + } + if err := os.Rename(tmp, root); err != nil { + return err } + removeTmp = false + return nil } func pathContains(base, path string) bool { diff --git a/internal/codegen/render/skill_test.go b/internal/codegen/render/skill_test.go index 8ca8ad2..dffab5a 100644 --- a/internal/codegen/render/skill_test.go +++ b/internal/codegen/render/skill_test.go @@ -377,93 +377,6 @@ func TestRenderSkillDirectory_RegeneratesOwnedDirectory(t *testing.T) { } } -func TestApplySkillIncludes_AppendsAndCreates(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "acmectl") - if err := RenderSkillDirectory(skillDir, &config.Manifest{CLI: config.CLIInfo{Name: "acmectl"}}, nil); err != nil { - t.Fatalf("RenderSkillDirectory: %v", err) - } - before := readFile(t, dir, "skills/acmectl/SKILL.md") - - include := filepath.Join(dir, "include") - includeFiles := map[string]string{ - "SKILL.md": "## Module availability\n\nExtra guidance.\n", - "references/extra.md": "# Extra\n", - "scripts/helper.py": "print('ok')\n", - "assets/template.html": "
\n", - "agents/metadata.yaml": "policy: {}\n", - } - writeFiles(t, include, includeFiles) - - if err := ApplySkillIncludes(skillDir, include); err != nil { - t.Fatalf("ApplySkillIncludes: %v", err) - } - - got := readFile(t, dir, "skills/acmectl/SKILL.md") - if !strings.HasPrefix(got, before) { - t.Errorf("existing SKILL.md content should be preserved as a prefix") - } - if !strings.Contains(got, "## Module availability") || !strings.Contains(got, "Extra guidance.") { - t.Errorf("SKILL.md missing appended include content:\n%s", got) - } - for path, body := range includeFiles { - if path == "SKILL.md" { - continue - } - target := filepath.Join("skills/acmectl", path) - if got := readFile(t, dir, target); got != body { - t.Errorf("included skill resource %s = %q, want %q", target, got, body) - } - } -} - -func TestApplySkillIncludes_EmptyIsNoOp(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "acmectl") - if err := RenderSkillDirectory(skillDir, &config.Manifest{CLI: config.CLIInfo{Name: "acmectl"}}, nil); err != nil { - t.Fatalf("RenderSkillDirectory: %v", err) - } - before := readFile(t, dir, "skills/acmectl/SKILL.md") - - if err := ApplySkillIncludes(skillDir, ""); err != nil { - t.Fatalf("ApplySkillIncludes empty: %v", err) - } - if after := readFile(t, dir, "skills/acmectl/SKILL.md"); after != before { - t.Errorf("SKILL.md changed by no-op include:\nbefore=%q\nafter=%q", before, after) - } -} - -func TestApplySkillIncludes_MissingRootFails(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "acmectl") - - err := ApplySkillIncludes(skillDir, filepath.Join(dir, "does-not-exist")) - if err == nil { - t.Fatal("expected missing include root error") - } - if !strings.Contains(err.Error(), "does not exist") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestApplySkillIncludes_RejectsExistingNonAppendableGeneratedTargets(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "acmectl") - if err := RenderSkillDirectory(skillDir, &config.Manifest{CLI: config.CLIInfo{Name: "acmectl"}}, nil); err != nil { - t.Fatalf("RenderSkillDirectory: %v", err) - } - include := filepath.Join(dir, "include") - writeFiles(t, include, map[string]string{"agents/openai.yaml": "blocked\n"}) - - err := ApplySkillIncludes(skillDir, include) - if err == nil { - t.Fatal("expected protected target error") - } - if !strings.Contains(err.Error(), "existing non-appendable generated file") { - t.Fatalf("unexpected error: %v", err) - } -} - func readFile(t *testing.T, root string, path string) string { t.Helper() data, err := os.ReadFile(filepath.Join(root, path)) @@ -472,16 +385,3 @@ func readFile(t *testing.T, root string, path string) string { } return string(data) } - -func writeFiles(t *testing.T, root string, files map[string]string) { - t.Helper() - for path, body := range files { - full := filepath.Join(root, path) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(full, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - } -} diff --git a/internal/lathecmd/lathecmd.go b/internal/lathecmd/lathecmd.go index cde3610..362a4d3 100644 --- a/internal/lathecmd/lathecmd.go +++ b/internal/lathecmd/lathecmd.go @@ -224,30 +224,27 @@ func runCodegen(sourcesPath string, manifestPath string, cacheRoot string, overl return err } if skillDir != "" { - if err := render.RenderSkillDirectory(skillDir, manifest, skillModules); err != nil { - return err - } - if err := render.ApplySkillIncludes(skillDir, skillInclude); err != nil { + if err := render.RenderSkillDirectoryWithInclude(skillDir, manifest, skillModules, skillInclude); err != nil { return err } } return nil } -func resolveSkillOutput(manifestPath string, flags skillFlagOptions) (*config.Manifest, string, string, error) { +func resolveSkillOutput(manifestPath string, flags skillFlagOptions) (*config.Manifest, string, render.SkillInclude, error) { manifest, rootConfig, include, err := loadCodegenManifest(manifestPath) if err != nil { if os.IsNotExist(err) && flags.RootSet && flags.Root == "" && (!flags.IncludeSet || flags.Include == "") { - return &config.Manifest{CLI: config.CLIInfo{CommandPath: config.CommandPathAuto}}, "", "", nil + return &config.Manifest{CLI: config.CLIInfo{CommandPath: config.CommandPathAuto}}, "", render.SkillInclude{}, nil } - return nil, "", "", err + return nil, "", render.SkillInclude{}, err } if flags.RootSet && flags.Root == "" { - if flags.IncludeSet && flags.Include != "" { - return nil, "", "", fmt.Errorf("skill include requires skill generation") + if skillIncludeConfigured(include) || flags.IncludeSet && flags.Include != "" { + return nil, "", render.SkillInclude{}, fmt.Errorf("skill include requires skill generation") } - return manifest, "", "", nil + return manifest, "", render.SkillInclude{}, nil } root := "skills" @@ -259,26 +256,30 @@ func resolveSkillOutput(manifestPath string, flags skillFlagOptions) (*config.Ma } if flags.IncludeSet { - include = flags.Include + include.Path = flags.Include } if root == "" { - if include != "" { - return nil, "", "", fmt.Errorf("skill include requires skill generation") + if skillIncludeConfigured(include) { + return nil, "", render.SkillInclude{}, fmt.Errorf("skill include requires skill generation") } - return manifest, "", "", nil + return manifest, "", render.SkillInclude{}, nil } skillDir, err := skillOutputDir(root, manifest.CLI.Name) if err != nil { - return nil, "", "", err + return nil, "", render.SkillInclude{}, err } - if err := render.ValidateSkillIncludeRoot(root, include); err != nil { - return nil, "", "", err + if err := render.ValidateSkillIncludeRoot(root, include.Path); err != nil { + return nil, "", render.SkillInclude{}, err } return manifest, skillDir, include, nil } +func skillIncludeConfigured(include render.SkillInclude) bool { + return include.Path != "" || len(include.Files) > 0 +} + func skillFlagsFrom(fs *flag.FlagSet, root, include *string) skillFlagOptions { var rootSet, includeSet bool fs.Visit(func(f *flag.Flag) { @@ -297,25 +298,79 @@ func skillFlagsFrom(fs *flag.FlagSet, root, include *string) skillFlagOptions { } } -func loadCodegenManifest(path string) (*config.Manifest, *string, string, error) { +func loadCodegenManifest(path string) (*config.Manifest, *string, render.SkillInclude, error) { data, err := os.ReadFile(path) if err != nil { - return nil, nil, "", err + return nil, nil, render.SkillInclude{}, err } manifest, err := config.Load(data) if err != nil { - return nil, nil, "", err + return nil, nil, render.SkillInclude{}, err } var codegen struct { Skill struct { - Root *string `yaml:"root"` - Include string `yaml:"include"` + Root *string `yaml:"root"` + Include skillIncludeConfig `yaml:"include"` } `yaml:"skill"` } if err := yaml.Unmarshal(data, &codegen); err != nil { - return nil, nil, "", fmt.Errorf("parse cli.yaml: %w", err) + return nil, nil, render.SkillInclude{}, fmt.Errorf("parse cli.yaml: %w", err) + } + return manifest, codegen.Skill.Root, codegen.Skill.Include.SkillInclude, nil +} + +type skillIncludeConfig struct { + render.SkillInclude +} + +func (c *skillIncludeConfig) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + case yaml.ScalarNode: + c.Path = value.Value + return nil + case yaml.MappingNode: + for i := 0; i < len(value.Content); i += 2 { + key := value.Content[i] + val := value.Content[i+1] + switch key.Value { + case "path": + if val.Kind != yaml.ScalarNode { + return fmt.Errorf("skill.include.path must be a string") + } + c.Path = val.Value + case "files": + files, err := decodeSkillIncludeFiles(val) + if err != nil { + return err + } + c.Files = files + default: + return fmt.Errorf("unknown skill.include field %q", key.Value) + } + } + return nil + default: + return fmt.Errorf("skill.include must be a string or mapping") + } +} + +func decodeSkillIncludeFiles(node *yaml.Node) (map[string]render.SkillFileAction, error) { + if node.Kind != yaml.MappingNode { + return nil, fmt.Errorf("skill.include.files must be a mapping") + } + files := map[string]render.SkillFileAction{} + for i := 0; i < len(node.Content); i += 2 { + key := node.Content[i] + val := node.Content[i+1] + if key.Kind != yaml.ScalarNode || val.Kind != yaml.ScalarNode { + return nil, fmt.Errorf("skill.include.files entries must map file paths to actions") + } + if _, ok := files[key.Value]; ok { + return nil, fmt.Errorf("duplicate skill.include.files entry %q", key.Value) + } + files[key.Value] = render.SkillFileAction(val.Value) } - return manifest, codegen.Skill.Root, codegen.Skill.Include, nil + return files, nil } func resolveCacheRoot(root string) (string, error) {