diff --git a/docs/cli-usage.md b/docs/cli-usage.md index b3a068e..2a7db19 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -93,13 +93,15 @@ To customize generated Skill output, add an optional top-level `skill` block: skill: root: skills include: internal/skill-include + bundle: true ``` 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. The -include path must stay outside `skill.root`. For per-file policy, use object -form: +include path must stay outside `skill.root`. Set `skill.bundle: true` to compile +the generated Skill into the CLI and expose ` skill install`; this requires +Skill generation to remain enabled. For per-file policy, use object form: ```yaml skill: @@ -277,7 +279,12 @@ internal/generated/ contains the generated agent Skill guide and module references when Skill generation is enabled. These outputs are reproducible from `cli.yaml`, `specs/sources.yaml`, synced specs, and overlays. Skill output also includes any -resources declared by `skill.include`. +resources declared by `skill.include`. When `skill.bundle` is true, codegen also +mirrors the Skill tree under `internal/generated/skillbundle/` and pins: + +```sh +go get github.com/lathe-cli/kitup/go@v0.1.3 github.com/lathe-cli/kitup/go-cobra@v0.1.3 +``` ## Wire the Generated CLI @@ -301,7 +308,7 @@ var manifestBytes []byte func main() { os.Exit(lathe.Run(lathe.RunOptions{ Manifest: manifestBytes, - Mount: generated.MountModules, + Mount: generated.Mount, })) } ``` diff --git a/docs/contracts.md b/docs/contracts.md index 4258b8c..3b4b2ac 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -29,9 +29,11 @@ file; when this page and the code disagree, trust the code and fix this page. - This is the agent-facing discovery contract and the source of truth for generated operation details: HTTP method and path template, auth requirements, flags, body schema, output and pagination hints. +- `catalog.cli.capabilities` lists compiled first-party capabilities such as + `skill.bundle`. Capability commands are not catalog operations. - Only generated API operation commands carry catalog entries. Framework - commands (`auth`, `commands`, `search`, `update`, `__lathe`) are discovered - through `--help`, not the catalog. + commands (`auth`, `commands`, `search`, `skill`, `update`, `__lathe`) are + discovered through `--help`, not the catalog. - Consumers: agents following the documented loop (search, then `commands show`, then `auth status`, then execute), generated Skill files (guidance and indexes only, never execution authority), and external @@ -41,13 +43,13 @@ file; when this page and the code disagree, trust the code and fix this page. - Emitted by ` __lathe verify --json`; implemented in `pkg/lathe/verify.go`. -- Shape: `{"ok": bool, "checks": [{"name": string, "ok": bool, "error": - string}]}`. The process exits non-zero when any check fails. +- Version field: `version`, currently `1`. +- Shape: `{"version": number, "ok": bool, "checks": [{"name": string, "ok": + bool, "error": string}]}`. The process exits non-zero when any check fails. - This is the generated CLI's self-evidence: root help contract, catalog schema and JSON round-trip, per-command flag consistency, and an isolated - unauthenticated `auth status` probe. -- The report is not independently versioned today; treat any change to its - shape or check names as a contract change and document it here. + unauthenticated `auth status` probe. When `skill.bundle` is compiled in, the + `skill_install` check runs a temp-`HOME` install with an explicit Codex target. ## Structured errors and exit codes @@ -62,6 +64,7 @@ file; when this page and the code disagree, trust the code and fix this page. - `cli.yaml`, parsed by `pkg/config.Load`, plus codegen-only keys (`skill.root`, `skill.include`) parsed in `internal/lathecmd`. + `skill.bundle` is a first-party capability switch in the same domain block. - `specs/sources.yaml`, parsed by `internal/sourceconfig`, pinning upstream spec refs per module. - Optional overlay files, parsed by `internal/overlay`, merged at codegen time diff --git a/internal/codegen/app/app.go b/internal/codegen/app/app.go index 827d5d5..074dc99 100644 --- a/internal/codegen/app/app.go +++ b/internal/codegen/app/app.go @@ -30,6 +30,7 @@ type Skill struct { Dir string Include render.SkillInclude Modules []render.SkillModule + Bundle bool } // Validate rejects app compositions that would produce a conflicting root @@ -55,11 +56,21 @@ func (a *App) Write() error { } mounts = append(mounts, render.ModuleMount{Name: m.Source, Flat: m.Flat}) } - if err := render.RenderModulesGen(mounts); err != nil { + opts := render.ModulesGenOptions{} + if a.Skill != nil && a.Skill.Bundle { + opts.SkillBundle = &render.SkillBundleMount{Root: render.SkillDirName(a.Manifest.CLI.Name)} + } + if err := render.RenderModulesGenWithOptions(mounts, opts); err != nil { return err } if a.Skill == nil { - return nil + return render.RemoveSkillBundlePackage() + } + if err := render.RenderSkillDirectoryWithInclude(a.Skill.Dir, a.Manifest, a.Skill.Modules, a.Skill.Include); err != nil { + return err + } + if !a.Skill.Bundle { + return render.RemoveSkillBundlePackage() } - return render.RenderSkillDirectoryWithInclude(a.Skill.Dir, a.Manifest, a.Skill.Modules, a.Skill.Include) + return render.RenderSkillBundlePackage(a.Skill.Dir, a.Manifest.CLI.Name) } diff --git a/internal/codegen/render/render.go b/internal/codegen/render/render.go index ea666c7..3118487 100644 --- a/internal/codegen/render/render.go +++ b/internal/codegen/render/render.go @@ -19,6 +19,7 @@ import ( const ( GeneratedRoot = "internal/generated" ModulesGenFile = "internal/generated/modules_gen.go" + SkillBundleDir = "internal/generated/skillbundle" ) type moduleCtx struct { @@ -34,6 +35,14 @@ type ModuleMount struct { Flat bool } +type ModulesGenOptions struct { + SkillBundle *SkillBundleMount +} + +type SkillBundleMount struct { + Root string +} + // RuntimePkg is the import path downstream-generated modules use to reach // lathe's runtime. Downstream forks import lathe as a library; they do not // vendor or copy the runtime package into their own tree. @@ -190,6 +199,7 @@ var reservedRootCommands = map[string]bool{ "help": true, "login": true, "search": true, + "skill": true, "update": true, } @@ -197,12 +207,16 @@ var reservedRootCommands = map[string]bool{ // a reserved root command or another module on the generated root command. func ValidateModuleNames(names []string) error { seen := map[string]bool{} - for _, name := range names { + for _, raw := range names { + name := rootCommandName(raw) + if name == "" { + return fmt.Errorf("module name %q has empty generated root command", raw) + } if reservedRootCommands[name] { - return fmt.Errorf("module name %q conflicts with a reserved root command", name) + return fmt.Errorf("module name %q conflicts with a reserved root command", raw) } if seen[name] { - return fmt.Errorf("module name %q is mounted more than once", name) + return fmt.Errorf("module name %q is mounted more than once", raw) } seen[name] = true } @@ -444,15 +458,20 @@ func renderModuleSpecs(name, cliName string, specs []runtime.CommandSpec) error } func RenderModulesGen(modules []ModuleMount) error { + return RenderModulesGenWithOptions(modules, ModulesGenOptions{}) +} + +func RenderModulesGenWithOptions(modules []ModuleMount, opts ModulesGenOptions) error { mp, err := modulePath() if err != nil { return err } var buf strings.Builder if err := modulesTmpl.Execute(&buf, struct { - Prefix string - Modules []ModuleMount - }{Prefix: mp + "/internal/generated/", Modules: modules}); err != nil { + Prefix string + Modules []ModuleMount + SkillBundle *SkillBundleMount + }{Prefix: mp + "/internal/generated/", Modules: modules, SkillBundle: opts.SkillBundle}); err != nil { return err } formatted, err := format.Source([]byte(buf.String())) @@ -467,6 +486,75 @@ func RenderModulesGen(modules []ModuleMount) error { return nil } +func RenderSkillBundlePackage(skillDir string, cliName string) error { + root := SkillDirName(cliName) + dst := filepath.Join(SkillBundleDir, root) + if err := os.MkdirAll(SkillBundleDir, 0o755); err != nil { + return err + } + if err := os.RemoveAll(dst); err != nil { + return err + } + if err := copySkillBundleFiles(skillDir, dst); err != nil { + return err + } + var buf strings.Builder + if err := skillBundleTmpl.Execute(&buf, struct{ Root string }{Root: root}); err != nil { + return err + } + formatted, err := format.Source([]byte(buf.String())) + if err != nil { + _ = os.WriteFile(filepath.Join(SkillBundleDir, "skillbundle_gen.go.unformatted"), []byte(buf.String()), 0o644) + return err + } + if err := os.WriteFile(filepath.Join(SkillBundleDir, "skillbundle_gen.go"), formatted, 0o644); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "wrote %s\n", SkillBundleDir) + return nil +} + +func RemoveSkillBundlePackage() error { + return os.RemoveAll(SkillBundleDir) +} + +func copySkillBundleFiles(src string, dst string) error { + return filepath.WalkDir(src, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if strings.HasPrefix(entry.Name(), ".") { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + if rel == "." { + return os.MkdirAll(dst, 0o755) + } + target := filepath.Join(dst, rel) + if entry.IsDir() { + return os.MkdirAll(target, 0o755) + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, info.Mode().Perm()) + }) +} + func schemaLiteral(s *runtime.SchemaSpec) string { if s == nil { return "nil" @@ -695,14 +783,27 @@ var modulesTmpl = template.Must(template.New("modules").Parse(`// Code generated package generated import ( +{{- if .SkillBundle}} + lathekitup "github.com/lathe-cli/kitup/go" + lathekitupcobra "github.com/lathe-cli/kitup/go-cobra" + latheruntime "github.com/lathe-cli/lathe/pkg/runtime" +{{- end}} "github.com/spf13/cobra" {{- range .Modules}} {{.Name}} "{{$.Prefix}}{{.Name}}" {{- end}} +{{- if .SkillBundle}} + lathegeneratedskillbundle "{{$.Prefix}}skillbundle" +{{- end}} ) -// MountModules mounts every module declared in sources.yaml under root. +// Mount mounts every generated command and capability declared by codegen. +func Mount(root *cobra.Command) error { + return MountModules(root) +} + +// MountModules mounts every module and generated capability under root. // The import list above is the single source of truth for which modules // are compiled into this binary. main.go wires this call after // app.NewApp() so the framework package never imports downstream code. @@ -711,11 +812,30 @@ func MountModules(root *cobra.Command) error { if err := {{.Name}}.{{if .Flat}}MountFlat{{else}}Mount{{end}}(root); err != nil { return err } +{{- end}} +{{- if .SkillBundle}} + latheruntime.AttachCapability(root, latheruntime.CapabilitySkillBundle) + root.AddCommand(lathekitupcobra.NewSkillCommand(lathekitupcobra.Options{ + AppID: root.Name(), + Bundle: lathekitup.FSBundle(lathegeneratedskillbundle.FS, lathegeneratedskillbundle.Root), + })) {{- end}} return nil } `)) +var skillBundleTmpl = template.Must(template.New("skillbundle").Parse(`// Code generated by lathe codegen. DO NOT EDIT. + +package skillbundle + +import "embed" + +const Root = {{printf "%q" .Root}} + +//go:embed {{.Root}}/** +var FS embed.FS +`)) + // modulePath reads the `module` directive from go.mod in the current working // directory. Codegen uses this to compute the downstream's own generated/ // package import prefix so a downstream fork can rename its Go module without diff --git a/internal/codegen/render/render_test.go b/internal/codegen/render/render_test.go index 38809e4..f88fbfc 100644 --- a/internal/codegen/render/render_test.go +++ b/internal/codegen/render/render_test.go @@ -472,6 +472,75 @@ func TestRenderModulesGen_UsesFlatMount(t *testing.T) { } } +func TestRenderModulesGen_WithSkillBundle(t *testing.T) { + chdirWithGeneratedRoot(t) + + if err := RenderModulesGenWithOptions([]ModuleMount{{Name: "alpha"}}, ModulesGenOptions{ + SkillBundle: &SkillBundleMount{Root: "acmectl"}, + }); err != nil { + t.Fatalf("RenderModulesGenWithOptions: %v", err) + } + got := generatedModules(t) + for _, want := range []string{ + `func Mount(root *cobra.Command) error`, + `return MountModules(root)`, + `lathekitup "github.com/lathe-cli/kitup/go"`, + `lathekitupcobra "github.com/lathe-cli/kitup/go-cobra"`, + `latheruntime.AttachCapability(root, latheruntime.CapabilitySkillBundle)`, + `lathegeneratedskillbundle "example.com/fake/internal/generated/skillbundle"`, + } { + if !strings.Contains(got, want) { + t.Errorf("output missing %q\n%s", want, got) + } + } +} + +func TestRenderSkillBundlePackage(t *testing.T) { + chdirWithGeneratedRoot(t) + if err := os.MkdirAll("skills/acmectl/agents", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("skills/acmectl/SKILL.md", []byte("---\nname: acmectl\ndescription: test\n---\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("skills/acmectl/.lathe-skill", []byte("owner"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("skills/acmectl/agents/openai.yaml", []byte("version: 1\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll("internal/generated/skillbundle/otherctl", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("internal/generated/skillbundle/otherctl/SKILL.md", []byte("other"), 0o644); err != nil { + t.Fatal(err) + } + + if err := RenderSkillBundlePackage("skills/acmectl", "acmectl"); err != nil { + t.Fatalf("RenderSkillBundlePackage: %v", err) + } + for _, path := range []string{ + "internal/generated/skillbundle/skillbundle_gen.go", + "internal/generated/skillbundle/acmectl/SKILL.md", + "internal/generated/skillbundle/acmectl/agents/openai.yaml", + "internal/generated/skillbundle/otherctl/SKILL.md", + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s: %v", path, err) + } + } + if _, err := os.Stat("internal/generated/skillbundle/acmectl/.lathe-skill"); !os.IsNotExist(err) { + t.Fatalf("dotfile should be skipped, stat err = %v", err) + } + got, err := os.ReadFile("internal/generated/skillbundle/skillbundle_gen.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), `//go:embed acmectl/**`) { + t.Fatalf("embed bridge missing root:\n%s", got) + } +} + func TestResolveFlatCommandPath(t *testing.T) { specs := []runtime.CommandSpec{{Group: "Users", Use: "list-users"}} flat, err := ResolveFlatCommandPath("auto", 1, specs) @@ -517,6 +586,14 @@ func TestResolveFlatCommandPath(t *testing.T) { t.Fatal("auto should keep Cobra-normalized root command conflicts namespaced") } + flat, err = ResolveFlatCommandPath("auto", 1, []runtime.CommandSpec{{Group: "Skill", Use: "install-skill"}}) + if err != nil { + t.Fatalf("ResolveFlatCommandPath: %v", err) + } + if flat { + t.Fatal("auto should keep skill root command conflicts namespaced") + } + flat, err = ResolveFlatCommandPath("auto", 1, []runtime.CommandSpec{ {Group: "Users", Use: "list-users"}, {Group: "Users API", Use: "get-user"}, @@ -538,6 +615,11 @@ func TestResolveFlatCommandPath(t *testing.T) { t.Fatalf("expected Cobra-normalized flat conflict error, got %v", err) } + _, err = ResolveFlatCommandPath("flat", 1, []runtime.CommandSpec{{Group: "Skill", Use: "install-skill"}}) + if err == nil || !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("expected skill flat conflict error, got %v", err) + } + _, err = ResolveFlatCommandPath("flat", 1, []runtime.CommandSpec{ {Group: "Pets", Use: "list-pets"}, {Group: "Pets", Use: "get-pet"}, @@ -601,14 +683,22 @@ func TestValidateModuleNames(t *testing.T) { if err := ValidateModuleNames([]string{"pets", "billing"}); err != nil { t.Fatalf("distinct module names should pass: %v", err) } - for _, reserved := range []string{"__lathe", "auth", "commands", "help", "login", "search", "update"} { + for _, reserved := range []string{"__lathe", "auth", "commands", "help", "login", "search", "skill", "update"} { err := ValidateModuleNames([]string{"pets", reserved}) if err == nil || !strings.Contains(err.Error(), "reserved root command") { t.Fatalf("module name %q should be rejected, got %v", reserved, err) } } - err := ValidateModuleNames([]string{"pets", "pets"}) + err := ValidateModuleNames([]string{"pets", "Skill API"}) + if err == nil || !strings.Contains(err.Error(), "reserved root command") { + t.Fatalf("Cobra-normalized module name should be rejected, got %v", err) + } + err = ValidateModuleNames([]string{"pets", "pets"}) if err == nil || !strings.Contains(err.Error(), "mounted more than once") { t.Fatalf("duplicate module names should be rejected, got %v", err) } + err = ValidateModuleNames([]string{"pets", "Pets API"}) + if err == nil || !strings.Contains(err.Error(), "mounted more than once") { + t.Fatalf("Cobra-normalized duplicate module names should be rejected, got %v", err) + } } diff --git a/internal/lathecmd/lathecmd.go b/internal/lathecmd/lathecmd.go index a1addf4..db0dbd0 100644 --- a/internal/lathecmd/lathecmd.go +++ b/internal/lathecmd/lathecmd.go @@ -5,7 +5,9 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" + "strings" "github.com/lathe-cli/lathe/internal/codegen/app" "github.com/lathe-cli/lathe/internal/codegen/backends/graphql" @@ -30,6 +32,11 @@ type skillFlagOptions struct { IncludeSet bool } +const ( + kitupGoDependency = "github.com/lathe-cli/kitup/go@v0.1.3" + kitupGoCobraDependency = "github.com/lathe-cli/kitup/go-cobra@v0.1.3" +) + func Run(args []string) error { return runWithOutputs(args, os.Stdout, os.Stderr) } @@ -98,7 +105,7 @@ func RunCodegen(args []string, output io.Writer) error { if err := fs.Parse(args); err != nil { return err } - return runCodegen(*sourcesPath, *manifestPath, *cacheRoot, *overlayDir, skillFlagsFrom(fs, skillRoot, skillInclude)) + return runCodegen(*sourcesPath, *manifestPath, *cacheRoot, *overlayDir, skillFlagsFrom(fs, skillRoot, skillInclude), output) } func RunBootstrap(args []string, output io.Writer) error { @@ -125,7 +132,7 @@ func RunBootstrap(args []string, output io.Writer) error { if err := specsync.Sync(cfg, specsync.Options{CacheRoot: absRoot}); err != nil { return err } - return runCodegen(*sourcesPath, *manifestPath, absRoot, *overlayDir, skillFlagsFrom(fs, skillRoot, skillInclude)) + return runCodegen(*sourcesPath, *manifestPath, absRoot, *overlayDir, skillFlagsFrom(fs, skillRoot, skillInclude), output) } func printRootUsage(output io.Writer) { @@ -142,7 +149,7 @@ Run "lathe -h" for command-specific flags. `) } -func runCodegen(sourcesPath string, manifestPath string, cacheRoot string, overlayDir string, skillFlags skillFlagOptions) error { +func runCodegen(sourcesPath string, manifestPath string, cacheRoot string, overlayDir string, skillFlags skillFlagOptions, output io.Writer) error { cfg, err := sourceconfig.Load(sourcesPath) if err != nil { return err @@ -171,7 +178,13 @@ func runCodegen(sourcesPath string, manifestPath string, cacheRoot string, overl if err := generated.Validate(); err != nil { return err } - return generated.Write() + if err := generated.Write(); err != nil { + return err + } + if generated.Manifest.Skill.Bundle { + return pinSkillBundleDependencies(output) + } + return nil } // buildGeneratedApp parses and normalizes every configured source into the @@ -179,7 +192,10 @@ func runCodegen(sourcesPath string, manifestPath string, cacheRoot string, overl func buildGeneratedApp(cfg *sourceconfig.Config, overlays map[string]overlay.Module, syncRoot string, manifest *config.Manifest, skillDir string, skillInclude render.SkillInclude) (*app.App, error) { generated := &app.App{Manifest: manifest} if skillDir != "" { - generated.Skill = &app.Skill{Dir: skillDir, Include: skillInclude} + generated.Skill = &app.Skill{Dir: skillDir, Include: skillInclude, Bundle: manifest.Skill.Bundle} + } + if manifest.Skill.Bundle && generated.Skill == nil { + return nil, fmt.Errorf("skill.bundle requires skill generation") } ordered := cfg.Ordered() @@ -247,6 +263,9 @@ func resolveSkillOutput(manifestPath string, flags skillFlagOptions) (*config.Ma } if flags.RootSet && flags.Root == "" { + if manifest.Skill.Bundle { + return nil, "", render.SkillInclude{}, fmt.Errorf("skill.bundle requires skill generation") + } if skillIncludeConfigured(include) || flags.IncludeSet && flags.Include != "" { return nil, "", render.SkillInclude{}, fmt.Errorf("skill include requires skill generation") } @@ -266,6 +285,9 @@ func resolveSkillOutput(manifestPath string, flags skillFlagOptions) (*config.Ma } if root == "" { + if manifest.Skill.Bundle { + return nil, "", render.SkillInclude{}, fmt.Errorf("skill.bundle requires skill generation") + } if skillIncludeConfigured(include) { return nil, "", render.SkillInclude{}, fmt.Errorf("skill include requires skill generation") } @@ -282,6 +304,18 @@ func resolveSkillOutput(manifestPath string, flags skillFlagOptions) (*config.Ma return manifest, skillDir, include, nil } +func pinSkillBundleDependencies(output io.Writer) error { + args := []string{"get", kitupGoDependency, kitupGoCobraDependency} + fmt.Fprintf(output, "go %s\n", strings.Join(args, " ")) + cmd := exec.Command("go", args...) + cmd.Stdout = output + cmd.Stderr = output + if err := cmd.Run(); err != nil { + return fmt.Errorf("pin skill bundle dependencies: %w", err) + } + return nil +} + func skillIncludeConfigured(include render.SkillInclude) bool { return include.Path != "" || len(include.Files) > 0 } diff --git a/internal/lathecmd/lathecmd_test.go b/internal/lathecmd/lathecmd_test.go index 5af98d7..c166941 100644 --- a/internal/lathecmd/lathecmd_test.go +++ b/internal/lathecmd/lathecmd_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" ) @@ -189,6 +190,65 @@ skill: } } +func TestRunCodegen_SkillBundleGeneratesEmbedAndPinsDeps(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + seedCodegenProject(t, true) + writeCodegenFile(t, "cli.yaml", `cli: + name: acmectl + short: Acme CLI +skill: + bundle: true +`) + logPath := filepath.Join(dir, "go-args.txt") + bin := filepath.Join(dir, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + goScript := filepath.Join(bin, "go") + if err := os.WriteFile(goScript, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" > "+strconv.Quote(logPath)+"\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + var out bytes.Buffer + if err := RunCodegen([]string{"-sources", "specs/sources.yaml", "-cache", ".cache"}, &out); err != nil { + t.Fatalf("run: %v", err) + } + + for _, path := range []string{ + "internal/generated/skillbundle/skillbundle_gen.go", + "internal/generated/skillbundle/acmectl/SKILL.md", + "internal/generated/skillbundle/acmectl/agents/openai.yaml", + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s: %v", path, err) + } + } + if _, err := os.Stat("internal/generated/skillbundle/acmectl/.lathe-skill"); !os.IsNotExist(err) { + t.Fatalf("bundle should not include skill owner file, stat err = %v", err) + } + modulesGen := readCodegenFile(t, "internal/generated/modules_gen.go") + for _, want := range []string{ + `func Mount(root *cobra.Command) error`, + `lathekitup.FSBundle(lathegeneratedskillbundle.FS, lathegeneratedskillbundle.Root)`, + `latheruntime.AttachCapability(root, latheruntime.CapabilitySkillBundle)`, + `lathekitupcobra.NewSkillCommand`, + } { + if !strings.Contains(modulesGen, want) { + t.Fatalf("modules_gen.go missing %q:\n%s", want, modulesGen) + } + } + args := readCodegenFile(t, logPath) + wantArgs := "get " + kitupGoDependency + " " + kitupGoCobraDependency + "\n" + if args != wantArgs { + t.Fatalf("go args = %q, want %q", args, wantArgs) + } + if !strings.Contains(out.String(), "go "+strings.TrimSpace(wantArgs)) { + t.Fatalf("output missing go get command:\n%s", out.String()) + } +} + func TestRunCodegen_SkillFlagsOverrideManifestConfig(t *testing.T) { dir := t.TempDir() t.Chdir(dir) diff --git a/pkg/config/manifest.go b/pkg/config/manifest.go index 6bafd2b..142e88d 100644 --- a/pkg/config/manifest.go +++ b/pkg/config/manifest.go @@ -12,6 +12,7 @@ type Manifest struct { CLI CLIInfo `yaml:"cli"` Auth AuthInfo `yaml:"auth"` Update UpdateInfo `yaml:"update,omitempty"` + Skill SkillInfo `yaml:"skill,omitempty"` } type CLIInfo struct { @@ -32,6 +33,10 @@ type UpdateInfo struct { GitHub *GitHubUpdate `yaml:"github,omitempty"` } +type SkillInfo struct { + Bundle bool `yaml:"bundle,omitempty"` +} + type AuthLogin struct { Type string `yaml:"type"` StartPath string `yaml:"start_path"` diff --git a/pkg/lathe/verify.go b/pkg/lathe/verify.go index 07d100d..35d1b20 100644 --- a/pkg/lathe/verify.go +++ b/pkg/lathe/verify.go @@ -1,11 +1,13 @@ package lathe import ( + "bytes" "encoding/json" "errors" "fmt" "io" "os" + "path/filepath" "reflect" "strings" @@ -16,8 +18,9 @@ import ( ) type verifyReport struct { - OK bool `json:"ok"` - Checks []verifyCheck `json:"checks"` + Version int `json:"version"` + OK bool `json:"ok"` + Checks []verifyCheck `json:"checks"` } type verifyCheck struct { @@ -28,6 +31,8 @@ type verifyCheck struct { type verifyFailedError struct{} +const verifyReportVersion = 1 + func (verifyFailedError) Error() string { return "generated CLI verify failed" } @@ -56,7 +61,7 @@ func verifyCmd(m *config.Manifest) *cobra.Command { } func verifyGenerated(root *cobra.Command, m *config.Manifest) verifyReport { - report := verifyReport{OK: true} + report := verifyReport{Version: verifyReportVersion, OK: true} catalog := runtime.BuildCatalog(root, catalogOptions(m, false)) report.add("root_help", verifyRootHelp(root, m.CLI.Name)) @@ -67,6 +72,9 @@ func verifyGenerated(root *cobra.Command, m *config.Manifest) verifyReport { report.add("commands_show:"+strings.Join(entry.Path, " "), verifyCatalogEntry(root, m, entry)) } report.add("auth_status_unauthenticated", verifyAuthStatusUnauthenticated(m)) + if runtime.HasCapability(root, runtime.CapabilitySkillBundle) { + report.add("skill_install", verifySkillInstall(root, m)) + } return report } @@ -228,6 +236,74 @@ func verifyAuthStatusUnauthenticated(m *config.Manifest) error { return nil } +func verifySkillInstall(root *cobra.Command, m *config.Manifest) error { + if findCommand(root, []string{"skill", "install"}) == nil { + return errors.New("missing skill install command") + } + tempHome, err := os.MkdirTemp("", m.CLI.Name+"-skill-verify-*") + if err != nil { + return err + } + defer func() { + _ = os.RemoveAll(tempHome) + }() + + oldHome, hadHome := os.LookupEnv("HOME") + if err := os.Setenv("HOME", tempHome); err != nil { + return err + } + defer func() { + if hadHome { + _ = os.Setenv("HOME", oldHome) + } else { + _ = os.Unsetenv("HOME") + } + }() + + var out bytes.Buffer + oldOut := root.OutOrStdout() + oldErr := root.ErrOrStderr() + root.SetOut(&out) + root.SetErr(&out) + defer func() { + root.SetOut(oldOut) + root.SetErr(oldErr) + }() + root.SetArgs([]string{"skill", "install", "--scope", "user", "--agent", "codex", "--yes"}) + if err := root.Execute(); err != nil { + return fmt.Errorf("skill install: %w", err) + } + target := filepath.Join(tempHome, ".agents", "skills", skillName(m.CLI.Name)) + for _, name := range []string{"SKILL.md", ".kitup.json"} { + if _, err := os.Stat(filepath.Join(target, name)); err != nil { + return fmt.Errorf("skill install missing %s: %w", name, err) + } + } + return nil +} + +func skillName(name string) string { + var b strings.Builder + lastDash := false + for _, r := range strings.ToLower(strings.TrimSpace(name)) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || r == ' ' || r == '.': + if !lastDash && b.Len() > 0 { + b.WriteByte('-') + lastDash = true + } + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "cli" + } + return out +} + func findCommand(root *cobra.Command, path []string) *cobra.Command { cur := root for _, segment := range path { diff --git a/pkg/lathe/verify_test.go b/pkg/lathe/verify_test.go index 8a4e67e..ccaeb03 100644 --- a/pkg/lathe/verify_test.go +++ b/pkg/lathe/verify_test.go @@ -3,6 +3,9 @@ package lathe import ( "bytes" "encoding/json" + "errors" + "os" + "path/filepath" "strings" "testing" @@ -80,6 +83,9 @@ func TestRunVerifyGeneratedJSON(t *testing.T) { if !report.OK { t.Fatalf("report = %+v", report) } + if report.Version != verifyReportVersion { + t.Fatalf("version = %d, want %d", report.Version, verifyReportVersion) + } for _, want := range []string{ "root_help", "commands_schema", @@ -96,6 +102,54 @@ func TestRunVerifyGeneratedJSON(t *testing.T) { } } +func TestVerifyGeneratedSkillInstall(t *testing.T) { + root := NewApp(testManifest()) + if err := runtime.Build(root, "demo", []runtime.CommandSpec{{ + Group: "Users", + Use: "get-user", + Method: "GET", + PathTpl: "/users/{id}", + }}); err != nil { + t.Fatal(err) + } + runtime.AttachCapability(root, runtime.CapabilitySkillBundle) + skill := &cobra.Command{Use: "skill"} + hookRan := false + install := &cobra.Command{ + Use: "install", + PreRunE: func(_ *cobra.Command, _ []string) error { + hookRan = true + return nil + }, + RunE: func(_ *cobra.Command, _ []string) error { + if !hookRan { + return errors.New("pre-run hook did not run") + } + target := filepath.Join(os.Getenv("HOME"), ".agents", "skills", "myctl") + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("skill"), 0o644); err != nil { + return err + } + return os.WriteFile(filepath.Join(target, ".kitup.json"), []byte("{}"), 0o644) + }, + } + install.Flags().String("scope", "", "") + install.Flags().String("agent", "", "") + install.Flags().Bool("yes", false, "") + skill.AddCommand(install) + root.AddCommand(skill) + + report := verifyGenerated(root, testManifest()) + if !verifyReportHasCheck(report, "skill_install") { + t.Fatalf("report missing skill_install: %+v", report.Checks) + } + if !hookRan { + t.Fatal("skill install hook did not run") + } +} + func TestRunVerifyGeneratedFailureReturnsJSONOnly(t *testing.T) { var stdout, stderr bytes.Buffer code := run(RunOptions{ diff --git a/pkg/runtime/catalog.go b/pkg/runtime/catalog.go index b5b4dae..13b1873 100644 --- a/pkg/runtime/catalog.go +++ b/pkg/runtime/catalog.go @@ -10,15 +10,18 @@ import ( "github.com/spf13/cobra" ) -const CatalogSchemaVersion = 9 +const CatalogSchemaVersion = 10 const DefaultSearchLimit = 20 const catalogCommandAnnotation = "lathe.catalog.command" +const catalogCapabilitiesAnnotation = "lathe.catalog.capabilities" +const CapabilitySkillBundle = "skill.bundle" type CatalogOptions struct { CLIName string CLIVersion string IncludeHidden bool + Capabilities []string } type SearchOptions struct { @@ -34,8 +37,9 @@ type Catalog struct { } type CatalogCLI struct { - Name string `json:"name"` - Version string `json:"version,omitempty"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` } type CatalogOutputFormats struct { @@ -136,10 +140,36 @@ func AttachCatalogCommand(cmd *cobra.Command, service string, spec CommandSpec) cmd.Annotations[catalogCommandAnnotation] = string(raw) } +func AttachCapability(root *cobra.Command, capability string) { + if root == nil || capability == "" { + return + } + values := append(capabilitiesFromAnnotation(root), capability) + values = normalizeCapabilities(values) + if root.Annotations == nil { + root.Annotations = map[string]string{} + } + root.Annotations[catalogCapabilitiesAnnotation] = strings.Join(values, ",") +} + +func HasCapability(root *cobra.Command, capability string) bool { + for _, value := range Capabilities(root) { + if value == capability { + return true + } + } + return false +} + +func Capabilities(root *cobra.Command) []string { + return normalizeCapabilities(capabilitiesFromAnnotation(root)) +} + func BuildCatalog(root *cobra.Command, opts CatalogOptions) Catalog { if opts.CLIName == "" { opts.CLIName = root.Use } + capabilities := normalizeCapabilities(append(append([]string(nil), opts.Capabilities...), Capabilities(root)...)) commands := make([]CatalogCommand, 0) walkCatalog(root, nil, opts, &commands) sort.Slice(commands, func(i, j int) bool { @@ -147,12 +177,38 @@ func BuildCatalog(root *cobra.Command, opts CatalogOptions) Catalog { }) return Catalog{ CatalogSchemaVersion: CatalogSchemaVersion, - CLI: CatalogCLI{Name: opts.CLIName, Version: opts.CLIVersion}, + CLI: CatalogCLI{Name: opts.CLIName, Version: opts.CLIVersion, Capabilities: capabilities}, Output: CatalogOutputFormats{DefaultFormat: "table", Formats: FormatterNames()}, Commands: commands, } } +func capabilitiesFromAnnotation(root *cobra.Command) []string { + if root == nil || root.Annotations == nil { + return nil + } + raw := root.Annotations[catalogCapabilitiesAnnotation] + if raw == "" { + return nil + } + return strings.Split(raw, ",") +} + +func normalizeCapabilities(values []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + sort.Strings(out) + return out +} + func FindCatalogCommand(root *cobra.Command, path []string, opts CatalogOptions) (CatalogCommand, bool) { cur := root canonical := make([]string, 0, len(path)) diff --git a/pkg/runtime/catalog_test.go b/pkg/runtime/catalog_test.go index 9ac3fac..234d65b 100644 --- a/pkg/runtime/catalog_test.go +++ b/pkg/runtime/catalog_test.go @@ -259,6 +259,17 @@ func TestBuildCatalog_HiddenCommands(t *testing.T) { } } +func TestBuildCatalog_Capabilities(t *testing.T) { + root := newRootWithModuleGroup() + AttachCapability(root, CapabilitySkillBundle) + AttachCapability(root, CapabilitySkillBundle) + + catalog := BuildCatalog(root, CatalogOptions{Capabilities: []string{"trace"}}) + if !reflect.DeepEqual(catalog.CLI.Capabilities, []string{"skill.bundle", "trace"}) { + t.Fatalf("capabilities = %#v", catalog.CLI.Capabilities) + } +} + func TestFindAndSearchCatalog(t *testing.T) { root := newRootWithModuleGroup() mustBuild(t, root, "demo", []CommandSpec{