Files
marketplace/cli/internal/cmd/tree.go
T
naudachu 9480e48312 feat: add the kettle CLI, replacing the plugin's Python scripts
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>
2026-08-11 19:05:39 +05:00

165 lines
4.1 KiB
Go

package cmd
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
func init() {
register(&Command{
Name: "tree",
Group: GroupIssue,
Args: "[<id>…]",
Short: "draw the dependency graph of the local store",
Long: `Edges come from the ` + "`depends:`" + ` metadata, which is the authoritative edge list;
prose in the body is never walked. Because the graph is slugs all the way down,
this works identically for issues that were never pushed anywhere.
Downwards is what this draws — what an issue depends on. The other direction is
a grep, not a flag:
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md`,
Examples: []Example{
{"kettle tree", "every root (nothing depends on it)"},
{"kettle tree wire-sqlc-appclick", "one subtree"},
{"kettle tree --depth 2 --write", "shallow, and saved beside the issues"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
depth := fs.Int("depth", 6, "maximum depth")
write := fs.Bool("write", false, "also write <store>/tree-<slug>.md")
out := storeFlag(fs)
return func(args []string) error {
root, err := storeRoot(*out)
if err != nil {
return err
}
if err := issue.StoreError(root); err != nil {
return err
}
issues, err := issue.LoadAll(root)
if err != nil {
return err
}
edges := issue.Graph(issues)
roots := args
for _, r := range roots {
if _, ok := issues[r]; !ok {
return Fail("no issue %q in %s", r, root)
}
}
if len(roots) == 0 {
dependedOn := map[string]bool{}
for _, deps := range edges {
for _, d := range deps {
dependedOn[d] = true
}
}
for id := range issues {
if !dependedOn[id] {
roots = append(roots, id)
}
}
if len(roots) == 0 { // every issue is somebody's dependency
for id := range issues {
roots = append(roots, id)
}
}
sort.Strings(roots)
}
text := renderTree(roots, issues, edges, *depth)
fmt.Print(text)
if *write {
slug := "all"
if len(roots) == 1 {
slug = roots[0]
}
path := filepath.Join(root, "tree-"+slug+".md")
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
return err
}
fmt.Printf("written: %s\n", path)
}
return nil
}
},
})
}
func renderTree(roots []string, issues map[string]*issue.Issue, edges map[string][]string, depth int) string {
var lines []string
seen := map[string]bool{}
var walk func(id, prefix string, isLast, isRoot bool, level int)
walk = func(id, prefix string, isLast, isRoot bool, level int) {
connector := ""
if !isRoot {
connector = "├── "
if isLast {
connector = "└── "
}
}
lines = append(lines, prefix+connector+treeLabel(id, issues, seen, edges))
if seen[id] || level >= depth {
return
}
seen[id] = true
kids := edges[id]
childPrefix := prefix
if !isRoot {
childPrefix = prefix + "│ "
if isLast {
childPrefix = prefix + " "
}
}
for i, k := range kids {
walk(k, childPrefix, i == len(kids)-1, false, level+1)
}
}
for _, r := range roots {
if seen[r] {
continue // already drawn as somebody's child — one tree, not two
}
walk(r, "", true, true, 0)
lines = append(lines, "")
}
head := fmt.Sprintf("%d root(s)", len(roots))
if len(roots) == 1 {
head = roots[0]
}
out := fmt.Sprintf("# Dependency tree — %s\n\n```\n%s```\n", head, strings.Join(lines, "\n"))
if cycles := issue.FindCycles(edges); len(cycles) > 0 {
out += "\n## Cycles\n\n"
for _, c := range cycles {
out += "- " + strings.Join(c, " -> ") + "\n"
}
}
return out
}
func treeLabel(id string, issues map[string]*issue.Issue, seen map[string]bool, edges map[string][]string) string {
i, ok := issues[id]
if !ok {
return id + " (not in the store)"
}
tail := ""
if seen[id] && len(edges[id]) > 0 {
tail = " (see above)"
}
typ := i.Type()
if typ == "" {
typ = "-"
}
return fmt.Sprintf("%s [%s] %s — %s %s.md%s", id, typ, i.Title, i.State, id, tail)
}