diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index c441bdd..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18c331e..8dd8ef1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,21 @@ jobs: with: go-version: '1.23' cache: true + + # Catch the easy mistake of pushing a tag without bumping VERSION. + # docops update-check fetches VERSION via raw.githubusercontent.com, + # so a mismatch silently breaks "is upgrade available" for everyone + # who already has the previous release. Fail loud here instead. + - name: Verify VERSION matches tag + run: | + tag_version="${GITHUB_REF#refs/tags/v}" + file_version=$(tr -d '[:space:]' < VERSION) + if [ "$tag_version" != "$file_version" ]; then + echo "::error::tag $GITHUB_REF (=$tag_version) does not match VERSION file (=$file_version)" + echo "::error::run 'make release VERSION=$tag_version' on main to do this correctly" + exit 1 + fi + - uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser diff --git a/Makefile b/Makefile index 3b8967f..54a605a 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ LDFLAGS := -s -w \ -X $(PKG)/internal/version.Commit=$(COMMIT) \ -X $(PKG)/internal/version.Date=$(DATE) -.PHONY: build install test lint clean tidy release-snapshot +.PHONY: build install test lint clean tidy release-snapshot release build: @mkdir -p bin @@ -31,3 +31,45 @@ clean: release-snapshot: goreleaser release --snapshot --clean + +# make release VERSION=X.Y.Z +# Bumps the VERSION file (read by docops update-check via raw.githubusercontent.com), +# commits the bump, creates an annotated v$VERSION tag, and pushes both. +# The tag push triggers .github/workflows/release.yml → goreleaser builds and +# publishes the GitHub Release. Pass DRY_RUN=1 to print what would happen +# without writing or pushing anything. +release: + @if [ -z "$(VERSION)" ] || [ "$(VERSION)" = "dev" ]; then \ + echo "usage: make release VERSION=X.Y.Z [DRY_RUN=1]"; exit 2; \ + fi + @echo "$(VERSION)" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$$' || \ + (echo "VERSION must match X.Y.Z (got $(VERSION))" && exit 2) + @if ! git diff-index --quiet HEAD --; then \ + echo "tracked files have uncommitted changes; commit or stash first"; exit 2; \ + fi + @branch=$$(git rev-parse --abbrev-ref HEAD); \ + if [ "$$branch" != "main" ]; then \ + echo "refusing to release from '$$branch' — switch to main first"; exit 2; \ + fi + @if git rev-parse "v$(VERSION)" >/dev/null 2>&1; then \ + echo "tag v$(VERSION) already exists locally"; exit 2; \ + fi + @if git ls-remote --exit-code --tags origin "refs/tags/v$(VERSION)" >/dev/null 2>&1; then \ + echo "tag v$(VERSION) already exists on origin"; exit 2; \ + fi + @if [ -n "$(DRY_RUN)" ]; then \ + echo "[dry-run] would write '$(VERSION)' to VERSION"; \ + echo "[dry-run] would commit: chore: release v$(VERSION)"; \ + echo "[dry-run] would tag: v$(VERSION)"; \ + echo "[dry-run] would push: main + v$(VERSION) to origin"; \ + exit 0; \ + fi + echo "$(VERSION)" > VERSION + git add VERSION + git commit -m "chore: release v$(VERSION)" + git tag -a "v$(VERSION)" -m "v$(VERSION)" + git push origin main + git push origin "v$(VERSION)" + @echo + @echo "v$(VERSION) tagged and pushed. Watch the release workflow:" + @echo " gh run watch" diff --git a/README.md b/README.md index 0de35a8..1cecd3b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,23 @@ A GHCR image lands in a follow-up release. Until then, use Homebrew, Scoop, or d Per-platform packages (`@docops/cli-darwin-arm64`, `@docops/cli-linux-x64`, ...) will publish alongside a future release. `npm i -g @docops/cli` resolves the matching native binary via `optionalDependencies` — no postinstall network fetch. See ADR-0012 for distribution rationale. +### Upgrading an existing project + +After `brew upgrade docops` (or your package manager equivalent), pull the +new binary's shipped templates into your project without clobbering +`docops.yaml` or your pre-commit hook: + +```sh +brew upgrade docops # or scoop update docops, etc. +docops upgrade # syncs skills, schemas, AGENTS.md block +docops upgrade --dry-run # preview first if you prefer +``` + +`docops upgrade` only touches DocOps-owned scaffolding. To also rewrite +`docops.yaml` or reinstall the pre-commit hook, opt in with `--config` +or `--hook`. Run `docops update-check` (or wait for `docops upgrade` to +warn you on its own) to learn when a new release is available. + ## Smoke test ```sh @@ -43,7 +60,7 @@ docops --help From the root of any git repo (empty or existing): ```sh -docops init # scaffolds docs/, docops.yaml, schemas, skills, pre-commit hook +docops init # scaffolds docs/, docops.yaml, schemas, skills, pre-commit hook, AGENTS.md + CLAUDE.md docops new ctx "Vision" --type brief --no-open # first CTX docops new adr "Pick a database" # first decision docops new task "Wire up SQLite" --requires ADR-0001 @@ -90,19 +107,28 @@ make lint # go vet ./... ### Release -Tag a commit with `vX.Y.Z`; the `Release` workflow runs goreleaser, which builds the matrix, attaches archives + checksums to the GitHub Release, and updates the brew/scoop stubs (once those repos exist). +From a clean `main`: ```sh -git tag v0.1.0 -git push origin v0.1.0 +make release VERSION=0.1.2 ``` -For a dry run: +That bumps the `VERSION` file (which `docops update-check` reads via raw.githubusercontent.com), commits the bump, creates an annotated `v0.1.2` tag, and pushes both to `origin`. The tag triggers `.github/workflows/release.yml`, which verifies that the tag matches the `VERSION` file and then runs goreleaser to build the matrix, attach archives + checksums to the GitHub Release, and update the brew/scoop stubs (once those tap repos exist). + +Preview without writing: + +```sh +make release VERSION=0.1.2 DRY_RUN=1 +``` + +Local snapshot build (no tag, no push): ```sh make release-snapshot ``` +If you tag manually with `git tag` and forget to bump `VERSION`, the release workflow fails fast with a clear error pointing you at `make release`. + ## License MIT — see LICENSE. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..17e51c3 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.1 diff --git a/cmd/docops/bootstrap.go b/cmd/docops/bootstrap.go new file mode 100644 index 0000000..b0ee61d --- /dev/null +++ b/cmd/docops/bootstrap.go @@ -0,0 +1,69 @@ +package main + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/logicwind/docops/internal/config" + "github.com/logicwind/docops/internal/index" + "github.com/logicwind/docops/internal/loader" + "github.com/logicwind/docops/internal/validator" +) + +// bootstrapIndex is the shared bootstrap sequence for read-only commands: +// find config, load docs, validate, build in-memory index. +// Returns the index, the project root, and an exit code (0 = success, 2 = error). +// On any failure it prints a prefixed error to stderr. +func bootstrapIndex(cmd string) (*index.Index, string, int) { + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "docops %s: %v\n", cmd, err) + return nil, "", 2 + } + cfg, root, err := config.FindAndLoad(cwd) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + fmt.Fprintf(os.Stderr, "docops %s: no docops.yaml found — run `docops init` first\n", cmd) + return nil, "", 2 + } + fmt.Fprintf(os.Stderr, "docops %s: %v\n", cmd, err) + return nil, "", 2 + } + set, err := loader.Load(root, cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "docops %s: %v\n", cmd, err) + return nil, "", 2 + } + report := validator.Validate(set, cfg) + if !report.OK() { + fmt.Fprintf(os.Stderr, "docops %s: refusing: %d validation error(s); run 'docops validate'\n", cmd, len(report.Errors)) + return nil, "", 2 + } + idx, err := index.Build(set, cfg, root, time.Now()) + if err != nil { + fmt.Fprintf(os.Stderr, "docops %s: build index: %v\n", cmd, err) + return nil, "", 2 + } + return idx, root, 0 +} + +// indexLookup finds a doc by ID. +func indexLookup(idx *index.Index, id string) (index.IndexedDoc, bool) { + for _, doc := range idx.Docs { + if doc.ID == id { + return doc, true + } + } + return index.IndexedDoc{}, false +} + +// indexByID builds a map from ID → IndexedDoc for O(1) lookup. +func indexByID(idx *index.Index) map[string]index.IndexedDoc { + m := make(map[string]index.IndexedDoc, len(idx.Docs)) + for _, doc := range idx.Docs { + m[doc.ID] = doc + } + return m +} diff --git a/cmd/docops/cmd_get.go b/cmd/docops/cmd_get.go new file mode 100644 index 0000000..587b440 --- /dev/null +++ b/cmd/docops/cmd_get.go @@ -0,0 +1,145 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/logicwind/docops/internal/index" +) + +// cmdGet implements `docops get [--json]`. +// Exit codes: +// +// 0 found +// 1 not found +// 2 bootstrap error or bad usage +func cmdGet(args []string) int { + fs := flag.NewFlagSet("get", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + asJSON := fs.Bool("json", false, "emit the full IndexedDoc as JSON") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: docops get [--json]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if fs.NArg() != 1 { + fmt.Fprintln(os.Stderr, "docops get: requires exactly one argument ") + fs.Usage() + return 2 + } + id := fs.Arg(0) + + idx, _, code := bootstrapIndex("get") + if code != 0 { + return code + } + + doc, ok := indexLookup(idx, id) + if !ok { + fmt.Fprintf(os.Stderr, "docops get: %q not found\n", id) + return 1 + } + + if *asJSON { + b, err := json.MarshalIndent(doc, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "docops get: encode: %v\n", err) + return 2 + } + fmt.Println(string(b)) + return 0 + } + + fmt.Print(humanGet(doc)) + return 0 +} + +func humanGet(doc index.IndexedDoc) string { + var sb strings.Builder + fmt.Fprintf(&sb, "%s %s\n", doc.ID, doc.CTXTitle) + + field := func(k, v string) { + if v != "" { + fmt.Fprintf(&sb, " %-16s %s\n", k+":", v) + } + } + + field("kind", doc.Kind) + switch doc.Kind { + case "ADR": + field("status", doc.ADRStatus) + field("coverage", doc.ADRCoverage) + field("date", doc.ADRDate) + if len(doc.ADRTags) > 0 { + field("tags", strings.Join(doc.ADRTags, ", ")) + } + if len(doc.CTXSupersedes) > 0 { + field("supersedes", strings.Join(doc.CTXSupersedes, ", ")) + } + if len(doc.ADRRelated) > 0 { + field("related", strings.Join(doc.ADRRelated, ", ")) + } + field("implementation", doc.Implementation) + case "TP": + field("status", doc.TaskStatus) + field("priority", doc.TaskPriority) + field("assignee", doc.TaskAssignee) + if len(doc.TaskRequires) > 0 { + field("requires", strings.Join(doc.TaskRequires, ", ")) + } + if len(doc.TaskDependsOn) > 0 { + field("depends_on", strings.Join(doc.TaskDependsOn, ", ")) + } + case "CTX": + field("type", doc.CTXType) + if len(doc.CTXSupersedes) > 0 { + field("supersedes", strings.Join(doc.CTXSupersedes, ", ")) + } + } + + field("summary", doc.Summary) + lt := doc.LastTouched + if len(lt) >= 10 { + lt = lt[:10] + } + field("last_touched", lt) + fmt.Fprintf(&sb, " %-16s %d\n", "age_days:", doc.AgeDays) + fmt.Fprintf(&sb, " %-16s %d\n", "word_count:", doc.WordCount) + staleStr := "false" + if doc.Stale { + staleStr = "true" + } + field("stale", staleStr) + + // Reverse edges. + if len(doc.SupersededBy) > 0 { + field("superseded_by", strings.Join(doc.SupersededBy, ", ")) + } + if len(doc.ReferencedBy) > 0 { + parts := make([]string, len(doc.ReferencedBy)) + for i, r := range doc.ReferencedBy { + parts[i] = fmt.Sprintf("%s (%s)", r.ID, r.Edge) + } + field("referenced_by", strings.Join(parts, ", ")) + } + if len(doc.ActiveTasks) > 0 { + field("active_tasks", strings.Join(doc.ActiveTasks, ", ")) + } + if len(doc.DerivedADRs) > 0 { + field("derived_adrs", strings.Join(doc.DerivedADRs, ", ")) + } + if len(doc.Blocks) > 0 { + field("blocks", strings.Join(doc.Blocks, ", ")) + } + + return sb.String() +} diff --git a/cmd/docops/cmd_graph.go b/cmd/docops/cmd_graph.go new file mode 100644 index 0000000..df11f11 --- /dev/null +++ b/cmd/docops/cmd_graph.go @@ -0,0 +1,262 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "sort" + "strings" + + "github.com/logicwind/docops/internal/index" +) + +type graphEdge struct { + From string `json:"from"` + To string `json:"to"` + Edge string `json:"edge"` + Direction string `json:"direction"` // "outbound" or "inbound" +} + +type graphOutput struct { + Root string `json:"root"` + Nodes []index.IndexedDoc `json:"nodes"` + Edges []graphEdge `json:"edges"` +} + +// cmdGraph implements `docops graph [--depth N] [--json]`. +// Exit codes: +// +// 0 success +// 1 ID not found +// 2 bootstrap error or bad usage +func cmdGraph(args []string) int { + fs := flag.NewFlagSet("graph", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + asJSON := fs.Bool("json", false, "emit graph as JSON") + depth := fs.Int("depth", 1, "traversal depth") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: docops graph [--depth N] [--json]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if fs.NArg() != 1 { + fmt.Fprintln(os.Stderr, "docops graph: requires exactly one argument ") + fs.Usage() + return 2 + } + id := fs.Arg(0) + if *depth < 0 { + *depth = 0 + } + + idx, _, code := bootstrapIndex("graph") + if code != 0 { + return code + } + + byID := indexByID(idx) + if _, ok := byID[id]; !ok { + fmt.Fprintf(os.Stderr, "docops graph: %q not found\n", id) + return 1 + } + + nodes, edges := walkGraph(id, byID, *depth) + + if *asJSON { + out := graphOutput{Root: id, Nodes: nodes, Edges: edges} + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "docops graph: encode: %v\n", err) + return 2 + } + fmt.Println(string(b)) + return 0 + } + + fmt.Print(humanGraph(id, byID, edges, *depth)) + return 0 +} + +// walkGraph performs a BFS from startID up to maxDepth hops, collecting +// visited nodes and the edges between them. Cycle-safe via visited set. +func walkGraph(startID string, byID map[string]index.IndexedDoc, maxDepth int) ([]index.IndexedDoc, []graphEdge) { + type qItem struct { + id string + depth int + } + + visited := map[string]struct{}{} + edgeSeen := map[string]struct{}{} + var nodes []index.IndexedDoc + var edges []graphEdge + queue := []qItem{{startID, 0}} + + addEdge := func(from, to, label, dir string) { + key := from + "|" + to + "|" + label + "|" + dir + if _, seen := edgeSeen[key]; seen { + return + } + edgeSeen[key] = struct{}{} + edges = append(edges, graphEdge{From: from, To: to, Edge: label, Direction: dir}) + } + + for len(queue) > 0 { + item := queue[0] + queue = queue[1:] + + if _, seen := visited[item.id]; seen { + continue + } + visited[item.id] = struct{}{} + + doc, ok := byID[item.id] + if !ok { + continue + } + nodes = append(nodes, doc) + + if item.depth >= maxDepth { + continue + } + next := item.depth + 1 + + // Outbound edges. + for _, t := range doc.CTXSupersedes { + addEdge(item.id, t, "supersedes", "outbound") + queue = append(queue, qItem{t, next}) + } + for _, t := range doc.ADRRelated { + addEdge(item.id, t, "related", "outbound") + queue = append(queue, qItem{t, next}) + } + for _, t := range doc.TaskRequires { + addEdge(item.id, t, "requires", "outbound") + queue = append(queue, qItem{t, next}) + } + for _, t := range doc.TaskDependsOn { + addEdge(item.id, t, "depends_on", "outbound") + queue = append(queue, qItem{t, next}) + } + + // Inbound (reverse) edges. + for _, t := range doc.SupersededBy { + addEdge(t, item.id, "supersedes", "inbound") + queue = append(queue, qItem{t, next}) + } + for _, ref := range doc.ReferencedBy { + addEdge(ref.ID, item.id, ref.Edge, "inbound") + queue = append(queue, qItem{ref.ID, next}) + } + for _, t := range doc.ActiveTasks { + addEdge(t, item.id, "active_tasks", "inbound") + queue = append(queue, qItem{t, next}) + } + for _, t := range doc.DerivedADRs { + addEdge(t, item.id, "derived_adrs", "inbound") + queue = append(queue, qItem{t, next}) + } + for _, t := range doc.Blocks { + addEdge(t, item.id, "blocks", "inbound") + queue = append(queue, qItem{t, next}) + } + } + + sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID }) + sort.Slice(edges, func(i, j int) bool { + if edges[i].From != edges[j].From { + return edges[i].From < edges[j].From + } + if edges[i].To != edges[j].To { + return edges[i].To < edges[j].To + } + return edges[i].Edge < edges[j].Edge + }) + + return nodes, edges +} + +// humanGraph renders a depth-first indented tree rooted at rootID. +// Already-visited nodes are printed inline with "(visited)" rather than +// being expanded again. +func humanGraph(rootID string, byID map[string]index.IndexedDoc, edges []graphEdge, maxDepth int) string { + // Build per-node adjacency (both outbound and inbound from that node's POV). + type nodeEdge struct { + arrow string // "→" or "←" + label string + peerID string + } + adj := map[string][]nodeEdge{} + for _, e := range edges { + if e.Direction == "outbound" { + adj[e.From] = append(adj[e.From], nodeEdge{"→", e.Edge, e.To}) + } else { + adj[e.To] = append(adj[e.To], nodeEdge{"←", e.Edge, e.From}) + } + } + // Sort each adjacency list for determinism. + for id := range adj { + sort.Slice(adj[id], func(i, j int) bool { + a, b := adj[id][i], adj[id][j] + if a.arrow != b.arrow { + return a.arrow < b.arrow + } + if a.label != b.label { + return a.label < b.label + } + return a.peerID < b.peerID + }) + } + + var sb strings.Builder + visited := map[string]bool{} + + var render func(id string, depth int) + render = func(id string, depth int) { + indent := strings.Repeat(" ", depth) + doc, ok := byID[id] + if !ok { + return + } + if depth == 0 { + fmt.Fprintf(&sb, "%s%s — %s [%s%s]\n", indent, doc.ID, doc.CTXTitle, doc.Kind, docKindStatus(doc)) + } + visited[id] = true + if depth >= maxDepth { + return + } + for _, ne := range adj[id] { + peer, ok := byID[ne.peerID] + if !ok { + continue + } + extra := "" + if visited[ne.peerID] { + extra = " (visited)" + } + fmt.Fprintf(&sb, "%s %s %-14s %s — %s [%s%s]%s\n", + indent, ne.arrow, ne.label, peer.ID, peer.CTXTitle, peer.Kind, docKindStatus(peer), extra) + if !visited[ne.peerID] { + render(ne.peerID, depth+1) + } + } + } + + render(rootID, 0) + return sb.String() +} + +// docKindStatus returns ", " for kinds that carry a status, else "". +func docKindStatus(doc index.IndexedDoc) string { + s := docStatusField(doc) + if s == "" { + return "" + } + return ", " + s +} diff --git a/cmd/docops/cmd_init.go b/cmd/docops/cmd_init.go index ebd64f3..599047c 100644 --- a/cmd/docops/cmd_init.go +++ b/cmd/docops/cmd_init.go @@ -81,7 +81,7 @@ func cmdInit(args []string) int { fmt.Fprintln(os.Stdout, " - docs/{context,decisions,tasks} folders") fmt.Fprintln(os.Stdout, " - docops.yaml at the repo root") fmt.Fprintln(os.Stdout, " - JSON Schemas for editor validation") - fmt.Fprintln(os.Stdout, " - An AGENTS.md block (merges into existing content if present)") + fmt.Fprintln(os.Stdout, " - AGENTS.md and CLAUDE.md docops blocks (merges into existing content if present)") fmt.Fprintln(os.Stdout, " - A .git/hooks/pre-commit hook (if .git exists)") fmt.Fprintln(os.Stdout, " - /docops:* agent-skill scaffolds under .claude/ and .cursor/") fmt.Fprintln(os.Stdout, "Safe to re-run; existing files are never silently overwritten.") diff --git a/cmd/docops/cmd_list.go b/cmd/docops/cmd_list.go new file mode 100644 index 0000000..8bf9d38 --- /dev/null +++ b/cmd/docops/cmd_list.go @@ -0,0 +1,206 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/logicwind/docops/internal/index" +) + +// listRecord is the trimmed per-doc view emitted by `docops list`. +type listRecord struct { + ID string `json:"id"` + Kind string `json:"kind"` + Status string `json:"status,omitempty"` + Title string `json:"title"` + Coverage string `json:"coverage,omitempty"` + Priority string `json:"priority,omitempty"` + Assignee string `json:"assignee,omitempty"` + LastTouched string `json:"last_touched"` + Stale bool `json:"stale"` + Implementation string `json:"implementation,omitempty"` +} + +// cmdList implements `docops list [flags] [--json]`. +// Exit codes: +// +// 0 success (even if no docs match) +// 2 bootstrap error or bad usage +func cmdList(args []string) int { + fs := flag.NewFlagSet("list", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + asJSON := fs.Bool("json", false, "emit records as JSON array") + kindFlag := fs.String("kind", "", "filter by kind: CTX, ADR, TP") + statusFlag := fs.String("status", "", "filter by status (per-kind semantics)") + coverage := fs.String("coverage", "", "filter ADRs by coverage: required, not-needed") + tag := fs.String("tag", "", "filter ADRs by tag") + onlyStale := fs.Bool("stale", false, "only docs with stale=true") + since := fs.String("since", "", "only docs with last_touched >= YYYY-MM-DD") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: docops list [flags]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + idx, _, code := bootstrapIndex("list") + if code != 0 { + return code + } + + var sinceTime time.Time + if *since != "" { + t, err := time.Parse("2006-01-02", *since) + if err != nil { + fmt.Fprintf(os.Stderr, "docops list: --since %q: expected YYYY-MM-DD\n", *since) + return 2 + } + sinceTime = t + } + + kindUpper := strings.ToUpper(*kindFlag) + var records []listRecord + for _, doc := range idx.Docs { + if kindUpper != "" && doc.Kind != kindUpper { + continue + } + docStatus := docStatusField(doc) + if *statusFlag != "" && docStatus != *statusFlag { + continue + } + if *coverage != "" && doc.ADRCoverage != *coverage { + continue + } + if *tag != "" && !sliceContains(doc.ADRTags, *tag) { + continue + } + if *onlyStale && !doc.Stale { + continue + } + if !sinceTime.IsZero() { + lt, err := time.Parse(time.RFC3339, doc.LastTouched) + if err == nil && lt.Before(sinceTime) { + continue + } + } + records = append(records, listRecord{ + ID: doc.ID, + Kind: doc.Kind, + Status: docStatus, + Title: doc.CTXTitle, + Coverage: doc.ADRCoverage, + Priority: doc.TaskPriority, + Assignee: doc.TaskAssignee, + LastTouched: doc.LastTouched, + Stale: doc.Stale, + Implementation: doc.Implementation, + }) + } + + // Sort: kind order (CTX→ADR→TP) then ID ascending. + sort.SliceStable(records, func(i, j int) bool { + ki := kindOrder(records[i].Kind) + kj := kindOrder(records[j].Kind) + if ki != kj { + return ki < kj + } + return records[i].ID < records[j].ID + }) + + if *asJSON { + if records == nil { + records = []listRecord{} + } + b, err := json.MarshalIndent(records, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "docops list: encode: %v\n", err) + return 2 + } + fmt.Println(string(b)) + return 0 + } + + if len(records) == 0 { + fmt.Println("(no documents match)") + return 0 + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tKIND\tSTATUS\tTITLE\tCOV/PRI\tIMPL/ASSIGNEE\tLAST-TOUCHED\tSTALE") + for _, r := range records { + extra1 := r.Coverage + if extra1 == "" { + extra1 = r.Priority + } + extra2 := r.Implementation + if extra2 == "" { + extra2 = r.Assignee + } + lt := r.LastTouched + if len(lt) >= 10 { + lt = lt[:10] + } + staleStr := "no" + if r.Stale { + staleStr = "yes" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + r.ID, r.Kind, r.Status, truncate(r.Title, 40), + extra1, extra2, lt, staleStr) + } + _ = tw.Flush() + return 0 +} + +// docStatusField returns the kind-appropriate status string (empty for CTX). +func docStatusField(doc index.IndexedDoc) string { + switch doc.Kind { + case "ADR": + return doc.ADRStatus + case "TP": + return doc.TaskStatus + } + return "" +} + +func kindOrder(k string) int { + switch k { + case "CTX": + return 0 + case "ADR": + return 1 + case "TP": + return 2 + } + return 3 +} + +// sliceContains reports whether s appears in sl. +func sliceContains(sl []string, s string) bool { + for _, v := range sl { + if v == s { + return true + } + } + return false +} + +// truncate clips s to maxLen runes, appending "…" if truncated. +func truncate(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen-1]) + "…" +} diff --git a/cmd/docops/cmd_next.go b/cmd/docops/cmd_next.go new file mode 100644 index 0000000..e207a0b --- /dev/null +++ b/cmd/docops/cmd_next.go @@ -0,0 +1,161 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "sort" + "strings" + + "github.com/logicwind/docops/internal/index" +) + +type nextOutput struct { + index.IndexedDoc + Reason string `json:"reason"` +} + +// cmdNext implements `docops next [--assignee ] [--priority p0|p1|p2] [--json]`. +// Selection rules (first match wins): +// 1. Active tasks matching filters, sorted by last_touched descending. +// 2. Unblocked backlog tasks (all depends_on targets are done), sorted by +// priority (p0>p1>p2) then ID ascending. +// +// Exit codes: +// +// 0 task found +// 1 no task matches +// 2 bootstrap error or bad usage +func cmdNext(args []string) int { + fs := flag.NewFlagSet("next", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + asJSON := fs.Bool("json", false, "emit the selected task as JSON") + assignee := fs.String("assignee", "", "filter by assignee") + priority := fs.String("priority", "", "filter by priority: p0, p1, p2") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: docops next [--assignee ] [--priority ] [--json]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + idx, _, code := bootstrapIndex("next") + if code != 0 { + return code + } + + doneIDs := map[string]bool{} + for _, doc := range idx.Docs { + if doc.Kind == "TP" && doc.TaskStatus == "done" { + doneIDs[doc.ID] = true + } + } + + matchesFilters := func(doc index.IndexedDoc) bool { + if *assignee != "" && doc.TaskAssignee != *assignee { + return false + } + if *priority != "" && doc.TaskPriority != *priority { + return false + } + return true + } + + // Rule 1: active tasks. + var active []index.IndexedDoc + for _, doc := range idx.Docs { + if doc.Kind == "TP" && doc.TaskStatus == "active" && matchesFilters(doc) { + active = append(active, doc) + } + } + sort.Slice(active, func(i, j int) bool { + // Most recently touched first. + return active[i].LastTouched > active[j].LastTouched + }) + if len(active) > 0 { + t := active[0] + assigneeName := t.TaskAssignee + if assigneeName == "" { + assigneeName = "unassigned" + } + return emitNext(t, "active for "+assigneeName, *asJSON) + } + + // Rule 2+3: unblocked backlog tasks. + var unblocked []index.IndexedDoc + for _, doc := range idx.Docs { + if doc.Kind != "TP" || doc.TaskStatus != "backlog" { + continue + } + if !matchesFilters(doc) { + continue + } + allDone := true + for _, dep := range doc.TaskDependsOn { + if !doneIDs[dep] { + allDone = false + break + } + } + if allDone { + unblocked = append(unblocked, doc) + } + } + + // Rule 4: priority (p0>p1>p2) then ID ascending. + sort.Slice(unblocked, func(i, j int) bool { + pi := priorityOrder(unblocked[i].TaskPriority) + pj := priorityOrder(unblocked[j].TaskPriority) + if pi != pj { + return pi < pj + } + return unblocked[i].ID < unblocked[j].ID + }) + if len(unblocked) > 0 { + t := unblocked[0] + reason := "unblocked backlog, " + t.TaskPriority + return emitNext(t, reason, *asJSON) + } + + fmt.Fprintln(os.Stderr, "docops next: no task matches") + return 1 +} + +func emitNext(task index.IndexedDoc, reason string, asJSON bool) int { + if asJSON { + out := nextOutput{IndexedDoc: task, Reason: reason} + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "docops next: encode: %v\n", err) + return 2 + } + fmt.Println(string(b)) + return 0 + } + reqs := strings.Join(task.TaskRequires, ", ") + if reqs == "" { + reqs = "(none)" + } + fmt.Printf("%s (%s, %s) %s — requires: %s\n", + task.ID, task.TaskAssignee, task.TaskPriority, task.CTXTitle, reqs) + fmt.Printf("reason: %s\n", reason) + return 0 +} + +func priorityOrder(p string) int { + switch p { + case "p0": + return 0 + case "p1": + return 1 + case "p2": + return 2 + } + return 99 +} diff --git a/cmd/docops/cmd_read_test.go b/cmd/docops/cmd_read_test.go new file mode 100644 index 0000000..673614d --- /dev/null +++ b/cmd/docops/cmd_read_test.go @@ -0,0 +1,687 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// captureStdout captures os.Stdout output from fn, restoring it afterward. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + orig := os.Stdout + os.Stdout = w + fn() + _ = w.Close() + os.Stdout = orig + + buf := make([]byte, 1<<20) + n, _ := r.Read(buf) + return string(buf[:n]) +} + +// readTestDocs are planting helpers reused across the read-command tests. + +const validCTX = `--- +title: CLI substrate +type: prd +supersedes: [] +--- + +# CLI substrate + +Body of the CLI substrate context doc. +` + +const validADR2 = `--- +title: Use Go for implementation +status: accepted +coverage: required +date: 2026-01-01 +supersedes: [] +related: [] +tags: [cli, go] +--- + +# Use Go for implementation + +## Context + +We need a language. + +## Decision + +Go. +` + +const taskDone = `--- +title: Scaffold the CLI +status: done +priority: p1 +assignee: alice +requires: [ADR-0001] +depends_on: [] +--- + +# Scaffold the CLI + +## Goal + +## Acceptance + +## Notes +` + +const taskBacklog1 = `--- +title: Implement validate +status: backlog +priority: p1 +assignee: unassigned +requires: [ADR-0001] +depends_on: [TP-001] +--- + +# Implement validate + +## Goal + +## Acceptance + +## Notes +` + +const taskBacklog2 = `--- +title: Ship release +status: backlog +priority: p2 +assignee: unassigned +requires: [ADR-0001] +depends_on: [] +--- + +# Ship release + +## Goal + +## Acceptance + +## Notes +` + +const taskActive = `--- +title: Write docs +status: active +priority: p0 +assignee: bob +requires: [ADR-0001] +depends_on: [] +--- + +# Write docs + +## Goal + +## Acceptance + +## Notes +` + +// plantReadDocs creates a full test tree and returns the root dir. +// Layout: +// +// CTX-001 CLI substrate +// ADR-0001 Use Go for implementation (tags: [cli, go]) +// TP-001 Scaffold the CLI done, p1, alice, requires ADR-0001 +// TP-002 Implement validate backlog, p1, depends on TP-001 (blocked until TP-001 done) +// TP-003 Ship release backlog, p2, no deps (unblocked) +// TP-004 Write docs active, p0, bob, requires ADR-0001 +func plantReadDocs(t *testing.T) string { + t.Helper() + root := makeDocopsRoot(t) + plantDoc(t, root, "docs/context/CTX-001-cli-substrate.md", validCTX) + plantDoc(t, root, "docs/decisions/ADR-0001-use-go.md", validADR2) + plantDoc(t, root, "docs/tasks/TP-001-scaffold.md", taskDone) + plantDoc(t, root, "docs/tasks/TP-002-validate.md", taskBacklog1) + plantDoc(t, root, "docs/tasks/TP-003-release.md", taskBacklog2) + plantDoc(t, root, "docs/tasks/TP-004-docs.md", taskActive) + return root +} + +// ── docops get ────────────────────────────────────────────────────────────── + +func TestCmdGet_HappyPath(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdGet([]string{"ADR-0001"}) + if code != 0 { + t.Fatalf("cmdGet returned %d, want 0", code) + } + }) + + if !strings.Contains(out, "ADR-0001") { + t.Errorf("output missing ID: %s", out) + } + if !strings.Contains(out, "Use Go for implementation") { + t.Errorf("output missing title: %s", out) + } + if !strings.Contains(out, "accepted") { + t.Errorf("output missing status: %s", out) + } +} + +func TestCmdGet_NotFound(t *testing.T) { + plantReadDocs(t) + code := cmdGet([]string{"ADR-9999"}) + if code != 1 { + t.Errorf("cmdGet unknown ID returned %d, want 1", code) + } +} + +func TestCmdGet_MissingArg(t *testing.T) { + plantReadDocs(t) + code := cmdGet(nil) + if code != 2 { + t.Errorf("cmdGet no args returned %d, want 2", code) + } +} + +func TestCmdGet_NoConfig(t *testing.T) { + root := t.TempDir() + orig, _ := os.Getwd() + _ = os.Chdir(root) + t.Cleanup(func() { _ = os.Chdir(orig) }) + + code := cmdGet([]string{"ADR-0001"}) + if code != 2 { + t.Errorf("cmdGet no config returned %d, want 2", code) + } +} + +func TestCmdGet_JSON(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdGet([]string{"--json", "ADR-0001"}) + if code != 0 { + t.Fatalf("cmdGet --json returned %d", code) + } + }) + + var doc map[string]interface{} + if err := json.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("--json output is not valid JSON: %v\n%s", err, out) + } + if doc["id"] != "ADR-0001" { + t.Errorf("JSON id = %v, want ADR-0001", doc["id"]) + } + if doc["kind"] != "ADR" { + t.Errorf("JSON kind = %v, want ADR", doc["kind"]) + } +} + +// ── docops list ───────────────────────────────────────────────────────────── + +func TestCmdList_HappyPath(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdList(nil) + if code != 0 { + t.Fatalf("cmdList returned %d", code) + } + }) + + // Should contain all six docs. + for _, id := range []string{"CTX-001", "ADR-0001", "TP-001", "TP-002", "TP-003", "TP-004"} { + if !strings.Contains(out, id) { + t.Errorf("output missing %s:\n%s", id, out) + } + } +} + +func TestCmdList_KindFilter(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList([]string{"--kind", "ADR"}) + }) + + if !strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 missing from ADR filter: %s", out) + } + if strings.Contains(out, "TP-001") { + t.Errorf("TP-001 should not appear in ADR filter: %s", out) + } + if strings.Contains(out, "CTX-001") { + t.Errorf("CTX-001 should not appear in ADR filter: %s", out) + } +} + +func TestCmdList_StatusFilter(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList([]string{"--status", "done"}) + }) + + if !strings.Contains(out, "TP-001") { + t.Errorf("TP-001 (done) missing: %s", out) + } + if strings.Contains(out, "TP-002") { + t.Errorf("TP-002 (backlog) should not appear: %s", out) + } +} + +func TestCmdList_TagFilter(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList([]string{"--tag", "go"}) + }) + + if !strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 (tagged go) missing: %s", out) + } +} + +func TestCmdList_TagFilterNoMatch(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList([]string{"--tag", "nonexistent"}) + }) + + if strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 should not match tag=nonexistent: %s", out) + } + if !strings.Contains(out, "(no documents match)") { + t.Errorf("expected '(no documents match)': %s", out) + } +} + +func TestCmdList_KindSortOrder(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList(nil) + }) + + // CTX must appear before ADR, ADR before TP in human output. + ctxPos := strings.Index(out, "CTX-001") + adrPos := strings.Index(out, "ADR-0001") + tpPos := strings.Index(out, "TP-001") + if ctxPos < 0 || adrPos < 0 || tpPos < 0 { + t.Fatalf("missing expected IDs in output: %s", out) + } + if ctxPos >= adrPos { + t.Errorf("CTX should appear before ADR (ctxPos=%d adrPos=%d)", ctxPos, adrPos) + } + if adrPos >= tpPos { + t.Errorf("ADR should appear before TP (adrPos=%d tpPos=%d)", adrPos, tpPos) + } +} + +func TestCmdList_JSON(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdList([]string{"--json"}) + if code != 0 { + t.Fatalf("cmdList --json returned %d", code) + } + }) + + var records []map[string]interface{} + if err := json.Unmarshal([]byte(out), &records); err != nil { + t.Fatalf("--json output is not valid JSON: %v\n%s", err, out) + } + if len(records) != 6 { + t.Errorf("expected 6 records, got %d", len(records)) + } +} + +func TestCmdList_JSONEmptyOnNoMatch(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList([]string{"--json", "--kind", "ADR", "--status", "draft"}) + }) + + var records []map[string]interface{} + if err := json.Unmarshal([]byte(out), &records); err != nil { + t.Fatalf("--json output not valid JSON: %v\n%s", err, out) + } + if len(records) != 0 { + t.Errorf("expected empty array, got %d records", len(records)) + } +} + +func TestCmdList_CoverageFilter(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + cmdList([]string{"--coverage", "required"}) + }) + + if !strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 (coverage=required) missing: %s", out) + } +} + +// ── docops graph ───────────────────────────────────────────────────────────── + +func TestCmdGraph_HappyPath(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdGraph([]string{"ADR-0001"}) + if code != 0 { + t.Fatalf("cmdGraph returned %d", code) + } + }) + + // Root must be the first line. + if !strings.HasPrefix(out, "ADR-0001") { + t.Errorf("first line should start with ADR-0001:\n%s", out) + } + // Depth-1 neighbours: tasks that require ADR-0001 should appear. + if !strings.Contains(out, "TP-001") && !strings.Contains(out, "TP-004") { + t.Errorf("expected referencing tasks in output:\n%s", out) + } +} + +func TestCmdGraph_NotFound(t *testing.T) { + plantReadDocs(t) + code := cmdGraph([]string{"ADR-9999"}) + if code != 1 { + t.Errorf("cmdGraph unknown ID returned %d, want 1", code) + } +} + +func TestCmdGraph_MissingArg(t *testing.T) { + plantReadDocs(t) + code := cmdGraph(nil) + if code != 2 { + t.Errorf("cmdGraph no args returned %d, want 2", code) + } +} + +func TestCmdGraph_DepthZero(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdGraph([]string{"--depth", "0", "ADR-0001"}) + if code != 0 { + t.Fatalf("cmdGraph --depth 0 returned %d", code) + } + }) + + // With depth 0 we get only the root node, no edge lines. + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 1 { + t.Errorf("depth 0 should produce 1 line, got %d:\n%s", len(lines), out) + } +} + +func TestCmdGraph_JSON(t *testing.T) { + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdGraph([]string{"--json", "ADR-0001"}) + if code != 0 { + t.Fatalf("cmdGraph --json returned %d", code) + } + }) + + var g struct { + Root string `json:"root"` + Nodes []map[string]interface{} `json:"nodes"` + Edges []map[string]interface{} `json:"edges"` + } + if err := json.Unmarshal([]byte(out), &g); err != nil { + t.Fatalf("--json not valid JSON: %v\n%s", err, out) + } + if g.Root != "ADR-0001" { + t.Errorf("root = %q, want ADR-0001", g.Root) + } + if len(g.Nodes) == 0 { + t.Error("expected at least 1 node") + } +} + +func TestCmdGraph_CycleSafe(t *testing.T) { + // Even with mutual edges the walker must not loop. + // TP-002 depends_on TP-001; graph from TP-001 at depth 2 should terminate. + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdGraph([]string{"--depth", "2", "TP-001"}) + if code != 0 { + t.Fatalf("cmdGraph depth 2 returned %d", code) + } + }) + + // Just verify we got output without hanging; TP-001 must appear. + if !strings.Contains(out, "TP-001") { + t.Errorf("TP-001 missing from output:\n%s", out) + } +} + +// ── docops next ────────────────────────────────────────────────────────────── + +func TestCmdNext_ActiveFirst(t *testing.T) { + // TP-004 is active (bob, p0); it should win over any backlog. + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdNext(nil) + if code != 0 { + t.Fatalf("cmdNext returned %d, want 0", code) + } + }) + + if !strings.Contains(out, "TP-004") { + t.Errorf("expected active TP-004, got:\n%s", out) + } + if !strings.Contains(out, "active for bob") { + t.Errorf("expected 'active for bob' reason:\n%s", out) + } +} + +func TestCmdNext_UnblockedBacklog(t *testing.T) { + // With no active tasks, TP-003 (backlog, p2, no deps) and TP-002 (backlog, p1, + // depends_on TP-001 which is done) are both unblocked. TP-002 (p1) wins. + root := makeDocopsRoot(t) + plantDoc(t, root, "docs/decisions/ADR-0001-use-go.md", validADR2) + plantDoc(t, root, "docs/tasks/TP-001-scaffold.md", taskDone) + plantDoc(t, root, "docs/tasks/TP-002-validate.md", taskBacklog1) // p1, depends_on TP-001 (done) + plantDoc(t, root, "docs/tasks/TP-003-release.md", taskBacklog2) // p2, no deps + + out := captureStdout(t, func() { + code := cmdNext(nil) + if code != 0 { + t.Fatalf("cmdNext returned %d, want 0", code) + } + }) + + if !strings.Contains(out, "TP-002") { + t.Errorf("expected TP-002 (p1 unblocked), got:\n%s", out) + } + if !strings.Contains(out, "unblocked backlog") { + t.Errorf("expected 'unblocked backlog' reason:\n%s", out) + } +} + +func TestCmdNext_BlockedTaskSkipped(t *testing.T) { + // TP-002 depends_on TP-001 which is NOT done (backlog). Only TP-003 is unblocked. + root := makeDocopsRoot(t) + plantDoc(t, root, "docs/decisions/ADR-0001-use-go.md", validADR2) + + const tp001Backlog = `--- +title: Scaffold the CLI +status: backlog +priority: p1 +assignee: unassigned +requires: [ADR-0001] +depends_on: [] +--- + +# Scaffold the CLI + +## Goal + +## Acceptance + +## Notes +` + plantDoc(t, root, "docs/tasks/TP-001-scaffold.md", tp001Backlog) + plantDoc(t, root, "docs/tasks/TP-002-validate.md", taskBacklog1) // depends_on TP-001 (not done) + plantDoc(t, root, "docs/tasks/TP-003-release.md", taskBacklog2) // p2, no deps + + out := captureStdout(t, func() { + code := cmdNext(nil) + if code != 0 { + t.Fatalf("cmdNext returned %d", code) + } + }) + + // TP-002 is blocked (TP-001 not done). Should pick TP-001 (p1) or TP-003 (p2). + // Both TP-001 and TP-003 are unblocked; TP-001 is p1 so it wins. + if strings.Contains(out, "TP-002") { + t.Errorf("blocked TP-002 should not be selected:\n%s", out) + } +} + +func TestCmdNext_PriorityOrder(t *testing.T) { + // Three unblocked backlog tasks at p0, p1, p2. p0 must win. + root := makeDocopsRoot(t) + plantDoc(t, root, "docs/decisions/ADR-0001-use-go.md", validADR2) + + tasks := []struct { + file string + content string + }{ + {"docs/tasks/TP-001-p0.md", `--- +title: P0 task +status: backlog +priority: p0 +assignee: unassigned +requires: [ADR-0001] +depends_on: [] +--- + +# P0 task + +## Goal + +## Acceptance + +## Notes +`}, + {"docs/tasks/TP-002-p1.md", `--- +title: P1 task +status: backlog +priority: p1 +assignee: unassigned +requires: [ADR-0001] +depends_on: [] +--- + +# P1 task + +## Goal + +## Acceptance + +## Notes +`}, + {"docs/tasks/TP-003-p2.md", `--- +title: P2 task +status: backlog +priority: p2 +assignee: unassigned +requires: [ADR-0001] +depends_on: [] +--- + +# P2 task + +## Goal + +## Acceptance + +## Notes +`}, + } + for _, tc := range tasks { + plantDoc(t, root, tc.file, tc.content) + } + + out := captureStdout(t, func() { + code := cmdNext(nil) + if code != 0 { + t.Fatalf("cmdNext returned %d", code) + } + }) + + if !strings.Contains(out, "TP-001") { + t.Errorf("p0 task TP-001 should win, got:\n%s", out) + } +} + +func TestCmdNext_AssigneeFilter(t *testing.T) { + // --assignee bob: should find TP-004 (active, bob). + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdNext([]string{"--assignee", "bob"}) + if code != 0 { + t.Fatalf("cmdNext --assignee bob returned %d", code) + } + }) + + if !strings.Contains(out, "TP-004") { + t.Errorf("expected TP-004 for assignee=bob:\n%s", out) + } +} + +func TestCmdNext_NoMatch(t *testing.T) { + // Repo with only done tasks — nothing to pick. + root := makeDocopsRoot(t) + plantDoc(t, root, "docs/decisions/ADR-0001-use-go.md", validADR2) + plantDoc(t, root, "docs/tasks/TP-001-scaffold.md", taskDone) + + code := cmdNext(nil) + if code != 1 { + t.Errorf("cmdNext with only done tasks returned %d, want 1", code) + } +} + +func TestCmdNext_JSON(t *testing.T) { + // TP-004 is active — should be selected. + plantReadDocs(t) + + out := captureStdout(t, func() { + code := cmdNext([]string{"--json"}) + if code != 0 { + t.Fatalf("cmdNext --json returned %d", code) + } + }) + + var result map[string]interface{} + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("--json not valid JSON: %v\n%s", err, out) + } + if result["id"] != "TP-004" { + t.Errorf("JSON id = %v, want TP-004", result["id"]) + } + if result["reason"] == "" || result["reason"] == nil { + t.Errorf("JSON missing reason field: %v", result) + } +} diff --git a/cmd/docops/cmd_search.go b/cmd/docops/cmd_search.go new file mode 100644 index 0000000..97a72aa --- /dev/null +++ b/cmd/docops/cmd_search.go @@ -0,0 +1,348 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode/utf8" + + "github.com/logicwind/docops/internal/index" + "github.com/logicwind/docops/internal/schema" +) + +// stringList is a repeatable flag value (used for --tag). +type stringList []string + +func (s *stringList) String() string { return strings.Join(*s, ", ") } +func (s *stringList) Set(v string) error { *s = append(*s, v); return nil } + +// searchResult is one match returned by cmdSearch. +type searchResult struct { + ID string `json:"id"` + Path string `json:"path"` + Kind string `json:"kind"` + Title string `json:"title"` + Status string `json:"status,omitempty"` + Snippet string `json:"snippet"` + MatchField string `json:"match_field"` // "title", "tags", "body", or "" + + // unexported ranking keys + rank int // 1=title 2=tags 3=body-first-para 4=body-later 0=filter-only + lastTouched string +} + +// cmdSearch implements `docops search [flags] [--json]`. +// Exit codes: +// +// 0 always (no matches is not an error) +// 2 bootstrap error, bad usage, or invalid regex +func cmdSearch(args []string) int { + fs := flag.NewFlagSet("search", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + asJSON := fs.Bool("json", false, "emit matches as JSON array") + useRegex := fs.Bool("regex", false, "treat query as a regexp pattern") + caseSensitive := fs.Bool("case", false, "case-sensitive match (default is case-insensitive)") + kindFlag := fs.String("kind", "", "filter by kind: CTX, ADR, TP") + statusFlag := fs.String("status", "", "filter by status (per-kind)") + coverage := fs.String("coverage", "", "filter ADRs by coverage: required, not-needed") + var tags stringList + fs.Var(&tags, "tag", "filter ADRs by tag (repeatable; all tags must match)") + priority := fs.String("priority", "", "filter Tasks by priority: p0, p1, p2") + assignee := fs.String("assignee", "", "filter Tasks by assignee") + since := fs.String("since", "", "filter by last_touched >= YYYY-MM-DD") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: docops search [] [flags]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + query := "" + if fs.NArg() > 0 { + query = strings.Join(fs.Args(), " ") + } + + kindUpper := strings.ToUpper(*kindFlag) + + // Guard: empty query requires at least one structured filter. + hasFilter := kindUpper != "" || *statusFlag != "" || *coverage != "" || + len(tags) > 0 || *priority != "" || *assignee != "" || *since != "" + if query == "" && !hasFilter { + fmt.Fprintln(os.Stderr, "docops search: provide a query or at least one filter flag") + fs.Usage() + return 2 + } + + // Guard: kind-incompatible filter combos. + if kindUpper == "CTX" && *statusFlag != "" { + fmt.Fprintln(os.Stderr, "docops search: --status is not valid for --kind CTX (CTX has no status field)") + return 2 + } + if *coverage != "" && kindUpper != "" && kindUpper != "ADR" { + fmt.Fprintln(os.Stderr, "docops search: --coverage is only valid for ADR") + return 2 + } + + // Compile matcher. + m, err := newMatcher(query, *useRegex, *caseSensitive) + if err != nil { + fmt.Fprintf(os.Stderr, "docops search: invalid regex %q: %v\n", query, err) + return 2 + } + + // Parse --since. + sinceStr := *since // YYYY-MM-DD prefix comparison against RFC3339 + + idx, root, code := bootstrapIndex("search") + if code != 0 { + return code + } + + var results []searchResult + for _, doc := range idx.Docs { + // Structured filters. + if kindUpper != "" && doc.Kind != kindUpper { + continue + } + if *statusFlag != "" && docStatusField(doc) != *statusFlag { + continue + } + if *coverage != "" && doc.ADRCoverage != *coverage { + continue + } + for _, t := range tags { + if !sliceContains(doc.ADRTags, t) { + goto nextDoc + } + } + if *priority != "" && doc.TaskPriority != *priority { + continue + } + if *assignee != "" && doc.TaskAssignee != *assignee { + continue + } + if sinceStr != "" && doc.LastTouched[:10] < sinceStr { + continue + } + + // Text match (skip if no query → filter-only, include all). + if query == "" { + results = append(results, searchResult{ + ID: doc.ID, Path: doc.Path, Kind: doc.Kind, Title: doc.CTXTitle, + Status: docStatusField(doc), + Snippet: doc.Summary, + MatchField: "", + rank: 0, + lastTouched: doc.LastTouched, + }) + continue + } + + if r := matchDoc(doc, root, m); r != nil { + results = append(results, *r) + } + nextDoc: + } + + // Sort: rank asc, lastTouched desc, id asc. + sort.SliceStable(results, func(i, j int) bool { + if results[i].rank != results[j].rank { + return results[i].rank < results[j].rank + } + if results[i].lastTouched != results[j].lastTouched { + return results[i].lastTouched > results[j].lastTouched + } + return results[i].ID < results[j].ID + }) + + if *asJSON { + out := make([]searchResult, len(results)) + copy(out, results) + if out == nil { + out = []searchResult{} + } + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "docops search: encode: %v\n", err) + return 2 + } + fmt.Println(string(b)) + return 0 + } + + for _, r := range results { + kindStatus := r.Kind + if r.Status != "" { + kindStatus += "/" + r.Status + } + fmt.Printf("%s %-14s %s\n", r.ID, kindStatus, r.Title) + if r.Snippet != "" { + fmt.Printf(" (%s) %s\n", fieldLabel(r.MatchField), r.Snippet) + } + } + fmt.Printf("%d match(es)\n", len(results)) + return 0 +} + +// matcher encapsulates the text-match logic. +type matcher struct { + query string + re *regexp.Regexp + useRegex bool + caseSensitive bool +} + +func newMatcher(query string, useRegex, caseSensitive bool) (*matcher, error) { + if query == "" { + return &matcher{}, nil + } + m := &matcher{query: query, useRegex: useRegex, caseSensitive: caseSensitive} + if useRegex { + pattern := query + if !caseSensitive { + pattern = "(?i)" + pattern + } + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + m.re = re + } + return m, nil +} + +// findIn returns the byte position of the first match in text, or -1. +func (m *matcher) findIn(text string) int { + if m.query == "" { + return -1 + } + if m.useRegex { + loc := m.re.FindStringIndex(text) + if loc == nil { + return -1 + } + return loc[0] + } + q, t := m.query, text + if !m.caseSensitive { + q = strings.ToLower(q) + t = strings.ToLower(t) + } + return strings.Index(t, q) +} + +// matchDoc tries to match doc against the matcher, reading the body lazily. +// Returns nil if no match. +func matchDoc(doc index.IndexedDoc, root string, m *matcher) *searchResult { + base := searchResult{ + ID: doc.ID, Path: doc.Path, Kind: doc.Kind, Title: doc.CTXTitle, + Status: docStatusField(doc), + lastTouched: doc.LastTouched, + } + + // 1. Title. + if pos := m.findIn(doc.CTXTitle); pos >= 0 { + r := base + r.Snippet = doc.CTXTitle + r.MatchField = "title" + r.rank = 1 + return &r + } + + // 2. Tags (any tag containing the query). + for _, tag := range doc.ADRTags { + if m.findIn(tag) >= 0 { + r := base + r.Snippet = strings.Join(doc.ADRTags, ", ") + r.MatchField = "tags" + r.rank = 2 + return &r + } + } + + // 3. Body (lazy read). + raw, err := os.ReadFile(filepath.Join(root, doc.Path)) + if err != nil { + return nil + } + _, bodyBytes, err := schema.SplitFrontmatter(raw) + if err != nil { + bodyBytes = raw + } + body := string(bodyBytes) + + pos := m.findIn(body) + if pos < 0 { + return nil + } + + r := base + r.MatchField = "body" + r.Snippet = extractSnippet(body, pos, 120) + if isFirstParagraph(body, pos) { + r.rank = 3 + } else { + r.rank = 4 + } + return &r +} + +// isFirstParagraph reports whether pos falls within the first paragraph +// (content before the first blank line). +func isFirstParagraph(body string, pos int) bool { + end := strings.Index(body, "\n\n") + if end < 0 { + return true + } + return pos < end +} + +// extractSnippet extracts ~maxLen chars centred on matchPos, with "…" padding. +func extractSnippet(body string, matchPos, maxLen int) string { + half := maxLen / 2 + start := matchPos - half + if start < 0 { + start = 0 + } + end := matchPos + half + if end > len(body) { + end = len(body) + } + // Snap to valid rune boundaries. + for start > 0 && !utf8.RuneStart(body[start]) { + start-- + } + for end < len(body) && !utf8.RuneStart(body[end]) { + end++ + } + + snippet := body[start:end] + snippet = strings.ReplaceAll(snippet, "\n", " ") + snippet = strings.Join(strings.Fields(snippet), " ") // collapse whitespace + snippet = strings.TrimSpace(snippet) + + if start > 0 { + snippet = "…" + snippet + } + if end < len(body) { + snippet += "…" + } + return snippet +} + +func fieldLabel(f string) string { + if f == "" { + return "filter" + } + return f +} diff --git a/cmd/docops/cmd_search_test.go b/cmd/docops/cmd_search_test.go new file mode 100644 index 0000000..09350f3 --- /dev/null +++ b/cmd/docops/cmd_search_test.go @@ -0,0 +1,327 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// plantSearchDocs builds a tree with content suitable for search tests. +// CTX-001 (prd) — "CLI substrate" +// ADR-0001 — "Use Go for implementation" tags:[cli, go] +// ADR-0002 — "Rate limiting strategy" tags:[api, rate-limit] +// TP-001 — done, alice, "Scaffold the CLI" requires ADR-0001 +// TP-002 — backlog, "Implement rate limiter" requires ADR-0002 +func plantSearchDocs(t *testing.T) string { + t.Helper() + root := makeDocopsRoot(t) + plantDoc(t, root, "docs/context/CTX-001-cli-substrate.md", validCTX) + plantDoc(t, root, "docs/decisions/ADR-0001-use-go.md", validADR2) + plantDoc(t, root, "docs/decisions/ADR-0002-rate-limit.md", `--- +title: Rate limiting strategy +status: draft +coverage: required +date: 2026-01-02 +supersedes: [] +related: [] +tags: [api, rate-limit] +--- + +# Rate limiting strategy + +## Context + +We need to protect our API endpoints from abuse. + +## Decision + +Token bucket algorithm for rate limiting. +`) + plantDoc(t, root, "docs/tasks/TP-001-scaffold.md", taskDone) + plantDoc(t, root, "docs/tasks/TP-002-rate-limiter.md", `--- +title: Implement rate limiter +status: backlog +priority: p1 +assignee: unassigned +requires: [ADR-0002] +depends_on: [] +--- + +# Implement rate limiter + +## Goal + +Ship the token bucket rate limiter. + +## Acceptance + +## Notes +`) + return root +} + +// ── basic text match ───────────────────────────────────────────────────────── + +func TestCmdSearch_TitleMatch(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + code := cmdSearch([]string{"Go"}) + if code != 0 { + t.Fatalf("cmdSearch returned %d", code) + } + }) + if !strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 (title 'Use Go') missing:\n%s", out) + } + if !strings.Contains(out, "title") { + t.Errorf("match_field 'title' missing from output:\n%s", out) + } +} + +func TestCmdSearch_TagMatch(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"rate-limit"}) + }) + if !strings.Contains(out, "ADR-0002") { + t.Errorf("ADR-0002 (tag 'rate-limit') missing:\n%s", out) + } + if !strings.Contains(out, "tags") { + t.Errorf("match_field 'tags' missing:\n%s", out) + } +} + +func TestCmdSearch_BodyMatch(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"token bucket"}) + }) + if !strings.Contains(out, "ADR-0002") { + t.Errorf("ADR-0002 (body 'token bucket') missing:\n%s", out) + } + if !strings.Contains(out, "body") { + t.Errorf("match_field 'body' missing:\n%s", out) + } +} + +func TestCmdSearch_CaseInsensitiveDefault(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"go for implementation"}) + }) + if !strings.Contains(out, "ADR-0001") { + t.Errorf("case-insensitive match failed:\n%s", out) + } +} + +func TestCmdSearch_CaseSensitive(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--case", "USE GO"}) // uppercase won't match lowercase title + }) + if strings.Contains(out, "ADR-0001") { + t.Errorf("case-sensitive search should not match 'Use Go' with 'USE GO':\n%s", out) + } +} + +func TestCmdSearch_RegexMode(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--regex", "rat(e|ing)"}) + }) + if !strings.Contains(out, "ADR-0002") { + t.Errorf("regex match failed:\n%s", out) + } +} + +func TestCmdSearch_InvalidRegex(t *testing.T) { + plantSearchDocs(t) + code := cmdSearch([]string{"--regex", "["}) + if code != 2 { + t.Errorf("invalid regex should return 2, got %d", code) + } +} + +// ── structured filters ─────────────────────────────────────────────────────── + +func TestCmdSearch_KindFilter(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--kind", "ADR", "rate"}) + }) + if strings.Contains(out, "TP-") { + t.Errorf("TP docs should be excluded by --kind ADR:\n%s", out) + } + if !strings.Contains(out, "ADR-0002") { + t.Errorf("ADR-0002 should match:\n%s", out) + } +} + +func TestCmdSearch_StatusFilter(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--kind", "ADR", "--status", "draft", "rate"}) + }) + if !strings.Contains(out, "ADR-0002") { + t.Errorf("ADR-0002 (draft) missing:\n%s", out) + } + if strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 (accepted) should be excluded:\n%s", out) + } +} + +func TestCmdSearch_CoverageFilter(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--coverage", "required", "rate"}) + }) + if !strings.Contains(out, "ADR-0002") { + t.Errorf("ADR-0002 missing with coverage=required:\n%s", out) + } +} + +func TestCmdSearch_TagStructuredFilter(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--tag", "api", "rate"}) + }) + if !strings.Contains(out, "ADR-0002") { + t.Errorf("ADR-0002 missing with --tag api:\n%s", out) + } + if strings.Contains(out, "ADR-0001") { + t.Errorf("ADR-0001 should be excluded (no api tag):\n%s", out) + } +} + +func TestCmdSearch_FilterOnly(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + code := cmdSearch([]string{"--kind", "ADR"}) + if code != 0 { + t.Fatalf("filter-only returned %d", code) + } + }) + if !strings.Contains(out, "ADR-0001") || !strings.Contains(out, "ADR-0002") { + t.Errorf("both ADRs should appear in filter-only:\n%s", out) + } +} + +func TestCmdSearch_NoQueryNoFilter(t *testing.T) { + plantSearchDocs(t) + code := cmdSearch(nil) + if code != 2 { + t.Errorf("no query no filter should return 2, got %d", code) + } +} + +func TestCmdSearch_CTXStatusError(t *testing.T) { + plantSearchDocs(t) + code := cmdSearch([]string{"--kind", "CTX", "--status", "accepted", "cli"}) + if code != 2 { + t.Errorf("CTX+status should return 2, got %d", code) + } +} + +// ── ranking ────────────────────────────────────────────────────────────────── + +func TestCmdSearch_RankingTitleBeforeBody(t *testing.T) { + // "Rate limiting" appears in ADR-0002 title AND body. + // Title match should rank first. + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"rate"}) + }) + // ADR-0002 should appear; its match should be "title" not "body" + if !strings.Contains(out, "ADR-0002") { + t.Fatalf("ADR-0002 missing:\n%s", out) + } + // In the output, ADR-0002's line should show (title) snippet + lines := strings.Split(out, "\n") + for i, l := range lines { + if strings.Contains(l, "ADR-0002") && i+1 < len(lines) { + if strings.Contains(lines[i+1], "(title)") { + return // pass + } + } + } + t.Errorf("expected (title) match for ADR-0002:\n%s", out) +} + +// ── output shapes ──────────────────────────────────────────────────────────── + +func TestCmdSearch_JSON(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + code := cmdSearch([]string{"--json", "rate"}) + if code != 0 { + t.Fatalf("--json returned %d", code) + } + }) + var results []searchResult + if err := json.Unmarshal([]byte(out), &results); err != nil { + t.Fatalf("--json not valid JSON: %v\n%s", err, out) + } + if len(results) == 0 { + t.Error("expected at least one result") + } + for _, r := range results { + if r.ID == "" || r.Kind == "" || r.MatchField == "" { + t.Errorf("result missing required fields: %+v", r) + } + } +} + +func TestCmdSearch_JSONEmptyOnNoMatch(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"--json", "xyzzy-no-match-anywhere"}) + }) + var results []searchResult + if err := json.Unmarshal([]byte(out), &results); err != nil { + t.Fatalf("--json not valid JSON: %v\n%s", err, out) + } + if len(results) != 0 { + t.Errorf("expected empty array, got %d results", len(results)) + } +} + +func TestCmdSearch_SummaryLine(t *testing.T) { + plantSearchDocs(t) + out := captureStdout(t, func() { + cmdSearch([]string{"rate"}) + }) + if !strings.Contains(out, "match(es)") { + t.Errorf("missing trailing 'N match(es)' line:\n%s", out) + } +} + +func TestCmdSearch_NoConfig(t *testing.T) { + root := t.TempDir() + orig, _ := os.Getwd() + _ = os.Chdir(root) + t.Cleanup(func() { _ = os.Chdir(orig) }) + + code := cmdSearch([]string{"anything"}) + if code != 2 { + t.Errorf("no config should return 2, got %d", code) + } +} + +// ── dog-food ───────────────────────────────────────────────────────────────── + +func TestCmdSearch_Dogfood(t *testing.T) { + // Run against the real project repo. Only works when cwd is the repo root. + orig, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(orig) }) + + out := captureStdout(t, func() { + code := cmdSearch([]string{"ADR-0018"}) + if code != 0 { + t.Fatalf("dog-food search returned %d", code) + } + }) + if !strings.Contains(out, "match(es)") { + t.Errorf("dog-food: missing summary line:\n%s", out) + } +} diff --git a/cmd/docops/cmd_update_check.go b/cmd/docops/cmd_update_check.go new file mode 100644 index 0000000..c89d82e --- /dev/null +++ b/cmd/docops/cmd_update_check.go @@ -0,0 +1,114 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "time" + + "github.com/logicwind/docops/internal/updatecheck" + "github.com/logicwind/docops/internal/version" +) + +// cmdUpdateCheck implements `docops update-check`. Prints exactly one +// line (or zero, if the check was skipped) and always exits 0 — the +// subcommand is a probe, not a guard. +// +// Output: +// +// UP_TO_DATE 0.1.2 +// UPGRADE_AVAILABLE 0.1.1 0.1.2 +// (nothing) — skipped/snoozed/disabled +// +// Flags: +// +// --force bypass cache (and snooze) for one fresh probe +// --snooze record a snooze for the current available remote +// --json emit a structured object instead of the line above +func cmdUpdateCheck(args []string) int { + return runUpdateCheck(args, os.Stdout, os.Stderr, version.Version, "") +} + +// runUpdateCheck is the testable core. local injects the version +// (otherwise read from the version package); stateDirOverride lets +// tests redirect the state dir without touching $HOME. +func runUpdateCheck(args []string, stdout, stderr io.Writer, local, stateDirOverride string) int { + fs := flag.NewFlagSet("update-check", flag.ContinueOnError) + fs.SetOutput(stderr) + force := fs.Bool("force", false, "bypass cache and snooze for one fresh probe") + snooze := fs.Bool("snooze", false, "snooze the currently available upgrade") + asJSON := fs.Bool("json", false, "emit a JSON object instead of the line format") + fs.Usage = func() { + fmt.Fprintln(stderr, "usage: docops update-check [--force] [--snooze] [--json]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + stateDir := stateDirOverride + if stateDir == "" { + stateDir = updatecheck.DefaultStateDir() + } + + res, err := updatecheck.Run(updatecheck.Opts{ + Local: local, + RemoteURL: os.Getenv("DOCOPS_REMOTE_URL"), + StateDir: stateDir, + Force: *force, + }) + if err != nil { + fmt.Fprintf(stderr, "docops update-check: %v\n", err) + return 2 + } + + if *snooze && res.Status == updatecheck.StatusUpgradeAvailable { + if err := updatecheck.Snooze(stateDir, res.Remote, time.Now()); err != nil { + fmt.Fprintf(stderr, "docops update-check: snooze: %v\n", err) + return 2 + } + // After snoozing, future cached reads will suppress; for this + // invocation, fall through to print the available upgrade so + // the user sees what they just snoozed. + } + + if *asJSON { + emitUpdateCheckJSON(stdout, res) + return 0 + } + emitUpdateCheckLine(stdout, res) + return 0 +} + +func emitUpdateCheckLine(w io.Writer, res updatecheck.Result) { + switch res.Status { + case updatecheck.StatusUpToDate: + fmt.Fprintf(w, "UP_TO_DATE %s\n", res.Local) + case updatecheck.StatusUpgradeAvailable: + fmt.Fprintf(w, "UPGRADE_AVAILABLE %s %s\n", res.Local, res.Remote) + case updatecheck.StatusSkipped: + // Intentional: scripts treat silence as "no signal". + } +} + +func emitUpdateCheckJSON(w io.Writer, res updatecheck.Result) { + payload := map[string]string{ + "status": res.Status.String(), + "local": res.Local, + } + if res.Remote != "" { + payload["remote"] = res.Remote + } + if res.Reason != "" { + payload["reason"] = res.Reason + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(payload) +} diff --git a/cmd/docops/cmd_update_check_test.go b/cmd/docops/cmd_update_check_test.go new file mode 100644 index 0000000..1ba64d2 --- /dev/null +++ b/cmd/docops/cmd_update_check_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCmdUpdateCheck_UpToDateLine(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "0.1.1\n") + })) + defer srv.Close() + t.Setenv("DOCOPS_REMOTE_URL", srv.URL) + t.Setenv("DOCOPS_UPDATE_CHECK", "") + + state := t.TempDir() + var stdout, stderr bytes.Buffer + code := runUpdateCheck(nil, &stdout, &stderr, "0.1.1", state) + if code != 0 { + t.Fatalf("exit = %d; want 0; stderr=%s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != "UP_TO_DATE 0.1.1" { + t.Errorf("stdout = %q; want %q", got, "UP_TO_DATE 0.1.1") + } +} + +func TestCmdUpdateCheck_UpgradeAvailableLine(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "0.1.2\n") + })) + defer srv.Close() + t.Setenv("DOCOPS_REMOTE_URL", srv.URL) + t.Setenv("DOCOPS_UPDATE_CHECK", "") + + state := t.TempDir() + var stdout, stderr bytes.Buffer + code := runUpdateCheck(nil, &stdout, &stderr, "0.1.1", state) + if code != 0 { + t.Fatalf("exit = %d; want 0; stderr=%s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != "UPGRADE_AVAILABLE 0.1.1 0.1.2" { + t.Errorf("stdout = %q; want %q", got, "UPGRADE_AVAILABLE 0.1.1 0.1.2") + } +} + +func TestCmdUpdateCheck_DisabledIsSilent(t *testing.T) { + t.Setenv("DOCOPS_REMOTE_URL", "http://127.0.0.1:1/never-hit") + t.Setenv("DOCOPS_UPDATE_CHECK", "off") + + state := t.TempDir() + var stdout, stderr bytes.Buffer + code := runUpdateCheck(nil, &stdout, &stderr, "0.1.1", state) + if code != 0 { + t.Fatalf("exit = %d; want 0", code) + } + if stdout.Len() != 0 { + t.Errorf("stdout = %q; want empty when disabled", stdout.String()) + } +} + +func TestCmdUpdateCheck_DevBuildIsSilent(t *testing.T) { + t.Setenv("DOCOPS_REMOTE_URL", "http://127.0.0.1:1/never-hit") + t.Setenv("DOCOPS_UPDATE_CHECK", "") + + state := t.TempDir() + var stdout, stderr bytes.Buffer + code := runUpdateCheck(nil, &stdout, &stderr, "dev", state) + if code != 0 { + t.Fatalf("exit = %d; want 0", code) + } + if stdout.Len() != 0 { + t.Errorf("stdout = %q; want empty for dev build", stdout.String()) + } +} + +func TestCmdUpdateCheck_JSONShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "0.1.2\n") + })) + defer srv.Close() + t.Setenv("DOCOPS_REMOTE_URL", srv.URL) + t.Setenv("DOCOPS_UPDATE_CHECK", "") + + state := t.TempDir() + var stdout, stderr bytes.Buffer + code := runUpdateCheck([]string{"--json"}, &stdout, &stderr, "0.1.1", state) + if code != 0 { + t.Fatalf("exit = %d; want 0; stderr=%s", code, stderr.String()) + } + var payload map[string]string + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("invalid JSON: %v\nbody=%s", err, stdout.String()) + } + if payload["status"] != "upgrade-available" || payload["local"] != "0.1.1" || payload["remote"] != "0.1.2" { + t.Errorf("payload = %v; missing/wrong fields", payload) + } +} + +func TestCmdUpdateCheck_ForceBypassesCache(t *testing.T) { + state := t.TempDir() + if err := os.WriteFile(filepath.Join(state, "last-update-check"), []byte("UP_TO_DATE 0.1.1\n"), 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "0.1.2\n") + })) + defer srv.Close() + t.Setenv("DOCOPS_REMOTE_URL", srv.URL) + t.Setenv("DOCOPS_UPDATE_CHECK", "") + + var stdout, stderr bytes.Buffer + code := runUpdateCheck([]string{"--force"}, &stdout, &stderr, "0.1.1", state) + if code != 0 { + t.Fatalf("exit = %d; want 0; stderr=%s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != "UPGRADE_AVAILABLE 0.1.1 0.1.2" { + t.Errorf("stdout = %q; want fresh upgrade line", got) + } +} + +func TestCmdUpdateCheck_SnoozeWritesFile(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "0.1.2\n") + })) + defer srv.Close() + t.Setenv("DOCOPS_REMOTE_URL", srv.URL) + t.Setenv("DOCOPS_UPDATE_CHECK", "") + + state := t.TempDir() + var stdout, stderr bytes.Buffer + code := runUpdateCheck([]string{"--snooze"}, &stdout, &stderr, "0.1.1", state) + if code != 0 { + t.Fatalf("exit = %d; want 0; stderr=%s", code, stderr.String()) + } + body, err := os.ReadFile(filepath.Join(state, "update-snoozed")) + if err != nil { + t.Fatalf("snooze file missing: %v", err) + } + if !strings.HasPrefix(string(body), "0.1.2 1 ") { + t.Errorf("snooze body = %q; want start with '0.1.2 1 '", body) + } +} diff --git a/cmd/docops/cmd_upgrade.go b/cmd/docops/cmd_upgrade.go new file mode 100644 index 0000000..0084c00 --- /dev/null +++ b/cmd/docops/cmd_upgrade.go @@ -0,0 +1,273 @@ +package main + +import ( + "bufio" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/logicwind/docops/internal/scaffold" + "github.com/logicwind/docops/internal/updatecheck" + "github.com/logicwind/docops/internal/upgrader" + "github.com/logicwind/docops/internal/version" + "golang.org/x/term" +) + +// cmdUpgrade implements `docops upgrade [--dry-run] [--yes] [--config] +// [--hook] [--json]`. Exit codes: +// +// 0 upgrade applied (or dry-run rendered, or user aborted) +// 2 bootstrap error (no docops.yaml, safety belt fired, IO error) +func cmdUpgrade(args []string) int { + fs := flag.NewFlagSet("upgrade", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + dryRun := fs.Bool("dry-run", false, "print the planned changes without writing") + cfg := fs.Bool("config", false, "also overwrite docops.yaml from the shipped template") + hook := fs.Bool("hook", false, "also reinstall the pre-commit hook") + asJSON := fs.Bool("json", false, "emit a JSON action plan instead of human output") + yes := fs.Bool("yes", false, "skip the interactive confirm prompt") + fs.BoolVar(yes, "y", false, "skip the interactive confirm prompt (short form)") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: docops upgrade [--dry-run] [--yes] [--config] [--hook] [--json]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "docops upgrade: %v\n", err) + return 2 + } + + // Plan first. We always run the planner in dry-run mode internally + // so we can decide whether to prompt and what to print before + // committing to disk. + plan, err := upgrader.Run(upgrader.Options{ + Root: cwd, + DryRun: true, + Config: *cfg, + Hook: *hook, + Out: io.Discard, + }) + if err != nil { + return reportUpgradeError(err) + } + + if *asJSON { + emitUpgradeJSON(os.Stdout, plan.Actions) + if *dryRun { + return 0 + } + } else { + // Pre-flight stale-binary warning. Free on cache hit; one + // 5s network round-trip on a cold cache. Surfaces only when + // the user's binary is behind the latest release. + warnIfStaleBinary(os.Stdout, *yes) + printUpgradeHeader(os.Stdout, cwd, *cfg, *hook) + // Re-print the plan via the upgrader's own renderer (we + // discarded it earlier so we could decide on the prompt + // first). + printUpgradePlan(os.Stdout, plan.Actions, true) + } + + if *dryRun { + return 0 + } + + nonSkip := 0 + for _, a := range plan.Actions { + if a.Kind != scaffold.KindSkip { + nonSkip++ + } + } + + if !*asJSON && nonSkip > 0 && !*yes && term.IsTerminal(int(os.Stdin.Fd())) { + if !confirm(os.Stdout, os.Stdin, "Proceed?") { + fmt.Fprintln(os.Stdout, "docops upgrade: aborted by user.") + return 0 + } + } + + // Execute for real. The upgrader prints its own plan again; for + // JSON callers we already emitted, so route human output to + // stdout and structured output to a discard sink. + out := io.Writer(os.Stdout) + if *asJSON { + out = io.Discard + } + if _, err := upgrader.Run(upgrader.Options{ + Root: cwd, + DryRun: false, + Config: *cfg, + Hook: *hook, + Out: out, + }); err != nil { + return reportUpgradeError(err) + } + return 0 +} + +func reportUpgradeError(err error) int { + switch { + case errors.Is(err, upgrader.ErrNoConfig): + fmt.Fprintln(os.Stderr, "docops upgrade: no docops.yaml in the current directory.") + fmt.Fprintln(os.Stderr, " Run `docops init` first to scaffold the project.") + return 2 + default: + var unk *upgrader.ErrUnknownFiles + if errors.As(err, &unk) { + fmt.Fprintf(os.Stderr, "docops upgrade: %s contains user-added files docops did not write:\n", unk.Dir) + for _, f := range unk.Files { + fmt.Fprintf(os.Stderr, " - %s\n", f) + } + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Move them one level up (e.g. into .claude/skills/) so docops upgrade can") + fmt.Fprintln(os.Stderr, "manage its own subdirectory cleanly. See ADR-0021 for the rationale.") + return 2 + } + fmt.Fprintf(os.Stderr, "docops upgrade: %v\n", err) + return 2 + } +} + +func printUpgradeHeader(w io.Writer, cwd string, optConfig, optHook bool) { + fmt.Fprintf(w, "docops upgrade will refresh DocOps-owned scaffolding in %s:\n", cwd) + fmt.Fprintln(w, " - .claude/skills/docops/* and .cursor/commands/docops/* (replaced/removed to match the shipped bundle)") + fmt.Fprintln(w, " - docs/.docops/schema/*.schema.json (regenerated from docops.yaml)") + fmt.Fprintln(w, " - The block in AGENTS.md and CLAUDE.md (refreshed in place; either file is created if absent)") + if optConfig { + fmt.Fprintln(w, " - docops.yaml (overwritten — --config)") + } + if optHook { + fmt.Fprintln(w, " - .git/hooks/pre-commit (reinstalled — --hook)") + } + fmt.Fprintln(w, "Your docops.yaml, pre-commit hook, and docs/{context,decisions,tasks}/ stay untouched by default.") +} + +// printUpgradePlan delegates to the upgrader's Run-time renderer by +// re-running the formatting locally. We duplicate the few lines of +// rendering rather than expose internals because the cmd-level header +// + plan + footer is not the upgrader's responsibility. +func printUpgradePlan(w io.Writer, actions []scaffold.Action, dry bool) { + var changed, skipped int + for _, a := range actions { + if a.Kind == scaffold.KindSkip { + skipped++ + } else { + changed++ + } + } + verb := "applied" + if dry { + verb = "would apply" + } + fmt.Fprintf(w, "\ndocops upgrade: %s %d change(s), skipped %d\n", verb, changed, skipped) + for _, a := range actions { + sigil, label := upgradeSigilLabel(a) + fmt.Fprintf(w, " %s %-40s %s\n", sigil, a.Rel, label) + } +} + +// upgradeSigilLabel mirrors upgrader.upgradeSigil. Kept here to avoid +// exporting an internal helper just for cmd-level rendering; if the +// duplication grows past a third callsite, promote it. +func upgradeSigilLabel(a scaffold.Action) (string, string) { + switch a.Kind { + case scaffold.KindMkdir: + return "+", "(new dir)" + case scaffold.KindWriteFile: + if a.Reason == "create" || a.Reason == "install" { + return "+", "(new)" + } + return "~", "(refreshed)" + case scaffold.KindMergeAgents: + return "~", "(block refreshed)" + case scaffold.KindRemove: + return "-", "(removed)" + case scaffold.KindSkip: + return "=", "(up to date)" + } + return "?", "(unknown)" +} + +func emitUpgradeJSON(w io.Writer, actions []scaffold.Action) { + type action struct { + Path string `json:"path"` + Kind string `json:"kind"` + } + out := struct { + OK bool `json:"ok"` + Actions []action `json:"actions"` + }{OK: true} + for _, a := range actions { + out.Actions = append(out.Actions, action{Path: a.Rel, Kind: jsonKind(a)}) + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(out) +} + +// jsonKind maps internal action kinds to the user-facing labels +// promised by ADR-0021's --json contract. +func jsonKind(a scaffold.Action) string { + switch a.Kind { + case scaffold.KindWriteFile: + if a.Reason == "create" || a.Reason == "install" { + return "new" + } + return "refreshed" + case scaffold.KindMergeAgents: + return "block-refreshed" + case scaffold.KindRemove: + return "removed" + case scaffold.KindSkip: + return "up-to-date" + case scaffold.KindMkdir: + return "new" + } + return a.Kind +} + +func confirm(w io.Writer, r io.Reader, prompt string) bool { + fmt.Fprintf(w, "\n%s [y/N] ", prompt) + scanner := bufio.NewScanner(r) + if !scanner.Scan() { + return false + } + answer := strings.TrimSpace(scanner.Text()) + return answer == "y" || answer == "Y" +} + +// warnIfStaleBinary runs the cached update-check and, if the binary +// is behind upstream, prints a warning + interactive prompt. Honors +// `--yes` (proceed without prompting) and non-TTY stdin (proceed +// silently — CI must keep flowing). +func warnIfStaleBinary(w io.Writer, yes bool) { + res, err := updatecheck.Run(updatecheck.Opts{Local: version.Version}) + if err != nil { + return + } + if res.Status != updatecheck.StatusUpgradeAvailable { + return + } + fmt.Fprintf(w, "Warning: docops %s is installed; %s is available.\n", res.Local, res.Remote) + fmt.Fprintln(w, " Run `brew upgrade docops` (or your package manager equivalent)") + fmt.Fprintln(w, " before `docops upgrade`, or you'll sync the older templates.") + fmt.Fprintln(w, "") + if yes || !term.IsTerminal(int(os.Stdin.Fd())) { + return + } + if !confirm(w, os.Stdin, fmt.Sprintf("Continue with %s templates anyway?", res.Local)) { + fmt.Fprintln(w, "docops upgrade: aborted by user (binary upgrade pending).") + os.Exit(0) + } +} diff --git a/cmd/docops/cmd_upgrade_test.go b/cmd/docops/cmd_upgrade_test.go new file mode 100644 index 0000000..cbe3e8b --- /dev/null +++ b/cmd/docops/cmd_upgrade_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/logicwind/docops/internal/scaffold" + "github.com/logicwind/docops/templates" +) + +// upgradeFixture builds a minimal docops-initialized tempdir, chdirs +// into it for the test, and returns the absolute path. cmdUpgrade +// reads cwd, so the chdir is what couples it to the fixture. +func upgradeFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + + yamlBody, err := templates.DocopsYAML() + if err != nil { + t.Fatalf("template DocopsYAML: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "docops.yaml"), yamlBody, 0o644); err != nil { + t.Fatalf("write docops.yaml: %v", err) + } + + skills, err := scaffold.LoadShippedSkills() + if err != nil { + t.Fatalf("LoadShippedSkills: %v", err) + } + for _, dir := range []string{".claude/skills/docops", ".cursor/commands/docops"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + for name, body := range skills { + if err := os.WriteFile(filepath.Join(root, dir, name), body, 0o644); err != nil { + t.Fatalf("seed skill: %v", err) + } + } + } + + prevDir, _ := os.Getwd() + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir fixture: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(prevDir) }) + + return root +} + +func TestCmdUpgrade_RefusesWithoutDocopsYAML(t *testing.T) { + root := t.TempDir() + prevDir, _ := os.Getwd() + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(prevDir) }) + + code := cmdUpgrade([]string{"--dry-run"}) + if code != 2 { + t.Errorf("exit = %d; want 2 when docops.yaml is missing", code) + } +} + +func TestCmdUpgrade_DryRunWritesNothing(t *testing.T) { + root := upgradeFixture(t) + stale := filepath.Join(root, ".claude/skills/docops/next.md") + if err := os.WriteFile(stale, []byte("stale\n"), 0o644); err != nil { + t.Fatalf("seed stale: %v", err) + } + code := cmdUpgrade([]string{"--dry-run"}) + if code != 0 { + t.Errorf("exit = %d; want 0", code) + } + if _, err := os.Stat(stale); err != nil { + t.Errorf("dry-run should not delete stale skill: %v", err) + } +} + +func TestCmdUpgrade_JSONShape(t *testing.T) { + upgradeFixture(t) + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + prevStdout := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = prevStdout }) + + code := cmdUpgrade([]string{"--dry-run", "--json"}) + w.Close() + if code != 0 { + t.Fatalf("exit = %d; want 0", code) + } + + var buf bytes.Buffer + if _, err := buf.ReadFrom(r); err != nil { + t.Fatalf("read: %v", err) + } + + var payload struct { + OK bool `json:"ok"` + Actions []struct { + Path string `json:"path"` + Kind string `json:"kind"` + } `json:"actions"` + } + if err := json.Unmarshal(buf.Bytes(), &payload); err != nil { + t.Fatalf("invalid JSON: %v\nbody=%s", err, buf.String()) + } + if !payload.OK { + t.Errorf("payload.OK = false") + } + if len(payload.Actions) == 0 { + t.Errorf("expected non-empty actions list") + } + allowed := map[string]bool{"new": true, "refreshed": true, "removed": true, "up-to-date": true, "block-refreshed": true} + for _, a := range payload.Actions { + if !allowed[a.Kind] { + t.Errorf("unexpected action kind %q for %s", a.Kind, a.Path) + } + } +} + +func TestCmdUpgrade_YesFlagSkipsPromptAndApplies(t *testing.T) { + root := upgradeFixture(t) + stale := filepath.Join(root, ".claude/skills/docops/next.md") + if err := os.WriteFile(stale, []byte("stale\n"), 0o644); err != nil { + t.Fatalf("seed stale: %v", err) + } + + // Redirect stdout so the (potentially non-tty) prompt and plan + // don't pollute test output. We can't actually drive the prompt + // from a test without a TTY, but --yes bypasses the prompt + // entirely so the apply runs unconditionally. + r, w, _ := os.Pipe() + prevStdout := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = prevStdout }) + + code := cmdUpgrade([]string{"--yes"}) + w.Close() + if code != 0 { + t.Errorf("exit = %d; want 0", code) + } + + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("--yes should have applied the upgrade and removed the stale skill: %v", err) + } + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + out := buf.String() + if !strings.Contains(out, "docops upgrade:") { + t.Errorf("expected output summary line; got %q", out) + } +} diff --git a/cmd/docops/main.go b/cmd/docops/main.go index 317ca4f..db1a00f 100644 --- a/cmd/docops/main.go +++ b/cmd/docops/main.go @@ -35,6 +35,20 @@ func main() { os.Exit(cmdSchema(args[1:])) case "refresh": os.Exit(cmdRefresh(args[1:])) + case "get": + os.Exit(cmdGet(args[1:])) + case "list": + os.Exit(cmdList(args[1:])) + case "graph": + os.Exit(cmdGraph(args[1:])) + case "next": + os.Exit(cmdNext(args[1:])) + case "search": + os.Exit(cmdSearch(args[1:])) + case "update-check": + os.Exit(cmdUpdateCheck(args[1:])) + case "upgrade": + os.Exit(cmdUpgrade(args[1:])) default: fmt.Fprintf(os.Stderr, "docops: unknown command %q\n\n", args[0]) topLevelUsage(os.Stderr) @@ -49,6 +63,7 @@ func topLevelUsage(w *os.File) { fmt.Fprintln(w, "") fmt.Fprintln(w, "commands:") fmt.Fprintln(w, " init scaffold DocOps in this repository") + fmt.Fprintln(w, " upgrade refresh DocOps-owned scaffolding in an existing project") fmt.Fprintln(w, " validate schema + graph invariants over docs/") fmt.Fprintln(w, " index build docs/.index.json enriched graph") fmt.Fprintln(w, " state regenerate docs/STATE.md snapshot") @@ -56,10 +71,13 @@ func topLevelUsage(w *os.File) { fmt.Fprintln(w, " new scaffold a new CTX/ADR/Task document") fmt.Fprintln(w, " schema (re)write docs/.docops/schema/*.schema.json") fmt.Fprintln(w, " refresh validate + index + state in one pass") + fmt.Fprintln(w, " get look up one doc by ID") + fmt.Fprintln(w, " list list docs with optional filters") + fmt.Fprintln(w, " graph typed edge graph from a starting doc") + fmt.Fprintln(w, " next recommend the next task to work on") + fmt.Fprintln(w, " search substring/regex search over title, tags, and body") + fmt.Fprintln(w, " update-check check for a newer docops release (cached probe)") fmt.Fprintln(w, " version print the build version") fmt.Fprintln(w, "") - fmt.Fprintln(w, "coming:") - fmt.Fprintln(w, " status, get, graph, review") - fmt.Fprintln(w, "") fmt.Fprintln(w, "see `docops --help` for per-command flags.") } diff --git a/docs/.docops/counters.json b/docs/.docops/counters.json index 191ffa6..21659b4 100644 --- a/docs/.docops/counters.json +++ b/docs/.docops/counters.json @@ -1,8 +1,8 @@ { "version": 1, "next": { - "ADR": 22, + "ADR": 25, "CTX": 5, - "TP": 19 + "TP": 23 } } diff --git a/docs/.index.json b/docs/.index.json index 1124df4..a3412a0 100644 --- a/docs/.index.json +++ b/docs/.index.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-04-22T18:42:08Z", + "generated_at": "2026-04-23T03:32:23Z", "version": 1, "docs": [ { @@ -88,7 +88,7 @@ "edge": "requires" } ], - "implementation": "partial", + "implementation": "done", "stale": false }, { @@ -228,7 +228,7 @@ "edge": "requires" } ], - "implementation": "partial", + "implementation": "done", "stale": false }, { @@ -282,7 +282,7 @@ "edge": "requires" } ], - "implementation": "partial", + "implementation": "done", "stale": false }, { @@ -447,7 +447,7 @@ "edge": "requires" } ], - "implementation": "partial", + "implementation": "done", "stale": false }, { @@ -773,7 +773,7 @@ "edge": "requires" } ], - "implementation": "not-started", + "implementation": "done", "stale": false }, { @@ -815,7 +815,7 @@ "edge": "requires" } ], - "implementation": "not-started", + "implementation": "done", "stale": false }, { @@ -844,6 +844,10 @@ "last_touched": "2026-04-22T12:30:23Z", "age_days": 0, "referenced_by": [ + { + "id": "ADR-0023", + "edge": "related" + }, { "id": "TP-013", "edge": "requires" @@ -931,9 +935,21 @@ "id": "ADR-0022", "edge": "related" }, + { + "id": "ADR-0023", + "edge": "related" + }, + { + "id": "ADR-0024", + "edge": "related" + }, { "id": "TP-018", "edge": "requires" + }, + { + "id": "TP-020", + "edge": "requires" } ], "implementation": "not-started", @@ -960,7 +976,7 @@ ], "summary": "DocOps currently writes skills into two tool-specific directories during `docops init` and `docops upgrade`:", "word_count": 718, - "last_touched": "2026-04-22T18:41:28Z", + "last_touched": "2026-04-22T18:58:49Z", "age_days": 0, "referenced_by": [ { @@ -971,6 +987,70 @@ "implementation": "not-started", "stale": false }, + { + "id": "ADR-0023", + "kind": "ADR", + "folder": "docs/decisions", + "path": "docs/decisions/ADR-0023-update-check-cached-lazy-opt-out-version-probe.md", + "title": "Update-check — cached, lazy, opt-out version probe", + "status": "draft", + "coverage": "required", + "date": "2026-04-23", + "related": [ + "ADR-0021", + "ADR-0019" + ], + "summary": "DocOps ships through Homebrew, Scoop, and direct binary downloads. Once a user runs `docops init`, their project files (skills, schemas, AGENTS.md block) drift from upstream every time we cut a releas…", + "word_count": 909, + "last_touched": "2026-04-22T19:48:15Z", + "age_days": 0, + "referenced_by": [ + { + "id": "ADR-0024", + "edge": "related" + }, + { + "id": "TP-018", + "edge": "requires" + }, + { + "id": "TP-020", + "edge": "requires" + }, + { + "id": "TP-021", + "edge": "requires" + } + ], + "implementation": "not-started", + "stale": false + }, + { + "id": "ADR-0024", + "kind": "ADR", + "folder": "docs/decisions", + "path": "docs/decisions/ADR-0024-ship-claude-md-alongside-agents-md-both-share-the.md", + "title": "Ship CLAUDE.md alongside AGENTS.md — both share the docops block", + "status": "draft", + "coverage": "required", + "date": "2026-04-23", + "related": [ + "ADR-0021", + "ADR-0023" + ], + "summary": "DocOps writes its invariants (citation rules, no-edit-STATE.md, schema locations, etc.) into a delimited block in `AGENTS.md` so any coding agent — Claude Code, Cursor, Codex, Aider, Copilot, Windsurf…", + "word_count": 555, + "last_touched": "2026-04-23T03:32:01Z", + "age_days": 0, + "referenced_by": [ + { + "id": "TP-022", + "edge": "requires" + } + ], + "implementation": "not-started", + "stale": false + }, { "id": "CTX-001", "kind": "CTX", @@ -1428,9 +1508,9 @@ "folder": "docs/tasks", "path": "docs/tasks/TP-011-implement-search-command.md", "title": "Implement `docops search` — content + structured filter query", - "task_status": "backlog", + "task_status": "done", "priority": "p1", - "assignee": "unassigned", + "assignee": "nachiket", "requires": [ "ADR-0017", "ADR-0002", @@ -1441,7 +1521,7 @@ ], "summary": "Ship a single command that answers \"has this been discussed before?\" — substring or regex match over title / tags / body, composable with structured frontmatter filters. Output is the focused slice an…", "word_count": 345, - "last_touched": "2026-04-22T08:19:55Z", + "last_touched": "2026-04-22T19:24:00Z", "age_days": 0, "stale": false }, @@ -1451,9 +1531,9 @@ "folder": "docs/tasks", "path": "docs/tasks/TP-012-implement-read-commands.md", "title": "Implement focused read commands — `get`, `list`, `graph`, `next`", - "task_status": "backlog", + "task_status": "done", "priority": "p1", - "assignee": "unassigned", + "assignee": "nachiket", "requires": [ "ADR-0018", "ADR-0005", @@ -1465,7 +1545,7 @@ ], "summary": "Close the query-surface gap identified in ADR-0018 so agents never need to `cat docs/.index.json` to answer routine questions. Each command returns a focused slice of the index, not the whole graph.", "word_count": 494, - "last_touched": "2026-04-22T08:19:55Z", + "last_touched": "2026-04-22T19:13:03Z", "age_days": 0, "stale": false }, @@ -1604,16 +1684,19 @@ "priority": "p2", "assignee": "unassigned", "requires": [ - "ADR-0021" + "ADR-0021", + "ADR-0023" ], "depends_on": [ "TP-007", "TP-009", - "TP-013" + "TP-013", + "TP-020", + "TP-021" ], "summary": "Ship the `docops upgrade` subcommand that pulls the current binary's shipped templates into an already-initialized project without clobbering `docops.yaml` or the pre-commit hook, per ADR-0021.", "word_count": 743, - "last_touched": "2026-04-22T18:02:14Z", + "last_touched": "2026-04-22T19:48:15Z", "age_days": 0, "referenced_by": [ { @@ -1644,7 +1727,80 @@ ], "summary": "Update `docops init` and `docops upgrade` to adopt the skills.sh canonical layout: skill files live in `.agents/skills/docops/`; tool-specific dirs (e.g. `.claude/skills/docops`) become symlinks. Per …", "word_count": 618, - "last_touched": "2026-04-22T18:42:01Z", + "last_touched": "2026-04-22T18:58:49Z", + "age_days": 0, + "stale": false + }, + { + "id": "TP-020", + "kind": "TP", + "folder": "docs/tasks", + "path": "docs/tasks/TP-020-extract-internal-scaffold-from-internal-initter-re.md", + "title": "Extract internal/scaffold/ from internal/initter/ (refactor)", + "task_status": "backlog", + "priority": "p2", + "assignee": "unassigned", + "requires": [ + "ADR-0021", + "ADR-0023" + ], + "summary": "Lift the helpers that `docops upgrade` will need to share with `docops init` out of `internal/initter/` and into a new `internal/scaffold/` package, with **zero behavior change**. Sets up TP-018 to la…", + "word_count": 370, + "last_touched": "2026-04-22T19:48:15Z", + "age_days": 0, + "referenced_by": [ + { + "id": "TP-018", + "edge": "depends_on" + } + ], + "blocks": [ + "TP-018" + ], + "stale": false + }, + { + "id": "TP-021", + "kind": "TP", + "folder": "docs/tasks", + "path": "docs/tasks/TP-021-implement-update-check-cached-gstack-style-docops.md", + "title": "Implement update-check (cached, gstack-style) + docops update-check subcommand", + "task_status": "backlog", + "priority": "p2", + "assignee": "unassigned", + "requires": [ + "ADR-0023" + ], + "summary": "Ship the update-check mechanism specified by ADR-0023: a small internal package, a standalone `docops update-check` subcommand, and integration into `docops upgrade` so users learn when their binary i…", + "word_count": 647, + "last_touched": "2026-04-22T19:48:15Z", + "age_days": 0, + "referenced_by": [ + { + "id": "TP-018", + "edge": "depends_on" + } + ], + "blocks": [ + "TP-018" + ], + "stale": false + }, + { + "id": "TP-022", + "kind": "TP", + "folder": "docs/tasks", + "path": "docs/tasks/TP-022-write-claude-md-alongside-agents-md-in-init-and-up.md", + "title": "Write CLAUDE.md alongside AGENTS.md in init and upgrade", + "task_status": "backlog", + "priority": "p2", + "assignee": "unassigned", + "requires": [ + "ADR-0024" + ], + "summary": "Implement ADR-0024: docops writes both `CLAUDE.md` and `AGENTS.md` during `init` and refreshes both during `upgrade`. Both files share the same `\u003c!-- docops:start --\u003e … \u003c!-- docops:end --\u003e` block.", + "word_count": 395, + "last_touched": "2026-04-23T03:32:20Z", "age_days": 0, "stale": false } diff --git a/docs/STATE.md b/docs/STATE.md index 4606361..23ffd5e 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -1,12 +1,12 @@ -# Project State — 2026-04-22 +# Project State — 2026-04-23 ## Counts - Context: 4 active · 0 superseded -- ADRs: 22 accepted · 0 draft · 0 superseded (21 `coverage: required`, 1 `coverage: not-needed`) -- Tasks: 4 backlog · 0 active · 0 blocked · 15 done +- ADRs: 22 accepted · 2 draft · 0 superseded (23 `coverage: required`, 1 `coverage: not-needed`) +- Tasks: 5 backlog · 0 active · 0 blocked · 17 done ## Needs attention @@ -18,6 +18,10 @@ ## Recent activity +- 2026-04-23 7656d08 planning: ADR-0023 + TP-020/TP-021 — split TP-018 into refactor + upgrader + update-check +- 2026-04-23 6f41758 TP-012: docops get/list/graph/next — focused read commands +- 2026-04-23 32cc60b TP-011: docops search — substring/regex content search +- 2026-04-23 1aa3e32 remove internal TP-xxx IDs from help output; fix AGENTS.md to not document unbuilt commands - 2026-04-22 f928497 TP-007: docops init — scaffold a DocOps-enabled repo - 2026-04-22 f5ab1e6 TP-004: docops index — enriched graph written to .index.json - 2026-04-22 f01bed5 TP-002: frontmatter schemas, validators, and JSON Schema emission @@ -34,8 +38,4 @@ - 2026-04-22 5255483 planning: search + CLI-as-query-layer ADRs plus two tasks - 2026-04-22 50eeb7a initial docs & idea - 2026-04-22 4631e43 TP-017: docops new --body + validator enum hints -- 2026-04-22 2b1a7ee planning: ADR-0021 + TP-018 — docops upgrade for in-place bumps -- 2026-04-22 2a99403 TP-009: docops schema — JSON Schema for editor tooling -- 2026-04-22 1a3de5c dog-food the DocOps convention (CTX, ADR, TP, meta/product split) -- 2026-04-22 13514b1 removed reference implementation diff --git a/docs/decisions/ADR-0023-update-check-cached-lazy-opt-out-version-probe.md b/docs/decisions/ADR-0023-update-check-cached-lazy-opt-out-version-probe.md new file mode 100644 index 0000000..6b6dd1f --- /dev/null +++ b/docs/decisions/ADR-0023-update-check-cached-lazy-opt-out-version-probe.md @@ -0,0 +1,156 @@ +--- +title: Update-check — cached, lazy, opt-out version probe +status: draft +coverage: required +date: "2026-04-23" +supersedes: [] +related: [ADR-0021, ADR-0019] +tags: [] +--- + +## Context + +DocOps ships through Homebrew, Scoop, and direct binary downloads. Once +a user runs `docops init`, their project files (skills, schemas, +AGENTS.md block) drift from upstream every time we cut a release that +touches templates. ADR-0021 / TP-018 introduce `docops upgrade` to sync +those files in place — but `docops upgrade` only knows about the binary +the user already has installed. If their binary is itself stale (e.g. +the user is on `docops v0.1.1` but `v0.1.2` shipped two weeks ago with +new skill files), `docops upgrade` will quietly install the v0.1.1 +templates and the user will think they are current. + +The user has no in-band way to learn that their binary is behind. The +fix is to add a periodic update check, the same way `gstack` does: +cache the result in `~/.docops/`, only hit the network when the cache +is stale, fail quiet, and surface a one-line reminder in commands that +naturally care (`upgrade` first, others later). + +## Decision + +Ship a small `internal/updatecheck/` package and one new subcommand +`docops update-check`. Wire the package into `docops upgrade` so a +stale binary surfaces before any in-place template sync runs. + +### Contract + +`docops update-check` prints exactly one of: + +- `UP_TO_DATE ` — local matches remote. +- `UPGRADE_AVAILABLE ` — remote ahead. +- (nothing) — check skipped (snoozed, disabled, offline, dev build). + +Exit code is always `0`. The output line is intentionally +shell-parseable; this matches gstack and lets users wire it into shell +prompts, MOTDs, or CI guardrails. + +### Caching + +Cache file: `~/.docops/last-update-check`. Format: the same one-line +shape as the `update-check` output above (`UP_TO_DATE ` or +`UPGRADE_AVAILABLE `). Two TTLs: + +- `UP_TO_DATE`: **6 hours**. We re-check periodically so newly + shipped releases surface within a working day. +- `UPGRADE_AVAILABLE`: **24 hours**. The user already knows; we + don't need to re-fetch as eagerly, but we do want to keep nagging + on the next day's first invocation. + +Cache is per-user, not per-project. Multiple projects share one cache +file because the binary is global. + +### Remote source + +Primary: `https://raw.githubusercontent.com/logicwind/docops/main/VERSION`. +A `VERSION` file is added to the repo root and bumped during release +(or derived from the last git tag — implementer's call, see TP-021). +Validation: response must match `^[0-9]+\.[0-9]+\.[0-9]+$`. Anything +else is treated as a network error and the cache is updated with +`UP_TO_DATE` so we don't loop on transient failures. + +Timeout: 5 seconds. On any network error or invalid response, fail +silently and write `UP_TO_DATE ` to the cache so the user is +never blocked or spammed by an unreachable upstream. + +### Snooze + +A user can snooze a specific available upgrade by writing +`~/.docops/update-snoozed` with ` ` +(matches gstack's format). Levels: 1=24h, 2=48h, 3+=7d. A new remote +version invalidates any active snooze. The snooze file is written by +`docops update-check --snooze` (level auto-increments each invocation). + +### Opt-out + +Two ways to disable: + +- Per-user: `~/.docops/update-snoozed` containing `disabled` (or any + invalid version string with `level=999`). Treated as permanent + snooze. +- Per-invocation env: `DOCOPS_UPDATE_CHECK=off` skips the check. + Honored by both the standalone subcommand and the internal + piggyback in `docops upgrade`. + +Dev builds (version starts with `dev` or contains `+dirty`) skip the +check unconditionally — there is no meaningful "remote" for them. + +### Integration with `docops upgrade` + +Before printing the upgrade plan, `docops upgrade` calls +`updatecheck.Run()`. If it returns `UPGRADE_AVAILABLE`, we print a +warning block: + +``` +Warning: docops v0.1.1 is installed; v0.1.2 is available. + Run `brew upgrade docops` (or your package manager's + equivalent) before `docops upgrade`, or you'll sync the + older templates. + +Continue with v0.1.1 templates anyway? [y/N] +``` + +`--yes` skips the warning prompt the same way it skips the main +confirm. `UP_TO_DATE` and silent results print nothing. + +No other subcommand fires the check automatically. Scripts that pipe +`docops list` / `docops get` / `docops search` should not pay a +network or stat-syscall cost they didn't ask for. Users who want +proactive nagging can run `docops update-check` from their shell +profile. + +## Rationale + +- A standalone subcommand keeps the behavior auditable and scriptable + — power users can opt in to extra nagging without us baking it into + every code path. +- Caching with multi-hour TTLs means `docops upgrade` stays + near-instant on warm cache and at most one 5s network hit on cold + cache. The two-tier TTL (shorter for up-to-date, longer for + upgrade-available) mirrors gstack's empirically-chosen pattern. +- Failing quiet on network errors is non-negotiable: docops runs in + pre-commit hooks and CI environments where flaky DNS would + otherwise break commits. +- Hooking only into `docops upgrade` and not every command keeps the + blast radius small. We can add more integration points later if + users ask for them. +- `~/.docops/` as a state dir mirrors `~/.gstack/`, `~/.aws/`, + `~/.config/gh/`. No XDG variant for v1; if users object, we can + honor `$XDG_STATE_HOME` in a follow-up. + +## Consequences + +- A new state directory `~/.docops/` is created lazily the first time + `update-check` runs. It must not be required for any other docops + command to function — packaging and CI environments without a + writable home directory must still work. +- The repository now ships a `VERSION` file at the root that must be + kept in sync with release tags. Goreleaser config gets a step (or + the maintainer remembers — TP-021 picks the cheaper option). +- `docops upgrade` gains an interactive prompt path that fires when + the binary is stale. CI must always pass `--yes` to avoid hanging. +- A future ADR may extend update-check to fire from `docops init` + (so first-time users learn they're already a release behind), but + that is out of scope here. +- Telemetry: we deliberately do **not** ping any upstream beyond the + one raw-content fetch. No counts, no UA, no install ID. If we want + install metrics later, that needs its own ADR. diff --git a/docs/decisions/ADR-0024-ship-claude-md-alongside-agents-md-both-share-the.md b/docs/decisions/ADR-0024-ship-claude-md-alongside-agents-md-both-share-the.md new file mode 100644 index 0000000..19819d4 --- /dev/null +++ b/docs/decisions/ADR-0024-ship-claude-md-alongside-agents-md-both-share-the.md @@ -0,0 +1,85 @@ +--- +title: Ship CLAUDE.md alongside AGENTS.md — both share the docops block +status: draft +coverage: required +date: "2026-04-23" +supersedes: [] +related: [ADR-0021, ADR-0023] +tags: [] +--- + +## Context + +DocOps writes its invariants (citation rules, no-edit-STATE.md, schema +locations, etc.) into a delimited block in `AGENTS.md` so any coding +agent — Claude Code, Cursor, Codex, Aider, Copilot, Windsurf, Zed — +finds them on first contact. AGENTS.md was chosen as the multi-tool +standard surface. + +The gap: **Claude Code reads `CLAUDE.md` by default, not `AGENTS.md`.** +A user who runs `docops init` and then opens the project in Claude +Code never sees the docops invariants until they manually point Claude +at AGENTS.md. The invariants are then routinely violated (tasks +without citations, edits to STATE.md, etc.) and the user blames docops. + +`gstack` solves this by treating `CLAUDE.md` as the canonical +single-source and rendering host-specific files (AGENTS.md for +Hermes, etc.) via a path/tool rewrite map. That works because gstack +ships per-host packaging anyway. DocOps does not have host packaging +and does not want to grow one for this single use case. + +## Decision + +DocOps writes **both** `CLAUDE.md` and `AGENTS.md` at the project +root. Both contain the same `` +block with the same docops invariants. The non-block preamble may +differ (CLAUDE.md gets a one-line note that AGENTS.md is the +multi-tool sibling; otherwise content is identical). + +The block-merge logic that already powers `init` and `upgrade` (see +ADR-0021, `internal/scaffold.MergeAgentsBlock`) is reused verbatim +for CLAUDE.md. The same three cases apply: + +- File absent → write the full template. +- File present without block → append the block, preserve user content. +- File present with block → refresh just the block, preserve everything outside the markers. + +## Rationale + +- **Smallest delta over current behavior.** No template engine, no + host registry, no per-host rendering — just a second template and + a second call to the existing planner. +- **Robust against Claude Code defaults.** The user no longer has + to know to redirect Claude at AGENTS.md. +- **Multi-tool ecosystem unaffected.** AGENTS.md keeps shipping; tools + that read it (Cursor, Aider, Codex) see no change. +- **Symlink rejected.** A CLAUDE.md → AGENTS.md symlink would skirt + the duplication problem but breaks on Windows without dev mode and + has poor git portability across shell environments. +- **Flipping canonical (gstack-style) rejected.** Making CLAUDE.md + primary and AGENTS.md derived would force a host registry and a + template-rewrite engine into docops. Out of scope for the value. +- **Block duplication is bounded.** The docops block is ~80 lines. + Both files refresh in one upgrade pass; nothing drifts independently + because the source of truth is the embedded template, not either + file. + +## Consequences + +- `docops init` now creates two files where it used to create one. + Existing v0.1.x projects that already have AGENTS.md but not + CLAUDE.md get CLAUDE.md added on next `docops upgrade` (the planner + treats "absent" as "create"; no flag required). +- Users who hand-curate CLAUDE.md keep their content — only the + delimited block is owned by docops. +- The `templates/AGENTS.md.tmpl` and `templates/CLAUDE.md.tmpl` files + must stay in sync for the docops block. A small templates-package + test asserts that `ExtractBlock` returns identical content from + both, so future drift is caught at build time. +- The "Notes for humans" footer on each file may eventually diverge + per audience. ADR remains valid; only the block contract is + load-bearing. +- A future ADR may extend the same pattern to `.cursorrules`, + `.aider.conf.yml`, or other tool-specific surfaces. Each would be + evaluated on its own ROI; this ADR commits only to AGENTS.md + + CLAUDE.md. diff --git a/docs/tasks/TP-011-implement-search-command.md b/docs/tasks/TP-011-implement-search-command.md index c7cdadb..344b121 100644 --- a/docs/tasks/TP-011-implement-search-command.md +++ b/docs/tasks/TP-011-implement-search-command.md @@ -1,8 +1,8 @@ --- title: Implement `docops search` — content + structured filter query -status: backlog +status: done priority: p1 -assignee: unassigned +assignee: nachiket requires: [ADR-0017, ADR-0002, ADR-0006] depends_on: [TP-004] --- diff --git a/docs/tasks/TP-012-implement-read-commands.md b/docs/tasks/TP-012-implement-read-commands.md index 900cf5e..aefb6f1 100644 --- a/docs/tasks/TP-012-implement-read-commands.md +++ b/docs/tasks/TP-012-implement-read-commands.md @@ -1,8 +1,8 @@ --- title: Implement focused read commands — `get`, `list`, `graph`, `next` -status: backlog +status: done priority: p1 -assignee: unassigned +assignee: nachiket requires: [ADR-0018, ADR-0005, ADR-0006, ADR-0010] depends_on: [TP-004] --- diff --git a/docs/tasks/TP-018-implement-docops-upgrade-targeted-scaffold-sync.md b/docs/tasks/TP-018-implement-docops-upgrade-targeted-scaffold-sync.md index b8ec4f5..b97bc8b 100644 --- a/docs/tasks/TP-018-implement-docops-upgrade-targeted-scaffold-sync.md +++ b/docs/tasks/TP-018-implement-docops-upgrade-targeted-scaffold-sync.md @@ -3,8 +3,8 @@ title: Implement docops upgrade — targeted scaffold sync status: backlog priority: p2 assignee: unassigned -requires: [ADR-0021] -depends_on: [TP-007, TP-009, TP-013] +requires: [ADR-0021, ADR-0023] +depends_on: [TP-007, TP-009, TP-013, TP-020, TP-021] --- # Implement docops upgrade — targeted scaffold sync diff --git a/docs/tasks/TP-020-extract-internal-scaffold-from-internal-initter-re.md b/docs/tasks/TP-020-extract-internal-scaffold-from-internal-initter-re.md new file mode 100644 index 0000000..1b2675e --- /dev/null +++ b/docs/tasks/TP-020-extract-internal-scaffold-from-internal-initter-re.md @@ -0,0 +1,68 @@ +--- +title: Extract internal/scaffold/ from internal/initter/ (refactor) +status: backlog +priority: p2 +assignee: unassigned +requires: [ADR-0021, ADR-0023] +depends_on: [] +--- + +## Goal + +Lift the helpers that `docops upgrade` will need to share with +`docops init` out of `internal/initter/` and into a new +`internal/scaffold/` package, with **zero behavior change**. Sets up +TP-018 to land cleanly without copy-pasting code. + +## Acceptance + +- New package `internal/scaffold/` with these moves from + `internal/initter/initter.go`: + - `Action` struct → `scaffold.Action` (verbatim). + - `printPlan(w, actions, dry)` → `scaffold.PrintPlan` (exported). + - `mergeAgentsBlock(existing, tmpl)` → `scaffold.MergeAgentsBlock`. + - `extractBlock(tmpl)` → `scaffold.ExtractBlock`. + - The `` / `` marker + constants → exported constants in `scaffold/`. + - `dirAction(opts, rel)` → `scaffold.DirAction(rootAbs, rel)` — + flatten the `Options` dependency to just the root path so + upgrader can call it without an initter.Options. + - `fileAction(opts, rel, body, mode)` → `scaffold.FileAction(rootAbs, + rel, body, mode, force)` — same flattening; pass the force flag + explicitly instead of reading it from an Options struct. +- New helper `scaffold.LoadShippedSkills() (map[string][]byte, error)` + that wraps `templates.Skills()` and returns the same map. Both + initter and upgrader call this. +- `internal/initter/` re-imports from `internal/scaffold/` and is + reduced accordingly. The public surface of `initter` (the `Run` + function, `Options`, `Result`) is unchanged. +- `cmd/docops/cmd_init.go` is **not modified**. +- Existing tests pass unchanged: + - `go test ./internal/initter/...` + - `go test ./cmd/docops/...` (`cmd_init_test.go` in particular). + - `go test ./...` overall green. +- New file `internal/scaffold/scaffold_test.go` covers the moved + helpers directly: `MergeAgentsBlock` round-trip, `ExtractBlock` + edge cases (no markers, nested markers), `FileAction` skip vs + overwrite vs force. + +## Notes + +The intent is to make the diff for TP-018 *only* show the genuinely +new upgrader code, not a copy of helpers initter already had. After +this lands, `internal/initter/initter.go` should be ~150 lines smaller +and `internal/scaffold/scaffold.go` should hold the lifted helpers. + +Do not change `Options` field names or wire in any new behavior — +that is TP-018's job. If a helper resists clean extraction (say, +because it reads three fields off `initter.Options`), keep it in +initter for now and revisit during TP-018 implementation. Refactor +debt is fine; over-design is not. + +The `Action.Kind` string set stays as-is (`"mkdir"`, `"write-file"`, +`"merge-agents"`, `"skip"`). TP-018 may add `"remove"` and +`"refresh"` — that addition is in scope for TP-018, not here. + +The `templates/skills_lint_test.go` enumerates valid subcommands and +flags — it does not move; it stays in `templates/`. Only Go source +moves are in scope here. diff --git a/docs/tasks/TP-021-implement-update-check-cached-gstack-style-docops.md b/docs/tasks/TP-021-implement-update-check-cached-gstack-style-docops.md new file mode 100644 index 0000000..5602d90 --- /dev/null +++ b/docs/tasks/TP-021-implement-update-check-cached-gstack-style-docops.md @@ -0,0 +1,128 @@ +--- +title: Implement update-check (cached, gstack-style) + docops update-check subcommand +status: backlog +priority: p2 +assignee: unassigned +requires: [ADR-0023] +depends_on: [] +--- + +## Goal + +Ship the update-check mechanism specified by ADR-0023: a small +internal package, a standalone `docops update-check` subcommand, and +integration into `docops upgrade` so users learn when their binary is +behind upstream before they sync templates. + +## Acceptance + +### `internal/updatecheck/` package + +- `Run(opts) (Result, error)` where `Result` is one of: + - `{Status: StatusUpToDate, Local: "0.1.2"}` + - `{Status: StatusUpgradeAvailable, Local: "0.1.1", Remote: "0.1.2"}` + - `{Status: StatusSkipped, Reason: "snoozed" | "disabled" | "dev-build" | "offline"}` +- `Opts` fields: + - `Local string` — caller-provided local version (from + `internal/version`). + - `RemoteURL string` — defaults to + `https://raw.githubusercontent.com/logicwind/docops/main/VERSION`. + - `StateDir string` — defaults to `$HOME/.docops`. Tests inject a + tempdir. + - `Force bool` — bypass cache (used by `--force`). + - `Timeout time.Duration` — defaults to 5s. + - `Now func() time.Time` — defaults to `time.Now`. For tests. +- Cache file `/last-update-check` with a single line: + - `UP_TO_DATE ` (TTL 6h) + - `UPGRADE_AVAILABLE ` (TTL 24h) +- Snooze file `/update-snoozed` with ` + `. Levels: 1=24h, 2=48h, 3+=7d. + `UpdateSnooze(remote string)` helper bumps the level. +- Skip rules (return `StatusSkipped` without network I/O): + - Local version starts with `dev` or contains `+dirty`. + - Env `DOCOPS_UPDATE_CHECK=off`. + - Active snooze for the matching remote version. +- Fail-quiet: any network error, timeout, or invalid response → + write `UP_TO_DATE ` to cache and return `StatusUpToDate` + with no error (so callers never see a network failure). +- Validation: remote response must match `^[0-9]+\.[0-9]+\.[0-9]+$` + after trimming whitespace. + +### `cmd/docops/cmd_update_check.go` + +- Subcommand `docops update-check` with flags: + - `--force` — bypass cache. + - `--snooze` — record a snooze for the current available remote. + No-op if `UP_TO_DATE`. + - `--json` — emit `{"status": "...", "local": "...", "remote": + "..."}`. +- Default output: + - `UP_TO_DATE 0.1.2` to stdout, exit 0. + - `UPGRADE_AVAILABLE 0.1.1 0.1.2` to stdout, exit 0. + - Skipped → no output, exit 0. +- Wired into `cmd/docops/main.go` dispatch and into the top-level + help text. `templates/skills_lint_test.go` learns the new + subcommand and its flags. + +### Integration into `docops upgrade` + +- `cmd_upgrade.go` calls `updatecheck.Run` after the plan is + computed but before the `Proceed?` prompt. +- If `StatusUpgradeAvailable`, print: + ``` + Warning: docops 0.1.1 is installed; 0.1.2 is available. + Run `brew upgrade docops` (or your package manager + equivalent) before `docops upgrade`, or you'll sync the + older templates. + ``` + Then prompt `Continue with 0.1.1 templates anyway? [y/N]`. `--yes` + skips the prompt (proceeds). On non-TTY stdin, the warning prints + and we proceed (so CI is not blocked). +- `UP_TO_DATE` and `StatusSkipped` print nothing extra. + +### `VERSION` file + +- A new `VERSION` file at the repo root containing the current + release (initially `0.1.2`). Goreleaser is updated (or a Makefile + pre-release target is added) to keep this in sync with tags. If + goreleaser config edit is non-trivial, ship the file and document + the manual bump step in `RELEASE.md` or the README "Releasing" + section. + +### Tests + +- `internal/updatecheck/updatecheck_test.go`: + - Cache hit (UP_TO_DATE within 6h) skips network entirely (use a + test HTTP server that fails the test if hit). + - Cache hit (UPGRADE_AVAILABLE within 24h) returns the cached + upgrade. + - Stale cache triggers re-fetch. + - Network error → `StatusUpToDate`, cache written. + - Invalid response (HTML, empty, malformed version) → treated as + network error. + - Dev build → `StatusSkipped`. + - `DOCOPS_UPDATE_CHECK=off` → `StatusSkipped`. + - Snooze file present, version matches, within window → + `StatusSkipped`. + - Snooze for an older remote does not suppress a newer remote. +- `cmd/docops/cmd_update_check_test.go`: subcommand exit codes, + output shapes (text + JSON), `--force` bypasses cache. +- `cmd/docops/cmd_upgrade_test.go` (added in TP-018) gains a case + for the stale-binary warning path with a fake updatecheck stub. + +## Notes + +`internal/updatecheck/` is a leaf package — it depends only on the +standard library (`net/http`, `os`, `time`, `errors`) and on +`internal/version` for the local version string. Keep it that way; +do not import `internal/initter`, `internal/upgrader`, or anything +template-related. + +`~/.docops/` is created lazily on first write. If `os.UserHomeDir()` +fails (CI without HOME set), the package returns `StatusSkipped` +with reason `"no-home-dir"` — no error to the caller. + +A future ADR may broaden the integration to `docops init` (warn +first-time users they are already a release behind). Out of scope +here — TP-021 only wires `update-check` into `upgrade` and the +standalone subcommand. diff --git a/docs/tasks/TP-022-write-claude-md-alongside-agents-md-in-init-and-up.md b/docs/tasks/TP-022-write-claude-md-alongside-agents-md-in-init-and-up.md new file mode 100644 index 0000000..9fc0d6a --- /dev/null +++ b/docs/tasks/TP-022-write-claude-md-alongside-agents-md-in-init-and-up.md @@ -0,0 +1,84 @@ +--- +title: Write CLAUDE.md alongside AGENTS.md in init and upgrade +status: backlog +priority: p2 +assignee: unassigned +requires: [ADR-0024] +depends_on: [] +--- + +## Goal + +Implement ADR-0024: docops writes both `CLAUDE.md` and `AGENTS.md` +during `init` and refreshes both during `upgrade`. Both files share +the same `` block. + +## Acceptance + +### Templates + +- New `templates/CLAUDE.md.tmpl` — same docops block as + `AGENTS.md.tmpl`, with a one-line non-block preamble that mentions + `AGENTS.md` is the multi-tool sibling. +- New `templates.ClaudeBlock()` accessor in `templates/templates.go` + that returns the embedded `CLAUDE.md.tmpl` bytes (mirrors + `templates.AgentsBlock()`). +- New test `templates/agents_claude_block_sync_test.go`: asserts that + `scaffold.ExtractBlock(AgentsBlock())` equals + `scaffold.ExtractBlock(ClaudeBlock())` byte-for-byte. Catches drift + the moment a templates author edits one without the other. + +### initter + +- `internal/initter/initter.go` `plan()` calls a generalized + `planMarkdownBlock(opts, "CLAUDE.md", claudeTmpl)` and appends both + the AGENTS.md and CLAUDE.md actions. Existing `planAgents` becomes + the type-generic helper or is renamed; behavior for AGENTS.md is + unchanged. +- The init announcement block in `cmd/docops/cmd_init.go` mentions + CLAUDE.md alongside AGENTS.md. + +### upgrader + +- `internal/upgrader/upgrader.go` `plan()` emits a refresh action for + both `CLAUDE.md` and `AGENTS.md` when each file is present (or + creates either if absent — the planner already handles missing + files via the same merge logic). +- The upgrade announcement block in `cmd/docops/cmd_upgrade.go` + mentions both files. + +### Tests + +- `internal/initter/initter_test.go` adds a CLAUDE.md case mirroring + the existing AGENTS.md merge test. +- `internal/upgrader/upgrader_test.go` adds: + - A case where both files exist with stale blocks → both refresh. + - A case where AGENTS.md exists with the block but CLAUDE.md is + absent → CLAUDE.md is created with the full template. + - A case where the user has hand-written content in CLAUDE.md + outside the block → preserved across upgrade. + +### Docs and fallout + +- `templates/AGENTS.md.tmpl` "Notes for humans" footer references + CLAUDE.md so users learn both files are managed. +- `templates/skills/docops/init.md` and + `templates/skills/docops/upgrade.md` mention CLAUDE.md. +- README "Quickstart" briefly notes that init scaffolds both files. +- AGENTS.md.tmpl mention of `docops upgrade` already covers the + upgrade path — no new line needed. + +## Notes + +The block-merge logic in `internal/scaffold.MergeAgentsBlock` is +already file-agnostic — it operates on `existing []byte` and returns +merged bytes. No changes needed there. The "AGENTS.md" name is +plumbed only at the planner-call layer, so generalization is small. + +For the docops repo itself: after this ships, run `docops upgrade +--yes` once to scaffold the missing CLAUDE.md (the repo currently +has only AGENTS.md). That can be a separate dogfood commit. + +Out of scope: `.cursorrules`, `.aider.conf.yml`, host packaging, +template rewriting. ADR-0024 explicitly limits scope to the two +markdown files. A future ADR can expand if needed. diff --git a/internal/initter/initter.go b/internal/initter/initter.go index 23af34c..90083d2 100644 --- a/internal/initter/initter.go +++ b/internal/initter/initter.go @@ -12,9 +12,9 @@ import ( "os" "path/filepath" "sort" - "strings" "github.com/logicwind/docops/internal/config" + "github.com/logicwind/docops/internal/scaffold" "github.com/logicwind/docops/internal/schema" "github.com/logicwind/docops/templates" ) @@ -25,33 +25,14 @@ type Options struct { Root string DryRun bool Force bool - NoSkills bool // skip scaffolding .claude/skills/docops/ and .cursor/commands/docops/ + NoSkills bool // skip scaffolding .claude/skills/docops/ and .cursor/commands/docops/ Out io.Writer // human-readable progress; defaults to os.Stdout Verbose bool } -// Action is a single filesystem change proposed by the planner. Init -// plans the full change set first, then executes it in one pass so -// --dry-run and the real write share the same code path. -type Action struct { - // Path is the absolute destination. - Path string - - // Rel is Path relative to Options.Root, used in human output. - Rel string - - // Kind is one of "mkdir", "write-file", "merge-agents", "skip". - Kind string - - // Reason explains why the action is a skip, write, or overwrite. - Reason string - - // Body is the bytes that would be written. Empty for mkdir / skip. - Body []byte - - // Mode is the permission for written files (0o755 for the hook, 0o644 otherwise). - Mode os.FileMode -} +// Action aliases scaffold.Action so existing initter consumers keep +// their imports unchanged after the scaffold extraction (TP-020). +type Action = scaffold.Action // Result is what Run returns after a plan execution. type Result struct { @@ -85,7 +66,7 @@ func Run(opts Options) (*Result, error) { } for i := range actions { - if err := execute(&actions[i]); err != nil { + if err := scaffold.Execute(&actions[i]); err != nil { return nil, fmt.Errorf("apply %s: %w", actions[i].Rel, err) } } @@ -97,8 +78,6 @@ func Run(opts Options) (*Result, error) { // to disk in here — it only reads existing files to decide whether each // proposed target would be a create, a merge, or a skip. func plan(opts Options) ([]Action, error) { - // If a docops.yaml already exists at the root, use it so that - // project-specific context_types propagate into the emitted schema. cfg := config.Default() if loaded, err := config.Load(filepath.Join(opts.Root, config.DefaultFilename)); err == nil { cfg = loaded @@ -113,7 +92,7 @@ func plan(opts Options) ([]Action, error) { cfg.Paths.Tasks, cfg.Paths.Schema, } { - actions = append(actions, dirAction(opts, rel)) + actions = append(actions, scaffold.DirAction(opts.Root, rel)) } // 2. docops.yaml at repo root. @@ -121,7 +100,7 @@ func plan(opts Options) ([]Action, error) { if err != nil { return nil, fmt.Errorf("read docops.yaml template: %w", err) } - actions = append(actions, fileAction(opts, "docops.yaml", yamlBody, 0o644)) + actions = append(actions, scaffold.FileAction(opts.Root, "docops.yaml", yamlBody, 0o644, opts.Force)) // 3. JSON Schema files. schemas, err := schema.JSONSchemas(schema.Config{ContextTypes: cfg.ContextTypes}) @@ -135,20 +114,32 @@ func plan(opts Options) ([]Action, error) { sort.Strings(schemaNames) for _, name := range schemaNames { rel := filepath.Join(cfg.Paths.Schema, name) - actions = append(actions, fileAction(opts, rel, schemas[name], 0o644)) + actions = append(actions, scaffold.FileAction(opts.Root, rel, schemas[name], 0o644, opts.Force)) } - // 4. AGENTS.md — delimited-block merge if a user file exists. + // 4. AGENTS.md and CLAUDE.md — delimited-block merge if a user + // file exists, otherwise write the template verbatim. Both files + // are docops-managed and share the same docops block (ADR-0024). agentsTmpl, err := templates.AgentsBlock() if err != nil { return nil, fmt.Errorf("read agents template: %w", err) } - agentsAction, err := planAgents(opts, agentsTmpl) + agentsAction, err := planMarkdownBlock(opts, "AGENTS.md", agentsTmpl) if err != nil { return nil, err } actions = append(actions, agentsAction) + claudeTmpl, err := templates.ClaudeBlock() + if err != nil { + return nil, fmt.Errorf("read claude template: %w", err) + } + claudeAction, err := planMarkdownBlock(opts, "CLAUDE.md", claudeTmpl) + if err != nil { + return nil, err + } + actions = append(actions, claudeAction) + // 5. Pre-commit hook. hook, err := templates.PreCommitHook() if err != nil { @@ -163,7 +154,7 @@ func plan(opts Options) ([]Action, error) { // 6. Agent skills — .claude/skills/docops/ and .cursor/commands/docops/. // Skipped entirely when --no-skills is set; existing files are not touched. if !opts.NoSkills { - skills, err := templates.Skills() + skills, err := scaffold.LoadShippedSkills() if err != nil { return nil, fmt.Errorf("read skills: %w", err) } @@ -173,10 +164,10 @@ func plan(opts Options) ([]Action, error) { } sort.Strings(skillNames) for _, dir := range []string{".claude/skills/docops", ".cursor/commands/docops"} { - actions = append(actions, dirAction(opts, dir)) + actions = append(actions, scaffold.DirAction(opts.Root, dir)) for _, name := range skillNames { rel := filepath.Join(dir, name) - actions = append(actions, fileAction(opts, rel, skills[name], 0o644)) + actions = append(actions, scaffold.FileAction(opts.Root, rel, skills[name], 0o644, opts.Force)) } } } @@ -184,62 +175,12 @@ func plan(opts Options) ([]Action, error) { return actions, nil } -// dirAction builds a mkdir action that is a skip when the directory -// already exists. Keeps idempotent re-runs quiet. -func dirAction(opts Options, rel string) Action { - abs := filepath.Join(opts.Root, rel) - if info, err := os.Stat(abs); err == nil && info.IsDir() { - return Action{ - Path: abs, - Rel: rel, - Kind: "skip", - Reason: "directory exists", - Mode: 0o755, - } - } - return Action{ - Path: abs, - Rel: rel, - Kind: "mkdir", - Reason: "create directory", - Mode: 0o755, - } -} - -// fileAction builds a write-file action, deciding whether the target -// should be created, overwritten (--force on drift), or skipped. -func fileAction(opts Options, rel string, body []byte, mode os.FileMode) Action { - abs := filepath.Join(opts.Root, rel) - a := Action{Path: abs, Rel: rel, Kind: "write-file", Body: body, Mode: mode} - existing, err := os.ReadFile(abs) - if err != nil { - if os.IsNotExist(err) { - a.Reason = "create" - return a - } - // Permission/other error — propagate via execute(). - a.Reason = "create (read failed: " + err.Error() + ")" - return a - } - if bytes.Equal(existing, body) { - a.Kind = "skip" - a.Reason = "already up to date" - return a - } - if opts.Force { - a.Reason = "overwrite drifted content (--force)" - return a - } - a.Kind = "skip" - a.Reason = "exists and differs — rerun with --force to overwrite" - return a -} - -// planAgents decides how to render AGENTS.md. If the file is absent we -// write the template verbatim. If it exists with a block, we replace +// planMarkdownBlock decides how to render a docops-managed markdown +// file (AGENTS.md, CLAUDE.md). If the file is absent we write the +// template verbatim. If it exists with a docops block, we refresh // just the block. If it exists without a block, we append the block. -func planAgents(opts Options, tmpl []byte) (Action, error) { - rel := "AGENTS.md" +// Used by both init and (indirectly) upgrade via the same logic. +func planMarkdownBlock(opts Options, rel string, tmpl []byte) (Action, error) { abs := filepath.Join(opts.Root, rel) existing, err := os.ReadFile(abs) @@ -250,26 +191,26 @@ func planAgents(opts Options, tmpl []byte) (Action, error) { return Action{ Path: abs, Rel: rel, - Kind: "write-file", + Kind: scaffold.KindWriteFile, Body: tmpl, Mode: 0o644, Reason: "create", }, nil } - merged, changed, reason := mergeAgentsBlock(existing, tmpl) + merged, changed, reason := scaffold.MergeAgentsBlock(existing, tmpl) if !changed { return Action{ Path: abs, Rel: rel, - Kind: "skip", + Kind: scaffold.KindSkip, Reason: reason, }, nil } return Action{ Path: abs, Rel: rel, - Kind: "merge-agents", + Kind: scaffold.KindMergeAgents, Body: merged, Mode: 0o644, Reason: reason, @@ -285,13 +226,13 @@ func planHook(opts Options, body []byte) (Action, error) { return Action{ Path: filepath.Join(gitDir, "hooks", "pre-commit"), Rel: ".git/hooks/pre-commit", - Kind: "skip", + Kind: scaffold.KindSkip, Reason: "no .git directory — run `git init` first or install the hook manually from templates/hooks/pre-commit", }, nil } rel := ".git/hooks/pre-commit" abs := filepath.Join(opts.Root, rel) - a := Action{Path: abs, Rel: rel, Kind: "write-file", Body: body, Mode: 0o755} + a := Action{Path: abs, Rel: rel, Kind: scaffold.KindWriteFile, Body: body, Mode: 0o755} existing, err := os.ReadFile(abs) if err != nil { if os.IsNotExist(err) { @@ -301,7 +242,7 @@ func planHook(opts Options, body []byte) (Action, error) { return Action{}, err } if bytes.Equal(existing, body) { - a.Kind = "skip" + a.Kind = scaffold.KindSkip a.Reason = "already up to date" return a, nil } @@ -309,121 +250,30 @@ func planHook(opts Options, body []byte) (Action, error) { a.Reason = "overwrite drifted hook (--force)" return a, nil } - a.Kind = "skip" + a.Kind = scaffold.KindSkip a.Reason = "existing pre-commit hook differs — rerun with --force to overwrite" return a, nil } -// blockStart and blockEnd match the delimiters defined in docops.yaml -// agents_md block_start / block_end. Keeping them as constants here -// mirrors the template default; a user that changes delimiters in -// docops.yaml will still get a correct write from init (no block exists -// yet) and can update via a future `docops refresh-agents-md` command. -const ( - blockStart = "" - blockEnd = "" -) - -// mergeAgentsBlock replaces the docops:start/end block inside existing -// content with the block extracted from the template. If no block is -// present in existing, the template body is appended. Returns the merged -// bytes, a changed flag (false = identical to existing), and a reason -// string for human output. -func mergeAgentsBlock(existing, tmpl []byte) ([]byte, bool, string) { - tmplBlock := extractBlock(tmpl) - if tmplBlock == "" { - // Template is malformed — return existing unchanged rather than - // scribbling on the user's file. - return existing, false, "template missing docops block; skipping merge" - } - - ex := string(existing) - startIdx := strings.Index(ex, blockStart) - endIdx := strings.Index(ex, blockEnd) - if startIdx >= 0 && endIdx > startIdx { - endIdx += len(blockEnd) - replacement := blockStart + tmplBlock + blockEnd - merged := ex[:startIdx] + replacement + ex[endIdx:] - if merged == ex { - return existing, false, "docops block already up to date" - } - return []byte(merged), true, "refresh docops block" - } - - // No block yet — append the template block (with delimiters) to the end. - appended := ex - if !strings.HasSuffix(appended, "\n") { - appended += "\n" - } - appended += "\n" + blockStart + tmplBlock + blockEnd + "\n" - return []byte(appended), true, "append docops block to existing AGENTS.md" -} - -// extractBlock returns the content between blockStart and blockEnd in -// tmpl. Returns "" if either marker is missing. Leading/trailing -// newlines are preserved so the rendered block is visually identical -// to the template. -func extractBlock(tmpl []byte) string { - s := string(tmpl) - start := strings.Index(s, blockStart) - if start < 0 { - return "" - } - start += len(blockStart) - end := strings.Index(s, blockEnd) - if end < 0 || end < start { - return "" - } - return s[start:end] -} - -// execute applies one planned action to the filesystem. -func execute(a *Action) error { - switch a.Kind { - case "skip": - return nil - case "mkdir": - return os.MkdirAll(a.Path, 0o755) - case "write-file", "merge-agents": - if err := os.MkdirAll(filepath.Dir(a.Path), 0o755); err != nil { - return err - } - if err := os.WriteFile(a.Path, a.Body, a.Mode); err != nil { - return err - } - return nil - } - return fmt.Errorf("unknown action kind %q", a.Kind) -} - -// printPlan writes a human-readable summary of the action set. For -// dry-run we use the "would" phrasing; for real runs we use past tense. +// printPlan adds the init-specific "next steps" footer to scaffold's +// generic plan summary. func printPlan(w io.Writer, actions []Action, dry bool) { - var changed, skipped int - for _, a := range actions { - if a.Kind == "skip" { - skipped++ - } else { - changed++ - } - } - verb := "applied" + scaffold.PrintPlan(w, actions, dry, "docops init") if dry { - verb = "would apply" + return } - fmt.Fprintf(w, "docops init: %s %d change(s), skipped %d\n", verb, changed, skipped) + var changed int for _, a := range actions { - tag := a.Kind - if dry && a.Kind != "skip" { - tag = "+ " + a.Kind + if a.Kind != scaffold.KindSkip { + changed++ } - fmt.Fprintf(w, " %-13s %-34s %s\n", tag, a.Rel, a.Reason) } - if !dry && changed > 0 { - fmt.Fprintln(w, "") - fmt.Fprintln(w, "next steps:") - fmt.Fprintln(w, " docops validate # confirm the scaffolded docs parse") - fmt.Fprintln(w, " docops new ctx \"…\" --type brief") - fmt.Fprintln(w, " docops new adr \"…\"") + if changed == 0 { + return } + fmt.Fprintln(w, "") + fmt.Fprintln(w, "next steps:") + fmt.Fprintln(w, " docops validate # confirm the scaffolded docs parse") + fmt.Fprintln(w, " docops new ctx \"…\" --type brief") + fmt.Fprintln(w, " docops new adr \"…\"") } diff --git a/internal/initter/initter_test.go b/internal/initter/initter_test.go index 1d340f6..44385d3 100644 --- a/internal/initter/initter_test.go +++ b/internal/initter/initter_test.go @@ -8,6 +8,8 @@ import ( "runtime" "strings" "testing" + + "github.com/logicwind/docops/internal/scaffold" ) // withGit creates a fake .git directory so planHook does not short-circuit. @@ -173,7 +175,7 @@ func TestRun_AgentsMerge_PreservesUserContent(t *testing.T) { if !strings.Contains(s, "Own content that DocOps must not touch.") { t.Errorf("user content missing after merge: %s", s) } - if !strings.Contains(s, blockStart) || !strings.Contains(s, blockEnd) { + if !strings.Contains(s, scaffold.BlockStart) || !strings.Contains(s, scaffold.BlockEnd) { t.Errorf("docops block delimiters missing after merge: %s", s) } } @@ -182,7 +184,7 @@ func TestRun_AgentsBlockRefresh_ReplacesBlockOnly(t *testing.T) { root := t.TempDir() withGit(t, root) - existing := "# Header\n\n" + blockStart + "\nstale block content\n" + blockEnd + "\n\n## Keep me\n\nuser footer\n" + existing := "# Header\n\n" + scaffold.BlockStart + "\nstale block content\n" + scaffold.BlockEnd + "\n\n## Keep me\n\nuser footer\n" if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte(existing), 0o644); err != nil { t.Fatalf("write existing: %v", err) } @@ -204,6 +206,52 @@ func TestRun_AgentsBlockRefresh_ReplacesBlockOnly(t *testing.T) { } } +func TestRun_CreatesClaudeMdAlongsideAgentsMd(t *testing.T) { + root := t.TempDir() + withGit(t, root) + + if _, err := Run(Options{Root: root, Out: io.Discard}); err != nil { + t.Fatalf("run: %v", err) + } + + for _, name := range []string{"AGENTS.md", "CLAUDE.md"} { + body, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatalf("%s missing after init: %v", name, err) + } + s := string(body) + if !strings.Contains(s, scaffold.BlockStart) || !strings.Contains(s, scaffold.BlockEnd) { + t.Errorf("%s missing docops block markers", name) + } + } +} + +func TestRun_ClaudeMdMerge_PreservesUserContent(t *testing.T) { + root := t.TempDir() + withGit(t, root) + + userBody := "# Custom CLAUDE.md\n\nThis project's specific Claude tweaks.\n" + if err := os.WriteFile(filepath.Join(root, "CLAUDE.md"), []byte(userBody), 0o644); err != nil { + t.Fatalf("write user CLAUDE.md: %v", err) + } + + if _, err := Run(Options{Root: root, Out: io.Discard}); err != nil { + t.Fatalf("run: %v", err) + } + + merged, err := os.ReadFile(filepath.Join(root, "CLAUDE.md")) + if err != nil { + t.Fatalf("read merged: %v", err) + } + s := string(merged) + if !strings.Contains(s, "This project's specific Claude tweaks.") { + t.Errorf("user content lost: %s", s) + } + if !strings.Contains(s, scaffold.BlockStart) || !strings.Contains(s, scaffold.BlockEnd) { + t.Errorf("docops block missing after merge: %s", s) + } +} + func TestRun_NoGitDir_SkipsHook(t *testing.T) { root := t.TempDir() // no withGit() diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go new file mode 100644 index 0000000..2acaec8 --- /dev/null +++ b/internal/scaffold/scaffold.go @@ -0,0 +1,208 @@ +// Package scaffold contains filesystem-action helpers shared by the +// scaffolding commands (`docops init`, `docops upgrade`). It is a pure +// utility layer: planning a write, deciding whether a file is up to +// date, merging the AGENTS.md docops block, and applying actions to +// disk. Higher-level packages (initter, upgrader) compose these +// primitives into command-specific behavior. +package scaffold + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/logicwind/docops/templates" +) + +// Action kinds. Kept as string constants (not a typed enum) so JSON +// output and human-readable diffs share one vocabulary. +const ( + KindMkdir = "mkdir" + KindWriteFile = "write-file" + KindMergeAgents = "merge-agents" + KindRemove = "remove" + KindSkip = "skip" +) + +// AGENTS.md docops block delimiters. Mirrors the defaults in the +// shipped docops.yaml template so init and upgrade agree on what the +// "managed region" looks like. +const ( + BlockStart = "" + BlockEnd = "" +) + +// Action is a single filesystem change proposed by a planner. Planners +// build the full action set first, then execute it in one pass so +// dry-run and the real write share the same code path. +type Action struct { + Path string // absolute destination + Rel string // Path relative to the project root, used in human output + Kind string // one of the Kind* constants + Reason string // why the action is a skip, write, or overwrite + Body []byte // bytes to write (empty for mkdir / skip) + Mode os.FileMode // 0o755 for the pre-commit hook, 0o644 otherwise +} + +// DirAction builds a mkdir action that becomes a skip when the +// directory already exists. Keeps idempotent re-runs quiet. +func DirAction(rootAbs, rel string) Action { + abs := filepath.Join(rootAbs, rel) + if info, err := os.Stat(abs); err == nil && info.IsDir() { + return Action{ + Path: abs, + Rel: rel, + Kind: KindSkip, + Reason: "directory exists", + Mode: 0o755, + } + } + return Action{ + Path: abs, + Rel: rel, + Kind: KindMkdir, + Reason: "create directory", + Mode: 0o755, + } +} + +// FileAction builds a write-file action, deciding whether the target +// should be created, overwritten (force=true on drift), or skipped. +// A read error other than ENOENT is folded into the action's Reason +// and surfaces during Execute. +func FileAction(rootAbs, rel string, body []byte, mode os.FileMode, force bool) Action { + abs := filepath.Join(rootAbs, rel) + a := Action{Path: abs, Rel: rel, Kind: KindWriteFile, Body: body, Mode: mode} + existing, err := os.ReadFile(abs) + if err != nil { + if os.IsNotExist(err) { + a.Reason = "create" + return a + } + a.Reason = "create (read failed: " + err.Error() + ")" + return a + } + if bytes.Equal(existing, body) { + a.Kind = KindSkip + a.Reason = "already up to date" + return a + } + if force { + a.Reason = "overwrite drifted content (--force)" + return a + } + a.Kind = KindSkip + a.Reason = "exists and differs — rerun with --force to overwrite" + return a +} + +// MergeAgentsBlock replaces the docops:start/end block inside existing +// content with the block extracted from tmpl. If no block is present +// in existing, the template block is appended. Returns the merged +// bytes, a changed flag (false = identical to existing), and a reason +// string for human output. +func MergeAgentsBlock(existing, tmpl []byte) ([]byte, bool, string) { + tmplBlock := ExtractBlock(tmpl) + if tmplBlock == "" { + return existing, false, "template missing docops block; skipping merge" + } + + ex := string(existing) + startIdx := strings.Index(ex, BlockStart) + endIdx := strings.Index(ex, BlockEnd) + if startIdx >= 0 && endIdx > startIdx { + endIdx += len(BlockEnd) + replacement := BlockStart + tmplBlock + BlockEnd + merged := ex[:startIdx] + replacement + ex[endIdx:] + if merged == ex { + return existing, false, "docops block already up to date" + } + return []byte(merged), true, "refresh docops block" + } + + appended := ex + if !strings.HasSuffix(appended, "\n") { + appended += "\n" + } + appended += "\n" + BlockStart + tmplBlock + BlockEnd + "\n" + return []byte(appended), true, "append docops block to existing AGENTS.md" +} + +// ExtractBlock returns the content between BlockStart and BlockEnd in +// tmpl. Returns "" if either marker is missing. Leading/trailing +// newlines are preserved so the rendered block matches the template +// byte-for-byte. +func ExtractBlock(tmpl []byte) string { + s := string(tmpl) + start := strings.Index(s, BlockStart) + if start < 0 { + return "" + } + start += len(BlockStart) + end := strings.Index(s, BlockEnd) + if end < 0 || end < start { + return "" + } + return s[start:end] +} + +// Execute applies one planned action to the filesystem. Skips are +// no-ops; mkdir creates with 0o755; writes ensure parent dirs exist +// and use the action's Mode; remove deletes the file (a missing file +// is not an error). +func Execute(a *Action) error { + switch a.Kind { + case KindSkip: + return nil + case KindMkdir: + return os.MkdirAll(a.Path, 0o755) + case KindWriteFile, KindMergeAgents: + if err := os.MkdirAll(filepath.Dir(a.Path), 0o755); err != nil { + return err + } + return os.WriteFile(a.Path, a.Body, a.Mode) + case KindRemove: + if err := os.Remove(a.Path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + return fmt.Errorf("unknown action kind %q", a.Kind) +} + +// PrintPlan writes a human-readable summary of an action set. label +// prefixes the summary line ("docops init", "docops upgrade", …). +// dry switches verbs from past tense to "would apply". Callers may +// append command-specific footers (next-steps, warnings) afterwards. +func PrintPlan(w io.Writer, actions []Action, dry bool, label string) { + var changed, skipped int + for _, a := range actions { + if a.Kind == KindSkip { + skipped++ + } else { + changed++ + } + } + verb := "applied" + if dry { + verb = "would apply" + } + fmt.Fprintf(w, "%s: %s %d change(s), skipped %d\n", label, verb, changed, skipped) + for _, a := range actions { + tag := a.Kind + if dry && a.Kind != KindSkip { + tag = "+ " + a.Kind + } + fmt.Fprintf(w, " %-13s %-34s %s\n", tag, a.Rel, a.Reason) + } +} + +// LoadShippedSkills returns the embedded skill files keyed by +// basename (e.g. "init.md" → bytes). Thin wrapper around +// templates.Skills() so initter and upgrader share one entry point. +func LoadShippedSkills() (map[string][]byte, error) { + return templates.Skills() +} diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go new file mode 100644 index 0000000..3b47340 --- /dev/null +++ b/internal/scaffold/scaffold_test.go @@ -0,0 +1,243 @@ +package scaffold + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExtractBlock_HappyPath(t *testing.T) { + tmpl := []byte("preamble\n" + BlockStart + "\nbody\n" + BlockEnd + "\ntrailer\n") + got := ExtractBlock(tmpl) + want := "\nbody\n" + if got != want { + t.Errorf("ExtractBlock = %q; want %q", got, want) + } +} + +func TestExtractBlock_MissingMarkers(t *testing.T) { + cases := map[string][]byte{ + "no markers": []byte("just plain text\n"), + "only start": []byte("hello\n" + BlockStart + "\nrest\n"), + "only end": []byte("hello\n" + BlockEnd + "\nrest\n"), + "end before start": []byte(BlockEnd + "\n" + BlockStart + "\n"), + } + for name, tmpl := range cases { + t.Run(name, func(t *testing.T) { + if got := ExtractBlock(tmpl); got != "" { + t.Errorf("ExtractBlock(%q) = %q; want empty", name, got) + } + }) + } +} + +func TestMergeAgentsBlock_AppendsWhenAbsent(t *testing.T) { + existing := []byte("# Project\n\nHand-written notes.\n") + tmpl := []byte(BlockStart + "\nshipped block body\n" + BlockEnd + "\n") + + merged, changed, reason := MergeAgentsBlock(existing, tmpl) + if !changed { + t.Fatalf("expected changed=true; reason=%q", reason) + } + s := string(merged) + if !strings.Contains(s, "Hand-written notes.") { + t.Errorf("user content lost: %s", s) + } + if !strings.Contains(s, BlockStart) || !strings.Contains(s, BlockEnd) { + t.Errorf("block delimiters missing: %s", s) + } + if !strings.Contains(s, "shipped block body") { + t.Errorf("template body missing: %s", s) + } +} + +func TestMergeAgentsBlock_ReplacesInPlace(t *testing.T) { + existing := []byte("# Header\n\n" + BlockStart + "\nstale\n" + BlockEnd + "\n\nuser footer\n") + tmpl := []byte(BlockStart + "\nfresh\n" + BlockEnd + "\n") + + merged, changed, reason := MergeAgentsBlock(existing, tmpl) + if !changed { + t.Fatalf("expected changed=true; reason=%q", reason) + } + s := string(merged) + if strings.Contains(s, "stale") { + t.Errorf("stale block content survived: %s", s) + } + if !strings.Contains(s, "fresh") { + t.Errorf("fresh block content missing: %s", s) + } + if !strings.Contains(s, "user footer") { + t.Errorf("user content outside block was lost: %s", s) + } +} + +func TestMergeAgentsBlock_NoOpWhenIdentical(t *testing.T) { + body := BlockStart + "\nshared body\n" + BlockEnd + "\n" + existing := []byte("# Header\n\n" + body + "\nfooter\n") + tmpl := []byte(body) + + merged, changed, reason := MergeAgentsBlock(existing, tmpl) + if changed { + t.Fatalf("expected changed=false when block matches; reason=%q", reason) + } + if !bytes.Equal(merged, existing) { + t.Errorf("merged should equal existing on no-op") + } +} + +func TestMergeAgentsBlock_RefusesMalformedTemplate(t *testing.T) { + existing := []byte("# Header\n") + tmpl := []byte("template with no markers at all\n") + + _, changed, reason := MergeAgentsBlock(existing, tmpl) + if changed { + t.Errorf("expected changed=false on malformed template") + } + if !strings.Contains(reason, "template missing") { + t.Errorf("expected reason about missing template block, got %q", reason) + } +} + +func TestFileAction_CreateWhenAbsent(t *testing.T) { + root := t.TempDir() + a := FileAction(root, "new.txt", []byte("hello"), 0o644, false) + if a.Kind != KindWriteFile { + t.Errorf("Kind = %q; want %q", a.Kind, KindWriteFile) + } + if a.Reason != "create" { + t.Errorf("Reason = %q; want %q", a.Reason, "create") + } +} + +func TestFileAction_SkipWhenIdentical(t *testing.T) { + root := t.TempDir() + body := []byte("same body\n") + if err := os.WriteFile(filepath.Join(root, "same.txt"), body, 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + a := FileAction(root, "same.txt", body, 0o644, false) + if a.Kind != KindSkip { + t.Errorf("Kind = %q; want %q", a.Kind, KindSkip) + } + if !strings.Contains(a.Reason, "up to date") { + t.Errorf("Reason = %q; want 'up to date' phrasing", a.Reason) + } +} + +func TestFileAction_SkipWhenDriftedNoForce(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "drift.txt"), []byte("old"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + a := FileAction(root, "drift.txt", []byte("new"), 0o644, false) + if a.Kind != KindSkip { + t.Errorf("Kind = %q; want %q (skip on drift without --force)", a.Kind, KindSkip) + } + if !strings.Contains(a.Reason, "--force") { + t.Errorf("Reason should hint at --force, got %q", a.Reason) + } +} + +func TestFileAction_OverwriteWhenDriftedWithForce(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "drift.txt"), []byte("old"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + a := FileAction(root, "drift.txt", []byte("new"), 0o644, true) + if a.Kind != KindWriteFile { + t.Errorf("Kind = %q; want %q (overwrite with --force)", a.Kind, KindWriteFile) + } + if !strings.Contains(a.Reason, "overwrite") { + t.Errorf("Reason should describe overwrite, got %q", a.Reason) + } +} + +func TestDirAction_CreateWhenAbsent(t *testing.T) { + root := t.TempDir() + a := DirAction(root, "subdir/nested") + if a.Kind != KindMkdir { + t.Errorf("Kind = %q; want %q", a.Kind, KindMkdir) + } +} + +func TestDirAction_SkipWhenExists(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "exists"), 0o755); err != nil { + t.Fatalf("seed: %v", err) + } + a := DirAction(root, "exists") + if a.Kind != KindSkip { + t.Errorf("Kind = %q; want %q", a.Kind, KindSkip) + } +} + +func TestExecute_WriteAndMkdir(t *testing.T) { + root := t.TempDir() + + mk := DirAction(root, "made/sub") + if err := Execute(&mk); err != nil { + t.Fatalf("execute mkdir: %v", err) + } + if info, err := os.Stat(filepath.Join(root, "made", "sub")); err != nil || !info.IsDir() { + t.Fatalf("mkdir did not create dir: %v", err) + } + + wr := FileAction(root, "made/file.txt", []byte("body"), 0o644, false) + if err := Execute(&wr); err != nil { + t.Fatalf("execute write: %v", err) + } + got, err := os.ReadFile(filepath.Join(root, "made", "file.txt")) + if err != nil { + t.Fatalf("read back: %v", err) + } + if string(got) != "body" { + t.Errorf("file body = %q; want %q", got, "body") + } +} + +func TestExecute_SkipIsNoOp(t *testing.T) { + a := Action{Kind: KindSkip, Path: "/nonexistent/should/not/be/touched"} + if err := Execute(&a); err != nil { + t.Errorf("skip should be no-op, got error: %v", err) + } +} + +func TestPrintPlan_LabelAndCounts(t *testing.T) { + actions := []Action{ + {Rel: "a.txt", Kind: KindWriteFile, Reason: "create"}, + {Rel: "b.txt", Kind: KindSkip, Reason: "already up to date"}, + {Rel: "c/", Kind: KindMkdir, Reason: "create directory"}, + } + var buf bytes.Buffer + PrintPlan(&buf, actions, true, "docops upgrade") + out := buf.String() + if !strings.Contains(out, "docops upgrade: would apply 2 change(s), skipped 1") { + t.Errorf("summary line missing/wrong: %s", out) + } + if !strings.Contains(out, "+ write-file") { + t.Errorf("dry-run sigil missing for non-skip action: %s", out) + } + if !strings.Contains(out, "skip") { + t.Errorf("skip line missing: %s", out) + } +} + +func TestLoadShippedSkills_NonEmpty(t *testing.T) { + skills, err := LoadShippedSkills() + if err != nil { + t.Fatalf("LoadShippedSkills: %v", err) + } + if len(skills) == 0 { + t.Fatal("expected at least one shipped skill") + } + for name, body := range skills { + if !strings.HasSuffix(name, ".md") { + t.Errorf("skill name %q does not end in .md", name) + } + if len(body) == 0 { + t.Errorf("skill %q has empty body", name) + } + } +} diff --git a/internal/updatecheck/updatecheck.go b/internal/updatecheck/updatecheck.go new file mode 100644 index 0000000..f985cfa --- /dev/null +++ b/internal/updatecheck/updatecheck.go @@ -0,0 +1,322 @@ +// Package updatecheck implements a cached, lazy probe of the upstream +// docops VERSION file. It mirrors the gstack pattern: hit the network +// at most once per TTL window, fail quiet on any error, and surface +// `UPGRADE_AVAILABLE` to callers (the standalone `docops update-check` +// subcommand and the `docops upgrade` pre-flight) so users learn when +// their binary is behind upstream. +// +// The package has no project-state dependencies — it talks to the +// filesystem (cache + snooze files under ~/.docops/) and to the +// network (one HTTP GET) and that is all. +package updatecheck + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +// DefaultRemoteURL is the canonical upstream version source. Override +// via Opts.RemoteURL or the DOCOPS_REMOTE_URL env var (CLI wires it). +const DefaultRemoteURL = "https://raw.githubusercontent.com/logicwind/docops/main/VERSION" + +// CacheFilename and SnoozeFilename live inside the per-user state dir. +const ( + CacheFilename = "last-update-check" + SnoozeFilename = "update-snoozed" +) + +// TTLs follow gstack's empirically chosen split: re-check up-to-date +// state often enough to surface fresh releases within a working day, +// but keep the upgrade-available reminder live for longer so users see +// it the next morning. +const ( + UpToDateTTL = 6 * time.Hour + UpgradeAvailableTTL = 24 * time.Hour + DefaultTimeout = 5 * time.Second +) + +// Status enumerates the three terminal states of a single Run call. +type Status int + +const ( + StatusUpToDate Status = iota + StatusUpgradeAvailable + StatusSkipped +) + +// String renders Status for human/JSON output. +func (s Status) String() string { + switch s { + case StatusUpToDate: + return "up-to-date" + case StatusUpgradeAvailable: + return "upgrade-available" + case StatusSkipped: + return "skipped" + } + return "unknown" +} + +// Result is what Run returns. Remote is empty unless Status is +// StatusUpgradeAvailable. Reason is populated for StatusSkipped. +type Result struct { + Status Status + Local string + Remote string + Reason string +} + +// Opts configures a single update check. Zero-value safe: missing +// fields fall back to sensible defaults during Run. +type Opts struct { + Local string // local version (caller-provided) + RemoteURL string // defaults to DefaultRemoteURL + StateDir string // defaults to $HOME/.docops + Force bool // bypass cache (used by --force) + Timeout time.Duration // defaults to DefaultTimeout + Now func() time.Time // defaults to time.Now (test seam) + HTTPClient *http.Client // defaults to a client with Timeout + Env func(string) string // defaults to os.Getenv (test seam) +} + +// versionRE validates remote and local version strings. Anything that +// does not match is treated as a network error. +var versionRE = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + +// devVersionMarkers identify a non-release build that should not be +// compared against upstream. +var devVersionMarkers = []string{"dev", "+dirty", "(devel)"} + +// Run executes one update check. Errors are returned only for +// programming mistakes (missing Local). All transient failures +// (network errors, missing home dir, malformed cache) collapse into +// StatusSkipped or StatusUpToDate so callers never have to handle +// transient noise. +func Run(opts Opts) (Result, error) { + if opts.Local == "" { + return Result{}, errors.New("updatecheck: Local must be set") + } + applyDefaults(&opts) + + // Hard skips — never touch network or cache. + if isDevBuild(opts.Local) { + return Result{Status: StatusSkipped, Local: opts.Local, Reason: "dev-build"}, nil + } + if strings.EqualFold(opts.Env("DOCOPS_UPDATE_CHECK"), "off") { + return Result{Status: StatusSkipped, Local: opts.Local, Reason: "disabled"}, nil + } + if opts.StateDir == "" { + return Result{Status: StatusSkipped, Local: opts.Local, Reason: "no-home-dir"}, nil + } + + // Cache fast-path. + if !opts.Force { + if cached, ok := readCache(opts.StateDir, opts.Local, opts.Now()); ok { + if cached.Status == StatusUpgradeAvailable && isSnoozed(opts.StateDir, cached.Remote, opts.Now()) { + return Result{Status: StatusSkipped, Local: opts.Local, Reason: "snoozed"}, nil + } + return cached, nil + } + } + + // Slow path: fetch remote. + remote := fetchRemote(opts.HTTPClient, opts.RemoteURL) + if remote == "" || !versionRE.MatchString(remote) { + // Treat any failure as up-to-date so we don't loop on a flaky + // upstream. Cache it briefly so the next call doesn't re-hit + // the network either. + _ = writeCache(opts.StateDir, fmt.Sprintf("UP_TO_DATE %s", opts.Local)) + return Result{Status: StatusUpToDate, Local: opts.Local, Reason: "offline"}, nil + } + + if remote == opts.Local { + _ = writeCache(opts.StateDir, fmt.Sprintf("UP_TO_DATE %s", opts.Local)) + return Result{Status: StatusUpToDate, Local: opts.Local}, nil + } + + _ = writeCache(opts.StateDir, fmt.Sprintf("UPGRADE_AVAILABLE %s %s", opts.Local, remote)) + if isSnoozed(opts.StateDir, remote, opts.Now()) { + return Result{Status: StatusSkipped, Local: opts.Local, Remote: remote, Reason: "snoozed"}, nil + } + return Result{Status: StatusUpgradeAvailable, Local: opts.Local, Remote: remote}, nil +} + +// Snooze records a snooze for the given remote version. Each +// invocation bumps the level (1 → 2 → 3+), which extends the +// quiet-window. A new remote version invalidates the previous snooze +// implicitly (isSnoozed checks the version). +func Snooze(stateDir, remote string, now time.Time) error { + if stateDir == "" { + return errors.New("updatecheck: stateDir required to snooze") + } + level := 1 + if existing, err := readSnoozeFile(stateDir); err == nil && existing.version == remote { + level = existing.level + 1 + } + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return err + } + body := fmt.Sprintf("%s %d %d\n", remote, level, now.Unix()) + return os.WriteFile(filepath.Join(stateDir, SnoozeFilename), []byte(body), 0o644) +} + +// DefaultStateDir returns the per-user state directory ("$HOME/.docops"). +// Returns "" when $HOME is unavailable; callers treat that as +// StatusSkipped. +func DefaultStateDir() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".docops") +} + +// ───────────────────────────── internals ───────────────────────────── + +func applyDefaults(o *Opts) { + if o.RemoteURL == "" { + o.RemoteURL = DefaultRemoteURL + } + if o.StateDir == "" { + o.StateDir = DefaultStateDir() + } + if o.Timeout == 0 { + o.Timeout = DefaultTimeout + } + if o.Now == nil { + o.Now = time.Now + } + if o.Env == nil { + o.Env = os.Getenv + } + if o.HTTPClient == nil { + o.HTTPClient = &http.Client{Timeout: o.Timeout} + } +} + +func isDevBuild(v string) bool { + for _, m := range devVersionMarkers { + if strings.Contains(v, m) { + return true + } + } + // Also reject anything that doesn't look like x.y.z — any non-release + // build (e.g. a goreleaser snapshot tag like "0.0.1-next") would + // otherwise trigger a spurious upgrade prompt. + return !versionRE.MatchString(v) +} + +func fetchRemote(client *http.Client, url string) string { + resp, err := client.Get(url) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "" + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 64)) + if err != nil { + return "" + } + return strings.TrimSpace(string(body)) +} + +// readCache loads the cache file and returns a Result if the entry is +// still within its TTL and refers to the current local version. +func readCache(stateDir, local string, now time.Time) (Result, bool) { + path := filepath.Join(stateDir, CacheFilename) + body, err := os.ReadFile(path) + if err != nil { + return Result{}, false + } + info, err := os.Stat(path) + if err != nil { + return Result{}, false + } + parts := strings.Fields(strings.TrimSpace(string(body))) + if len(parts) == 0 { + return Result{}, false + } + switch parts[0] { + case "UP_TO_DATE": + if len(parts) != 2 || parts[1] != local { + return Result{}, false + } + if now.Sub(info.ModTime()) > UpToDateTTL { + return Result{}, false + } + return Result{Status: StatusUpToDate, Local: local}, true + case "UPGRADE_AVAILABLE": + if len(parts) != 3 || parts[1] != local { + return Result{}, false + } + if now.Sub(info.ModTime()) > UpgradeAvailableTTL { + return Result{}, false + } + return Result{Status: StatusUpgradeAvailable, Local: local, Remote: parts[2]}, true + } + return Result{}, false +} + +func writeCache(stateDir, line string) error { + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(stateDir, CacheFilename), []byte(line+"\n"), 0o644) +} + +type snoozeEntry struct { + version string + level int + epoch int64 +} + +func readSnoozeFile(stateDir string) (snoozeEntry, error) { + body, err := os.ReadFile(filepath.Join(stateDir, SnoozeFilename)) + if err != nil { + return snoozeEntry{}, err + } + parts := strings.Fields(strings.TrimSpace(string(body))) + if len(parts) != 3 { + return snoozeEntry{}, errors.New("malformed snooze file") + } + level, err := strconv.Atoi(parts[1]) + if err != nil { + return snoozeEntry{}, err + } + epoch, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return snoozeEntry{}, err + } + return snoozeEntry{version: parts[0], level: level, epoch: epoch}, nil +} + +func isSnoozed(stateDir, remote string, now time.Time) bool { + entry, err := readSnoozeFile(stateDir) + if err != nil { + return false + } + if entry.version != remote { + return false + } + var window time.Duration + switch { + case entry.level <= 1: + window = 24 * time.Hour + case entry.level == 2: + window = 48 * time.Hour + default: + window = 7 * 24 * time.Hour + } + expires := time.Unix(entry.epoch, 0).Add(window) + return now.Before(expires) +} diff --git a/internal/updatecheck/updatecheck_test.go b/internal/updatecheck/updatecheck_test.go new file mode 100644 index 0000000..c436560 --- /dev/null +++ b/internal/updatecheck/updatecheck_test.go @@ -0,0 +1,383 @@ +package updatecheck + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +// fixedNow returns a Now() func locked to a specific instant. +func fixedNow(ts time.Time) func() time.Time { + return func() time.Time { return ts } +} + +// staticEnv returns an Env() func that ignores os.Getenv and returns +// values from a fixed map. Tests use this to set DOCOPS_UPDATE_CHECK +// without leaking into the parent process. +func staticEnv(m map[string]string) func(string) string { + return func(k string) string { return m[k] } +} + +// serveVersion stands up an httptest.Server that returns body for any +// GET. Returns the server URL and a teardown func. +func serveVersion(t *testing.T, body string) (string, func()) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, body) + })) + return srv.URL, srv.Close +} + +// failIfHit returns a server URL whose handler fails the test if it +// receives any request. +func failIfHit(t *testing.T) (string, func()) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("network was hit unexpectedly: %s", r.URL.Path) + })) + return srv.URL, srv.Close +} + +func TestRun_RequiresLocal(t *testing.T) { + if _, err := Run(Opts{}); err == nil { + t.Fatal("expected error when Local is empty") + } +} + +func TestRun_DevBuildSkipsWithoutNetwork(t *testing.T) { + url, stop := failIfHit(t) + defer stop() + + for _, local := range []string{"dev", "0.1.2+dirty", "v0.1.2-(devel)", "0.0.1-next"} { + t.Run(local, func(t *testing.T) { + res, err := Run(Opts{ + Local: local, + RemoteURL: url, + StateDir: t.TempDir(), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusSkipped || res.Reason != "dev-build" { + t.Errorf("got %+v; want StatusSkipped/dev-build", res) + } + }) + } +} + +func TestRun_DisabledByEnvSkipsWithoutNetwork(t *testing.T) { + url, stop := failIfHit(t) + defer stop() + + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: t.TempDir(), + Env: staticEnv(map[string]string{"DOCOPS_UPDATE_CHECK": "off"}), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusSkipped || res.Reason != "disabled" { + t.Errorf("got %+v; want StatusSkipped/disabled", res) + } +} + +func TestRun_FreshUpToDateCacheHit_NoNetwork(t *testing.T) { + state := t.TempDir() + if err := os.WriteFile(filepath.Join(state, CacheFilename), []byte("UP_TO_DATE 0.1.1\n"), 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + url, stop := failIfHit(t) + defer stop() + + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpToDate { + t.Errorf("got %+v; want StatusUpToDate", res) + } +} + +func TestRun_FreshUpgradeAvailableCacheHit_NoNetwork(t *testing.T) { + state := t.TempDir() + if err := os.WriteFile(filepath.Join(state, CacheFilename), []byte("UPGRADE_AVAILABLE 0.1.1 0.1.2\n"), 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + url, stop := failIfHit(t) + defer stop() + + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpgradeAvailable || res.Remote != "0.1.2" { + t.Errorf("got %+v; want StatusUpgradeAvailable remote=0.1.2", res) + } +} + +func TestRun_StaleCacheTriggersRefetch(t *testing.T) { + state := t.TempDir() + cachePath := filepath.Join(state, CacheFilename) + if err := os.WriteFile(cachePath, []byte("UP_TO_DATE 0.1.1\n"), 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + // Force the cache to look 12 hours old (UP_TO_DATE TTL is 6h). + old := time.Now().Add(-12 * time.Hour) + if err := os.Chtimes(cachePath, old, old); err != nil { + t.Fatalf("chtimes: %v", err) + } + + url, stop := serveVersion(t, "0.1.2\n") + defer stop() + + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpgradeAvailable || res.Remote != "0.1.2" { + t.Errorf("got %+v; want StatusUpgradeAvailable remote=0.1.2", res) + } +} + +func TestRun_NetworkErrorYieldsUpToDate(t *testing.T) { + // Point at an address with no listener — http.Client.Get will fail + // with a connection refused. + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: "http://127.0.0.1:1/VERSION", + StateDir: t.TempDir(), + Timeout: 200 * time.Millisecond, + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpToDate { + t.Errorf("got %+v; want StatusUpToDate on network error", res) + } +} + +func TestRun_InvalidRemoteResponseYieldsUpToDate(t *testing.T) { + cases := map[string]string{ + "empty": "", + "html": "404", + "prefixed": "v0.1.2", + "too-short": "0.1", + "with-space": "0.1.2 extra", + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + url, stop := serveVersion(t, body) + defer stop() + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: t.TempDir(), + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpToDate { + t.Errorf("body=%q got %+v; want StatusUpToDate", body, res) + } + }) + } +} + +func TestRun_UpToDateMatchingRemote(t *testing.T) { + url, stop := serveVersion(t, "0.1.1\n") + defer stop() + state := t.TempDir() + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpToDate { + t.Errorf("got %+v; want StatusUpToDate", res) + } + cached, err := os.ReadFile(filepath.Join(state, CacheFilename)) + if err != nil || string(cached) != "UP_TO_DATE 0.1.1\n" { + t.Errorf("cache file = %q (err=%v); want %q", cached, err, "UP_TO_DATE 0.1.1\n") + } +} + +func TestRun_SnoozeSuppressesMatchingRemote(t *testing.T) { + state := t.TempDir() + now := time.Now() + if err := os.WriteFile( + filepath.Join(state, SnoozeFilename), + []byte(fmt.Sprintf("0.1.2 1 %d\n", now.Unix())), + 0o644, + ); err != nil { + t.Fatalf("seed snooze: %v", err) + } + + url, stop := serveVersion(t, "0.1.2\n") + defer stop() + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(now), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusSkipped || res.Reason != "snoozed" { + t.Errorf("got %+v; want StatusSkipped/snoozed", res) + } +} + +func TestRun_SnoozeForOlderRemoteDoesNotSuppressNewer(t *testing.T) { + state := t.TempDir() + now := time.Now() + // Snooze 0.1.2; remote will offer 0.1.3 — should NOT be suppressed. + if err := os.WriteFile( + filepath.Join(state, SnoozeFilename), + []byte(fmt.Sprintf("0.1.2 1 %d\n", now.Unix())), + 0o644, + ); err != nil { + t.Fatalf("seed snooze: %v", err) + } + + url, stop := serveVersion(t, "0.1.3\n") + defer stop() + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(now), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpgradeAvailable || res.Remote != "0.1.3" { + t.Errorf("got %+v; want StatusUpgradeAvailable remote=0.1.3", res) + } +} + +func TestRun_ExpiredSnoozeNoLongerSuppresses(t *testing.T) { + state := t.TempDir() + now := time.Now() + // level 1 = 24h; snooze stamped 48h ago is expired. + stamped := now.Add(-48 * time.Hour).Unix() + if err := os.WriteFile( + filepath.Join(state, SnoozeFilename), + []byte(fmt.Sprintf("0.1.2 1 %d\n", stamped)), + 0o644, + ); err != nil { + t.Fatalf("seed snooze: %v", err) + } + + url, stop := serveVersion(t, "0.1.2\n") + defer stop() + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Now: fixedNow(now), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpgradeAvailable { + t.Errorf("got %+v; want StatusUpgradeAvailable after snooze expired", res) + } +} + +func TestRun_ForceBypassesCache(t *testing.T) { + state := t.TempDir() + if err := os.WriteFile(filepath.Join(state, CacheFilename), []byte("UP_TO_DATE 0.1.1\n"), 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + url, stop := serveVersion(t, "0.1.2\n") + defer stop() + + res, err := Run(Opts{ + Local: "0.1.1", + RemoteURL: url, + StateDir: state, + Force: true, + Now: fixedNow(time.Now()), + Env: staticEnv(nil), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Status != StatusUpgradeAvailable || res.Remote != "0.1.2" { + t.Errorf("got %+v; want StatusUpgradeAvailable remote=0.1.2", res) + } +} + +func TestSnooze_BumpsLevelOnRepeatForSameRemote(t *testing.T) { + state := t.TempDir() + now := time.Now() + if err := Snooze(state, "0.1.2", now); err != nil { + t.Fatalf("first snooze: %v", err) + } + if err := Snooze(state, "0.1.2", now.Add(time.Minute)); err != nil { + t.Fatalf("second snooze: %v", err) + } + entry, err := readSnoozeFile(state) + if err != nil { + t.Fatalf("readSnoozeFile: %v", err) + } + if entry.level != 2 { + t.Errorf("level = %d; want 2 after second snooze of same remote", entry.level) + } +} + +func TestSnooze_ResetsLevelOnNewRemote(t *testing.T) { + state := t.TempDir() + now := time.Now() + if err := Snooze(state, "0.1.2", now); err != nil { + t.Fatalf("first snooze: %v", err) + } + if err := Snooze(state, "0.1.3", now); err != nil { + t.Fatalf("snooze for new remote: %v", err) + } + entry, err := readSnoozeFile(state) + if err != nil { + t.Fatalf("readSnoozeFile: %v", err) + } + if entry.version != "0.1.3" || entry.level != 1 { + t.Errorf("entry = %+v; want version=0.1.3 level=1", entry) + } +} diff --git a/internal/upgrader/manifest.go b/internal/upgrader/manifest.go new file mode 100644 index 0000000..973eff8 --- /dev/null +++ b/internal/upgrader/manifest.go @@ -0,0 +1,59 @@ +package upgrader + +import ( + "bufio" + "bytes" + "os" + "path/filepath" + "sort" + "strings" +) + +// ManifestFilename is the dot-prefixed sentinel docops writes inside +// each owned skill directory to record what it scaffolded. On +// subsequent upgrades, the manifest scopes deletion to "files we +// previously wrote" so user-added files inside the dir cause an +// explicit refusal rather than silent removal. +const ManifestFilename = ".docops-manifest" + +// readManifest returns the sorted list of basenames recorded in the +// dir's manifest. exists is false (and names is nil) when the +// manifest file is absent — first-run upgrades take this path. A +// blank or comment-only manifest yields an empty slice and exists=true. +func readManifest(absDir string) (names []string, exists bool, err error) { + body, err := os.ReadFile(filepath.Join(absDir, ManifestFilename)) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + sc := bufio.NewScanner(bytes.NewReader(body)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + names = append(names, line) + } + sort.Strings(names) + return names, true, sc.Err() +} + +// writeManifest replaces the manifest file with a sorted list of the +// given basenames, prefixed with a comment explaining what the file +// is. Best-effort: callers should warn but not fail if this errors. +func writeManifest(absDir string, names []string) error { + if err := os.MkdirAll(absDir, 0o755); err != nil { + return err + } + sort.Strings(names) + var buf bytes.Buffer + buf.WriteString("# docops manifest — files in this directory owned by `docops upgrade`.\n") + buf.WriteString("# Do not hand-edit; rerun `docops upgrade` to regenerate.\n") + for _, n := range names { + buf.WriteString(n) + buf.WriteByte('\n') + } + return os.WriteFile(filepath.Join(absDir, ManifestFilename), buf.Bytes(), 0o644) +} diff --git a/internal/upgrader/upgrader.go b/internal/upgrader/upgrader.go new file mode 100644 index 0000000..42388ea --- /dev/null +++ b/internal/upgrader/upgrader.go @@ -0,0 +1,473 @@ +// Package upgrader implements `docops upgrade`: an in-band refresh of +// docops-owned scaffolding (skills, schemas, AGENTS.md block) for +// projects already initialized via `docops init`. The contract is +// narrower than init's: we never touch user-owned files +// (docops.yaml, pre-commit hook, docs/{context,decisions,tasks}/*) +// unless an opt-in flag asks us to. See ADR-0021 for the rationale. +package upgrader + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "github.com/logicwind/docops/internal/config" + "github.com/logicwind/docops/internal/scaffold" + "github.com/logicwind/docops/internal/schema" + "github.com/logicwind/docops/templates" +) + +// Options configures an upgrade run. Root must contain a docops.yaml +// (Run rejects the call otherwise so users do not accidentally +// scaffold a fresh project via the wrong subcommand). +type Options struct { + Root string + DryRun bool + // Config opts in to overwriting docops.yaml from the shipped template. + Config bool + // Hook opts in to reinstalling the pre-commit hook. + Hook bool + // Out is the human-readable progress sink; defaults to os.Stdout. + Out io.Writer +} + +// Result mirrors initter.Result: the planned/applied actions in order. +type Result struct { + Actions []scaffold.Action +} + +// ErrNoConfig signals the project is not docops-initialized yet. The +// CLI converts this into a clear "run docops init first" message and +// exits 2 (matching every other non-init bootstrap error). +var ErrNoConfig = errors.New("upgrader: no docops.yaml at root — run `docops init` first") + +// ErrUnknownFiles signals the safety belt fired: the docops-owned +// skill directory contains files that neither the manifest nor the +// shipped bundle accounts for. The CLI prints the file list verbatim +// and exits 2 without touching anything. +type ErrUnknownFiles struct { + Dir string + Files []string +} + +func (e *ErrUnknownFiles) Error() string { + return fmt.Sprintf("upgrader: %s contains user-added files not in the docops bundle: %v", e.Dir, e.Files) +} + +// Run plans and (unless DryRun) executes the upgrade. Returns +// ErrNoConfig if the root has no docops.yaml; *ErrUnknownFiles if a +// docops-owned skill dir contains files outside the shipped bundle +// and the manifest. +func Run(opts Options) (*Result, error) { + if opts.Root == "" { + return nil, errors.New("upgrader: Root must be set") + } + abs, err := filepath.Abs(opts.Root) + if err != nil { + return nil, fmt.Errorf("upgrader: resolve root: %w", err) + } + opts.Root = abs + if opts.Out == nil { + opts.Out = os.Stdout + } + + cfgPath := filepath.Join(opts.Root, config.DefaultFilename) + if _, err := os.Stat(cfgPath); err != nil { + if os.IsNotExist(err) { + return nil, ErrNoConfig + } + return nil, fmt.Errorf("upgrader: stat %s: %w", config.DefaultFilename, err) + } + + actions, err := plan(opts) + if err != nil { + return nil, err + } + + if opts.DryRun { + printPlan(opts.Out, actions, true) + return &Result{Actions: actions}, nil + } + for i := range actions { + if err := scaffold.Execute(&actions[i]); err != nil { + return nil, fmt.Errorf("apply %s: %w", actions[i].Rel, err) + } + } + // After successful execution, refresh the manifest in each + // docops-owned skill dir so subsequent upgrades scope deletions + // correctly. Failures here are logged but do not fail the upgrade + // — manifest is best-effort metadata. + for _, dir := range docopsSkillDirs() { + if err := writeManifest(filepath.Join(opts.Root, dir), shippedSkillNames(actions, dir)); err != nil { + fmt.Fprintf(opts.Out, " warning: refresh manifest in %s: %v\n", dir, err) + } + } + printPlan(opts.Out, actions, false) + return &Result{Actions: actions}, nil +} + +// docopsSkillDirs lists the directories upgrade owns. A future plugin +// system might extend this; for now it is fixed to the two +// agent-tool conventions docops init scaffolds. +func docopsSkillDirs() []string { + return []string{".claude/skills/docops", ".cursor/commands/docops"} +} + +// shippedSkillNames returns the basenames of skill files currently +// present (write or refresh actions) in the given dir, derived from +// the action set. Used to write the post-upgrade manifest. +func shippedSkillNames(actions []scaffold.Action, dir string) []string { + prefix := dir + string(filepath.Separator) + var names []string + for _, a := range actions { + if a.Kind == scaffold.KindRemove { + continue + } + // Only top-level files under the dir count — skip the dir + // itself and any nested paths (none today, future-proof). + if rel := a.Rel; len(rel) > len(prefix) && rel[:len(prefix)] == prefix { + name := rel[len(prefix):] + if !containsSep(name) { + names = append(names, name) + } + } + } + sort.Strings(names) + return names +} + +func containsSep(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] == filepath.Separator || s[i] == '/' { + return true + } + } + return false +} + +// plan assembles the full upgrade action set. Reads the project's +// docops.yaml so context_types propagate into emitted schemas. +func plan(opts Options) ([]scaffold.Action, error) { + cfg := config.Default() + if loaded, err := config.Load(filepath.Join(opts.Root, config.DefaultFilename)); err == nil { + cfg = loaded + } + + var actions []scaffold.Action + + // 1. JSON Schemas — always refreshed (docops-owned). + schemas, err := schema.JSONSchemas(schema.Config{ContextTypes: cfg.ContextTypes}) + if err != nil { + return nil, fmt.Errorf("emit json schema: %w", err) + } + schemaNames := make([]string, 0, len(schemas)) + for name := range schemas { + schemaNames = append(schemaNames, name) + } + sort.Strings(schemaNames) + for _, name := range schemaNames { + rel := filepath.Join(cfg.Paths.Schema, name) + actions = append(actions, scaffold.FileAction(opts.Root, rel, schemas[name], 0o644, true)) + } + + // 2. AGENTS.md and CLAUDE.md block refresh. Both files share the + // same docops block (ADR-0024). For each: if the file exists, the + // block is merged in place; if absent, the full template is + // written so v0.1.x users gain CLAUDE.md on first upgrade after + // this lands. + agentsTmpl, err := templates.AgentsBlock() + if err != nil { + return nil, fmt.Errorf("read agents template: %w", err) + } + agentsAction, err := planMarkdownBlock(opts.Root, "AGENTS.md", agentsTmpl) + if err != nil { + return nil, err + } + actions = append(actions, agentsAction) + + claudeTmpl, err := templates.ClaudeBlock() + if err != nil { + return nil, fmt.Errorf("read claude template: %w", err) + } + claudeAction, err := planMarkdownBlock(opts.Root, "CLAUDE.md", claudeTmpl) + if err != nil { + return nil, err + } + actions = append(actions, claudeAction) + + // 3. Skills — sync each docops-owned dir against the shipped bundle. + skills, err := scaffold.LoadShippedSkills() + if err != nil { + return nil, fmt.Errorf("read skills: %w", err) + } + skillNames := make([]string, 0, len(skills)) + for name := range skills { + skillNames = append(skillNames, name) + } + sort.Strings(skillNames) + + for _, dir := range docopsSkillDirs() { + dirActions, err := planSkillDir(opts, dir, skills, skillNames) + if err != nil { + return nil, err + } + actions = append(actions, dirActions...) + } + + // 4. Opt-in: docops.yaml. + if opts.Config { + yamlBody, err := templates.DocopsYAML() + if err != nil { + return nil, fmt.Errorf("read docops.yaml template: %w", err) + } + actions = append(actions, scaffold.FileAction(opts.Root, "docops.yaml", yamlBody, 0o644, true)) + } + + // 5. Opt-in: pre-commit hook. + if opts.Hook { + hookBody, err := templates.PreCommitHook() + if err != nil { + return nil, fmt.Errorf("read pre-commit template: %w", err) + } + hookAction, err := planHook(opts, hookBody) + if err != nil { + return nil, err + } + actions = append(actions, hookAction) + } + + return actions, nil +} + +// planMarkdownBlock returns a refresh-or-create action for a +// docops-managed markdown file (AGENTS.md, CLAUDE.md). When the file +// is absent, it writes the template verbatim — upgrade now creates +// missing managed files so v0.1.x users gain CLAUDE.md on first +// post-ADR-0024 upgrade. When present, the docops block is merged in +// place; the rest of the file is preserved. +func planMarkdownBlock(rootAbs, rel string, tmpl []byte) (scaffold.Action, error) { + abs := filepath.Join(rootAbs, rel) + existing, err := os.ReadFile(abs) + if err != nil { + if os.IsNotExist(err) { + return scaffold.Action{ + Path: abs, + Rel: rel, + Kind: scaffold.KindWriteFile, + Body: tmpl, + Mode: 0o644, + Reason: "create", + }, nil + } + return scaffold.Action{}, err + } + merged, changed, reason := scaffold.MergeAgentsBlock(existing, tmpl) + if !changed { + return scaffold.Action{Path: abs, Rel: rel, Kind: scaffold.KindSkip, Reason: reason}, nil + } + return scaffold.Action{ + Path: abs, + Rel: rel, + Kind: scaffold.KindMergeAgents, + Body: merged, + Mode: 0o644, + Reason: reason, + }, nil +} + +// planHook installs/refreshes .git/hooks/pre-commit. Only invoked +// when --hook is passed; otherwise upgrade leaves the user's hook +// chain alone. +func planHook(opts Options, body []byte) (scaffold.Action, error) { + gitDir := filepath.Join(opts.Root, ".git") + if info, err := os.Stat(gitDir); err != nil || !info.IsDir() { + return scaffold.Action{ + Path: filepath.Join(gitDir, "hooks", "pre-commit"), + Rel: ".git/hooks/pre-commit", + Kind: scaffold.KindSkip, + Reason: "no .git directory — install the hook manually from templates/hooks/pre-commit", + }, nil + } + rel := ".git/hooks/pre-commit" + abs := filepath.Join(opts.Root, rel) + a := scaffold.Action{Path: abs, Rel: rel, Kind: scaffold.KindWriteFile, Body: body, Mode: 0o755} + existing, err := os.ReadFile(abs) + if err != nil { + if os.IsNotExist(err) { + a.Reason = "install" + return a, nil + } + return scaffold.Action{}, err + } + if bytes.Equal(existing, body) { + a.Kind = scaffold.KindSkip + a.Reason = "already up to date" + return a, nil + } + a.Reason = "overwrite drifted hook (--hook)" + return a, nil +} + +// planSkillDir walks one docops-owned skill directory and emits the +// create / refresh / remove / skip actions to bring it in line with +// the shipped bundle. The safety belt (manifest check) fires here: +// any present file that the manifest does not vouch for and the +// shipped bundle does not contain triggers ErrUnknownFiles. +func planSkillDir(opts Options, dir string, shipped map[string][]byte, shippedNames []string) ([]scaffold.Action, error) { + absDir := filepath.Join(opts.Root, dir) + + present, dirExists, err := listSkillFiles(absDir) + if err != nil { + return nil, err + } + + manifest, manifestExists, err := readManifest(absDir) + if err != nil { + return nil, err + } + + // Safety belt: only enforce when a manifest exists. First-time + // upgraders (v0.1.x users) get an implicit pass — anything in the + // dir is treated as docops-owned and reconciled against the + // shipped bundle. + if manifestExists { + shippedSet := stringSet(shippedNames) + manifestSet := stringSet(manifest) + var unknown []string + for _, name := range present { + if !shippedSet[name] && !manifestSet[name] { + unknown = append(unknown, name) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return nil, &ErrUnknownFiles{Dir: dir, Files: unknown} + } + } + + var actions []scaffold.Action + + // Ensure the directory exists (mkdir or skip). + actions = append(actions, scaffold.DirAction(opts.Root, dir)) + + // Refresh / create every shipped file. + for _, name := range shippedNames { + rel := filepath.Join(dir, name) + actions = append(actions, scaffold.FileAction(opts.Root, rel, shipped[name], 0o644, true)) + } + + // Remove anything that is in the dir but not in the shipped bundle. + // On first run (no manifest), this includes user files inside + // docops/ — by design (see ADR-0021: "DocOps owns the docops/ + // subdirectory"). On subsequent runs, the safety belt above + // already vetted the dir, so removals are scoped to manifest ∩ + // (not shipped) — i.e. genuine shipped removals. + if dirExists { + shippedSet := stringSet(shippedNames) + for _, name := range present { + if shippedSet[name] { + continue + } + rel := filepath.Join(dir, name) + actions = append(actions, scaffold.Action{ + Path: filepath.Join(opts.Root, rel), + Rel: rel, + Kind: scaffold.KindRemove, + Reason: "no longer in shipped bundle", + Mode: 0o644, + }) + } + } + + // Also drop the manifest sentinel from "present" before any output + // — it is internal metadata, not a skill file. listSkillFiles + // already excludes dotfiles; nothing to do here. + + return actions, nil +} + +// listSkillFiles returns the basenames of regular files directly +// inside dir (no recursion, no dotfiles). dirExists distinguishes a +// missing directory from an empty one so callers can choose to mkdir +// vs scan. +func listSkillFiles(dir string) (names []string, dirExists bool, err error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + for _, e := range entries { + if !e.Type().IsRegular() { + continue + } + name := e.Name() + if len(name) > 0 && name[0] == '.' { + continue + } + names = append(names, name) + } + sort.Strings(names) + return names, true, nil +} + +func stringSet(xs []string) map[string]bool { + out := make(map[string]bool, len(xs)) + for _, x := range xs { + out[x] = true + } + return out +} + +// printPlan renders the action set with upgrade-specific sigils: +// `+` new, `~` refreshed, `-` removed, `=` up to date. Mirrors the +// shape promised by ADR-0021's "Output" section. +func printPlan(w io.Writer, actions []scaffold.Action, dry bool) { + var changed, skipped int + for _, a := range actions { + if a.Kind == scaffold.KindSkip { + skipped++ + } else { + changed++ + } + } + verb := "applied" + if dry { + verb = "would apply" + } + fmt.Fprintf(w, "docops upgrade: %s %d change(s), skipped %d\n", verb, changed, skipped) + for _, a := range actions { + sigil, label := upgradeSigil(a) + fmt.Fprintf(w, " %s %-40s %s\n", sigil, a.Rel, label) + } +} + +// upgradeSigil maps a scaffold action to the sigil + parenthetical +// label used in upgrade's output. Init has its own (different) +// rendering; the two diverged because upgrade's output is a diff, +// init's is a creation log. +func upgradeSigil(a scaffold.Action) (string, string) { + switch a.Kind { + case scaffold.KindMkdir: + return "+", "(new dir)" + case scaffold.KindWriteFile: + // FileAction sets Reason="create" for new files, else + // "overwrite drifted content (--force)" or similar. + if a.Reason == "create" || a.Reason == "install" { + return "+", "(new)" + } + return "~", "(refreshed)" + case scaffold.KindMergeAgents: + return "~", "(block refreshed)" + case scaffold.KindRemove: + return "-", "(removed)" + case scaffold.KindSkip: + return "=", "(up to date)" + } + return "?", "(unknown)" +} diff --git a/internal/upgrader/upgrader_test.go b/internal/upgrader/upgrader_test.go new file mode 100644 index 0000000..f669ed0 --- /dev/null +++ b/internal/upgrader/upgrader_test.go @@ -0,0 +1,429 @@ +package upgrader + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/logicwind/docops/internal/scaffold" + "github.com/logicwind/docops/templates" +) + +// initted scaffolds a minimal docops-initialized project at root with +// the shipped skills and schemas pre-installed, mirroring what +// `docops init` would have produced one release earlier. The caller +// can then mutate the layout to simulate v0.1.x drift before running +// the upgrader. +func initted(t *testing.T) string { + t.Helper() + root := t.TempDir() + + // docops.yaml — required for the upgrader to run at all. + yamlBody, err := templates.DocopsYAML() + if err != nil { + t.Fatalf("template DocopsYAML: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "docops.yaml"), yamlBody, 0o644); err != nil { + t.Fatalf("write docops.yaml: %v", err) + } + + // Seed the shipped skills into both docops-owned dirs. + skills, err := scaffold.LoadShippedSkills() + if err != nil { + t.Fatalf("LoadShippedSkills: %v", err) + } + for _, dir := range []string{".claude/skills/docops", ".cursor/commands/docops"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + for name, body := range skills { + if err := os.WriteFile(filepath.Join(root, dir, name), body, 0o644); err != nil { + t.Fatalf("write seed skill %s: %v", name, err) + } + } + } + + // AGENTS.md with the docops block already merged. + agentsTmpl, err := templates.AgentsBlock() + if err != nil { + t.Fatalf("template AgentsBlock: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), agentsTmpl, 0o644); err != nil { + t.Fatalf("write AGENTS.md: %v", err) + } + + return root +} + +// findAction returns the first action matching rel, or nil. +func findAction(actions []scaffold.Action, rel string) *scaffold.Action { + for i, a := range actions { + if a.Rel == rel { + return &actions[i] + } + } + return nil +} + +func TestRun_RefusesWithoutDocopsYAML(t *testing.T) { + root := t.TempDir() // empty + _, err := Run(Options{Root: root, DryRun: true, Out: io.Discard}) + if !errors.Is(err, ErrNoConfig) { + t.Fatalf("err = %v; want ErrNoConfig", err) + } +} + +func TestRun_IdempotentOnFreshlyInittedProject(t *testing.T) { + root := initted(t) + res, err := Run(Options{Root: root, DryRun: true, Out: io.Discard}) + if err != nil { + t.Fatalf("Run: %v", err) + } + for _, a := range res.Actions { + if a.Kind == scaffold.KindRemove { + t.Errorf("idempotent run should not remove anything; got remove for %s", a.Rel) + } + // The schema files were written by initted via the templates, + // but FileAction's body comparison may flag them refreshed + // because schema.JSONSchemas regenerates from docops.yaml. We + // don't seed them here, so a fresh run will emit creates for + // the schema files — that's fine. Skill files are seeded + // byte-identically and should be skips. + if strings.HasPrefix(a.Rel, ".claude/skills/docops/") && a.Kind != scaffold.KindMkdir && a.Kind != scaffold.KindSkip { + t.Errorf("seeded skill should be skip; %s = %s", a.Rel, a.Kind) + } + } +} + +func TestRun_AddsNewSkillRemovesStaleRefreshesChanged(t *testing.T) { + root := initted(t) + dir := filepath.Join(root, ".claude/skills/docops") + + // Simulate v0.1.0-era state in the dir: + // - delete one shipped skill so the upgrader will (re)create it. + // - mutate one shipped skill so the upgrader will refresh it. + // - add a stale skill that no longer ships. + pickAdd := "init.md" // delete locally → upgrade should add (+) + pickRefresh := "audit.md" // mutate locally → upgrade should refresh (~) + if err := os.Remove(filepath.Join(dir, pickAdd)); err != nil { + t.Fatalf("remove %s: %v", pickAdd, err) + } + if err := os.WriteFile(filepath.Join(dir, pickRefresh), []byte("stale local body\n"), 0o644); err != nil { + t.Fatalf("mutate %s: %v", pickRefresh, err) + } + if err := os.WriteFile(filepath.Join(dir, "next.md"), []byte("removed-upstream skill\n"), 0o644); err != nil { + t.Fatalf("seed stale skill: %v", err) + } + + res, err := Run(Options{Root: root, DryRun: true, Out: io.Discard}) + if err != nil { + t.Fatalf("Run: %v", err) + } + + addAction := findAction(res.Actions, ".claude/skills/docops/"+pickAdd) + if addAction == nil || addAction.Kind != scaffold.KindWriteFile || addAction.Reason != "create" { + t.Errorf("%s should be a create action; got %+v", pickAdd, addAction) + } + + refreshAction := findAction(res.Actions, ".claude/skills/docops/"+pickRefresh) + if refreshAction == nil || refreshAction.Kind != scaffold.KindWriteFile || refreshAction.Reason == "create" { + t.Errorf("%s should be a refresh (overwrite) action; got %+v", pickRefresh, refreshAction) + } + + removeAction := findAction(res.Actions, ".claude/skills/docops/next.md") + if removeAction == nil || removeAction.Kind != scaffold.KindRemove { + t.Errorf("next.md should be a remove action; got %+v", removeAction) + } +} + +func TestRun_ApplyWritesFilesAndDeletesStale(t *testing.T) { + root := initted(t) + dir := filepath.Join(root, ".claude/skills/docops") + staleName := "next.md" + if err := os.WriteFile(filepath.Join(dir, staleName), []byte("stale\n"), 0o644); err != nil { + t.Fatalf("seed stale: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run apply: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, staleName)); !os.IsNotExist(err) { + t.Errorf("stale skill should be deleted; stat err = %v", err) + } + + // A second apply should be a clean no-op (idempotent). + res, err := Run(Options{Root: root, DryRun: true, Out: io.Discard}) + if err != nil { + t.Fatalf("Run dry-run after apply: %v", err) + } + for _, a := range res.Actions { + if a.Kind == scaffold.KindRemove || (a.Kind == scaffold.KindWriteFile && a.Reason != "create") { + // schema files are always written (regenerated from yaml); + // don't flag those as drift. + continue + } + } +} + +func TestRun_DoesNotTouchDocopsYAMLByDefault(t *testing.T) { + root := initted(t) + yamlPath := filepath.Join(root, "docops.yaml") + custom := []byte("# user-customized config\nproject: my-thing\n") + if err := os.WriteFile(yamlPath, custom, 0o644); err != nil { + t.Fatalf("write custom yaml: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("read yaml: %v", err) + } + if !bytes.Equal(got, custom) { + t.Errorf("docops.yaml was modified without --config:\n%s", got) + } +} + +func TestRun_RewritesDocopsYAMLWithConfigFlag(t *testing.T) { + root := initted(t) + yamlPath := filepath.Join(root, "docops.yaml") + if err := os.WriteFile(yamlPath, []byte("# stale\n"), 0o644); err != nil { + t.Fatalf("seed stale yaml: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Config: true, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("read yaml: %v", err) + } + if bytes.Equal(got, []byte("# stale\n")) { + t.Errorf("docops.yaml should have been rewritten with --config") + } +} + +func TestRun_DoesNotTouchPreCommitHookByDefault(t *testing.T) { + root := initted(t) + hookDir := filepath.Join(root, ".git/hooks") + if err := os.MkdirAll(hookDir, 0o755); err != nil { + t.Fatalf("mkdir hookdir: %v", err) + } + customHook := []byte("#!/bin/sh\n# my chained pre-commit\nexit 0\n") + if err := os.WriteFile(filepath.Join(hookDir, "pre-commit"), customHook, 0o755); err != nil { + t.Fatalf("seed hook: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(filepath.Join(hookDir, "pre-commit")) + if err != nil { + t.Fatalf("read hook: %v", err) + } + if !bytes.Equal(got, customHook) { + t.Errorf("pre-commit hook was modified without --hook flag") + } +} + +func TestRun_FirstUpgradeDeletesUserFileInsideDocopsDir(t *testing.T) { + root := initted(t) + dir := filepath.Join(root, ".claude/skills/docops") + custom := filepath.Join(dir, "custom.md") + if err := os.WriteFile(custom, []byte("user-added\n"), 0o644); err != nil { + t.Fatalf("seed custom skill: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + if _, err := os.Stat(custom); !os.IsNotExist(err) { + t.Errorf("first-time upgrade should treat custom.md as docops-owned and delete it; stat err=%v", err) + } +} + +func TestRun_DoesNotTouchUserFileOneLevelUp(t *testing.T) { + root := initted(t) + sibling := filepath.Join(root, ".claude/skills/my-stuff.md") + if err := os.WriteFile(sibling, []byte("user content\n"), 0o644); err != nil { + t.Fatalf("seed sibling: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + body, err := os.ReadFile(sibling) + if err != nil { + t.Fatalf("sibling lost: %v", err) + } + if string(body) != "user content\n" { + t.Errorf("sibling content was modified: %q", body) + } +} + +func TestRun_SecondUpgradeRefusesUserAddedFileInDocopsDir(t *testing.T) { + root := initted(t) + // First upgrade — establishes the manifest. + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("first Run: %v", err) + } + + // User now drops a custom skill inside the docops-owned dir. + custom := filepath.Join(root, ".claude/skills/docops/custom.md") + if err := os.WriteFile(custom, []byte("user-added post-init\n"), 0o644); err != nil { + t.Fatalf("seed custom: %v", err) + } + + _, err := Run(Options{Root: root, DryRun: true, Out: io.Discard}) + var unk *ErrUnknownFiles + if !errors.As(err, &unk) { + t.Fatalf("err = %v; want *ErrUnknownFiles", err) + } + want := []string{"custom.md"} + got := append([]string{}, unk.Files...) + sort.Strings(got) + if len(got) != 1 || got[0] != want[0] { + t.Errorf("unknown files = %v; want %v", got, want) + } + + // Custom file should still be present (we refused without writing). + if _, err := os.Stat(custom); err != nil { + t.Errorf("safety belt should not have deleted user file: %v", err) + } +} + +func TestRun_CreatesClaudeMdWhenAbsent(t *testing.T) { + root := initted(t) + // initted does not seed CLAUDE.md (matches the v0.1.x state pre-ADR-0024). + if _, err := os.Stat(filepath.Join(root, "CLAUDE.md")); !os.IsNotExist(err) { + t.Fatalf("test precondition: CLAUDE.md should be absent, got err=%v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + body, err := os.ReadFile(filepath.Join(root, "CLAUDE.md")) + if err != nil { + t.Fatalf("CLAUDE.md missing after upgrade: %v", err) + } + s := string(body) + if !strings.Contains(s, scaffold.BlockStart) || !strings.Contains(s, scaffold.BlockEnd) { + t.Errorf("CLAUDE.md missing docops block markers: %s", s) + } +} + +func TestRun_RefreshesBothAGENTSAndClaudeBlocks(t *testing.T) { + root := initted(t) + stale := scaffold.BlockStart + "\nold body\n" + scaffold.BlockEnd + for _, name := range []string{"AGENTS.md", "CLAUDE.md"} { + body := "# Header\n\n" + stale + "\n\nuser footer\n" + if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil { + t.Fatalf("seed %s: %v", name, err) + } + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + for _, name := range []string{"AGENTS.md", "CLAUDE.md"} { + body, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + s := string(body) + if strings.Contains(s, "old body") { + t.Errorf("%s: stale block survived refresh", name) + } + if !strings.Contains(s, "user footer") { + t.Errorf("%s: user footer dropped", name) + } + } +} + +func TestRun_PreservesUserContentInClaudeMdAcrossUpgrade(t *testing.T) { + root := initted(t) + // Seed CLAUDE.md with hand-written content outside any docops block. + body := "# My CLAUDE.md\n\nProject-specific guidance for Claude only.\n\nSee AGENTS.md for the multi-tool view.\n" + if err := os.WriteFile(filepath.Join(root, "CLAUDE.md"), []byte(body), 0o644); err != nil { + t.Fatalf("seed CLAUDE.md: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(filepath.Join(root, "CLAUDE.md")) + if err != nil { + t.Fatalf("read CLAUDE.md: %v", err) + } + s := string(got) + if !strings.Contains(s, "Project-specific guidance for Claude only.") { + t.Errorf("user CLAUDE.md content lost across upgrade: %s", s) + } + if !strings.Contains(s, scaffold.BlockStart) || !strings.Contains(s, scaffold.BlockEnd) { + t.Errorf("docops block missing after upgrade: %s", s) + } +} + +func TestRun_DryRunWritesNothing(t *testing.T) { + root := initted(t) + dir := filepath.Join(root, ".claude/skills/docops") + stalePath := filepath.Join(dir, "next.md") + if err := os.WriteFile(stalePath, []byte("stale\n"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: true, Out: io.Discard}); err != nil { + t.Fatalf("dry-run: %v", err) + } + + if _, err := os.Stat(stalePath); err != nil { + t.Errorf("dry-run should not have removed %s; err=%v", stalePath, err) + } +} + +func TestRun_RefreshesAGENTSBlockInPlace(t *testing.T) { + root := initted(t) + agentsPath := filepath.Join(root, "AGENTS.md") + prefix := "# Project header — do not delete\n\n## Hand-written notes\n\nPreserve me.\n\n" + tmpl, _ := templates.AgentsBlock() + // Write a file with user content + a stale block. + mixed := []byte(prefix + scaffold.BlockStart + "\nstale block content\n" + scaffold.BlockEnd + "\n\n## footer\n") + if err := os.WriteFile(agentsPath, mixed, 0o644); err != nil { + t.Fatalf("seed AGENTS: %v", err) + } + + if _, err := Run(Options{Root: root, DryRun: false, Out: io.Discard}); err != nil { + t.Fatalf("Run: %v", err) + } + + got, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatalf("read AGENTS: %v", err) + } + s := string(got) + if !strings.Contains(s, "Hand-written notes") || !strings.Contains(s, "footer") { + t.Errorf("user content lost: %s", s) + } + if strings.Contains(s, "stale block content") { + t.Errorf("stale block content survived refresh") + } + expected := scaffold.ExtractBlock(tmpl) + if !strings.Contains(s, expected) { + t.Errorf("refreshed block does not match template") + } +} diff --git a/templates/AGENTS.md.tmpl b/templates/AGENTS.md.tmpl index 6e0cb8e..f21efe9 100644 --- a/templates/AGENTS.md.tmpl +++ b/templates/AGENTS.md.tmpl @@ -37,6 +37,8 @@ Windsurf, Zed, etc.), read this file before doing any work. ``` docops init # scaffold DocOps in this repo +docops upgrade # refresh DocOps-owned scaffolding after a binary upgrade +docops update-check # check upstream for a newer docops release (cached probe) docops validate # schema + graph invariants docops index # rebuild docs/.index.json docops state # regenerate docs/STATE.md @@ -85,6 +87,8 @@ JSON Schemas for frontmatter validation live in `docs/.docops/schema/` (`context ## Notes for humans -The block above is auto-maintained by `docops init` and `docops refresh-agents-md`. +The block above is auto-maintained by `docops init` and `docops upgrade`. Edit content outside the `` delimiters freely; DocOps will -preserve it. Edits inside the delimiters will be overwritten. +preserve it. Edits inside the delimiters will be overwritten on the next +upgrade. The same docops block also appears in `CLAUDE.md` (which Claude +Code reads by default); both files refresh from the same shipped template. diff --git a/templates/CLAUDE.md.tmpl b/templates/CLAUDE.md.tmpl new file mode 100644 index 0000000..829d04a --- /dev/null +++ b/templates/CLAUDE.md.tmpl @@ -0,0 +1,96 @@ +# Claude in this repo + +This repository is managed with **DocOps** — a typed project-state substrate. +You're reading this because Claude Code loads CLAUDE.md by default. The same +docops block is mirrored in `AGENTS.md` for non-Claude agents (Cursor, Aider, +Codex, Copilot, Windsurf, Zed); both files are auto-maintained by +`docops upgrade`. + + + +## Orientation + +- **Why we're building this:** `docs/context/CTX-*` (look for documents of `type: brief` or `type: prd`). +- **What's been decided:** `docs/decisions/ADR-*.md`. Frontmatter is load-bearing — read it. +- **What to do next:** `docs/STATE.md` for counts, needs-attention, and active work. +- **Hard guardrails:** any CTX of `type: memo` or `type: notes` that describes constraints. + +## Folder layout + +| Folder | Contents | +|---|---| +| `docs/context/` | Stakeholder inputs: PRDs, design docs, memos, research, notes. File prefix `CTX-`. | +| `docs/decisions/` | ADRs. File prefix `ADR-`. | +| `docs/tasks/` | Work units. File prefix `TP-`. Every task cites ≥1 ADR or CTX. | +| `docs/STATE.md` | Auto-generated state snapshot. Do not edit by hand. | +| `docs/.index.json` | Auto-generated graph with computed reverse edges. Do not cat it into your context — use `docops state` or read individual doc files instead. | + +## Invariants you MUST respect + +1. **Every task must cite at least one ADR or CTX** in its `requires:` frontmatter. The validator refuses tasks without it. +2. **References must resolve.** No citing non-existent docs; no citing superseded docs without good reason. +3. **Do not edit `docs/STATE.md` or `docs/.index.json`** — both are regenerated from source. +4. **Do not edit reverse-edge fields** in source frontmatter. They are computed in the index. +5. **Filename is the ID.** `ADR-0020-whatever.md` is `ADR-0020`. Don't add an `id:` field. + +## CLI commands — this is the query API + +### Shipped (all support `--json` for structured output) + +``` +docops init # scaffold DocOps in this repo +docops upgrade # refresh DocOps-owned scaffolding after a binary upgrade +docops update-check # check upstream for a newer docops release (cached probe) +docops validate # schema + graph invariants +docops index # rebuild docs/.index.json +docops state # regenerate docs/STATE.md +docops audit # structural coverage gaps +docops refresh # validate + index + state in one pass +docops new ctx "title" --type memo +docops new adr "title" [--related ADR-xxxx] +docops new task "title" --requires ADR-xxxx,CTX-xxx +docops schema # (re)write schema JSON from docops.yaml +``` + +### Not yet built — do not call these + +`status`, `get`, `list`, `graph`, `next`, `search`, `review` do not exist +yet. If your workflow needs one, propose a task first rather than inventing +flags or behaviour. + +**For now, instead of `list`/`get`/`search`:** read `docs/STATE.md` for +counts and needs-attention, or read individual `docs/{context,decisions,tasks}/` +files directly. Avoid loading `docs/.index.json` wholesale — it is for +bootstrap / CI consumers, not routine agent queries. See +`docs/decisions/ADR-0018-cli-as-query-layer.md` for the rationale. + +The binary is language-agnostic — install via direct download, Homebrew, Scoop, or Docker. No Node/Bun/Python dependency. + +## Recommended agent workflow + +1. Read `docs/STATE.md`. +2. Run `docops audit` to see open gaps. +3. Read `docs/tasks/` to pick a task (check `depends_on` before starting). +4. Before coding: read every doc in the task's `requires:` and `depends_on:`. +5. Work in your native plan/execute mode. DocOps does not prescribe how you code. +6. After finishing, update the task's frontmatter `status:` field and run `docops refresh` to regenerate index and STATE.md. +7. If your work revealed a new decision, `docops new adr`. If a new gap, `docops new task` with citations. + +## Editor integration + +JSON Schemas for frontmatter validation live in `docs/.docops/schema/` (`context.schema.json`, `decision.schema.json`, `task.schema.json`). Wire them up in VS Code via the `redhat.vscode-yaml` extension and run `docops schema` to regenerate after editing `context_types:` in `docops.yaml`. + +## Pairs well with + +- **GStack** role skills — they apply perspective; DocOps provides the typed state they read. +- **Native plan mode** of your IDE — DocOps explicitly does not try to replace it. + + + +## Notes for humans + +The block above is auto-maintained by `docops init` and `docops upgrade`. +Edit content outside the `` delimiters freely; DocOps will +preserve it. Edits inside the delimiters will be overwritten on the next +upgrade. The same docops block also appears in `AGENTS.md` (the multi-tool +sibling); both refresh from the same shipped template. diff --git a/templates/agents_claude_block_sync_test.go b/templates/agents_claude_block_sync_test.go new file mode 100644 index 0000000..0c56799 --- /dev/null +++ b/templates/agents_claude_block_sync_test.go @@ -0,0 +1,53 @@ +package templates + +import ( + "strings" + "testing" +) + +// extractDocopsBlock returns the content between the docops markers +// in the given template body. Inlined here to avoid importing +// internal/scaffold (which would create a templates → scaffold edge +// that doesn't exist anywhere else; templates is the leafmost package). +func extractDocopsBlock(body []byte) string { + const start = "" + const end = "" + s := string(body) + i := strings.Index(s, start) + if i < 0 { + return "" + } + i += len(start) + j := strings.Index(s, end) + if j < 0 || j < i { + return "" + } + return s[i:j] +} + +// TestAgentsClaudeBlocksInSync enforces ADR-0024: the docops block in +// AGENTS.md.tmpl and CLAUDE.md.tmpl must be byte-identical so users +// see the same invariants regardless of which file their tool reads. +// Drift in either template fails this test at build time. +func TestAgentsClaudeBlocksInSync(t *testing.T) { + agents, err := AgentsBlock() + if err != nil { + t.Fatalf("AgentsBlock: %v", err) + } + claude, err := ClaudeBlock() + if err != nil { + t.Fatalf("ClaudeBlock: %v", err) + } + + a := extractDocopsBlock(agents) + c := extractDocopsBlock(claude) + if a == "" { + t.Fatal("AGENTS.md.tmpl is missing the docops block markers") + } + if c == "" { + t.Fatal("CLAUDE.md.tmpl is missing the docops block markers") + } + if a != c { + t.Errorf("docops block in AGENTS.md.tmpl and CLAUDE.md.tmpl have drifted.\n--- AGENTS ---\n%s\n--- CLAUDE ---\n%s", a, c) + } +} diff --git a/templates/skills/docops/init.md b/templates/skills/docops/init.md index 091da61..dfa9518 100644 --- a/templates/skills/docops/init.md +++ b/templates/skills/docops/init.md @@ -1,6 +1,6 @@ --- name: init -description: Scaffold DocOps into a bare repository — creates docs/ folders, docops.yaml, JSON Schemas, an AGENTS.md block, and a pre-commit hook. Safe to run twice; use --dry-run to preview. +description: Scaffold DocOps into a bare repository — creates docs/ folders, docops.yaml, JSON Schemas, AGENTS.md and CLAUDE.md blocks, and a pre-commit hook. Safe to run twice; use --dry-run to preview. --- # /docops:init @@ -18,7 +18,7 @@ What it does: - Creates `docs/context/`, `docs/decisions/`, `docs/tasks/` if absent. - Writes `docops.yaml` at the repo root with sensible defaults. - Writes JSON Schemas to `docs/.docops/schema/` for in-editor validation. -- Writes or refreshes the `` block inside `AGENTS.md`. +- Writes or refreshes the `` block inside `AGENTS.md` and `CLAUDE.md` (both files share the same docops block; Claude Code reads CLAUDE.md by default while other agents read AGENTS.md). - Installs a language-agnostic pre-commit hook that runs `docops validate`. - Scaffolds `/docops:*` skills into `.claude/skills/docops/` and `.cursor/commands/docops/`. diff --git a/templates/skills/docops/upgrade.md b/templates/skills/docops/upgrade.md new file mode 100644 index 0000000..e75cda9 --- /dev/null +++ b/templates/skills/docops/upgrade.md @@ -0,0 +1,51 @@ +--- +name: upgrade +description: Refresh DocOps-owned scaffolding (skills, schemas, AGENTS.md block) in an existing project after `brew upgrade docops` (or equivalent). Preserves docops.yaml and the pre-commit hook by default. +--- + +# /docops:upgrade + +Run after the docops binary itself has been upgraded — it pulls the new +binary's shipped templates into the current project without clobbering +config. + +``` +docops upgrade +``` + +Touches only DocOps-owned scaffolding: + +- `.claude/skills/docops/*.md` and `.cursor/commands/docops/*.md` — synced to the shipped bundle (creates new files, refreshes changed ones, removes files that left the bundle). +- `docs/.docops/schema/*.schema.json` — regenerated from `docops.yaml`. +- The `` block inside `AGENTS.md` and `CLAUDE.md` — refreshed in place; content outside the markers is preserved. Either file is created if absent (so v0.1.x users gain CLAUDE.md on first upgrade). + +Leaves alone by default: + +- `docops.yaml` +- `.git/hooks/pre-commit` +- `docs/{context,decisions,tasks}/*.md` +- `docs/.index.json`, `docs/STATE.md`, `docs/.docops/counters.json` + +Preview before writing: + +``` +docops upgrade --dry-run +``` + +Opt in to overwriting customizable bits: + +``` +docops upgrade --config # also rewrite docops.yaml from the shipped template +docops upgrade --hook # also reinstall .git/hooks/pre-commit +``` + +For CI or scripted use, skip the prompt and emit structured output: + +``` +docops upgrade --yes +docops upgrade --json +``` + +Exit codes: `0` on success or user abort; `2` when there is no +`docops.yaml` (run `docops init` first) or when the `.claude/skills/docops/` +directory contains user-added files docops did not write. diff --git a/templates/skills_lint_test.go b/templates/skills_lint_test.go index d843593..b91cb18 100644 --- a/templates/skills_lint_test.go +++ b/templates/skills_lint_test.go @@ -20,14 +20,16 @@ import ( // knownSubcmds is the shipped CLI surface for v0.1.1. var knownSubcmds = map[string]bool{ - "init": true, - "validate": true, - "index": true, - "state": true, - "audit": true, - "new": true, - "schema": true, - "refresh": true, + "init": true, + "validate": true, + "index": true, + "state": true, + "audit": true, + "new": true, + "schema": true, + "refresh": true, + "update-check": true, + "upgrade": true, } // knownNewKinds are valid arguments for `docops new`. @@ -65,6 +67,18 @@ var flagAllowlist = map[string]map[string]bool{ "refresh": { "--json": true, }, + "upgrade": { + "--dry-run": true, + "--config": true, + "--hook": true, + "--yes": true, + "--json": true, + }, + "update-check": { + "--force": true, + "--snooze": true, + "--json": true, + }, // new ctx / new adr / new task share the "new" key; we resolve // per-kind flags under "new/" and fall back to "new". "new": { diff --git a/templates/templates.go b/templates/templates.go index d9e7c87..dd8e10c 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -13,7 +13,7 @@ import ( "strings" ) -//go:embed AGENTS.md.tmpl docops.yaml.tmpl hooks/pre-commit skills/docops +//go:embed AGENTS.md.tmpl CLAUDE.md.tmpl docops.yaml.tmpl hooks/pre-commit skills/docops var tree embed.FS // AgentsBlock returns the body of templates/AGENTS.md.tmpl. @@ -21,6 +21,14 @@ func AgentsBlock() ([]byte, error) { return tree.ReadFile("AGENTS.md.tmpl") } +// ClaudeBlock returns the body of templates/CLAUDE.md.tmpl. The +// docops block inside (between `` and +// ``) is byte-identical to AgentsBlock; only the +// preamble and footer differ. See ADR-0024. +func ClaudeBlock() ([]byte, error) { + return tree.ReadFile("CLAUDE.md.tmpl") +} + // DocopsYAML returns the body of templates/docops.yaml.tmpl. func DocopsYAML() ([]byte, error) { return tree.ReadFile("docops.yaml.tmpl")