Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions docs/cli-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cli> skill install`; this requires
Skill generation to remain enabled. For per-file policy, use object form:

```yaml
skill:
Expand Down Expand Up @@ -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

Expand All @@ -301,7 +308,7 @@ var manifestBytes []byte
func main() {
os.Exit(lathe.Run(lathe.RunOptions{
Manifest: manifestBytes,
Mount: generated.MountModules,
Mount: generated.Mount,
}))
}
```
Expand Down
17 changes: 10 additions & 7 deletions docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,13 +43,13 @@ file; when this page and the code disagree, trust the code and fix this page.

- Emitted by `<cli> __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

Expand All @@ -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
Expand Down
17 changes: 14 additions & 3 deletions internal/codegen/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Comment on lines 66 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid deleting skillbundle source modules

When sources.yaml contains a source key named skillbundle and Skill bundling is disabled (the default), the loop above renders that API module into internal/generated/skillbundle, then this cleanup removes the same directory and leaves modules_gen.go importing a package that no longer exists. Source names are not rejected in sourceconfig, so an existing module with this otherwise-valid name is broken by a normal lathe codegen; reserve/validate the name or put the bundle package somewhere that cannot collide with source modules.

Useful? React with 👍 / 👎.

}
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)
}
134 changes: 127 additions & 7 deletions internal/codegen/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
const (
GeneratedRoot = "internal/generated"
ModulesGenFile = "internal/generated/modules_gen.go"
SkillBundleDir = "internal/generated/skillbundle"
)

type moduleCtx struct {
Expand All @@ -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.
Expand Down Expand Up @@ -190,19 +199,24 @@ var reservedRootCommands = map[string]bool{
"help": true,
"login": true,
"search": true,
"skill": true,
"update": true,
}

// ValidateModuleNames rejects namespaced module mount names that would shadow
// 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
}
Expand Down Expand Up @@ -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()))
Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading