From 50c761a1a41fa2d2ceafd885423291871e30e3f9 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 4 Mar 2026 13:53:59 -0800 Subject: [PATCH 01/11] Initialize Go module and build infrastructure Module github.com/basecamp/cli with Go 1.24. Makefile with check (default), fmt-check, vet, and test targets. MAKEFILE-CONVENTION.md documents the standard target contract for all 37signals CLI repos. --- MAKEFILE-CONVENTION.md | 57 ++++++++++++++++++++++++++++++++++++++++++ Makefile | 28 +++++++++++++++++++++ go.mod | 21 ++++++++++++++++ go.sum | 34 +++++++++++++++++++++++++ 4 files changed, 140 insertions(+) create mode 100644 MAKEFILE-CONVENTION.md create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum diff --git a/MAKEFILE-CONVENTION.md b/MAKEFILE-CONVENTION.md new file mode 100644 index 0000000..f654121 --- /dev/null +++ b/MAKEFILE-CONVENTION.md @@ -0,0 +1,57 @@ +# Makefile Convention + +Standard Makefile targets for 37signals Go CLIs and libraries. The seed template (`seed/Makefile`) is the canonical implementation. The `rubric-check` action verifies conformance. + +## Targets + +### Required (all repos) + +| Target | Default? | Contract | +|--------|----------|----------| +| `check` | **Yes** | Fast checks for inner-loop dev. Must be the first target so bare `make` runs it. | +| `test` | | `go test ./...` — unit tests only, no network, no external deps. | +| `vet` | | `go vet ./...` — static analysis. | +| `fmt` | | `gofmt -w .` — fix formatting in place. | +| `fmt-check` | | Fail with exit 1 if any file needs formatting. Print offending files. | + +### Required (CLI repos with a binary) + +| Target | Contract | +|--------|----------| +| `build` | `go build -o ./bin/BINARY ./cmd/BINARY` — deterministic output path. | +| `test-e2e` | `bats e2e/` — end-to-end integration tests. | +| `clean` | `rm -rf ./bin` — remove build artifacts. | + +### Optional (recommended) + +| Target | Contract | +|--------|----------| +| `lint` | `golangci-lint run` — requires golangci-lint installed. | +| `test-race` | `go test -race ./...` — tests with race detector. | +| `bench` | `go test -bench=. -benchmem ./...` — benchmarks. | +| `bench-cpu` | Benchmarks with CPU profile output. | +| `bench-mem` | Benchmarks with memory profile output. | +| `check-all` | Full CI suite: fmt-check + vet + lint + test-race + test-e2e + bench. | + +## Composition Rules + +- **`check`** must be the **first target** (Make's default). Bare `make` should always work. +- **`check`** is fast. It runs what you'd run before every commit: `fmt-check vet test` (libraries) or `fmt-check vet test test-e2e` (CLIs). +- **`check-all`** is thorough. It runs what CI runs: adds lint, race detection, and benchmarks. Slower, but catches more. +- **`lint`** is separate from `check` because golangci-lint is an external install. `check` should work with just Go. +- Targets must not swallow errors. Every command should fail the target on non-zero exit. + +## Variables + +CLI Makefiles should define at the top: + +```makefile +BINARY_NAME := $(shell basename $(CURDIR)) +BUILD_DIR := ./bin +``` + +This derives the binary name from the directory (e.g., `basecamp-cli/` → `basecamp-cli`). Override `BINARY_NAME` if the binary should differ from the directory name. + +## Library repos + +Library repos (like `basecamp/cli` itself) omit `build`, `test-e2e`, and `clean` since there's no binary. Their `check` is: `fmt-check vet test`. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cf825ff --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.PHONY: check test test-race vet lint fmt fmt-check bench check-all + +# Default target: fast checks for inner-loop dev. +check: fmt-check vet test + +test: + go test ./... + +test-race: + go test -race ./... + +vet: + go vet ./... + +lint: + golangci-lint run + +fmt: + gofmt -w . + +fmt-check: + @test -z "$$(gofmt -l .)" || (echo "Run 'make fmt' to fix formatting" && gofmt -l . && exit 1) + +bench: + go test -bench=. -benchmem ./... + +# Full suite: everything CI runs. +check-all: fmt-check vet lint test-race bench diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c2a1dee --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/basecamp/cli + +go 1.24 + +require ( + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 + github.com/zalando/go-keyring v0.2.6 +) + +require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.26.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..83d6806 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 99e5b775e4af3e695eb1fb6af367560f86fef553 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 4 Mar 2026 13:54:08 -0800 Subject: [PATCH 02/11] Add output package: envelopes, exit codes, errors, and format dispatch The agent contract in type form. A CLI that imports output and uses Writer.OK() / Writer.Err() automatically complies with Tier 1A and 1B of the rubric. - Response / ErrorResponse envelope types - Format enum with TTY auto-detection (FormatAuto) - 8 typed exit codes (0=OK through 8=Ambiguous) - CLIError with Code, Message, Hint, Retryable, HTTPStatus - Typed constructors: ErrUsage, ErrNotFound, ErrAuth, etc. - NormalizeData with json.Number preservation for large IDs - TruncationNotice helpers for paginated responses - Breadcrumb type for agent chaining --- output/codes.go | 51 +++ output/envelope.go | 300 +++++++++++++++ output/errors.go | 142 +++++++ output/normalize.go | 69 ++++ output/output_test.go | 851 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1413 insertions(+) create mode 100644 output/codes.go create mode 100644 output/envelope.go create mode 100644 output/errors.go create mode 100644 output/normalize.go create mode 100644 output/output_test.go diff --git a/output/codes.go b/output/codes.go new file mode 100644 index 0000000..11d9cad --- /dev/null +++ b/output/codes.go @@ -0,0 +1,51 @@ +// Package output provides JSON/Markdown output formatting and error handling. +package output + +// Exit codes matching the Bash implementation. +const ( + ExitOK = 0 // Success + ExitUsage = 1 // Invalid arguments or flags + ExitNotFound = 2 // Resource not found + ExitAuth = 3 // Not authenticated + ExitForbidden = 4 // Access denied (scope issue) + ExitRateLimit = 5 // Rate limited (429) + ExitNetwork = 6 // Connection/DNS/timeout error + ExitAPI = 7 // Server returned error + ExitAmbiguous = 8 // Multiple matches for name +) + +// Error codes for JSON envelope. +const ( + CodeUsage = "usage" + CodeNotFound = "not_found" + CodeAuth = "auth_required" + CodeForbidden = "forbidden" + CodeRateLimit = "rate_limit" + CodeNetwork = "network" + CodeAPI = "api_error" + CodeAmbiguous = "ambiguous" +) + +// ExitCodeFor returns the exit code for a given error code. +func ExitCodeFor(code string) int { + switch code { + case CodeUsage: + return ExitUsage + case CodeNotFound: + return ExitNotFound + case CodeAuth: + return ExitAuth + case CodeForbidden: + return ExitForbidden + case CodeRateLimit: + return ExitRateLimit + case CodeNetwork: + return ExitNetwork + case CodeAPI: + return ExitAPI + case CodeAmbiguous: + return ExitAmbiguous + default: + return ExitAPI + } +} diff --git a/output/envelope.go b/output/envelope.go new file mode 100644 index 0000000..805177e --- /dev/null +++ b/output/envelope.go @@ -0,0 +1,300 @@ +package output + +import ( + "encoding/json" + "fmt" + "io" + "os" +) + +// Response is the success envelope for JSON output. +type Response struct { + OK bool `json:"ok"` + Data any `json:"data,omitempty"` + Summary string `json:"summary,omitempty"` + Notice string `json:"notice,omitempty"` + Breadcrumbs []Breadcrumb `json:"breadcrumbs,omitempty"` + Context map[string]any `json:"context,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +// Breadcrumb is a suggested follow-up action. +type Breadcrumb struct { + Action string `json:"action"` + Cmd string `json:"cmd"` + Description string `json:"description"` +} + +// ErrorResponse is the error envelope for JSON output. +type ErrorResponse struct { + OK bool `json:"ok"` + Error string `json:"error"` + Code string `json:"code"` + Hint string `json:"hint,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +// Format specifies the output format. +type Format int + +const ( + FormatAuto Format = iota // Auto-detect: TTY -> Styled, non-TTY -> JSON + FormatJSON // JSON envelope + FormatMarkdown // Literal Markdown syntax + FormatStyled // ANSI styled output + FormatQuiet // Raw JSON data, no envelope + FormatIDs // One ID per line + FormatCount // Integer count only +) + +// Options controls output behavior. +type Options struct { + Format Format + Writer io.Writer + Verbose bool +} + +// DefaultOptions returns options for standard output. +func DefaultOptions() Options { + return Options{ + Format: FormatAuto, + Writer: os.Stdout, + } +} + +// Writer handles all output formatting. +type Writer struct { + opts Options +} + +// New creates a new output writer. +func New(opts Options) *Writer { + if opts.Writer == nil { + opts.Writer = os.Stdout + } + return &Writer{opts: opts} +} + +// EffectiveFormat resolves FormatAuto based on TTY detection. +func (w *Writer) EffectiveFormat() Format { + if w.opts.Format == FormatAuto { + if isTTY(w.opts.Writer) { + return FormatStyled + } + return FormatJSON + } + return w.opts.Format +} + +// OK outputs a success response. +func (w *Writer) OK(data any, opts ...ResponseOption) error { + resp := &Response{OK: true, Data: data} + for _, opt := range opts { + opt(resp) + } + return w.write(resp) +} + +// Err outputs an error response. +func (w *Writer) Err(err error, opts ...ErrorResponseOption) error { + e := AsError(err) + resp := &ErrorResponse{ + OK: false, + Error: e.Message, + Code: e.Code, + Hint: e.Hint, + } + for _, opt := range opts { + opt(resp) + } + return w.write(resp) +} + +// ResponseOption modifies a Response. +type ResponseOption func(*Response) + +// ErrorResponseOption modifies an ErrorResponse. +type ErrorResponseOption func(*ErrorResponse) + +// WithSummary adds a summary to the response. +func WithSummary(s string) ResponseOption { + return func(r *Response) { r.Summary = s } +} + +// WithNotice adds an informational notice to the response. +func WithNotice(s string) ResponseOption { + return func(r *Response) { r.Notice = s } +} + +// TruncationNotice returns a notice string if results may be truncated. +// Returns empty string if no truncation warning is needed. +func TruncationNotice(count, defaultLimit int, all bool, explicitLimit int) string { + if all { + return "" + } + + limit := defaultLimit + if explicitLimit > 0 { + limit = explicitLimit + } + + if limit == 0 { + return "" + } + + if count > 0 && count >= limit { + return fmt.Sprintf("Showing %d results (use --all for complete list)", count) + } + + return "" +} + +// TruncationNoticeWithTotal returns a truncation notice when results are truncated. +// Uses totalCount from API's X-Total-Count header to show accurate counts. +// Returns empty string if no truncation or totalCount is 0 (unavailable). +func TruncationNoticeWithTotal(count, totalCount int) string { + if totalCount == 0 || count >= totalCount { + return "" + } + + return fmt.Sprintf("Showing %d of %d results (use --all for complete list)", count, totalCount) +} + +// WithBreadcrumbs adds breadcrumbs to the response. +func WithBreadcrumbs(b ...Breadcrumb) ResponseOption { + return func(r *Response) { r.Breadcrumbs = append(r.Breadcrumbs, b...) } +} + +// WithoutBreadcrumbs removes all breadcrumbs from the response. +func WithoutBreadcrumbs() ResponseOption { + return func(r *Response) { r.Breadcrumbs = nil } +} + +// WithContext adds context to the response. +func WithContext(key string, value any) ResponseOption { + return func(r *Response) { + if r.Context == nil { + r.Context = make(map[string]any) + } + r.Context[key] = value + } +} + +// WithMeta adds metadata to the response. +func WithMeta(key string, value any) ResponseOption { + return func(r *Response) { + if r.Meta == nil { + r.Meta = make(map[string]any) + } + r.Meta[key] = value + } +} + +func (w *Writer) write(v any) error { + format := w.opts.Format + + if format == FormatAuto { + if isTTY(w.opts.Writer) { + format = FormatStyled + } else { + format = FormatJSON + } + } + + switch format { + case FormatQuiet: + if resp, ok := v.(*Response); ok { + return w.writeQuiet(resp.Data) + } + return w.writeQuiet(v) + case FormatIDs: + return w.writeIDs(v) + case FormatCount: + return w.writeCount(v) + case FormatStyled, FormatMarkdown: + // The shared package doesn't do rendering -- apps provide that. + // Fall back to JSON. + return w.writeJSON(v) + default: + return w.writeJSON(v) + } +} + +// isTTY checks if the writer is a terminal. +func isTTY(w io.Writer) bool { + if f, ok := w.(*os.File); ok { + fi, err := f.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 + } + return false +} + +func (w *Writer) writeJSON(v any) error { + toEncode := v + if resp, ok := v.(*Response); ok { + respCopy := *resp + respCopy.Data = NormalizeData(resp.Data) + toEncode = &respCopy + } + enc := json.NewEncoder(w.opts.Writer) + enc.SetIndent("", " ") + return enc.Encode(toEncode) +} + +func (w *Writer) writeQuiet(v any) error { + return w.writeJSON(NormalizeData(v)) +} + +func (w *Writer) writeIDs(v any) error { + resp, ok := v.(*Response) + if !ok { + return w.writeJSON(v) + } + + data := NormalizeData(resp.Data) + + switch d := data.(type) { + case []map[string]any: + for _, item := range d { + if id, ok := item["id"]; ok { + if _, err := fmt.Fprintln(w.opts.Writer, id); err != nil { + return err + } + } + } + case map[string]any: + if id, ok := d["id"]; ok { + if _, err := fmt.Fprintln(w.opts.Writer, id); err != nil { + return err + } + } + } + return nil +} + +func (w *Writer) writeCount(v any) error { + resp, ok := v.(*Response) + if !ok { + return w.writeJSON(v) + } + + data := NormalizeData(resp.Data) + + switch d := data.(type) { + case nil: + _, err := fmt.Fprintln(w.opts.Writer, 0) + return err + case []any: + _, err := fmt.Fprintln(w.opts.Writer, len(d)) + return err + case []map[string]any: + _, err := fmt.Fprintln(w.opts.Writer, len(d)) + return err + default: + _, err := fmt.Fprintln(w.opts.Writer, 1) + return err + } +} diff --git a/output/errors.go b/output/errors.go new file mode 100644 index 0000000..e1ae724 --- /dev/null +++ b/output/errors.go @@ -0,0 +1,142 @@ +package output + +import ( + "errors" + "fmt" +) + +// Error is a structured error with code, message, and optional hint. +type Error struct { + Code string + Message string + Hint string + HTTPStatus int + Retryable bool + Cause error +} + +func (e *Error) Error() string { + if e.Hint != "" { + return fmt.Sprintf("%s: %s", e.Message, e.Hint) + } + return e.Message +} + +func (e *Error) Unwrap() error { + return e.Cause +} + +// ExitCode returns the appropriate exit code for this error. +func (e *Error) ExitCode() int { + return ExitCodeFor(e.Code) +} + +// Error constructors for common cases. + +func ErrUsage(msg string) *Error { + return &Error{Code: CodeUsage, Message: msg} +} + +func ErrUsageHint(msg, hint string) *Error { + return &Error{Code: CodeUsage, Message: msg, Hint: hint} +} + +func ErrNotFound(resource, identifier string) *Error { + return &Error{ + Code: CodeNotFound, + Message: fmt.Sprintf("%s not found: %s", resource, identifier), + } +} + +func ErrNotFoundHint(resource, identifier, hint string) *Error { + return &Error{ + Code: CodeNotFound, + Message: fmt.Sprintf("%s not found: %s", resource, identifier), + Hint: hint, + } +} + +func ErrAuth(msg string) *Error { + return &Error{ + Code: CodeAuth, + Message: msg, + Hint: "Not authenticated. Run your CLI's auth login command.", + } +} + +func ErrForbidden(msg string) *Error { + return &Error{ + Code: CodeForbidden, + Message: msg, + HTTPStatus: 403, + } +} + +func ErrForbiddenScope() *Error { + return &Error{ + Code: CodeForbidden, + Message: "Access denied: insufficient scope", + Hint: "Access denied: insufficient scope. Re-authenticate with broader permissions.", + HTTPStatus: 403, + } +} + +func ErrRateLimit(retryAfter int) *Error { + hint := "Try again later" + if retryAfter > 0 { + hint = fmt.Sprintf("Try again in %d seconds", retryAfter) + } + return &Error{ + Code: CodeRateLimit, + Message: "Rate limited", + Hint: hint, + HTTPStatus: 429, + Retryable: true, + } +} + +func ErrNetwork(cause error) *Error { + return &Error{ + Code: CodeNetwork, + Message: "Network error", + Hint: cause.Error(), + Retryable: true, + Cause: cause, + } +} + +func ErrAPI(status int, msg string) *Error { + return &Error{ + Code: CodeAPI, + Message: msg, + HTTPStatus: status, + } +} + +func ErrAmbiguous(resource string, matches []string) *Error { + hint := "Be more specific" + if len(matches) > 0 && len(matches) <= 5 { + hint = fmt.Sprintf("Did you mean: %v", matches) + } + return &Error{ + Code: CodeAmbiguous, + Message: fmt.Sprintf("Ambiguous %s", resource), + Hint: hint, + } +} + +// AsError attempts to convert an error to an *Error. +func AsError(err error) *Error { + if err == nil { + return &Error{Code: CodeAPI, Message: "unknown error"} + } + var e *Error + if errors.As(err, &e) { + return e + } + return &Error{ + Code: CodeAPI, + Message: err.Error(), + Cause: err, + } +} diff --git a/output/normalize.go b/output/normalize.go new file mode 100644 index 0000000..6b101a5 --- /dev/null +++ b/output/normalize.go @@ -0,0 +1,69 @@ +package output + +import ( + "bytes" + "encoding/json" +) + +// NormalizeData converts json.RawMessage and other types to standard Go types. +func NormalizeData(data any) any { + // Handle json.RawMessage by unmarshaling it + if raw, ok := data.(json.RawMessage); ok { + var unmarshaled any + if err := unmarshalPreservingNumbers(raw, &unmarshaled); err == nil { + return normalizeUnmarshaled(unmarshaled) + } + return data + } + + // Handle typed structs/slices by marshaling then unmarshaling + // This converts struct types to map[string]any + switch data.(type) { + case []map[string]any, map[string]any, []any: + return data // Already normalized + case nil: + return data + default: + // Try to convert via JSON round-trip + b, err := json.Marshal(data) + if err != nil { + return data + } + var unmarshaled any + if err := unmarshalPreservingNumbers(b, &unmarshaled); err != nil { + return data + } + return normalizeUnmarshaled(unmarshaled) + } +} + +// unmarshalPreservingNumbers decodes JSON using UseNumber so numeric values +// remain as json.Number instead of being converted to float64. This preserves +// precision for large integer IDs that exceed 53-bit float64 range. +func unmarshalPreservingNumbers(data []byte, v any) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + return dec.Decode(v) +} + +// normalizeUnmarshaled converts []any to []map[string]any if all elements are maps. +func normalizeUnmarshaled(v any) any { + switch d := v.(type) { + case []any: + // Check if all elements are maps, convert to []map[string]any + if len(d) == 0 { + return []map[string]any{} + } + maps := make([]map[string]any, 0, len(d)) + for _, item := range d { + if m, ok := item.(map[string]any); ok { + maps = append(maps, m) + } else { + return v // Mixed types, return as-is + } + } + return maps + default: + return v + } +} diff --git a/output/output_test.go b/output/output_test.go new file mode 100644 index 0000000..2d8f027 --- /dev/null +++ b/output/output_test.go @@ -0,0 +1,851 @@ +package output + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================= +// Exit Codes Tests +// ============================================================================= + +func TestExitCodeFor(t *testing.T) { + tests := []struct { + code string + expected int + }{ + {CodeUsage, ExitUsage}, + {CodeNotFound, ExitNotFound}, + {CodeAuth, ExitAuth}, + {CodeForbidden, ExitForbidden}, + {CodeRateLimit, ExitRateLimit}, + {CodeNetwork, ExitNetwork}, + {CodeAPI, ExitAPI}, + {CodeAmbiguous, ExitAmbiguous}, + {"unknown_code", ExitAPI}, // Unknown codes default to ExitAPI + {"", ExitAPI}, // Empty code defaults to ExitAPI + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + result := ExitCodeFor(tt.code) + assert.Equal(t, tt.expected, result, "ExitCodeFor(%q)", tt.code) + }) + } +} + +func TestExitCodeConstants(t *testing.T) { + expected := map[int]int{ + ExitOK: 0, + ExitUsage: 1, + ExitNotFound: 2, + ExitAuth: 3, + ExitForbidden: 4, + ExitRateLimit: 5, + ExitNetwork: 6, + ExitAPI: 7, + ExitAmbiguous: 8, + } + + for code, value := range expected { + assert.Equal(t, value, code, "Exit code constant mismatch") + } +} + +func TestErrorCodeConstants(t *testing.T) { + codes := []string{ + CodeUsage, + CodeNotFound, + CodeAuth, + CodeForbidden, + CodeRateLimit, + CodeNetwork, + CodeAPI, + CodeAmbiguous, + } + + for _, code := range codes { + assert.NotEmpty(t, code, "Error code should not be empty") + } +} + +// ============================================================================= +// Error Struct Tests +// ============================================================================= + +func TestErrorInterface(t *testing.T) { + errWithHint := &Error{ + Code: CodeNotFound, + Message: "resource not found", + Hint: "check the ID", + } + assert.Equal(t, "resource not found: check the ID", errWithHint.Error()) + + errNoHint := &Error{ + Code: CodeNotFound, + Message: "resource not found", + } + assert.Equal(t, "resource not found", errNoHint.Error()) +} + +func TestErrorUnwrap(t *testing.T) { + cause := errors.New("underlying error") + err := &Error{ + Code: CodeAPI, + Message: "api error", + Cause: cause, + } + + unwrapped := err.Unwrap() + assert.Equal(t, cause, unwrapped) //nolint:errorlint // testing Unwrap returns exact wrapped error +} + +func TestErrorUnwrapNil(t *testing.T) { + err := &Error{ + Code: CodeAPI, + Message: "api error", + } + + assert.Nil(t, err.Unwrap(), "Unwrap() should return nil when Cause is nil") +} + +func TestErrorExitCode(t *testing.T) { + tests := []struct { + code string + expected int + }{ + {CodeUsage, ExitUsage}, + {CodeNotFound, ExitNotFound}, + {CodeAuth, ExitAuth}, + {CodeForbidden, ExitForbidden}, + {CodeRateLimit, ExitRateLimit}, + {CodeNetwork, ExitNetwork}, + {CodeAPI, ExitAPI}, + {CodeAmbiguous, ExitAmbiguous}, + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + err := &Error{Code: tt.code, Message: "test"} + assert.Equal(t, tt.expected, err.ExitCode()) + }) + } +} + +// ============================================================================= +// Error Constructors Tests +// ============================================================================= + +func TestErrUsage(t *testing.T) { + err := ErrUsage("invalid argument") + + assert.Equal(t, CodeUsage, err.Code) + assert.Equal(t, "invalid argument", err.Message) + assert.Equal(t, ExitUsage, err.ExitCode()) +} + +func TestErrUsageHint(t *testing.T) { + err := ErrUsageHint("invalid argument", "try --help") + + assert.Equal(t, CodeUsage, err.Code) + assert.Equal(t, "invalid argument", err.Message) + assert.Equal(t, "try --help", err.Hint) +} + +func TestErrNotFound(t *testing.T) { + err := ErrNotFound("project", "123") + + assert.Equal(t, CodeNotFound, err.Code) + assert.Equal(t, "project not found: 123", err.Message) + assert.Equal(t, ExitNotFound, err.ExitCode()) +} + +func TestErrNotFoundHint(t *testing.T) { + err := ErrNotFoundHint("project", "123", "check project ID") + + assert.Equal(t, CodeNotFound, err.Code) + assert.Equal(t, "check project ID", err.Hint) +} + +func TestErrAuth(t *testing.T) { + err := ErrAuth("not authenticated") + + assert.Equal(t, CodeAuth, err.Code) + assert.NotEmpty(t, err.Hint, "Hint should contain login instruction") + assert.Equal(t, ExitAuth, err.ExitCode()) +} + +func TestErrForbidden(t *testing.T) { + err := ErrForbidden("access denied") + + assert.Equal(t, CodeForbidden, err.Code) + assert.Equal(t, 403, err.HTTPStatus) + assert.Equal(t, ExitForbidden, err.ExitCode()) +} + +func TestErrForbiddenScope(t *testing.T) { + err := ErrForbiddenScope() + + assert.Equal(t, CodeForbidden, err.Code) + assert.Equal(t, 403, err.HTTPStatus) + assert.NotEmpty(t, err.Hint, "Hint should not be empty for scope error") +} + +func TestErrRateLimit(t *testing.T) { + err := ErrRateLimit(60) + + assert.Equal(t, CodeRateLimit, err.Code) + assert.Equal(t, 429, err.HTTPStatus) + assert.True(t, err.Retryable, "RateLimit error should be retryable") + assert.NotEmpty(t, err.Hint, "Hint should contain retry time") + assert.Equal(t, ExitRateLimit, err.ExitCode()) +} + +func TestErrRateLimitZero(t *testing.T) { + err := ErrRateLimit(0) + + assert.Equal(t, "Try again later", err.Hint) +} + +func TestErrNetwork(t *testing.T) { + cause := errors.New("connection refused") + err := ErrNetwork(cause) + + assert.Equal(t, CodeNetwork, err.Code) + assert.True(t, err.Retryable, "Network error should be retryable") + assert.Equal(t, cause, err.Cause) //nolint:errorlint // testing Cause field is exact wrapped error + assert.Equal(t, "connection refused", err.Hint) + assert.Equal(t, ExitNetwork, err.ExitCode()) +} + +func TestErrAPI(t *testing.T) { + err := ErrAPI(500, "server error") + + assert.Equal(t, CodeAPI, err.Code) + assert.Equal(t, 500, err.HTTPStatus) + assert.Equal(t, "server error", err.Message) + assert.Equal(t, ExitAPI, err.ExitCode()) +} + +func TestErrAmbiguous(t *testing.T) { + matches := []string{"Project A", "Project B", "Project Alpha"} + err := ErrAmbiguous("multiple matches", matches) + + assert.Equal(t, CodeAmbiguous, err.Code) + assert.NotEmpty(t, err.Hint, "Hint should contain matches") + assert.Equal(t, ExitAmbiguous, err.ExitCode()) +} + +// ============================================================================= +// AsError Tests +// ============================================================================= + +func TestAsErrorWithOutputError(t *testing.T) { + original := &Error{ + Code: CodeNotFound, + Message: "not found", + Hint: "try again", + } + + result := AsError(original) + assert.Equal(t, original, result, "AsError should return same *Error unchanged") +} + +func TestAsErrorWithStandardError(t *testing.T) { + original := errors.New("something went wrong") + + result := AsError(original) + assert.Equal(t, CodeAPI, result.Code) + assert.Equal(t, "something went wrong", result.Message) + assert.Equal(t, original, result.Cause) //nolint:errorlint // testing Cause field is exact original error +} + +func TestAsErrorWithWrappedOutputError(t *testing.T) { + original := &Error{ + Code: CodeAuth, + Message: "auth required", + } + wrapped := errors.Join(errors.New("wrapper"), original) + + result := AsError(wrapped) + assert.Equal(t, CodeAuth, result.Code) +} + +// ============================================================================= +// Envelope/Response Tests +// ============================================================================= + +func TestResponseJSON(t *testing.T) { + resp := &Response{ + OK: true, + Data: map[string]string{"name": "Test Project"}, + Summary: "Found 1 project", + } + + data, err := json.Marshal(resp) + require.NoError(t, err, "Failed to marshal") + + var decoded map[string]any + require.NoError(t, json.Unmarshal(data, &decoded), "Failed to unmarshal") + + assert.Equal(t, true, decoded["ok"]) + assert.Equal(t, "Found 1 project", decoded["summary"]) +} + +func TestErrorResponseJSON(t *testing.T) { + resp := &ErrorResponse{ + OK: false, + Error: "not found", + Code: CodeNotFound, + Hint: "check the ID", + } + + data, err := json.Marshal(resp) + require.NoError(t, err, "Failed to marshal") + + var decoded map[string]any + require.NoError(t, json.Unmarshal(data, &decoded), "Failed to unmarshal") + + assert.Equal(t, false, decoded["ok"]) + assert.Equal(t, "not found", decoded["error"]) + assert.Equal(t, CodeNotFound, decoded["code"]) +} + +func TestBreadcrumb(t *testing.T) { + bc := Breadcrumb{ + Action: "show", + Cmd: "mycli projects show 123", + Description: "View project details", + } + + data, err := json.Marshal(bc) + require.NoError(t, err, "Failed to marshal") + + var decoded map[string]string + require.NoError(t, json.Unmarshal(data, &decoded), "Failed to unmarshal") + + assert.Equal(t, "show", decoded["action"]) + assert.Equal(t, "mycli projects show 123", decoded["cmd"]) +} + +// ============================================================================= +// Writer Tests +// ============================================================================= + +func TestWriterOK(t *testing.T) { + var buf bytes.Buffer + w := New(Options{ + Format: FormatJSON, + Writer: &buf, + }) + + data := map[string]string{"id": "123", "name": "Test"} + err := w.OK(data, WithSummary("test summary")) + require.NoError(t, err, "OK() failed") + + var resp Response + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp), "Failed to unmarshal output") + + assert.True(t, resp.OK) + assert.Equal(t, "test summary", resp.Summary) +} + +func TestWriterErr(t *testing.T) { + var buf bytes.Buffer + w := New(Options{ + Format: FormatJSON, + Writer: &buf, + }) + + err := w.Err(ErrNotFound("project", "123")) + require.NoError(t, err, "Err() failed") + + var resp ErrorResponse + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp), "Failed to unmarshal output") + + assert.False(t, resp.OK) + assert.Equal(t, CodeNotFound, resp.Code) +} + +func TestWriterQuietFormat(t *testing.T) { + var buf bytes.Buffer + w := New(Options{ + Format: FormatQuiet, + Writer: &buf, + }) + + data := map[string]string{"id": "123", "name": "Test"} + err := w.OK(data, WithSummary("ignored")) + require.NoError(t, err, "OK() failed") + + var decoded map[string]string + require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded), "Failed to unmarshal output") + + assert.Equal(t, "123", decoded["id"]) + _, exists := decoded["ok"] + assert.False(t, exists, "Quiet format should not include envelope ok field") +} + +func TestWriterQuietFormatString(t *testing.T) { + var buf bytes.Buffer + w := New(Options{ + Format: FormatQuiet, + Writer: &buf, + }) + + err := w.OK("my-auth-token-value") + require.NoError(t, err, "OK() failed") + + output := buf.String() + assert.Equal(t, "\"my-auth-token-value\"\n", output) +} + +func TestWriterIDsFormat(t *testing.T) { + var buf bytes.Buffer + w := New(Options{ + Format: FormatIDs, + Writer: &buf, + }) + + data := []map[string]any{ + {"id": 123, "name": "Project A"}, + {"id": 456, "name": "Project B"}, + } + err := w.OK(data) + require.NoError(t, err, "OK() failed") + + output := buf.String() + assert.Equal(t, "123\n456\n", output) +} + +func TestWriterIDsFormatWithSingleItem(t *testing.T) { + var buf bytes.Buffer + w := New(Options{ + Format: FormatIDs, + Writer: &buf, + }) + + data := map[string]any{"id": 999, "name": "Single"} + err := w.OK(data) + require.NoError(t, err, "OK() failed") + + output := buf.String() + assert.Equal(t, "999\n", output) +} + +func TestWriterIDsFormatWithNoID(t *testing.T) { + var buf bytes.Buffer + w := New(Options{ + Format: FormatIDs, + Writer: &buf, + }) + + data := []map[string]any{ + {"name": "No ID"}, + } + err := w.OK(data) + require.NoError(t, err, "OK() failed") + + output := buf.String() + assert.Equal(t, "", output) +} + +func TestWriterCountFormat(t *testing.T) { + var buf bytes.Buffer + w := New(Options{ + Format: FormatCount, + Writer: &buf, + }) + + data := []map[string]any{ + {"id": 1}, + {"id": 2}, + {"id": 3}, + } + err := w.OK(data) + require.NoError(t, err, "OK() failed") + + output := buf.String() + assert.Equal(t, "3\n", output) +} + +func TestWriterCountFormatSingleItem(t *testing.T) { + var buf bytes.Buffer + w := New(Options{ + Format: FormatCount, + Writer: &buf, + }) + + data := map[string]any{"id": 1, "name": "Single"} + err := w.OK(data) + require.NoError(t, err, "OK() failed") + + output := buf.String() + assert.Equal(t, "1\n", output) +} + +func TestDefaultOptions(t *testing.T) { + opts := DefaultOptions() + + assert.Equal(t, FormatAuto, opts.Format) + assert.NotNil(t, opts.Writer, "Default Writer should not be nil") +} + +func TestNewWithNilWriter(t *testing.T) { + w := New(Options{ + Format: FormatJSON, + Writer: nil, + }) + + assert.NotNil(t, w.opts.Writer, "Writer should default to os.Stdout, not nil") +} + +// ============================================================================= +// Response Options Tests +// ============================================================================= + +func TestWithSummary(t *testing.T) { + resp := &Response{} + WithSummary("test summary")(resp) + + assert.Equal(t, "test summary", resp.Summary) +} + +func TestWithNotice(t *testing.T) { + resp := &Response{} + WithNotice("truncated")(resp) + + assert.Equal(t, "truncated", resp.Notice) +} + +func TestWithBreadcrumbs(t *testing.T) { + resp := &Response{} + bc1 := Breadcrumb{Action: "list", Cmd: "mycli list", Description: "List items"} + bc2 := Breadcrumb{Action: "show", Cmd: "mycli show 1", Description: "Show item"} + + WithBreadcrumbs(bc1, bc2)(resp) + + require.Len(t, resp.Breadcrumbs, 2) + assert.Equal(t, "list", resp.Breadcrumbs[0].Action) +} + +func TestWithBreadcrumbsAppend(t *testing.T) { + resp := &Response{ + Breadcrumbs: []Breadcrumb{{Action: "initial"}}, + } + bc := Breadcrumb{Action: "added"} + + WithBreadcrumbs(bc)(resp) + + assert.Len(t, resp.Breadcrumbs, 2) +} + +func TestWithoutBreadcrumbs(t *testing.T) { + resp := &Response{ + Breadcrumbs: []Breadcrumb{{Action: "existing"}}, + } + + WithoutBreadcrumbs()(resp) + + assert.Nil(t, resp.Breadcrumbs) +} + +func TestWithContext(t *testing.T) { + resp := &Response{} + + WithContext("project_id", 123)(resp) + WithContext("user", "alice")(resp) + + assert.Equal(t, 123, resp.Context["project_id"]) + assert.Equal(t, "alice", resp.Context["user"]) +} + +func TestWithMeta(t *testing.T) { + resp := &Response{} + + WithMeta("page", 1)(resp) + WithMeta("total", 100)(resp) + + assert.Equal(t, 1, resp.Meta["page"]) + assert.Equal(t, 100, resp.Meta["total"]) +} + +// ============================================================================= +// NormalizeData Tests +// ============================================================================= + +func TestNormalizeDataWithSlice(t *testing.T) { + data := []map[string]any{ + {"id": 1, "name": "A"}, + {"id": 2, "name": "B"}, + } + + result := NormalizeData(data) + slice, ok := result.([]map[string]any) + require.True(t, ok, "Expected []map[string]any, got %T", result) + assert.Len(t, slice, 2) +} + +func TestNormalizeDataWithMap(t *testing.T) { + data := map[string]any{"id": 1, "name": "A"} + + result := NormalizeData(data) + m, ok := result.(map[string]any) + require.True(t, ok, "Expected map[string]any, got %T", result) + assert.Equal(t, 1, m["id"]) +} + +func TestNormalizeDataWithJSONRawMessage(t *testing.T) { + raw := json.RawMessage(`[{"id": 1}, {"id": 2}]`) + + result := NormalizeData(raw) + slice, ok := result.([]map[string]any) + require.True(t, ok, "Expected []map[string]any, got %T", result) + assert.Len(t, slice, 2) +} + +func TestNormalizeDataWithStruct(t *testing.T) { + type Item struct { + ID int `json:"id"` + Name string `json:"name"` + } + data := Item{ID: 1, Name: "Test"} + + result := NormalizeData(data) + m, ok := result.(map[string]any) + require.True(t, ok, "Expected map[string]any, got %T", result) + assert.Equal(t, json.Number("1"), m["id"]) // UseNumber preserves numeric precision +} + +func TestNormalizeDataWithNil(t *testing.T) { + result := NormalizeData(nil) + assert.Nil(t, result) +} + +func TestNormalizeDataPreservesLargeIDs(t *testing.T) { + // Verify json.Number preservation for large IDs that exceed float64 precision + raw := json.RawMessage(`{"id": 9007199254740993}`) + + result := NormalizeData(raw) + m, ok := result.(map[string]any) + require.True(t, ok, "Expected map[string]any, got %T", result) + + id, ok := m["id"].(json.Number) + require.True(t, ok, "Expected json.Number, got %T", m["id"]) + assert.Equal(t, "9007199254740993", id.String()) +} + +func TestNormalizeDataEmptyArray(t *testing.T) { + raw := json.RawMessage(`[]`) + + result := NormalizeData(raw) + slice, ok := result.([]map[string]any) + require.True(t, ok, "Expected []map[string]any, got %T", result) + assert.Len(t, slice, 0) +} + +// ============================================================================= +// TruncationNotice Tests +// ============================================================================= + +func TestTruncationNotice(t *testing.T) { + tests := []struct { + name string + count int + defaultLimit int + all bool + explicitLimit int + expected string + }{ + {"at limit", 100, 100, false, 0, "Showing 100 results (use --all for complete list)"}, + {"below limit", 50, 100, false, 0, ""}, + {"with --all", 100, 100, true, 0, ""}, + {"zero limit", 50, 0, false, 0, ""}, + {"explicit limit at boundary", 25, 100, false, 25, "Showing 25 results (use --all for complete list)"}, + {"explicit limit not reached", 10, 100, false, 25, ""}, + {"above limit", 150, 100, false, 0, "Showing 150 results (use --all for complete list)"}, + {"zero count", 0, 100, false, 0, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TruncationNotice(tt.count, tt.defaultLimit, tt.all, tt.explicitLimit) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestTruncationNoticeWithTotal(t *testing.T) { + tests := []struct { + name string + count int + totalCount int + expected string + }{ + {"truncated", 25, 100, "Showing 25 of 100 results (use --all for complete list)"}, + {"not truncated", 100, 100, ""}, + {"zero total", 25, 0, ""}, + {"count exceeds total", 100, 50, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TruncationNoticeWithTotal(tt.count, tt.totalCount) + assert.Equal(t, tt.expected, result) + }) + } +} + +// ============================================================================= +// EffectiveFormat Tests +// ============================================================================= + +func TestEffectiveFormat(t *testing.T) { + tests := []struct { + name string + format Format + expected Format + }{ + {"JSON stays JSON", FormatJSON, FormatJSON}, + {"Markdown stays Markdown", FormatMarkdown, FormatMarkdown}, + {"Styled stays Styled", FormatStyled, FormatStyled}, + {"Quiet stays Quiet", FormatQuiet, FormatQuiet}, + {"IDs stays IDs", FormatIDs, FormatIDs}, + {"Count stays Count", FormatCount, FormatCount}, + // FormatAuto resolves to FormatJSON when writer is not a TTY (bytes.Buffer) + {"Auto resolves to JSON for non-TTY", FormatAuto, FormatJSON}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + w := New(Options{ + Format: tt.format, + Writer: &buf, + }) + + got := w.EffectiveFormat() + assert.Equal(t, tt.expected, got) + }) + } +} + +// ============================================================================= +// Format Constants Tests +// ============================================================================= + +func TestFormatConstants(t *testing.T) { + formats := map[Format]string{ + FormatAuto: "auto", + FormatJSON: "json", + FormatMarkdown: "markdown", + FormatStyled: "styled", + FormatQuiet: "quiet", + FormatIDs: "ids", + FormatCount: "count", + } + + seen := make(map[Format]bool) + for format := range formats { + assert.False(t, seen[format], "Duplicate format value: %d", format) + seen[format] = true + } +} + +// ============================================================================= +// Error Edge Case Tests +// ============================================================================= + +func TestErrorWithHTTPStatus(t *testing.T) { + testCases := []struct { + name string + err *Error + expectedStatus int + }{ + {"forbidden", ErrForbidden("x"), 403}, + {"forbidden scope", ErrForbiddenScope(), 403}, + {"rate limit", ErrRateLimit(60), 429}, + {"api error", ErrAPI(500, "x"), 500}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expectedStatus, tc.err.HTTPStatus) + }) + } +} + +func TestErrorRetryable(t *testing.T) { + retryable := []struct { + name string + err *Error + }{ + {"rate limit", ErrRateLimit(60)}, + {"network", ErrNetwork(errors.New("connection failed"))}, + } + + for _, tc := range retryable { + t.Run(tc.name+" is retryable", func(t *testing.T) { + assert.True(t, tc.err.Retryable, "Expected error to be retryable") + }) + } + + nonRetryable := []struct { + name string + err *Error + }{ + {"not found", ErrNotFound("x", "y")}, + {"auth", ErrAuth("x")}, + {"forbidden", ErrForbidden("x")}, + {"usage", ErrUsage("x")}, + {"ambiguous", ErrAmbiguous("x", nil)}, + } + + for _, tc := range nonRetryable { + t.Run(tc.name+" is not retryable", func(t *testing.T) { + assert.False(t, tc.err.Retryable, "Expected error not to be retryable") + }) + } +} + +// ============================================================================= +// Writer with Styled/Markdown falls back to JSON +// ============================================================================= + +func TestWriterStyledFallsBackToJSON(t *testing.T) { + var buf bytes.Buffer + w := New(Options{ + Format: FormatStyled, + Writer: &buf, + }) + + data := map[string]string{"id": "1", "name": "Test"} + err := w.OK(data) + require.NoError(t, err) + + // Styled/Markdown fall back to JSON in the shared package + var resp Response + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + assert.True(t, resp.OK) +} + +func TestWriterMarkdownFallsBackToJSON(t *testing.T) { + var buf bytes.Buffer + w := New(Options{ + Format: FormatMarkdown, + Writer: &buf, + }) + + data := map[string]string{"id": "1", "name": "Test"} + err := w.OK(data) + require.NoError(t, err) + + var resp Response + require.NoError(t, json.Unmarshal(buf.Bytes(), &resp)) + assert.True(t, resp.OK) +} From d66bf2a5f6ae68179c37feeae91f3e4f9269ed41 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 4 Mar 2026 13:54:15 -0800 Subject: [PATCH 03/11] Add credstore package: keyring + file fallback credential storage Parameterized by ServiceName, DisableEnvVar, and FallbackDir so each CLI configures its own namespace. Probes system keyring at init, falls back to atomic file writes with 0600 permissions. Includes MigrateToKeyring() for upgrading file-based credentials. --- credstore/file.go | 114 ++++++++++++++++++++++++++++++++++++++++ credstore/migrate.go | 28 ++++++++++ credstore/store.go | 104 ++++++++++++++++++++++++++++++++++++ credstore/store_test.go | 84 +++++++++++++++++++++++++++++ 4 files changed, 330 insertions(+) create mode 100644 credstore/file.go create mode 100644 credstore/migrate.go create mode 100644 credstore/store.go create mode 100644 credstore/store_test.go diff --git a/credstore/file.go b/credstore/file.go new file mode 100644 index 0000000..c9cd1d6 --- /dev/null +++ b/credstore/file.go @@ -0,0 +1,114 @@ +package credstore + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" +) + +func (s *Store) credentialsPath() string { + return filepath.Join(s.fallbackDir, "credentials.json") +} + +func (s *Store) loadAllFromFile() (map[string][]byte, error) { + data, err := os.ReadFile(s.credentialsPath()) + if err != nil { + if os.IsNotExist(err) { + return make(map[string][]byte), nil + } + return nil, err + } + + var all map[string]json.RawMessage + if err := json.Unmarshal(data, &all); err != nil { + return nil, err + } + + result := make(map[string][]byte, len(all)) + for k, v := range all { + result[k] = []byte(v) + } + return result, nil +} + +func (s *Store) saveAllToFile(all map[string][]byte) error { + if err := os.MkdirAll(s.fallbackDir, 0700); err != nil { + return err + } + + // Convert to json.RawMessage for proper JSON nesting + raw := make(map[string]json.RawMessage, len(all)) + for k, v := range all { + raw[k] = json.RawMessage(v) + } + + data, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return err + } + + // Atomic write + tmpFile, err := os.CreateTemp(s.fallbackDir, "credentials-*.json.tmp") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return err + } + if err := tmpFile.Chmod(0600); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return err + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpPath) + return err + } + + destPath := s.credentialsPath() + if err := os.Rename(tmpPath, destPath); err != nil { + if runtime.GOOS == "windows" { + _ = os.Remove(destPath) + return os.Rename(tmpPath, destPath) + } + os.Remove(tmpPath) + return err + } + return nil +} + +func (s *Store) loadFromFile(key string) ([]byte, error) { + all, err := s.loadAllFromFile() + if err != nil { + return nil, err + } + data, ok := all[key] + if !ok { + return nil, fmt.Errorf("credentials not found for %s", key) + } + return data, nil +} + +func (s *Store) saveToFile(key string, data []byte) error { + all, err := s.loadAllFromFile() + if err != nil { + return err + } + all[key] = data + return s.saveAllToFile(all) +} + +func (s *Store) deleteFromFile(key string) error { + all, err := s.loadAllFromFile() + if err != nil { + return err + } + delete(all, key) + return s.saveAllToFile(all) +} diff --git a/credstore/migrate.go b/credstore/migrate.go new file mode 100644 index 0000000..1392fc0 --- /dev/null +++ b/credstore/migrate.go @@ -0,0 +1,28 @@ +package credstore + +import ( + "fmt" + "os" +) + +// MigrateToKeyring migrates credentials from file to keyring. +// No-op if keyring is not available. +func (s *Store) MigrateToKeyring() error { + if !s.useKeyring { + return nil + } + + all, err := s.loadAllFromFile() + if err != nil { + return nil // No file to migrate + } + + for key, data := range all { + if err := s.Save(key, data); err != nil { + return fmt.Errorf("failed to migrate %s: %w", key, err) + } + } + + _ = os.Remove(s.credentialsPath()) + return nil +} diff --git a/credstore/store.go b/credstore/store.go new file mode 100644 index 0000000..fdacb0e --- /dev/null +++ b/credstore/store.go @@ -0,0 +1,104 @@ +package credstore + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + + "github.com/zalando/go-keyring" +) + +// StoreOptions configures credential storage. +type StoreOptions struct { + // ServiceName is the keyring service name (e.g., "basecamp", "fizzy"). + ServiceName string + + // DisableEnvVar is the env var name that disables keyring (e.g., "BASECAMP_NO_KEYRING"). + // When set to any non-empty value, forces file-based storage. + DisableEnvVar string + + // FallbackDir is the directory for file-based credential storage. + FallbackDir string +} + +// Store handles credential storage with keyring preference and file fallback. +type Store struct { + serviceName string + useKeyring bool + fallbackDir string + fallbackWarning string +} + +// NewStore creates a credential store. It probes the system keyring +// and falls back to file storage if unavailable. +func NewStore(opts StoreOptions) *Store { + if opts.DisableEnvVar != "" && os.Getenv(opts.DisableEnvVar) != "" { + return &Store{serviceName: opts.ServiceName, useKeyring: false, fallbackDir: opts.FallbackDir} + } + + // Probe keyring with a random key to avoid collisions. + probeKey := probeKeyName() + err := keyring.Set(opts.ServiceName, probeKey, "probe") + if err == nil { + _ = keyring.Delete(opts.ServiceName, probeKey) + return &Store{serviceName: opts.ServiceName, useKeyring: true, fallbackDir: opts.FallbackDir} + } + + return &Store{ + serviceName: opts.ServiceName, + useKeyring: false, + fallbackDir: opts.FallbackDir, + fallbackWarning: fmt.Sprintf("system keyring unavailable, credentials stored in plaintext at %s", filepath.Join(opts.FallbackDir, "credentials.json")), + } +} + +// FallbackWarning returns a warning message if the store fell back to file +// storage, or empty string if using keyring. +func (s *Store) FallbackWarning() string { + return s.fallbackWarning +} + +func probeKeyName() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return "__probe_" + hex.EncodeToString(b) +} + +func (s *Store) key(name string) string { + return fmt.Sprintf("%s::%s", s.serviceName, name) +} + +// Load retrieves credentials for the given key. +func (s *Store) Load(key string) ([]byte, error) { + if s.useKeyring { + data, err := keyring.Get(s.serviceName, s.key(key)) + if err != nil { + return nil, fmt.Errorf("credentials not found: %w", err) + } + return []byte(data), nil + } + return s.loadFromFile(key) +} + +// Save stores credentials for the given key. +func (s *Store) Save(key string, data []byte) error { + if s.useKeyring { + return keyring.Set(s.serviceName, s.key(key), string(data)) + } + return s.saveToFile(key, data) +} + +// Delete removes credentials for the given key. +func (s *Store) Delete(key string) error { + if s.useKeyring { + return keyring.Delete(s.serviceName, s.key(key)) + } + return s.deleteFromFile(key) +} + +// UsingKeyring returns true if the store is using the system keyring. +func (s *Store) UsingKeyring() bool { + return s.useKeyring +} diff --git a/credstore/store_test.go b/credstore/store_test.go new file mode 100644 index 0000000..32ce981 --- /dev/null +++ b/credstore/store_test.go @@ -0,0 +1,84 @@ +package credstore + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileStore(t *testing.T) { + dir := t.TempDir() + t.Setenv("TEST_NO_KEYRING", "1") + + store := NewStore(StoreOptions{ + ServiceName: "test", + DisableEnvVar: "TEST_NO_KEYRING", + FallbackDir: dir, + }) + + assert.False(t, store.UsingKeyring()) + + // Save + err := store.Save("mykey", []byte(`{"token":"abc123"}`)) + require.NoError(t, err) + + // Load + data, err := store.Load("mykey") + require.NoError(t, err) + assert.JSONEq(t, `{"token":"abc123"}`, string(data)) + + // Verify file permissions + info, err := os.Stat(filepath.Join(dir, "credentials.json")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) + + // Delete + err = store.Delete("mykey") + require.NoError(t, err) + + _, err = store.Load("mykey") + assert.Error(t, err) +} + +func TestFileStoreMultipleKeys(t *testing.T) { + dir := t.TempDir() + t.Setenv("TEST_NO_KEYRING", "1") + + store := NewStore(StoreOptions{ + ServiceName: "test", + DisableEnvVar: "TEST_NO_KEYRING", + FallbackDir: dir, + }) + + store.Save("key1", []byte(`{"a":1}`)) + store.Save("key2", []byte(`{"b":2}`)) + + d1, _ := store.Load("key1") + d2, _ := store.Load("key2") + assert.JSONEq(t, `{"a":1}`, string(d1)) + assert.JSONEq(t, `{"b":2}`, string(d2)) + + // Delete one, other persists + store.Delete("key1") + _, err := store.Load("key1") + assert.Error(t, err) + d2, _ = store.Load("key2") + assert.JSONEq(t, `{"b":2}`, string(d2)) +} + +func TestLoadNonexistent(t *testing.T) { + dir := t.TempDir() + t.Setenv("TEST_NO_KEYRING", "1") + + store := NewStore(StoreOptions{ + ServiceName: "test", + DisableEnvVar: "TEST_NO_KEYRING", + FallbackDir: dir, + }) + + _, err := store.Load("nonexistent") + assert.Error(t, err) +} From 6005a5614ff91591bb9581408035d8628df1e2e8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 4 Mar 2026 13:54:20 -0800 Subject: [PATCH 04/11] Add pkce package: PKCE verifier, challenge, and state generation Three stdlib-only functions implementing RFC 7636 for OAuth flows. GenerateVerifier (32 bytes), GenerateChallenge (S256), GenerateState (16 bytes). --- pkce/pkce.go | 31 +++++++++++++++++++++++++++ pkce/pkce_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 pkce/pkce.go create mode 100644 pkce/pkce_test.go diff --git a/pkce/pkce.go b/pkce/pkce.go new file mode 100644 index 0000000..a07bc0f --- /dev/null +++ b/pkce/pkce.go @@ -0,0 +1,31 @@ +package pkce + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" +) + +// GenerateVerifier creates a PKCE code verifier (RFC 7636). +func GenerateVerifier() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + return base64.RawURLEncoding.EncodeToString(b) +} + +// GenerateChallenge creates a PKCE code challenge from a verifier (S256). +func GenerateChallenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +// GenerateState creates a random state parameter for CSRF protection. +func GenerateState() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/pkce/pkce_test.go b/pkce/pkce_test.go new file mode 100644 index 0000000..66acb0a --- /dev/null +++ b/pkce/pkce_test.go @@ -0,0 +1,54 @@ +package pkce + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateVerifier(t *testing.T) { + v := GenerateVerifier() + assert.NotEmpty(t, v) + + // Should be base64url encoded 32 bytes = 43 chars + assert.Len(t, v, 43) + + // Should be valid base64url + _, err := base64.RawURLEncoding.DecodeString(v) + assert.NoError(t, err) + + // Should be unique + v2 := GenerateVerifier() + assert.NotEqual(t, v, v2) +} + +func TestGenerateChallenge(t *testing.T) { + v := GenerateVerifier() + c := GenerateChallenge(v) + assert.NotEmpty(t, c) + + // Should be base64url encoded SHA256 = 43 chars + assert.Len(t, c, 43) + + // Deterministic for same input + c2 := GenerateChallenge(v) + assert.Equal(t, c, c2) + + // Different input -> different output + v2 := GenerateVerifier() + c3 := GenerateChallenge(v2) + assert.NotEqual(t, c, c3) +} + +func TestGenerateState(t *testing.T) { + s := GenerateState() + assert.NotEmpty(t, s) + + // Should be base64url encoded 16 bytes = 22 chars + assert.Len(t, s, 22) + + // Should be unique + s2 := GenerateState() + assert.NotEqual(t, s, s2) +} From 8c8b2d0f1afcd680353b60ef736403b68fe55f44 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 4 Mar 2026 13:54:25 -0800 Subject: [PATCH 05/11] Add oauthcallback package: local HTTP server for OAuth redirects WaitForCallback starts a local HTTP server, validates the OAuth state parameter, and returns the authorization code. 5-minute timeout, sync.Once shutdown, HTML response to the browser. --- oauthcallback/server.go | 103 ++++++++++++++++++++++ oauthcallback/server_test.go | 160 +++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 oauthcallback/server.go create mode 100644 oauthcallback/server_test.go diff --git a/oauthcallback/server.go b/oauthcallback/server.go new file mode 100644 index 0000000..0e39ae2 --- /dev/null +++ b/oauthcallback/server.go @@ -0,0 +1,103 @@ +package oauthcallback + +import ( + "context" + "fmt" + "net" + "net/http" + "sync" + "time" +) + +// WaitForCallback starts a local HTTP server on listener and waits for an +// OAuth callback. It returns the authorization code from the callback. +// +// If listener is nil, one is created on listenAddr. Passing a pre-bound +// listener (e.g., from net.Listen("tcp", "127.0.0.1:0")) is preferred +// for tests to avoid port conflicts. +func WaitForCallback(ctx context.Context, expectedState string, listener net.Listener, listenAddr string) (string, error) { + if listener == nil { + lc := net.ListenConfig{} + var err error + listener, err = lc.Listen(ctx, "tcp", listenAddr) + if err != nil { + return "", fmt.Errorf("failed to start callback server: %w", err) + } + } + defer listener.Close() + + codeCh := make(chan string, 1) + errCh := make(chan error, 1) + var once sync.Once + + send := func(ch chan<- string, val string) { + select { + case ch <- val: + default: + } + } + sendErr := func(ch chan<- error, val error) { + select { + case ch <- val: + default: + } + } + + server := &http.Server{ + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + } + + shutdown := func() { + once.Do(func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + go func() { defer cancel(); server.Shutdown(shutdownCtx) }() + }) + } + + server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + code := r.URL.Query().Get("code") + errParam := r.URL.Query().Get("error") + + if errParam != "" { + sendErr(errCh, fmt.Errorf("OAuth error: %s", errParam)) + fmt.Fprint(w, "

Authentication failed

You can close this window.

") + shutdown() + return + } + + if state != expectedState { + sendErr(errCh, fmt.Errorf("state mismatch: CSRF protection failed")) + fmt.Fprint(w, "

Authentication failed

State mismatch.

") + shutdown() + return + } + + if code == "" { + sendErr(errCh, fmt.Errorf("OAuth callback missing authorization code")) + fmt.Fprint(w, "

Authentication failed

Missing authorization code.

") + shutdown() + return + } + + send(codeCh, code) + fmt.Fprint(w, "

Authentication successful!

You can close this window.

") + shutdown() + }) + + go server.Serve(listener) + + select { + case code := <-codeCh: + return code, nil + case err := <-errCh: + return "", err + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(5 * time.Minute): + return "", fmt.Errorf("authentication timeout waiting for callback on %s", listener.Addr()) + } +} diff --git a/oauthcallback/server_test.go b/oauthcallback/server_test.go new file mode 100644 index 0000000..602575a --- /dev/null +++ b/oauthcallback/server_test.go @@ -0,0 +1,160 @@ +package oauthcallback + +import ( + "context" + "fmt" + "net" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func listen(t *testing.T) net.Listener { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + return ln +} + +func TestWaitForCallback_Success(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ln := listen(t) + addr := ln.Addr().String() + state := "test-state-123" + + codeCh := make(chan string, 1) + errCh := make(chan error, 1) + + go func() { + code, err := WaitForCallback(ctx, state, ln, "") + if err != nil { + errCh <- err + } else { + codeCh <- code + } + }() + + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get(fmt.Sprintf("http://%s/callback?state=test-state-123&code=auth-code-456", addr)) + require.NoError(t, err) + resp.Body.Close() + + select { + case code := <-codeCh: + assert.Equal(t, "auth-code-456", code) + case err := <-errCh: + t.Fatalf("unexpected error: %v", err) + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for callback") + } +} + +func TestWaitForCallback_MissingCode(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ln := listen(t) + addr := ln.Addr().String() + errCh := make(chan error, 1) + + go func() { + _, err := WaitForCallback(ctx, "state", ln, "") + errCh <- err + }() + + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get(fmt.Sprintf("http://%s/callback?state=state", addr)) + require.NoError(t, err) + resp.Body.Close() + + select { + case err := <-errCh: + assert.Contains(t, err.Error(), "missing authorization code") + case <-time.After(3 * time.Second): + t.Fatal("timeout") + } +} + +func TestWaitForCallback_StateMismatch(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ln := listen(t) + addr := ln.Addr().String() + errCh := make(chan error, 1) + + go func() { + _, err := WaitForCallback(ctx, "expected-state", ln, "") + errCh <- err + }() + + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get(fmt.Sprintf("http://%s/callback?state=wrong-state&code=abc", addr)) + require.NoError(t, err) + resp.Body.Close() + + select { + case err := <-errCh: + assert.Contains(t, err.Error(), "state mismatch") + case <-time.After(3 * time.Second): + t.Fatal("timeout") + } +} + +func TestWaitForCallback_OAuthError(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ln := listen(t) + addr := ln.Addr().String() + errCh := make(chan error, 1) + + go func() { + _, err := WaitForCallback(ctx, "state", ln, "") + errCh <- err + }() + + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get(fmt.Sprintf("http://%s/callback?error=access_denied", addr)) + require.NoError(t, err) + resp.Body.Close() + + select { + case err := <-errCh: + assert.Contains(t, err.Error(), "access_denied") + case <-time.After(3 * time.Second): + t.Fatal("timeout") + } +} + +func TestWaitForCallback_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + ln := listen(t) + errCh := make(chan error, 1) + + go func() { + _, err := WaitForCallback(ctx, "state", ln, "") + errCh <- err + }() + + time.Sleep(100 * time.Millisecond) + cancel() + + select { + case err := <-errCh: + assert.Error(t, err) + case <-time.After(3 * time.Second): + t.Fatal("timeout") + } +} From 2712bed79f99bc2b9fcae8dcaff9474f64e32768 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 4 Mar 2026 13:54:30 -0800 Subject: [PATCH 06/11] Add surface package: CLI surface snapshots and compatibility diffs Walks a Cobra command tree to produce deterministic CMD/FLAG/SUB entries. Diff() compares two snapshots and reports additions and removals. HasBreakingChanges() flags flag/subcommand removals for CI enforcement. --- surface/diff.go | 48 +++++++++++ surface/snapshot.go | 105 +++++++++++++++++++++++ surface/surface_test.go | 180 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+) create mode 100644 surface/diff.go create mode 100644 surface/snapshot.go create mode 100644 surface/surface_test.go diff --git a/surface/diff.go b/surface/diff.go new file mode 100644 index 0000000..4be3973 --- /dev/null +++ b/surface/diff.go @@ -0,0 +1,48 @@ +package surface + +import "sort" + +// DiffResult contains the differences between two surface snapshots. +type DiffResult struct { + Added []Entry // Entries in new but not in old + Removed []Entry // Entries in old but not in new (breaking changes) +} + +// HasBreakingChanges returns true if any entries were removed. +func (d DiffResult) HasBreakingChanges() bool { + return len(d.Removed) > 0 +} + +// Diff compares two snapshots and returns additions and removals. +func Diff(old, new []Entry) DiffResult { + oldSet := make(map[string]Entry, len(old)) + for _, e := range old { + oldSet[e.String()] = e + } + + newSet := make(map[string]Entry, len(new)) + for _, e := range new { + newSet[e.String()] = e + } + + var result DiffResult + + // Find removals (in old but not in new) + for key, e := range oldSet { + if _, ok := newSet[key]; !ok { + result.Removed = append(result.Removed, e) + } + } + + // Find additions (in new but not in old) + for key, e := range newSet { + if _, ok := oldSet[key]; !ok { + result.Added = append(result.Added, e) + } + } + + sort.Slice(result.Added, func(i, j int) bool { return result.Added[i].String() < result.Added[j].String() }) + sort.Slice(result.Removed, func(i, j int) bool { return result.Removed[i].String() < result.Removed[j].String() }) + + return result +} diff --git a/surface/snapshot.go b/surface/snapshot.go new file mode 100644 index 0000000..7d389cc --- /dev/null +++ b/surface/snapshot.go @@ -0,0 +1,105 @@ +package surface + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// EntryKind identifies the type of surface entry. +type EntryKind string + +const ( + KindCmd EntryKind = "CMD" + KindFlag EntryKind = "FLAG" + KindSub EntryKind = "SUB" +) + +// Entry represents a single element in the CLI surface. +type Entry struct { + Kind EntryKind + Path string // Full command path (e.g., "basecamp projects list") + Name string // Flag or subcommand name + FlagType string // Flag type (e.g., "string", "bool") — only for FLAG entries +} + +// String returns the canonical string representation of the entry. +// Format: "CMD path", "FLAG path --name type=flagtype", "SUB path name" +func (e Entry) String() string { + switch e.Kind { + case KindCmd: + return fmt.Sprintf("CMD %s", e.Path) + case KindFlag: + return fmt.Sprintf("FLAG %s --%s type=%s", e.Path, e.Name, e.FlagType) + case KindSub: + return fmt.Sprintf("SUB %s %s", e.Path, e.Name) + default: + return fmt.Sprintf("%s %s %s", e.Kind, e.Path, e.Name) + } +} + +// Snapshot walks a Cobra command tree and returns all surface entries. +func Snapshot(cmd *cobra.Command) []Entry { + var entries []Entry + walkCommand(cmd, cmd.Name(), &entries) + return entries +} + +// SnapshotString returns a sorted, newline-joined string of all surface entries. +func SnapshotString(cmd *cobra.Command) string { + entries := Snapshot(cmd) + lines := make([]string, len(entries)) + for i, e := range entries { + lines[i] = e.String() + } + sort.Strings(lines) + return strings.Join(lines, "\n") +} + +func walkCommand(cmd *cobra.Command, path string, entries *[]Entry) { + // Emit CMD entry + *entries = append(*entries, Entry{Kind: KindCmd, Path: path}) + + // Collect and sort all flags visible at this command level: + // local flags, persistent flags on this command, and inherited persistent flags. + var flags []Entry + seen := make(map[string]bool) + addFlag := func(f *pflag.Flag) { + if seen[f.Name] || f.Hidden { + return + } + seen[f.Name] = true + flags = append(flags, Entry{ + Kind: KindFlag, + Path: path, + Name: f.Name, + FlagType: f.Value.Type(), + }) + } + cmd.Flags().VisitAll(addFlag) + cmd.PersistentFlags().VisitAll(addFlag) + if cmd.HasParent() { + cmd.InheritedFlags().VisitAll(addFlag) + } + sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name }) + *entries = append(*entries, flags...) + + // Collect and sort subcommands + subs := cmd.Commands() + sort.Slice(subs, func(i, j int) bool { return subs[i].Name() < subs[j].Name() }) + + for _, sub := range subs { + if sub.Hidden { + continue + } + *entries = append(*entries, Entry{ + Kind: KindSub, + Path: path, + Name: sub.Name(), + }) + walkCommand(sub, path+" "+sub.Name(), entries) + } +} diff --git a/surface/surface_test.go b/surface/surface_test.go new file mode 100644 index 0000000..4596c34 --- /dev/null +++ b/surface/surface_test.go @@ -0,0 +1,180 @@ +package surface + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func newTestRoot() *cobra.Command { + root := &cobra.Command{ + Use: "mycli", + } + root.PersistentFlags().Bool("json", false, "JSON output") + root.PersistentFlags().Bool("verbose", false, "Verbose output") + + projects := &cobra.Command{Use: "projects", Short: "Manage projects"} + projects.Flags().Int("limit", 50, "Limit results") + + list := &cobra.Command{Use: "list", Short: "List projects"} + list.Flags().Bool("all", false, "Show all") + + show := &cobra.Command{Use: "show", Short: "Show project"} + show.Flags().String("format", "table", "Output format") + + projects.AddCommand(list, show) + root.AddCommand(projects) + + hidden := &cobra.Command{Use: "internal", Hidden: true} + root.AddCommand(hidden) + + return root +} + +func TestSnapshot(t *testing.T) { + root := newTestRoot() + entries := Snapshot(root) + + // Should have entries for all visible commands + var cmds []string + for _, e := range entries { + if e.Kind == KindCmd { + cmds = append(cmds, e.Path) + } + } + + assert.Contains(t, cmds, "mycli") + assert.Contains(t, cmds, "mycli projects") + assert.Contains(t, cmds, "mycli projects list") + assert.Contains(t, cmds, "mycli projects show") +} + +func TestSnapshotFlags(t *testing.T) { + root := newTestRoot() + entries := Snapshot(root) + + var flags []string + for _, e := range entries { + if e.Kind == KindFlag { + flags = append(flags, e.String()) + } + } + + assert.Contains(t, flags, "FLAG mycli --json type=bool") + assert.Contains(t, flags, "FLAG mycli --verbose type=bool") + assert.Contains(t, flags, "FLAG mycli projects --limit type=int") + assert.Contains(t, flags, "FLAG mycli projects list --all type=bool") +} + +func TestSnapshotSubcommands(t *testing.T) { + root := newTestRoot() + entries := Snapshot(root) + + var subs []string + for _, e := range entries { + if e.Kind == KindSub { + subs = append(subs, e.String()) + } + } + + assert.Contains(t, subs, "SUB mycli projects") + assert.Contains(t, subs, "SUB mycli projects list") + assert.Contains(t, subs, "SUB mycli projects show") +} + +func TestSnapshotHiddenExcluded(t *testing.T) { + root := newTestRoot() + entries := Snapshot(root) + + for _, e := range entries { + assert.NotContains(t, e.Path, "internal", "hidden commands should be excluded") + } +} + +func TestSnapshotString(t *testing.T) { + root := newTestRoot() + s := SnapshotString(root) + + assert.NotEmpty(t, s) + + // Should be sorted + lines := splitLines(s) + for i := 1; i < len(lines); i++ { + assert.True(t, lines[i-1] <= lines[i], "lines should be sorted: %q > %q", lines[i-1], lines[i]) + } +} + +func TestDiffIdentical(t *testing.T) { + root := newTestRoot() + entries := Snapshot(root) + + result := Diff(entries, entries) + assert.Empty(t, result.Added) + assert.Empty(t, result.Removed) + assert.False(t, result.HasBreakingChanges()) +} + +func TestDiffAdditions(t *testing.T) { + root1 := newTestRoot() + old := Snapshot(root1) + + root2 := newTestRoot() + root2.AddCommand(&cobra.Command{Use: "newcmd", Short: "New command"}) + new := Snapshot(root2) + + result := Diff(old, new) + assert.NotEmpty(t, result.Added) + assert.Empty(t, result.Removed) + assert.False(t, result.HasBreakingChanges()) + + // Check specific addition + var addedCmds []string + for _, e := range result.Added { + if e.Kind == KindCmd { + addedCmds = append(addedCmds, e.Path) + } + } + assert.Contains(t, addedCmds, "mycli newcmd") +} + +func TestDiffRemovals(t *testing.T) { + root1 := newTestRoot() + root1.AddCommand(&cobra.Command{Use: "oldcmd"}) + old := Snapshot(root1) + + root2 := newTestRoot() + new := Snapshot(root2) + + result := Diff(old, new) + assert.NotEmpty(t, result.Removed) + assert.True(t, result.HasBreakingChanges()) +} + +func TestEntryString(t *testing.T) { + tests := []struct { + entry Entry + expected string + }{ + {Entry{Kind: KindCmd, Path: "mycli"}, "CMD mycli"}, + {Entry{Kind: KindFlag, Path: "mycli", Name: "json", FlagType: "bool"}, "FLAG mycli --json type=bool"}, + {Entry{Kind: KindSub, Path: "mycli", Name: "projects"}, "SUB mycli projects"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.entry.String()) + }) + } +} + +func splitLines(s string) []string { + var lines []string + for _, line := range strings.Split(s, "\n") { + if line != "" { + lines = append(lines, line) + } + } + return lines +} From ee6598d1ec682e2098da140a72a6e9cabb5fca6d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 4 Mar 2026 13:54:36 -0800 Subject: [PATCH 07/11] Add profile package: named environment profiles for CLI tools Profile bundles a base URL with optional app-specific Extra fields. Store manages profiles in a JSON config file with atomic writes. Resolve implements strict precedence: --profile flag > APP_PROFILE env > default_profile config > auto-select single > interactive picker. CredentialKey returns "profile:" for named profiles. --- profile/profile.go | 211 +++++++++++++++++++++++++++++++++ profile/profile_test.go | 256 ++++++++++++++++++++++++++++++++++++++++ profile/resolve.go | 90 ++++++++++++++ 3 files changed, 557 insertions(+) create mode 100644 profile/profile.go create mode 100644 profile/profile_test.go create mode 100644 profile/resolve.go diff --git a/profile/profile.go b/profile/profile.go new file mode 100644 index 0000000..3f51ad8 --- /dev/null +++ b/profile/profile.go @@ -0,0 +1,211 @@ +// Package profile provides named profile management for CLI tools. +// +// A profile bundles a base URL with optional app-specific settings, +// letting users and agents target different environments or accounts +// with --profile or APP_PROFILE without env var juggling. +package profile + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" +) + +// Profile is a named environment configuration. +type Profile struct { + Name string `json:"-"` + BaseURL string `json:"base_url"` + Extra map[string]json.RawMessage `json:"extra,omitempty"` +} + +// validName matches alphanumeric + hyphen + underscore, must start with alnum. +var validName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) + +// ValidateName checks that a profile name is well-formed. +func ValidateName(name string) error { + if !validName.MatchString(name) { + return fmt.Errorf("invalid profile name %q: must match [a-zA-Z0-9][a-zA-Z0-9_-]*", name) + } + return nil +} + +// CredentialKey returns the credential store key for a profile. +// With a profile: "profile:". Without: the base URL. +func CredentialKey(profileName, baseURL string) string { + if profileName != "" { + return "profile:" + profileName + } + return baseURL +} + +// configFile holds the on-disk JSON structure. +type configFile struct { + Profiles map[string]*Profile `json:"profiles,omitempty"` + DefaultProfile string `json:"default_profile,omitempty"` +} + +// Store manages named profiles in a JSON config file. +type Store struct { + path string +} + +// NewStore creates a profile store backed by configPath (e.g., +// ~/.config/myapp/config.json). The file and parent directory are +// created on first write. +func NewStore(configPath string) *Store { + return &Store{path: configPath} +} + +// load reads and parses the config file. Returns an empty config if +// the file doesn't exist. +func (s *Store) load() (*configFile, error) { + data, err := os.ReadFile(s.path) //nolint:gosec // G304: path from trusted config location + if err != nil { + if os.IsNotExist(err) { + return &configFile{Profiles: make(map[string]*Profile)}, nil + } + return nil, err + } + + var cfg configFile + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("malformed config at %s: %w", s.path, err) + } + if cfg.Profiles == nil { + cfg.Profiles = make(map[string]*Profile) + } + // Backfill Name field from map key. + for name, p := range cfg.Profiles { + p.Name = name + } + return &cfg, nil +} + +// save writes the config file atomically. +func (s *Store) save(cfg *configFile) error { + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + tmpFile, err := os.CreateTemp(dir, "config-*.json.tmp") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return err + } + if err := tmpFile.Chmod(0600); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return err + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpPath) + return err + } + + if err := os.Rename(tmpPath, s.path); err != nil { + if runtime.GOOS == "windows" { + _ = os.Remove(s.path) + return os.Rename(tmpPath, s.path) + } + os.Remove(tmpPath) + return err + } + return nil +} + +// List returns all profiles and the default profile name. +func (s *Store) List() (map[string]*Profile, string, error) { + cfg, err := s.load() + if err != nil { + return nil, "", err + } + return cfg.Profiles, cfg.DefaultProfile, nil +} + +// Get returns a single profile by name. +func (s *Store) Get(name string) (*Profile, error) { + cfg, err := s.load() + if err != nil { + return nil, err + } + p, ok := cfg.Profiles[name] + if !ok { + return nil, fmt.Errorf("profile %q not found", name) + } + return p, nil +} + +// Create adds a new profile. Returns an error if it already exists. +func (s *Store) Create(p *Profile) error { + if err := ValidateName(p.Name); err != nil { + return err + } + if p.BaseURL == "" { + return fmt.Errorf("profile %q: base_url is required", p.Name) + } + + cfg, err := s.load() + if err != nil { + return err + } + if _, exists := cfg.Profiles[p.Name]; exists { + return fmt.Errorf("profile %q already exists", p.Name) + } + + cfg.Profiles[p.Name] = p + + // Auto-set default if this is the first profile. + if len(cfg.Profiles) == 1 { + cfg.DefaultProfile = p.Name + } + + return s.save(cfg) +} + +// Delete removes a profile by name. Clears default_profile if it +// pointed to the deleted profile. +func (s *Store) Delete(name string) error { + cfg, err := s.load() + if err != nil { + return err + } + if _, exists := cfg.Profiles[name]; !exists { + return fmt.Errorf("profile %q not found", name) + } + + delete(cfg.Profiles, name) + if cfg.DefaultProfile == name { + cfg.DefaultProfile = "" + } + + return s.save(cfg) +} + +// SetDefault sets the default profile. +func (s *Store) SetDefault(name string) error { + cfg, err := s.load() + if err != nil { + return err + } + if _, exists := cfg.Profiles[name]; !exists { + return fmt.Errorf("profile %q not found", name) + } + + cfg.DefaultProfile = name + return s.save(cfg) +} diff --git a/profile/profile_test.go b/profile/profile_test.go new file mode 100644 index 0000000..acde0f8 --- /dev/null +++ b/profile/profile_test.go @@ -0,0 +1,256 @@ +package profile + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateName(t *testing.T) { + valid := []string{"personal", "staging", "client-a", "prod_1", "A", "a1"} + for _, name := range valid { + assert.NoError(t, ValidateName(name), "should be valid: %q", name) + } + + invalid := []string{"", "-start", "_start", "has space", "has.dot", "!bang"} + for _, name := range invalid { + assert.Error(t, ValidateName(name), "should be invalid: %q", name) + } +} + +func TestCredentialKey(t *testing.T) { + assert.Equal(t, "profile:staging", CredentialKey("staging", "https://api.example.com")) + assert.Equal(t, "https://api.example.com", CredentialKey("", "https://api.example.com")) +} + +func TestStoreCreateAndList(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + store := NewStore(path) + + // Empty store + profiles, def, err := store.List() + require.NoError(t, err) + assert.Empty(t, profiles) + assert.Empty(t, def) + + // Create first profile — becomes default + err = store.Create(&Profile{Name: "personal", BaseURL: "https://api.example.com"}) + require.NoError(t, err) + + profiles, def, err = store.List() + require.NoError(t, err) + assert.Len(t, profiles, 1) + assert.Equal(t, "personal", def) + assert.Equal(t, "https://api.example.com", profiles["personal"].BaseURL) + + // Create second profile — default unchanged + err = store.Create(&Profile{Name: "staging", BaseURL: "https://staging.example.com"}) + require.NoError(t, err) + + profiles, def, err = store.List() + require.NoError(t, err) + assert.Len(t, profiles, 2) + assert.Equal(t, "personal", def) +} + +func TestStoreCreateDuplicate(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + store := NewStore(path) + + require.NoError(t, store.Create(&Profile{Name: "prod", BaseURL: "https://a.com"})) + err := store.Create(&Profile{Name: "prod", BaseURL: "https://b.com"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") +} + +func TestStoreCreateValidation(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + store := NewStore(path) + + err := store.Create(&Profile{Name: "-bad", BaseURL: "https://a.com"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid profile name") + + err = store.Create(&Profile{Name: "good", BaseURL: ""}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "base_url is required") +} + +func TestStoreGet(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + store := NewStore(path) + + require.NoError(t, store.Create(&Profile{Name: "prod", BaseURL: "https://a.com"})) + + p, err := store.Get("prod") + require.NoError(t, err) + assert.Equal(t, "prod", p.Name) + assert.Equal(t, "https://a.com", p.BaseURL) + + _, err = store.Get("nonexistent") + assert.Error(t, err) +} + +func TestStoreDelete(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + store := NewStore(path) + + require.NoError(t, store.Create(&Profile{Name: "a", BaseURL: "https://a.com"})) + require.NoError(t, store.Create(&Profile{Name: "b", BaseURL: "https://b.com"})) + + // Delete the default profile — default cleared + require.NoError(t, store.Delete("a")) + + profiles, def, err := store.List() + require.NoError(t, err) + assert.Len(t, profiles, 1) + assert.Empty(t, def) + + // Delete nonexistent + err = store.Delete("nonexistent") + assert.Error(t, err) +} + +func TestStoreSetDefault(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + store := NewStore(path) + + require.NoError(t, store.Create(&Profile{Name: "a", BaseURL: "https://a.com"})) + require.NoError(t, store.Create(&Profile{Name: "b", BaseURL: "https://b.com"})) + + require.NoError(t, store.SetDefault("b")) + _, def, _ := store.List() + assert.Equal(t, "b", def) + + err := store.SetDefault("nonexistent") + assert.Error(t, err) +} + +func TestStoreFilePermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + store := NewStore(path) + + require.NoError(t, store.Create(&Profile{Name: "p", BaseURL: "https://a.com"})) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) +} + +func TestStoreExtraFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + store := NewStore(path) + + extra := map[string]json.RawMessage{ + "account_id": json.RawMessage(`"12345"`), + "scope": json.RawMessage(`"full"`), + } + require.NoError(t, store.Create(&Profile{Name: "prod", BaseURL: "https://a.com", Extra: extra})) + + p, err := store.Get("prod") + require.NoError(t, err) + assert.Equal(t, `"12345"`, string(p.Extra["account_id"])) + assert.Equal(t, `"full"`, string(p.Extra["scope"])) +} + +// Resolution tests + +func TestResolveNoProfiles(t *testing.T) { + name, err := Resolve(ResolveOptions{}) + require.NoError(t, err) + assert.Empty(t, name) +} + +func TestResolveFlagWins(t *testing.T) { + profiles := map[string]*Profile{ + "a": {Name: "a", BaseURL: "https://a.com"}, + "b": {Name: "b", BaseURL: "https://b.com"}, + } + name, err := Resolve(ResolveOptions{ + FlagValue: "b", + EnvVar: "a", + DefaultProfile: "a", + Profiles: profiles, + }) + require.NoError(t, err) + assert.Equal(t, "b", name) +} + +func TestResolveEnvFallback(t *testing.T) { + profiles := map[string]*Profile{ + "a": {Name: "a", BaseURL: "https://a.com"}, + "b": {Name: "b", BaseURL: "https://b.com"}, + } + name, err := Resolve(ResolveOptions{ + EnvVar: "b", + Profiles: profiles, + }) + require.NoError(t, err) + assert.Equal(t, "b", name) +} + +func TestResolveDefaultFallback(t *testing.T) { + profiles := map[string]*Profile{ + "a": {Name: "a", BaseURL: "https://a.com"}, + "b": {Name: "b", BaseURL: "https://b.com"}, + } + name, err := Resolve(ResolveOptions{ + DefaultProfile: "a", + Profiles: profiles, + }) + require.NoError(t, err) + assert.Equal(t, "a", name) +} + +func TestResolveAutoSelectSingle(t *testing.T) { + profiles := map[string]*Profile{ + "only": {Name: "only", BaseURL: "https://only.com"}, + } + name, err := Resolve(ResolveOptions{Profiles: profiles}) + require.NoError(t, err) + assert.Equal(t, "only", name) +} + +func TestResolveMultipleNoSelection(t *testing.T) { + profiles := map[string]*Profile{ + "a": {Name: "a", BaseURL: "https://a.com"}, + "b": {Name: "b", BaseURL: "https://b.com"}, + } + _, err := Resolve(ResolveOptions{Profiles: profiles}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "multiple profiles") +} + +func TestResolveInteractivePicker(t *testing.T) { + profiles := map[string]*Profile{ + "a": {Name: "a", BaseURL: "https://a.com"}, + "b": {Name: "b", BaseURL: "https://b.com"}, + } + name, err := Resolve(ResolveOptions{ + Profiles: profiles, + Interactive: true, + Picker: func(names []string) (string, error) { return "b", nil }, + }) + require.NoError(t, err) + assert.Equal(t, "b", name) +} + +func TestResolveNotFound(t *testing.T) { + profiles := map[string]*Profile{ + "a": {Name: "a", BaseURL: "https://a.com"}, + } + + _, err := Resolve(ResolveOptions{FlagValue: "missing", Profiles: profiles}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + + _, err = Resolve(ResolveOptions{EnvVar: "missing", Profiles: profiles}) + assert.Error(t, err) + + _, err = Resolve(ResolveOptions{DefaultProfile: "missing", Profiles: profiles}) + assert.Error(t, err) +} diff --git a/profile/resolve.go b/profile/resolve.go new file mode 100644 index 0000000..613f0fb --- /dev/null +++ b/profile/resolve.go @@ -0,0 +1,90 @@ +package profile + +import ( + "fmt" + "sort" +) + +// ResolveOptions controls how profile resolution behaves. +type ResolveOptions struct { + // FlagValue is the --profile flag value (highest priority). + FlagValue string + + // EnvVar is the environment variable value (e.g., APP_PROFILE). + EnvVar string + + // DefaultProfile is the default_profile from config. + DefaultProfile string + + // Profiles is the set of known profiles. + Profiles map[string]*Profile + + // Interactive is true when the user can be prompted (TTY, no --agent/--json). + // When true and multiple profiles exist with no selection, Picker is called. + Interactive bool + + // Picker prompts the user to choose a profile. Only called when + // Interactive is true and multiple profiles exist with no other selection. + // Returns the chosen profile name. If nil, resolution fails instead of prompting. + Picker func(names []string) (string, error) +} + +// Resolve determines the active profile using strict precedence: +// +// 1. --profile flag +// 2. APP_PROFILE env var +// 3. default_profile in config +// 4. Auto-select if exactly one profile exists +// 5. Interactive picker (if available) +// 6. Error +// +// Returns ("", nil) when no profiles are configured (profile-less mode). +func Resolve(opts ResolveOptions) (string, error) { + if len(opts.Profiles) == 0 { + return "", nil + } + + // 1. Flag + if opts.FlagValue != "" { + if _, ok := opts.Profiles[opts.FlagValue]; !ok { + return "", fmt.Errorf("profile %q not found", opts.FlagValue) + } + return opts.FlagValue, nil + } + + // 2. Env var + if opts.EnvVar != "" { + if _, ok := opts.Profiles[opts.EnvVar]; !ok { + return "", fmt.Errorf("profile %q (from environment) not found", opts.EnvVar) + } + return opts.EnvVar, nil + } + + // 3. Config default + if opts.DefaultProfile != "" { + if _, ok := opts.Profiles[opts.DefaultProfile]; !ok { + return "", fmt.Errorf("default profile %q not found", opts.DefaultProfile) + } + return opts.DefaultProfile, nil + } + + // 4. Auto-select single profile + if len(opts.Profiles) == 1 { + for name := range opts.Profiles { + return name, nil + } + } + + // 5. Interactive picker + if opts.Interactive && opts.Picker != nil { + names := make([]string, 0, len(opts.Profiles)) + for name := range opts.Profiles { + names = append(names, name) + } + sort.Strings(names) + return opts.Picker(names) + } + + // 6. Error — multiple profiles, no selection + return "", fmt.Errorf("multiple profiles configured; use --profile or set a default") +} From 5bf86d81b01b09eab7e8f642b1d60a50d2b6606c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 4 Mar 2026 13:54:43 -0800 Subject: [PATCH 08/11] Add CLI rubric specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 80 criteria across 4 tiers evaluating agent operability, reliability, integration, and distribution. Two profiles: API CLI (full rubric) and TUI tool (subset). 7 design principles. Scoring template for evaluating any CLI against the standard. Tier 1: Agent Contract (26) — structured output, exit codes, programmatic discovery, authentication Tier 2: Reliability (16) — surface stability, resilience, configuration Tier 3: Agent Integration (11) — skill/plugin, pagination, observability Tier 4: Distribution & Ecosystem (27) — distribution, testing, completion, developer experience --- RUBRIC.md | 310 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 RUBRIC.md diff --git a/RUBRIC.md b/RUBRIC.md new file mode 100644 index 0000000..6ada0d7 --- /dev/null +++ b/RUBRIC.md @@ -0,0 +1,310 @@ +# 37signals CLI Rubric + +A specification that codifies the design decisions from `basecamp-cli` into a reusable standard for all 37signals Go CLIs. Use this rubric to evaluate existing CLIs, guide new ones, and ensure consistency across the portfolio. + +## Profiles + +| Profile | Scope | Tiers | +|---------|-------|-------| +| **API CLI** | Full-featured CLI wrapping a 37signals product API (e.g. `basecamp`, `hey`) | All 4 tiers, all criteria | +| **TUI tool** | Single-purpose terminal UI or developer tool | 1D (Auth), 4A (Distribution), 4B (Testing), 4D (Dev Experience), plus TUI-specific criteria where noted | + +Criteria marked **(API)** apply only to the API CLI profile. All other criteria apply to both profiles. + +--- + +## Philosophy + +### 1. Structured output is the default +Every command returns a JSON envelope (`{ok, data, summary, breadcrumbs}`) when piped or when `--json` is passed. TTY gets styled output. The same command serves humans and machines. + +### 2. Typed exit codes are a contract +Eight codes (0–8) map to categories agents can branch on without parsing stderr. `0=OK, 1=Usage, 2=NotFound, 3=Auth, 4=Forbidden, 5=RateLimit, 6=Network, 7=API, 8=Ambiguous`. + +### 3. Programmatic discovery beats documentation +`--help --agent` returns structured JSON. Breadcrumbs in every response suggest next actions. `commands --json` returns the full catalog. An agent can explore the CLI without reading docs. + +### 4. Breadcrumbs are navigation, not decoration +Every success response includes suggested follow-up commands. This is the primary mechanism for agent chaining — each response tells the agent what to do next. + +### 5. Errors are actionable data +Every error carries a machine-readable code, a human hint, and a retryable flag. Agents retry rate limits automatically. Humans see what to do. + +### 6. TTY auto-detection makes the right thing easy +`FormatAuto` resolves based on stdout. No flags needed for the common case. `--json` forces JSON. `--styled` forces ANSI. The CLI adapts to its context. + +### 7. Surface stability enables automation +Flag and subcommand removals are breaking changes caught by CI. Agents can depend on the CLI's surface as a stable API. + +--- + +## Tier 1: Agent Contract + +### 1A. Structured Output **(API)** + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 1A.1 | `--json` flag on every command | Yes | `output` | `internal/output/format.go` | +| 1A.2 | TTY auto-detection: styled on terminal, JSON when piped | Yes | `output` | `internal/output/format.go` | +| 1A.3 | Success envelope: `{ok: true, data, summary, breadcrumbs}` | Yes | `output` | `internal/output/envelope.go` | +| 1A.4 | Error envelope: `{ok: false, error, code, hint}` | Yes | `output` | `internal/output/errors.go` | +| 1A.5 | `--quiet` flag: raw JSON data, no envelope | Yes | `output` | `internal/output/format.go` | +| 1A.6 | `--agent` flag: quiet JSON + suppress interactive prompts | Yes | `output` | `internal/output/format.go` | +| 1A.7 | `--ids-only`: one ID per line | No | `output` | `internal/output/format.go` | +| 1A.8 | `--count`: integer count only | No | `output` | `internal/output/format.go` | +| 1A.9 | `--markdown`: literal GFM output | No | — | — | +| 1A.10 | Large integer ID preservation (`json.Decoder.UseNumber`) | Yes | `output` | `internal/output/json.go` | + +### 1B. Exit Codes + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 1B.1 | 8 typed exit codes: 0=OK 1=Usage 2=NotFound 3=Auth 4=Forbidden 5=RateLimit 6=Network 7=API 8=Ambiguous | Yes | `output` | `internal/output/exit.go` | +| 1B.2 | Machine-readable code strings in error envelope **(API)** | Yes | `output` | `internal/output/errors.go` | +| 1B.3 | Typed error constructors | Yes | `output` | `internal/output/errors.go` | +| 1B.4 | Errors carry retryable flag | Yes | `output` | `internal/output/errors.go` | +| 1B.5 | Errors carry actionable hint text | Yes | `output` | `internal/output/errors.go` | + +### 1C. Programmatic Discovery **(API)** + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 1C.1 | `--help --agent` emits structured JSON | Yes | `surface` | `internal/cli/help.go` | +| 1C.2 | Breadcrumbs in every success response | Yes | `output` | `internal/output/envelope.go` | +| 1C.3 | ` commands --json` returns full catalog | No | — | `internal/commands/commands.go` | +| 1C.4 | Agent notes via command annotations | No | — | `internal/commands/commands.go` | +| 1C.5 | Interactive prompts suppressed for machine output | Yes | `output` | `internal/output/format.go` | + +### 1D. Authentication + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 1D.1 | `APP_TOKEN` env var bypasses all interactive auth | Yes | — | `internal/auth/token.go` | +| 1D.2 | Interactive auth flow (OAuth+PKCE, token wizard, etc.) | Yes | `pkce`, `oauthcallback` | `internal/auth/oauth.go` | +| 1D.3 | System keyring preferred, file fallback (0600) | Yes | `credstore` | `internal/auth/credstore/` | +| 1D.4 | Token auto-refresh with expiry buffer | Yes | — | `internal/auth/refresh.go` | +| 1D.5 | Auth management commands (login, logout, status) | Yes | — | `internal/commands/auth.go` | +| 1D.6 | `APP_NO_KEYRING=1` env to force file storage | Yes | `credstore` | `internal/auth/credstore/` | + +--- + +## Tier 2: Reliability + +### 2A. Surface Stability + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 2A.1 | `--version` with embedded version, commit, date | Yes | — | `internal/version/version.go` | +| 2A.2 | CLI surface snapshot generation | No | `surface` | `internal/surface/` | +| 2A.3 | Surface compat check in CI (fail on removals) | No | `surface` | `.github/workflows/` | +| 2A.4 | Command catalog parity test **(API)** | No | — | `internal/commands/commands_test.go` | +| 2A.5 | Cobra error messages normalized | No | — | `internal/cli/root.go` | + +### 2B. Resilience + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 2B.1 | Retry with exponential backoff | Yes | — | `internal/resilience/retry.go` | +| 2B.2 | Rate limit handling (429, Retry-After) | Yes | `output` | `internal/resilience/ratelimit.go` | +| 2B.3 | Circuit breaker | No | — | — | +| 2B.4 | Request concurrency limiter | No | — | — | + +### 2C. Configuration + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 2C.1 | Layered config: flag > env > local > repo > global > default | Yes | — | `internal/config/` | +| 2C.2 | Source tracking on every config value | Yes | — | `internal/config/source.go` | +| 2C.3 | `config show` with source attribution **(API)** | Yes | — | `internal/commands/config.go` | +| 2C.4 | Per-repo config at git root | Yes | — | `internal/config/` | +| 2C.5 | HTTPS enforcement for non-localhost | Yes | — | `internal/config/` | +| 2C.6 | XDG directory compliance (config/state/cache separation) | Yes | — | `internal/config/` | +| 2C.7 | Named profiles (`--profile`, `APP_PROFILE`, default_profile) | Yes | `profile` | `internal/config/` | + +--- + +## Tier 3: Agent Integration **(API)** + +### 3A. Skill & Plugin + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 3A.1 | `SKILL.md` embedded via `go:embed` | Yes | — | `skills/SKILL.md` | +| 3A.2 | ` skill` prints embedded skill | Yes | — | `internal/commands/skill.go` | +| 3A.3 | `.claude-plugin/` with plugin.json, hooks, agents | Yes | — | `.claude-plugin/` | +| 3A.4 | SessionStart hook emits CLI context | Yes | — | `.claude-plugin/hooks/` | +| 3A.5 | Skill synced to `basecamp/skills` on release | Yes | — | `scripts/sync-skills.sh` | + +### 3B. Pagination + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 3B.1 | `--limit N` to cap results | Yes | — | `internal/commands/` | +| 3B.2 | `--all` to fetch all pages | Yes | — | `internal/commands/` | +| 3B.3 | Truncation notice in response | Yes | `output` | `internal/output/envelope.go` | + +### 3C. Observability + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 3C.1 | `--verbose` / `-v` (stackable) | Yes | — | `internal/cli/root.go` | +| 3C.2 | `APP_DEBUG` env var | Yes | — | `internal/cli/root.go` | +| 3C.3 | `--stats` adds `meta.stats` to envelope **(API)** | No | — | — | + +--- + +## Tier 4: Distribution & Ecosystem + +### 4A. Distribution + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 4A.1 | Cross-platform builds (darwin/linux arm64+amd64, windows amd64) | Yes | — | `.goreleaser.yml` | +| 4A.2 | Homebrew tap | Yes | — | `.goreleaser.yml` | +| 4A.3 | One-line install script | Yes | — | `install.sh` | +| 4A.4 | GoReleaser (or equivalent) release automation | Yes | — | `.goreleaser.yml` | +| 4A.5 | SHA256 checksums + cosign signing | Yes | — | `.goreleaser.yml` | +| 4A.6 | SBOM generation | Yes | — | `.goreleaser.yml` | +| 4A.7 | macOS notarization | Yes | — | `.goreleaser.yml` | +| 4A.8 | Scoop (Windows) | Yes | — | `.goreleaser.yml` | +| 4A.9 | AUR (Arch Linux) | Yes | — | `scripts/publish-aur.sh` | + +### 4B. Testing + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 4B.1 | Unit tests | Yes | — | `*_test.go` | +| 4B.2 | E2E integration tests (BATS or subprocess) | Yes | — | `e2e/` | +| 4B.3 | E2E in CI | Yes | — | `.github/workflows/` | +| 4B.4 | TUI integration tests (appDriver pattern, if TUI exists) | No | — | — | +| 4B.5 | Race detection in CI | No | — | — | +| 4B.6 | Fuzz testing for parsers | No | — | — | +| 4B.7 | Benchmarks with regression detection | No | — | — | + +### 4C. Shell Completion **(API)** + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 4C.1 | ` completion bash/zsh/fish/powershell` | Yes | — | `internal/commands/completion.go` | +| 4C.2 | File-based completion cache with TTL | No | — | — | +| 4C.3 | Flag-specific dynamic completions | No | — | — | + +### 4D. Developer Experience + +| # | Criterion | Seed | pkg | Reference | +|---|-----------|------|-----|-----------| +| 4D.1 | README with quick-start, examples, output format docs | Yes | — | `README.md` | +| 4D.2 | CONTRIBUTING.md | Yes | — | `CONTRIBUTING.md` | +| 4D.3 | AGENTS.md with coding style section | Yes | — | `AGENTS.md` | +| 4D.4 | Makefile with build, test, test-e2e, check, lint | Yes | — | `Makefile` | +| 4D.5 | golangci-lint with committed config | Yes | — | `.golangci.yml` | +| 4D.6 | `doctor` command (connectivity, auth, config, cache, completion) | Yes | — | `internal/commands/doctor.go` | +| 4D.7 | `setup` command with first-run auto-detection | Yes | — | `internal/commands/wizard.go` | +| 4D.8 | API coverage tracking **(API)** | Yes | — | `API-COVERAGE.md` | +| 4D.9 | CI pipeline (test, lint, security, e2e, surface) | Yes | — | `.github/workflows/` | + +--- + +## Scoring Template + +Copy this template and fill it in to score a CLI against the rubric. + +For the **TUI tool profile**, score only the applicable tiers (1D, 4A, 4B, 4D) and mark all **(API)** criteria as N/A. + +```markdown +## Scorecard: [CLI Name] + +| Tier | Score | Max | +|------|-------|-----| +| T1: Agent Contract | /26 | 26 | +| T2: Reliability | /16 | 16 | +| T3: Agent Integration | /11 | 11 | +| T4: Distribution | /28 | 28 | +| **Total** | **/81** | **81** | + +### Detailed Results + +| # | Criterion | Pass | N/A | Notes | +|---|-----------|------|-----|-------| +| 1A.1 | `--json` flag on every command | | | | +| 1A.2 | TTY auto-detection | | | | +| 1A.3 | Success envelope | | | | +| 1A.4 | Error envelope | | | | +| 1A.5 | `--quiet` flag | | | | +| 1A.6 | `--agent` flag | | | | +| 1A.7 | `--ids-only` | | | | +| 1A.8 | `--count` | | | | +| 1A.9 | `--markdown` | | | | +| 1A.10 | Large integer ID preservation | | | | +| 1B.1 | 8 typed exit codes | | | | +| 1B.2 | Machine-readable code strings | | | | +| 1B.3 | Typed error constructors | | | | +| 1B.4 | Retryable flag | | | | +| 1B.5 | Actionable hint text | | | | +| 1C.1 | `--help --agent` | | | | +| 1C.2 | Breadcrumbs | | | | +| 1C.3 | `commands --json` | | | | +| 1C.4 | Agent notes | | | | +| 1C.5 | Prompts suppressed | | | | +| 1D.1 | Token env var | | | | +| 1D.2 | Interactive auth flow | | | | +| 1D.3 | System keyring + file fallback | | | | +| 1D.4 | Token auto-refresh | | | | +| 1D.5 | Auth management commands | | | | +| 1D.6 | No-keyring env var | | | | +| 2A.1 | `--version` | | | | +| 2A.2 | Surface snapshot | | | | +| 2A.3 | Surface compat CI | | | | +| 2A.4 | Catalog parity test | | | | +| 2A.5 | Cobra errors normalized | | | | +| 2B.1 | Retry with backoff | | | | +| 2B.2 | Rate limit handling | | | | +| 2B.3 | Circuit breaker | | | | +| 2B.4 | Concurrency limiter | | | | +| 2C.1 | Layered config | | | | +| 2C.2 | Source tracking | | | | +| 2C.3 | `config show` | | | | +| 2C.4 | Per-repo config | | | | +| 2C.5 | HTTPS enforcement | | | | +| 2C.6 | XDG compliance | | | | +| 2C.7 | Named profiles | | | | +| 3A.1 | Embedded SKILL.md | | | | +| 3A.2 | `skill` command | | | | +| 3A.3 | `.claude-plugin/` | | | | +| 3A.4 | SessionStart hook | | | | +| 3A.5 | Skill synced on release | | | | +| 3B.1 | `--limit N` | | | | +| 3B.2 | `--all` | | | | +| 3B.3 | Truncation notice | | | | +| 3C.1 | `--verbose` / `-v` | | | | +| 3C.2 | Debug env var | | | | +| 3C.3 | `--stats` | | | | +| 4A.1 | Cross-platform builds | | | | +| 4A.2 | Homebrew tap | | | | +| 4A.3 | Install script | | | | +| 4A.4 | GoReleaser | | | | +| 4A.5 | Checksums + signing | | | | +| 4A.6 | SBOM | | | | +| 4A.7 | macOS notarization | | | | +| 4A.8 | Scoop (Windows) | | | | +| 4A.9 | AUR (Arch Linux) | | | | +| 4B.1 | Unit tests | | | | +| 4B.2 | E2E tests | | | | +| 4B.3 | E2E in CI | | | | +| 4B.4 | TUI integration tests | | | | +| 4B.5 | Race detection | | | | +| 4B.6 | Fuzz testing | | | | +| 4B.7 | Benchmarks | | | | +| 4C.1 | Shell completion | | | | +| 4C.2 | Completion cache | | | | +| 4C.3 | Dynamic completions | | | | +| 4D.1 | README | | | | +| 4D.2 | CONTRIBUTING.md | | | | +| 4D.3 | AGENTS.md | | | | +| 4D.4 | Makefile | | | | +| 4D.5 | golangci-lint | | | | +| 4D.6 | `doctor` command | | | | +| 4D.7 | `setup` command | | | | +| 4D.8 | API coverage tracking | | | | +| 4D.9 | CI pipeline | | | | +``` From b347c908e330f83b50978c39fa591e2d06f2bd02 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 4 Mar 2026 13:54:48 -0800 Subject: [PATCH 09/11] Add reusable GitHub Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/rubric-check — score a CLI against the rubric actions/surface-compat — fail on flag/subcommand removal actions/sync-skills — publish embedded skills to basecamp/skills --- actions/rubric-check/action.yml | 111 +++++++++++++++++++++++++ actions/surface-compat/action.yml | 110 ++++++++++++++++++++++++ actions/sync-skills/action.yml | 133 ++++++++++++++++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 actions/rubric-check/action.yml create mode 100644 actions/surface-compat/action.yml create mode 100644 actions/sync-skills/action.yml diff --git a/actions/rubric-check/action.yml b/actions/rubric-check/action.yml new file mode 100644 index 0000000..b093260 --- /dev/null +++ b/actions/rubric-check/action.yml @@ -0,0 +1,111 @@ +name: CLI Rubric Check +description: Score a Go CLI against the 37signals CLI rubric + +inputs: + cli-binary: + description: Path to the built CLI binary + required: true + profile: + description: Rubric profile (api-cli or tui-tool) + required: false + default: api-cli + +outputs: + score: + description: Number of criteria passed + value: ${{ steps.score.outputs.score }} + total: + description: Total criteria for profile + value: ${{ steps.score.outputs.total }} + report: + description: Full rubric report + value: ${{ steps.score.outputs.report }} + +runs: + using: composite + steps: + - name: Verify binary exists + shell: bash + run: | + if [ ! -x "${{ inputs.cli-binary }}" ]; then + echo "::error::CLI binary not found or not executable: ${{ inputs.cli-binary }}" + exit 1 + fi + + - name: Score rubric + id: score + shell: bash + run: | + BINARY="${{ inputs.cli-binary }}" + PROFILE="${{ inputs.profile }}" + PASSED=0 + FAILED=0 + TOTAL=0 + REPORT="" + + check() { + local id="$1" desc="$2" result="$3" + TOTAL=$((TOTAL + 1)) + if [ "$result" = "pass" ]; then + PASSED=$((PASSED + 1)) + REPORT="${REPORT}${id} | PASS | ${desc}\n" + else + FAILED=$((FAILED + 1)) + REPORT="${REPORT}${id} | FAIL | ${desc}\n" + fi + } + + # 2A.1: --version flag + if "$BINARY" --version >/dev/null 2>&1; then + check "2A.1" "--version with embedded version" "pass" + else + check "2A.1" "--version with embedded version" "fail" + fi + + # 1B.1: Exit codes - bad usage should exit 1 + "$BINARY" --nonexistent-flag >/dev/null 2>&1 + EXIT_CODE=$? + if [ "$EXIT_CODE" -eq 1 ]; then + check "1B.1" "Exit code 1 on bad usage" "pass" + else + check "1B.1" "Exit code 1 on bad usage (got $EXIT_CODE)" "fail" + fi + + if [ "$PROFILE" = "api-cli" ]; then + # 1A.1: --json flag + if "$BINARY" --help 2>&1 | grep -q '\-\-json'; then + check "1A.1" "--json flag available" "pass" + else + check "1A.1" "--json flag available" "fail" + fi + + # 1A.6: --agent flag + if "$BINARY" --help 2>&1 | grep -q '\-\-agent'; then + check "1A.6" "--agent flag available" "pass" + else + check "1A.6" "--agent flag available" "fail" + fi + + # 1C.1: --help --agent structured JSON + if "$BINARY" --help --agent 2>/dev/null | python3 -m json.tool >/dev/null 2>&1; then + check "1C.1" "--help --agent emits JSON" "pass" + else + check "1C.1" "--help --agent emits JSON" "fail" + fi + fi + + echo "score=${PASSED}" >> "$GITHUB_OUTPUT" + echo "total=${TOTAL}" >> "$GITHUB_OUTPUT" + + # Write report + { + echo "report<> "$GITHUB_OUTPUT" + + echo "## Rubric Score: ${PASSED}/${TOTAL}" >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/surface-compat/action.yml b/actions/surface-compat/action.yml new file mode 100644 index 0000000..d0da717 --- /dev/null +++ b/actions/surface-compat/action.yml @@ -0,0 +1,110 @@ +name: CLI Surface Compatibility Check +description: Fail if CLI flags or subcommands were removed (breaking change) + +inputs: + cli-binary: + description: Path to the built CLI binary + required: true + baseline: + description: Path to the baseline surface snapshot file + required: true + +outputs: + added: + description: Number of surface entries added + value: ${{ steps.diff.outputs.added }} + removed: + description: Number of surface entries removed (breaking) + value: ${{ steps.diff.outputs.removed }} + +runs: + using: composite + steps: + - name: Verify inputs + shell: bash + run: | + if [ ! -x "${{ inputs.cli-binary }}" ]; then + echo "::error::CLI binary not found: ${{ inputs.cli-binary }}" + exit 1 + fi + if [ ! -f "${{ inputs.baseline }}" ]; then + echo "::error::Baseline file not found: ${{ inputs.baseline }}" + exit 1 + fi + + - name: Generate current surface + shell: bash + run: | + # Walk the CLI command tree and produce surface snapshot + BINARY="${{ inputs.cli-binary }}" + ROOT_NAME=$(basename "$BINARY") + + walk_commands() { + local cmd_path="$1" + local -a args=() + if [ "$cmd_path" != "$ROOT_NAME" ]; then + args=(${cmd_path#$ROOT_NAME }) + fi + + local json + json=$("$BINARY" "${args[@]}" --help --agent 2>/dev/null) || { + echo "::warning::--help --agent failed for: $cmd_path" + return 0 + } + + echo "$json" | jq -r --arg path "$cmd_path" ' + "CMD \($path)", + ((.flags // []) | sort_by(.name) | .[] | + "FLAG \($path) --\(.name) type=\(.type)"), + ((.subcommands // []) | sort_by(.name) | .[] | + "SUB \($path) \(.name)") + ' 2>/dev/null || true + + local subs + subs=$(echo "$json" | jq -r '.subcommands // [] | .[].name' 2>/dev/null) || true + for sub in $subs; do + walk_commands "$cmd_path $sub" + done + } + + walk_commands "$ROOT_NAME" | LC_ALL=C sort > /tmp/surface-current.txt + + - name: Diff surfaces + id: diff + shell: bash + run: | + BASELINE="${{ inputs.baseline }}" + CURRENT="/tmp/surface-current.txt" + + # comm requires sorted input — sort both with consistent locale + # Sort to temp files to avoid modifying the checked-in baseline + BASELINE_SORTED=$(mktemp) + LC_ALL=C sort "$BASELINE" > "$BASELINE_SORTED" + LC_ALL=C sort "$CURRENT" -o "$CURRENT" + BASELINE="$BASELINE_SORTED" + + REMOVED=$(comm -23 "$BASELINE" "$CURRENT" | wc -l | tr -d ' ') + ADDED=$(comm -13 "$BASELINE" "$CURRENT" | wc -l | tr -d ' ') + + echo "added=${ADDED}" >> "$GITHUB_OUTPUT" + echo "removed=${REMOVED}" >> "$GITHUB_OUTPUT" + + if [ "$REMOVED" -gt 0 ]; then + echo "::error::${REMOVED} surface entries removed (breaking change)" + echo "" + echo "Removed entries:" + comm -23 "$BASELINE" "$CURRENT" + echo "" + if [ "$ADDED" -gt 0 ]; then + echo "Added entries (informational):" + comm -13 "$BASELINE" "$CURRENT" + fi + exit 1 + fi + + echo "Surface check passed. ${ADDED} entries added, 0 removed." + if [ "$ADDED" -gt 0 ]; then + echo "" + echo "New entries:" + comm -13 "$BASELINE" "$CURRENT" + fi diff --git a/actions/sync-skills/action.yml b/actions/sync-skills/action.yml new file mode 100644 index 0000000..b8de04c --- /dev/null +++ b/actions/sync-skills/action.yml @@ -0,0 +1,133 @@ +name: Sync Skills +description: Sync embedded SKILL.md files to basecamp/skills distribution repo + +inputs: + skills-token: + description: GitHub token with push access to basecamp/skills + required: true + release-tag: + description: The release tag (e.g., v1.2.3) + required: true + source-sha: + description: The source commit SHA + required: true + cli-name: + description: The CLI name (used as directory prefix in skills repo) + required: true + dry-run: + description: '"local" to skip push, "remote" to skip commit+push, empty for real run' + required: false + default: "" + +runs: + using: composite + steps: + - name: Sync skills to distribution repo + shell: bash + env: + SKILLS_TOKEN: ${{ inputs.skills-token }} + RELEASE_TAG: ${{ inputs.release-tag }} + SOURCE_SHA: ${{ inputs.source-sha }} + CLI_NAME: ${{ inputs.cli-name }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + SKILLS_REPO="basecamp/skills" + SKILLS_DIR="skills" + MANAGED_MANIFEST=".managed-skills" + + # Clone the skills repo + WORK_DIR=$(mktemp -d) + trap 'rm -rf "$WORK_DIR"' EXIT + + echo "::group::Clone skills repo" + if ! git clone "https://x-access-token:${SKILLS_TOKEN}@github.com/${SKILLS_REPO}.git" "$WORK_DIR/skills-repo" 2>&1 | grep -v 'x-access-token'; then + echo "::error::Failed to clone ${SKILLS_REPO}" + exit 1 + fi + echo "::endgroup::" + + TARGET_DIR="${WORK_DIR}/skills-repo" + + # Collect skill directories + SKILL_DIRS=() + for skill_dir in ${SKILLS_DIR}/*/; do + if [[ -f "${skill_dir}/SKILL.md" ]]; then + SKILL_DIRS+=("$skill_dir") + fi + done + + if [[ ${#SKILL_DIRS[@]} -eq 0 ]]; then + echo "No skills found in ${SKILLS_DIR}/" + exit 0 + fi + + echo "Found ${#SKILL_DIRS[@]} skill(s) to sync" + + # Copy skills + MANAGED_SKILLS=() + for skill_dir in "${SKILL_DIRS[@]}"; do + skill_name=$(basename "$skill_dir") + dest="${TARGET_DIR}/${CLI_NAME}/${skill_name}" + echo " Syncing ${skill_name}..." + mkdir -p "$dest" + # Preserve subdirectory structure; exclude Go sources and dotfiles + (cd "$skill_dir" && find . -type f ! -name '*.go' ! -name '.*' | while read -r f; do + mkdir -p "$dest/$(dirname "$f")" + cp "$f" "$dest/$f" + done) + MANAGED_SKILLS+=("${CLI_NAME}/${skill_name}") + done + + # Update manifest + MANIFEST_PATH="${TARGET_DIR}/${MANAGED_MANIFEST}" + if [[ -f "$MANIFEST_PATH" ]]; then + grep -v "^${CLI_NAME}/" "$MANIFEST_PATH" > "${MANIFEST_PATH}.tmp" || true + mv "${MANIFEST_PATH}.tmp" "$MANIFEST_PATH" + fi + for skill in "${MANAGED_SKILLS[@]}"; do + echo "$skill" >> "$MANIFEST_PATH" + done + sort -u -o "$MANIFEST_PATH" "$MANIFEST_PATH" + + # Remove stale skills + if [[ -d "${TARGET_DIR}/${CLI_NAME}" ]]; then + for existing in "${TARGET_DIR}/${CLI_NAME}"/*/; do + existing_name=$(basename "$existing") + found=false + for skill_dir in "${SKILL_DIRS[@]}"; do + [[ "$(basename "$skill_dir")" == "$existing_name" ]] && found=true && break + done + if [[ "$found" == "false" ]]; then + echo " Removing stale: ${existing_name}" + rm -rf "$existing" + fi + done + fi + + [[ "$DRY_RUN" == "remote" ]] && echo "DRY_RUN=remote: done" && exit 0 + + # Commit + cd "$TARGET_DIR" + git add -A + if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi + + git config user.name "${CLI_NAME}-cli[bot]" + git config user.email "${CLI_NAME}-cli[bot]@users.noreply.github.com" + git commit -m "Sync ${CLI_NAME} skills from ${RELEASE_TAG} + + Source: ${SOURCE_SHA}" + + [[ "$DRY_RUN" == "local" ]] && echo "DRY_RUN=local: done" && exit 0 + + # Push + echo "::group::Push to ${SKILLS_REPO}" + if ! git push origin main; then + git pull --rebase origin main + git push origin main + fi + echo "::endgroup::" + echo "Skills synced successfully" From f3f3466041b8ee16e742996e43b03f75004903fa Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 4 Mar 2026 13:54:56 -0800 Subject: [PATCH 10/11] Add seed templates for bootstrapping new CLIs Complete starter kit for a new 37signals CLI: - internal/output/ and internal/auth/ wrapping shared packages - internal/commands/ with doctor, setup, and skill command skeletons - .claude-plugin/ with SessionStart hook, navigator and context-linker agent templates - skills/ with embed.go and SKILL.md templates - scripts/sync-skills.sh for publishing skills on release - .goreleaser.yaml with cosign signing, SBOM, and notarization - .golangci.yml, Makefile, AGENTS.md, CONTRIBUTING.md templates - API-COVERAGE.md tracking matrix template --- seed/.claude-plugin/agents/context-linker.md | 40 ++++++ seed/.claude-plugin/agents/navigator.md | 48 +++++++ seed/.claude-plugin/hooks/session-start.sh | 76 ++++++++++ seed/.claude-plugin/plugin.json | 16 +++ seed/.golangci.yml | 26 ++++ seed/.goreleaser.yaml | 80 +++++++++++ seed/AGENTS.md.tmpl | 61 ++++++++ seed/API-COVERAGE.md.tmpl | 30 ++++ seed/CONTRIBUTING.md.tmpl | 43 ++++++ seed/Makefile | 46 ++++++ seed/internal/auth/auth.go | 28 ++++ seed/internal/commands/doctor.go.tmpl | 46 ++++++ seed/internal/commands/setup.go.tmpl | 50 +++++++ seed/internal/commands/skill.go.tmpl | 27 ++++ seed/internal/output/output.go | 42 ++++++ seed/scripts/publish-aur.sh | 92 ++++++++++++ seed/scripts/sync-skills.sh | 140 +++++++++++++++++++ seed/skills/app/SKILL.md.tmpl | 37 +++++ seed/skills/embed.go.tmpl | 7 + 19 files changed, 935 insertions(+) create mode 100644 seed/.claude-plugin/agents/context-linker.md create mode 100644 seed/.claude-plugin/agents/navigator.md create mode 100755 seed/.claude-plugin/hooks/session-start.sh create mode 100644 seed/.claude-plugin/plugin.json create mode 100644 seed/.golangci.yml create mode 100644 seed/.goreleaser.yaml create mode 100644 seed/AGENTS.md.tmpl create mode 100644 seed/API-COVERAGE.md.tmpl create mode 100644 seed/CONTRIBUTING.md.tmpl create mode 100644 seed/Makefile create mode 100644 seed/internal/auth/auth.go create mode 100644 seed/internal/commands/doctor.go.tmpl create mode 100644 seed/internal/commands/setup.go.tmpl create mode 100644 seed/internal/commands/skill.go.tmpl create mode 100644 seed/internal/output/output.go create mode 100755 seed/scripts/publish-aur.sh create mode 100755 seed/scripts/sync-skills.sh create mode 100644 seed/skills/app/SKILL.md.tmpl create mode 100644 seed/skills/embed.go.tmpl diff --git a/seed/.claude-plugin/agents/context-linker.md b/seed/.claude-plugin/agents/context-linker.md new file mode 100644 index 0000000..e4fb729 --- /dev/null +++ b/seed/.claude-plugin/agents/context-linker.md @@ -0,0 +1,40 @@ +--- +name: context-linker +description: | + Automatically link code changes to {{.Name}} items. + Use when: committing code, creating PRs, resolving issues. + Detects item IDs from branch names, commit messages, and PR descriptions. +--- + +# Context Linker Agent + +Connect code changes to {{.Name}} items. + +## Detection Patterns + +Look for references in: + +1. **Branch names**: `feature/todo-12345-description`, `fix/12345-bug` +2. **Commit messages**: `[#12345] Fix bug`, `Fixes #12345` +3. **PR descriptions**: `Closes #12345`, `Related: ` + +## Workflow: On Commit + +1. Extract item ID from branch name: + ```bash + BRANCH=$(git branch --show-current) + ITEM_ID=$(echo "$BRANCH" | grep -oE '[0-9]+' | head -1) + ``` + +2. If found, offer to link: + ```bash + COMMIT=$(git rev-parse --short HEAD) + MSG=$(git log -1 --format=%s) + # Add comment or update linked item + ``` + +## Workflow: On PR Creation + +1. Check branch name and PR description for item references +2. For each referenced item, add PR link +3. Offer to update item status when PR is merged diff --git a/seed/.claude-plugin/agents/navigator.md b/seed/.claude-plugin/agents/navigator.md new file mode 100644 index 0000000..e69fae7 --- /dev/null +++ b/seed/.claude-plugin/agents/navigator.md @@ -0,0 +1,48 @@ +--- +name: {{.Name}}-navigator +description: | + Cross-resource search and navigation for {{.Name}}. + Use when the user needs to find items, discover structure, + or navigate the workspace. +tools: + - Bash + - Read +model: sonnet +--- + +# {{.Name}} Navigator Agent + +You help users find and navigate {{.Name}} resources. + +## Capabilities + +1. **Search** — Find resources by content or attributes +2. **Discover structure** — List available resources and their relationships +3. **Filter and sort** — By status, assignee, date, type +4. **Navigate context** — Drill down into specific items + +## Available Commands + +```bash +# Discovery +{{.Name}} list +{{.Name}} show + +# Search +{{.Name}} search "query" + +# Filtered listing +{{.Name}} list --status active --limit 20 +``` + +## Search Strategy + +1. Use full-text search for content queries +2. Use list commands with filters for browsing +3. Narrow by known context (project, account, etc.) + +## Output + +- Show item ID for follow-up actions +- Include parent context for clarity +- Offer breadcrumb actions (view, update, delete) diff --git a/seed/.claude-plugin/hooks/session-start.sh b/seed/.claude-plugin/hooks/session-start.sh new file mode 100755 index 0000000..4729364 --- /dev/null +++ b/seed/.claude-plugin/hooks/session-start.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# session-start.sh — Load CLI context at session start for Claude Code. +# +# Emits config, auth status, and active profile so the agent knows +# what environment it's operating in. +# +# TODO: Replace CLI_NAME, config paths, and commands for your CLI. + +set -euo pipefail + +CLI_NAME="${CLI_NAME:-mycli}" + +# Require jq for JSON parsing +if ! command -v jq &>/dev/null; then + exit 0 +fi + +# Find the CLI binary — prefer PATH, fall back to plugin's bin directory +if command -v "$CLI_NAME" &>/dev/null; then + CLI_BIN="$CLI_NAME" +else + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + CLI_BIN="${SCRIPT_DIR}/../../bin/${CLI_NAME}" + if [[ ! -x "$CLI_BIN" ]]; then + cat << EOF + +${CLI_NAME} plugin: CLI not found. +Install: https://github.com/basecamp/${CLI_NAME}-cli#installation + +EOF + exit 0 + fi +fi + +# Get CLI version +cli_version=$("$CLI_BIN" --version 2>/dev/null | awk '{print $NF}' || true) + +# Check if we have configuration +config_output=$("$CLI_BIN" config show --json 2>/dev/null || echo '{}') +has_config=$(echo "$config_output" | jq -r '.data // empty' 2>/dev/null) + +if [[ -z "$has_config" ]] || [[ "$has_config" == "{}" ]]; then + exit 0 +fi + +# Build context message +context="${CLI_NAME} context loaded:" + +if [[ -n "$cli_version" ]]; then + context+="\n CLI: v${cli_version}" +fi + +# Show active profile if using named profiles +active_profile=$("$CLI_BIN" profile show --json 2>/dev/null | jq -r '.data.name // empty' 2>/dev/null || true) +if [[ -n "$active_profile" ]]; then + context+="\n Profile: $active_profile" +fi + +# Check if authenticated +auth_status=$("$CLI_BIN" auth status --json 2>/dev/null || echo '{}') +is_auth=$(echo "$auth_status" | jq -r '.data.authenticated // false') + +if [[ "$is_auth" != "true" ]]; then + context+="\n Auth: Not authenticated (run: ${CLI_NAME} auth login)" +fi + +cat << EOF + +$(echo -e "$context") + +Use \`${CLI_NAME}\` commands to interact with the API: + ${CLI_NAME} auth login # Authenticate + ${CLI_NAME} auth status # Check auth status + ${CLI_NAME} doctor # Diagnose configuration + +EOF diff --git a/seed/.claude-plugin/plugin.json b/seed/.claude-plugin/plugin.json new file mode 100644 index 0000000..dbfadb5 --- /dev/null +++ b/seed/.claude-plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "name": "{{.Name}}", + "version": "0.1.0", + "description": "{{.Name}} integration for Claude Code.", + "author": { + "name": "37signals", + "email": "support@37signals.com" + }, + "hooks": { + "SessionStart": ["hooks/session-start.sh"] + }, + "agents": [ + "agents/navigator.md", + "agents/context-linker.md" + ] +} diff --git a/seed/.golangci.yml b/seed/.golangci.yml new file mode 100644 index 0000000..159e5dc --- /dev/null +++ b/seed/.golangci.yml @@ -0,0 +1,26 @@ +linters: + enable: + - govet + - errcheck + - staticcheck + - unused + - gosimple + - ineffassign + - misspell + - gocritic + - gofmt + +linters-settings: + govet: + enable-all: true + disable: + - fieldalignment + errcheck: + check-blank: true + misspell: + locale: US + +issues: + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/seed/.goreleaser.yaml b/seed/.goreleaser.yaml new file mode 100644 index 0000000..8e2326d --- /dev/null +++ b/seed/.goreleaser.yaml @@ -0,0 +1,80 @@ +version: 2 + +builds: + - main: ./cmd/{{ .ProjectName }} + binary: "{{ .ProjectName }}" + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + +universal_binaries: + - replace: true + +archives: + - format: tar.gz + format_overrides: + - goos: windows + format: zip + +checksum: + name_template: checksums.txt + algorithm: sha256 + +signs: + - cmd: cosign + artifacts: checksum + args: + - sign-blob + - "--yes" + - "--output-signature=${signature}" + - "${artifact}" + +sboms: + - artifacts: archive + +# macOS notarization — requires Apple Developer credentials. +# Set GORELEASER_NOTARIZE_MACOS=true and configure notarytool credentials +# in CI to enable. See: https://goreleaser.com/customization/notarize/ +notarize: + - macos: + - enabled: '{{ isEnvSet "GORELEASER_NOTARIZE_MACOS" }}' + sign: + certificate: "{{.Env.MACOS_SIGN_P12}}" + password: "{{.Env.MACOS_SIGN_PASSWORD}}" + notarize: + issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}" + key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}" + key: "{{.Env.MACOS_NOTARY_KEY}}" + +scoops: + - repository: + owner: basecamp + name: scoop-bucket + homepage: "https://github.com/basecamp/{{ .ProjectName }}" + description: "{{ .ProjectName }} CLI" + license: MIT + +# AUR publishing is handled by scripts/publish-aur.sh in CI. +# See: https://wiki.archlinux.org/title/AUR_submission_guidelines + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" diff --git a/seed/AGENTS.md.tmpl b/seed/AGENTS.md.tmpl new file mode 100644 index 0000000..64a3577 --- /dev/null +++ b/seed/AGENTS.md.tmpl @@ -0,0 +1,61 @@ +# {{.Name}} CLI Development Context + +## Getting Started + +See [CONTRIBUTING.md](CONTRIBUTING.md) for build setup, testing, and PR workflow. + +## Repository Structure + +``` +{{.Name}}-cli/ +├── cmd/{{.Name}}/ # Main entrypoint +├── internal/ +│ ├── auth/ # Authentication +│ ├── commands/ # Command implementations +│ ├── config/ # Configuration management +│ ├── output/ # Output formatting (wraps github.com/basecamp/cli/output) +│ └── sdk/ # API SDK wrapper +├── e2e/ # BATS integration tests +├── skills/ # Agent skills +└── .claude-plugin/ # Claude Code integration +``` + +## Testing + +```bash +make build # Build binary +make test # Run Go unit tests +make test-e2e # Run BATS end-to-end tests +make check # All checks (fmt-check, vet, lint, test, test-e2e) +``` + +## Coding Style + +### Go Import Ordering + +1. Standard library +2. Third-party packages +3. Project-internal packages + +Separate each group with a blank line. + +### Method Ordering Within Types + +1. Constructor (`New*`) +2. Public methods (alphabetical) +3. Private methods (alphabetical) +4. Helpers + +### Naming Conventions + +- Commands: `newXxxCmd()` returns `*cobra.Command` +- Error constructors: `Err` prefix (e.g., `ErrNotFound`) +- Options: functional options pattern (`WithXxx`) +- Test helpers: `newTestXxx` or `setupXxx` + +## Output Format + +All commands produce structured output via the `github.com/basecamp/cli/output` package: +- Success: `{ok: true, data: ..., summary: "...", breadcrumbs: [...]}` +- Error: `{ok: false, error: "...", code: "...", hint: "..."}` +- Exit codes: 0=OK, 1=Usage, 2=NotFound, 3=Auth, 4=Forbidden, 5=RateLimit, 6=Network, 7=API, 8=Ambiguous diff --git a/seed/API-COVERAGE.md.tmpl b/seed/API-COVERAGE.md.tmpl new file mode 100644 index 0000000..29c2d2c --- /dev/null +++ b/seed/API-COVERAGE.md.tmpl @@ -0,0 +1,30 @@ +# API Coverage + +Tracks which API endpoints have corresponding CLI commands. + +## Coverage Summary + +| Section | Endpoints | Covered | % | +|---------|-----------|---------|---| +| Example Resource | 5 | 0 | 0% | +| **Total** | **5** | **0** | **0%** | + +## Detailed Coverage + +### Example Resource + +| Method | Endpoint | CLI Command | Status | +|--------|----------|-------------|--------| +| GET | `/resources.json` | `{{.Name}} resources list` | | +| GET | `/resources/{id}.json` | `{{.Name}} resources show` | | +| POST | `/resources.json` | `{{.Name}} resources create` | | +| PUT | `/resources/{id}.json` | `{{.Name}} resources update` | | +| DELETE | `/resources/{id}.json` | `{{.Name}} resources delete` | | + +## Out of Scope + +Endpoints intentionally not covered (e.g., admin-only, deprecated): + +| Section | Endpoints | Reason | +|---------|-----------|--------| +| — | — | — | diff --git a/seed/CONTRIBUTING.md.tmpl b/seed/CONTRIBUTING.md.tmpl new file mode 100644 index 0000000..b300f13 --- /dev/null +++ b/seed/CONTRIBUTING.md.tmpl @@ -0,0 +1,43 @@ +# Contributing to {{.Name}} CLI + +## Prerequisites + +- Go 1.24+ +- [bats-core](https://github.com/bats-core/bats-core) for e2e tests +- [golangci-lint](https://golangci-lint.run/) for linting + +## Building + +```bash +make build # Builds to ./bin/{{.Name}} +``` + +## Testing + +```bash +make test # Unit tests +make test-e2e # End-to-end tests (requires bats) +make check # Full check suite +``` + +## PR Workflow + +1. Create a feature branch from `main` +2. Make your changes +3. Run `make check` to verify all checks pass +4. Open a pull request + +## Code Style + +- Run `make fmt` before committing +- Follow patterns in existing code +- See AGENTS.md for detailed coding conventions + +## Release Process + +Releases are automated via GoReleaser on tagged commits. + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` diff --git a/seed/Makefile b/seed/Makefile new file mode 100644 index 0000000..7669ca3 --- /dev/null +++ b/seed/Makefile @@ -0,0 +1,46 @@ +BINARY_NAME := $(shell basename $(CURDIR)) +BUILD_DIR := ./bin + +.PHONY: check build test test-race test-e2e vet lint fmt fmt-check bench bench-cpu bench-mem check-all clean + +# Default target: fast checks suitable for pre-commit / inner-loop dev. +check: fmt-check vet test test-e2e + +build: + go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/$(BINARY_NAME) + +test: + go test ./... + +test-race: + go test -race ./... + +test-e2e: + bats e2e/ + +vet: + go vet ./... + +lint: + golangci-lint run + +fmt: + gofmt -w . + +fmt-check: + @test -z "$$(gofmt -l .)" || (echo "Run 'make fmt' to fix formatting" && gofmt -l . && exit 1) + +bench: + go test -bench=. -benchmem ./... + +bench-cpu: + go test -bench=. -cpuprofile=cpu.prof ./... + +bench-mem: + go test -bench=. -memprofile=mem.prof ./... + +# Full suite: everything CI runs. Slower — includes lint, race detector, benchmarks. +check-all: fmt-check vet lint test-race test-e2e bench + +clean: + rm -rf $(BUILD_DIR) diff --git a/seed/internal/auth/auth.go b/seed/internal/auth/auth.go new file mode 100644 index 0000000..387db1f --- /dev/null +++ b/seed/internal/auth/auth.go @@ -0,0 +1,28 @@ +// Package auth provides authentication using shared CLI infrastructure. +// +// This is a seed template. Customize ServiceName, DisableEnvVar, and +// the auth flow for your application. +package auth + +import ( + "github.com/basecamp/cli/credstore" + "github.com/basecamp/cli/pkce" +) + +// NewCredentialStore creates a credential store for this CLI. +// Replace "your-app" with your application name and "APP_NO_KEYRING" +// with your application's env var. +func NewCredentialStore(configDir string) *credstore.Store { + return credstore.NewStore(credstore.StoreOptions{ + ServiceName: "your-app", // TODO: Replace with your app name + DisableEnvVar: "APP_NO_KEYRING", // TODO: Replace with your env var + FallbackDir: configDir, + }) +} + +// GeneratePKCE generates PKCE verifier and challenge for OAuth flows. +func GeneratePKCE() (verifier, challenge string) { + verifier = pkce.GenerateVerifier() + challenge = pkce.GenerateChallenge(verifier) + return +} diff --git a/seed/internal/commands/doctor.go.tmpl b/seed/internal/commands/doctor.go.tmpl new file mode 100644 index 0000000..74e0d22 --- /dev/null +++ b/seed/internal/commands/doctor.go.tmpl @@ -0,0 +1,46 @@ +package commands + +// Doctor command skeleton — checks CLI health. +// +// Modeled on basecamp-cli's doctor command. Each check returns a +// Check{Name, Status, Message, Hint} struct. Checks run sequentially +// and the result is rendered as a styled checklist (TTY) or JSON +// envelope (piped/--json). +// +// Recommended checks: +// 1. CLI Version — binary version matches latest release +// 2. Config Files — config directory exists, files readable +// 3. Credentials — credential store accessible (keyring or file) +// 4. Authentication — token valid, not expired +// 5. API Connectivity — base URL reachable, healthy response +// 6. Account Access — authenticated user can list resources +// 7. Cache — cache directory exists, size reasonable +// 8. Shell Completion — completion script installed for current shell +// 9. Claude Plugin — .claude-plugin/ installed, hooks present +// +// Implementation pattern: +// +// func NewDoctorCmd() *cobra.Command { +// cmd := &cobra.Command{ +// Use: "doctor", +// Short: "Check CLI configuration and connectivity", +// RunE: runDoctor, +// } +// cmd.Flags().BoolP("verbose", "v", false, "Show all checks including skipped") +// return cmd +// } +// +// type Check struct { +// Name string `json:"name"` +// Status string `json:"status"` // "pass", "fail", "warn", "skip" +// Message string `json:"message"` +// Hint string `json:"hint,omitempty"` +// } +// +// type DoctorResult struct { +// Checks []Check `json:"checks"` +// Passed int `json:"passed"` +// Failed int `json:"failed"` +// Warned int `json:"warned"` +// Skipped int `json:"skipped"` +// } diff --git a/seed/internal/commands/setup.go.tmpl b/seed/internal/commands/setup.go.tmpl new file mode 100644 index 0000000..0757375 --- /dev/null +++ b/seed/internal/commands/setup.go.tmpl @@ -0,0 +1,50 @@ +package commands + +// Setup command skeleton — guided first-run onboarding. +// +// Modeled on basecamp-cli's setup/wizard command. Detects first-run +// condition (no credentials + no token env + interactive TTY) and +// walks the user through authentication and initial configuration. +// +// Recommended flow: +// 1. Welcome — brief intro, what setup will do +// 2. Authentication — run auth login (OAuth, PAT, etc.) +// 3. Account Selection — if multiple accounts, pick one +// 4. Project Selection — optional: pick a default project/workspace +// 5. Save Config — write to global or local config, ask user +// 6. Summary — show what was configured, next steps +// +// First-run auto-detection: +// +// func isFirstRun(app *appctx.App) bool { +// // No stored credentials +// if _, err := credStore.Load(credentialKey); err != nil { +// // No token env var +// if os.Getenv("APP_TOKEN") == "" { +// // Interactive terminal +// if isatty.IsTerminal(os.Stdout.Fd()) { +// return true +// } +// } +// } +// return false +// } +// +// Implementation pattern: +// +// func NewSetupCmd() *cobra.Command { +// return &cobra.Command{ +// Use: "setup", +// Short: "Configure {{.Name}} for first use", +// Long: "Interactive setup wizard. Authenticates, selects defaults, and saves configuration.", +// RunE: runSetup, +// } +// } +// +// type SetupResult struct { +// Version string `json:"version"` +// Status string `json:"status"` // "complete", "partial", "skipped" +// AccountID int64 `json:"account_id,omitempty"` +// AccountName string `json:"account_name,omitempty"` +// ConfigScope string `json:"config_scope,omitempty"` // "global", "local" +// } diff --git a/seed/internal/commands/skill.go.tmpl b/seed/internal/commands/skill.go.tmpl new file mode 100644 index 0000000..af1b9ee --- /dev/null +++ b/seed/internal/commands/skill.go.tmpl @@ -0,0 +1,27 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" + + // TODO: Replace with your module path + "github.com/basecamp/{{.Name}}-cli/skills" +) + +// NewSkillCmd creates the skill command. +func NewSkillCmd() *cobra.Command { + return &cobra.Command{ + Use: "skill", + Short: "Print the embedded agent skill file", + Long: "Print the SKILL.md embedded in this binary. Any agent can bootstrap from this output.", + RunE: func(cmd *cobra.Command, args []string) error { + data, err := skills.FS.ReadFile("{{.Name}}/SKILL.md") + if err != nil { + return fmt.Errorf("reading embedded skill: %w", err) + } + _, err = fmt.Fprint(cmd.OutOrStdout(), string(data)) + return err + }, + } +} diff --git a/seed/internal/output/output.go b/seed/internal/output/output.go new file mode 100644 index 0000000..ff1849e --- /dev/null +++ b/seed/internal/output/output.go @@ -0,0 +1,42 @@ +// Package output wraps github.com/basecamp/cli/output with app-specific additions. +// +// This is a seed template. Customize it for your CLI. +package output + +import ( + "github.com/basecamp/cli/output" +) + +// Re-export core types for convenience. +type ( + Response = output.Response + ErrorResponse = output.ErrorResponse + Breadcrumb = output.Breadcrumb + Format = output.Format + Options = output.Options + Writer = output.Writer + Error = output.Error + ResponseOption = output.ResponseOption +) + +// Re-export format constants. +const ( + FormatAuto = output.FormatAuto + FormatJSON = output.FormatJSON + FormatMarkdown = output.FormatMarkdown + FormatStyled = output.FormatStyled + FormatQuiet = output.FormatQuiet + FormatIDs = output.FormatIDs + FormatCount = output.FormatCount +) + +// Re-export constructors and helpers. +var ( + New = output.New + DefaultOptions = output.DefaultOptions + WithSummary = output.WithSummary + WithNotice = output.WithNotice + WithBreadcrumbs = output.WithBreadcrumbs + WithContext = output.WithContext + WithMeta = output.WithMeta +) diff --git a/seed/scripts/publish-aur.sh b/seed/scripts/publish-aur.sh new file mode 100755 index 0000000..4d3781e --- /dev/null +++ b/seed/scripts/publish-aur.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# publish-aur.sh — Publish release to Arch User Repository. +# +# Run from CI after GoReleaser completes. Updates the AUR PKGBUILD +# with the new version and checksums, then pushes to the AUR git repo. +# +# Required env vars: +# AUR_SSH_KEY — SSH private key with push access to AUR +# RELEASE_TAG — The release tag (e.g., v1.2.3) +# +# TODO: Replace CLI_NAME and AUR_PACKAGE with your values. + +set -euo pipefail + +CLI_NAME="${CLI_NAME:-mycli}" +AUR_PACKAGE="${AUR_PACKAGE:-${CLI_NAME}-bin}" +VERSION="${RELEASE_TAG#v}" + +: "${AUR_SSH_KEY:?AUR_SSH_KEY is required}" +: "${RELEASE_TAG:?RELEASE_TAG is required}" + +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +# Set up SSH for AUR +mkdir -p ~/.ssh +echo "$AUR_SSH_KEY" > ~/.ssh/aur +chmod 600 ~/.ssh/aur +cat >> ~/.ssh/config < PKGBUILD < +pkgname=${AUR_PACKAGE} +pkgver=${VERSION} +pkgrel=1 +pkgdesc="${CLI_NAME} CLI" +arch=('x86_64' 'aarch64') +url="https://github.com/basecamp/${CLI_NAME}-cli" +license=('MIT') +provides=('${CLI_NAME}') +conflicts=('${CLI_NAME}') + +source_x86_64=("\${url}/releases/download/v\${pkgver}/${CLI_NAME}_\${pkgver}_linux_amd64.tar.gz") +source_aarch64=("\${url}/releases/download/v\${pkgver}/${CLI_NAME}_\${pkgver}_linux_arm64.tar.gz") +sha256sums_x86_64=('${AMD64_SHA}') +sha256sums_aarch64=('${ARM64_SHA}') + +package() { + install -Dm755 ${CLI_NAME} "\${pkgdir}/usr/bin/${CLI_NAME}" +} +PKGBUILD + +# Generate .SRCINFO +makepkg --printsrcinfo > .SRCINFO + +# Commit and push +git add PKGBUILD .SRCINFO +if git diff --cached --quiet; then + echo "No changes to AUR package" + exit 0 +fi + +git config user.name "${CLI_NAME}-cli[bot]" +git config user.email "${CLI_NAME}-cli[bot]@users.noreply.github.com" +git commit -m "Update to ${VERSION}" +git push origin master + +echo "AUR package updated to ${VERSION}" diff --git a/seed/scripts/sync-skills.sh b/seed/scripts/sync-skills.sh new file mode 100755 index 0000000..a395468 --- /dev/null +++ b/seed/scripts/sync-skills.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# sync-skills.sh — Sync embedded skills to basecamp/skills distribution repo. +# +# Run from CI on release (tag push). Copies skills/*/SKILL.md to the +# basecamp/skills repo, commits, and pushes. +# +# Required env vars: +# SKILLS_TOKEN — GitHub token with push access to basecamp/skills +# RELEASE_TAG — The release tag (e.g., v1.2.3) +# SOURCE_SHA — The source commit SHA +# +# Optional env vars: +# DRY_RUN — "local" to skip push, "remote" to skip commit+push +# +# TODO: Replace CLI_NAME with your CLI name. + +set -euo pipefail + +CLI_NAME="${CLI_NAME:-mycli}" +SKILLS_REPO="basecamp/skills" +SKILLS_DIR="skills" +MANAGED_MANIFEST=".managed-skills" + +: "${SKILLS_TOKEN:?SKILLS_TOKEN is required}" +: "${RELEASE_TAG:?RELEASE_TAG is required}" +: "${SOURCE_SHA:?SOURCE_SHA is required}" + +DRY_RUN="${DRY_RUN:-}" + +# Clone the skills repo +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +echo "Cloning ${SKILLS_REPO}..." +git clone "https://x-access-token:${SKILLS_TOKEN}@github.com/${SKILLS_REPO}.git" "$WORK_DIR/skills-repo" 2>/dev/null + +TARGET_DIR="${WORK_DIR}/skills-repo" + +# Collect skill directories from source +SKILL_DIRS=() +for skill_dir in ${SKILLS_DIR}/*/; do + if [[ -f "${skill_dir}/SKILL.md" ]]; then + SKILL_DIRS+=("$skill_dir") + fi +done + +if [[ ${#SKILL_DIRS[@]} -eq 0 ]]; then + echo "No skills found in ${SKILLS_DIR}/" + exit 0 +fi + +echo "Found ${#SKILL_DIRS[@]} skill(s) to sync" + +# Copy skills to target repo +MANAGED_SKILLS=() +for skill_dir in "${SKILL_DIRS[@]}"; do + skill_name=$(basename "$skill_dir") + dest="${TARGET_DIR}/${CLI_NAME}/${skill_name}" + + echo " Syncing ${skill_name}..." + mkdir -p "$dest" + + # Copy non-Go, non-dotfiles preserving subdirectory structure + (cd "$skill_dir" && find . -type f ! -name '*.go' ! -name '.*' | while read -r f; do + mkdir -p "$dest/$(dirname "$f")" + cp "$f" "$dest/$f" + done) + + MANAGED_SKILLS+=("${CLI_NAME}/${skill_name}") +done + +# Update managed manifest +MANIFEST_PATH="${TARGET_DIR}/${MANAGED_MANIFEST}" +if [[ -f "$MANIFEST_PATH" ]]; then + # Remove stale entries for this CLI + grep -v "^${CLI_NAME}/" "$MANIFEST_PATH" > "${MANIFEST_PATH}.tmp" || true + mv "${MANIFEST_PATH}.tmp" "$MANIFEST_PATH" +fi + +# Append current skills +for skill in "${MANAGED_SKILLS[@]}"; do + echo "$skill" >> "$MANIFEST_PATH" +done +sort -u -o "$MANIFEST_PATH" "$MANIFEST_PATH" + +# Check for stale skills to remove +if [[ -d "${TARGET_DIR}/${CLI_NAME}" ]]; then + for existing in "${TARGET_DIR}/${CLI_NAME}"/*/; do + existing_name=$(basename "$existing") + found=false + for skill_dir in "${SKILL_DIRS[@]}"; do + if [[ "$(basename "$skill_dir")" == "$existing_name" ]]; then + found=true + break + fi + done + if [[ "$found" == "false" ]]; then + echo " Removing stale skill: ${existing_name}" + rm -rf "$existing" + fi + done +fi + +if [[ "$DRY_RUN" == "remote" ]]; then + echo "DRY_RUN=remote: skipping commit and push" + exit 0 +fi + +# Commit and push +cd "$TARGET_DIR" +git add -A + +if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 +fi + +git config user.name "${CLI_NAME}-cli[bot]" +git config user.email "${CLI_NAME}-cli[bot]@users.noreply.github.com" + +git commit -m "$(cat < list + +# Show details +{{.Name}} show + +# Create +{{.Name}} create --name "New Resource" +``` + +## Output Formats + +- Default (TTY): Styled terminal output +- `--json`: Full JSON envelope +- `--quiet`: Raw JSON data only +- `--agent`: JSON + suppress prompts +- `--ids-only`: One ID per line +- `--count`: Integer count diff --git a/seed/skills/embed.go.tmpl b/seed/skills/embed.go.tmpl new file mode 100644 index 0000000..96b5998 --- /dev/null +++ b/seed/skills/embed.go.tmpl @@ -0,0 +1,7 @@ +// Package skills embeds the skill files in the binary. +package skills + +import "embed" + +//go:embed {{.Name}} +var FS embed.FS From 4e1ce9cd1cc33ace47bda24f6d1c0d2aa2b981f7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 4 Mar 2026 13:55:01 -0800 Subject: [PATCH 11/11] Add agent skills and prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skills/rubric-audit/SKILL.md — agent skill for scoring any CLI prompts/close-gap.md — agent prompt for closing a specific rubric gap prompts/seed-cli.md — agent prompt for bootstrapping a new CLI --- prompts/close-gap.md | 50 +++++++++++++++++ prompts/seed-cli.md | 88 +++++++++++++++++++++++++++++ skills/rubric-audit/SKILL.md | 104 +++++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 prompts/close-gap.md create mode 100644 prompts/seed-cli.md create mode 100644 skills/rubric-audit/SKILL.md diff --git a/prompts/close-gap.md b/prompts/close-gap.md new file mode 100644 index 0000000..7d54795 --- /dev/null +++ b/prompts/close-gap.md @@ -0,0 +1,50 @@ +# Close a Rubric Gap + +You are closing a specific gap in a Go CLI's compliance with the 37signals CLI rubric. + +## Input + +- **Criterion ID**: e.g., "1A.1" (--json flag on every command) +- **CLI repo**: The repository you're working in +- **Current state**: What exists today + +## Process + +1. Read RUBRIC.md to understand the criterion requirements +2. Read the reference implementation in basecamp-cli +3. Assess the current state of the target CLI +4. Implement the minimum change to meet the criterion +5. Add or update tests +6. Verify the criterion is met + +## Shared Packages + +If the gap can be closed by adopting a shared package, prefer that over writing new code: + +| Gap Area | Package | Import | +|----------|---------|--------| +| Output envelope, formats, exit codes | `output` | `github.com/basecamp/cli/output` | +| Credential storage (keyring + file) | `credstore` | `github.com/basecamp/cli/credstore` | +| PKCE helpers | `pkce` | `github.com/basecamp/cli/pkce` | +| OAuth callback server | `oauthcallback` | `github.com/basecamp/cli/oauthcallback` | +| CLI surface snapshots | `surface` | `github.com/basecamp/cli/surface` | + +## Implementation Patterns + +### Adding --json flag (1A.1) +Add a persistent `--json` flag to the root command. In your output wrapper, check the flag and set `FormatJSON` accordingly. + +### Adding structured output (1A.3-4) +Import `github.com/basecamp/cli/output` and use `Writer.OK()` / `Writer.Err()` in every command's RunE. + +### Adding exit codes (1B.1) +Use `output.AsError(err).ExitCode()` in your root command's error handler. Map all errors through typed constructors. + +### Adding --help --agent (1C.1) +Detect `--help --agent` flag combination. When both are set, emit a JSON object with: name, description, flags (name, type, default, description), subcommands (name, description). + +### Adding keyring (1D.3) +Replace file-only credential storage with `credstore.NewStore()`. Set ServiceName to your app name, DisableEnvVar to `APP_NO_KEYRING`. + +### Adding surface stability (2A.2-3) +Use the `surface` package to generate snapshots. Commit the baseline. Add the `surface-compat` GitHub Action to CI. diff --git a/prompts/seed-cli.md b/prompts/seed-cli.md new file mode 100644 index 0000000..fa4a2c0 --- /dev/null +++ b/prompts/seed-cli.md @@ -0,0 +1,88 @@ +# Bootstrap a New CLI from Seed + +You are creating a new Go CLI for a 37signals product using the seed templates. + +## Input + +- **App name**: e.g., "fizzy", "hey" +- **API base URL**: e.g., "https://fizzy.37signals.com" +- **Auth model**: OAuth+PKCE, bearer token (PAT), or purchase token + +## Process + +1. Create the repository structure: + ``` + -cli/ + ├── cmd//main.go + ├── internal/ + │ ├── auth/ + │ ├── commands/ + │ ├── config/ + │ └── output/ + ├── e2e/ + ├── skills/ + ├── .claude-plugin/ + ├── go.mod + ├── Makefile + ├── .goreleaser.yaml + ├── .golangci.yml + ├── AGENTS.md + ├── CONTRIBUTING.md + └── README.md + ``` + +2. Initialize go.mod with `github.com/basecamp/-cli` + +3. Add shared dependencies: + ``` + go get github.com/basecamp/cli/output + go get github.com/basecamp/cli/credstore + go get github.com/basecamp/cli/pkce + go get github.com/spf13/cobra + ``` + +4. Copy and customize seed templates: + - `seed/Makefile` → `Makefile` (update BINARY_NAME) + - `seed/.goreleaser.yaml` → `.goreleaser.yaml` (update ProjectName) + - `seed/.golangci.yml` → `.golangci.yml` + - `seed/AGENTS.md.tmpl` → `AGENTS.md` (fill in app name) + - `seed/CONTRIBUTING.md.tmpl` → `CONTRIBUTING.md` (fill in app name) + - `seed/internal/output/output.go` → `internal/output/output.go` + - `seed/internal/auth/auth.go` → `internal/auth/auth.go` (customize service name, env vars) + - `seed/.claude-plugin/` → `.claude-plugin/` (customize) + - `seed/skills/SKILL.md.tmpl` → `skills/SKILL.md` (customize) + +5. Create the root command in `cmd//main.go`: + - Import `github.com/spf13/cobra` + - Add persistent flags: --json, --quiet, --agent, --verbose, --ids-only, --count, --markdown + - Wire up output.Writer with format resolution + - Add --help --agent handler + +6. Create auth commands: ` auth login`, ` auth logout`, ` auth status` + +7. Create first resource command as an example + +8. Run `make check` to verify everything works + +## Auth Model Configuration + +### OAuth + PKCE +```go +import ( + "github.com/basecamp/cli/credstore" + "github.com/basecamp/cli/pkce" + "github.com/basecamp/cli/oauthcallback" +) +``` + +### Bearer Token (PAT) +```go +import "github.com/basecamp/cli/credstore" +// No PKCE or callback needed — user provides token directly +``` + +### Purchase Token (HMAC) +```go +import "github.com/basecamp/cli/credstore" +// Custom verification logic — credstore handles storage only +``` diff --git a/skills/rubric-audit/SKILL.md b/skills/rubric-audit/SKILL.md new file mode 100644 index 0000000..e6f1677 --- /dev/null +++ b/skills/rubric-audit/SKILL.md @@ -0,0 +1,104 @@ +--- +name: rubric-audit +description: Audit a Go CLI against the 37signals CLI rubric +--- + +# CLI Rubric Audit + +Audit a Go CLI repository against the 37signals CLI rubric (RUBRIC.md). + +## Usage + +Run this skill in the root of a Go CLI repository to produce a gap report. + +## Audit Process + +### 1. Identify the CLI + +- Find the main binary (check `cmd/` directory or Makefile) +- Build it: `make build` or `go build ./cmd/` +- Determine the profile: API CLI (wraps a web API) or TUI tool (full-screen interface) + +### 2. Check Tier 1: Agent Contract + +#### 1A. Structured Output (API CLI only) +- [ ] Run ` --help` — does `--json` flag exist? +- [ ] Pipe a command: ` | cat` — does it output JSON automatically? +- [ ] Run with `--json`: verify `{ok: true, data: ...}` envelope +- [ ] Run invalid command: verify `{ok: false, error: ..., code: ...}` envelope +- [ ] Check for `--quiet`, `--agent`, `--ids-only`, `--count`, `--markdown` flags +- [ ] Grep for `json.Decoder.UseNumber` or `json.Number` in output code + +#### 1B. Exit Codes +- [ ] Run with bad args: should exit 1 +- [ ] Access nonexistent resource: should exit 2 +- [ ] Run without auth: should exit 3 +- [ ] Check error types in code: look for typed error constructors + +#### 1C. Programmatic Discovery (API CLI only) +- [ ] Run `--help --agent`: should emit structured JSON +- [ ] Check responses for breadcrumbs +- [ ] Look for `commands --json` or catalog command + +#### 1D. Authentication +- [ ] Check for `APP_TOKEN` env var support +- [ ] Check for keyring usage (go-keyring dependency) +- [ ] Check for file fallback with 0600 perms +- [ ] Check for token refresh logic + +### 3. Check Tier 2: Reliability + +#### 2A. Surface Stability +- [ ] `--version` flag exists and shows version/commit/date +- [ ] Surface snapshot script or tool exists +- [ ] CI runs surface compat check + +#### 2B. Resilience +- [ ] Grep for retry/backoff logic +- [ ] Check for 429/rate limit handling + +#### 2C. Configuration +- [ ] Check config loading order (flag > env > file) +- [ ] Check for HTTPS enforcement +- [ ] Check for XDG directory usage + +### 4. Check Tier 3: Agent Integration (API CLI only) + +- [ ] Check for SKILL.md and go:embed +- [ ] Check for .claude-plugin/ directory +- [ ] Check for --limit, --all flags on list commands +- [ ] Check for --verbose, APP_DEBUG + +### 5. Check Tier 4: Distribution & Ecosystem + +- [ ] Check for .goreleaser.yaml +- [ ] Check for Homebrew tap +- [ ] Check for e2e tests +- [ ] Check for golangci-lint config +- [ ] Check for CONTRIBUTING.md, AGENTS.md + +## Output Format + +Produce a scorecard: + +``` +## Scorecard: + +| Tier | Score | Max | +|------|-------|-----| +| T1: Agent Contract | X/21 | 21 | +| T2: Reliability | X/14 | 14 | +| T3: Agent Integration | X/9 | 9 | +| T4: Distribution | X/19 | 19 | +| **Total** | **X/63** | **63** | + +### Critical Gaps +1. [Most impactful gap] +2. [Second gap] +... + +### Recommended Priority +1. [First thing to fix — highest leverage] +2. [Second] +... +```