Files
marketplace/cli/internal/cmd/interactive.go
T
naudachu 8b1b11001a feat: drop the kettle plugin; the binary writes its own skills
The plugin and the binary shipped on two release cadences and nothing on an
operator's machine ever checked that the one they installed described the other.
The generated flag block existed precisely so a renamed flag could not ship with
documentation recommending the old one — and then shipped one version behind the
registry it came from, which is the same bug one hop downstream.

So the prose moved into the binary. `internal/scaffold` embeds every document;
`kettle init` and `kettle gen scaffold` write them into a project's own
`.claude/`. The two cannot disagree because there is one artefact.

The namespace survived the move. A project's skills are flat, so the prefix is
spelled into the directory name (`kettle-issue`); a project's *commands* take
their namespace from a subdirectory, so `commands/kettle/init.md` is still
`/kettle:init`. Four of the six command files are thin pointers at a skill, and
that is what kept ~1,600 lines of `/kettle:…` cross-references true without a
rewrite. `init` and `auth` lost `disable-model-invocation: true` — being a
command is that property — and `auth` now restricts `allowed-tools` so a model
cannot reach `kettle auth add` at all.

`gen scaffold` writes files whole rather than splicing a region. The old
refusal protected somebody's hand-written prose around the block; that prose is
embedded now, so there is none to protect, and preserving local edits would
freeze a project's documentation at whatever version first initialized it.
`--check` warns before an upgrade discards one.

The plugin's `agents-sync.sh` — 141 lines of Python behind a filename that said
`.sh` — became `internal/mirror` and `kettle mirror`. Same seven branches, same
refusal to merge two real files that differ, now with a table test per branch
and a check that a repair converges in one pass. `--hook` is the PreToolUse
form and exits 0 on every path including a panic. It is opt-in per project,
which is strictly narrower than the plugin hook that was on for everybody who
installed it.

`kettle init --interactive` walks a person through the login, the token (read
with the echo off, so it lands in no history and no file), the repository, the
`.claude/` tree and the mirror hook. It refuses a stdin that is not a terminal
and names the flags instead: every question it asks has one, and it performs
nothing itself, so an interactive run and a flag run are one code path.

Two rules that used to be prose are now the binary's: init refuses a linked
worktree and names the main checkout, and writing into an existing
`.claude/settings.json` is refused with the snippet printed rather than
reformatting a file the operator commits.

The scaffold version stamp went to its own `.kettle/scaffold.yaml` rather than
into `config.yaml`, because unknown keys there are a hard error and that file
may be committed and read by whatever build each machine has.

golang.org/x/term becomes a direct dependency; it was already in the tree
indirectly, so no module was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:17:24 +05:00

286 lines
8.5 KiB
Go

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 <name>`\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
}