9480e48312
The plugin resolved its issue store from `__file__`, which put it inside a versioned plugin cache: issues written from one project were invisible from the next, and `origin: local` files — the only copy of that work by definition — were stranded a version bump at a time. The walk that answers "which directory is the project" was written three times over, and in a linked worktree the three disagreed. Both are runtime failures rather than logic ones, so the fix is a compiled binary: one walk, imported rather than re-derived, and a layering rule the build graph enforces instead of a grep. Seven packages, knowledge flowing one way. `project` answers which directory is the project and depends on nothing. `issue` is the domain — format, taxonomy, validation, checkboxes, dependency graph, the store, eviction — offline, with no tracker in it. `wire` holds the protocol shapes. `gitea` is the transport, `mapping` the bridge, `config` the credentials, `cmd` the command tree. Four tests hold the boundaries, each failing on a real mistake rather than a naming convention. The marker moves to `.kettle/` and the login pin moves out of the harness's settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens live in one file per machine, mode 0600, outside every working tree. That retires the PreToolUse guard hook entirely — the binary holds its own credentials, so a command running under a login nobody chose is not expressible rather than caught. `kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a move: a store left behind at an old path is one somebody edits by accident months later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
158 lines
4.9 KiB
Go
158 lines
4.9 KiB
Go
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 (tmp/ or .tea/), 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
|
|
}
|
|
},
|
|
})
|
|
}
|