Skip to content

refactor(cli): migrate from stdlib flag to spf13/cobra with sub-subcommands + shell completion #134

@laradji

Description

@laradji

Parent: none
Supersedes: #98's "CLI library: stdlib flag + os.Args dispatch. Cobra/urfave are out" decision (locked 2026-02-XX), whose own revisit clause ("revisit if sub-sub-commands or shell completion become load-bearing") has now fired
Depends on: none

Decision (locked 2026-04-17)

Replace the os.Args[1] switch + flag.NewFlagSet dispatch in cmd/deadzone/ with spf13/cobra. Each runX(args []string) error function becomes a *cobra.Command with RunE:. Sub-subcommands (packs upload|download|list) become child commands under a packs parent. Shell completion ships out-of-the-box via cobra's completion subcommand. -flag single-dash form gets replaced by POSIX --flag everywhere, with all callsites (justfile, GH workflows, tests, docs) updated in the same PR.

Framework choice: cobra over kong/urfave-cli. Rationale: de facto Go CLI standard (43.7k stars, used by kubectl, gh, hugo), best shell-completion ergonomics, zero surprises for contributors familiar with the Go ecosystem. The boilerplate cost is real but manageable at 6 top-level commands + 3 child commands.

Why

Three observations converged to trigger this migration:

  1. Sub-subcommand surface already existspacks upload|download|list are in the code (disabled per scraper/packs: folder-per-lib layout + retire per-artifact release, ship deadzone.db only #101 but structurally present). cli: consolidate the 4 binaries into a single deadzone with subcommands #98's "revisit if sub-sub-commands become load-bearing" trigger has fired
  2. -version / --version / version accepted but undocumented — dispatch accepts all three forms (main.go:79), the usage text lists none. Pure UX debt that a CLI framework would resolve for free
  3. Shell completion promised then deferred in cli: consolidate the 4 binaries into a single deadzone with subcommands #98's "Out of scope → Future issue". With cobra it's cobra.Command{}.GenBashCompletionFile(...) — the cost drops from "write a completion script by hand" to "enable a built-in"

Acceptance criteria

  • go.mod gains github.com/spf13/cobra (latest stable at impl time). github.com/spf13/pflag is cobra's transitive dep — do not add explicitly
  • cmd/deadzone/main.go — replace the dispatch(args []string) error switch with a cobra rootCmd that AddsCommand for each subcommand. main() becomes if err := rootCmd.Execute(); ...
  • Per-subcommand files (server.go, scrape.go, consolidate.go, dbrelease.go, fetchdb.go, packs.go) each export a Cmd() or package-level *cobra.Command variable. Internal logic stays in the same file — only the flag wiring layer changes
  • All flags migrate from flag.StringVar(&dbPath, "db", "", "...") to cmd.Flags().StringVar(&dbPath, "db", "", "..."). No short-form aliases added beyond what existed before (keep POSIX convention: --db long, -d only if deliberate)
  • packs parent command owns packs upload, packs download, packs list as cobra child commands — even though all three are DISABLED per scraper/packs: folder-per-lib layout + retire per-artifact release, ship deadzone.db only #101, they still surface in deadzone packs --help with their disabled-state error
  • Top-level -version behavior preserved via rootCmd.Version = buildinfo.Format(...). deadzone --version and deadzone version both work; undocumented -version single-dash form may break, document in the PR body
  • deadzone completion bash|zsh|fish|powershell works — cobra ships this automatically; verify it appears in deadzone --help output
  • All flag invocations in the codebase migrate from -flag to --flag — this is the single widest change:
    • justfile recipes: scrape lib=... → likely already kwargs, verify; any literal -flag in recipe bodies gets updated
    • .github/workflows/scrape-pack.ymlscrape -artifacts ./artifacts -lib <id>--artifacts ./artifacts --lib <id>
    • .github/workflows/ci.yml and .github/workflows/release.yml — audit for single-dash flag uses
    • cmd/deadzone/*_test.go — update test harnesses that exec the binary or build args lists
    • docs/research/*.md and README.md — any usage example with -flag form
  • CLAUDE.mdArchitectural context bullet on subcommand dispatch updates: "Routing via spf13/cobra. rootCmd in cmd/deadzone/main.go registers child commands declared in sibling files (one *cobra.Command per file). Revisit if completion / cobra.CompletionOptions needs become more exotic, not before."
  • CLAUDE.mdConventions bullet updates: "POSIX --flag form everywhere. Single-dash -flag no longer accepted (cobra/pflag semantics)."
  • README.md → Build-from-source section: all deadzone ... -flag examples updated to --flag
  • just test -short passes
  • Manual smoke: deadzone --help, deadzone completion bash | head, deadzone scrape --help, deadzone packs upload (returns the disabled error), deadzone --version, deadzone server --db /tmp/foo.db — all work as expected

Code skeleton (sketch — finalize in implementation)

// cmd/deadzone/main.go
package main

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
	"github.com/laradji/deadzone/internal/buildinfo"
)

var (
	version = "dev"
	commit  = "unknown"
	date    = "unknown"
)

var rootCmd = &cobra.Command{
	Use:     "deadzone",
	Short:   "Self-hosted MCP server for semantic doc search",
	Long:    `deadzone scrapes, indexes, and serves library documentation via MCP stdio.`,
	Version: buildinfo.Format("deadzone", version, commit, date),
	// Silence cobra's default error printing — main() owns exit codes.
	SilenceUsage:  true,
	SilenceErrors: true,
}

func init() {
	rootCmd.AddCommand(serverCmd, scrapeCmd, consolidateCmd, dbreleaseCmd, fetchDBCmd, packsCmd)
}

func main() {
	if err := rootCmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, "deadzone:", err)
		os.Exit(1)
	}
}
// cmd/deadzone/server.go (sketch)
var serverCmd = &cobra.Command{
	Use:   "server",
	Short: "Run the MCP stdio server against a deadzone.db",
	RunE: func(cmd *cobra.Command, args []string) error {
		return runServer() // existing logic, minus the flag.Parse wiring
	},
}

func init() {
	serverCmd.Flags().StringVar(&dbPath, "db", "", "path to turso database file (default: auto-resolve)")
	serverCmd.Flags().StringVar(&embedderKind, "embedder", "hugot", "embedder to use (valid: hugot)")
	serverCmd.Flags().BoolVar(&verbose, "verbose", false, "include the raw query text in per-call logs")
}
// cmd/deadzone/packs.go (sketch — parent + children)
var packsCmd = &cobra.Command{
	Use:   "packs",
	Short: "(disabled; see 'deadzone dbrelease')",
}

var packsUploadCmd = &cobra.Command{
	Use:   "upload",
	Short: "DISABLED per #101 — use 'deadzone dbrelease' to ship deadzone.db",
	RunE:  func(*cobra.Command, []string) error { return errPerArtifactDisabled },
}

// ... packsDownloadCmd, packsListCmd similar

func init() {
	packsCmd.AddCommand(packsUploadCmd, packsDownloadCmd, packsListCmd)
}

Concrete file pointers

Files to modify:

  • go.mod / go.sumgo get github.com/spf13/cobra@latest
  • cmd/deadzone/main.go — complete rewrite of dispatch (preserve buildinfo + exit-code behavior)
  • cmd/deadzone/server.go, scrape.go, consolidate.go, dbrelease.go, fetchdb.go, packs.go — replace flag.NewFlagSet with cobra flag wiring; keep the internal business logic functions
  • cmd/deadzone/*_test.go — update any test that exec'd the binary or built os.Args-style slices
  • justfile — audit/update any recipe body that uses -flag form
  • .github/workflows/scrape-pack.yml — update scrape -artifacts ... -lib ... to POSIX
  • .github/workflows/ci.yml, .github/workflows/release.yml — same audit
  • CLAUDE.md — two bullet updates per AC above
  • README.md — Build-from-source section usage examples
  • docs/research/ingestion-architecture.md, other research docs — audit for -flag form in shell examples

Files to read as reference — do NOT refactor:

Test commands (literal, for agent self-check)

  • mise exec -- go build -tags ORT ./... — builds clean
  • just test -short — full suite green (will catch any callsite not updated from -flag to --flag)
  • deadzone --help — full subcommand list with completion shown
  • deadzone completion bash > /tmp/deadzone.bash && head /tmp/deadzone.bash — valid bash completion script
  • deadzone version AND deadzone --version AND deadzone -v — all surface the buildinfo banner
  • deadzone packs upload — returns the disabled-error exit code 1 with a clear message
  • deadzone scrape --help — POSIX --flag form, no -flag stub (confirm it REJECTS -flag form cleanly now)
  • Re-dispatch scrape-pack.yml on a branch with this migration — the workflow's scrape step must still succeed end-to-end (validates the .github/workflows/scrape-pack.yml update)

Out of scope (fenced)

  • No new subcommands — migration only; no deadzone init, deadzone status, deadzone search even though they'd be trivial in cobra. File separately
  • No renaming of existing subcommandsserver, scrape, consolidate stay as-is
  • No introduction of viper for config — cobra can work with or without viper; stay without
  • No struct-based config objects — keep package-level vars per subcommand; cobra's Flags().XxxVar binding reads them the same way
  • No addition of shorthand flags beyond what currently exists (avoid API bloat for one-off short forms)
  • No documentation site generation via cobra.GenManTree — static man pages are their own issue
  • No cobra.OnInitialize magic for env-var loading — keep the existing os.Getenv reads inline in each runX function
  • No backward-compat -flag shim — POSIX --flag only. Pre-1.0, no user base
  • No changes to internal packagesinternal/{db,embed,scraper,packs,ort} untouched
  • No test-suite framework swap — keep existing stdlib testing

Open sub-decisions for the implementer

  1. Top-level flag placement: --verbose is currently duplicated across scrape and server. Cobra allows a PersistentFlags() on rootCmd that all children inherit. Consider hoisting OR keep per-subcommand. Implementer picks based on whether the semantics are truly identical (they might not be — server --verbose logs raw queries, scrape --verbose logs per-doc debug lines)
  2. Completion install path: cobra produces a script; whether to add a just install-completion recipe or leave operators to run deadzone completion bash > /etc/bash_completion.d/deadzone manually is an ergonomics call
  3. fetch-db vs fetch_db vs fetchdb: cobra accepts hyphens. Current name is fetch-db. Keep it

Related

Metadata

Metadata

Assignees

Labels

P2Normal — clear value, not urgentfeatureNew feature

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions