package cmd import ( "bufio" "fmt" "os" "path/filepath" "strconv" "strings" "golang.org/x/term" "git.noodles.cam/claude-skills/marketplace/cli/internal/config" "git.noodles.cam/claude-skills/marketplace/cli/internal/project" ) // askInit fills in an initOptions by asking, and is the ONLY thing in this // binary that reads from a terminal. // // Two rules hold it in place, and both are about what it is not allowed to // become. It answers no question the flags cannot answer, so nothing here is a // capability that exists only behind a prompt; and it performs nothing itself — // it fills in the struct and hands it back, so the run that follows an // interactive session is byte for byte the run somebody else gets from flags. func askInit(opts *initOptions) error { if !term.IsTerminal(int(os.Stdin.Fd())) { return Fail("--interactive needs a terminal, and standard input is not one.\n" + "Every question it asks has a flag: --login, --repo, --scaffold-out, --no-scaffold, --mirror-hook.") } in := bufio.NewReader(os.Stdin) fmt.Printf("kettle init — %s\n\n", opts.Root) if main := project.MainWorktree(opts.Root); main != "" { // Asked before anything else, because every answer after it would be // about a project that must not be created here. return Fail("%s is a linked worktree of the project at %s — initialize the main checkout instead", opts.Root, main) } if err := askLogin(in, opts); err != nil { return err } if err := askRepo(in, opts); err != nil { return err } opts.Scaffold = askYesNo(in, "Write the kettle commands and skills into "+ relativeTo(opts.Root, opts.scaffoldOut())+"?", true) opts.MirrorHook = askYesNo(in, "Mirror AGENTS.md to CLAUDE.md in this project? "+ "(registers `kettle mirror --hook` on PreToolUse)", false) fmt.Println() return nil } // askLogin offers what the machine already holds, and never invents one. // // Adding a login is offered here rather than left to `kettle auth add` for one // reason: this is a person at a terminal, so the token can be read with the echo // turned off — no shell history, no temp file, no scrollback. That is strictly // better than every other way of getting a secret into this program, and it is // the single strongest argument for the wizard existing at all. func askLogin(in *bufio.Reader, opts *initOptions) error { logins, err := config.LoadLogins() if err != nil { return err } if opts.Login != "" { fmt.Printf("login %s (given on the command line)\n", opts.Login) return nil } names := logins.Names() fmt.Println("Logins on this machine:") if len(names) == 0 { fmt.Println(" (none)") } for i, l := range logins.Logins { fmt.Printf(" %d) %-16s %s\n", i+1, l.Name, l.URL) } fmt.Printf(" a) add one\n s) skip — pin a login later with `kettle init --login `\n") for { answer := strings.TrimSpace(ask(in, "Which login should this project run under?", "s")) switch strings.ToLower(answer) { case "s", "skip": return nil case "a", "add": name, err := addLogin(in, logins) if err != nil { return err } opts.Login = name return nil } if n, err := strconv.Atoi(answer); err == nil && n >= 1 && n <= len(logins.Logins) { opts.Login = logins.Logins[n-1].Name return nil } // A name typed in full is the same answer as its number, and is what // somebody who already knows the login will reach for. if logins.Find(answer) != nil { opts.Login = answer return nil } fmt.Printf(" %q is not one of them.\n", answer) } } // addLogin writes one entry into the machine-wide file, token and all. func addLogin(in *bufio.Reader, logins *config.Logins) (string, error) { name := required(in, " Name for this login (a label, not a username)") url := strings.TrimRight(required(in, " Instance URL, e.g. https://git.example.com"), "/") user := ask(in, " Account it belongs to (documentation only)", "") scopes := ask(in, " Scopes it was minted with (documentation only)", "write:issue,write:repository") token, err := askSecret(" Token (not echoed)") if err != nil { return "", err } if token == "" { return "", Fail("no token given") } entry := config.Login{ Name: name, URL: url, User: user, Scopes: splitScopes(scopes), Token: token, } if existing := logins.Find(name); existing != nil { *existing = entry } else { logins.Logins = append(logins.Logins, entry) } if err := config.SaveLogins(logins); err != nil { return "", err } fmt.Printf(" %s -> %s %s\n\n", entry.Name, entry.URL, config.LoginsPath()) return name, nil } // askRepo offers what the git remote says, because that is right nearly every // time and wrong in a way the operator can see immediately. func askRepo(in *bufio.Reader, opts *initOptions) error { if opts.Repo != "" { fmt.Printf("repo %s (given on the command line)\n", opts.Repo) return nil } guess := repoFromGitConfig(opts.Root) answer := strings.TrimSpace(ask(in, "Tracker repository, as owner/name", guess)) if answer == "" { return nil } if owner, name, ok := strings.Cut(answer, "/"); !ok || owner == "" || name == "" { return Fail("%q is not owner/name", answer) } opts.Repo = answer return nil } // repoFromGitConfig reads owner/name out of `origin` in .git/config, or "". // // Parsed rather than shelled out to, because `git` is one more thing that has to // be installed and one more process to fail in a way this has to interpret. A // guess is worth exactly what it costs, and this costs a file read: anything it // cannot make sense of is no guess at all, and the operator types the answer. func repoFromGitConfig(root string) string { raw, err := os.ReadFile(filepath.Join(root, ".git", "config")) if err != nil { return "" } inOrigin := false for _, line := range strings.Split(string(raw), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "[") { inOrigin = strings.HasPrefix(line, `[remote "origin"]`) continue } if !inOrigin { continue } value, ok := strings.CutPrefix(line, "url") if !ok { continue } if _, v, found := strings.Cut(value, "="); found { return ownerName(strings.TrimSpace(v)) } } return "" } // ownerName is the last two path elements of a git URL, without any .git. // `git@host:owner/name.git` and `https://host/owner/name` both answer // `owner/name`; anything else answers "". func ownerName(url string) string { url = strings.TrimSuffix(strings.TrimSpace(url), ".git") if _, after, found := strings.Cut(url, "://"); found { url = after if _, path, ok := strings.Cut(url, "/"); ok { url = path } else { return "" } } else if _, after, found := strings.Cut(url, ":"); found { url = after } parts := strings.Split(strings.Trim(url, "/"), "/") if len(parts) < 2 { return "" } owner, name := parts[len(parts)-2], parts[len(parts)-1] if owner == "" || name == "" { return "" } return owner + "/" + name } // ask prints a question and returns the answer, or def when the line is empty. func ask(in *bufio.Reader, question, def string) string { if def != "" { fmt.Printf("%s [%s]: ", question, def) } else { fmt.Printf("%s: ", question) } line, err := in.ReadString('\n') if err != nil && line == "" { return def } if answer := strings.TrimSpace(line); answer != "" { return answer } return def } func required(in *bufio.Reader, question string) string { for { if answer := ask(in, question, ""); answer != "" { return answer } } } // askYesNo is deliberately biased: the default is what an operator gets by // holding down return, so every prompt whose wrong answer costs something // defaults to no. func askYesNo(in *bufio.Reader, question string, def bool) bool { hint := "y/N" if def { hint = "Y/n" } for { fmt.Printf("%s [%s]: ", question, hint) line, err := in.ReadString('\n') if err != nil && line == "" { return def } switch strings.ToLower(strings.TrimSpace(line)) { case "": return def case "y", "yes": return true case "n", "no": return false } } } // askSecret reads a line with the terminal's echo turned off. // // This is the whole reason --interactive needs a terminal rather than merely // preferring one: a token typed at a prompt that echoes is a token in somebody's // scrollback, and a token passed as an argument is a token in their shell // history. Neither is recoverable after the fact. func askSecret(question string) (string, error) { fmt.Print(question + ": ") raw, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() if err != nil { return "", err } return strings.TrimSpace(string(raw)), nil }