package cmd import ( "errors" "flag" "fmt" "io/fs" "os" "path/filepath" "strings" "unicode/utf8" "git.noodles.cam/claude-skills/marketplace/cli/internal/project" "git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold" ) // The region markers. What sits between them comes from the registry; the rest // of the document is the embedded prose around it. const ( genOpen = "" genClose = "" ) // genBanner opens every generated region. The first thing anybody who finds the // block wants to do is edit it in place, so the block says who wrote it and // which command writes it again. const genBanner = "**Generated from the kettle command registry by `kettle gen scaffold`.** " + "Everything between the two markers is replaced on the next run — " + "the prose around it is embedded in the binary and replaced with it." // exampleAlign is the widest example command that still gets its `# what` // padded into a column. One long pipeline would otherwise push every other // comment off the right edge of the page. const exampleAlign = 56 func init() { register(&Command{ Name: "gen", Group: GroupProject, Args: "scaffold", Short: "write this project's .claude/ commands, skills and subagent", Long: `A skill tells an agent how to invoke this binary, and a command is how an operator invokes one by hand. Both are written from here, whole, because both travel INSIDE the binary: the prose is embedded next to the code it describes and the flag tables are rendered from the command registry the binary is built from, so neither can be a version behind the other. That is the whole reason these documents are not a plugin any more. A plugin ships on its own cadence, and nothing on an operator's machine ever checked that the one they installed described the binary they installed — so a renamed flag could still arrive with documentation recommending the old one, which is exactly the failure the generated block was invented to prevent, one hop further downstream. EVERY FILE IS WRITTEN WHOLE, and that is a deliberate reversal. The old generator owned a region and left every byte outside it alone, because the prose around the block was somebody's hand-written file. It is not any more: it is embedded, so there is no hand-written half left to protect, and preserving local edits would mean freezing a project's documentation at whatever version first initialized it. The markers stay in the output so a reader can see which half came from the registry. WHAT THIS MEANS FOR A LOCAL EDIT: it does not survive. Run --check before an upgrade if you have made one; the fix for a sentence that is wrong is a newer kettle, not a patch that the next run silently discards. The output is deterministic to the byte — no timestamps, no map iteration — so regenerating something that has not changed produces no diff. --check is that property made useful: it writes nothing and exits 1 when any file on disk differs from what would be written, which is what a pre-commit hook or a CI step calls. It wins over --dry-run when both are given.`, Examples: []Example{ {"kettle gen scaffold", "write .claude/ under this project"}, {"kettle gen scaffold --out ~/code/x/.claude", "write it somewhere else"}, {"kettle gen scaffold --dry-run", "print what would change; write nothing"}, {"kettle gen scaffold --check", "exit 1 if the documents are out of date"}, }, Setup: func(fs *flag.FlagSet) func([]string) error { out := fs.String("out", "", "directory to write into (default: /"+scaffold.Marker+")") dryRun := fs.Bool("dry-run", false, "print what would change; write nothing") check := fs.Bool("check", false, "write nothing, exit 1 if anything is out of date") return func(args []string) error { target := "scaffold" if len(args) > 0 { target = args[0] } if len(args) > 1 || target != "scaffold" { return Fail("the only target is `scaffold` — try `kettle gen scaffold`") } dir, err := scaffoldDir(*out) if err != nil { return err } return genScaffold(dir, *dryRun, *check) } }, }) } // scaffoldDir resolves where the tree goes. // // An explicit --out is used exactly as typed, relative and all, because that is // what the operator asked for. Without one the answer comes from the marker, the // same walk every other command uses — and no marker is an answer rather than a // fallback, because a `.claude/` written into a plausible-looking directory is // the failure the marker exists to replace. func scaffoldDir(out string) (string, error) { if out != "" { return out, nil } root := project.Root("") if root == "" { return "", project.NotFoundError("") } return filepath.Join(root, scaffold.Marker), nil } func genScaffold(dir string, dryRun, check bool) error { // --check is a read-only question about the working tree, so it overrules // --dry-run rather than combining with it. if check { dryRun = true } files, err := renderAll() if err != nil { return err } var written, unchanged, outdated int for _, f := range files { path := filepath.Join(dir, filepath.FromSlash(f.Path)) existing, err := os.ReadFile(path) switch { case errors.Is(err, fs.ErrNotExist): outdated++ if err := report(path, "missing", "would create", "created", dryRun, check, func() error { return writeFile(path, f.Body) }); err != nil { return err } if !dryRun { written++ } case err != nil: return err case string(existing) == f.Body: unchanged++ fmt.Printf("%-13s %s\n", "unchanged", path) default: outdated++ if err := report(path, "stale", "would update", "updated", dryRun, check, func() error { return writeFile(path, f.Body) }); err != nil { return err } if !dryRun { written++ } } } switch { case check: fmt.Printf("%d file(s) checked, %d out of date\n", len(files), outdated) if outdated > 0 { fmt.Printf("run `kettle gen scaffold --out %s`\n", dir) return SilentError{Code: 1} } case dryRun: fmt.Printf("%d file(s) would change, %d unchanged — nothing was written\n", outdated, unchanged) default: fmt.Printf("%d file(s) written, %d unchanged\n", written, unchanged) } return nil } // report prints one line for one file and performs the write unless this run is // only answering a question. func report(path, checkWord, dryWord, doneWord string, dryRun, check bool, write func() error) error { switch { case check: fmt.Printf("%-13s %s\n", checkWord, path) case dryRun: fmt.Printf("%-13s %s\n", dryWord, path) default: if err := write(); err != nil { return err } fmt.Printf("%-13s %s\n", doneWord, path) } return nil } // renderAll is every embedded document with its generated region filled in. // // Nothing here touches the disk: the result is what the tree SHOULD be, and // comparing it against what is there is a separate question asked by the caller. // That split is what lets --check be exact rather than a heuristic about // timestamps. func renderAll() ([]scaffold.File, error) { files := scaffold.Files() out := make([]scaffold.File, 0, len(files)) for _, f := range files { if f.Group == "" { out = append(out, f) continue } block, err := renderGroup(commandsIn(f.Group)) if err != nil { return nil, err } body, err := spliceRegion(f.Body, block) if err != nil { // The embedded document is shipped inside this binary, so a missing // marker is a build-time mistake in this repository and not // something an operator can have caused. return nil, Fail("%s: %v — this is a bug in the embedded document, not in your project", f.Path, err) } f.Body = body out = append(out, f) } return out, nil } // errNoRegion is what a document that declares a region and has none reports. var errNoRegion = errors.New("no " + genOpen + " … " + genClose + " region") // renderGroup is the generated block for one group, without the markers and // without a trailing newline. func renderGroup(cmds []*Command) (string, error) { var b strings.Builder b.WriteString(genBanner) b.WriteString("\n") for _, c := range cmds { text := renderCommand(c) // A block holding either marker would cut itself in half on the next // run — the splice would end the region in the middle of the prose that // mentions it. Loud here rather than quietly truncated on disk. if strings.Contains(text, genOpen) || strings.Contains(text, genClose) { return "", Fail("command %q spells a region marker out in full; the generated block would then end inside itself — write it another way", c.Name) } b.WriteString(text) } return strings.TrimRight(b.String(), "\n"), nil } func renderCommand(c *Command) string { var b strings.Builder fmt.Fprintf(&b, "\n## `%s`\n\n%s\n", c.Usage(), c.Short) if long := strings.TrimSpace(c.Long); long != "" { b.WriteString("\n" + long + "\n") } if flags := c.Flags(); len(flags) > 0 { b.WriteString("\n| flag | default | what it does |\n| --- | --- | --- |\n") for _, f := range flags { fmt.Fprintf(&b, "| `--%s` | %s | %s |\n", f.Name, defaultCell(f.DefValue), cell(f.Usage)) } } if len(c.Examples) > 0 { w := exampleWidth(c.Examples) b.WriteString("\n```bash\n") for _, e := range c.Examples { pad := w - utf8.RuneCountInString(e.Cmd) if pad < 0 { pad = 0 } fmt.Fprintf(&b, "%s%s # %s\n", e.Cmd, strings.Repeat(" ", pad), e.What) } b.WriteString("```\n") } return b.String() } // exampleWidth is the column the `# what` comments line up at. Runes, not // bytes: an example with Cyrillic in it would otherwise pull the column left by // however many multi-byte characters it holds. func exampleWidth(examples []Example) int { w := 0 for _, e := range examples { if n := utf8.RuneCountInString(e.Cmd); n > w && n <= exampleAlign { w = n } } return w } func defaultCell(v string) string { if v == "" { return "—" } return "`" + cell(v) + "`" } // cell keeps a value from breaking out of its table row. func cell(s string) string { s = strings.ReplaceAll(s, "\n", " ") return strings.ReplaceAll(s, "|", `\|`) } func region(block string) string { return genOpen + "\n" + block + "\n" + genClose } // spliceRegion swaps the block into existing, leaving every other byte alone. func spliceRegion(existing, block string) (string, error) { start := strings.Index(existing, genOpen) if start < 0 { return "", errNoRegion } rest := start + len(genOpen) end := strings.Index(existing[rest:], genClose) if end < 0 { return "", fmt.Errorf("%s is missing its %s", genOpen, genClose) } return existing[:start] + region(block) + existing[rest+end+len(genClose):], nil } // commandsIn lists a group's commands in the order Commands() returns them — // the same order twice, so two runs cannot differ. func commandsIn(group string) []*Command { var out []*Command for _, c := range Commands() { if c.Group == group { out = append(out, c) } } return out } func writeFile(path, content string) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } return os.WriteFile(path, []byte(content), 0o644) }