// Package scaffold holds the documents kettle writes into a project: the slash // commands an operator invokes, the skills a model loads, and the runner // subagent. // // They live inside the binary rather than beside it. A plugin shipped this prose // once, on its own release cadence, and nothing on an operator's machine checked // that the plugin they had installed described the binary they had installed — // so a renamed flag could ship with documentation recommending the old one, // which is the exact failure the generated block was invented to prevent, one // hop further downstream. Prose that travels inside the binary cannot be a // version behind it. // // This package depends on nothing but the standard library and holds no // rendering logic: it hands out embedded files and says which of them carry a // generated region. Splicing the command registry into that region is // internal/cmd's, because the registry is. package scaffold import ( "embed" "io/fs" "path" "sort" "strings" ) //go:embed all:assets var assets embed.FS const assetRoot = "assets" // Marker is the directory these files are written into, relative to the project // root. It belongs to the agent harness, not to kettle: everything kettle owns // is under `.kettle/`, and this is the one tree it writes that somebody else // defines the shape of. const Marker = ".claude" // generated maps an output path to the command group whose flag table belongs // in it. A file that is not in here carries no generated region and is shipped // exactly as embedded. // // Explicit rather than derived from the directory name: the group ⇄ skill // correspondence is a decision, and one that has not always held — `init` and // `auth` are commands with no skill of their own, and `project` is a skill // covering four commands. A test in internal/cmd asserts every group in the // registry is named here exactly once, so adding a group fails loudly rather // than silently shipping a skill nobody can find. var generated = map[string]string{ "skills/kettle-project/SKILL.md": "project", "skills/kettle-issue/SKILL.md": "issue", "skills/kettle-sync/SKILL.md": "sync", "skills/kettle-api/SKILL.md": "api", } // File is one document, ready to be written under the output directory. type File struct { // Path is relative to the output directory, always with forward slashes: // "commands/kettle/init.md", "skills/kettle-issue/SKILL.md". Path string // Body is the file as embedded — before any generated region is spliced in. Body string // Group is the command group whose flag table belongs in this file, or "". Group string } // Files is every document, sorted by path. // // Sorted, not in walk order, because the sort is the promise: two runs of the // same binary produce the same list, so a receipt and a --check diff are // comparable between machines. func Files() []File { var out []File _ = fs.WalkDir(assets, assetRoot, func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } rel := strings.TrimPrefix(p, assetRoot+"/") body, err := assets.ReadFile(p) if err != nil { return err } out = append(out, File{Path: rel, Body: string(body), Group: generated[rel]}) return nil }) sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) return out } // Groups lists every command group that has a file here, sorted. func Groups() []string { out := make([]string, 0, len(generated)) for _, g := range generated { out = append(out, g) } sort.Strings(out) return out } // PathFor is the output path carrying a group's flag table, or "". func PathFor(group string) string { for p, g := range generated { if g == group { return p } } return "" } // Dirs lists the directories the output tree is made of, parents first, so a // caller can create them in order. func Dirs() []string { seen := map[string]bool{} var out []string for _, f := range Files() { for _, d := range parents(path.Dir(f.Path)) { if !seen[d] { seen[d] = true out = append(out, d) } } } sort.Strings(out) return out } func parents(dir string) []string { if dir == "." || dir == "" { return nil } return append(parents(path.Dir(dir)), dir) }