package cmd import ( "errors" "flag" "fmt" "os" "path/filepath" "strings" "git.noodles.cam/claude-skills/marketplace/cli/internal/config" "git.noodles.cam/claude-skills/marketplace/cli/internal/project" "git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold" ) // initOptions is everything init does, as data. // // It exists so that --interactive and the flags are two ways of filling in one // struct rather than two implementations of one command. Every question the // wizard asks has a field here and therefore a flag: a step that could only be // reached by answering a prompt would be a step no script, no CI run and no // agent could take. type initOptions struct { Root string Login string Repo string Scaffold bool ScaffoldOut string MirrorHook bool ForceSettings bool DryRun bool } // writeConfig creates or updates .kettle/config.yaml, touching only the // settings it was given. // // Init is idempotent, and that has to include the config: re-running it to add // a repository must not silently drop the login somebody pinned last week. func writeConfig(root, login, repo string, dryRun bool) (string, error) { path := filepath.Join(root, project.Marker, "config.yaml") rel := filepath.Join(project.Marker, "config.yaml") cfg, existed, err := config.ReadProjectFile(path) if err != nil { return "", err } changed := !existed if login != "" && cfg.Login != login { cfg.Login, changed = login, true } if repo != "" && cfg.Repo != repo { cfg.Repo, changed = repo, true } if !changed { return "", nil } verb := "updated" if !existed { verb = "created" } detail := "no login or repository pinned yet — `kettle init --login … --repo …`" if cfg.Login != "" || cfg.Repo != "" { detail = fmt.Sprintf("login: %s, repo: %s", orNone(cfg.Login), orNone(cfg.Repo)) } if dryRun { return fmt.Sprintf("%s %s (%s)", verb, rel, detail), nil } if err := config.SaveProject(path, cfg); err != nil { return "", err } return fmt.Sprintf("%s %s (%s)", verb, rel, detail), nil } func orNone(s string) string { if s == "" { return "none" } return s } func init() { register(&Command{ Name: "init", Group: GroupProject, Short: "make this directory a project that tracks issues", Long: `Creates ` + "`.kettle/`" + ` — the marker every other command resolves the store from, and ` + "`.kettle/config.yaml`" + `, which says which tracker repository these issues belong to and which login to reach it under — and writes ` + "`.claude/`" + `: the slash commands an operator invokes, the skills a model loads, and the runner subagent. The marker is deliberately something an operator makes, not something inferred from the tree: ` + "`.git`" + ` is in every clone, so anything that inferred a root from one would write issues into whatever it happened to be installed in. --login pins a name, never a credential. The tokens live in one file per machine, outside every working tree, managed with ` + "`kettle auth`" + `. All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates an older store in if it finds one (either layout the tea plugin used, oldest first), writes the config without disturbing settings it was not given, writes the .claude/ tree, and adds .kettle/ to .gitignore. Each migration is a move, not a copy — two stores is the state the marker exists to prevent — and it refuses to pick a winner when both sides hold a file of the same name. IT REFUSES TO RUN IN A LINKED WORKTREE, and names the main checkout instead. A worktree is the same project on another branch and reaches the store by a hop out to the main checkout; a marker here would give one project two stores, and the directory holding the second one disappears with the branch. --interactive walks a person through the whole thing — the login, the token with the echo turned off, the repository, the .claude/ tree and the AGENTS.md mirror hook. IT REQUIRES A TERMINAL and refuses a standard input that is not one, which is deliberate: every question it asks has a flag beside it, so nothing that is not a person ever needs to answer a prompt.`, Examples: []Example{ {"kettle init", "initialize the current directory"}, {"kettle init --interactive", "be walked through it, at a terminal"}, {"kettle init --login noodles --repo claude-skills/marketplace", "and point it at a tracker"}, {"kettle init --mirror-hook", "register the AGENTS.md mirror on PreToolUse(Bash)"}, {"kettle init --at ~/code/x", "initialize somewhere else"}, {"kettle init --dry-run", "say what it would do, touch nothing"}, }, Setup: func(fs *flag.FlagSet) func([]string) error { at := fs.String("at", "", "directory to initialize (default: the working directory)") login := fs.String("login", "", "name of a login in the machine-wide file (see `kettle auth`)") repo := fs.String("repo", "", "tracker repository, as owner/name") interactive := fs.Bool("interactive", false, "ask, one question at a time; requires a terminal") noScaffold := fs.Bool("no-scaffold", false, "do not write the .claude/ commands, skills and subagent") scaffoldOut := fs.String("scaffold-out", "", "where the .claude/ tree goes (default: /"+scaffold.Marker+")") mirrorHook := fs.Bool("mirror-hook", false, "register `kettle mirror --hook` on PreToolUse(Bash)") forceSettings := fs.Bool("force-settings", false, "let the hook be merged into an existing settings.json, reformatting it") dryRun := fs.Bool("dry-run", false, "report what would happen; change nothing") return func(args []string) error { root := *at if root == "" { wd, err := os.Getwd() if err != nil { return err } root = wd } root, err := filepath.Abs(root) if err != nil { return err } if fi, err := os.Stat(root); err != nil || !fi.IsDir() { return Fail("%s is not a directory", root) } opts := initOptions{ Root: root, Login: *login, Repo: *repo, Scaffold: !*noScaffold, ScaffoldOut: *scaffoldOut, MirrorHook: *mirrorHook, ForceSettings: *forceSettings, DryRun: *dryRun, } if *interactive { if err := askInit(&opts); err != nil { return err } } return runInit(opts) } }, }) } func runInit(opts initOptions) error { // A worktree is the same project on another branch. The rule used to live in // a skill somebody had to read; it is here because the wizard is now the // front door and a front door cannot rely on the reader having read anything. if main := project.MainWorktree(opts.Root); main != "" { return Fail("%s is a linked worktree of the project at %s.\n"+ "A worktree reaches that store on its own — the walk crosses to it through the `gitdir:` in the .git file — "+ "and a marker here would give one project two stores, the second of which is deleted with the branch.\n"+ "Initialize the main checkout instead: kettle init --at %s", opts.Root, main, main) } // A second marker inside an existing project gives it a second store, and // the nearer one wins — which is a surprise worth naming before it happens, // not after. if existing := project.Root(opts.Root); existing != "" && existing != opts.Root { fmt.Fprintf(os.Stderr, "warning: %s already sits inside the project at %s — a second marker here gives it a second store, and the nearer one wins.\n", opts.Root, existing) } if opts.Repo != "" { if owner, name, ok := strings.Cut(opts.Repo, "/"); !ok || owner == "" || name == "" { return Fail("--repo %q is not owner/name", opts.Repo) } } done, err := project.Init(opts.Root, opts.DryRun) if err != nil { return err } line, err := writeConfig(opts.Root, opts.Login, opts.Repo, opts.DryRun) if err != nil { return err } if line != "" { done = append(done, line) } prefix := "" if opts.DryRun { prefix = "would: " } for _, l := range done { fmt.Println(prefix + l) } if opts.Scaffold { if err := initScaffold(opts); err != nil { return err } } if opts.MirrorHook { if err := initMirrorHook(opts); err != nil { return err } } return nil } // scaffoldOut is where the .claude/ tree goes for this run. An explicit // --scaffold-out is used exactly as typed; without one it sits beside the // marker, which is the only place another command can find it again. func (o initOptions) scaffoldOut() string { if o.ScaffoldOut != "" { return o.ScaffoldOut } return filepath.Join(o.Root, scaffold.Marker) } func initScaffold(opts initOptions) error { out := opts.scaffoldOut() if err := genScaffold(out, opts.DryRun, false); err != nil { return err } if opts.DryRun { return nil } // The stamp is written last and is not load-bearing: nothing resolves from // it, and deleting it costs the warning in `kettle config` and nothing else. rec := &config.Scaffold{Version: Version, Out: relativeTo(opts.Root, out)} return config.SaveScaffoldFile(filepath.Join(opts.Root, project.Marker, "scaffold.yaml"), rec) } func initMirrorHook(opts initOptions) error { line, err := writeMirrorHook(opts.scaffoldOut(), opts.ForceSettings, opts.DryRun) if err != nil { // An existing settings.json is a decision for the operator, not a // failure of the run: everything before this point already happened and // saying otherwise would send them looking for damage there is none of. if errors.Is(err, errSettingsExist) { fmt.Fprintf(os.Stderr, "kettle init: the mirror hook was not registered — %v\n", err) return nil } return err } if line != "" { prefix := "" if opts.DryRun { prefix = "would: " } fmt.Println(prefix + line) } return nil } // relativeTo is path as written down in the scaffold record: relative when it // sits under the project, absolute when the operator sent it somewhere else. func relativeTo(root, path string) string { rel, err := filepath.Rel(root, path) if err != nil || strings.HasPrefix(rel, "..") { return path } return rel }