package cmd import ( "flag" "fmt" "os" "path/filepath" "strings" "git.noodles.cam/claude-skills/marketplace/cli/internal/config" "git.noodles.cam/claude-skills/marketplace/cli/internal/project" ) // 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. 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, 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. Do NOT run this inside a linked worktree. 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.`, Examples: []Example{ {"kettle init", "initialize the current directory"}, {"kettle init --login noodles --repo claude-skills/marketplace", "and point it at a tracker"}, {"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") 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) } // 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(root); existing != "" && existing != 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", root, existing) } if *repo != "" { if owner, name, ok := strings.Cut(*repo, "/"); !ok || owner == "" || name == "" { return Fail("--repo %q is not owner/name", *repo) } } done, err := project.Init(root, *dryRun) if err != nil { return err } line, err := writeConfig(root, *login, *repo, *dryRun) if err != nil { return err } if line != "" { done = append(done, line) } prefix := "" if *dryRun { prefix = "would: " } for _, l := range done { fmt.Println(prefix + l) } return nil } }, }) }