Files
marketplace/cli/internal/project/project.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

258 lines
7.9 KiB
Go

// Package project answers one question: which directory is the project.
//
// Everything that is a fact about the project — the issue store, the request
// payload scratchpad, the tracker the issues belong to — is resolved from the
// answer, and the answer is found by one walk written once. The guard, the
// transport and the store used to each have their own copy of that walk in
// Python, and they disagreed: in a linked worktree `tea` worked while every
// script reported no login pinned.
//
// This package depends on nothing but the standard library, and nothing in it
// resolves from the binary's own location. Where an installation keeps its
// files is a fact about the installation; which issues a tree has is a fact
// about the tree, and a binary installed in one place and pointed at another
// must answer from the one it was pointed at.
package project
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Marker is the directory an operator creates to state "this is a project".
// It is never inferred. `.git` was tried and is in every clone, including this
// repository's own, so a plugin resolved its store inside itself.
const Marker = ".kettle"
// Everything under the marker, each resolved by the same walk so that which
// command wrote a file cannot change where it landed.
var (
storeParts = []string{Marker, "issues"}
payloadParts = []string{Marker, "payload"}
configParts = []string{Marker, "config.yaml"}
scaffoldParts = []string{Marker, "scaffold.yaml"}
)
// Anchors are the directories a root search starts from, in order, first hit
// wins: the project the agent harness was opened on, then the working
// directory. A non-empty start overrides both and exists so resolution can be
// exercised against a scratch tree.
func Anchors(start string) []string {
if start != "" {
abs, err := filepath.Abs(start)
if err != nil {
return nil
}
return []string{abs}
}
var out []string
for _, d := range []string{os.Getenv("CLAUDE_PROJECT_DIR"), cwd()} {
if d == "" || !isDir(d) {
continue
}
abs, err := filepath.Abs(d)
if err != nil {
continue
}
if !contains(out, abs) {
out = append(out, abs)
}
}
return out
}
// Parents yields start and every ancestor of it, up to the filesystem root.
func Parents(start string) []string {
d, err := filepath.Abs(start)
if err != nil {
return nil
}
var out []string
for {
out = append(out, d)
parent := filepath.Dir(d)
if parent == d {
return out
}
d = parent
}
}
// GitDirOf is the private git directory `d/.git` points at, or "".
//
// Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a directory
// and there is nothing to follow.
func GitDirOf(d string) string {
p := filepath.Join(d, ".git")
fi, err := os.Stat(p)
if err != nil || fi.IsDir() {
return ""
}
head, err := os.ReadFile(p)
if err != nil {
return ""
}
for _, line := range strings.Split(string(head), "\n") {
line = strings.TrimSpace(line)
target, ok := strings.CutPrefix(line, "gitdir:")
if !ok {
continue
}
target = strings.TrimSpace(target)
if target == "" {
return ""
}
if !filepath.IsAbs(target) {
target = filepath.Join(d, target)
}
abs, err := filepath.Abs(target)
if err != nil {
return ""
}
return abs
}
return ""
}
// MainWorktree is the main working tree of d's repository when d is a linked
// worktree, or "".
//
// `<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir` file
// holds a path to `<main>/.git`; the main working tree is its parent. The
// `.git` basename check keeps this to worktrees: a submodule's `.git` is a
// pointer too, but it points into `<super>/.git/modules/…`, and the tree it
// belongs to is already on the parent chain.
func MainWorktree(d string) string {
gitdir := GitDirOf(d)
if gitdir == "" || !isDir(gitdir) {
return ""
}
common := gitdir
if raw, err := os.ReadFile(filepath.Join(gitdir, "commondir")); err == nil {
if rel := strings.TrimSpace(string(raw)); rel != "" {
if abs, err := filepath.Abs(filepath.Join(gitdir, rel)); err == nil {
common = abs
}
}
}
if filepath.Base(common) != ".git" {
return ""
}
root := filepath.Dir(common)
abs, err := filepath.Abs(d)
if err != nil {
return ""
}
if root != "" && isDir(root) && root != abs {
return root
}
return ""
}
// Root is the nearest ancestor of an anchor (inclusive) holding the marker, or
// "" when there is no project.
//
// A marker, not a fixed number of `..` hops: how deep a caller sits below the
// root is an implementation detail of the layout, and the layout is not a
// promise. Walking up means every command sees one store from anywhere inside
// the project — including from inside the store itself — while a cd into a
// DIFFERENT project correctly answers with that project's store.
//
// A linked worktree is the same project on another branch, and the marker is
// gitignored, so it is only ever in the main checkout: the chain is searched
// first and always wins, then the main working tree of any worktree met on it.
func Root(start string) string {
for _, anchor := range Anchors(start) {
var hops []string
for _, d := range Parents(anchor) {
if isDir(filepath.Join(d, Marker)) {
return d
}
if main := MainWorktree(d); main != "" && !contains(hops, main) {
hops = append(hops, main)
}
}
// One level of indirection, never two: a main checkout is not itself a
// linked worktree, so this cannot chain and cannot cycle.
for _, hop := range hops {
for _, d := range Parents(hop) {
if isDir(filepath.Join(d, Marker)) {
return d
}
}
}
}
return ""
}
// StoreRoot is the absolute path of the issue store, or "" with no project.
func StoreRoot(start string) string { return under(start, storeParts) }
// PayloadRoot is the absolute path of the request-payload scratchpad, or "".
//
// A sibling of the store under the same marker, resolved by the same walk, so
// the scratchpad and the store can never end up in two different projects —
// and so a scratchpad can never sit INSIDE a store, where a call that touched
// no issue would still materialize the issue directory.
func PayloadRoot(start string) string { return under(start, payloadParts) }
// ConfigPath is the absolute path of the project's tracker config, or "".
func ConfigPath(start string) string { return under(start, configParts) }
// ScaffoldPath is the absolute path of the record of what was written into the
// project's `.claude/` tree, or "".
//
// A file of its own rather than a field in config.yaml, and the reason is a rule
// stated in [config]: an unknown key in config.yaml is an error rather than a
// silent drop, so a field added there is a one-way door for a file that may be
// committed and read by whatever version each machine happens to have. This
// record is written and read by one binary about one directory, so it can carry
// that cost where the shared file cannot.
func ScaffoldPath(start string) string { return under(start, scaffoldParts) }
func under(start string, parts []string) string {
root := Root(start)
if root == "" {
return ""
}
return filepath.Join(append([]string{root}, parts...)...)
}
// NotFoundError explains why no project could be resolved, naming every
// directory the search began from.
//
// The anchors, not the whole chain above them: an operator who sees the two
// places the search started knows immediately whether it started where they
// meant it to.
func NotFoundError(start string) error {
dirs := strings.Join(Anchors(start), " and ")
if dirs == "" {
dirs = "nowhere"
}
return fmt.Errorf("no %s/ found — searched up from %s. Run `kettle init` in the project you mean to track issues in", Marker, dirs)
}
func isDir(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
func cwd() string {
d, err := os.Getwd()
if err != nil {
return ""
}
return d
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}