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/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 | | | | +``` 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" 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) +} 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= 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") + } +} 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) +} 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) +} 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") +} 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/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 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] +... +``` 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 +}