diff --git a/.agents/skills/create-pr-await-qodo-review/SKILL.md b/.agents/skills/create-pr-await-qodo-review/SKILL.md new file mode 100644 index 0000000..7028e4d --- /dev/null +++ b/.agents/skills/create-pr-await-qodo-review/SKILL.md @@ -0,0 +1,135 @@ +--- +name: create-pr-await-qodo-review +description: Orchestrate the full PR creation workflow β€” commit, branch, push, create the PR via gh CLI, then optionally wait for the Qodo bot's automated code review and surface its findings. Use this skill whenever the user asks to "create a PR", "open a PR", "submit a PR", "commit and push and create a PR", or any variation of making a pull request. Also trigger when the user says "let's PR this" or "ship it" in a context where there are uncommitted or unpushed changes, or when they want to wait for / poll / check the Qodo review on a PR. +--- + +# Create PR and Await Qodo Review + +This skill walks through the complete pull request workflow for this repository, from staging changes through to waiting for the automated Qodo code review and surfacing its findings. + +The reason this skill exists is that PR creation involves several coordinated steps where getting the details right matters β€” conventional commit format, structured PR bodies, and the opportunity to catch review feedback before context-switching away. Following this workflow means the PR is ready for human review the moment it lands. + +## Workflow + +### Step 1: Pre-flight + +Run these in parallel to understand what you're working with: + +```bash +git status # untracked + modified files +git diff # unstaged changes +git diff --cached # staged changes +git log --oneline -5 # recent commit style +``` + +Before proceeding, verify there are actual changes to commit. If the working tree is clean, tell the user and stop. + +### Step 2: Commit + +Stage the relevant files β€” prefer naming specific files over `git add -A` to avoid accidentally including sensitive files (`.env`, credentials, local settings). + +Write a conventional commit message following the project's release flow (see `docs/guides/release-flow.md`). The format is `type(scope): description` or `type: description`. + +**Version-bumping types:** `feat` (minor), `fix` (patch), `perf` (patch), `deps` (patch), `revert` (patch) +**Non-bumping types:** `chore`, `docs`, `style`, `refactor`, `test`, `build`, `ci` + +End every commit message with: +``` +Co-Authored-By: Claude Opus 4.6 (1M context) +``` + +Use a HEREDOC for the commit message to preserve formatting: +```bash +git commit -m "$(cat <<'EOF' +type(scope): short description + +Longer explanation of why, not what. + +Co-Authored-By: Claude Opus 4.6 (1M context) +EOF +)" +``` + +### Step 3: Branch and push + +If currently on `main`, create a feature branch first β€” never commit directly to main. Branch naming convention: `type/short-description` (e.g., `fix/yaml-parsing`, `ci/harden-workflows`). + +```bash +git checkout -b # only if on main +git push -u origin +``` + +### Step 4: Create the PR + +Use `gh pr create` with a structured body. PR titles follow the same conventional commit format (they become the squash-merge commit message on main). + +```bash +gh pr create --title "type(scope): description" --body "$(cat <<'EOF' +## Summary +<1-3 bullet points explaining what and why> + +## Test plan +[Describe how changes were verified] + +πŸ€– Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Capture the returned PR URL β€” `gh pr create` prints it to stdout. + +### Step 5: Wait for the Qodo review (always) + +Every PR in this repo gets an automated Qodo code review, and the whole point of this skill is to catch that feedback before you context-switch away β€” so **always** wait for it. Don't ask the user whether to wait; just tell them you're watching for it and that they're free to keep working in the meantime. + +> "PR created at {url}. I'll watch for the Qodo code review and surface its findings as soon as it lands." + +Drive the wait with the **Monitor** tool, not a foreground command. The poller streams progress to stderr (which goes only to the monitor's output file) and prints exactly one terminal event to stdout β€” the full review body when it lands, or a `TIMEOUT:` line if it never does β€” so each notification is something you'd actually act on, with no log noise in between. + +Call the `Monitor` tool with: + +- **command**: `python scripts/review_poll.py --interval 30 --timeout 900` +- **description**: something specific like `"Qodo review on PR #123"` (it appears in every notification) +- **persistent**: `true` β€” the poller ends itself when the review lands or it hits its own `--timeout`, so let it run for the session rather than racing a separate monitor deadline + +Then keep working. When the monitor fires, react to what it emitted: + +- **Review body** (markdown) β†’ parse out findings. Look for sections like `Bugs`, `Rule violations`, `Action required`, or `Review recommended`. Summarize them for the user, highlighting bugs and actionable items, and offer to fix anything real. +- **`TIMEOUT: ...` line** (or a non-zero exit) β†’ the review didn't post in time. Tell the user, share the PR URL, and offer to re-arm the monitor with a longer `--timeout` or check the PR manually. + +A 30s poll interval keeps you clear of GitHub API rate limits, and 900s (15 min) comfortably covers how long Qodo typically takes. Bump `--timeout` for unusually large PRs. + +## Script reference + +**`scripts/review_poll.py`** β€” Polls a GitHub PR for the Qodo bot's code review comment. + +``` +python scripts/review_poll.py [--interval 15] [--timeout 300] +``` + +- Accepts `https://github.com/owner/repo/pull/123` or `owner/repo#123` +- Shells out to `gh api` (Python standard library only, no third-party dependencies) +- Detects the Qodo bot comment by author login (contains "qodo") +- Distinguishes placeholder ("Looking for bugs? Check back in a few minutes") from the final review (contains `Bugs`, `Rule violations`, `Action required`, etc.) +- Exit 0 = review found (body on stdout), exit 1 = timeout (a `TIMEOUT:` line on stdout + message on stderr), exit 2 = bad args +- Designed for the Monitor tool: only terminal events (the review body, or the `TIMEOUT:` line) go to stdout; per-poll progress goes to stderr so it never spams notifications + +## Decision patterns + +### When to create a new branch + +- On `main` β†’ always create a branch +- On an existing feature branch β†’ stay on it (the user likely has in-progress work) +- Branch already tracks remote and is up to date β†’ just push new commits + +### Commit granularity + +- One logical change = one commit. Don't bundle unrelated fixes. +- If the user explicitly asks for a single commit covering multiple changes, that's fine. +- If a pre-commit hook fails, fix the issue and create a NEW commit (don't amend β€” the failed commit didn't happen). + +### PR body depth + +- Small changes (typos, config tweaks) β†’ brief summary, minimal test plan +- Feature work β†’ explain the why, list affected areas, thorough test plan +- Security/CI changes β†’ explain the threat model or reasoning, link to relevant incidents or docs diff --git a/.agents/skills/create-pr-await-qodo-review/evals/evals.json b/.agents/skills/create-pr-await-qodo-review/evals/evals.json new file mode 100644 index 0000000..26d5aa6 --- /dev/null +++ b/.agents/skills/create-pr-await-qodo-review/evals/evals.json @@ -0,0 +1,39 @@ +[ + { + "id": 1, + "name": "full-pr-flow-always-waits", + "description": "User asks to open a PR for finished work β€” the skill should run the commit/branch/push/create flow and then ALWAYS wait for the Qodo review without asking permission, driving the wait with the Monitor tool.", + "prompt": "The origin-resolver changes on this branch are done and tested β€” let's open a PR against main.", + "trap": "Model asks the user whether to wait for the Qodo review (it should always wait), runs the poller in the foreground (blocking), or forgets the review wait entirely after creating the PR.", + "assertions": [ + { "id": "1.1", "text": "Follows the commit -> branch (if on main) -> push -> `gh pr create` sequence with a conventional-commit title" }, + { "id": "1.2", "text": "Waits for the Qodo review unconditionally β€” does NOT ask the user whether to wait" }, + { "id": "1.3", "text": "Drives the wait with the Monitor tool running `scripts/review_poll.py` (persistent), not a blocking foreground command" }, + { "id": "1.4", "text": "States it will surface the Qodo findings once the review lands and that the user can keep working meanwhile" } + ] + }, + { + "id": 2, + "name": "ship-it-from-main", + "description": "Casual 'ship it' on dirty working tree while on main β€” must create a branch first (never commit to main), use conventional commit + structured PR body, then wait for Qodo via Monitor.", + "prompt": "ok ship it β€” branch, push, and raise the pull request", + "trap": "Model commits directly to main, uses a non-conventional commit/PR title, or skips the Qodo review wait.", + "assertions": [ + { "id": "2.1", "text": "Creates a feature branch before committing because the change would otherwise land on main" }, + { "id": "2.2", "text": "Uses conventional-commit format for both the commit message and the PR title" }, + { "id": "2.3", "text": "Arms the Monitor tool on `review_poll.py` to wait for the Qodo review after the PR is created" } + ] + }, + { + "id": 3, + "name": "check-review-on-existing-pr", + "description": "User asks specifically about the Qodo review on an already-open PR β€” the skill should poll that PR for the review rather than creating a new one.", + "prompt": "what did qodo flag on PR #128? has the review landed yet?", + "trap": "Model tries to create a new PR, or hits the GitHub API once and reports 'not ready' instead of watching until the review lands.", + "assertions": [ + { "id": "3.1", "text": "Recognizes the PR already exists and does NOT run the commit/branch/create flow" }, + { "id": "3.2", "text": "Uses the Monitor tool with `review_poll.py` against PR #128 to wait for and report the Qodo findings" }, + { "id": "3.3", "text": "Summarizes the Qodo findings (or reports the timeout) rather than dumping the raw comment" } + ] + } +] diff --git a/.agents/skills/create-pr-await-qodo-review/evals/trigger-eval-set.json b/.agents/skills/create-pr-await-qodo-review/evals/trigger-eval-set.json new file mode 100644 index 0000000..b26cd4d --- /dev/null +++ b/.agents/skills/create-pr-await-qodo-review/evals/trigger-eval-set.json @@ -0,0 +1,15 @@ +[ + { "query": "The origin-resolver changes on this branch are done and tested β€” let's open a PR against main.", "should_trigger": true }, + { "query": "commit what I've got staged and push it up as a pull request", "should_trigger": true }, + { "query": "feature's finished, can you create a PR for these changes and tell me what the qodo review comes back with?", "should_trigger": true }, + { "query": "let's PR this", "should_trigger": true }, + { "query": "ok ship it β€” branch, push, and raise the pull request", "should_trigger": true }, + { "query": "open a pull request with a proper summary and test plan, then wait for the bot review", "should_trigger": true }, + { "query": "push my work to a new branch and submit a PR", "should_trigger": true }, + { "query": "what did qodo flag on PR #128? has the review landed yet?", "should_trigger": true }, + { "query": "open PR #128 in my browser so I can read through the discussion", "should_trigger": false }, + { "query": "review this diff with me in crit before I commit anything", "should_trigger": false }, + { "query": "rebase my feature branch onto main and help me resolve the conflicts", "should_trigger": false }, + { "query": "what's the status of the CI checks on my current branch?", "should_trigger": false }, + { "query": "just stage and commit these changes locally β€” don't push or open anything yet", "should_trigger": false } +] diff --git a/.agents/skills/create-pr-await-qodo-review/scripts/review_poll.py b/.agents/skills/create-pr-await-qodo-review/scripts/review_poll.py new file mode 100644 index 0000000..4350594 --- /dev/null +++ b/.agents/skills/create-pr-await-qodo-review/scripts/review_poll.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +review_poll.py β€” Poll a GitHub PR for the Qodo code review comment. + +Qodo posts a placeholder comment immediately after PR creation, then edits it +with the full review once analysis is complete. This script polls until the +final review appears and prints it to stdout. + +Usage: + python scripts/review_poll.py [--interval 15] [--timeout 300] + +Accepts: + https://github.com/owner/repo/pull/123 + owner/repo#123 + +Exit codes: + 0 β€” review found (body printed to stdout) + 1 β€” timeout + 2 β€” argument/usage error + +Standard library only β€” shells out to `gh api`, no third-party dependencies. +""" + +import argparse +import json +import re +import subprocess +import sys +import time + +PLACEHOLDER_MARKERS = ( + "Looking for bugs?", + "Check back in a few minutes", +) + +REVIEW_READY_MARKERS = ( + "Bugs", + "Rule violations", + "Action required", + "Review recommended", + "Requirement gaps", + "UX Issues", +) + +VALID_SLUG = re.compile(r"^[A-Za-z0-9_.-]+$") + + +def parse_pr(value): + """Parse a PR reference into (owner, repo, number) or raise ValueError.""" + url_match = re.search(r"github\.com/([^/]+)/([^/]+)/pull/(\d+)", value) + if url_match: + owner, repo, number = url_match.groups() + else: + short_match = re.match(r"^([^/]+)/([^#]+)#(\d+)$", value) + if not short_match: + raise ValueError(f"could not parse PR reference: {value}") + owner, repo, number = short_match.groups() + + if not VALID_SLUG.match(owner) or not VALID_SLUG.match(repo): + raise ValueError(f"invalid owner/repo characters: {owner}/{repo}") + if not number.isdigit(): + raise ValueError(f"invalid PR number: {number}") + + return owner, repo, number + + +def positive_int(value): + ivalue = int(value) + if ivalue < 1: + raise argparse.ArgumentTypeError(f"must be a positive integer (got: {value})") + return ivalue + + +def parse_args(argv): + parser = argparse.ArgumentParser( + prog="review_poll.py", + description="Poll a GitHub PR for the Qodo code review comment.", + ) + parser.add_argument("pr", metavar="pr-url", help="GitHub PR URL or owner/repo#number") + parser.add_argument( + "--interval", type=positive_int, default=15, help="seconds between polls (default: 15)" + ) + parser.add_argument( + "--timeout", type=positive_int, default=300, help="max seconds to wait (default: 300)" + ) + args = parser.parse_args(argv) + + try: + owner, repo, number = parse_pr(args.pr) + except ValueError as err: + parser.error(str(err)) + + return owner, repo, number, args.interval, args.timeout + + +def fetch_comments(owner, repo, number): + """Return a list of {id, author, body} dicts for the PR's issue comments.""" + result = subprocess.run( + [ + "gh", + "api", + f"repos/{owner}/{repo}/issues/{number}/comments", + "--paginate", + "--jq", + ".[] | {id, author: .user.login, body}", + ], + capture_output=True, + text=True, + check=True, + ) + # --paginate with --jq outputs one JSON object per line (NDJSON). + return [json.loads(line) for line in result.stdout.splitlines() if line.strip()] + + +def is_placeholder(body): + return any(marker in body for marker in PLACEHOLDER_MARKERS) + + +def is_review_ready(body): + return not is_placeholder(body) and any(marker in body for marker in REVIEW_READY_MARKERS) + + +def find_qodo_review_comment(comments): + """Pick the Qodo "Code Review" comment, falling back to placeholder/last.""" + qodo = [c for c in comments if c.get("author") and "qodo" in c["author"].lower()] + + code_review = next((c for c in qodo if "Code Review" in c["body"]), None) + if code_review: + return code_review + + placeholder = next((c for c in qodo if is_placeholder(c["body"])), None) + if placeholder: + return placeholder + + return qodo[-1] if qodo else None + + +def main(): + owner, repo, number, interval, timeout = parse_args(sys.argv[1:]) + pr_ref = f"{owner}/{repo}#{number}" + + print(f"Polling {pr_ref} every {interval}s (timeout: {timeout}s)...", file=sys.stderr) + + start = time.monotonic() + while time.monotonic() - start < timeout: + elapsed = round(time.monotonic() - start) + try: + comments = fetch_comments(owner, repo, number) + qodo = find_qodo_review_comment(comments) + + if qodo: + if is_review_ready(qodo["body"]): + print(f"Review ready for {pr_ref}", file=sys.stderr) + print(qodo["body"]) + return 0 + print( + f"Found Qodo comment but still placeholder... ({elapsed}s elapsed)", + file=sys.stderr, + ) + else: + print(f"No Qodo comment yet... ({elapsed}s elapsed)", file=sys.stderr) + except subprocess.CalledProcessError as err: + print(f"API error: {err.stderr.strip()}, retrying...", file=sys.stderr) + + time.sleep(interval) + + # Emit the timeout on stdout too, not just stderr: when run under the + # Monitor tool only stdout lines become notifications, and a silent exit + # would be indistinguishable from "still polling". + print(f"TIMEOUT: no Qodo review for {pr_ref} after {timeout}s") + print(f"Timeout: no Qodo review found after {timeout}s", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/crit-code-review/SKILL.md b/.agents/skills/crit-code-review/SKILL.md new file mode 100644 index 0000000..9f85065 --- /dev/null +++ b/.agents/skills/crit-code-review/SKILL.md @@ -0,0 +1,69 @@ +--- +name: crit-code-review +description: Review code changes in crit's multi-file TUI with syntax highlighting and diff markers. After the review, address any comments. +allowed-tools: Bash(crit *), Read, Edit, Grep, MultiEdit +--- + +# Code Review + +Review code changes using crit's multi-file code review TUI. + +## Prerequisites + +The `crit` binary must be installed and on PATH. If not installed: + +```bash +go install github.com/kevindutra/crit/cmd/crit@latest +``` + +## Step 1: Launch the TUI + +Check if `$TMUX` is set: + +If in tmux, run this command with a **timeout of 600000** (10 minutes) since it blocks until the user finishes reviewing: +```bash +crit review --code --detach --wait +``` + +If not in tmux (command fails with "requires a tmux session"), ask the user to run the TUI manually: + +> Please run this in your terminal, review the changes, and let me know when you're done: +> +> ``` +> crit review --code +> ``` + +Wait for the user to confirm before proceeding. + +## Step 2: Read the comments + +After the user confirms the review is complete, read the aggregate review comments: + +```bash +crit status --code +``` + +This outputs JSON with all files and their comments. + +## Step 3: Address comments + +For each file in the `files` array, for each comment: + +1. Read the `line` number and `content_snippet` to locate where the comment applies +2. Read the `body` for what the reviewer wants changed +3. Edit the file to address the comment + +After addressing ALL comments across ALL files, summarize what you changed. + +## Step 4: Re-review (optional) + +After making changes, ask the user if they want to re-review: + +> "I've addressed all comments. Want to review the changes? I'll open crit again." + +If yes, go back to Step 1. If no, done. + +## Important notes + +- Do NOT modify files while the TUI is open β€” only edit after it exits +- The `content_snippet` field shows the line content when the comment was created β€” use it to find the right location even if line numbers have shifted diff --git a/.agents/skills/crit-plan-review/SKILL.md b/.agents/skills/crit-plan-review/SKILL.md new file mode 100644 index 0000000..8cc369d --- /dev/null +++ b/.agents/skills/crit-plan-review/SKILL.md @@ -0,0 +1,70 @@ +--- +name: crit-plan-review +description: Open a document or plan in crit's interactive TUI for review. After the review, address any comments by editing the document. Use when a plan or document needs human review, when the user asks to review a document, or after generating/updating a plan. +allowed-tools: Bash(crit *), Read, Edit, Grep +argument-hint: +--- + +# Review Document + +Review the document at `$ARGUMENTS` using crit's interactive TUI. + +## Prerequisites + +The `crit` binary must be installed and on PATH. If not installed: + +```bash +go install github.com/kevindutra/crit/cmd/crit@latest +``` + +## Step 1: Launch the TUI + +Check if `$TMUX` is set: + +If in tmux, run this command with a **timeout of 600000** (10 minutes) since it blocks until the user finishes reviewing: +```bash +crit review $ARGUMENTS --detach --wait +``` + +If not in tmux (command fails with "requires a tmux session"), ask the user to run the TUI manually: + +> Please run this in your terminal, review the document, and let me know when you're done: +> +> ``` +> crit review $ARGUMENTS +> ``` + +Wait for the user to confirm before proceeding. + +## Step 2: Read the comments + +After the user confirms the review is complete, read the review comments: + +```bash +crit status $ARGUMENTS +``` + +This outputs JSON with the file path and comments array. + +## Step 3: Address comments + +For each comment in the `comments` array: + +1. Read the `line` number and `content_snippet` to locate where in the document the comment applies +2. Read the `body` for what the reviewer wants changed +3. Edit the document at `$ARGUMENTS` to address the comment + +After addressing ALL comments, summarize what you changed. + +## Step 4: Re-review (optional) + +After making changes, ask the user if they want to re-review: + +> "I've addressed all comments. Want to review the changes? I'll open crit again." + +If yes, go back to Step 1. If no, done. + +## Important notes + +- Do NOT modify the document while the TUI is open β€” only edit after it exits +- The `content_snippet` field shows the line content when the comment was created β€” use it to find the right location even if line numbers have shifted diff --git a/.agents/skills/crit-review/SKILL.md b/.agents/skills/crit-review/SKILL.md new file mode 100644 index 0000000..fce7cae --- /dev/null +++ b/.agents/skills/crit-review/SKILL.md @@ -0,0 +1,17 @@ +--- +name: crit-review +description: Open crit for review. Routes to code review (multi-file TUI for code changes) or plan/document review (single-file TUI). +--- + +# Review + +Ask the user what they want to review: + +> What are you looking to review? +> +> 1. **Code changes** β€” Review changed files in a tabbed TUI with syntax highlighting and diff markers +> 2. **A document or plan** β€” Review a specific file in the interactive TUI + +If the user chooses **code review**, invoke the `/crit-code-review` skill. + +If the user chooses **document/plan review**, ask for the file path and invoke the `/crit-plan-review` skill with that path. diff --git a/.agents/skills/golang-cli/SKILL.md b/.agents/skills/golang-cli/SKILL.md new file mode 100644 index 0000000..d861c04 --- /dev/null +++ b/.agents/skills/golang-cli/SKILL.md @@ -0,0 +1,205 @@ +--- +name: golang-cli +description: "Golang CLI application development. Use when building, modifying, or reviewing a Go CLI tool β€” especially for command structure, flag handling, configuration layering, version embedding, exit codes, I/O patterns, signal handling, shell completion, argument validation, and CLI unit testing. Also triggers when code uses cobra, viper, or urfave/cli. For cobra-specific APIs β†’ See `samber/cc-skills-golang@golang-spf13-cobra` skill; for viper configuration layering β†’ See `samber/cc-skills-golang@golang-spf13-viper` skill." +user-invocable: true +license: MIT +compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang. +metadata: + author: samber + version: "1.2.0" + openclaw: + emoji: "πŸ’»" + homepage: https://github.com/samber/cc-skills-golang + requires: + bins: + - go + install: [] +allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent AskUserQuestion +--- + +**Persona:** You are a Go CLI engineer. You build tools that feel native to the Unix shell β€” composable, scriptable, and predictable under automation. + +**Modes:** + +- **Build** β€” creating a new CLI from scratch: follow the project structure, root command setup, flag binding, and version embedding sections sequentially. +- **Extend** β€” adding subcommands, flags, or completions to an existing CLI: read the current command tree first, then apply changes consistent with the existing structure. +- **Review** β€” auditing an existing CLI for correctness: check the Common Mistakes table, verify `SilenceUsage`/`SilenceErrors`, flag-to-Viper binding, exit codes, and stdout/stderr discipline. + +# Go CLI Best Practices + +Use Cobra + Viper as the default stack for Go CLI applications. Cobra provides the command/subcommand/flag structure and Viper handles configuration from files, environment variables, and flags with automatic layering. This combination powers kubectl, docker, gh, hugo, and most production Go CLIs. + +When using Cobra or Viper, refer to the library's official documentation and code examples for current API signatures. + +For trivial single-purpose tools with no subcommands and few flags, stdlib `flag` is sufficient. + +## Quick Reference + +| Concern | Package / Tool | +| ------------------- | ------------------------------------ | +| Commands & flags | `github.com/spf13/cobra` | +| Configuration | `github.com/spf13/viper` | +| Flag parsing | `github.com/spf13/pflag` (via Cobra) | +| Colored output | `github.com/fatih/color` | +| Table output | `github.com/olekukonko/tablewriter` | +| Interactive prompts | `github.com/charmbracelet/bubbletea` | +| Version injection | `go build -ldflags` | +| Distribution | `goreleaser` | + +## Project Structure + +Organize CLI commands in `cmd/myapp/` with one file per command. Keep `main.go` minimal β€” it only calls `Execute()`. + +``` +myapp/ +β”œβ”€β”€ cmd/ +β”‚ └── myapp/ +β”‚ β”œβ”€β”€ main.go # package main, only calls Execute() +β”‚ β”œβ”€β”€ root.go # Root command + Viper init +β”‚ β”œβ”€β”€ serve.go # "serve" subcommand +β”‚ β”œβ”€β”€ migrate.go # "migrate" subcommand +β”‚ └── version.go # "version" subcommand +β”œβ”€β”€ go.mod +└── go.sum +``` + +`main.go` should be minimal β€” see [assets/examples/main.go](assets/examples/main.go). + +## Root Command Setup + +The root command initializes Viper configuration and sets up global behavior via `PersistentPreRunE`. See [assets/examples/root.go](assets/examples/root.go). + +Key points: + +- `SilenceUsage: true` MUST be set β€” prevents printing the full usage text on every error +- `SilenceErrors: true` MUST be set β€” lets you control error output format yourself +- `PersistentPreRunE` runs before every subcommand, so config is always initialized +- Logs go to stderr, output goes to stdout + +## Subcommands + +Add subcommands by creating separate files in `cmd/myapp/` and registering them in `init()`. See [assets/examples/serve.go](assets/examples/serve.go) for a complete subcommand example including command groups. + +## Flags + +See [assets/examples/flags.go](assets/examples/flags.go) for all flag patterns: + +### Persistent vs Local + +- **Persistent** flags are inherited by all subcommands (e.g., `--config`) +- **Local** flags only apply to the command they're defined on (e.g., `--port`) + +### Required Flags + +Use `MarkFlagRequired`, `MarkFlagsMutuallyExclusive`, and `MarkFlagsOneRequired` for flag constraints. + +### Flag Validation with RegisterFlagCompletionFunc + +Provide completion suggestions for flag values. + +### Always Bind Flags to Viper + +This ensures `viper.GetInt("port")` returns the flag value, env var `MYAPP_PORT`, or config file value β€” whichever has highest precedence. + +## Argument Validation + +Cobra provides built-in validators for positional arguments. See [assets/examples/args.go](assets/examples/args.go) for both built-in and custom validation examples. + +| Validator | Description | +| --------------------------- | ------------------------------------ | +| `cobra.NoArgs` | Fails if any args provided | +| `cobra.ExactArgs(n)` | Requires exactly n args | +| `cobra.MinimumNArgs(n)` | Requires at least n args | +| `cobra.MaximumNArgs(n)` | Allows at most n args | +| `cobra.RangeArgs(min, max)` | Requires between min and max | +| `cobra.ExactValidArgs(n)` | Exactly n args, must be in ValidArgs | + +## Configuration with Viper + +Viper resolves configuration values in this order (highest to lowest precedence): + +1. **CLI flags** (explicit user input) +2. **Environment variables** (deployment config) +3. **Config file** (persistent settings) +4. **Defaults** (set in code) + +See [assets/examples/config.go](assets/examples/config.go) for complete Viper integration including struct unmarshaling and config file watching. + +### Example Config File (.myapp.yaml) + +```yaml +port: 8080 +host: localhost +log-level: info +database: + dsn: postgres://localhost:5432/myapp + max-conn: 25 +``` + +With the setup above, these are all equivalent: + +- Flag: `--port 9090` +- Env var: `MYAPP_PORT=9090` +- Config file: `port: 9090` + +## Version and Build Info + +Version SHOULD be embedded at compile time using `ldflags`. See [assets/examples/version.go](assets/examples/version.go) for the version command and build instructions. + +## Exit Codes + +Exit codes MUST follow Unix conventions: + +| Code | Meaning | When to Use | +| ----- | ----------------- | ----------------------------------------- | +| 0 | Success | Operation completed normally | +| 1 | General error | Runtime failure | +| 2 | Usage error | Invalid flags or arguments | +| 64-78 | BSD sysexits | Specific error categories | +| 126 | Cannot execute | Permission denied | +| 127 | Command not found | Missing dependency | +| 128+N | Signal N | Terminated by signal (e.g., 130 = SIGINT) | + +See [assets/examples/exit_codes.go](assets/examples/exit_codes.go) for a pattern mapping errors to exit codes. + +## I/O Patterns + +See [assets/examples/output.go](assets/examples/output.go) for all I/O patterns: + +- **stdout vs stderr**: NEVER write diagnostic output to stdout β€” stdout is for program output (pipeable), stderr for logs/errors/diagnostics +- **Detecting pipe vs terminal**: check `os.ModeCharDevice` on stdout +- **Machine-readable output**: support `--output` flag for table/json/plain formats +- **Colors**: use `fatih/color` which auto-disables when output is not a terminal + +## Signal Handling + +Signal handling MUST use `signal.NotifyContext` to propagate cancellation through context. See [assets/examples/signal.go](assets/examples/signal.go) for graceful HTTP server shutdown. + +## Shell Completions + +Cobra generates completions for bash, zsh, fish, and PowerShell automatically. See [assets/examples/completion.go](assets/examples/completion.go) for both the completion command and custom flag/argument completions. + +## Testing CLI Commands + +Test commands by executing them programmatically and capturing output. See [assets/examples/cli_test.go](assets/examples/cli_test.go). + +Use `cmd.OutOrStdout()` and `cmd.ErrOrStderr()` in commands (instead of `os.Stdout` / `os.Stderr`) so output can be captured in tests. + +## Common Mistakes + +| Mistake | Fix | +| --- | --- | +| Writing to `os.Stdout` directly | Tests can't capture output. Use `cmd.OutOrStdout()` which tests can redirect to a buffer | +| Calling `os.Exit()` inside `RunE` | Cobra's error handling, deferred functions, and cleanup code never run. Return an error, let `main()` decide | +| Not binding flags to Viper | Flags won't be configurable via env/config. Call `viper.BindPFlag` for every configurable flag | +| Missing `viper.SetEnvPrefix` | `PORT` collides with other tools. Use a prefix (`MYAPP_PORT`) to namespace env vars | +| Logging to stdout | Unix pipes chain stdout β€” logs corrupt the data stream for the next program. Logs go to stderr | +| Printing usage on every error | Full help text on every error is noise. Set `SilenceUsage: true`, save full usage for `--help` | +| Config file required | Users without a config file get a crash. Ignore `viper.ConfigFileNotFoundError` β€” config should be optional | +| Not using `PersistentPreRunE` | Config initialization must happen before any subcommand. Use root's `PersistentPreRunE` | +| Hardcoded version string | Version gets out of sync with tags. Inject via `ldflags` at build time from git tags | +| Not supporting `--output` format | Scripts can't parse human-readable output. Add JSON/table/plain for machine consumption | + +## Related Skills + +See `samber/cc-skills-golang@golang-project-layout`, `samber/cc-skills-golang@golang-dependency-injection`, `samber/cc-skills-golang@golang-testing`, `samber/cc-skills-golang@golang-design-patterns` skills. diff --git a/.agents/skills/golang-cli/assets/examples/args.go b/.agents/skills/golang-cli/assets/examples/args.go new file mode 100644 index 0000000..0015945 --- /dev/null +++ b/.agents/skills/golang-cli/assets/examples/args.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// Cobra provides built-in validators for positional arguments. +// See the table in SKILL.md for all available validators. +var deployCmd = &cobra.Command{ + Use: "deploy [environment]", + Short: "Deploy to an environment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + env := args[0] + _ = env + // deploy... + return nil + }, +} + +// Custom validation example: +var deployWithValidationCmd = &cobra.Command{ + Use: "deploy [environment]", + Short: "Deploy to an environment", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("expected exactly 1 argument, got %d", len(args)) + } + valid := map[string]bool{"dev": true, "staging": true, "prod": true} + if !valid[args[0]] { + return fmt.Errorf("invalid environment %q, must be one of: dev, staging, prod", args[0]) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + // deploy... + return nil + }, +} diff --git a/.agents/skills/golang-cli/assets/examples/cli_test.go b/.agents/skills/golang-cli/assets/examples/cli_test.go new file mode 100644 index 0000000..483ebcc --- /dev/null +++ b/.agents/skills/golang-cli/assets/examples/cli_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "testing" + + "github.com/spf13/cobra" +) + +// Test commands by executing them programmatically and capturing output. +// Use cmd.OutOrStdout() and cmd.ErrOrStderr() in commands (instead of +// os.Stdout / os.Stderr) so output can be captured in tests. + +func executeCommand(root *cobra.Command, args ...string) (string, error) { + buf := new(bytes.Buffer) + root.SetOut(buf) + root.SetErr(buf) + root.SetArgs(args) + err := root.Execute() + return buf.String(), err +} + +func TestServeCommand(t *testing.T) { + tests := []struct { + name string + args []string + want string + wantErr bool + }{ + { + name: "default port", + args: []string{"serve"}, + want: "listening on :8080\n", + }, + { + name: "custom port", + args: []string{"serve", "--port", "9090"}, + want: "listening on :9090\n", + }, + { + name: "missing required flag", + args: []string{"serve", "--host", ""}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := executeCommand(rootCmd, tt.args...) + if (err != nil) != tt.wantErr { + t.Errorf("error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("output = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/.agents/skills/golang-cli/assets/examples/completion.go b/.agents/skills/golang-cli/assets/examples/completion.go new file mode 100644 index 0000000..8ba9620 --- /dev/null +++ b/.agents/skills/golang-cli/assets/examples/completion.go @@ -0,0 +1,58 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" +) + +// === Shell Completion Command === +// Cobra generates completions for bash, zsh, fish, and PowerShell automatically. + +func init() { + rootCmd.AddCommand(&cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion script", + Args: cobra.ExactValidArgs(1), + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return rootCmd.GenBashCompletionV2(os.Stdout, true) + case "zsh": + return rootCmd.GenZshCompletion(os.Stdout) + case "fish": + return rootCmd.GenFishCompletion(os.Stdout, true) + case "powershell": + return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout) + } + return nil + }, + }) +} + +// === Custom Completions === +// Add custom completions for flags and arguments. + +func customCompletionExamples() { + deployCmd.RegisterFlagCompletionFunc("env", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{ + "dev\tDevelopment environment", + "staging\tStaging environment", + "prod\tProduction environment", + }, cobra.ShellCompDirectiveNoFileComp + }) + + // Dynamic argument completion + deployCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return getAvailableServices(), cobra.ShellCompDirectiveNoFileComp + } +} + +func getAvailableServices() []string { + // fetch available services dynamically + return nil +} diff --git a/.agents/skills/golang-cli/assets/examples/config.go b/.agents/skills/golang-cli/assets/examples/config.go new file mode 100644 index 0000000..c0fe629 --- /dev/null +++ b/.agents/skills/golang-cli/assets/examples/config.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + "strings" + + "github.com/fsnotify/fsnotify" + "github.com/spf13/viper" +) + +// === Complete Cobra + Viper Integration === + +func initConfigComplete() error { + // 1. Config file + if cfgFile != "" { + viper.SetConfigFile(cfgFile) // explicit path + } else { + home, _ := os.UserHomeDir() + viper.AddConfigPath(home) // search $HOME + viper.AddConfigPath(".") // search current dir + viper.SetConfigName(".myapp") + viper.SetConfigType("yaml") + } + + // 2. Environment variables + viper.SetEnvPrefix("MYAPP") // MYAPP_PORT, MYAPP_LOG_LEVEL + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) // log-level β†’ MYAPP_LOG_LEVEL + viper.AutomaticEnv() // bind all env vars automatically + + // 3. Read config file (ignore "not found") + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return fmt.Errorf("reading config: %w", err) + } + } + + return nil +} + +// === Unmarshaling into Structs === + +type Config struct { + Port int `mapstructure:"port"` + Host string `mapstructure:"host"` + LogLevel string `mapstructure:"log-level"` + Database struct { + DSN string `mapstructure:"dsn"` + MaxConn int `mapstructure:"max-conn"` + } `mapstructure:"database"` +} + +func loadConfig() (Config, error) { + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + return Config{}, fmt.Errorf("unmarshaling config: %w", err) + } + return cfg, nil +} + +// === Watching Config File Changes === +// For long-running CLIs (servers, daemons): + +func watchConfig() { + viper.OnConfigChange(func(e fsnotify.Event) { + slog.Info("config file changed", "file", e.Name) + // re-read and apply config + }) + viper.WatchConfig() +} diff --git a/.agents/skills/golang-cli/assets/examples/exit_codes.go b/.agents/skills/golang-cli/assets/examples/exit_codes.go new file mode 100644 index 0000000..d10019b --- /dev/null +++ b/.agents/skills/golang-cli/assets/examples/exit_codes.go @@ -0,0 +1,28 @@ +package main + +import ( + "errors" + "os" + + "github.com/you/myapp/cmd" +) + +// Pattern for mapping errors to exit codes. +func mainWithExitCodes() { + if err := cmd.Execute(); err != nil { + // Cobra already printed the error via RunE + var exitErr *ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.Code) + } + os.Exit(1) + } +} + +type ExitError struct { + Code int + Err error +} + +func (e *ExitError) Error() string { return e.Err.Error() } +func (e *ExitError) Unwrap() error { return e.Err } diff --git a/.agents/skills/golang-cli/assets/examples/flags.go b/.agents/skills/golang-cli/assets/examples/flags.go new file mode 100644 index 0000000..7c0995f --- /dev/null +++ b/.agents/skills/golang-cli/assets/examples/flags.go @@ -0,0 +1,41 @@ +package main + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func flagExamples() { + // === Persistent vs Local === + + // Persistent β€” inherited by all subcommands + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file path") + + // Local β€” only for this command + serveCmd.Flags().IntP("port", "p", 8080, "port to listen on") + + // === Required Flags === + + serveCmd.Flags().String("host", "", "hostname to bind to") + serveCmd.MarkFlagRequired("host") + + // Mutually exclusive flags + rootCmd.MarkFlagsMutuallyExclusive("json", "yaml") + + // At least one required + rootCmd.MarkFlagsOneRequired("output-file", "stdout") + + // === Flag Validation with RegisterFlagCompletionFunc === + + serveCmd.Flags().String("env", "dev", "environment (dev, staging, prod)") + serveCmd.RegisterFlagCompletionFunc("env", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"dev", "staging", "prod"}, cobra.ShellCompDirectiveNoFileComp + }) + + // === Always Bind Flags to Viper === + // This ensures viper.GetInt("port") returns the flag value, env var MYAPP_PORT, + // or config file value β€” whichever has highest precedence. + + serveCmd.Flags().IntP("port", "p", 8080, "port to listen on") + viper.BindPFlag("port", serveCmd.Flags().Lookup("port")) +} diff --git a/.agents/skills/golang-cli/assets/examples/main.go b/.agents/skills/golang-cli/assets/examples/main.go new file mode 100644 index 0000000..fec0ac9 --- /dev/null +++ b/.agents/skills/golang-cli/assets/examples/main.go @@ -0,0 +1,12 @@ +// cmd/myapp/main.go +package main + +import ( + "os" +) + +func main() { + if err := Execute(); err != nil { + os.Exit(1) + } +} diff --git a/.agents/skills/golang-cli/assets/examples/output.go b/.agents/skills/golang-cli/assets/examples/output.go new file mode 100644 index 0000000..1865968 --- /dev/null +++ b/.agents/skills/golang-cli/assets/examples/output.go @@ -0,0 +1,75 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +// === stdout vs stderr === +// stdout: Program output (data, results). This is what gets piped. +// stderr: Logs, progress, errors, diagnostics. Not piped by default. + +func outputExample(cmd *cobra.Command, result string, err error) { + // Output data to stdout (pipeable) + fmt.Fprintln(cmd.OutOrStdout(), result) + + // Logs and errors to stderr (use slog) + // slog.Error("operation failed", "error", err) +} + +// === Detecting Pipe vs Terminal === + +func isTerminal() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +// === Machine-Readable Output === +// Support --output flag for different output formats. + +type User struct { + ID string + Name string +} + +func printUsers(cmd *cobra.Command, users []User) error { + format, _ := cmd.Flags().GetString("output") + switch format { + case "json": + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(users) + case "plain": + for _, u := range users { + fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\n", u.ID, u.Name) + } + default: // "table" + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tNAME") + for _, u := range users { + fmt.Fprintf(w, "%s\t%s\n", u.ID, u.Name) + } + w.Flush() + } + return nil +} + +// === Colors === +// Use fatih/color β€” it auto-disables when output is not a terminal. + +func colorExamples(cmd *cobra.Command, env string, err error) { + color.Green("Success: deployed to %s", env) + color.Red("Error: %v", err) + + // Or for reusable styles + success := color.New(color.FgGreen, color.Bold).SprintFunc() + fmt.Fprintf(cmd.OutOrStdout(), "%s deployed\n", success("v1.2.3")) +} diff --git a/.agents/skills/golang-cli/assets/examples/root.go b/.agents/skills/golang-cli/assets/examples/root.go new file mode 100644 index 0000000..ae72d65 --- /dev/null +++ b/.agents/skills/golang-cli/assets/examples/root.go @@ -0,0 +1,74 @@ +// cmd/myapp/root.go +package main + +import ( + "fmt" + "log/slog" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var cfgFile string + +var rootCmd = &cobra.Command{ + Use: "myapp", + Short: "A brief description of your application", + Long: "A longer description with usage examples.", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return initConfig() + }, + SilenceUsage: true, // don't print usage on errors from RunE + SilenceErrors: true, // handle error printing yourself +} + +func Execute() error { + return rootCmd.Execute() +} + +func init() { + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default $HOME/.myapp.yaml)") + rootCmd.PersistentFlags().String("log-level", "info", "log level (debug, info, warn, error)") + viper.BindPFlag("log-level", rootCmd.PersistentFlags().Lookup("log-level")) +} + +func initConfig() error { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("finding home directory: %w", err) + } + viper.AddConfigPath(home) + viper.AddConfigPath(".") + viper.SetConfigName(".myapp") + viper.SetConfigType("yaml") + } + + viper.SetEnvPrefix("MYAPP") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + viper.AutomaticEnv() + + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return fmt.Errorf("reading config: %w", err) + } + } + + // Set up logging based on config + level := slog.LevelInfo + switch strings.ToLower(viper.GetString("log-level")) { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))) + + return nil +} diff --git a/.agents/skills/golang-cli/assets/examples/serve.go b/.agents/skills/golang-cli/assets/examples/serve.go new file mode 100644 index 0000000..69e595f --- /dev/null +++ b/.agents/skills/golang-cli/assets/examples/serve.go @@ -0,0 +1,31 @@ +// cmd/myapp/serve.go +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the HTTP server", + RunE: func(cmd *cobra.Command, args []string) error { + port := viper.GetInt("port") + fmt.Fprintf(cmd.OutOrStdout(), "listening on :%d\n", port) + // start server... + return nil + }, +} + +func init() { + rootCmd.AddCommand(serveCmd) + serveCmd.Flags().IntP("port", "p", 8080, "port to listen on") + viper.BindPFlag("port", serveCmd.Flags().Lookup("port")) +} + +// For command groups, use AddGroup and set GroupID on commands: +// +// rootCmd.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"}) +// serveCmd.GroupID = "management" diff --git a/.agents/skills/golang-cli/assets/examples/signal.go b/.agents/skills/golang-cli/assets/examples/signal.go new file mode 100644 index 0000000..7e72edd --- /dev/null +++ b/.agents/skills/golang-cli/assets/examples/signal.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" +) + +// Use signal.NotifyContext to propagate cancellation through context. +var serveWithSignalCmd = &cobra.Command{ + Use: "serve", + Short: "Start the server", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + + srv := &http.Server{Addr: ":8080"} + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + srv.Shutdown(shutdownCtx) + }() + + slog.Info("server starting", "addr", srv.Addr) + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + return fmt.Errorf("server failed: %w", err) + } + return nil + }, +} diff --git a/.agents/skills/golang-cli/assets/examples/version.go b/.agents/skills/golang-cli/assets/examples/version.go new file mode 100644 index 0000000..41038f8 --- /dev/null +++ b/.agents/skills/golang-cli/assets/examples/version.go @@ -0,0 +1,39 @@ +// cmd/myapp/version.go +package main + +import ( + "fmt" + "runtime/debug" + + "github.com/spf13/cobra" +) + +// Set via ldflags +var ( + version = "dev" + commit = "unknown" + date = "unknown" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version information", + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintf(cmd.OutOrStdout(), "myapp %s (commit: %s, built: %s)\n", version, commit, date) + + if info, ok := debug.ReadBuildInfo(); ok { + fmt.Fprintf(cmd.OutOrStdout(), "go: %s\n", info.GoVersion) + } + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} + +// Build with: +// +// go build -ldflags "-X github.com/you/myapp/cmd/myapp.version=1.2.3 \ +// -X github.com/you/myapp/cmd/myapp.commit=$(git rev-parse --short HEAD) \ +// -X github.com/you/myapp/cmd/myapp.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ +// -o bin/myapp ./cmd/myapp diff --git a/.agents/skills/golang-cli/evals/evals.json b/.agents/skills/golang-cli/evals/evals.json new file mode 100644 index 0000000..67d455c --- /dev/null +++ b/.agents/skills/golang-cli/evals/evals.json @@ -0,0 +1,342 @@ +[ + { + "id": 1, + "name": "minimal-main-and-execute", + "description": "Tests that main.go is minimal and only calls Execute(), with os.Exit handled in main not inside commands", + "prompt": "Create the entry point for a Go CLI application called 'deploy' using Cobra. The app should have a root command and a 'push' subcommand. Write main.go and root.go.", + "trap": "Model puts configuration logic, flag parsing, or complex setup directly in main.go instead of keeping it minimal. May also call os.Exit inside RunE instead of returning errors.", + "assertions": [ + { + "id": "1.1", + "text": "main.go only calls Execute() (or rootCmd.Execute()) and os.Exit on error β€” no configuration, flag setup, or business logic in main()" + }, + { + "id": "1.2", + "text": "The root command sets SilenceUsage: true to prevent printing full usage text on every error" + }, + { + "id": "1.3", + "text": "The root command sets SilenceErrors: true to control error output format" + }, + { + "id": "1.4", + "text": "Subcommands do NOT call os.Exit() inside RunE β€” they return errors and let main() decide the exit code" + }, + { + "id": "1.5", + "text": "The push subcommand is registered via rootCmd.AddCommand() in an init() function or setup function" + } + ] + }, + { + "id": 2, + "name": "viper-config-layering", + "description": "Tests proper Viper configuration precedence: flags > env > config file > defaults, with env prefix and config-file-not-required", + "prompt": "I'm building a Go CLI server tool with Cobra. It needs a --port flag (default 3000) that can also be set via the MYSERVER_PORT env var or a config file at ~/.myserver.yaml. Write the configuration setup code.", + "trap": "Model doesn't bind flags to Viper (so flags and env/config are disconnected), forgets SetEnvPrefix (causing env var collisions), or crashes when no config file exists instead of ignoring ConfigFileNotFoundError.", + "assertions": [ + { + "id": "2.1", + "text": "Calls viper.BindPFlag to bind the port flag to Viper, ensuring viper.GetInt('port') returns the flag value when set" + }, + { + "id": "2.2", + "text": "Sets an env prefix with viper.SetEnvPrefix('MYSERVER' or similar) to namespace env vars and avoid collisions" + }, + { + "id": "2.3", + "text": "Calls viper.AutomaticEnv() to enable automatic env var binding" + }, + { + "id": "2.4", + "text": "Handles viper.ConfigFileNotFoundError gracefully (ignores it) β€” config file is optional, not crashing when absent" + }, + { + "id": "2.5", + "text": "The precedence order is correct: CLI flags > environment variables > config file > defaults" + } + ] + }, + { + "id": 3, + "name": "persistent-pre-run-config-init", + "description": "Tests that configuration initialization happens in PersistentPreRunE on the root command", + "prompt": "My Go CLI has a root command and three subcommands (serve, migrate, status). All of them need access to a database DSN from config. Where should I initialize the configuration so all subcommands have access? Write the code.", + "trap": "Model initializes config inside each subcommand's RunE (duplicating logic), or uses a global init() function instead of PersistentPreRunE, or puts it in cobra.OnInitialize without connecting it to the command tree properly.", + "assertions": [ + { + "id": "3.1", + "text": "Configuration initialization happens in PersistentPreRunE on the root command β€” this ensures it runs before every subcommand" + }, + { + "id": "3.2", + "text": "Config init is NOT duplicated inside each subcommand's RunE β€” it happens once in the root" + }, + { + "id": "3.3", + "text": "The --config flag (or equivalent) is a persistent flag on the root command so all subcommands inherit it" + }, + { + "id": "3.4", + "text": "Environment variables use a replacer (SetEnvKeyReplacer) to handle hyphens-to-underscores mapping (e.g., log-level becomes LOG_LEVEL)" + }, + { + "id": "3.5", + "text": "Logging is configured to write to stderr, not stdout" + } + ] + }, + { + "id": 4, + "name": "stdout-vs-stderr-separation", + "description": "Tests that program output goes to stdout and diagnostics/errors/logs go to stderr", + "prompt": "Write a Go CLI command 'list-users' using Cobra that fetches users from a database and prints them. It should log progress messages, handle errors, and support being piped to other commands (e.g., `myapp list-users | grep admin`). Write the RunE function.", + "trap": "Model writes log messages, error messages, or progress indicators to stdout (using fmt.Println) instead of stderr, which would corrupt piped output. May also use os.Stdout directly instead of cmd.OutOrStdout().", + "assertions": [ + { + "id": "4.1", + "text": "Program output (the user list) goes to stdout via cmd.OutOrStdout() or fmt.Fprint(cmd.OutOrStdout(), ...) β€” NOT os.Stdout directly" + }, + { + "id": "4.2", + "text": "Log messages, progress indicators, or diagnostic output go to stderr (via slog, log, or fmt.Fprint(os.Stderr, ...)) β€” NOT stdout" + }, + { + "id": "4.3", + "text": "Error messages go to stderr (via cmd.ErrOrStderr() or os.Stderr), not mixed with program output on stdout" + }, + { + "id": "4.4", + "text": "Uses cmd.OutOrStdout() instead of os.Stdout directly, enabling test capture" + }, + { + "id": "4.5", + "text": "The function returns an error from RunE rather than calling os.Exit() or log.Fatal() on failure" + } + ] + }, + { + "id": 5, + "name": "version-ldflags-injection", + "description": "Tests that version info is injected at build time via ldflags, not hardcoded", + "prompt": "Add a 'version' command to my Go CLI app that shows the version, git commit, and build date. How should I handle the version string?", + "trap": "Model hardcodes the version string as a constant (const version = \"1.0.0\") instead of using ldflags injection. May also not include the build command example.", + "assertions": [ + { + "id": "5.1", + "text": "Version, commit, and date are package-level variables (var, not const) with placeholder defaults like 'dev' or 'unknown'" + }, + { + "id": "5.2", + "text": "Shows or explains the -ldflags '-X ...' build command for injecting values at compile time" + }, + { + "id": "5.3", + "text": "The version command uses cmd.OutOrStdout() for output, not fmt.Println or os.Stdout directly" + }, + { + "id": "5.4", + "text": "Version is NOT hardcoded as a const string that would get out of sync with git tags" + }, + { + "id": "5.5", + "text": "Optionally includes runtime/debug.ReadBuildInfo() as a fallback or supplement for Go module version info" + } + ] + }, + { + "id": 6, + "name": "exit-code-conventions", + "description": "Tests proper Unix exit code mapping β€” 0 for success, 1 for general error, 2 for usage errors", + "prompt": "My Go CLI tool needs to report different exit codes for different failure types: invalid arguments, missing config file, network timeout, and successful completion. Write the error handling and exit code logic in main.go.", + "trap": "Model uses the same exit code (1) for all errors, or calls os.Exit deep inside command handlers instead of in main(). May also use non-standard exit codes.", + "assertions": [ + { + "id": "6.1", + "text": "Exit code 0 for success, exit code 1 for general runtime errors, exit code 2 for usage/argument errors β€” follows Unix conventions" + }, + { + "id": "6.2", + "text": "os.Exit() is called only in main(), not inside RunE functions or command handlers" + }, + { + "id": "6.3", + "text": "Uses a typed error or error wrapping pattern (like ExitError with a Code field) to propagate exit codes from commands to main" + }, + { + "id": "6.4", + "text": "Errors are returned from commands, not swallowed with os.Exit() calls that skip deferred cleanup" + }, + { + "id": "6.5", + "text": "Different error categories map to different exit codes, not all errors producing exit code 1" + } + ] + }, + { + "id": 7, + "name": "signal-handling-with-context", + "description": "Tests that signal handling uses signal.NotifyContext for context-based cancellation", + "prompt": "My Go CLI has a long-running 'serve' command that starts an HTTP server. I need graceful shutdown when the user presses Ctrl+C. Write the signal handling code.", + "trap": "Model uses a raw signal channel with signal.Notify instead of signal.NotifyContext, missing context propagation. May also not handle the shutdown timeout or forget SIGTERM.", + "assertions": [ + { + "id": "7.1", + "text": "Uses signal.NotifyContext to propagate cancellation through context β€” NOT a raw channel with signal.Notify and manual select" + }, + { + "id": "7.2", + "text": "Handles both os.Interrupt (Ctrl+C / SIGINT) and syscall.SIGTERM (container orchestrators)" + }, + { + "id": "7.3", + "text": "Creates a shutdown timeout context (e.g., 10-30 seconds) for graceful shutdown, not blocking indefinitely" + }, + { + "id": "7.4", + "text": "Calls srv.Shutdown(ctx) for graceful HTTP server shutdown, not srv.Close() which drops in-flight requests" + }, + { + "id": "7.5", + "text": "Distinguishes http.ErrServerClosed (normal shutdown) from unexpected server errors" + } + ] + }, + { + "id": 8, + "name": "flag-binding-and-constraints", + "description": "Tests flag patterns: persistent vs local, required flags, mutual exclusion, and Viper binding", + "prompt": "My Go CLI 'deploy' command needs these flags:\n- --env (required, must be one of: dev, staging, prod)\n- --tag (required, the docker image tag)\n- --dry-run and --force (mutually exclusive)\n- --verbose (available on all commands, not just deploy)\n\nWrite the flag setup code using Cobra.", + "trap": "Model makes --verbose a local flag instead of persistent, doesn't use MarkFlagsMutuallyExclusive for dry-run/force, or forgets to bind flags to Viper.", + "assertions": [ + { + "id": "8.1", + "text": "--verbose is a persistent flag (PersistentFlags) on the root command, not a local flag on deploy β€” it needs to be available on all commands" + }, + { + "id": "8.2", + "text": "Uses MarkFlagRequired for --env and --tag flags" + }, + { + "id": "8.3", + "text": "Uses MarkFlagsMutuallyExclusive for --dry-run and --force" + }, + { + "id": "8.4", + "text": "Uses RegisterFlagCompletionFunc to provide completion values for --env (dev, staging, prod)" + }, + { + "id": "8.5", + "text": "Binds configurable flags to Viper with viper.BindPFlag so they can be set via env vars or config file" + } + ] + }, + { + "id": 9, + "name": "argument-validation", + "description": "Tests use of Cobra's built-in argument validators instead of manual validation in RunE", + "prompt": "I have three Cobra commands:\n1. 'status' β€” takes no arguments\n2. 'deploy' β€” takes exactly one argument (the service name)\n3. 'scale' β€” takes 2-3 arguments (service, replica count, optional region)\n\nHow should I validate the arguments for each command?", + "trap": "Model manually validates len(args) inside RunE instead of using Cobra's declarative validators (cobra.NoArgs, cobra.ExactArgs, cobra.RangeArgs). May also use custom validation where built-in validators suffice.", + "assertions": [ + { + "id": "9.1", + "text": "Uses cobra.NoArgs for the status command β€” not manual len(args) == 0 check" + }, + { + "id": "9.2", + "text": "Uses cobra.ExactArgs(1) for the deploy command β€” not manual len(args) != 1 check" + }, + { + "id": "9.3", + "text": "Uses cobra.RangeArgs(2, 3) for the scale command β€” not manual len(args) < 2 || len(args) > 3 check" + }, + { + "id": "9.4", + "text": "Validators are set on the Args field of the command struct, not implemented inside RunE" + } + ] + }, + { + "id": 10, + "name": "cli-testing-pattern", + "description": "Tests that CLI commands are tested by executing programmatically with captured output", + "prompt": "Write tests for a Cobra CLI command 'greet' that takes a --name flag and prints a greeting. Test the default behavior and a custom name. Show the test helper and test function.", + "trap": "Model tests by running os.exec on the compiled binary instead of executing commands programmatically. May also not capture output via cmd.SetOut/cmd.SetErr buffers.", + "assertions": [ + { + "id": "10.1", + "text": "Creates an executeCommand helper that sets up a buffer, calls cmd.SetOut(buf) and cmd.SetErr(buf), sets args, and executes" + }, + { + "id": "10.2", + "text": "Tests are table-driven with multiple cases (at least default and custom name)" + }, + { + "id": "10.3", + "text": "Uses cmd.SetArgs() to pass arguments programmatically β€” not os/exec.Command" + }, + { + "id": "10.4", + "text": "Captures output via a bytes.Buffer set on the command β€” not by redirecting os.Stdout" + }, + { + "id": "10.5", + "text": "Tests check both the output string and the error return value" + } + ] + }, + { + "id": 11, + "name": "machine-readable-output-format", + "description": "Tests support for --output flag with multiple formats (json/table/plain) for scriptability", + "prompt": "My Go CLI command 'list-services' shows running services. I need it to support both human-readable and machine-parseable output for scripting. What's the best approach?", + "trap": "Model only supports a single output format, or adds a --json boolean flag instead of a flexible --output format flag. May also not use tabwriter for table output.", + "assertions": [ + { + "id": "11.1", + "text": "Supports an --output flag with at least json and table formats (not just a --json boolean toggle)" + }, + { + "id": "11.2", + "text": "JSON output uses encoding/json encoder writing to cmd.OutOrStdout()" + }, + { + "id": "11.3", + "text": "Table output uses text/tabwriter or similar for aligned columns β€” not ad-hoc spacing" + }, + { + "id": "11.4", + "text": "The default format is human-readable (table), with JSON/plain as opt-in machine formats" + } + ] + }, + { + "id": 12, + "name": "shell-completion-setup", + "description": "Tests proper shell completion command and custom completions for flags", + "prompt": "Add shell completion support to my Go CLI built with Cobra. I want users to be able to run 'myapp completion bash' to generate a completion script. Also, the --env flag should suggest 'dev', 'staging', 'prod' during tab completion.", + "trap": "Model implements completion from scratch instead of using Cobra's built-in generators. May forget the ValidArgs field or RegisterFlagCompletionFunc for custom completions.", + "assertions": [ + { + "id": "12.1", + "text": "Creates a 'completion' subcommand that supports bash, zsh, fish, and powershell as arguments" + }, + { + "id": "12.2", + "text": "Uses Cobra's built-in completion generators (GenBashCompletionV2, GenZshCompletion, GenFishCompletion, GenPowerShellCompletionWithDesc) β€” not custom completion scripts" + }, + { + "id": "12.3", + "text": "Uses RegisterFlagCompletionFunc for the --env flag to suggest dev/staging/prod values" + }, + { + "id": "12.4", + "text": "Uses cobra.ExactValidArgs or ValidArgs to validate completion arguments for the completion command itself" + }, + { + "id": "12.5", + "text": "Returns cobra.ShellCompDirectiveNoFileComp for flags that don't accept file paths" + } + ] + } +] diff --git a/.agents/skills/golang-code-style/SKILL.md b/.agents/skills/golang-code-style/SKILL.md new file mode 100644 index 0000000..8968872 --- /dev/null +++ b/.agents/skills/golang-code-style/SKILL.md @@ -0,0 +1,236 @@ +--- +name: golang-code-style +description: "Golang code style conventions β€” line length and breaking, variable declarations, control flow clarity, when comments help vs hurt. Use when writing or reviewing Go code, asking about style or clarity, or establishing project coding standards. Not for naming conventions (β†’ See `samber/cc-skills-golang@golang-naming` skill), linter configuration (β†’ See `samber/cc-skills-golang@golang-lint` skill), or doc comments (β†’ See `samber/cc-skills-golang@golang-documentation` skill)." +user-invocable: true +license: MIT +compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang. +metadata: + author: samber + version: "1.2.0" + openclaw: + emoji: "🎨" + homepage: https://github.com/samber/cc-skills-golang + requires: + bins: + - go + install: [] +allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent +--- + +> **Community default.** A company skill that explicitly supersedes `samber/cc-skills-golang@golang-code-style` skill takes precedence. + +# Go Code Style + +Style rules that require human judgment β€” linters handle formatting, this skill handles clarity. For naming see `samber/cc-skills-golang@golang-naming` skill; for design patterns see `samber/cc-skills-golang@golang-design-patterns` skill; for struct/interface design see `samber/cc-skills-golang@golang-structs-interfaces` skill. + +> "Clear is better than clever." β€” Go Proverbs + +When ignoring a rule, add a comment to the code. + +## Line Length & Breaking + +No rigid line limit, but lines beyond ~120 characters MUST be broken. Break at **semantic boundaries**, not arbitrary column counts. Function calls with 4+ arguments MUST use one argument per line β€” even when the prompt asks for single-line code: + +```go +// Good β€” each argument on its own line, closing paren separate +mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { + handleUsers( + w, + r, + serviceName, + cfg, + logger, + authMiddleware, + ) +}) +``` + +When a function signature is too long, the real fix is often **fewer parameters** (use an options struct) rather than better line wrapping. For multi-line signatures, put each parameter on its own line. + +## Variable Declarations + +SHOULD use `:=` for non-zero values, `var` for zero-value initialization. The form signals intent: `var` means "this starts at zero." + +```go +var count int // zero value, set later +name := "default" // non-zero, := is appropriate +var buf bytes.Buffer // zero value is ready to use +``` + +### Slice & Map Initialization + +Slices and maps MUST be initialized explicitly, never nil. Nil maps panic on write; nil slices serialize to `null` in JSON (vs `[]` for empty slices), surprising API consumers. + +```go +users := []User{} // always initialized +m := map[string]int{} // always initialized +users := make([]User, 0, len(ids)) // preallocate when capacity is known +m := make(map[string]int, len(items)) // preallocate when size is known +``` + +Do not preallocate speculatively β€” `make([]T, 0, 1000)` wastes memory when the common case is 10 items. + +### Composite Literals + +Composite literals MUST use field names β€” positional fields break when the type adds or reorders fields: + +```go +srv := &http.Server{ + Addr: ":8080", + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, +} +``` + +## Control Flow + +### Reduce Nesting + +Errors and edge cases MUST be handled first (early return). Keep the happy path at minimal indentation: + +```go +func process(data []byte) (*Result, error) { + if len(data) == 0 { + return nil, errors.New("empty data") + } + + parsed, err := parse(data) + if err != nil { + return nil, fmt.Errorf("parsing: %w", err) + } + + return transform(parsed), nil +} +``` + +### Eliminate Unnecessary `else` + +When the `if` body ends with `return`/`break`/`continue`, the `else` MUST be dropped. Use default-then-override for simple assignments β€” assign a default, then override with independent conditions or a `switch`: + +```go +// Good β€” default-then-override with switch (cleanest for mutually exclusive overrides) +level := slog.LevelInfo +switch { +case debug: + level = slog.LevelDebug +case verbose: + level = slog.LevelWarn +} + +// Bad β€” else-if chain hides that there's a default +if debug { + level = slog.LevelDebug +} else if verbose { + level = slog.LevelWarn +} else { + level = slog.LevelInfo +} +``` + +### Complex Conditions & Init Scope + +When an `if` condition has 3+ operands, MUST extract into named booleans β€” a wall of `||` is unreadable and hides business logic. Keep expensive checks inline for short-circuit benefit. [Details](./references/details.md) + +```go +// Good β€” named booleans make intent clear +isAdmin := user.Role == RoleAdmin +isOwner := resource.OwnerID == user.ID +isPublicVerified := resource.IsPublic && user.IsVerified +if isAdmin || isOwner || isPublicVerified || permissions.Contains(PermOverride) { + allow() +} +``` + +Scope variables to `if` blocks when only needed for the check: + +```go +if err := validate(input); err != nil { + return err +} +``` + +### Switch Over If-Else Chains + +When comparing the same variable multiple times, prefer `switch`: + +```go +switch status { +case StatusActive: + activate() +case StatusInactive: + deactivate() +default: + panic(fmt.Sprintf("unexpected status: %d", status)) +} +``` + +## Function Design + +- Functions SHOULD be **short and focused** β€” one function, one job. +- Functions SHOULD have **≀4 parameters**. Beyond that, use an options struct (see `samber/cc-skills-golang@golang-design-patterns` skill). +- **Parameter order**: `context.Context` first, then inputs, then output destinations. +- Naked returns help in very short functions (1-3 lines) where return values are obvious, but become confusing when readers must scroll to find what's returned β€” name returns explicitly in longer functions. + +```go +func FetchUser(ctx context.Context, id string) (*User, error) +func SendEmail(ctx context.Context, msg EmailMessage) error // grouped into struct +``` + +### Prefer `range` for Iteration + +SHOULD use `range` over index-based loops. Use `range n` (Go 1.22+) for simple counting. + +```go +for _, user := range users { + process(user) +} +``` + +## Value vs Pointer Arguments + +Pass small types (`string`, `int`, `bool`, `time.Time`) by value. Use pointers when mutating, for large structs (~128+ bytes), or when nil is meaningful. [Details](./references/details.md) + +## Code Organization Within Files + +- **Group related declarations**: type, constructor, methods together +- **Order**: package doc, imports, constants, types, constructors, methods, helpers +- **One primary type per file** when it has significant methods +- **Blank imports** (`_ "pkg"`) register side effects (init functions). Restricting them to `main` and test packages makes side effects visible at the application root, not hidden in library code +- **Dot imports** pollute the namespace and make it impossible to tell where a name comes from β€” never use in library code +- **Unexport aggressively** β€” you can always export later; unexporting is a breaking change + +## String Handling + +Use `strconv` for simple conversions (faster), `fmt.Sprintf` for complex formatting. Use `%q` in error messages to make string boundaries visible. Use `strings.Builder` for loops, `+` for simple concatenation. + +## Type Conversions + +Prefer explicit, narrow conversions. Use generics over `any` when a concrete type will do: + +```go +func Contains[T comparable](slice []T, target T) bool // not []any +``` + +## Philosophy + +- **"A little copying is better than a little dependency"** +- **Use `slices` and `maps` standard packages**; for filter/group-by/chunk, use `github.com/samber/lo` +- **"Reflection is never clear"** β€” avoid `reflect` unless necessary +- **Don't abstract prematurely** β€” extract when the pattern is stable +- **Minimize public surface** β€” every exported name is a commitment + +## Parallelizing Code Style Reviews + +When reviewing code style across a large codebase, use up to 5 parallel sub-agents (via the Agent tool), each targeting an independent style concern (e.g. control flow, function design, variable declarations, string handling, code organization). + +## Enforce with Linters + +Many rules are enforced automatically: `gofmt`, `gofumpt`, `goimports`, `gocritic`, `revive`, `wsl_v5`. β†’ See the `samber/cc-skills-golang@golang-lint` skill. + +## Cross-References + +- β†’ See the `samber/cc-skills-golang@golang-naming` skill for identifier naming conventions +- β†’ See the `samber/cc-skills-golang@golang-structs-interfaces` skill for pointer vs value receivers, interface design +- β†’ See the `samber/cc-skills-golang@golang-design-patterns` skill for functional options, builders, constructors +- β†’ See the `samber/cc-skills-golang@golang-lint` skill for automated formatting enforcement +- β†’ See `samber/cc-skills-golang@golang-continuous-integration` skill for automated AI-driven code review in CI using these guidelines diff --git a/.agents/skills/golang-code-style/evals/evals.json b/.agents/skills/golang-code-style/evals/evals.json new file mode 100644 index 0000000..df03154 --- /dev/null +++ b/.agents/skills/golang-code-style/evals/evals.json @@ -0,0 +1,570 @@ +[ + { + "id": 1, + "name": "zero-value-intent-signal", + "description": "var vs := signals intent: var for zero-value start, := for non-zero β€” even when the zero value IS the business default", + "prompt": "Write a Go file `session.go` in package `session`. Create a Session struct with fields: userID string, requestCount int, isAuthenticated bool, lastError error, startedAt time.Time, tags []string. Add a constructor NewSession(userID string) that initializes the struct. In the constructor, requestCount starts at zero (it will be incremented on each use), isAuthenticated starts false (it must be explicitly set after login), lastError starts nil, startedAt is set to time.Now(), and tags is initialized as an empty slice. Also add a local variable inside a method Describe() that builds a description string from a fixed prefix 'session:'.", + "trap": "Model uses := for all fields including zero-value ones (requestCount := 0, isAuthenticated := false) or uses var for non-zero assignments like startedAt, obscuring intent", + "assertions": [ + { + "id": "1.1", + "text": "requestCount is NOT explicitly initialized to 0 in the constructor β€” the zero value is relied upon via var or struct literal without that field, not via requestCount := 0" + }, + { + "id": "1.2", + "text": "startedAt uses := assignment (startedAt := time.Now() or field assignment) β€” NOT var startedAt time.Time followed by assignment β€” because it has a non-zero meaningful value" + }, + { + "id": "1.3", + "text": "The fixed prefix string variable in Describe() uses := (prefix := 'session:') β€” not var prefix string = 'session:'" + }, + { + "id": "1.4", + "text": "tags is initialized with []string{} or make([]string, 0) β€” never nil β€” because nil slices serialize to null in JSON" + } + ] + }, + { + "id": 2, + "name": "empty-slice-map-not-nil", + "description": "Empty collections initialized as []T{} or make(), never nil", + "prompt": "Write a Go file `handler.go` in package `api`. Create a Handler struct. Add a method ListUsers that returns a slice of User structs (define User with Name and Email fields). Add a method GetTags that returns a map[string]string. Both methods should return empty collections when there's no data. Add a method BuildResponse that takes a slice of results and a map of metadata and processes them.", + "trap": "Model returns nil slices/maps or uses uninitialized var declarations for empty collections, causing nil != empty issues in JSON serialization and caller code", + "assertions": [ + { + "id": "2.1", + "text": "Empty slice return uses []User{} or make([]User, 0) β€” never returns nil or an uninitialized var declaration without assignment" + }, + { + "id": "2.2", + "text": "Empty map return uses map[string]string{} or make(map[string]string) β€” never returns nil or an uninitialized var declaration without assignment" + }, + { + "id": "2.3", + "text": "When capacity is known (e.g., from len(input)), make() with capacity hint is used for preallocating slices or maps" + } + ] + }, + { + "id": 3, + "name": "named-struct-fields-nested", + "description": "All struct literals use named fields, including nested and anonymous structs", + "prompt": "Write a Go file `middleware.go` in package `middleware`. Create a RateLimiter struct with fields: windowSize time.Duration, maxRequests int, perIP bool. Create a middleware chain config using a struct literal with nested structs: a TimeoutConfig containing duration and errorMessage, a RetryConfig containing maxAttempts int and backoff time.Duration, and a CircuitBreakerConfig containing threshold float64 and resetAfter time.Duration. Instantiate each of these structs as part of a larger MiddlewareConfig struct. Also create a tls.Config with MinVersion and a list of cipher suites.", + "trap": "Model uses positional struct literals for the nested configs since they have few fields β€” e.g., TimeoutConfig{5*time.Second, 'timeout'} β€” which silently breaks when fields are reordered", + "assertions": [ + { + "id": "3.1", + "text": "RateLimiter literal uses named fields (WindowSize:, MaxRequests:, PerIP:) β€” not positional" + }, + { + "id": "3.2", + "text": "TimeoutConfig, RetryConfig, and CircuitBreakerConfig literals all use named fields β€” not positional β€” even though each has only 2-3 fields" + }, + { + "id": "3.3", + "text": "tls.Config literal uses named fields (MinVersion:, CipherSuites:)" + }, + { + "id": "3.4", + "text": "No struct literal in the file uses positional field syntax" + } + ] + }, + { + "id": 4, + "name": "early-return-not-nested", + "description": "Validation uses early returns; happy path at minimal indentation despite prompt asking for nesting", + "prompt": "Write a Go function ProcessOrder in package `orders` that takes an order struct (define it with fields: ID string, Items []Item, Status string, CustomerID string). The function should validate that the ID is not empty, that there is at least one item, that the status is 'pending', that the customer exists (simulate with a lookup function), compute the total price, apply a discount if total > 100, and return the final total with an error. Write it with deeply nested if-else blocks.", + "trap": "Model follows the prompt's instruction to use deeply nested if-else blocks, burying the happy path inside 4+ levels of indentation", + "assertions": [ + { + "id": "4.1", + "text": "Validation checks (empty ID, no items, wrong status) use early return pattern β€” each check returns an error immediately rather than nesting the rest of the function inside an else block" + }, + { + "id": "4.2", + "text": "The happy path (compute total, apply discount, return) is at the top level of the function body (indentation level 1), not nested inside multiple if blocks" + }, + { + "id": "4.3", + "text": "The function has at most 2 levels of indentation for the main logic (excluding the error-check if blocks which return early)" + } + ] + }, + { + "id": 5, + "name": "no-else-after-return", + "description": "No else after return; default-then-override for simple assignments", + "prompt": "Write a Go function GetUserRole in package `auth` that takes a user struct with IsAdmin bool, IsModerator bool, and IsVerified bool fields. Return a role string: if admin return 'admin', else if moderator return 'moderator', else if verified return 'member', else return 'guest'. Also write a function SetLogLevel that takes a verbose bool and a debug bool, and sets the log level appropriately using slog.", + "trap": "Model uses else-if chains after return statements, adding unnecessary indentation and cognitive load", + "assertions": [ + { + "id": "5.1", + "text": "GetUserRole does NOT use else or else-if after a return statement β€” either uses early returns (if isAdmin { return 'admin' }; if isModerator { return 'moderator' }) or a switch statement" + }, + { + "id": "5.2", + "text": "SetLogLevel uses the default-then-override pattern: assigns a default level first, then conditionally overrides with if (not if-else chains)" + } + ] + }, + { + "id": 6, + "name": "switch-over-if-else-chain", + "description": "Multi-branch comparisons use switch statements, not if-else chains", + "prompt": "Write a Go function HandleEvent in package `events` that takes an event with a Type string field. The type can be 'click', 'scroll', 'keypress', 'hover', 'focus', or 'blur'. Each type should call a different handler function. Also write a function MapStatusCode that takes an int HTTP status code and returns a human-readable string for 200, 201, 204, 400, 401, 403, 404, 500, 502, 503.", + "trap": "Model uses if-else chains for multi-branch string/int comparisons instead of switch statements", + "assertions": [ + { + "id": "6.1", + "text": "HandleEvent uses a switch statement on event.Type, not an if-else chain" + }, + { + "id": "6.2", + "text": "MapStatusCode uses a switch statement on the status code, not an if-else chain" + }, + { + "id": "6.3", + "text": "Both switch statements include a default case" + } + ] + }, + { + "id": 7, + "name": "options-struct-not-many-params", + "description": "Groups params into options struct, context.Context first", + "prompt": "Write a Go function SendNotification in package `notify` that sends a notification. It needs these parameters: ctx context.Context, userID string, message string, channel string (email/sms/push), priority int, retryCount int, dryRun bool, templateID string, metadata map[string]string, callback func(error). Put all parameters directly in the function signature.", + "trap": "Model follows the prompt's instruction to put all 10 parameters directly in the function signature, creating an unusable API", + "assertions": [ + { + "id": "7.1", + "text": "context.Context is the first parameter in the function signature" + }, + { + "id": "7.2", + "text": "The function uses an options struct (or similar grouping) to reduce the parameter count to 4 or fewer in the main function signature β€” not all 10 parameters listed individually" + }, + { + "id": "7.3", + "text": "The options struct groups related configuration (channel, priority, retryCount, dryRun, templateID, metadata, callback) into a single parameter" + } + ] + }, + { + "id": 8, + "name": "value-vs-pointer-params", + "description": "Value params for small types; pointers only for mutation β€” not pointer everything", + "prompt": "Write Go functions in package `users`: (1) FormatName that takes a first name string and last name string and returns the formatted full name, (2) UpdateAge that modifies a User struct's age field, (3) FindUser that takes a user ID string and returns a User pointer, (4) CompareUsers that checks if two User structs (each about 32 bytes with Name string and Age int) are equal. Use pointer parameters for all functions.", + "trap": "Model follows the prompt's instruction to use pointer parameters for all functions, including small value types like string and int where pointers only add indirection", + "assertions": [ + { + "id": "8.1", + "text": "FormatName takes string parameters by value (not *string) β€” strings are small fixed-size types" + }, + { + "id": "8.2", + "text": "UpdateAge takes a *User pointer parameter because it mutates the struct" + }, + { + "id": "8.3", + "text": "CompareUsers takes User structs by value (not *User) because it only reads them and they are small (<128 bytes)" + } + ] + }, + { + "id": 9, + "name": "unexported-internal-helpers", + "description": "Helper functions called only within the same package stay unexported; exporting is a commitment", + "prompt": "Write a Go file `parser.go` in package `config`. Include: an exported ParseConfig function that reads a file path and returns a *Config struct; an exported ValidateConfig function that checks required fields; a helper function that tokenizes a raw config string (used only by ParseConfig); a helper function that resolves environment variable references in values (used by ParseConfig and ValidateConfig); a helper function that formats a field path for error messages (used only in error messages inside ValidateConfig). Make all functions exported for potential future reuse from other packages.", + "trap": "Model follows the prompt's instruction to export all functions, leaking tokenizer, env resolver, and error formatter as public API β€” any future change to them becomes a breaking change", + "assertions": [ + { + "id": "9.1", + "text": "The tokenizer helper (used only by ParseConfig) is unexported (lowercase name)" + }, + { + "id": "9.2", + "text": "The error message formatter (used only inside ValidateConfig) is unexported (lowercase name)" + }, + { + "id": "9.3", + "text": "ParseConfig and ValidateConfig remain exported β€” they are the true public API" + }, + { + "id": "9.4", + "text": "The env variable resolver may be exported OR unexported β€” both are defensible since it's used by two functions, but the model should NOT export all helpers blindly" + } + ] + }, + { + "id": 10, + "name": "strings-join-not-builder-for-known-slice", + "description": "strings.Join for joining a known slice; strings.Builder for dynamic/loop accumulation β€” not one-size-fits-all", + "prompt": "Write a Go file `format.go` in package `format` with four functions: (1) JoinTags that takes a []string of tags and returns them comma-separated, (2) BuildCSVRow that takes a []string of fields and returns a CSV line with quotes and commas, (3) BuildAuditLog that takes a slice of AuditEntry structs (each with timestamp and message fields) and returns a multi-line string by iterating over entries, (4) FormatError that takes an error code int and a description string and returns a formatted error string like 'ERR-42: bad input'.", + "trap": "Model uses strings.Builder for everything including JoinTags (which should use strings.Join) and fmt.Sprintf for the int conversion in FormatError instead of strconv", + "assertions": [ + { + "id": "10.1", + "text": "JoinTags uses strings.Join(tags, ',') β€” not a manual loop or strings.Builder β€” because the input is already a complete slice" + }, + { + "id": "10.2", + "text": "BuildAuditLog uses strings.Builder (WriteString in a loop) β€” not repeated += β€” because entries are accumulated one at a time in a loop" + }, + { + "id": "10.3", + "text": "FormatError uses fmt.Sprintf (or strconv.Itoa for the int part) β€” NOT strings.Builder β€” because it formats a short fixed-structure message" + }, + { + "id": "10.4", + "text": "No function uses += string concatenation inside a loop body" + } + ] + }, + { + "id": 11, + "name": "named-boolean-conditions-method-calls", + "description": "Expensive method calls in complex conditions extracted to named booleans to make intent readable", + "prompt": "Write a Go function CanPublish in package `cms` that determines whether a user can publish an article. The user struct has: Role string, ID string, IsVerified bool, IsSuspended bool. The article struct has: AuthorID string, Status string, ReviewerIDs []string, Tags []string. The permission check requires: (user.Role == 'editor' || user.Role == 'admin') AND NOT user.IsSuspended AND user.IsVerified AND (user.ID == article.AuthorID || contains(article.ReviewerIDs, user.ID)) AND article.Status == 'reviewed' AND NOT contains(article.Tags, 'restricted'). Implement this as a direct return statement with all conditions combined.", + "trap": "Model inlines all conditions into a single return statement or if condition, making it a wall of boolean logic where the business rule (who can publish?) is buried in implementation details", + "assertions": [ + { + "id": "11.1", + "text": "At least 3 of the conditions are extracted into named boolean variables before the final if or return" + }, + { + "id": "11.2", + "text": "Named booleans reflect business meaning β€” names like isEditor, isEligibleAuthor, isArticleReady rather than cond1, checkA" + }, + { + "id": "11.3", + "text": "The final expression reads like a policy statement: return isEligibleUser && isAuthorOrReviewer && isArticleReady (or similar)" + } + ] + }, + { + "id": 12, + "name": "line-breaks-at-semantic-boundaries", + "description": "Function calls with 4+ arguments broken at argument boundaries; no line over ~120 chars", + "prompt": "Write a Go function RegisterRoutes in package `router` that takes an *http.ServeMux and registers 6 API routes. Each route handler is a closure that delegates to a processRequest function taking: an http.ResponseWriter, an *http.Request, a serviceName string constant 'com.example.platform.api', a *Config, a *slog.Logger, and an authMiddleware func. Write idiomatic Go.", + "trap": "Model writes each mux.HandleFunc and inner processRequest call as a single long line since that is the most compact and natural first draft", + "assertions": [ + { + "id": "12.1", + "text": "The processRequest call inside each handler is broken across multiple lines β€” each argument on its own line β€” because it has 6 arguments" + }, + { + "id": "12.2", + "text": "Line breaks occur at semantic boundaries (after commas between arguments) β€” not at arbitrary column positions mid-expression" + }, + { + "id": "12.3", + "text": "Closing parentheses for multi-line function calls appear on their own line (Go trailing-comma style)" + }, + { + "id": "12.4", + "text": "No single line in the file exceeds approximately 140 characters" + } + ] + }, + { + "id": 13, + "name": "never-nil-return-for-collection", + "description": "Returns initialized empty collections even when no data β€” nil slices serialize to JSON null; nil maps panic on write", + "prompt": "Write a Go HTTP handler ListProducts in package `api` that queries a database for products matching a filter and returns JSON. Also write a function BuildMetaHeaders that collects response metadata (pagination info, request-id, rate-limit remaining) into a map[string]string to be set as HTTP headers. Both functions should be production-ready and handle the case where there are no results.", + "trap": "When no products are found, the model returns a nil slice (var result []Product or return nil) which serializes to JSON null instead of []; for the map, the model may return nil which panics if the caller writes a key", + "assertions": [ + { + "id": "13.1", + "text": "ListProducts initializes the result slice with []Product{} or make([]Product, 0) β€” NOT var result []Product left nil β€” so an empty result serializes to [] not null" + }, + { + "id": "13.2", + "text": "BuildMetaHeaders initializes and returns map[string]string{} or make(map[string]string) β€” NOT nil β€” because nil maps panic on write" + }, + { + "id": "13.3", + "text": "Neither function contains 'return nil' as a success/no-data path (nil is only paired with a non-nil error)" + } + ] + }, + { + "id": 14, + "name": "strconv-and-builder-natural-scenario", + "description": "strconv.Itoa for int-to-string; strings.Builder for loop accumulation β€” chosen naturally without explicit instruction", + "prompt": "Write a Go function ExportToCSV in package `export` that takes a []Record (each Record has: ID int, Name string, Score float64, Active bool) and returns a CSV string. The first line should be the header. Each subsequent line is a row. ID must be converted to a string for the CSV. Aim for correctness and reasonable performance β€” this function may be called with thousands of records.", + "trap": "Model converts ID with fmt.Sprintf('%d', r.ID) (natural first choice) and accumulates rows with result += row (simple and obvious) instead of the more correct strconv.Itoa and strings.Builder", + "assertions": [ + { + "id": "14.1", + "text": "ID integer-to-string conversion uses strconv.Itoa(r.ID) or strconv.FormatInt β€” NOT fmt.Sprintf('%d', r.ID)" + }, + { + "id": "14.2", + "text": "Row accumulation uses strings.Builder (WriteString or WriteByte in a loop) β€” NOT result += row or result = result + row in the loop body" + }, + { + "id": "14.3", + "text": "strings.Builder is declared once before the loop and .String() is called after the loop to produce the final output" + }, + { + "id": "14.4", + "text": "The function does NOT contain '+=' applied to a string variable inside a loop body" + } + ] + }, + { + "id": 15, + "name": "named-fields-override-positional-prompt", + "description": "Uses named struct fields even when prompt explicitly requests positional syntax", + "prompt": "Write Go structs in package `config` using positional field syntax for brevity. Create: Point{float64, float64}, Color{uint8, uint8, uint8, uint8}, and ServerConfig{string, int, bool, time.Duration, time.Duration, *tls.Config}. Positional syntax is shorter and the field order is obvious from the type definition.", + "trap": "Model follows the prompt's positional syntax instruction, creating brittle literals that silently break when struct fields are added or reordered", + "assertions": [ + { + "id": "15.1", + "text": "Point literal uses named fields (X:, Y: or similar) β€” NOT Point{1.0, 2.0}" + }, + { + "id": "15.2", + "text": "Color literal uses named fields (R:, G:, B:, A: or similar) β€” NOT Color{255, 128, 0, 255}" + }, + { + "id": "15.3", + "text": "ServerConfig literal uses named fields β€” NOT positional β€” because positional breaks when fields are added or reordered" + }, + { + "id": "15.4", + "text": "No struct literal in the file uses positional field syntax" + } + ] + }, + { + "id": 16, + "name": "early-return-override-nested-prompt", + "description": "Uses early returns despite prompt explicitly requesting nested if-else validation pattern", + "prompt": "Write a Go function ValidateAndProcess in package `pipeline` that validates an input struct (check: non-empty Name, Age > 0, valid Email containing '@', Status is 'active' or 'pending', non-nil Permissions slice, at least one Permission). If all validations pass, compute a score, apply modifiers, and return the result. Use the traditional if-else pattern: if valid { if next_valid { if next { ... } else { error } } else { error } } else { error }. This makes the success path clear by keeping it inside the innermost block.", + "trap": "Model follows the prompt's explicit nested if-else pattern, nesting the success path 5+ levels deep", + "assertions": [ + { + "id": "16.1", + "text": "Each validation check uses early return β€” if Name empty return error, if Age <= 0 return error, etc." + }, + { + "id": "16.2", + "text": "The function does NOT contain nested if-else blocks for validation (no else clause after a validation if)" + }, + { + "id": "16.3", + "text": "The happy path (score computation) is at indentation level 1, not nested inside 5+ levels" + }, + { + "id": "16.4", + "text": "Maximum indentation depth for the main logic is 2 (function body + one loop or if)" + } + ] + }, + { + "id": 17, + "name": "context-first-options-struct", + "description": "context.Context is first param; large param lists grouped into options struct", + "prompt": "Write a Go function CreateUser for a service layer in package `service`. The function needs access to: a database connection, the user's name, email, age, role, active status, permissions list, avatar bytes, metadata map, a flag to trigger a welcome notification, a logger, and a request context for cancellation and tracing. Implement this function with all inputs needed for the operation.", + "trap": "Model puts context.Context last or in the middle (as it might appear in a method converted from another pattern), and lists all parameters individually without grouping β€” the natural draft mirrors the bullet list order in the prompt", + "assertions": [ + { + "id": "17.1", + "text": "context.Context is the FIRST parameter in the function signature" + }, + { + "id": "17.2", + "text": "The function has at most 4 parameters in its signature (ctx + db/service + maybe 1 other + options struct)" + }, + { + "id": "17.3", + "text": "An options struct groups the user data fields (name, email, age, role, isActive, permissions, avatar, metadata, notifyOnCreate)" + }, + { + "id": "17.4", + "text": "The logger is either in the options struct or a field on a Service receiver β€” NOT a standalone function parameter alongside ctx" + } + ] + }, + { + "id": 18, + "name": "pointer-judgment-mixed-types", + "description": "Pointer vs value judgment for a mixed set of types β€” small value types by value, large structs and optional values by pointer", + "prompt": "Write Go function signatures in package `inventory` for: (1) ComputeDiscount that takes a price float64 and a discount rate float64 and returns the discounted price, (2) UpdateInventoryItem that modifies an InventoryItem struct (define it with ~10 fields: ID, SKU, Name, Description, Price, Stock, Weight, Category, Tags, UpdatedAt), (3) FindByCategory that takes a category string and returns matching items, (4) MergeConfig that takes two Config structs (small, 3 fields: Timeout time.Duration, MaxRetries int, Debug bool) and returns a merged Config, (5) ApplyPatch that takes an InventoryItem and an optional patch (may be absent β€” caller passes nothing when there is no patch) and applies it.", + "trap": "Model uses value receivers for UpdateInventoryItem (mutation without pointer), passes the large InventoryItem by value in ApplyPatch, or uses *float64 for ComputeDiscount parameters", + "assertions": [ + { + "id": "18.1", + "text": "ComputeDiscount takes (price float64, rate float64) float64 β€” NOT *float64 β€” small value types go by value" + }, + { + "id": "18.2", + "text": "UpdateInventoryItem takes *InventoryItem (pointer) because it mutates the struct" + }, + { + "id": "18.3", + "text": "MergeConfig takes two Config by value (not *Config) β€” the struct is small (~3 fields) and not mutated" + }, + { + "id": "18.4", + "text": "ApplyPatch takes the optional patch as *Patch or *InventoryItem (pointer) to represent the absent/nil case β€” NOT a value type that cannot be nil" + }, + { + "id": "18.5", + "text": "FindByCategory takes category string by value β€” NOT *string" + } + ] + }, + { + "id": 19, + "name": "named-conditions-override-inline-prompt", + "description": "Extracts complex conditions to named booleans despite prompt explicitly saying NOT to", + "prompt": "Write a Go function AssignPermission in package `rbac` that determines a user's effective permission level. Check these conditions with a traditional if-else chain β€” do NOT use variables to store intermediate results: if user.Role == 'superadmin' && !user.IsSuspended, or if user.Role == 'admin' && user.Department == resource.Department && user.TenantID == resource.TenantID, or if resource.IsPublic && user.IsVerified && !user.IsRestricted, or if user.Groups.Contains(resource.RequiredGroup) && user.MFA.IsEnabled() && time.Since(user.MFA.LastVerified) < 24*time.Hour, or if resource.ACL.HasEntry(user.ID) && resource.ACL.GetPermission(user.ID).Level >= MinReadLevel. Return the matching permission level or ErrAccessDenied.", + "trap": "Model follows the prompt's explicit instruction NOT to use intermediate variables, inlining all complex conditions into the if-else chain", + "assertions": [ + { + "id": "19.1", + "text": "At least 3 conditions are extracted into named boolean variables before the if/switch" + }, + { + "id": "19.2", + "text": "Named booleans have domain-meaningful names (e.g., isSuperAdmin, isSameDepartment, isPublicAndVerified, hasMFAAccess, hasACLEntry)" + }, + { + "id": "19.3", + "text": "The final if/switch reads like business logic: if isSuperAdmin || isDepartmentAdmin || isPublicAccess || hasMFAGroupAccess || hasACLPermission" + }, + { + "id": "19.4", + "text": "The most expensive checks (Groups.Contains + MFA, ACL.HasEntry + GetPermission) are either kept inline for short-circuit or extracted last" + } + ] + }, + { + "id": 20, + "name": "line-breaks-override-compact-prompt", + "description": "Breaks long lines despite prompt requesting single-line compact code", + "prompt": "Write a Go function SetupHandlers in package `api` that registers 8 REST endpoint handlers on an http.ServeMux. Each handler closure calls processRequest with: the http.ResponseWriter, *http.Request, the service name \"com.example.platform.microservices.user-management.api.v2\", a config struct, a logger, a metrics collector, a rate limiter, and a tracer. Write compact code β€” each mux.HandleFunc call should be on a single line for easy scanning.", + "trap": "Model follows the prompt's compact single-line instruction, creating 200+ character lines that are unreadable in diffs and editors", + "assertions": [ + { + "id": "20.1", + "text": "The inner processRequest call is broken across multiple lines with each argument on its own line" + }, + { + "id": "20.2", + "text": "No single line in the file exceeds ~140 characters" + }, + { + "id": "20.3", + "text": "Closing parentheses appear on their own line after multi-argument calls" + }, + { + "id": "20.4", + "text": "The service name string is extracted to a constant or variable, not repeated inline 8 times" + } + ] + }, + { + "id": 21, + "name": "switch-override-else-prompt", + "description": "Uses switch or early returns despite prompt explicitly requiring else after every if", + "prompt": "Write a Go function GetPricingTier in package `billing` that takes a customer struct with fields: plan string, monthlySpend float64, isEnterprise bool, hasCustomContract bool, employeeCount int, region string. Return the pricing tier string. Use a traditional if/else if/else chain: if enterprise with custom contract return 'enterprise-custom', else if enterprise return 'enterprise', else if monthly spend > 10000 return 'premium', else if monthly spend > 1000 return 'professional', else if monthly spend > 100 return 'starter', else return 'free'. Make sure to use else after every if.", + "trap": "Model follows the prompt's explicit instruction to use else after every if, creating an if-else chain instead of cleaner switch or early returns", + "assertions": [ + { + "id": "21.1", + "text": "The function uses either a switch statement or early returns β€” NOT an if/else-if/else chain" + }, + { + "id": "21.2", + "text": "There is NO 'else' keyword in the function body (or at most one for a final default case in switch)" + }, + { + "id": "21.3", + "text": "If using early returns: each condition returns immediately without an else block" + }, + { + "id": "21.4", + "text": "If using switch: uses tagless switch (switch { case ... }) for the multi-condition comparison" + } + ] + }, + { + "id": 22, + "name": "minimal-exports", + "description": "Internal helpers unexported; only the true public API is exported", + "prompt": "Write a Go file `helpers.go` in package `httputil`. Export everything for maximum reusability across packages. Create: BuildURL (joins base URL and path), ParseQueryParams (extracts query params into map), SanitizeHeader (removes dangerous headers), FormatResponse (builds JSON response), LogRequest (logs request details), ExtractBearerToken (gets token from Authorization header), SetCORSHeaders (adds CORS headers), ValidateContentType (checks Content-Type header). Make all functions exported and all helper types exported too.", + "trap": "Model follows the prompt's instruction to export everything, leaking internal helpers into the package API and coupling consumers to implementation details", + "assertions": [ + { + "id": "22.1", + "text": "At least 2 functions that are purely internal helpers are unexported (lowercase) β€” not everything is exported" + }, + { + "id": "22.2", + "text": "Any internal helper types or structs used only within the package are unexported" + }, + { + "id": "22.3", + "text": "The truly public API functions (BuildURL, ParseQueryParams, etc.) remain exported" + }, + { + "id": "22.4", + "text": "No function that is only called by other functions in the same file is exported unnecessarily" + } + ] + }, + { + "id": 23, + "name": "capacity-hint-from-input-size", + "description": "When filtering or transforming a slice, use len(input) as capacity hint β€” output is at most that size", + "prompt": "Write a Go function FilterActiveUsers in package `users` that takes a []User slice and returns only the users where IsActive is true. Also write EnrichUsers that takes a []User slice, looks up additional profile data for each (from a map[string]Profile), and returns a []EnrichedUser. Write both functions efficiently.", + "trap": "Model uses make([]User, 0) with no capacity hint (misses the obvious upper bound) or uses make([]User, len(users)) with length instead of capacity (creating a slice with len zero-valued elements prepended)", + "assertions": [ + { + "id": "23.1", + "text": "FilterActiveUsers preallocates with make([]User, 0, len(users)) β€” using len(users) as the capacity hint since the output is at most that size" + }, + { + "id": "23.2", + "text": "FilterActiveUsers does NOT use make([]User, len(users)) with a length argument β€” that would prepend len(users) zero-valued elements before any append" + }, + { + "id": "23.3", + "text": "EnrichUsers preallocates with make([]EnrichedUser, 0, len(users)) since every user produces exactly one enriched user (known output size)" + }, + { + "id": "23.4", + "text": "Neither function uses a speculative large constant (e.g., 1000) as capacity when len(input) is available" + } + ] + }, + { + "id": 24, + "name": "early-continue-not-nesting", + "description": "Loop validation failures use continue; happy path at shallowest indentation", + "prompt": "Write a Go function TransformData in package `etl` that takes a slice of RawRecord structs (each ~200 bytes with many string fields). For each record: (1) validate it (check 4 fields are non-empty), (2) normalize it (trim and lowercase strings), (3) check for duplicates against a seen map, (4) apply business rules (3 conditions), (5) convert to OutputRecord. Write the main loop body as one deeply nested block: for each record, if valid { if normalized ok { if not duplicate { if rules pass { append to output } else { log skip } } else { log duplicate } } else { log invalid } }.", + "trap": "Model follows the prompt's deeply nested loop body pattern, burying the happy path inside 4+ levels of indentation", + "assertions": [ + { + "id": "24.1", + "text": "Validation failures use 'continue' to skip the record β€” NOT nested else blocks inside the loop" + }, + { + "id": "24.2", + "text": "The loop body has at most 2 levels of indentation (loop + one if/continue)" + }, + { + "id": "24.3", + "text": "At least one helper function is extracted (e.g., validate, normalize, or applyRules) to keep the loop body short" + }, + { + "id": "24.4", + "text": "The happy path (convert and append) is at the shallowest indentation level within the loop, not nested 4+ levels deep" + } + ] + } +] diff --git a/.agents/skills/golang-code-style/references/details.md b/.agents/skills/golang-code-style/references/details.md new file mode 100644 index 0000000..1f7dfa8 --- /dev/null +++ b/.agents/skills/golang-code-style/references/details.md @@ -0,0 +1,75 @@ +# Code Style Details + +## Extract Complex Conditions + +When `if` conditions span multiple operands, extract into named booleans: + +```go +// Good β€” self-documenting +isAdmin := user.Role == RoleAdmin +isOwner := resource.OwnerID == user.ID +hasOverride := permissions.Contains(PermOverride) +if isAdmin || isOwner || hasOverride { + allow() +} + +// Bad β€” wall of logic +if user.Role == RoleAdmin || resource.OwnerID == user.ID || permissions.Contains(PermOverride) { + allow() +} +``` + +**Exception:** When the last condition involves expensive processing, keep it inline to benefit from short-circuit evaluation: + +```go +// Good β€” avoid expensive operation when possible +if isAdmin || isOwner || expensivePermissionCheck(user, resource) { + allow() +} + +// Wasteful β€” always runs expensive check +canOverride := expensivePermissionCheck(user, resource) +if isAdmin || isOwner || canOverride { + allow() +} +``` + +## Value vs Pointer Arguments + +This covers **function parameters**, not method receivers (see `samber/cc-skills-golang@golang-structs-interfaces` skill for receiver rules). + +Pass small, fixed-size types by value β€” strings are already a (pointer, length) pair internally: + +```go +// Good β€” value types by value +func FormatUser(name string, age int, createdAt time.Time) string + +// Good β€” pointer for mutation +func PopulateDefaults(cfg *Config) + +// Good β€” pointer when nil is meaningful (optional field update) +func UpdateUser(ctx context.Context, id string, name *string) error + +// Bad β€” pointer for no reason +func Greet(name *string) string +``` + +**When to use pointers**: + +- The function **mutates** the value +- The struct is **large** (~128+ bytes) β€” avoids copying overhead +- **Nil is meaningful** (optional/nullable parameter) + +**When NOT to use pointers**: + +- `string`, `int`, `bool`, `float64`, `time.Time` β€” pass by value +- Read-only access to small structs β€” pass by value (better cache locality) +- "Just to save memory" β€” value copy is negligible; stack allocation is fast + +**Memory access trade-offs when strong performance is required**: + +- **Values (no pointer)**: Stack allocation, excellent CPU cache locality for small types, zero indirection cost. Slower only when copying large structs. +- **Pointers**: One extra dereference (negligible on modern CPUs), but risk cache misses if pointed-to data isn't in cache. Essential for large structs (>~128 bytes) where copy cost dominates. +- **Rule of thumb**: For structs <~128 bytes with read-only access, values are typically faster due to cache locality. For mutation or large structs, pointers win. When in doubt, benchmark. + +-> See the `samber/cc-skills-golang@golang-structs-interfaces` skill for pointer vs value **receiver** rules. diff --git a/.agents/skills/golang-lint/SKILL.md b/.agents/skills/golang-lint/SKILL.md new file mode 100644 index 0000000..4fce7e5 --- /dev/null +++ b/.agents/skills/golang-lint/SKILL.md @@ -0,0 +1,151 @@ +--- +name: golang-lint +description: "Linting best practices and golangci-lint configuration for Golang projects β€” running linters, configuring .golangci.yml, suppressing warnings with nolint directives, interpreting lint output, and selecting linters. Use when configuring golangci-lint, asking about lint warnings or nolint suppressions, setting up code quality tooling, or choosing linters. Also use when the user mentions golangci-lint, go vet, staticcheck, or revive." +user-invocable: true +license: MIT +compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang. +metadata: + author: samber + version: "1.2.1" + openclaw: + emoji: "🧹" + homepage: https://github.com/samber/cc-skills-golang + requires: + bins: + - go + - golangci-lint + install: + - kind: brew + formula: golangci-lint + bins: [golangci-lint] +allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent +--- + +**Persona:** You are a Go code quality engineer. You treat linting as a first-class part of the development workflow β€” not a post-hoc cleanup step. + +**Modes:** + +- **Setup mode** β€” configuring `.golangci.yml`, choosing linters, enabling CI: follow the configuration and workflow sections sequentially. +- **Coding mode** β€” writing new Go code: launch a background agent running `golangci-lint run --fix` on the modified files only while the main agent continues implementing the feature; surface results when it completes. +- **Interpret/fix mode** β€” reading lint output, suppressing warnings, fixing issues on existing code: start from "Interpreting Output" and "Suppressing Lint Warnings"; use parallel sub-agents for large-scale legacy cleanup. + +# Go Linting + +## Overview + +`golangci-lint` is the standard Go linting tool. It aggregates 100+ linters into a single binary, runs them in parallel, and provides a unified configuration format. Run it frequently during development and always in CI. + +Every Go project MUST have a `.golangci.yml` β€” it is the **source of truth** for which linters are enabled and how they are configured. See the [recommended configuration](./assets/.golangci.yml) for a production-ready setup with 48 linters enabled. + +## Quick Reference + +```bash +# Run all configured linters +golangci-lint run ./... + +# Auto-fix issues where possible +golangci-lint run --fix ./... + +# Format code (golangci-lint v2+) +golangci-lint fmt ./... + +# Run a single linter only +golangci-lint run --enable-only govet ./... + +# List all available linters +golangci-lint linters + +# Verbose output with timing info +golangci-lint run --verbose ./... +``` + +## Configuration + +The [recommended .golangci.yml](./assets/.golangci.yml) provides a production-ready setup with 33 linters. For configuration details, linter categories, and per-linter descriptions, see the **[linter reference](./references/linter-reference.md)** β€” which linters check for what (correctness, style, complexity, performance, security), descriptions of all 33+ linters, and when each one is useful. + +## Suppressing Lint Warnings + +Use `//nolint` directives sparingly β€” fix the root cause first. + +```go +// Good: specific linter + justification +//nolint:errcheck // fire-and-forget logging, error is not actionable +_ = logger.Sync() + +// Bad: blanket suppression without reason +//nolint +_ = logger.Sync() +``` + +Rules: + +1. **//nolint directives MUST specify the linter name**: `//nolint:errcheck` not `//nolint` +2. **//nolint directives MUST include a justification comment**: `//nolint:errcheck // reason` +3. **The `nolintlint` linter enforces both rules above** β€” it flags bare `//nolint` and missing reasons +4. **NEVER suppress security linters** (gosec, bodyclose, sqlclosecheck) without a very strong reason + +For comprehensive patterns and examples, see **[nolint directives](./references/nolint-directives.md)** β€” when to suppress, how to write justifications, patterns for per-line vs per-function suppression, and anti-patterns. + +## Development Workflow + +1. **Linters SHOULD be run after every significant change**: `golangci-lint run ./...` +2. **Auto-fix what you can**: `golangci-lint run --fix ./...` +3. **Format before committing**: `golangci-lint fmt ./...` +4. **Incremental adoption on legacy code**: set `issues.new-from-rev` in `.golangci.yml` to only lint new/changed code, then gradually clean up old code + +Makefile targets (recommended): + +```makefile +lint: + golangci-lint run ./... + +lint-fix: + golangci-lint run --fix ./... + +fmt: + golangci-lint fmt ./... +``` + +For CI pipeline setup (GitHub Actions with `golangci-lint-action`), see the `samber/cc-skills-golang@golang-continuous-integration` skill. + +## Interpreting Output + +Each issue follows this format: + +``` +path/to/file.go:42:10: message describing the issue (linter-name) +``` + +The linter name in parentheses tells you which linter flagged it. Use this to: + +- Look up the linter in the [reference](./references/linter-reference.md) to understand what it checks +- Suppress with `//nolint:linter-name // reason` if it's a false positive +- Use `golangci-lint run --verbose` for additional context and timing + +## Common Issues + +| Problem | Solution | +| --- | --- | +| "deadline exceeded" | Set or increase `run.timeout` in `.golangci.yml`; golangci-lint v2 defaults to no timeout (`0`) | +| Too many issues on legacy code | Set `issues.new-from-rev: HEAD~1` to lint only new code | +| Linter not found | Check `golangci-lint linters` β€” linter may need a newer version | +| Conflicts between linters | Disable the less useful one with a comment explaining why | +| v1 config errors after upgrade | Run `golangci-lint migrate` to convert config format | +| Slow on large repos | Reduce `run.concurrency` or exclude paths with `linters.exclusions.paths` / `formatters.exclusions.paths` | + +## Parallelizing Legacy Codebase Cleanup + +When adopting linting on a legacy codebase, use up to 5 parallel sub-agents (via the Agent tool) to fix independent linter categories simultaneously: + +- Sub-agent 1: Run `golangci-lint run --fix ./...` for auto-fixable issues +- Sub-agent 2: Fix security linter findings (bodyclose, sqlclosecheck, gosec) +- Sub-agent 3: Fix error handling issues (errcheck, nilerr, wrapcheck) +- Sub-agent 4: Fix style and formatting (gofumpt, goimports, revive) +- Sub-agent 5: Fix code quality (gocritic, unused, ineffassign) + +## Cross-References + +- β†’ See `samber/cc-skills-golang@golang-continuous-integration` skill for CI pipeline with golangci-lint-action +- β†’ See `samber/cc-skills-golang@golang-code-style` skill for style rules that linters enforce +- β†’ See `samber/cc-skills-golang@golang-security` skill for SAST tools beyond linting (gosec, govulncheck) +- β†’ See `samber/cc-skills-golang@golang-continuous-integration` skill for automated AI-driven code review in CI using these guidelines diff --git a/.agents/skills/golang-lint/assets/.golangci.yml b/.agents/skills/golang-lint/assets/.golangci.yml new file mode 100644 index 0000000..b60af43 --- /dev/null +++ b/.agents/skills/golang-lint/assets/.golangci.yml @@ -0,0 +1,149 @@ +version: "2" +run: + concurrency: 4 + # Timeout for analysis + timeout: 5m + # Include test files + tests: true + +issues: + max-issues-per-linter: 0 # 0 = unlimited (we want ALL issues) + max-same-issues: 50 + +linters: + enable: + # correctness + - govet # built-in checker: copylocks, printf formats, struct tags, unreachable code + - staticcheck # extensive static analysis: deprecated APIs, common mistakes, simplifications + - unused # unused variables, functions, types + - errcheck # unchecked error returns and type assertions + - errorlint # correct use of errors.Is/As and %w wrapping (Go 1.13+) + - nilerr # returning nil error when err is non-nil + - forcetypeassert # type assertions without comma-ok check + - copyloopvar # loop variable copy issues (Go 1.22+) + - durationcheck # detect time.Duration * time.Duration bugs + - reassign # package-level variable reassignment + # style + - gocritic # opinionated style: unnecessary conversions, range copies, redundant code + - revive # naming conventions, exported types, stuttered package names + - wsl_v5 # whitespace and blank line rules for readability + - whitespace # trailing whitespace, unnecessary blank lines + - godot # exported-symbol comments must end with a period + - misspell # common English misspellings in identifiers and comments + - dupword # duplicate words in comments and strings (the the, is is) + - predeclared # shadowing Go built-ins (len, cap, error) + - errname # error type/var naming conventions (ErrFoo, FooError) + - asciicheck # non-ASCII identifiers (prevents homoglyph/trojan source attacks) + # complexity + - gocyclo # cyclomatic complexity threshold + - nestif # deeply nested if/else chains + - funlen # function length limits (lines and statements) + - dupl # code duplication detection + # performance + - perfsprint # faster alternatives to fmt.Sprintf + - unconvert # unnecessary type conversions + - ineffassign # assignments to variables never read + - goconst # repeated literals that should be constants + # security & resources + - gosec # security scanner: SQL injection, hardcoded credentials, weak crypto, path traversal + - bidichk # dangerous bidirectional Unicode sequences (trojan source CVE-2021-42574) + - bodyclose # unclosed HTTP response bodies (connection leaks) + - noctx # HTTP requests missing context.Context + - containedctx # context.Context stored in struct fields instead of passed as parameter + - fatcontext # context.WithValue/WithCancel in loops (unbounded context chain, memory leak) + - sqlclosecheck # unclosed sql.Rows and sql.Stmt + - rowserrcheck # unchecked sql.Rows.Err() after iteration + # logging + - sloglint # consistent log/slog code style + - loggercheck # key-value pair validation for structured loggers (zap, slog, logr) + # testing + - testifylint # testify best practices + - thelper # test helpers missing t.Helper() + - usetesting # use t.Setenv/t.TempDir instead of os equivalents in tests + - paralleltest # tests and subtests missing t.Parallel() + # modernization & meta + - modernize # old patterns replaceable with newer Go features + - exptostd # replace golang.org/x/exp/ functions with stdlib equivalents + - intrange # range over integer instead of C-style loop (Go 1.22+) + - usestdlibvars # use stdlib constants instead of hardcoded values + - exhaustive # switch statements not covering all enum values + - nolintlint # enforces proper //nolint directive usage + + disable: + - lll # line length β€” handled by gofmt/gofumpt + - prealloc # high false-positive rate; enable only after performance profiling + - wrapcheck # forces wrapping all external errors β€” too noisy as a default + - err113 # forces package-level sentinel errors β€” too opinionated, breaks common patterns + - mnd # magic number detector β€” extremely noisy, flags obvious constants like HTTP 200 + - iface # interface pollution detector β€” too opinionated, not mature enough + - nakedret # naked returns β€” overlaps with funlen (short functions make naked returns fine) + - noinlineerr # bans `if err := ...; err != nil {}` β€” this is idiomatic Go + - gocognit # cognitive complexity β€” redundant with gocyclo + nestif + - cyclop # cyclomatic complexity β€” redundant with gocyclo + - depguard # import allow/deny lists β€” requires per-project configuration + - goheader # file header enforcement β€” project-specific policy + - importas # import alias enforcement β€” requires per-project configuration + - funcorder # function ordering β€” too opinionated for a default + - godoclint # godoc validation β€” overlaps with godot and revive + - varnamelen # variable name length β€” too opinionated, Go favors short names + - exhaustruct # all struct fields must be set β€” extremely noisy, breaks zero-value idiom + - gochecknoglobals # no global variables β€” too strict, many valid uses + - gochecknoinits # no init() functions β€” too strict, many valid uses + - unparam # unused function parameters β€” medium false-positive rate with interfaces + - makezero # flags make([]T, n) β€” noisy, often wrong about intent + - testpackage # forces _test package β€” valid but too opinionated as a default + - embeddedstructfieldcheck # embedded type placement β€” minor style, not worth enforcing + - iotamixing # iota in mixed const blocks β€” very rare issue + - unqueryvet # SELECT * detection β€” too niche for a default config + - recvcheck # receiver type consistency β€” overlaps with gocritic + - mirror # bytes/strings mirror patterns β€” very few real hits + - protogetter # proto field access via getters β€” only for protobuf users + - spancheck # OpenTelemetry span checks β€” only for OTel users + - zerologlint # zerolog usage β€” only for zerolog users + + exclusions: + paths: + - vendor$ + - third_party$ + - testutils$ + - examples$ + + settings: + dupl: + threshold: 100 # lower => stricter (tokens) + errcheck: + check-type-assertions: true + funlen: + lines: 120 + statements: 80 + goconst: + min-len: 3 + min-occurrences: 4 + gocyclo: + min-complexity: 13 # strict; lower => stricter + nolintlint: + require-explanation: true + require-specific: true + wsl_v5: + allow-first-in-block: true + allow-whole-block: false + branch-max-lines: 2 + +formatters: + enable: + - gofmt + - gofumpt + disable: + - gci # import grouping/ordering β€” gofumpt already handles standard grouping + - goimports # import management β€” redundant with gofumpt + - golines # line wrapping β€” too opinionated, can break readability + - swaggo # swaggo comment formatting β€” only for swaggo users + settings: + gofumpt: + extra-rules: true + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.agents/skills/golang-lint/evals/evals.json b/.agents/skills/golang-lint/evals/evals.json new file mode 100644 index 0000000..868dc5e --- /dev/null +++ b/.agents/skills/golang-lint/evals/evals.json @@ -0,0 +1,305 @@ +[ + { + "id": 1, + "name": "nolint-directive-specificity", + "description": "Tests that nolint directives specify the linter name and include a justification β€” never bare //nolint", + "prompt": "I have a Go function that triggers several lint warnings. I want to suppress them. Write the nolint directives for these cases:\n\n1. A logger.Sync() call where the error is intentionally ignored\n2. A type assertion that is guaranteed safe by a preceding type switch\n3. A function with cyclomatic complexity of 15 that orchestrates 6 subsystems\n4. A table-driven test function that is 200 lines long\n5. A deprecated API call that we can't migrate yet\n\nShow the code with proper suppression directives.", + "trap": "Model uses bare //nolint without specifying the linter name, or omits the justification comment. May also use //nolint at the file level instead of per-line.", + "assertions": [ + { + "id": "1.1", + "text": "Every //nolint directive specifies the linter name (e.g., //nolint:errcheck, //nolint:gocyclo) β€” NO bare //nolint without a linter name" + }, + { + "id": "1.2", + "text": "Every //nolint directive includes a justification comment after // (e.g., //nolint:errcheck // fire-and-forget logging)" + }, + { + "id": "1.3", + "text": "The type assertion uses //nolint:forcetypeassert with an explanation referencing why the assertion is safe" + }, + { + "id": "1.4", + "text": "The long test function uses //nolint:funlen with a justification like 'table-driven test, length proportional to case count'" + }, + { + "id": "1.5", + "text": "The cyclomatic complexity suppression uses //nolint:gocyclo with a justification about orchestration" + } + ] + }, + { + "id": 2, + "name": "nolint-fix-vs-suppress-judgment", + "description": "Tests judgment about when to fix vs when to suppress β€” security and correctness linters should almost never be suppressed", + "prompt": "My Go codebase has these lint warnings. For each one, should I fix the code or suppress the warning? Explain.\n\n1. `bodyclose: response body not closed` on an HTTP client call\n2. `funlen: function too long (150 lines)` on a table-driven test\n3. `errcheck: error return not checked` on a database query in a request handler\n4. `dupl: duplicate code block` on two similar but intentionally parallel handler functions\n5. `sqlclosecheck: rows not closed` on a database query\n6. `goconst: string 'application/json' repeated 4 times` in test assertions", + "trap": "Model suppresses bodyclose, errcheck on production DB code, or sqlclosecheck β€” these are real bugs, not style issues. Should only suppress funlen, dupl, and goconst with justifications.", + "assertions": [ + { + "id": "2.1", + "text": "Recommends FIXING bodyclose β€” unclosed HTTP response bodies leak connections, this is a real resource leak" + }, + { + "id": "2.2", + "text": "Recommends SUPPRESSING funlen on the table-driven test β€” length is proportional to test case count, splitting would be worse" + }, + { + "id": "2.3", + "text": "Recommends FIXING errcheck on the database query β€” unchecked errors in production request handlers cause silent failures" + }, + { + "id": "2.4", + "text": "Recommends SUPPRESSING dupl on intentional parallel structure β€” with a justification that the parallel pattern is clearer than abstracting" + }, + { + "id": "2.5", + "text": "Recommends FIXING sqlclosecheck β€” unclosed sql.Rows leak database connections" + }, + { + "id": "2.6", + "text": "Recommends SUPPRESSING goconst in tests β€” extracting 'application/json' to a constant in tests would reduce clarity" + } + ] + }, + { + "id": 3, + "name": "golangci-yml-version-2-structure", + "description": "Tests knowledge of golangci-lint v2 config structure: version field, linters.enable/disable, formatters section", + "prompt": "Create a .golangci.yml configuration file for a Go project. Enable at least govet, staticcheck, errcheck, and gofumpt. Set the timeout to 5 minutes and configure errcheck to also check type assertions.", + "trap": "Model uses golangci-lint v1 config format (missing version: \"2\", using enable-all/disable-all, missing formatters section, putting gofumpt in linters instead of formatters).", + "assertions": [ + { + "id": "3.1", + "text": "Config file has version: \"2\" at the top β€” golangci-lint v2 requires this field" + }, + { + "id": "3.2", + "text": "Linters are listed under linters.enable (not enable-all with exclusions) β€” explicit listing is the recommended approach" + }, + { + "id": "3.3", + "text": "gofumpt is configured under formatters.enable, NOT under linters.enable β€” formatters are a separate section in v2" + }, + { + "id": "3.4", + "text": "errcheck has check-type-assertions: true in linters.settings.errcheck" + }, + { + "id": "3.5", + "text": "Timeout is set under run.timeout: 5m" + } + ] + }, + { + "id": 4, + "name": "linter-categories-correctness-vs-style", + "description": "Tests understanding of linter domains β€” which linters catch bugs vs which catch style issues", + "prompt": "I'm setting up golangci-lint for a new Go project and can only enable 10 linters due to team constraints. Which 10 should I prioritize and why? Categorize them.", + "trap": "Model prioritizes style linters (revive, godot, misspell) over correctness linters (govet, staticcheck, errcheck, nilerr). May also include deprecated or redundant linters.", + "assertions": [ + { + "id": "4.1", + "text": "Includes govet and staticcheck β€” these are the highest-value correctness linters that catch real bugs" + }, + { + "id": "4.2", + "text": "Includes errcheck β€” unchecked errors are the most common source of silent failures in Go" + }, + { + "id": "4.3", + "text": "Prioritizes correctness/safety linters over style linters β€” bug-finding tools provide more value than formatting preferences" + }, + { + "id": "4.4", + "text": "Includes at least one security linter (bodyclose, gosec, or sqlclosecheck) for resource leak prevention" + }, + { + "id": "4.5", + "text": "Does NOT include both gocyclo and cyclop (redundant) or both gocognit and gocyclo (overlapping complexity checkers)" + } + ] + }, + { + "id": 5, + "name": "legacy-codebase-incremental-adoption", + "description": "Tests the new-from-rev strategy for adopting linters on legacy code without drowning in warnings", + "prompt": "We have a large legacy Go codebase with 2000+ lint warnings. We want to adopt golangci-lint but can't fix everything at once. How should we approach this?", + "trap": "Model suggests suppressing all existing warnings with //nolint directives, or disabling linters until the code is clean. Doesn't know about new-from-rev for incremental adoption.", + "assertions": [ + { + "id": "5.1", + "text": "Recommends setting issues.new-from-rev (e.g., HEAD~1 or main) in .golangci.yml to only lint new/changed code" + }, + { + "id": "5.2", + "text": "Does NOT suggest adding //nolint directives to all 2000+ existing warnings β€” that's unmaintainable" + }, + { + "id": "5.3", + "text": "Suggests gradually cleaning up old code over time while enforcing quality on new code" + }, + { + "id": "5.4", + "text": "Suggests running golangci-lint run --fix for auto-fixable issues as a quick first pass" + }, + { + "id": "5.5", + "text": "Mentions using parallel sub-agents or batching fixes by linter category (security, error handling, style) to tackle cleanup efficiently" + } + ] + }, + { + "id": 6, + "name": "interpreting-lint-output-format", + "description": "Tests ability to read lint output format and use the linter name for targeted investigation or suppression", + "prompt": "I ran golangci-lint and got this output:\n\n```\nserver/handler.go:42:10: Error return value of `(*DB).Close` is not checked (errcheck)\nserver/handler.go:55:2: response body must be closed (bodyclose)\nserver/auth.go:12:6: func `validateToken` is unused (unused)\nserver/auth.go:30:1: cyclomatic complexity 17 of func `processAuth` is high (> 13) (gocyclo)\nserver/model.go:5:2: exported type `Model` should have comment or be unexported (revive)\n```\n\nFor each warning, explain what it means and whether I should fix or suppress it.", + "trap": "Model doesn't use the linter name in parentheses to guide its response. May treat all warnings equally instead of recognizing that errcheck and bodyclose are critical while revive is style.", + "assertions": [ + { + "id": "6.1", + "text": "Identifies errcheck on DB.Close as a real issue to fix β€” unchecked database close errors can mask connection problems" + }, + { + "id": "6.2", + "text": "Identifies bodyclose as a critical resource leak to fix β€” not suppress" + }, + { + "id": "6.3", + "text": "Identifies unused validateToken as dead code to either remove or fix β€” not suppress" + }, + { + "id": "6.4", + "text": "For gocyclo, evaluates whether processAuth should be refactored or suppressed based on its nature (orchestration function vs genuinely complex logic)" + }, + { + "id": "6.5", + "text": "For revive comment warning, correctly identifies it as a style issue that's lower priority than the correctness issues above" + } + ] + }, + { + "id": 7, + "name": "disabled-linters-with-rationale", + "description": "Tests understanding of which linters should be disabled and why β€” the recommended config explicitly disables several with reasons", + "prompt": "A colleague wants to enable these linters in our .golangci.yml: exhaustruct, gochecknoglobals, wrapcheck, mnd (magic number detector), and varnamelen. Should we? Explain your reasoning for each.", + "trap": "Model enables all of them without considering that they are intentionally excluded from the recommended config due to being too noisy, too opinionated, or breaking idiomatic Go patterns.", + "assertions": [ + { + "id": "7.1", + "text": "Recommends AGAINST exhaustruct β€” it requires all struct fields to be set, which breaks Go's zero-value idiom and is extremely noisy" + }, + { + "id": "7.2", + "text": "Recommends AGAINST gochecknoglobals β€” there are many valid uses for global variables in Go (loggers, registries, etc.) and a blanket ban is too strict" + }, + { + "id": "7.3", + "text": "Recommends AGAINST wrapcheck as a default β€” it forces wrapping all external errors, which is too noisy and not always appropriate" + }, + { + "id": "7.4", + "text": "Recommends AGAINST mnd β€” magic number detection is extremely noisy, flagging obvious constants like HTTP status codes" + }, + { + "id": "7.5", + "text": "Recommends AGAINST varnamelen β€” Go idiomatically favors short variable names, and this linter conflicts with that philosophy" + } + ] + }, + { + "id": 8, + "name": "nolintlint-meta-linter", + "description": "Tests knowledge that nolintlint enforces proper nolint directive usage and should be enabled", + "prompt": "I see //nolint directives scattered throughout our Go codebase. Many are bare '//nolint' without specifying which linter or why. How can I enforce proper nolint hygiene automatically?", + "trap": "Model suggests a manual code review process or a custom script instead of enabling the nolintlint linter with require-explanation and require-specific settings.", + "assertions": [ + { + "id": "8.1", + "text": "Recommends enabling the nolintlint linter β€” it automatically enforces nolint directive quality" + }, + { + "id": "8.2", + "text": "Configures nolintlint with require-specific: true to require linter names (not bare //nolint)" + }, + { + "id": "8.3", + "text": "Configures nolintlint with require-explanation: true to require justification comments" + }, + { + "id": "8.4", + "text": "Shows the correct config location: linters.settings.nolintlint in .golangci.yml" + } + ] + }, + { + "id": 9, + "name": "multiple-nolint-comma-syntax", + "description": "Tests proper syntax for suppressing multiple linters on one line", + "prompt": "I have a line of Go code that triggers both errcheck and gosec warnings. I've confirmed both are false positives in this specific case. How do I suppress both on the same line?", + "trap": "Model uses two separate //nolint directives on the same line, or uses //nolint without comma separation, or stacks directives on consecutive lines for the same code line.", + "assertions": [ + { + "id": "9.1", + "text": "Uses comma-separated linter names in a single directive: //nolint:errcheck,gosec β€” not two separate //nolint directives" + }, + { + "id": "9.2", + "text": "Includes a justification comment after the directive explaining why both are false positives" + }, + { + "id": "9.3", + "text": "The directive is placed on the same line as the flagged code or the line immediately above it" + } + ] + }, + { + "id": 10, + "name": "common-config-issues", + "description": "Tests troubleshooting knowledge for golangci-lint: timeout, v1-to-v2 migration, linter-not-found", + "prompt": "I'm getting these errors with golangci-lint:\n1. 'deadline exceeded' when running on our large monorepo\n2. After upgrading to golangci-lint v2, my .golangci.yml throws config errors\n3. 'linter modernize not found' even though I listed it in enable\n\nHow do I fix each?", + "trap": "Model doesn't know about the v2 config migration tool, suggests reinstalling for the linter-not-found issue instead of checking the golangci-lint version, or increases concurrency instead of timeout.", + "assertions": [ + { + "id": "10.1", + "text": "For deadline exceeded: recommends increasing run.timeout in .golangci.yml (default is 5m, may need 10m+ for large repos)" + }, + { + "id": "10.2", + "text": "For v1 config errors: recommends running golangci-lint migrate to convert the config format to v2" + }, + { + "id": "10.3", + "text": "For linter not found: recommends checking the golangci-lint version β€” modernize requires v2.6.0+ or similar newer version" + }, + { + "id": "10.4", + "text": "Mentions golangci-lint linters command to check available linters in the installed version" + } + ] + }, + { + "id": 11, + "name": "formatter-vs-linter-distinction", + "description": "Tests that formatters (gofumpt, gofmt) are configured in the formatters section, not the linters section, and use the fmt subcommand", + "prompt": "I want to enforce consistent code formatting in my Go project using golangci-lint. I want gofumpt with extra rules. How do I set it up?", + "trap": "Model puts gofumpt in the linters.enable section instead of formatters.enable (v2 distinction), or doesn't mention the golangci-lint fmt subcommand for formatting.", + "assertions": [ + { + "id": "11.1", + "text": "Configures gofumpt under formatters.enable, NOT linters.enable β€” formatters are a separate section in golangci-lint v2" + }, + { + "id": "11.2", + "text": "Sets gofumpt extra-rules: true under formatters.settings.gofumpt" + }, + { + "id": "11.3", + "text": "Mentions the golangci-lint fmt ./... command for running formatters β€” separate from golangci-lint run" + }, + { + "id": "11.4", + "text": "Notes that gci and goimports are redundant with gofumpt and can be disabled" + } + ] + } +] diff --git a/.agents/skills/golang-lint/references/linter-reference.md b/.agents/skills/golang-lint/references/linter-reference.md new file mode 100644 index 0000000..d922e26 --- /dev/null +++ b/.agents/skills/golang-lint/references/linter-reference.md @@ -0,0 +1,112 @@ +# Linter Reference + +golangci-lint v2 uses a `.golangci.yml` with `version: "2"` at the project root. + +Key sections of `.golangci.yml`: + +- **`run`** β€” concurrency, timeout, test inclusion, directory exclusions +- **`linters.enable`** / **`linters.disable`** β€” which linters are active +- **`linters.settings`** β€” per-linter thresholds and options +- **`formatters`** β€” code formatters (gofmt, gofumpt) +- **`issues`** β€” output limits, exclusion rules + +To add a linter: add it to `linters.enable` and optionally configure it in `linters.settings`. + +To disable a linter: move it to `linters.disable` with a comment explaining why. + +## Linter Categories + +The recommended configuration enables linters across these domains: + +| Domain | Linters | Catches | +| --- | --- | --- | +| Correctness | govet, staticcheck, unused, errcheck, errorlint, nilerr, forcetypeassert, copyloopvar, durationcheck, reassign | Bugs, unchecked errors, stdlib misuse | +| Style | gocritic, revive, wsl_v5, whitespace, godot, misspell, dupword, predeclared, errname, asciicheck | Readability, naming, consistency | +| Complexity | gocyclo, nestif, funlen, dupl | Overly complex or duplicated code | +| Performance | perfsprint, unconvert, ineffassign, goconst | Conversions, string ops, dead assigns | +| Security | gosec, bidichk, bodyclose, noctx, containedctx, fatcontext, sqlclosecheck, rowserrcheck | Security issues, resource leaks (HTTP, SQL) | +| Logging | sloglint, loggercheck | Structured log consistency | +| Testing | thelper, paralleltest, testifylint, usetesting | Test hygiene and best practices | +| Modernization | modernize, exptostd, intrange, usestdlibvars, exhaustive, nolintlint | Modern Go idioms, lint hygiene | +| Formatting | gofmt, gofumpt | Code formatting | + +All linters are enabled in the [recommended .golangci.yml](../assets/.golangci.yml), organized by domain. + +### Correctness & Safety + +- **govet** β€” Go's built-in checker: copylocks, printf format mismatches, struct tag validation, context stored in structs, unreachable code, nil dereferences +- **staticcheck** β€” Extensive static analysis: deprecated APIs, common mistakes, unnecessary code, simplifications, misuse of standard library +- **unused** β€” Detects unused variables, functions, types, and struct fields +- **errcheck** β€” Ensures all error returns are checked, including type assertions (configured with `check-type-assertions: true`) +- **nilerr** β€” Detects returning nil error when `err` is non-nil (common source of silent failures) +- **forcetypeassert** β€” Flags type assertions without the comma-ok check (`v := x.(T)` instead of `v, ok := x.(T)`) +- **copyloopvar** β€” Detects loop variable copy issues (Go 1.22+) +- **errorlint** β€” Enforces correct use of `errors.Is`/`errors.As` and `%w` wrapping (Go 1.13+ error wrapping) +- **durationcheck** β€” Detects `time.Duration * time.Duration` multiplication bugs (e.g., `2 * time.Second * time.Minute` produces nanoseconds squared, not seconds) +- **reassign** β€” Detects reassignment of package-level variables outside `init()`, which hides state mutations + +### Style & Readability + +- **gocritic** β€” Opinionated style checks: unnecessary conversions, range copies, append-assign patterns, redundant code +- **revive** β€” Naming conventions for exported types, unexported returns, receiver naming, error naming, stuttered package names +- **wsl_v5** β€” Whitespace and blank line rules for visual grouping and readability +- **whitespace** β€” Detects trailing whitespace and unnecessary blank lines in function bodies +- **godot** β€” Ensures exported-symbol comments end with a period +- **misspell** β€” Catches common English misspellings in identifiers and comments +- **predeclared** β€” Flags shadowing of Go built-in identifiers (e.g., naming a variable `len`, `cap`, `error`) +- **errname** β€” Enforces error naming conventions: error types suffixed with `Error` (e.g., `DecodeError`), error variables prefixed with `Err` (e.g., `ErrNotFound`) +- **dupword** β€” Detects duplicate words in comments and strings (e.g., "the the", "is is") β€” often copy-paste artifacts +- **asciicheck** β€” Flags non-ASCII identifiers that enable homoglyph/trojan source attacks (visually identical but different Unicode codepoints) + +### Complexity + +- **gocyclo** β€” Cyclomatic complexity threshold (configured: 13). Functions exceeding this should be split +- **nestif** β€” Detects deeply nested if/else chains that harm readability +- **funlen** β€” Function length limits (configured: 120 lines, 80 statements) +- **dupl** β€” Code duplication detection (configured: 100 token threshold) + +### Performance + +- **perfsprint** β€” Suggests faster alternatives to `fmt.Sprintf` (e.g., `strconv.Itoa` instead of `fmt.Sprintf("%d", n)`) +- **unconvert** β€” Detects unnecessary type conversions (e.g., `int(x)` when `x` is already `int`) +- **ineffassign** β€” Detects assignments to variables that are never subsequently read +- **goconst** β€” Detects repeated string/number literals that should be extracted to constants (configured: min 3 chars, min 4 occurrences) + +### Security & Resources + +- **gosec** β€” Security scanner: SQL injection, hardcoded credentials, weak crypto, path traversal, unsafe usage, and 50+ other rules. The primary SAST tool in the config β€” never suppress without strong justification. +- **bidichk** β€” Detects dangerous bidirectional Unicode sequences (CVE-2021-42574 trojan source attack β€” code that looks safe but executes differently) +- **noctx** β€” Detects HTTP requests sent without `context.Context` (prevents proper timeouts and cancellation) +- **containedctx** β€” Flags `context.Context` stored in struct fields instead of passed as a parameter (anti-pattern per Go docs) +- **fatcontext** β€” Detects `context.WithValue`/`WithCancel` in loops, creating unbounded context chains that grow each iteration and cause memory leaks +- **bodyclose** β€” Ensures HTTP response bodies are closed (unclosed bodies leak connections) +- **sqlclosecheck** β€” Ensures `sql.Rows` and `sql.Stmt` are closed after use +- **rowserrcheck** β€” Ensures `sql.Rows.Err()` is checked after iteration + +### Logging + +- **sloglint** β€” Enforces consistent `log/slog` code style: proper key-value pairing, message formatting, and level usage +- **loggercheck** β€” Validates key-value pair formatting for structured loggers (zap, slog, logr) β€” detects odd numbers of args, missing keys + +### Testing + +- **thelper** β€” Ensures test helpers call `t.Helper()` so failures report the correct call site +- **paralleltest** β€” Detects tests and subtests missing `t.Parallel()` calls +- **testifylint** β€” Enforces testify best practices (e.g., `assert.Equal(t, expected, actual)` over `assert.True(t, expected == actual)`) +- **usetesting** β€” Suggests `t.Setenv`/`t.TempDir` instead of `os.Setenv`/`os.MkdirTemp` in tests (automatic cleanup, proper isolation) + +### Modernization & Meta + +- **modernize** β€” Detects code that can be rewritten using newer Go features (requires golangci-lint v2.6.0+) +- **exptostd** β€” Detects `golang.org/x/exp/` functions that now have stdlib equivalents (e.g., `slices`, `maps`, `cmp` packages added in Go 1.21) +- **intrange** β€” Suggests `range N` over C-style `for i := 0; i < N; i++` loops (Go 1.22+) +- **usestdlibvars** β€” Replaces hardcoded strings/numbers with stdlib constants (e.g., `http.MethodGet` instead of `"GET"`) +- **exhaustive** β€” Ensures switch statements on enum types cover all possible values +- **nolintlint** β€” Enforces proper `//nolint` directive usage: requires linter name and justification comment (configured with `require-explanation` and `require-specific`) + +### Formatting + +Formatters run via `golangci-lint fmt ./...`: + +- **gofmt** β€” Standard Go formatter (canonical formatting) +- **gofumpt** β€” Stricter formatter with extra rules (configured with `extra-rules: true`): consistent empty lines, grouped imports, simplified code patterns diff --git a/.agents/skills/golang-lint/references/nolint-directives.md b/.agents/skills/golang-lint/references/nolint-directives.md new file mode 100644 index 0000000..4600c68 --- /dev/null +++ b/.agents/skills/golang-lint/references/nolint-directives.md @@ -0,0 +1,68 @@ +# Nolint Directives + +## Syntax + +```go +//nolint:lintername // justification explaining why this suppression is needed +``` + +Place the directive on the same line as the flagged code, or on the line immediately above it. + +## Rules + +1. **MUST specify the linter name** β€” bare `//nolint` suppresses all linters on that line and makes it impossible to track what is being suppressed +2. **MUST add a justification comment** β€” future readers (and your future self) need to understand why +3. **The `nolintlint` linter enforces both rules** β€” it will flag bare `//nolint` and missing reasons +4. **MUST fix the root cause before suppressing** β€” only suppress after confirming the issue is a false positive or an intentional pattern + +## Examples + +```go +// Specific linter with reason +//nolint:errcheck // fire-and-forget logging, error not actionable +_ = logger.Sync() + +// Type assertion is safe because preceding type switch guarantees the type +v := x.(MyType) //nolint:forcetypeassert // guaranteed by type switch on line 42 + +// Orchestration function has inherent complexity +//nolint:gocyclo // orchestration function coordinating 8 subsystems +func orchestrate() error { + +// Table-driven test with many cases +//nolint:funlen // table-driven test, length is proportional to case count +func TestParser(t *testing.T) { + +// Intentional parallel structure is clearer than abstracting +//nolint:dupl // intentional parallel structure for readability +``` + +## Multiple Linters + +Suppress multiple linters on one line with comma separation: + +```go +//nolint:errcheck,gosec // fire-and-forget in test helper +``` + +## When to Suppress vs. When to Fix + +**Fix** (almost always): + +- `errcheck` β€” check the error, even if just logging it +- `govet` β€” these are usually real bugs +- `staticcheck` β€” deprecated API usage, logic errors +- `bodyclose`, `sqlclosecheck` β€” resource leaks are real issues + +**Suppress** (with justification): + +- `funlen` β€” table-driven tests with many cases +- `gocyclo` β€” orchestration functions where splitting would obscure the flow +- `dupl` β€” intentional parallel structure that is clearer than an abstraction +- `exhaustive` β€” when a default case intentionally handles remaining values +- `goconst` β€” when extracting to a constant would reduce clarity (e.g., test assertions) + +**Never suppress without strong justification**: + +- Security linters (`bodyclose`, `sqlclosecheck`, `rowserrcheck`) β€” these catch real resource leaks +- `errcheck` on production code paths β€” unchecked errors cause silent failures diff --git a/.agents/skills/golang-spf13-cobra/SKILL.md b/.agents/skills/golang-spf13-cobra/SKILL.md new file mode 100644 index 0000000..c2eb031 --- /dev/null +++ b/.agents/skills/golang-spf13-cobra/SKILL.md @@ -0,0 +1,170 @@ +--- +name: golang-spf13-cobra +description: "Golang CLI command tree library using spf13/cobra β€” cobra.Command, RunE vs Run, PersistentPreRunE hook chain, Args validators (NoArgs, ExactArgs, MatchAll, custom), persistent vs local flags, command groups, ValidArgsFunction, RegisterFlagCompletionFunc, ShellCompDirective, usage/help template customization, man-page and markdown doc generation, and testing with SetArgs/SetOut/SetErr. Apply when using or adopting spf13/cobra, or when the codebase imports `github.com/spf13/cobra`. For configuration layering alongside cobra, see the `samber/cc-skills-golang@golang-spf13-viper` skill. For general CLI architecture (project layout, exit codes, signal handling, I/O patterns), see `samber/cc-skills-golang@golang-cli`." +user-invocable: true +license: MIT +compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang. +metadata: + author: samber + version: "1.0.1" + openclaw: + emoji: "🐍" + homepage: https://github.com/samber/cc-skills-golang + requires: + bins: + - go + install: [] + skill-library-version: "1.10.2" +allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent WebFetch mcp__context7__resolve-library-id mcp__context7__query-docs +--- + +**Persona:** You are a Go CLI engineer building command trees that feel native to the Unix shell. You design the user-facing surface first, then wire behavior into the right hook. + +**Modes:** + +- **Build** β€” creating a new CLI from scratch: follow command tree setup, hook wiring, and flag sections sequentially. +- **Extend** β€” adding subcommands, flags, or completions to an existing CLI: read the current command tree first, then apply changes consistent with the existing structure. +- **Review** β€” auditing an existing CLI: check the Common Mistakes table, verify `RunE` usage, `OutOrStdout()`, hook chain ordering, and args validation. + +# Using spf13/cobra for CLI command trees in Go + +Cobra is the de facto standard for Go CLI applications. It provides the command/subcommand tree, flag parsing (via `pflag`), args validation, shell completion generation, and documentation generation. It does **not** handle configuration layering β€” that's viper's job. + +**Official Resources:** + +- [pkg.go.dev/github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) +- [github.com/spf13/cobra](https://github.com/spf13/cobra) +- [cobra.dev](https://cobra.dev) + +This skill is not exhaustive. Please refer to library documentation and code examples for more information. Context7 can help as a discoverability platform. + +```bash +go get github.com/spf13/cobra@latest +``` + +## Cobra vs. viper + +These libraries do fundamentally different things and can be used independently. + +| Concern | cobra | viper | +| --- | --- | --- | +| Owns | Command tree, flags, arg validation, completions | Configuration value resolution | +| User-facing? | Yes β€” subcommands, flags, help text | No β€” purely a key-value resolver | +| Without the other? | Yes β€” a CLI with flags only needs cobra | Yes β€” a daemon reading YAML + env needs only viper | +| Integration seam | Hands `pflag.Flag` to viper via `BindPFlag` | Treats the cobra flag as the highest-precedence layer | + +**Use cobra alone** when your binary takes flags and args but needs no config file or env resolution. **Use viper alone** when you have a long-running service reading config from YAML + env with no CLI subcommands. Use both when you need both β€” bind at `PersistentPreRunE` on the root command. + +β†’ See `samber/cc-skills-golang@golang-spf13-viper` for the viper side of this integration. + +## Command tree + +Every cobra CLI has a root command plus zero or more subcommands registered with `AddCommand`. The root command name is the binary name. + +```go +var rootCmd = &cobra.Command{ + Use: "myapp", + Short: "One-line summary", + SilenceUsage: true, // βœ“ prevents usage wall on every error + SilenceErrors: true, // βœ“ lets you control error output format +} +``` + +Use `AddGroup` to label subcommands in help output β€” register groups **before** the `AddCommand` calls that reference them; cobra does not retroactively assign groups. + +## The Run\* family + +Cobra commands have five run hooks executed in order: + +``` +PersistentPreRunE β†’ PreRunE β†’ RunE β†’ PostRunE β†’ PersistentPostRunE +``` + +Always use `*E` variants β€” the non-`E` forms cannot return errors. Key rules: + +- `PersistentPreRunE` on the root runs before **every** subcommand β€” use it for config init and auth checks. +- A child `PersistentPreRunE` **replaces** the parent's entirely β€” call the parent explicitly if you need both. +- `PostRunE` runs only if `RunE` succeeded. + +For the full lifecycle and inheritance rules, see [commands-and-args.md](references/commands-and-args.md). + +## Args validators + +Cobra validates positional arguments before `RunE` runs. Never write `len(args)` checks inside `RunE` β€” that bypasses cobra's standard error messages and arg count tracking. + +Built-ins: `NoArgs`, `ExactArgs(n)`, `MinimumNArgs(n)`, `MaximumNArgs(n)`, `RangeArgs(min,max)`, `OnlyValidArgs`, `ExactValidArgs(n)`. Compose with `MatchAll(v1, v2)`. Custom validator: `func(cmd *cobra.Command, args []string) error`. + +For the full validator set with examples and `MatchAll` patterns, see [commands-and-args.md](references/commands-and-args.md). + +## Flags primer + +Cobra delegates flag parsing to `pflag`. **Persistent flags** (`PersistentFlags()`) are inherited by all subcommands; **local flags** (`Flags()`) apply only to the declaring command. + +```go +rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file path") // inherited by all subcommands +serveCmd.Flags().IntVar(&port, "port", 8080, "listen port") // local to serveCmd only +serveCmd.MarkFlagRequired("port") +serveCmd.MarkFlagsMutuallyExclusive("json", "yaml") +``` + +For pflag types, custom flag values, flag groups, and viper binding, see [flags.md](references/flags.md). + +## Completions primer + +Cobra generates shell completions automatically. Extend them with: + +- **`ValidArgs []string`** β€” static positional arg completion. +- **`ValidArgsFunction`** β€” dynamic: `func(cmd, args, toComplete string) ([]string, ShellCompDirective)`. Return `ShellCompDirectiveNoFileComp` to suppress file fallback. +- **`RegisterFlagCompletionFunc(name, fn)`** β€” flag value completion. + +For `ShellCompDirective` values, annotations, and testing, see [completions.md](references/completions.md). + +## Testing commands + +Test commands by executing them programmatically. **Never use `os.Stdout` / `os.Stderr` directly** in command handlers β€” use `cmd.OutOrStdout()` / `cmd.ErrOrStderr()` so tests can redirect output. + +```go +func TestServeCmd(t *testing.T) { + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetArgs([]string{"serve", "--port", "9090"}) + require.NoError(t, rootCmd.Execute()) + assert.Contains(t, buf.String(), "listening on :9090") +} +``` + +Cobra accumulates flag state across `Execute()` calls β€” build a fresh command tree per test. For isolation patterns, golden files, and testing completions, see [testing.md](references/testing.md). + +## Best Practices + +1. **Always use `RunE`, never `Run`** β€” `Run` cannot return an error; the only escape is `os.Exit` or panic, bypassing defers. +2. **Put config initialization in `PersistentPreRunE`** β€” it runs before every subcommand; the right place for viper binding and auth checks. +3. **Validate positional args with `Args`, not inside `RunE`** β€” `Args` gives cobra's standard error messages; `MatchAll` composes validators. +4. **Use `cmd.OutOrStdout()` / `cmd.ErrOrStderr()` for all output** β€” direct `os.Stdout` writes cannot be captured by tests. +5. **Re-create the command tree per test** β€” cobra accumulates flag state across `Execute()` calls on the same instance. + +## Common Mistakes + +| Mistake | Why it fails | Fix | +| --- | --- | --- | +| Using `Run` instead of `RunE` | Cannot return an error β€” only escape is `os.Exit` or panic, bypassing defers | Use `RunE` β€” return the error, let cobra handle the exit | +| Writing `len(args)` checks in `RunE` | Bypasses cobra's standard error messages ("accepts 1 arg, received 2") | Declare `Args: cobra.ExactArgs(1)` on the command | +| Writing to `os.Stdout` directly | Tests cannot capture output β€” os-level file handles can't be redirected | Use `cmd.OutOrStdout()` / `cmd.ErrOrStderr()` | +| Child `PersistentPreRunE` silently drops parent's | Cobra does not chain β€” the child replaces the parent's hook entirely | Call `parent.PersistentPreRunE(cmd, args)` from the child's hook | +| Reusing a root command across tests | Cobra accumulates flag state; second `Execute()` sees flags from the first | Build a fresh command tree per test | + +## Further Reading + +- [commands-and-args.md](references/commands-and-args.md) β€” full PreRun\*/PostRun\* chain, every Args validator, PersistentPreRunE inheritance rules +- [flags.md](references/flags.md) β€” pflag types, required/exclusive/oneRequired groups, custom value types, viper binding +- [completions.md](references/completions.md) β€” ShellCompDirective set, annotation-based completions, testing completions +- [generators.md](references/generators.md) β€” man page, markdown, YAML, RST doc generation; `cobra-cli` scaffolder +- [testing.md](references/testing.md) β€” isolation patterns, golden files, testing completions, table-driven command tests + +## Cross-References + +- β†’ See `samber/cc-skills-golang@golang-cli` skill for general CLI architecture β€” project layout, exit codes, signal handling, I/O patterns +- β†’ See `samber/cc-skills-golang@golang-spf13-viper` skill for configuration layering alongside cobra (flag β†’ env β†’ file β†’ default precedence) +- β†’ See `samber/cc-skills-golang@golang-testing` skill for general Go testing patterns + +If you encounter a bug or unexpected behavior in spf13/cobra, open an issue at . diff --git a/.agents/skills/golang-spf13-cobra/evals/evals.json b/.agents/skills/golang-spf13-cobra/evals/evals.json new file mode 100644 index 0000000..b24c9b8 --- /dev/null +++ b/.agents/skills/golang-spf13-cobra/evals/evals.json @@ -0,0 +1,444 @@ +[ + { + "id": 1, + "name": "rune-vs-run-error-propagation", + "description": "Tests use of RunE instead of Run for error propagation", + "prompt": "I'm writing a cobra subcommand in Go that calls an external API. If the API returns an error, the command should exit non-zero. Should I use Run or RunE?", + "trap": "Without the skill, the model may say both work, suggest using Run with os.Exit(1), or not explain why Run is problematic. The correct answer is always RunE β€” it propagates the error through cobra's error handling chain.", + "assertions": [ + { "id": "1.1", "text": "Recommends RunE, not Run" }, + { + "id": "1.2", + "text": "Explains that Run cannot return an error β€” you'd need os.Exit or panic" + }, + { + "id": "1.3", + "text": "Shows RunE returning the error from the handler" + }, + { "id": "1.4", "text": "Does NOT suggest using os.Exit inside RunE" }, + { + "id": "1.5", + "text": "Mentions that returning error from RunE causes cobra to exit non-zero" + } + ] + }, + { + "id": 2, + "name": "args-validator-not-manual-check", + "description": "Tests use of cobra Args validators instead of manual len(args) checks in RunE", + "prompt": "I'm writing a Go CLI with cobra. My 'delete' command requires exactly one positional argument (the resource name). How should I validate this?", + "trap": "Without the skill, the model writes len(args) != 1 check inside RunE. The correct approach is Args: cobra.ExactArgs(1) on the command definition, which validates before RunE runs and gives a standard error message.", + "assertions": [ + { + "id": "2.1", + "text": "Sets Args: cobra.ExactArgs(1) on the command struct" + }, + { "id": "2.2", "text": "Does NOT write len(args) check inside RunE" }, + { + "id": "2.3", + "text": "Mentions that cobra prints a standard error message when validation fails" + }, + { + "id": "2.4", + "text": "RunE body accesses args[0] directly without re-validating length" + } + ] + }, + { + "id": 3, + "name": "outOrStdout-not-os-stdout", + "description": "Tests use of cmd.OutOrStdout() instead of os.Stdout for testable output", + "prompt": "I'm writing a cobra command in Go that prints a table of results to the terminal. How should I write to stdout from inside RunE?", + "trap": "Without the skill, the model uses fmt.Println or os.Stdout directly. The correct approach is fmt.Fprintln(cmd.OutOrStdout(), ...) which can be redirected to a buffer in tests.", + "assertions": [ + { "id": "3.1", "text": "Uses cmd.OutOrStdout() as the io.Writer target" }, + { "id": "3.2", "text": "Does NOT use os.Stdout directly" }, + { + "id": "3.3", + "text": "Does NOT use fmt.Println (which hardcodes os.Stdout)" + }, + { + "id": "3.4", + "text": "Mentions testability as the reason β€” SetOut can redirect the writer in tests" + } + ] + }, + { + "id": 4, + "name": "persistent-prerunE-hook-chain", + "description": "Tests PersistentPreRunE on root for global config init and the child override trap", + "prompt": "In my Go CLI with cobra, I want to initialize viper config before any subcommand runs. I also have one subcommand that needs its own PersistentPreRunE for extra setup. How do I make sure both run?", + "trap": "Without the skill, the model defines PersistentPreRunE on both root and child without noting that the child's hook replaces the parent's β€” so root's config init never runs for that subcommand.", + "assertions": [ + { + "id": "4.1", + "text": "Explains that a child's PersistentPreRunE replaces (not chains) the parent's" + }, + { + "id": "4.2", + "text": "Shows explicitly calling the parent's PersistentPreRunE from inside the child's hook" + }, + { "id": "4.3", "text": "Does NOT claim both hooks run automatically" }, + { + "id": "4.4", + "text": "Uses PersistentPreRunE (the *E variant) not PersistentPreRun" + } + ] + }, + { + "id": 5, + "name": "silence-usage-and-errors", + "description": "Tests SilenceUsage and SilenceErrors on root command", + "prompt": "When my Go cobra CLI returns an error from RunE, the terminal shows the full usage/help text followed by the error. I only want to see the error message, not the usage. How do I fix this?", + "trap": "Without the skill, the model may suggest overriding SetUsageTemplate or wrapping the error. The correct fix is SilenceUsage: true on the root command.", + "assertions": [ + { + "id": "5.1", + "text": "Sets SilenceUsage: true on the root cobra.Command" + }, + { + "id": "5.2", + "text": "Optionally mentions SilenceErrors: true (for custom error formatting)" + }, + { + "id": "5.3", + "text": "Does NOT suggest removing or wrapping the error in RunE" + }, + { + "id": "5.4", + "text": "Explains that SilenceUsage only suppresses usage on error, not on --help" + } + ] + }, + { + "id": 6, + "name": "command-group-registration-order", + "description": "Tests that AddGroup must be called before AddCommand that references it", + "prompt": "I want to group my cobra subcommands in the help output under labels like 'Core Commands:' and 'Management Commands:'. How do I set this up?", + "trap": "Without the skill, the model calls AddCommand first and AddGroup after, which doesn't work β€” groups must be registered before the commands that reference them.", + "assertions": [ + { + "id": "6.1", + "text": "Calls AddGroup before AddCommand for commands that use that group" + }, + { + "id": "6.2", + "text": "Sets GroupID on the subcommand matching the Group's ID field" + }, + { "id": "6.3", "text": "Shows cobra.Group{ID: ..., Title: ...} struct" }, + { + "id": "6.4", + "text": "Does NOT call AddCommand before AddGroup for the same group" + } + ] + }, + { + "id": 7, + "name": "valid-args-function-dynamic-completion", + "description": "Tests ValidArgsFunction for dynamic shell completion instead of static ValidArgs", + "prompt": "My Go cobra 'get pod' command should complete pod names dynamically by querying the API server. ValidArgs only accepts a static list. How do I provide dynamic completions?", + "trap": "Without the skill, the model tries to populate ValidArgs at startup (querying the API at init time) or doesn't know about ValidArgsFunction.", + "assertions": [ + { + "id": "7.1", + "text": "Uses ValidArgsFunction (not ValidArgs) for dynamic completions" + }, + { + "id": "7.2", + "text": "Function signature returns ([]string, cobra.ShellCompDirective)" + }, + { + "id": "7.3", + "text": "Returns cobra.ShellCompDirectiveNoFileComp to prevent file fallback" + }, + { + "id": "7.4", + "text": "Does NOT query the API at init() or in ValidArgs (static list)" + }, + { + "id": "7.5", + "text": "Handles errors by returning cobra.ShellCompDirectiveError" + } + ] + }, + { + "id": 8, + "name": "register-flag-completion-func", + "description": "Tests RegisterFlagCompletionFunc for flag value completion", + "prompt": "My Go cobra command has an --output flag that accepts 'json', 'yaml', or 'table'. How do I make the shell complete valid values when the user types --output ?", + "trap": "Without the skill, the model does not know about RegisterFlagCompletionFunc and instead documents the valid values only in the flag description string.", + "assertions": [ + { + "id": "8.1", + "text": "Calls cmd.RegisterFlagCompletionFunc(\"output\", func(...) ...)" + }, + { + "id": "8.2", + "text": "The completion function returns []string{\"json\", \"yaml\", \"table\"} (or similar)" + }, + { "id": "8.3", "text": "Returns cobra.ShellCompDirectiveNoFileComp" }, + { + "id": "8.4", + "text": "Does NOT rely only on the flag usage string for user guidance" + } + ] + }, + { + "id": 9, + "name": "test-isolation-fresh-root", + "description": "Tests that a fresh command tree must be created per test to avoid flag state leakage", + "prompt": "I'm writing tests for my Go cobra CLI. My first test runs 'myapp serve --port 9090' and passes. My second test runs 'myapp serve' without --port and expects the default 8080, but gets 9090. What's wrong and how do I fix it?", + "trap": "Without the skill, the model may suggest resetting the flag value manually or calling ResetFlags(). The correct fix is to create a fresh command tree per test.", + "assertions": [ + { + "id": "9.1", + "text": "Identifies the root cause as reusing the same cobra.Command instance across tests" + }, + { + "id": "9.2", + "text": "Recommends building a new command tree per test (constructor function)" + }, + { + "id": "9.3", + "text": "Shows a newRootCmd() or similar factory function pattern" + }, + { + "id": "9.4", + "text": "Does NOT suggest ResetFlags() as the primary solution" + }, + { + "id": "9.5", + "text": "Each test calls the factory to get a fresh *cobra.Command" + } + ] + }, + { + "id": 10, + "name": "match-all-validator-composition", + "description": "Tests MatchAll for composing multiple arg validators", + "prompt": "My Go cobra 'apply' command needs positional args that are all valid resource names (from a known list) AND there must be at least one. How do I express both constraints?", + "trap": "Without the skill, the model writes a custom validator function that manually checks both conditions with if statements. MatchAll composes built-in validators without custom code.", + "assertions": [ + { "id": "10.1", "text": "Uses cobra.MatchAll to compose validators" }, + { + "id": "10.2", + "text": "Combines cobra.MinimumNArgs(1) (or ExactArgs) with cobra.OnlyValidArgs" + }, + { "id": "10.3", "text": "Sets ValidArgs with the known resource names" }, + { + "id": "10.4", + "text": "Does NOT write a fully manual validator function for the combined check" + } + ] + }, + { + "id": 11, + "name": "cobra-vs-viper-distinction", + "description": "Tests understanding of what cobra does vs what viper does", + "prompt": "I'm starting a Go CLI project. I need subcommands, flags, shell completions, AND the ability to read configuration from a YAML file and environment variables. I've heard of cobra and viper. Which library handles which concern?", + "trap": "Without the skill, the model may conflate the two or understate how they integrate. The correct answer clearly assigns cobra=command tree/flags/completions and viper=layered config resolution, with BindPFlag as the integration seam.", + "assertions": [ + { + "id": "11.1", + "text": "Assigns cobra to command tree, flags, arg validation, shell completions" + }, + { + "id": "11.2", + "text": "Assigns viper to config file, env var, and layered value resolution" + }, + { + "id": "11.3", + "text": "Identifies BindPFlag (or similar) as the integration seam between them" + }, + { + "id": "11.4", + "text": "Explains they can be used independently (cobra without viper, or viper without cobra)" + }, + { + "id": "11.5", + "text": "Does NOT say cobra reads config files or viper defines subcommands" + } + ] + }, + { + "id": 12, + "name": "cobra-cli-scaffolder", + "description": "Tests knowledge of the cobra-cli scaffolding tool", + "prompt": "I want to quickly scaffold a new Go CLI project with cobra. Is there a tool that generates the initial files and lets me add subcommands from the command line?", + "trap": "Without the skill, the model may say to create files manually or use a generic project generator. The cobra-cli tool is the canonical scaffolder for cobra projects.", + "assertions": [ + { + "id": "12.1", + "text": "Mentions cobra-cli (github.com/spf13/cobra-cli)" + }, + { + "id": "12.2", + "text": "Shows 'cobra-cli init ' for initialization" + }, + { + "id": "12.3", + "text": "Shows 'cobra-cli add ' for adding subcommands" + }, + { + "id": "12.4", + "text": "Explains that cobra-cli is separate from cobra itself (different import path)" + } + ] + }, + { + "id": 13, + "name": "stringarray-vs-stringslice-commas", + "description": "Tests StringArray vs StringSlice when flag values contain commas", + "prompt": "My Go cobra CLI has a --label flag that users pass multiple times like --label 'env=prod,region=us'. With my current setup, passing --label 'env=prod,region=us' results in two separate values ['env=prod', 'region=us'] instead of one. What flag type should I use?", + "trap": "Without the skill, the model uses StringSlice which splits on commas. StringArray is the correct choice when values may legitimately contain commas.", + "assertions": [ + { + "id": "13.1", + "text": "Recommends StringArray (or StringArrayVar) instead of StringSlice" + }, + { + "id": "13.2", + "text": "Explains that StringSlice splits on commas while StringArray does not" + }, + { + "id": "13.3", + "text": "Does NOT suggest quoting or escaping commas as the fix" + }, + { + "id": "13.4", + "text": "Shows the correct flag definition using StringArray or StringArrayVar" + } + ] + }, + { + "id": 14, + "name": "mutually-exclusive-flags", + "description": "Tests MarkFlagsMutuallyExclusive instead of manual RunE checks", + "prompt": "My Go cobra command has --json and --yaml flags for output format. Users should only be able to pass one of them. How do I prevent both from being passed at the same time?", + "trap": "Without the skill, the model writes an if statement checking both flags inside RunE. The correct approach is MarkFlagsMutuallyExclusive which cobra enforces at parse time before RunE.", + "assertions": [ + { + "id": "14.1", + "text": "Calls cmd.MarkFlagsMutuallyExclusive(\"json\", \"yaml\")" + }, + { + "id": "14.2", + "text": "Does NOT write a manual if-both-set check inside RunE" + }, + { + "id": "14.3", + "text": "Explains cobra enforces this at flag parse time and returns a standard error" + } + ] + }, + { + "id": 15, + "name": "required-together-flags", + "description": "Tests MarkFlagsRequiredTogether instead of manual RunE checks", + "prompt": "My Go cobra command has --tls-cert and --tls-key flags. If a user provides one, they must provide the other. How do I enforce this constraint?", + "trap": "Without the skill, the model writes manual validation in RunE checking if one is set without the other. MarkFlagsRequiredTogether enforces this at cobra's parse stage.", + "assertions": [ + { + "id": "15.1", + "text": "Calls cmd.MarkFlagsRequiredTogether(\"tls-cert\", \"tls-key\")" + }, + { + "id": "15.2", + "text": "Does NOT write manual if-one-without-the-other checks inside RunE" + }, + { + "id": "15.3", + "text": "Explains cobra validates this before RunE runs" + } + ] + }, + { + "id": 16, + "name": "one-required-flag-group", + "description": "Tests MarkFlagsOneRequired instead of manual RunE checks", + "prompt": "My Go cobra command accepts input from either --file or --stdin. At least one must be provided. How do I enforce that the user passes at least one of them?", + "trap": "Without the skill, the model checks flag presence inside RunE. MarkFlagsOneRequired enforces at parse time with a standard cobra error.", + "assertions": [ + { + "id": "16.1", + "text": "Calls cmd.MarkFlagsOneRequired(\"file\", \"stdin\")" + }, + { + "id": "16.2", + "text": "Does NOT write a manual check inside RunE for neither flag being set" + }, + { + "id": "16.3", + "text": "Explains cobra enforces this before RunE runs" + } + ] + }, + { + "id": 17, + "name": "flag-changed-distinguish-explicit-zero", + "description": "Tests cmd.Flags().Changed() to distinguish explicit zero from absent flag", + "prompt": "My Go cobra command has a --timeout flag defaulting to 30s. Users can pass --timeout 0 to disable timeouts entirely. In RunE, how do I tell whether the user explicitly passed --timeout 0 or simply didn't pass --timeout at all?", + "trap": "Without the skill, the model checks if timeout == 0, which conflates the two cases. The correct approach is cmd.Flags().Changed(\"timeout\") which returns true only when the user explicitly provided the flag.", + "assertions": [ + { + "id": "17.1", + "text": "Uses cmd.Flags().Changed(\"timeout\") to detect explicit user input" + }, + { + "id": "17.2", + "text": "Does NOT use if timeout == 0 as the sole distinguishing condition" + }, + { + "id": "17.3", + "text": "Explains Changed() returns true only when the flag was explicitly set by the user" + }, + { + "id": "17.4", + "text": "Shows the pattern: if Changed β†’ apply value, else β†’ use default behavior" + } + ] + }, + { + "id": 18, + "name": "postrunE-success-only-use-defer", + "description": "Tests that PostRunE runs only on RunE success and defer is the right cleanup pattern", + "prompt": "My Go cobra command opens a database connection early in RunE and I want to close it when the command finishes, whether it succeeds or fails. I added cleanup in PostRunE but noticed it doesn't run when RunE returns an error. What's the right pattern?", + "trap": "Without the skill, the model may suggest PersistentPostRunE or not know PostRunE is success-only. The correct pattern is defer inside RunE for guaranteed cleanup regardless of outcome.", + "assertions": [ + { + "id": "18.1", + "text": "Uses defer inside RunE to guarantee cleanup on both success and failure" + }, + { + "id": "18.2", + "text": "Explains PostRunE only runs when RunE returns nil (success)" + }, + { + "id": "18.3", + "text": "Does NOT present PostRunE as a solution for failure cleanup" + } + ] + }, + { + "id": 19, + "name": "errOrStderr-not-os-stderr", + "description": "Tests cmd.ErrOrStderr() instead of os.Stderr for capturable error output", + "prompt": "My Go cobra command writes diagnostic details to stderr using fmt.Fprintf(os.Stderr, ...) before returning an error. This works fine at runtime but my tests can't capture the stderr output. How do I fix this?", + "trap": "Without the skill, the model uses os.Stderr directly. The correct approach is cmd.ErrOrStderr() which tests can redirect via rootCmd.SetErr(buf).", + "assertions": [ + { + "id": "19.1", + "text": "Replaces os.Stderr with cmd.ErrOrStderr() as the write target" + }, + { "id": "19.2", "text": "Does NOT use os.Stderr directly" }, + { + "id": "19.3", + "text": "Shows rootCmd.SetErr(buf) in the test to capture stderr output" + }, + { + "id": "19.4", + "text": "Explains the symmetry with cmd.OutOrStdout() / SetOut for stdout" + } + ] + } +] diff --git a/.agents/skills/golang-spf13-cobra/references/commands-and-args.md b/.agents/skills/golang-spf13-cobra/references/commands-and-args.md new file mode 100644 index 0000000..ec4b69e --- /dev/null +++ b/.agents/skills/golang-spf13-cobra/references/commands-and-args.md @@ -0,0 +1,157 @@ +# Cobra Commands, Hooks, and Args Validators + +## The Run\* lifecycle + +Cobra commands have five run hooks. Cobra executes them in this fixed order: + +``` +PersistentPreRunE β†’ PreRunE β†’ RunE β†’ PostRunE β†’ PersistentPostRunE +``` + +Each `*E` hook returns `error`. The non-`*E` variants (`PersistentPreRun`, `PreRun`, `Run`, `PostRun`, `PersistentPostRun`) have signature `func(cmd *cobra.Command, args []string)` β€” they cannot signal failure without `os.Exit` or panic. **Always use the `*E` variants.** + +### Which hook to use + +| Hook | Scope | When to use | +| --- | --- | --- | +| `PersistentPreRunE` | Parent + all descendants | Config init, auth check, telemetry setup β€” must run before every subcommand | +| `PreRunE` | This command only | Validation that runs only for this command before `RunE` | +| `RunE` | This command only | Main handler β€” the primary business logic | +| `PostRunE` | This command only | Cleanup that runs only if `RunE` succeeded | +| `PersistentPostRunE` | Parent + all descendants | Global cleanup (close connections, flush buffers) | + +### Inheritance rules + +`PersistentPreRunE` defined on the root command runs before every subcommand. But if a child command defines **its own** `PersistentPreRunE`, it **replaces** (does not chain) the parent's hook. Call the parent explicitly if you need both: + +```go +var childCmd = &cobra.Command{ + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // call parent's hook first + if err := rootCmd.PersistentPreRunE(cmd, args); err != nil { + return err + } + // child-specific logic + return nil + }, +} +``` + +### Execution stops on first error + +If `PersistentPreRunE` returns an error, cobra stops β€” `RunE` and later hooks never run. Use this for fail-fast auth checks. + +## Args validators + +Args validators run before `RunE`. Cobra prints a clear error message and exits without calling `RunE` when validation fails. + +### Built-in validators + +```go +cobra.NoArgs // fails if any positional args provided +cobra.ArbitraryArgs // accepts any number of args (default) +cobra.ExactArgs(n int) // requires exactly n args +cobra.MinimumNArgs(n int) // requires at least n args +cobra.MaximumNArgs(n int) // requires at most n args +cobra.RangeArgs(min, max int) // requires between min and max args +cobra.OnlyValidArgs // all args must be in ValidArgs list +cobra.ExactValidArgs(n int) // exactly n args, all in ValidArgs +``` + +### Composing validators with MatchAll + +```go +var deleteCmd = &cobra.Command{ + Use: "delete ", + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + ValidArgs: []string{"pod", "service", "deployment"}, + RunE: func(cmd *cobra.Command, args []string) error { + return doDelete(args[0]) + }, +} +``` + +### Custom validators + +Signature: `func(cmd *cobra.Command, args []string) error` + +```go +func validatePositiveInt(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("requires exactly 1 arg, got %d", len(args)) + } + n, err := strconv.Atoi(args[0]) + if err != nil || n <= 0 { + return fmt.Errorf("argument must be a positive integer, got %q", args[0]) + } + return nil +} + +var cmd = &cobra.Command{ + Args: validatePositiveInt, + RunE: func(cmd *cobra.Command, args []string) error { /* ... */ }, +} +``` + +Combine custom validators with built-in ones using `MatchAll`: + +```go +Args: cobra.MatchAll(cobra.MinimumNArgs(1), validateAllPositive), +``` + +## Command registration and ordering + +```go +func init() { + // groups must be registered before AddCommand + rootCmd.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"}) + rootCmd.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"}) + + serveCmd.GroupID = "core" + migrateCmd.GroupID = "management" + + rootCmd.AddCommand(serveCmd, migrateCmd, versionCmd) +} +``` + +`versionCmd` has no `GroupID` β€” it appears in the default section. + +## Annotations + +Cobra supports arbitrary command annotations for framework-level metadata: + +```go +var serveCmd = &cobra.Command{ + Annotations: map[string]string{ + "category": "network", + "requires-auth": "true", + }, +} + +// read in a middleware hook: +if serveCmd.Annotations["requires-auth"] == "true" { + // enforce auth +} +``` + +## Hidden and deprecated commands + +```go +var internalCmd = &cobra.Command{ + Hidden: true, // not shown in help, still executable +} + +var oldCmd = &cobra.Command{ + Deprecated: "use `newcmd` instead", // shown in help, prints warning on use +} +``` + +## cobra.CheckErr + +`cobra.CheckErr(err)` is a convenience function: if `err != nil`, it prints the error to `cmd.ErrOrStderr()` and calls `os.Exit(1)`. Use it only in `main()` where you want a hard exit β€” not inside `RunE` where returning the error is preferred. + +```go +func main() { + cobra.CheckErr(rootCmd.Execute()) +} +``` diff --git a/.agents/skills/golang-spf13-cobra/references/completions.md b/.agents/skills/golang-spf13-cobra/references/completions.md new file mode 100644 index 0000000..673b94e --- /dev/null +++ b/.agents/skills/golang-spf13-cobra/references/completions.md @@ -0,0 +1,119 @@ +# Cobra Shell Completions Reference + +Cobra generates shell completion scripts for bash, zsh, fish, and PowerShell automatically. Subcommand names and flag names are completed for free. You add completions for flag values and positional arguments. + +## Built-in completion command + +Cobra registers a `completion` subcommand automatically: + +```bash +myapp completion bash # generate bash script +myapp completion zsh # generate zsh script +myapp completion fish # generate fish script +myapp completion powershell + +# Install (example for zsh): +myapp completion zsh > "${fpath[1]}/_myapp" +``` + +## ShellCompDirective + +The `ShellCompDirective` controls shell behavior after your completion function returns: + +| Directive | Meaning | +| --- | --- | +| `ShellCompDirectiveDefault` | Fall back to file completion after your results | +| `ShellCompDirectiveNoFileComp` | Disable file completion fallback | +| `ShellCompDirectiveNoSpace` | Don't add a space after the completion | +| `ShellCompDirectiveFilterFileExt(exts)` | Only show files with given extensions | +| `ShellCompDirectiveFilterDirs(dirs)` | Only show directories | +| `ShellCompDirectiveError` | Signal an error (show no completions) | + +Combine with bitwise OR: `cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace`. + +Use `ShellCompDirectiveNoFileComp` whenever your list is exhaustive β€” it prevents the shell from appending irrelevant files. + +## Static arg completions + +```go +var getCmd = &cobra.Command{ + Use: "get ", + ValidArgs: []string{"pod", "service", "deployment", "configmap"}, + Args: cobra.OnlyValidArgs, + RunE: func(cmd *cobra.Command, args []string) error { /* ... */ }, +} +``` + +## Dynamic arg completions + +```go +var getCmd = &cobra.Command{ + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + // first arg already provided β€” no more completions + return nil, cobra.ShellCompDirectiveNoFileComp + } + resources, err := listResources(toComplete) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + return resources, cobra.ShellCompDirectiveNoFileComp + }, +} +``` + +`toComplete` is the prefix the user has typed so far β€” filter your results by it for responsive completions. + +## Flag value completions + +```go +func init() { + rootCmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"json\tJSON output", "yaml\tYAML output", "table\tTable output"}, cobra.ShellCompDirectiveNoFileComp + }) + + rootCmd.RegisterFlagCompletionFunc("namespace", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + ns, err := listNamespaces() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + return ns, cobra.ShellCompDirectiveNoFileComp + }) +} +``` + +Descriptions after `\t` are shown in zsh and fish menus. + +## Completion annotations + +Mark a flag to complete as a file or directory: + +```go +cmd.Flags().String("config", "", "config file") +cmd.MarkFlagFilename("config", "yaml", "yml", "json") // only those extensions + +cmd.Flags().String("dir", "", "output directory") +cmd.MarkFlagDirname("dir") +``` + +## Testing completions + +```go +func TestCompletion(t *testing.T) { + rootCmd.SetArgs([]string{"__complete", "get", ""}) + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.Execute() + assert.Contains(t, buf.String(), "pod") + assert.Contains(t, buf.String(), "service") +} +``` + +`__complete` is cobra's internal completion request verb. Pass the partial args as additional arguments. + +## Disabling the completion command + +```go +rootCmd.CompletionOptions.DisableDefaultCmd = true // remove the completion subcommand +rootCmd.CompletionOptions.HiddenDefaultCmd = true // keep it but hide from help +``` diff --git a/.agents/skills/golang-spf13-cobra/references/flags.md b/.agents/skills/golang-spf13-cobra/references/flags.md new file mode 100644 index 0000000..a4e4c67 --- /dev/null +++ b/.agents/skills/golang-spf13-cobra/references/flags.md @@ -0,0 +1,125 @@ +# Cobra Flags Reference + +Cobra delegates all flag parsing to `github.com/spf13/pflag`. `cobra.Command` exposes two `*pflag.FlagSet`s: + +- `cmd.Flags()` β€” local flags, only available on this command. +- `cmd.PersistentFlags()` β€” inherited by all subcommands. + +## Common flag types + +```go +// String +cmd.Flags().String("name", "default", "description") +cmd.Flags().StringP("name", "n", "default", "description") // with shorthand + +// With pointer binding (no Lookup needed later) +var name string +cmd.Flags().StringVar(&name, "name", "default", "description") +cmd.Flags().StringVarP(&name, "name", "n", "default", "description") + +// Other types follow the same pattern: +cmd.Flags().Int / IntVar / IntVarP +cmd.Flags().Bool / BoolVar / BoolVarP +cmd.Flags().Float64 / Float64Var +cmd.Flags().Duration / DurationVar // parses "1h30m", "500ms" +cmd.Flags().StringSlice / StringSliceVar // comma-separated or repeated flags +cmd.Flags().StringArray / StringArrayVar // repeated flags only (no comma splitting) +cmd.Flags().IntSlice / IntSliceVar +cmd.Flags().StringToString // --label key=value --label k2=v2 +``` + +## StringSlice vs StringArray + +| Flag type | Input | Result | +| ------------- | --------------------- | --------------------------------- | +| `StringSlice` | `--tags a,b --tags c` | `["a", "b", "c"]` β€” commas split | +| `StringArray` | `--tags a,b --tags c` | `["a,b", "c"]` β€” commas NOT split | + +Use `StringArray` when values may legitimately contain commas. + +## Flag constraints + +```go +// Fail if flag not provided +cmd.MarkFlagRequired("output") + +// Fail if both provided +cmd.MarkFlagsMutuallyExclusive("json", "yaml", "table") + +// Fail if none provided +cmd.MarkFlagsOneRequired("file", "stdin") + +// Require flag only if another flag is set +cmd.MarkFlagsMutuallyExclusive("tls", "no-tls") +``` + +## Persistent flag patterns + +```go +func init() { + // global flags on root + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: $HOME/.myapp.yaml)") + rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "log level (debug, info, warn, error)") + + // bind to viper immediately after defining + viper.BindPFlag("config", rootCmd.PersistentFlags().Lookup("config")) + viper.BindPFlag("log-level", rootCmd.PersistentFlags().Lookup("log-level")) +} +``` + +## Custom flag value types + +Implement `pflag.Value` to parse arbitrary types: + +```go +type enumValue struct { + val string + allowed []string +} + +func (e *enumValue) String() string { return e.val } +func (e *enumValue) Type() string { return "enum" } +func (e *enumValue) Set(s string) error { + for _, a := range e.allowed { + if s == a { + e.val = s + return nil + } + } + return fmt.Errorf("must be one of %v", e.allowed) +} + +var outputFmt = &enumValue{val: "table", allowed: []string{"table", "json", "yaml"}} +cmd.Flags().Var(outputFmt, "output", "output format (table, json, yaml)") +``` + +## Flag groups (required together) + +Mark a set of flags that must all be provided if any one of them is provided: + +```go +cmd.Flags().String("tls-cert", "", "TLS certificate file") +cmd.Flags().String("tls-key", "", "TLS key file") +cmd.MarkFlagsRequiredTogether("tls-cert", "tls-key") +``` + +## Accessing flag values + +Prefer pointer binding (`StringVar`, `IntVar`, etc.) for type-safe access. When you need the flag post-parse: + +```go +port, err := cmd.Flags().GetInt("port") +name, err := cmd.Flags().GetString("name") +tags, err := cmd.Flags().GetStringSlice("tags") +``` + +## Flag changed vs default + +```go +if cmd.Flags().Changed("port") { + // user explicitly provided --port + // useful when distinguishing "user set 0" from "flag not provided" +} +``` + +`Changed()` is also how viper knows which flags are explicit overrides β€” it only promotes a flag to the highest precedence layer if `Changed()` is true. diff --git a/.agents/skills/golang-spf13-cobra/references/generators.md b/.agents/skills/golang-spf13-cobra/references/generators.md new file mode 100644 index 0000000..d7c587d --- /dev/null +++ b/.agents/skills/golang-spf13-cobra/references/generators.md @@ -0,0 +1,111 @@ +# Cobra Documentation and Scaffolding Generators + +## Doc generation + +Cobra can generate documentation from your command tree in multiple formats. Import the `cobra/doc` sub-package: + +```bash +go get github.com/spf13/cobra/doc +``` + +### Markdown + +```go +import "github.com/spf13/cobra/doc" + +err := doc.GenMarkdownTree(rootCmd, "/tmp/docs/") +// generates /tmp/docs/myapp.md, /tmp/docs/myapp_serve.md, etc. + +// Single command +var buf bytes.Buffer +doc.GenMarkdown(rootCmd, &buf) +``` + +### Man pages + +```go +header := &doc.GenManHeader{ + Title: "MYAPP", + Section: "1", + Date: &time.Time{}, + Source: "myapp v1.0.0", + Manual: "User Commands", +} +err := doc.GenManTree(rootCmd, header, "/usr/local/share/man/man1/") +``` + +### YAML + +```go +err := doc.GenYamlTree(rootCmd, "/tmp/docs/") +``` + +### RST (reStructuredText) + +```go +err := doc.GenReSTTree(rootCmd, "/tmp/docs/") +``` + +## cobra-cli scaffolder + +`cobra-cli` generates command files and wires them into your project: + +```bash +go get -tool github.com/spf13/cobra-cli@latest + +# Initialize a new cobra project +go tool cobra-cli init myapp + +# Add a subcommand +go tool cobra-cli add serve +go tool cobra-cli add migrate + +# Add with a parent other than root +cobra-cli add list --parent serve +``` + +Generated files follow the standard pattern: + +```go +// cmd/serve.go +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "A brief description of your command", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + rootCmd.AddCommand(serveCmd) +} +``` + +`cobra-cli` is optional β€” many teams write command files by hand following the same pattern. + +## Help and usage template customization + +Override the default help template: + +```go +rootCmd.SetHelpTemplate(` +Usage: {{.UseLine}} +{{if .HasAvailableSubCommands}} +Commands: +{{range .Commands}}{{if .IsAvailableCommand}} {{rpad .Name .NamePadding }} {{.Short}} +{{end}}{{end}}{{end}} +Flags: +{{.LocalFlags.FlagUsages | trimRightSpace}} +`) +``` + +Override the usage function entirely: + +```go +rootCmd.SetUsageFunc(func(cmd *cobra.Command) error { + fmt.Fprintf(cmd.OutOrStdout(), "Custom usage for %s\n", cmd.Name()) + return nil +}) +``` + +Common template functions available: `rpad`, `trimRightSpace`, `gt`, `eq`. diff --git a/.agents/skills/golang-spf13-cobra/references/testing.md b/.agents/skills/golang-spf13-cobra/references/testing.md new file mode 100644 index 0000000..1e520bb --- /dev/null +++ b/.agents/skills/golang-spf13-cobra/references/testing.md @@ -0,0 +1,154 @@ +# Testing Cobra Commands + +## Basic test pattern + +```go +func TestServeCmd(t *testing.T) { + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + rootCmd.SetArgs([]string{"serve", "--port", "9090", "--dry-run"}) + + err := rootCmd.Execute() + require.NoError(t, err) + assert.Contains(t, stdout.String(), "listening on :9090") + assert.Empty(t, stderr.String()) +} +``` + +## Isolation between tests + +Cobra accumulates flag state across `Execute()` calls on the same command instance. Tests must be isolated. + +### Option 1: Re-create the command tree per test (recommended for unit tests) + +```go +func newRootCmd() *cobra.Command { + root := &cobra.Command{Use: "myapp", SilenceUsage: true, SilenceErrors: true} + root.AddCommand(newServeCmd()) + return root +} + +func TestServeCmd(t *testing.T) { + root := newRootCmd() + root.SetArgs([]string{"serve", "--port", "9090"}) + err := root.Execute() + require.NoError(t, err) +} +``` + +### Option 2: Reset flags between tests + +```go +func TestWithReset(t *testing.T) { + t.Cleanup(func() { + rootCmd.ResetFlags() + // re-define flags if needed + }) +} +``` + +Re-creating is safer β€” `ResetFlags` only clears the flag set, not subcommand state. + +## Testing commands that write output + +Commands must use `cmd.OutOrStdout()` / `cmd.ErrOrStderr()` instead of `os.Stdout` / `os.Stderr` for this to work. + +```go +// In command handler: +func runServe(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "Server started") + fmt.Fprintln(cmd.ErrOrStderr(), "Debug: listening on port 8080") + return nil +} + +// In test: +buf := new(bytes.Buffer) +rootCmd.SetOut(buf) +rootCmd.Execute() +assert.Contains(t, buf.String(), "Server started") +``` + +## Golden file tests + +For commands with structured or lengthy output, use golden files: + +```go +func TestOutputFormat(t *testing.T) { + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetArgs([]string{"list", "--output", "json"}) + require.NoError(t, rootCmd.Execute()) + + golden := "testdata/list-json.golden" + if *update { // -update flag + os.WriteFile(golden, buf.Bytes(), 0644) + } + want, _ := os.ReadFile(golden) + assert.Equal(t, string(want), buf.String()) +} +``` + +Run with `-update` to regenerate golden files after intentional output changes. + +## Testing error paths + +```go +func TestInvalidArgs(t *testing.T) { + stderr := new(bytes.Buffer) + rootCmd.SetErr(stderr) + rootCmd.SetArgs([]string{"delete"}) // missing required arg + + err := rootCmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "accepts 1 arg") +} +``` + +## Table-driven command tests + +```go +tests := []struct { + name string + args []string + wantOut string + wantErr bool +}{ + {"no flags", []string{"serve"}, "listening on :8080", false}, + {"custom port", []string{"serve", "--port", "9090"}, "listening on :9090", false}, + {"invalid port", []string{"serve", "--port", "abc"}, "", true}, +} + +for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := newRootCmd() // fresh command tree per test + buf := new(bytes.Buffer) + root.SetOut(buf) + root.SetArgs(tt.args) + err := root.Execute() + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Contains(t, buf.String(), tt.wantOut) + } + }) +} +``` + +## Testing completions + +```go +func TestCompletion(t *testing.T) { + root := newRootCmd() + buf := new(bytes.Buffer) + root.SetOut(buf) + root.SetArgs([]string{"__complete", "delete", ""}) + root.Execute() + + assert.Contains(t, buf.String(), "pod") + assert.Contains(t, buf.String(), "service") +} +``` diff --git a/.agents/skills/golang-testing/SKILL.md b/.agents/skills/golang-testing/SKILL.md new file mode 100644 index 0000000..03c3e75 --- /dev/null +++ b/.agents/skills/golang-testing/SKILL.md @@ -0,0 +1,424 @@ +--- +name: golang-testing +description: "Production-ready Golang tests β€” table-driven tests, testify suites and mocks, parallel tests, fuzzing, fixtures, goroutine leak detection with goleak, snapshot testing, code coverage, integration tests, idiomatic test naming. Use when writing or reviewing Go tests, choosing a testing approach, setting up Go test CI, or debugging flaky/slow tests. For testify-specific APIs see `samber/cc-skills-golang@golang-stretchr-testify`; for measurement methodology see `samber/cc-skills-golang@golang-benchmark`." +user-invocable: true +license: MIT +compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang. +metadata: + author: samber + version: "1.2.1" + openclaw: + emoji: "πŸ§ͺ" + homepage: https://github.com/samber/cc-skills-golang + requires: + bins: + - go + - gotests + install: + - kind: go + package: github.com/cweill/gotests/gotests@latest + bins: [gotests] +allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent Bash(gotests:*) AskUserQuestion +--- + +**Persona:** You are a Go engineer who treats tests as executable specifications. You write tests to constrain behavior, not to hit coverage targets. + +**Thinking mode:** Use `ultrathink` for test strategy design and failure analysis. Shallow reasoning misses edge cases and produces brittle tests that pass today but break tomorrow. + +**Modes:** + +- **Write mode** β€” generating new tests for existing or new code. Work sequentially through the code under test; use `gotests` to scaffold table-driven tests, then enrich with edge cases and error paths. +- **Review mode** β€” reviewing a PR's test changes. Focus on the diff: check coverage of new behaviour, assertion quality, table-driven structure, and absence of flakiness patterns. Sequential. +- **Audit mode** β€” auditing an existing test suite for gaps, flakiness, or bad patterns (order-dependent tests, missing `t.Parallel()`, implementation-detail coupling). Launch up to 3 parallel sub-agents split by concern: (1) unit test quality and coverage gaps, (2) integration test isolation and build tags, (3) goroutine leaks and race conditions. +- **Debug mode** β€” a test is failing or flaky. Work sequentially: reproduce reliably, isolate the failing assertion, trace the root cause in production code or test setup. + +> **Community default.** A company skill that explicitly supersedes `samber/cc-skills-golang@golang-testing` skill takes precedence. + +# Go Testing Best Practices + +This skill guides the creation of production-ready tests for Go applications. Follow these principles to write maintainable, fast, and reliable tests. + +## Best Practices Summary + +1. Table-driven tests MUST use named subtests -- every test case needs a `name` field passed to `t.Run` +2. Integration tests MUST use build tags (`//go:build integration`) to separate from unit tests +3. Tests MUST NOT depend on execution order -- each test MUST be independently runnable +4. Independent tests SHOULD use `t.Parallel()` when possible +5. NEVER test implementation details -- test observable behavior and public API contracts +6. Packages with goroutines SHOULD use `goleak.VerifyTestMain` in `TestMain` to detect goroutine leaks +7. Use testify as helpers, not a replacement for standard library +8. Mock interfaces, not concrete types +9. Keep unit tests fast (< 1ms), use build tags for integration tests +10. Run tests with race detection in CI +11. Include examples as executable documentation + +## Test Structure and Organization + +### File Conventions + +```go +// package_test.go - tests in same package (white-box, access unexported) +package mypackage + +// mypackage_test.go - tests in test package (black-box, public API only) +package mypackage_test +``` + +### Naming Conventions + +```go +func TestAdd(t *testing.T) { ... } // function test +func TestMyStruct_MyMethod(t *testing.T) { ... } // method test +func BenchmarkAdd(b *testing.B) { ... } // benchmark +func ExampleAdd() { ... } // example +func FuzzAdd(f *testing.F) { ... } // fuzz test +``` + +## Table-Driven Tests + +Table-driven tests are the idiomatic Go way to test multiple scenarios. Always name each test case. + +```go +func TestCalculatePrice(t *testing.T) { + tests := []struct { + name string + quantity int + unitPrice float64 + expected float64 + }{ + { + name: "single item", + quantity: 1, + unitPrice: 10.0, + expected: 10.0, + }, + { + name: "bulk discount - 100 items", + quantity: 100, + unitPrice: 10.0, + expected: 900.0, // 10% discount + }, + { + name: "zero quantity", + quantity: 0, + unitPrice: 10.0, + expected: 0.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CalculatePrice(tt.quantity, tt.unitPrice) + if got != tt.expected { + t.Errorf("CalculatePrice(%d, %.2f) = %.2f, want %.2f", + tt.quantity, tt.unitPrice, got, tt.expected) + } + }) + } +} +``` + +## Unit Tests + +Unit tests should be fast (< 1ms), isolated (no external dependencies), and deterministic. + +## Testing HTTP Handlers + +Use `httptest` for handler tests with table-driven patterns. See [HTTP Testing](./references/http-testing.md) for examples with request/response bodies, query parameters, headers, and status code assertions. + +## Goroutine Leak Detection with goleak + +Use `go.uber.org/goleak` to detect leaking goroutines, especially for concurrent code: + +```go +import ( + "testing" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} +``` + +To exclude specific goroutine stacks (for known leaks or library goroutines): + +```go +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m, + goleak.IgnoreCurrent(), + ) +} +``` + +Or per-test: + +```go +func TestWorkerPool(t *testing.T) { + defer goleak.VerifyNone(t) + // ... test code ... +} +``` + +## testing/synctest for Deterministic Goroutine Testing + +`testing/synctest` (Go 1.25+) provides deterministic tests for goroutines, timers, deadlines, and context cancellation. Time advances only when all goroutines are blocked, making ordering predictable. + +When to use `synctest` instead of real time: + +- Testing concurrent code with time-based operations (time.Sleep, time.After, time.Ticker) +- When race conditions need to be reproducible +- When tests are flaky due to timing issues + +```go +import ( + "context" + "testing" + "testing/synctest" + "time" +) + +func TestContextTimeout(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const timeout = 5 * time.Second + + ctx, cancel := context.WithTimeout(t.Context(), timeout) + defer cancel() + + time.Sleep(timeout - time.Nanosecond) + synctest.Wait() + if err := ctx.Err(); err != nil { + t.Fatalf("before timeout: %v", err) + } + + time.Sleep(time.Nanosecond) + synctest.Wait() + if err := ctx.Err(); err != context.DeadlineExceeded { + t.Fatalf("after timeout: got %v, want DeadlineExceeded", err) + } + }) +} +``` + +Use `synctest.Test` in Go 1.25+ and Go 1.26+. Do not use the old Go 1.24 experimental `synctest.Run` API in Go 1.25+ or Go 1.26+ code. If a module explicitly targets Go 1.24 and opts into `GOEXPERIMENT=synctest`, use the old API only as a compatibility fallback. + +Key differences in `synctest`: + +- `time.Sleep` advances synthetic time instantly when the goroutine blocks +- `time.After` fires when synthetic time reaches the duration +- All goroutines run to blocking points before time advances +- Test execution is deterministic and repeatable + +## Test Timeouts + +For tests that may hang, use a timeout helper that panics with caller location. See [Helpers](./references/helpers.md). + +## Benchmarks + +β†’ See `samber/cc-skills-golang@golang-benchmark` skill for advanced benchmarking: `b.Loop()` (Go 1.24+), `benchstat`, profiling from benchmarks, and CI regression detection. + +Write benchmarks to measure performance and detect regressions: + +```go +func BenchmarkStringConcatenation(b *testing.B) { + b.Run("plus-operator", func(b *testing.B) { + for b.Loop() { + result := "a" + "b" + "c" + _ = result + } + }) + + b.Run("strings.Builder", func(b *testing.B) { + for b.Loop() { + var builder strings.Builder + builder.WriteString("a") + builder.WriteString("b") + builder.WriteString("c") + _ = builder.String() + } + }) +} +``` + +Benchmarks with different input sizes: + +```go +func BenchmarkFibonacci(b *testing.B) { + sizes := []int{10, 20, 30} + for _, size := range sizes { + b.Run(fmt.Sprintf("n=%d", size), func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + Fibonacci(size) + } + }) + } +} +``` + +For Go 1.24+, new benchmarks should use `b.Loop()`. Use legacy `b.N` loops only when the module targets Go <1.24 or when preserving old benchmark code intentionally. + +### Go 1.26+: test artifacts + +When a test, benchmark, or fuzz target needs to persist files for inspection, use `ArtifactDir()` instead of ad-hoc paths or repo-local output. + +```go +func TestRenderGoldenArtifact(t *testing.T) { + dir := t.ArtifactDir() + + out := filepath.Join(dir, "rendered.json") + if err := os.WriteFile(out, renderedBytes, 0o644); err != nil { + t.Fatal(err) + } + + t.Logf("artifact written: %s", out) +} +``` + +Available on `*testing.T`, `*testing.B`, and `*testing.F` in Go 1.26+. + +## Parallel Tests + +Use `t.Parallel()` to run tests concurrently: + +```go +func TestParallelOperations(t *testing.T) { + tests := []struct { + name string + data []byte + }{ + {"small data", make([]byte, 1024)}, + {"medium data", make([]byte, 1024*1024)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + result := Process(tt.data) + is.NotNil(result) + }) + } +} +``` + +## Fuzzing + +Use fuzzing to find edge cases and bugs: + +```go +func FuzzReverse(f *testing.F) { + f.Add("hello") + f.Add("") + f.Add("a") + + f.Fuzz(func(t *testing.T, input string) { + reversed := Reverse(input) + doubleReversed := Reverse(reversed) + if input != doubleReversed { + t.Errorf("Reverse(Reverse(%q)) = %q, want %q", input, doubleReversed, input) + } + }) +} +``` + +## Examples as Documentation + +Examples are executable documentation verified by `go test`: + +```go +func ExampleCalculatePrice() { + price := CalculatePrice(100, 10.0) + fmt.Printf("Price: %.2f\n", price) + // Output: Price: 900.00 +} + +func ExampleCalculatePrice_singleItem() { + price := CalculatePrice(1, 25.50) + fmt.Printf("Price: %.2f\n", price) + // Output: Price: 25.50 +} +``` + +## Code Coverage + +```bash +# Generate coverage file +go test -coverprofile=coverage.out ./... + +# View coverage in HTML +go tool cover -html=coverage.out + +# Coverage by function +go tool cover -func=coverage.out + +# Total coverage percentage +go tool cover -func=coverage.out | grep total +``` + +## Integration Tests + +Use build tags to separate integration tests from unit tests: + +```go +//go:build integration + +package mypackage + +func TestDatabaseIntegration(t *testing.T) { + db, err := sql.Open("postgres", os.Getenv("DATABASE_URL")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // Test real database operations +} +``` + +Run integration tests separately: + +```bash +go test -tags=integration ./... +``` + +For Docker Compose fixtures, SQL schemas, and integration test suites, see [Integration Testing](./references/integration-testing.md). + +## Mocking + +Mock interfaces, not concrete types. Define interfaces where consumed, then create mock implementations. + +For mock patterns, test fixtures, and time mocking, see [Mocking](./references/mocking.md). + +## Enforce with Linters + +Many test best practices are enforced automatically by linters: `thelper`, `paralleltest`, `testifylint`. See the `samber/cc-skills-golang@golang-lint` skill for configuration and usage. + +## Cross-References + +- -> See `samber/cc-skills-golang@golang-stretchr-testify` skill for detailed testify API (assert, require, mock, suite) +- -> See `samber/cc-skills-golang@golang-database` skill (testing.md) for database integration test patterns +- -> See `samber/cc-skills-golang@golang-concurrency` skill for goroutine leak detection with goleak +- -> See `samber/cc-skills-golang@golang-continuous-integration` skill for CI test configuration and GitHub Actions workflows +- -> See `samber/cc-skills-golang@golang-lint` skill for testifylint and paralleltest configuration +- -> See `samber/cc-skills-golang@golang-continuous-integration` skill for automated AI-driven code review in CI using these guidelines + +## Quick Reference + +```bash +go test ./... # all tests +go test -run TestName ./... # specific test by exact name +go test -run TestName/subtest ./... # subtests within a test +go test -run 'Test(Add|Sub)' ./... # multiple tests (regexp OR) +go test -run 'Test[A-Z]' ./... # tests starting with capital letter +go test -run 'TestUser.*' ./... # tests matching prefix +go test -run '.*Validation.*' ./... # tests containing substring +go test -run TestName/. ./... # all subtests of TestName +go test -run '/(unit|integration)' ./... # filter by subtest name +go test -race ./... # race detection +go test -cover ./... # coverage summary +go test -bench=. -benchmem ./... # benchmarks +go test -fuzz=FuzzName ./... # fuzzing +go test -tags=integration ./... # integration tests +``` diff --git a/.agents/skills/golang-testing/evals/evals.json b/.agents/skills/golang-testing/evals/evals.json new file mode 100644 index 0000000..9b80e02 --- /dev/null +++ b/.agents/skills/golang-testing/evals/evals.json @@ -0,0 +1,388 @@ +[ + { + "id": 1, + "name": "goleak-goroutine-leak-detection", + "description": "Tests use goleak for goroutine leak detection, not just task completion", + "prompt": "Write tests for a `workerpool` package. The package has a `Pool` struct with `Start(numWorkers int)`, `Submit(task func())`, and `Stop()` methods. Start spawns goroutines, Submit enqueues work, Stop shuts down gracefully. Write comprehensive unit tests covering start, submit tasks, and stop.", + "trap": "Model writes normal unit tests verifying task completion but omits goroutine leak detection β€” Stop() may appear to work while leaking goroutines", + "assertions": [ + { + "id": "1.1", + "text": "Uses goleak (go.uber.org/goleak) β€” either goleak.VerifyTestMain in TestMain or goleak.VerifyNone per-test β€” to detect goroutine leaks from the worker pool" + }, + { + "id": "1.2", + "text": "Has a TestMain function if using goleak.VerifyTestMain (the package-level approach)" + }, + { + "id": "1.3", + "text": "Tests verify that Stop() properly cleans up goroutines (not just that tasks complete)" + }, + { + "id": "1.4", + "text": "Imports go.uber.org/goleak" + } + ] + }, + { + "id": 2, + "name": "integration-build-tag-not-testing-short", + "description": "Integration tests use //go:build integration tag; testing.Short() is not an acceptable alternative", + "prompt": "Our team disagrees on how to separate integration tests from unit tests in our Go project. A teammate proposes:\n\n```go\nfunc TestUserRepository_Create(t *testing.T) {\n if testing.Short() {\n t.Skip(\"skipping integration test\")\n }\n db := connectToPostgres(t)\n // ... test ...\n}\n```\n\nThey argue: 'testing.Short() is the Go standard way β€” it's in the stdlib, you can configure it with -short, and every Go developer knows it. Build tags are extra complexity for no benefit.'\n\nHow should integration tests be separated? Is the teammate's approach correct? Write the correct implementation for a TestUserRepository_Create integration test.", + "trap": "Model accepts testing.Short() as a valid approach because it's a stdlib feature and the teammate's argument sounds reasonable. The skill teaches: build tags are required because testing.Short() still compiles tests into the binary, requires a flag to skip, and leaks DB connection attempts into normal test runs.", + "assertions": [ + { + "id": "2.1", + "text": "Rejects testing.Short() as the primary separation mechanism β€” does not accept the teammate's approach as correct" + }, + { + "id": "2.2", + "text": "Uses `//go:build integration` build tag (at the file level, before the package declaration)" + }, + { + "id": "2.3", + "text": "Explains why build tags are preferred: tests using testing.Short() still compile and attempt connections when running without -short, whereas build-tagged files are completely excluded from compilation" + }, + { + "id": "2.4", + "text": "Includes the command to run integration tests: go test -tags=integration ./..." + } + ] + }, + { + "id": 3, + "name": "parallel-subtests-pure-function", + "description": "Pure function subtests call t.Parallel(); top-level test also parallel", + "prompt": "Write table-driven tests for a pure function `Slugify(input string) string` that converts titles to URL-friendly slugs (lowercase, hyphens for spaces, strips special chars). Test at least 6 cases: normal title, unicode, multiple spaces, empty string, already-slugified input, and special characters only.", + "trap": "Model omits t.Parallel() since the function is pure and 'already fast enough', missing parallelism opportunities for stateless tests", + "assertions": [ + { + "id": "3.1", + "text": "Subtests call t.Parallel() β€” these are independent pure function tests with no shared mutable state" + }, + { + "id": "3.2", + "text": "Top-level test function also calls t.Parallel()" + }, + { + "id": "3.3", + "text": "Each test case has a descriptive `name` field used in t.Run" + }, + { + "id": "3.4", + "text": "At least 6 test cases as requested" + }, + { + "id": "3.5", + "text": "No shared mutable state between subtests (each subtest captures its own test case variable)" + } + ] + }, + { + "id": 4, + "name": "fake-clock-injection-for-time-dependent-tests", + "description": "Time-dependent code must accept a clock interface so tests can use clockwork.FakeClock; real time.Sleep is unacceptable", + "prompt": "Here is an existing RateLimiter implementation:\n\n```go\ntype RateLimiter struct {\n limit int\n window time.Duration\n count int\n resetAt time.Time\n}\n\nfunc NewRateLimiter(limit int, window time.Duration) *RateLimiter {\n return &RateLimiter{\n limit: limit,\n window: window,\n resetAt: time.Now().Add(window),\n }\n}\n\nfunc (r *RateLimiter) Allow() bool {\n now := time.Now()\n if now.After(r.resetAt) {\n r.count = 0\n r.resetAt = now.Add(r.window)\n }\n if r.count >= r.limit {\n return false\n }\n r.count++\n return true\n}\n```\n\nWrite tests that verify:\n1. Allow() returns true while under the limit\n2. Allow() returns false when the limit is exceeded\n3. The counter resets after the time window expires\n\nThe tests must run in milliseconds, not seconds. You may modify the implementation if needed.", + "trap": "Model uses time.Sleep(window + small margin) to test window expiration β€” the code uses time.Now() directly, making tests slow and flaky. The skill teaches to refactor the code to accept a clock interface (clockwork.Clock) and inject a FakeClock in tests.", + "assertions": [ + { + "id": "4.1", + "text": "Modifies the RateLimiter to accept a clock interface (e.g., clockwork.Clock or a custom Now() func) rather than calling time.Now() directly" + }, + { + "id": "4.2", + "text": "Uses clockwork.FakeClock (or equivalent) in tests to advance time without real sleeping β€” tests run in microseconds" + }, + { + "id": "4.3", + "text": "Tests the window reset scenario by advancing the fake clock past the window duration (e.g., fakeClock.Advance(window + time.Millisecond))" + }, + { + "id": "4.4", + "text": "No real-time time.Sleep in test code; use synctest.Test/synctest.Wait or a fake clock for deterministic synthetic time" + } + ] + }, + { + "id": 5, + "name": "consumer-site-interface-mocking", + "description": "Tests define interfaces at the consumer site and mock those, not concrete structs", + "prompt": "Test a `NotificationService` struct that has a `NotifyUser(userID string) error` method. It depends on two concrete structs: `SMTPClient` (with `Send(to, subject, body string) error`) and `AuditLogger` (with `Log(event string) error`). NotifyUser looks up the user's email, sends an email via SMTPClient, and logs the event via AuditLogger. Write comprehensive tests for NotifyUser.", + "trap": "Model embeds or wraps concrete SMTPClient/AuditLogger in mock structs, or creates test doubles that shadow the concrete types, instead of extracting consumer-site interfaces", + "assertions": [ + { + "id": "5.1", + "text": "Defines interfaces for the dependencies (e.g., EmailSender, Logger) rather than using the concrete SMTPClient/AuditLogger structs directly in tests" + }, + { + "id": "5.2", + "text": "Creates mock implementations of these interfaces (using testify/mock or manual mocks)" + }, + { + "id": "5.3", + "text": "Does NOT embed or wrap the concrete SMTPClient/AuditLogger structs in mock objects" + }, + { + "id": "5.4", + "text": "Uses dependency injection β€” NotificationService accepts interfaces, not concrete types" + }, + { + "id": "5.5", + "text": "Tests verify both happy path (send succeeds) and error scenarios (send fails, log fails)" + } + ] + }, + { + "id": 6, + "name": "test-observable-behavior-not-internals", + "description": "Tests verify behavior via public API only, not by inspecting the internal map field", + "prompt": "Test a `UserCache` struct with `Get(id string) (*User, bool)`, `Set(id string, user *User)`, and `Len() int` methods. The cache has an internal `data map[string]*User` field. Write tests that verify the cache stores and retrieves users correctly. Make sure to verify the internal map state is consistent after each operation.", + "trap": "Model directly accesses the internal data map field to verify state, coupling tests to implementation details", + "assertions": [ + { + "id": "6.1", + "text": "Tests observable behavior through Get/Set/Len public API only" + }, + { + "id": "6.2", + "text": "Does NOT directly access or inspect the internal `data` map field" + }, + { + "id": "6.3", + "text": "Does NOT use same-package (white-box) testing to examine cache internals" + }, + { + "id": "6.4", + "text": "Tests cover cache hit, cache miss, overwrite, and Len() correctness" + } + ] + }, + { + "id": 7, + "name": "external-test-package-resists-internal-access", + "description": "Tests use package_test to black-box test; does not access unexported state even when a teammate suggests it", + "prompt": "Write tests for a `tokenizer` package that exports `Tokenize(input string) []Token` and a `Token` struct with `Type` and `Value` fields. The package has an unexported `tokenCache map[string][]Token` field on the package level for memoization.\n\nA teammate says: 'We should verify the cache is being populated correctly β€” access tokenCache directly in the tests. It's in the same package so it's fine, and it makes the tests more reliable since we can confirm memoization is working.'\n\nWrite the tests. Should you follow the teammate's suggestion?", + "trap": "Model follows the teammate's suggestion and uses package tokenizer (white-box) to access tokenCache directly, reasoning that it makes tests more reliable. The skill teaches to use package tokenizer_test and test only observable behavior β€” cache behavior is an implementation detail, not a contract.", + "assertions": [ + { + "id": "7.1", + "text": "Rejects the teammate's suggestion β€” does not access tokenCache directly in tests" + }, + { + "id": "7.2", + "text": "Uses `package tokenizer_test` (external test package) instead of `package tokenizer`" + }, + { + "id": "7.3", + "text": "Explains why: cache behavior is an implementation detail β€” tests should verify the observable contract (same input β†’ same output), not whether a specific optimization is in place" + }, + { + "id": "7.4", + "text": "Tests focus on functional correctness: same input tokenizes identically on repeated calls, edge cases, different token types" + } + ] + }, + { + "id": 8, + "name": "example-functions-as-documentation", + "description": "Packages need Example functions with // Output: comments that serve as executable documentation in go doc", + "prompt": "I've written a `currency` package with these functions:\n\n```go\nfunc Format(cents int64, code string) string // Format(1234, \"USD\") β†’ \"$12.34\"\nfunc Parse(s string) (int64, string, error) // Parse(\"$12.34\") β†’ 1234, \"USD\", nil\nfunc Convert(cents int64, from, to string, rate float64) int64\n```\n\nA colleague says: 'The functions are self-explanatory β€” names and signatures are clear enough. We don't need extra documentation. Just write unit tests with good coverage.'\n\nWrite comprehensive test coverage for this package. Should you follow the colleague's advice about documentation?", + "trap": "Model follows the colleague's advice and writes only table-driven unit tests without Example functions β€” missing the executable documentation that shows real usage in `go doc` and on pkg.go.dev. The skill teaches that Example functions serve as both tests and documentation.", + "assertions": [ + { + "id": "8.1", + "text": "Disagrees with the colleague β€” includes Example functions despite the advice to skip them" + }, + { + "id": "8.2", + "text": "Includes at least one Example function (ExampleFormat, ExampleParse, or ExampleConvert)" + }, + { + "id": "8.3", + "text": "Example functions have `// Output:` comments so they are verified by go test" + }, + { + "id": "8.4", + "text": "Explains that Example functions serve as executable documentation visible in go doc and pkg.go.dev β€” not just tests" + } + ] + }, + { + "id": 9, + "name": "fuzz-test-for-critical-functions", + "description": "Security-critical functions get fuzz tests with seed corpus and property assertions", + "prompt": "Write tests for a `SanitizeHTML(input string) string` function that strips all HTML tags from input while preserving text content. Make sure to test edge cases thoroughly β€” this function is critical for security.", + "trap": "Model writes only table-driven tests for known edge cases, missing the fuzz test that would discover unexpected inputs causing XSS vulnerabilities", + "assertions": [ + { + "id": "9.1", + "text": "Includes a fuzz test function (FuzzSanitizeHTML or similar)" + }, + { + "id": "9.2", + "text": "Fuzz test uses f.Add() to provide seed corpus entries" + }, + { + "id": "9.3", + "text": "Fuzz test includes property-based assertions (e.g., output contains no < or > characters, or double-sanitize is idempotent)" + }, + { + "id": "9.4", + "text": "Also includes regular table-driven tests for known edge cases" + }, + { + "id": "9.5", + "text": "Table tests cover tricky cases like nested tags, unclosed tags, or script tags" + } + ] + }, + { + "id": 10, + "name": "test-helper-t-helper-attribution", + "description": "Test helpers must call t.Helper() so failures point to the caller's line, not the helper's internal line", + "prompt": "I have this test helper and some tests using it:\n\n```go\nfunc requireNoError(t *testing.T, err error, msg string) {\n if err != nil {\n t.Fatalf(\"%s: unexpected error: %v\", msg, err)\n }\n}\n\nfunc TestProcessOrder(t *testing.T) {\n order := NewOrder(\"prod-1\", 2)\n err := order.Validate()\n requireNoError(t, err, \"validate\")\n\n err = order.Submit()\n requireNoError(t, err, \"submit\")\n}\n```\n\nWhen Validate() fails, the test output reports a failure at the `t.Fatalf` line inside `requireNoError`, not at the `requireNoError(t, err, \"validate\")` call site in `TestProcessOrder`. Is this a problem? How do you fix it?", + "trap": "Model says this is expected behavior or suggests switching to t.Error() instead of the real fix. The skill teaches that t.Helper() must be called as the first statement in the helper so Go's test framework reports failures at the caller's line.", + "assertions": [ + { + "id": "10.1", + "text": "Identifies this as a real problem β€” the line number pointing to the helper's internal Fatalf is unhelpful for debugging which call caused the failure" + }, + { + "id": "10.2", + "text": "Fixes it by adding t.Helper() as the first statement in requireNoError β€” not by restructuring the helper or using a different assertion method" + }, + { + "id": "10.3", + "text": "Explains that t.Helper() marks the function as a test helper so that the testing framework reports the caller's file:line instead of the helper's file:line" + }, + { + "id": "10.4", + "text": "Does NOT suggest switching to t.Error() as the fix β€” t.Helper() is the correct solution regardless of t.Fatal vs t.Error" + } + ] + }, + { + "id": 11, + "name": "httptest-recorder-not-real-server", + "description": "HTTP handler tests use httptest.NewRecorder, not a real HTTP server", + "prompt": "Write end-to-end tests for a REST API handler `HandleCreateOrder(w http.ResponseWriter, r *http.Request)` that accepts POST with JSON body `{\"product\": \"...\", \"quantity\": N}`. It returns 201 with the order JSON on success, 400 for invalid JSON, and 422 for validation errors (empty product, quantity <= 0). Test it like a real client would call it.", + "trap": "Model starts a real HTTP server with httptest.NewServer or net/http ListenAndServe, adding unnecessary network overhead and port allocation to tests", + "assertions": [ + { + "id": "11.1", + "text": "Uses httptest.NewRecorder (not httptest.NewServer or a real HTTP server)" + }, + { + "id": "11.2", + "text": "Table-driven with named test cases covering multiple scenarios" + }, + { + "id": "11.3", + "text": "Tests at least 3 status codes (201, 400, 422)" + }, + { + "id": "11.4", + "text": "Verifies response body content (not just status code)" + }, + { + "id": "11.5", + "text": "Sets proper Content-Type header on requests" + } + ] + }, + { + "id": 12, + "name": "testify-suite-for-integration", + "description": "Integration tests use testify/suite with SetupSuite/TearDownTest for organized setup/teardown", + "prompt": "Write integration tests for an `OrderRepository` that interacts with PostgreSQL. It has `Create(order *Order) error`, `GetByID(id string) (*Order, error)`, and `ListByUserID(userID string) ([]*Order, error)`. Tests need database setup (create tables), per-test data cleanup, and graceful teardown. Organize them cleanly so setup/teardown happens automatically. These must not run during normal unit tests.", + "trap": "Model uses TestMain or plain setup functions with defer for teardown, mixing setup concerns into each test instead of a suite", + "assertions": [ + { + "id": "12.1", + "text": "Uses testify/suite.Suite struct embedding for test organization" + }, + { + "id": "12.2", + "text": "Has SetupSuite (or similar) for one-time database connection and schema setup" + }, + { + "id": "12.3", + "text": "Has SetupTest or TearDownTest for per-test data cleanup (e.g., TRUNCATE)" + }, + { + "id": "12.4", + "text": "Has TearDownSuite for graceful shutdown (close DB, docker-compose down)" + }, + { + "id": "12.5", + "text": "Uses `//go:build integration` build tag" + }, + { + "id": "12.6", + "text": "Has a runner function `func TestXxx(t *testing.T) { suite.Run(t, ...) }`" + } + ] + }, + { + "id": 13, + "name": "benchmark-report-allocs-and-input-sizes", + "description": "Benchmarks use b.ReportAllocs(), test multiple input sizes, and follow naming conventions", + "prompt": "Write benchmarks for a `Compress(data []byte) ([]byte, error)` function that compresses byte slices. We need to measure performance to decide if this is fast enough for our hot path. Just write the benchmark tests.", + "trap": "Model writes a single benchmark with one input size and omits b.ReportAllocs(), missing allocation tracking and size-scaling analysis", + "assertions": [ + { + "id": "13.1", + "text": "Calls b.ReportAllocs() to track memory allocations per operation" + }, + { + "id": "13.2", + "text": "Tests multiple input sizes using b.Run with descriptive sub-benchmark names (e.g., size=1KB, size=1MB)" + }, + { + "id": "13.3", + "text": "Uses b.Loop() for Go 1.24+ benchmark loops; uses legacy b.N only for older module targets" + }, + { + "id": "13.4", + "text": "Follows benchmark naming convention: BenchmarkCompress or BenchmarkCompress_" + }, + { + "id": "13.5", + "text": "Prevents compiler optimization of the result (assigns to a package-level variable or uses _ =)" + }, + { + "id": "13.6", + "text": "Does NOT include setup/allocation costs inside the timed loop (or uses b.ResetTimer if setup is needed)" + } + ] + }, + { + "id": 14, + "name": "race-detection-and-test-independence", + "description": "Tests for concurrent code include -race flag guidance and ensure test independence (no order dependence)", + "prompt": "Write tests for a `SafeMap[K comparable, V any]` struct that provides a goroutine-safe map with `Get(key K) (V, bool)`, `Set(key K, value V)`, `Delete(key K)`, and `Len() int` methods. Multiple goroutines will call these concurrently. Write thorough tests including concurrent access scenarios. Also include a note on how to run these tests in CI.", + "trap": "Model writes concurrent tests but omits -race flag guidance for CI and doesn't ensure tests are independently runnable (e.g., shares map state between test functions)", + "assertions": [ + { + "id": "14.1", + "text": "Includes concurrent test scenarios where multiple goroutines call Get/Set/Delete simultaneously" + }, + { + "id": "14.2", + "text": "Recommends running with -race flag (go test -race) for CI or includes it in a run command comment" + }, + { + "id": "14.3", + "text": "Each test function creates its own SafeMap instance β€” no shared state between test functions" + }, + { + "id": "14.4", + "text": "Uses sync.WaitGroup or similar synchronization to coordinate concurrent test goroutines" + }, + { + "id": "14.5", + "text": "Tests are independently runnable (any single test can pass when run in isolation with -run)" + } + ] + } +] diff --git a/.agents/skills/golang-testing/references/helpers.md b/.agents/skills/golang-testing/references/helpers.md new file mode 100644 index 0000000..3c04877 --- /dev/null +++ b/.agents/skills/golang-testing/references/helpers.md @@ -0,0 +1,42 @@ +# Test Helpers + +## Test Timeout + +For tests that may hang, use a timeout helper that panics with caller location: + +```go +// https://github.com/stretchr/testify/issues/1101 +func testWithTimeout(t *testing.T, timeout time.Duration) { + t.Helper() + + testFinished := make(chan struct{}) + t.Cleanup(func() { + close(testFinished) + }) + + var pc [1]uintptr + n := runtime.Callers(2, pc[:]) + line, funcName := "", "" + if n > 0 { + frames := runtime.CallersFrames(pc[:]) + frame, _ := frames.Next() + line = frame.File + ":" + strconv.Itoa(frame.Line) + funcName = frame.Function + } + + go func() { + select { + case <-testFinished: + case <-time.After(timeout): + panic(fmt.Sprintf("%s: Test timed out after: %v\n%s", funcName, timeout, line)) + } + }() +} + +// Usage +func TestLongRunningOperation(t *testing.T) { + testWithTimeout(t, 2*time.Second) + result := LongRunningOperation() + // If this takes longer than 2 seconds, the test panics with location info +} +``` diff --git a/.agents/skills/golang-testing/references/http-testing.md b/.agents/skills/golang-testing/references/http-testing.md new file mode 100644 index 0000000..a7ff122 --- /dev/null +++ b/.agents/skills/golang-testing/references/http-testing.md @@ -0,0 +1,84 @@ +# HTTP Handler Testing + +Use `httptest` package for testing HTTP handlers without starting a server. + +## Basic Handler Test + +```go +func TestCreateUserHandler(t *testing.T) { + tests := []struct { + name string + body string + expectedStatus int + }{ + { + name: "valid request", + body: `{"name": "Alice", "email": "alice@example.com"}`, + expectedStatus: http.StatusCreated, + }, + { + name: "invalid JSON", + body: `invalid json`, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + is := assert.New(t) + + req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + handler := http.HandlerFunc(CreateUserHandler) + handler.ServeHTTP(w, req) + + is.Equal(tt.expectedStatus, w.Code) + }) + } +} +``` + +## Query Parameters and Headers + +```go +func TestListUsersHandler(t *testing.T) { + tests := []struct { + name string + query string + authHeader string + expectedStatus int + }{ + { + name: "paginated results", + query: "?page=1&limit=10", + authHeader: "Bearer token123", + expectedStatus: http.StatusOK, + }, + { + name: "missing auth", + query: "?page=1", + authHeader: "", + expectedStatus: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + is := assert.New(t) + + req := httptest.NewRequest(http.MethodGet, "/users"+tt.query, nil) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + + w := httptest.NewRecorder() + handler := AuthMiddleware(ListUsersHandler) + handler.ServeHTTP(w, req) + + is.Equal(tt.expectedStatus, w.Code) + }) + } +} +``` diff --git a/.agents/skills/golang-testing/references/integration-testing.md b/.agents/skills/golang-testing/references/integration-testing.md new file mode 100644 index 0000000..ef2cadf --- /dev/null +++ b/.agents/skills/golang-testing/references/integration-testing.md @@ -0,0 +1,187 @@ +# Integration Testing + +## Docker Compose Fixture + +Create `pkg/myfeature/testdata/docker-compose.yml` for test services: + +```yaml +version: "3.8" +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: testdb + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U test"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 +``` + +## SQL Schema Fixture + +Create `pkg/myfeature/testdata/schema.sql` for database initialization: + +```sql +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + amount DECIMAL(10,2) NOT NULL, + status VARCHAR(50) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +## Test Data Fixture + +Create `pkg/myfeature/testdata/testdata.sql`: + +```sql +INSERT INTO users (name, email) VALUES + ('Alice Johnson', 'alice@example.com'), + ('Bob Smith', 'bob@example.com'), + ('Charlie Brown', 'charlie@example.com'); + +INSERT INTO orders (user_id, amount, status) VALUES + (1, 100.00, 'completed'), + (1, 50.00, 'pending'), + (2, 200.00, 'completed'); +``` + +## Using Fixtures in Tests + +```go +//go:build integration + +package database_test + +import ( + "database/sql" + "os" + "os/exec" + "testing" + "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type DatabaseTestSuite struct { + suite.Suite + db *sql.DB +} + +func (s *DatabaseTestSuite) SetupSuite() { + cmd := exec.Command("docker-compose", "-f", "testdata/docker-compose.yml", "up", "-d") + if err := cmd.Run(); err != nil { + s.T().Fatalf("failed to start docker-compose: %v", err) + } + + time.Sleep(5 * time.Second) + + db, err := sql.Open("postgres", "postgres://test:test@localhost:5433/testdb?sslmode=disable") + if err != nil { + s.T().Fatalf("failed to connect to database: %v", err) + } + s.db = db + + schema, _ := os.ReadFile("testdata/schema.sql") + _, err = db.Exec(string(schema)) + if err != nil { + s.T().Fatalf("failed to run schema: %v", err) + } +} + +func (s *DatabaseTestSuite) TearDownSuite() { + cmd := exec.Command("docker-compose", "-f", "testdata/docker-compose.yml", "down", "-v") + _ = cmd.Run() +} + +func (s *DatabaseTestSuite) SetupTest() { + _, err := s.db.Exec("TRUNCATE TABLE orders, users CASCADE") + if err != nil { + s.T().Fatalf("failed to clear database: %v", err) + } + + testdata, _ := os.ReadFile("testdata/testdata.sql") + _, err = s.db.Exec(string(testdata)) + if err != nil { + s.T().Fatalf("failed to load test data: %v", err) + } +} + +func (s *DatabaseTestSuite) TestUserCount() { + is := assert.New(s.T()) + + var count int + err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + is.NoError(err) + is.Equal(3, count) +} + +func (s *DatabaseTestSuite) TestOrderSum() { + is := assert.New(s.T()) + + var sum float64 + err := s.db.QueryRow("SELECT SUM(amount) FROM orders").Scan(&sum) + is.NoError(err) + is.InDelta(350.0, sum, 0.01) +} + +func TestDatabaseTestSuite(t *testing.T) { + suite.Run(t, new(DatabaseTestSuite)) +} +``` + +## Test Helper with Embedded Fixtures + +```go +package myfeature + +import ( + "database/sql" + "embed" +) + +//go:embed testdata/schema.sql testdata/testdata.sql +var fixtures embed.FS + +func SetupDB(db *sql.DB) error { + schema, err := fixtures.ReadFile("testdata/schema.sql") + if err != nil { + return err + } + if _, err := db.Exec(string(schema)); err != nil { + return err + } + + data, err := fixtures.ReadFile("testdata/testdata.sql") + if err != nil { + return err + } + if _, err := db.Exec(string(data)); err != nil { + return err + } + return nil +} +``` diff --git a/.agents/skills/golang-testing/references/mocking.md b/.agents/skills/golang-testing/references/mocking.md new file mode 100644 index 0000000..1b6e837 --- /dev/null +++ b/.agents/skills/golang-testing/references/mocking.md @@ -0,0 +1,206 @@ +# Mocking and Test Fixtures + +## Mocks with testify/mock + +Create interfaces for your dependencies, then mock them. + +> For the full testify/mock API (argument matchers, call modifiers, verification), see the `samber/cc-skills-golang@golang-stretchr-testify` skill. + +```go +// Define the interface +type Database interface { + GetUser(id string) (*User, error) + CreateUser(user *User) error +} + +// Mock implementation +type MockDatabase struct { + mock.Mock +} + +func (m *MockDatabase) GetUser(id string) (*User, error) { + args := m.Called(id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*User), args.Error(1) +} + +func (m *MockDatabase) CreateUser(user *User) error { + args := m.Called(user) + return args.Error(0) +} + +// Usage in tests +func TestService_GetUser(t *testing.T) { + is := assert.New(t) + + mockDB := new(MockDatabase) + service := NewService(mockDB) + + expectedUser := &User{ID: "1", Name: "John"} + mockDB.On("GetUser", "1").Return(expectedUser, nil) + + user, err := service.GetUser("1") + + is.NoError(err) + is.Equal(expectedUser, user) + mockDB.AssertExpectations(t) +} + +func TestService_GetUser_NotFound(t *testing.T) { + is := assert.New(t) + + mockDB := new(MockDatabase) + service := NewService(mockDB) + + mockDB.On("GetUser", "999").Return(nil, ErrNotFound) + + user, err := service.GetUser("999") + + is.Error(err) + is.ErrorIs(err, ErrNotFound) + is.Nil(user) + mockDB.AssertExpectations(t) +} +``` + +## Mock Organization + +For larger codebases, organize mocks alongside the code they mock: + +```go +// user_service.go +type UserService struct { + db Database + email EmailService +} +type Database interface { + GetUser(id string) (*User, error) + CreateUser(user *User) error +} +type EmailService interface { + SendWelcomeEmail(to string) error +} +``` + +```go +// user_service_test.go +package mypackage_test + +import ( + "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "path/to/mypackage" +) + +// MockDatabase implements mypackage.Database +type MockDatabase struct { + mock.Mock +} +func (m *MockDatabase) GetUser(id string) (*mypackage.User, error) { + args := m.Called(id) + if args.Get(0) == nil { return nil, args.Error(1) } + return args.Get(0).(*mypackage.User), args.Error(1) +} +func (m *MockDatabase) CreateUser(user *mypackage.User) error { + return m.Called(user).Error(0) +} + +// MockEmailService implements mypackage.EmailService +type MockEmailService struct { + mock.Mock +} +func (m *MockEmailService) SendWelcomeEmail(to string) error { + return m.Called(to).Error(0) +} + +func TestUserService_CreateUser(t *testing.T) { + mockDB := new(MockDatabase) + mockEmail := new(MockEmailService) + service := mypackage.NewUserService(mockDB, mockEmail) + + user := &mypackage.User{Name: "Test", Email: "test@example.com"} + mockDB.On("CreateUser", user).Return(nil) + mockEmail.On("SendWelcomeEmail", "test@example.com").Return(nil) + + err := service.CreateUser(user) + + assert.NoError(t, err) + mockDB.AssertExpectations(t) + mockEmail.AssertExpectations(t) +} +``` + +## Test Fixtures + +Create reusable test data in a separate package or file: + +```go +package fixtures + +import "time" + +var ( + DefaultUser = &User{ + ID: "user-123", + Name: "Jane Doe", + Email: "jane@example.com", + CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + } + + AdminUser = &User{ + ID: "admin-1", + Name: "Admin User", + Email: "admin@example.com", + Role: "admin", + CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + } +) + +func NewUser(name, email string) *User { + return &User{ + ID: "user-" + uuid.New().String(), + Name: name, + Email: email, + CreatedAt: time.Now(), + } +} +``` + +## Time Mocking + +Use `clockwork` to test time-dependent code without `time.Sleep()`: + +```go +import ( + "testing" + "time" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" +) + +func TestScheduler_AddJob(t *testing.T) { + is := assert.New(t) + + fakeClock := clockwork.NewFakeClock() + scheduler := NewScheduler(fakeClock) + + job := &Job{ID: "1", RunAt: time.Now().Add(1 * time.Hour)} + scheduler.AddJob(job) + + is.Equal(1, scheduler.PendingCount()) + + // Advance fake time + fakeClock.Advance(2 * time.Hour) + + is.Equal(0, scheduler.PendingCount()) +} +``` + +Install clockwork: + +```bash +go get github.com/jonboulle/clockwork +``` diff --git a/.agents/skills/skillrig-init/SKILL.md b/.agents/skills/skillrig-init/SKILL.md new file mode 100644 index 0000000..8966efc --- /dev/null +++ b/.agents/skills/skillrig-init/SKILL.md @@ -0,0 +1,117 @@ +--- +name: skillrig-init +description: >- + Bind a repository (or your per-user default) to a skill origin with `skillrig init`, + and understand how skillrig resolves the active origin. Use when the user wants to + "point this repo at our skills library", "set the origin", configure where skills come + from, set up `skillrig` in a repo, choose between a project vs global default origin, + use the `SKILLRIG_ORIGIN` environment variable, or fix a "no origin configured" / + "no origin given" error. Triggers on `skillrig init`, origin binding (OWNER/REPO), + and origin-resolution precedence questions. +license: MIT +metadata: + author: skillrig + cli: skillrig + user-invocable: true +--- + +# skillrig-init Skill + +**When to Load**: The user wants to point a repository at an existing skills origin +(`OWNER/REPO`), set a personal default, configure `SKILLRIG_ORIGIN`, or resolve a +"no origin configured" failure β€” or whenever `skillrig init` is referenced. + +## Overview + +`skillrig init` is an **Environment-pattern** command: it records an *existing* origin +(the `OWNER/REPO` that hosts your team's agent skills) into config so every later +`skillrig` command knows where skills come from. It is **idempotent** and +**consume-only** β€” it never creates or scaffolds an origin, never reaches the network, +and binding the same origin twice is a no-op. + +It writes one of two config files: + +- **Project** (default): `.skillrig/config.toml` at the **git repository root** (located + via an offline `git rev-parse --show-toplevel`), so a repo has one canonical config + regardless of which subdirectory you run from. Outside a git repo it falls back to + `./.skillrig/config.toml` in the current directory. +- **Global** (`--global`): the per-user default at `$XDG_CONFIG_HOME/skillrig/config.toml` + or `~/.config/skillrig/config.toml`, used when a repo has no origin of its own. + +`git` must be on `PATH` (used only for the offline repo-root lookup). + +## Command Surface + +| Flag | Purpose | When to use | +|------|---------|-------------| +| `--origin OWNER/REPO` | The origin to bind | Always prefer passing it explicitly (scripts/agents) | +| `--global` | Write the per-user default instead of the repo config | Setting a fallback used across all your repos | +| `--non-interactive` | Never prompt; fail fast if `--origin` is missing | CI/agents that must not block on input | +| `--json` | Emit a complete result object on stdout | Machine consumption | +| `--verbose` | Show underlying paths / raw cause behind summaries and errors | Debugging a failure | + +## Decision Criteria + +- **Project vs global**: bind the repo (no `--global`) so the repo is self-describing + and teammates resolve the same origin. Use `--global` only for a personal fallback. +- **`--origin` vs prompt**: always pass `--origin` in scripts/agents. The interactive + prompt appears only on a real terminal when `--origin` is omitted. +- **`--non-interactive`**: set it in any automated context. It forces fail-fast even on + a TTY, so the command never hangs waiting for input. +- **`SKILLRIG_ORIGIN`**: prefer this env var for one-off overrides (e.g. CI) β€” it beats + both config files without editing anything on disk. + +## Resolution Precedence + +Every command resolves the active origin with one rule (highest wins): + +``` +SKILLRIG_ORIGIN > project .skillrig/config.toml (nearest ancestor) > global config +``` + +- A blank/whitespace `SKILLRIG_ORIGIN` is treated as **unset** (falls through). +- A malformed or origin-less config file is **skipped**, and resolution continues down + the order β€” it is not a hard failure. +- When no source supplies an origin, that is the "no origin configured" state the user + must fix (see Error Handling). + +The project lookup walks **up** from the working directory, so any subdirectory of a +bound repo resolves the same origin. + +## JSON Output + +`skillrig init --origin my-org/my-skills --json` emits a single object with all keys +present; branch on `written` to tell a fresh bind from an idempotent no-op: + +```json +{ "ok": true, "origin": "my-org/my-skills", "scope": "project", "configPath": "/abs/.skillrig/config.toml", "written": true } +``` + +`scope` is `project` or `global`; `written` is `false` when the origin was already bound. + +## Workflow Patterns + +1. **Bind a repo**: `skillrig init --origin my-org/my-skills` β†’ run from anywhere in the + repo; config lands at the repo root. +2. **Personal default**: `skillrig init --origin my-org/my-skills --global`. +3. **CI / agent**: pass `--origin` (or set `SKILLRIG_ORIGIN`) **and** `--non-interactive` + so the command never prompts. +4. **One-off override**: `SKILLRIG_ORIGIN=ci-org/ci-skills skillrig ` β€” no file edit. + +## Error Handling + +| Symptom (stderr) | Cause | Fix | +|------------------|-------|-----| +| `invalid origin "": expected OWNER/REPO` | Origin not in `OWNER/REPO` shape | Pass a valid `--origin my-org/my-skills` | +| `no origin given … non-interactive session (no TTY)` | `init` run without `--origin` and stdin is not a terminal | Pass `--origin OWNER/REPO` or set `SKILLRIG_ORIGIN` | +| `no origin given … non-interactive mode requested (--non-interactive)` | `--non-interactive` set but no `--origin` | Pass `--origin OWNER/REPO` or set `SKILLRIG_ORIGIN` | +| "no origin configured" from a later command | No source supplied an origin | Run `skillrig init --origin OWNER/REPO`, or set `SKILLRIG_ORIGIN`, or add a `--global` default | + +All failures exit non-zero (usage/config errors exit `1`); add `--verbose` to see the +raw cause behind the message. + +## Token Efficiency + +Default human output is ≀2 lines (a confirmation plus a one-line resolve-order hint). +Use `--json` only when a program will parse the result; otherwise the compact human form +keeps context small. diff --git a/.agents/skills/skillrig-init/evals/evals.json b/.agents/skills/skillrig-init/evals/evals.json new file mode 100644 index 0000000..c013001 --- /dev/null +++ b/.agents/skills/skillrig-init/evals/evals.json @@ -0,0 +1,37 @@ +[ + { + "id": 1, + "name": "bind-repo-to-origin", + "description": "User asks to point a repo at the team's skills library β€” should reach for `skillrig init --origin OWNER/REPO` (project scope) and explain it lands at the git root.", + "prompt": "I want this repository to use our team's shared agent skills, which live at acme/agent-skills. How do I set that up with skillrig?", + "trap": "Model invents a non-existent command (e.g. `skillrig config set`), suggests editing config.toml by hand instead of `skillrig init`, or claims it scaffolds/creates the origin.", + "assertions": [ + { "id": "1.1", "text": "Recommends `skillrig init --origin acme/agent-skills` (origin in OWNER/REPO form)" }, + { "id": "1.2", "text": "Explains the project config is written at the git repository root (.skillrig/config.toml), not necessarily the current subdirectory" }, + { "id": "1.3", "text": "Does NOT claim init creates/scaffolds the origin β€” it binds an existing one (consume-only)" } + ] + }, + { + "id": 2, + "name": "ci-agent-non-interactive", + "description": "Automated/CI context β€” should pass --origin (or SKILLRIG_ORIGIN) AND --non-interactive so the command never blocks on a prompt.", + "prompt": "I'm running skillrig in CI where there's no terminal. How do I bind the origin so it never hangs waiting for input?", + "trap": "Model relies on TTY auto-detection alone, omits --non-interactive, or suggests piping input to the prompt instead of failing fast.", + "assertions": [ + { "id": "2.1", "text": "Recommends passing --origin OWNER/REPO explicitly (or setting SKILLRIG_ORIGIN)" }, + { "id": "2.2", "text": "Recommends --non-interactive to force fail-fast and never prompt, even if a TTY is present" } + ] + }, + { + "id": 3, + "name": "no-origin-and-precedence", + "description": "User hits a 'no origin configured' error and asks how resolution works β€” should explain SKILLRIG_ORIGIN > project > global and the concrete fixes.", + "prompt": "A skillrig command failed with 'no origin configured'. How does skillrig decide which origin to use, and how do I fix this?", + "trap": "Model gets the precedence order wrong, forgets the SKILLRIG_ORIGIN env override, or omits the --global fallback option.", + "assertions": [ + { "id": "3.1", "text": "States precedence SKILLRIG_ORIGIN > project .skillrig/config.toml > global config (highest wins)" }, + { "id": "3.2", "text": "Offers concrete fixes: run `skillrig init --origin OWNER/REPO`, set SKILLRIG_ORIGIN, or set a --global default" }, + { "id": "3.3", "text": "Notes the project config is found by walking up from the current directory (works from subdirectories)" } + ] + } +] diff --git a/.agents/skills/skillrig-init/evals/trigger-eval-set.json b/.agents/skills/skillrig-init/evals/trigger-eval-set.json new file mode 100644 index 0000000..79615bc --- /dev/null +++ b/.agents/skills/skillrig-init/evals/trigger-eval-set.json @@ -0,0 +1,11 @@ +[ + { "query": "I want this repository to use our team's shared agent skills at acme/agent-skills. How do I set that up?", "should_trigger": true }, + { "query": "How do I point this repo at our skills library with skillrig?", "should_trigger": true }, + { "query": "How do I set the skillrig origin for my project?", "should_trigger": true }, + { "query": "skillrig says 'no origin configured' β€” how do I fix that?", "should_trigger": true }, + { "query": "What's the precedence between SKILLRIG_ORIGIN and the project config file?", "should_trigger": true }, + { "query": "How do I set a personal default skills origin used across all my repos?", "should_trigger": true }, + { "query": "Write a table-driven unit test for a Go parsing function.", "should_trigger": false }, + { "query": "How do I rebase my feature branch onto main and resolve conflicts?", "should_trigger": false }, + { "query": "Configure golangci-lint to enable the gosec linter.", "should_trigger": false } +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ebd1fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# SpecLedger issue store lock +issues.jsonl.lock + +# Pycache (skills eval) +__pycache__/ + +# Go build artifacts +/skillrig +*.exe +*.test +*.out + +# OS / editor cruft +.DS_Store +*.swp diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..8a0edaf --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,156 @@ +version: "2" +run: + concurrency: 4 + # Timeout for analysis + timeout: 5m + # Include test files + tests: true + +issues: + max-issues-per-linter: 0 # 0 = unlimited (we want ALL issues) + max-same-issues: 50 + +linters: + enable: + # correctness + - govet # built-in checker: copylocks, printf formats, struct tags, unreachable code + - staticcheck # extensive static analysis: deprecated APIs, common mistakes, simplifications + - unused # unused variables, functions, types + - errcheck # unchecked error returns and type assertions + - errorlint # correct use of errors.Is/As and %w wrapping (Go 1.13+) + - nilerr # returning nil error when err is non-nil + - forcetypeassert # type assertions without comma-ok check + - copyloopvar # loop variable copy issues (Go 1.22+) + - durationcheck # detect time.Duration * time.Duration bugs + - reassign # package-level variable reassignment + # style + - gocritic # opinionated style: unnecessary conversions, range copies, redundant code + - revive # naming conventions, exported types, stuttered package names + - wsl_v5 # whitespace and blank line rules for readability + - whitespace # trailing whitespace, unnecessary blank lines + - godot # exported-symbol comments must end with a period + - misspell # common English misspellings in identifiers and comments + - dupword # duplicate words in comments and strings (the the, is is) + - predeclared # shadowing Go built-ins (len, cap, error) + - errname # error type/var naming conventions (ErrFoo, FooError) + - asciicheck # non-ASCII identifiers (prevents homoglyph/trojan source attacks) + # complexity + - gocyclo # cyclomatic complexity threshold + - nestif # deeply nested if/else chains + - funlen # function length limits (lines and statements) + - dupl # code duplication detection + # performance + - perfsprint # faster alternatives to fmt.Sprintf + - unconvert # unnecessary type conversions + - ineffassign # assignments to variables never read + - goconst # repeated literals that should be constants + # security & resources + - gosec # security scanner: SQL injection, hardcoded credentials, weak crypto, path traversal + - bidichk # dangerous bidirectional Unicode sequences (trojan source CVE-2021-42574) + - bodyclose # unclosed HTTP response bodies (connection leaks) + - noctx # HTTP requests missing context.Context + - containedctx # context.Context stored in struct fields instead of passed as parameter + - fatcontext # context.WithValue/WithCancel in loops (unbounded context chain, memory leak) + - sqlclosecheck # unclosed sql.Rows and sql.Stmt + - rowserrcheck # unchecked sql.Rows.Err() after iteration + # logging + - sloglint # consistent log/slog code style + - loggercheck # key-value pair validation for structured loggers (zap, slog, logr) + # testing + - testifylint # testify best practices + - thelper # test helpers missing t.Helper() + - usetesting # use t.Setenv/t.TempDir instead of os equivalents in tests + - paralleltest # tests and subtests missing t.Parallel() + # modernization & meta + - modernize # old patterns replaceable with newer Go features + - exptostd # replace golang.org/x/exp/ functions with stdlib equivalents + - intrange # range over integer instead of C-style loop (Go 1.22+) + - usestdlibvars # use stdlib constants instead of hardcoded values + - exhaustive # switch statements not covering all enum values + - nolintlint # enforces proper //nolint directive usage + + disable: + - lll # line length β€” handled by gofmt/gofumpt + - prealloc # high false-positive rate; enable only after performance profiling + - wrapcheck # forces wrapping all external errors β€” too noisy as a default + - err113 # forces package-level sentinel errors β€” too opinionated, breaks common patterns + - mnd # magic number detector β€” extremely noisy, flags obvious constants like HTTP 200 + - iface # interface pollution detector β€” too opinionated, not mature enough + - nakedret # naked returns β€” overlaps with funlen (short functions make naked returns fine) + - noinlineerr # bans `if err := ...; err != nil {}` β€” this is idiomatic Go + - gocognit # cognitive complexity β€” redundant with gocyclo + nestif + - cyclop # cyclomatic complexity β€” redundant with gocyclo + - depguard # import allow/deny lists β€” requires per-project configuration + - goheader # file header enforcement β€” project-specific policy + - importas # import alias enforcement β€” requires per-project configuration + - funcorder # function ordering β€” too opinionated for a default + - godoclint # godoc validation β€” overlaps with godot and revive + - varnamelen # variable name length β€” too opinionated, Go favors short names + - exhaustruct # all struct fields must be set β€” extremely noisy, breaks zero-value idiom + - gochecknoglobals # no global variables β€” too strict, many valid uses + - gochecknoinits # no init() functions β€” too strict, many valid uses + - unparam # unused function parameters β€” medium false-positive rate with interfaces + - makezero # flags make([]T, n) β€” noisy, often wrong about intent + - testpackage # forces _test package β€” valid but too opinionated as a default + - embeddedstructfieldcheck # embedded type placement β€” minor style, not worth enforcing + - iotamixing # iota in mixed const blocks β€” very rare issue + - unqueryvet # SELECT * detection β€” too niche for a default config + - recvcheck # receiver type consistency β€” overlaps with gocritic + - mirror # bytes/strings mirror patterns β€” very few real hits + - protogetter # proto field access via getters β€” only for protobuf users + - spancheck # OpenTelemetry span checks β€” only for OTel users + - zerologlint # zerolog usage β€” only for zerolog users + + exclusions: + paths: + - vendor$ + - third_party$ + - testutils$ + - examples$ + rules: + # gosec on test code is noise here: test fixtures use conventional file + # perms (0644) and read paths built from t.TempDir(), neither an attack + # surface. Correctness linters stay active on tests. + - path: _test\.go + linters: + - gosec + + settings: + dupl: + threshold: 100 # lower => stricter (tokens) + errcheck: + check-type-assertions: true + funlen: + lines: 120 + statements: 80 + goconst: + min-len: 3 + min-occurrences: 4 + gocyclo: + min-complexity: 13 # strict; lower => stricter + nolintlint: + require-explanation: true + require-specific: true + wsl_v5: + allow-first-in-block: true + allow-whole-block: false + branch-max-lines: 2 + +formatters: + enable: + - gofmt + - gofumpt + disable: + - gci # import grouping/ordering β€” gofumpt already handles standard grouping + - goimports # import management β€” redundant with gofumpt + - golines # line wrapping β€” too opinionated, can break readability + - swaggo # swaggo comment formatting β€” only for swaggo users + settings: + gofumpt: + extra-rules: true + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/AGENTS.md b/AGENTS.md index 22115aa..27bb014 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,9 @@ ## Issue Tracking with `sl issue` -**IMPORTANT**: This project uses the built-in **`sl issue`** commands for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. +**IMPORTANT**: This project uses the built-in **`sl issue`** commands for **issue and work-item tracking**. Do NOT use markdown TODO/task lists as a substitute for the issue system. + +> **Scope of this rule**: it targets *work-item / task tracking* only. It does **not** prohibit the checkbox checklists that are part of `/specledger` spec and plan artifacts β€” e.g. the spec-quality checklist (`checklists/requirements.md`) and the plan's Constitution Check (`plan.md`). Those are in-document review checklists generated by the workflow, not a parallel work tracker, and are allowed. ### Why `sl issue`? @@ -83,7 +85,8 @@ sl issue link SL-abc123 blocks SL-def456 # abc123 blocks def456 - βœ… Use `sl issue` for ALL task tracking - βœ… Issues are stored per-spec in `specledger//issues.jsonl` - βœ… Check `sl issue list --status open` before asking "what should I work on?" -- ❌ Do NOT create markdown TODO lists +- ❌ Do NOT use markdown TODO lists as a substitute for `sl issue` work-item tracking + - βœ… Checkbox checklists inside `/specledger` spec/plan artifacts (requirements checklist, Constitution Check) are in-document review checklists, not work tracking β€” allowed - ❌ Do NOT use external issue trackers - ❌ Do NOT duplicate tracking systems diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8b6ccc8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,10 @@ + + +## Active Technologies + +- Go 1.24+ (toolchain in this environment is 1.24.4; 1.25 also fine) β€” single static binary; cross-OS/arch via goreleaser later, out of scope here +- Go standard `go test`. Two tiers β€” (a) in-process Cobra unit tests via `SetArgs`/`SetOut`/`SetErr` + table-driven resolver tests; (b) `TestQuickstart_*` integration tests that build and exec the real binary (Constitution II/III). +- Local files only β€” project `.skillrig/config.toml`, global `~/.config/skillrig/config.toml` (XDG-aware). No database, no network. +- `github.com/spf13/cobra` (command tree); `github.com/pelletier/go-toml/v2` (config read/write β€” see research.md). Dependencies kept minimal (consume-only +- static binary). + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3ba993 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# skillrig + +`skillrig` is a CLI for pointing a repository (or your per-user default) at an +**origin** β€” the `OWNER/REPO` that hosts your team's agent skills β€” and resolving +which origin is active for any working directory. + +> NOTE: a skillrig **origin** is a GitHub repository with a determined structure, use the template repository provided to create your own origin. + +## Install + +Requires Go 1.24+ and `git` on your `PATH` (used for an offline repo-root lookup). + +```sh +go build -o skillrig . +``` + +## Usage + +### Bind a repo to an origin + +```sh +# Run from anywhere in the repo; the config lands at the git repository root. +skillrig init --origin my-org/my-skills +``` + +This writes `.skillrig/config.toml` at the git repository root (or the current +directory if you are not inside a git repo). `init` is **idempotent** and +**consume-only**: it records an existing origin, never creates or scaffolds one, +and binding the same origin twice is a no-op. + +### Set a personal default + +```sh +# Used when a repo has no origin of its own. +skillrig init --origin my-org/my-skills --global +``` + +Writes `$XDG_CONFIG_HOME/skillrig/config.toml` (or `~/.config/skillrig/config.toml`). + +### Scripts and agents + +```sh +# Never prompt; fail fast if --origin is missing (safe for CI/agents). +skillrig init --origin my-org/my-skills --non-interactive + +# Machine-readable result. +skillrig init --origin my-org/my-skills --json +``` + +## Origin resolution precedence + +Every command resolves the active origin with a single rule β€” highest wins: + +``` +SKILLRIG_ORIGIN > project .skillrig/config.toml (nearest ancestor) > global config +``` + +- `SKILLRIG_ORIGIN` overrides everything without editing any file: + ```sh + SKILLRIG_ORIGIN=ci-org/ci-skills skillrig + ``` + A blank/whitespace value is treated as **unset**. +- The project config is found by walking **up** from the current directory, so any + subdirectory of a bound repo resolves the same origin. +- A malformed or origin-less config file is skipped; resolution continues down the + order. When no source supplies an origin, commands report **"no origin configured"** β€” + fix it with `skillrig init --origin OWNER/REPO`, by setting `SKILLRIG_ORIGIN`, or with + a `--global` default. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Success (including an idempotent no-op) | +| `1` | Usage or configuration error (bad flags, invalid origin, no origin configured) | + +Codes `2` (verification) and `3` (prerequisite) are reserved for later commands. + +## Configuration file + +The v0 `config.toml` holds a single key: + +```toml +origin = 'my-org/my-skills' +``` + +Unknown keys are ignored on read, so config added by later versions will not break this +one. The full, extended `config.toml` structure is documented on the project docs +website; see also [docs/design/cli.md](docs/design/cli.md) for the CLI design contract. diff --git a/docs/design/cli.md b/docs/design/cli.md index 74d00d8..761afa1 100644 --- a/docs/design/cli.md +++ b/docs/design/cli.md @@ -361,6 +361,14 @@ The execution layer handles command routing, the shared `skillcore` primitives ( --- +## Borrowed Ideas (vNext, additive β€” not yet adopted) + +Harvested from the `agentic-cli-design` skill (tumf/skills). These are **additive** enhancements that this document does not currently mandate; they are recorded here so the idea isn't lost, grounded in our own authoritative contract. **This document remains authoritative.** Where the external skill conflicts with the rules above it was deliberately **not** adopted β€” specifically: our exit codes are `0/1/2/3` per [Exit Codes](#exit-codes) (not the skill's `2=invalid-args/3=auth/4=retryable`); **human compact output is the default** with `--json` opt-in (not JSON-primary, see [Two-Level Output](#principle-3-two-level-output-design)); and **errors are prose what/why/fix to stderr with the raw cause preserved** (not structured JSON errors, see [Principle 2](#principle-2-error-messages-as-navigation)). + +- **Machine-readable introspection beyond help text.** Today Progressive Discovery ([Principle 1](#principle-1-progressive-discovery)) is help-text based (Cobra-generated). A future enhancement could let the CLI emit its own spec for agents to parse: `commands --json` (command/arg tree), `schema --command --output json-schema` (per-command JSON Schema), `--help --json`, and top-level fixed fields `schemaVersion` / `type` / `ok`. Pull this forward only if agents need to parse the command tree programmatically (MCP-surface adjacency, architecture Β§2). +- **`install-skills` verb.** The CLI could ship *its own* usage skill from `/skills` into `.agents/skills` (or `--claude` β†’ `.claude/skills`, `--global` β†’ home). This is a natural mechanism for **Constitution IX (Skill–CLI Co-Evolution)** β€” skillrig installing the agent skill that teaches its own usage. Note this is the CLI's *own* skill, distinct from skillrig's core job of vendoring *org* skills; keep the two surfaces clearly separated and consume-only (Β§2b). +- **Agent-friendliness scorecard (0/1/2 per principle, 14 max).** A lightweight rubric usable as an extra review gate during `/specledger.checkpoint` to score a command's agent-readiness. Advisory only β€” it never becomes a blocking CI gate (that role belongs to `verify`/`lint`, R11/N1). + ## References - [Technical Architecture](../../../../architecture.md) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c0d55a9 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/skillrig/cli + +go 1.24 + +require ( + github.com/pelletier/go-toml/v2 v2.3.1 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..899b70d --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/exit.go b/internal/cli/exit.go new file mode 100644 index 0000000..0dcca32 --- /dev/null +++ b/internal/cli/exit.go @@ -0,0 +1,56 @@ +package cli + +import "fmt" + +// Load-bearing process exit codes (docs/design/cli.md). These are part of the +// CLI contract: scripts and agents branch on them, so their meanings are fixed. +const ( + // ExitOK signals success, including idempotent no-ops. + ExitOK = 0 + // ExitUsage signals a usage or configuration error (bad flags, invalid + // origin, no origin configured, unwritable config). + ExitUsage = 1 + // ExitVerification is reserved for a future verification failure (e.g. a + // `verify` command). Declared here so the meaning is stable; unused in this + // feature. + ExitVerification = 2 + // ExitPrereq is reserved for a future missing-prerequisite failure. + // Declared here for stability; unused in this feature. + ExitPrereq = 3 +) + +// UsageError marks an error as a usage or configuration problem that maps to +// ExitUsage. It preserves the raw cause so --verbose can surface it while the +// human-facing message stays an actionable what/why/fix string. +type UsageError struct { + // Msg is the rendered what/why/fix message shown to the user. + Msg string + // Cause is the underlying error, preserved for --verbose; may be nil. + Cause error +} + +func (e *UsageError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("%s: %v", e.Msg, e.Cause) + } + + return e.Msg +} + +func (e *UsageError) Unwrap() error { return e.Cause } + +// usageErrorf builds a UsageError with no underlying cause from a format string. +func usageErrorf(format string, args ...any) *UsageError { + return &UsageError{Msg: fmt.Sprintf(format, args...)} +} + +// exitCodeFor maps a returned error to a process exit code. nil β†’ ExitOK; every +// error in this feature's surface (usage/config) β†’ ExitUsage. Codes 2/3 are +// reserved for later commands and never returned here. +func exitCodeFor(err error) int { + if err == nil { + return ExitOK + } + + return ExitUsage +} diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..c45ec00 --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,223 @@ +package cli + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/skillrig/cli/internal/config" +) + +// originPromptLabel is the single interactive prompt (research D5: one stdlib +// bufio read on stderr, no retry loop). +const originPromptLabel = "Origin (OWNER/REPO): " + +// missingOriginFix is the shared fix line for the no-origin error paths. +const missingOriginFix = "fix: pass --origin OWNER/REPO (e.g. --origin my-org/my-skills) or set SKILLRIG_ORIGIN" + +// initCmd holds the init command's flags and its injectable seams. Production +// uses the os-backed defaults; tests inject deterministic stubs (interactivity, +// cwd, env) so the prompt and write-target logic are testable without a TTY. +type initCmd struct { + opts *globalOpts + origin string + global bool + nonInteractive bool + + // interactive reports whether stdin is an interactive terminal. Defaults to + // stdinIsTTY; overridden in tests to exercise the prompt path. + interactive func() bool + // getwd returns the working directory. Defaults to os.Getwd. + getwd func() (string, error) + // env is the environment accessor used for the global config path. + env config.Env +} + +// newInitCmd builds the `skillrig init` command (Environment pattern): bind a +// repo (or the per-user global default) to an existing origin. +func newInitCmd(opts *globalOpts) *cobra.Command { + ic := &initCmd{ + opts: opts, + interactive: stdinIsTTY, + getwd: os.Getwd, + env: config.OSEnv, + } + + cmd := &cobra.Command{ + Use: "init", + Short: "Bind this repo (or your global default) to an origin", + Long: "Bind a repository β€” or your per-user global default β€” to an existing origin,\n" + + "the OWNER/REPO that hosts your team's agent skills, by recording it in config.\n" + + "init is idempotent and consume-only: it does not create or scaffold an origin.\n\n" + + "Without --origin, init prompts on an interactive terminal; with --non-interactive\n" + + "(or no TTY) it fails fast instead of prompting, so scripts and agents never block.", + Example: " # Bind the current repo to an existing origin\n" + + " skillrig init --origin my-org/my-skills\n\n" + + " # Set your personal default origin (used when a repo has none)\n" + + " skillrig init --origin my-org/my-skills --global", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return ic.run(cmd) + }, + } + + cmd.Flags().StringVar(&ic.origin, "origin", "", "origin to bind, OWNER/REPO (prompted if omitted on a TTY)") + cmd.Flags().BoolVar(&ic.global, "global", false, "write the per-user global default instead of the repo config") + cmd.Flags().BoolVar(&ic.nonInteractive, "non-interactive", false, "never prompt; fail fast if --origin is missing") + + return cmd +} + +// run executes the bind: determine the write target and origin, validate, then +// write atomically (skipping the write when the origin is unchanged) and emit +// the result. +func (ic *initCmd) run(cmd *cobra.Command) error { + target, scope, err := ic.writeTarget(cmd) + if err != nil { + return err + } + + target = absOrSelf(target) + + raw, err := ic.resolveOriginInput(cmd) + if err != nil { + return err + } + + origin, err := config.ParseOrigin(raw) + if err != nil { + return &UsageError{Msg: err.Error(), Cause: err} + } + + written, err := writeIfChanged(target, origin) + if err != nil { + return &UsageError{ + Msg: fmt.Sprintf("cannot write %s\nwhy: %v\nfix: check directory permissions and path", target, err), + Cause: err, + } + } + + return renderBindResult(cmd.OutOrStdout(), bindResult{ + OK: true, + Origin: origin.String(), + Scope: scope, + ConfigPath: target, + Written: written, + }, ic.opts.json) +} + +// writeTarget resolves where to write and the scope label. --global β†’ the +// per-user global path; otherwise the git repo root (offline rev-parse) with a +// cwd fallback (FR-005/FR-010). +func (ic *initCmd) writeTarget(cmd *cobra.Command) (path, scope string, err error) { + if ic.global { + path, err = config.GlobalConfigPath(ic.env) + if err != nil { + return "", "", &UsageError{Msg: "cannot locate global config path\nwhy: " + err.Error() + "\nfix: set HOME or XDG_CONFIG_HOME", Cause: err} + } + + return path, "global", nil + } + + cwd, err := ic.getwd() + if err != nil { + return "", "", &UsageError{Msg: "cannot determine working directory\nwhy: " + err.Error(), Cause: err} + } + + path, err = config.ProjectWriteTarget(cmd.Context(), cwd) + if err != nil { + return "", "", &UsageError{ + Msg: "cannot locate the project config write target\nwhy: " + err.Error() + "\nfix: ensure git works in this directory, or pass --global", + Cause: err, + } + } + + return path, "project", nil +} + +// resolveOriginInput returns the raw origin string from --origin, an +// interactive prompt, or fails fast (FR-006a/FR-006c). It never blocks a +// non-interactive session. +func (ic *initCmd) resolveOriginInput(cmd *cobra.Command) (string, error) { + if strings.TrimSpace(ic.origin) != "" { + return ic.origin, nil + } + + if ic.nonInteractive { + return "", usageNoOrigin("non-interactive mode requested (--non-interactive)") + } + + if !ic.interactive() { + return "", usageNoOrigin("non-interactive session (no TTY)") + } + + return ic.prompt(cmd) +} + +// prompt writes the single origin prompt to stderr and reads one line from +// stdin (stdlib bufio, research D5). Empty input is a usage error β€” no retry. +func (ic *initCmd) prompt(cmd *cobra.Command) (string, error) { + if _, err := fmt.Fprint(cmd.ErrOrStderr(), originPromptLabel); err != nil { + return "", err + } + + scanner := bufio.NewScanner(cmd.InOrStdin()) + if !scanner.Scan() { + return "", usageNoOrigin("no input received on the prompt") + } + + line := strings.TrimSpace(scanner.Text()) + if line == "" { + return "", usageNoOrigin("empty origin entered") + } + + return line, nil +} + +// usageNoOrigin builds the three-part "no origin given" usage error (what / why +// / fix) used by the non-interactive and empty-prompt paths (US3, FR-006a/c). +func usageNoOrigin(why string) *UsageError { + return usageErrorf("no origin given\nwhy: %s\n%s", why, missingOriginFix) +} + +// writeIfChanged compares the requested origin against the existing config and +// writes atomically only when it differs (idempotent re-bind, FR-008/FR-009). +// It reports whether a write happened. +func writeIfChanged(target string, origin config.Origin) (bool, error) { + if existing, err := config.Load(target); err == nil { + if cur, perr := config.ParseOrigin(existing.Origin); perr == nil && cur.String() == origin.String() { + return false, nil + } + } + + if err := config.Save(target, origin); err != nil { + return false, err + } + + return true, nil +} + +// stdinIsTTY reports whether stdin is an interactive character device (research +// D4). Note: this is true for /dev/null too, so non-interactive callers should +// pipe stdin (a pipe is not a char device). +func stdinIsTTY() bool { + info, err := os.Stdin.Stat() + if err != nil { + return false + } + + return info.Mode()&os.ModeCharDevice != 0 +} + +// absOrSelf returns the absolute form of p, or p unchanged if that fails. +func absOrSelf(p string) string { + if abs, err := filepath.Abs(p); err == nil { + return abs + } + + return p +} diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go new file mode 100644 index 0000000..45001e2 --- /dev/null +++ b/internal/cli/init_test.go @@ -0,0 +1,118 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// newTestInitCmd builds an init command with injected seams: a stubbed +// interactivity flag, a fixed cwd, and an empty env. This is the quickstart's +// sanctioned "interactive shim" β€” it signals interactive mode in-process so the +// prompt path is exercised deterministically without a pty. +func newTestInitCmd(t *testing.T, interactive bool, cwd string) (*initCmd, *cobra.Command, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + + ic := &initCmd{ + opts: &globalOpts{}, + interactive: func() bool { return interactive }, + getwd: func() (string, error) { return cwd, nil }, + env: func(string) string { return "" }, + } + + var out, errBuf bytes.Buffer + + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + + return ic, cmd, &out, &errBuf +} + +// TestInit_PromptInteractive maps to quickstart TestQuickstart_PromptInteractive +// (US1 / FR-006a). A pty is unavailable (project keeps deps minimal), so the +// interactive prompt is covered in-process via the shim. +func TestInit_PromptInteractive(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + ic, cmd, out, errBuf := newTestInitCmd(t, true, cwd) + cmd.SetIn(strings.NewReader("my-org/my-skills\n")) + + if err := ic.run(cmd); err != nil { + t.Fatalf("run: %v", err) + } + + if !strings.Contains(errBuf.String(), originPromptLabel) { + t.Errorf("stderr missing prompt %q, got: %q", originPromptLabel, errBuf.String()) + } + + if !strings.Contains(out.String(), "my-org/my-skills") { + t.Errorf("stdout missing bound origin, got: %q", out.String()) + } + + got, err := os.ReadFile(filepath.Join(cwd, ".skillrig", "config.toml")) + if err != nil { + t.Fatalf("config not written: %v", err) + } + + if string(got) != "origin = 'my-org/my-skills'\n" { + t.Errorf("config = %q, want origin = 'my-org/my-skills'", got) + } +} + +// TestInit_PromptEmptyInputErrors covers the interactive-but-empty path: a +// blank line is a usage error, no retry loop (research D5). +func TestInit_PromptEmptyInputErrors(t *testing.T) { + t.Parallel() + + ic, cmd, _, _ := newTestInitCmd(t, true, t.TempDir()) + cmd.SetIn(strings.NewReader("\n")) + + err := ic.run(cmd) + if err == nil { + t.Fatal("expected usage error for empty prompt input") + } + + var usageErr *UsageError + if !errors.As(err, &usageErr) { + t.Errorf("error %T is not a *UsageError", err) + } +} + +// TestInit_NonInteractiveFlagOverridesTTY is the precise FR-006c assertion the +// quickstart's pty scenario would make: with interactive() == true (a TTY is +// present) but --non-interactive set, init must fail fast WITHOUT prompting. +func TestInit_NonInteractiveFlagOverridesTTY(t *testing.T) { + t.Parallel() + + ic, cmd, _, errBuf := newTestInitCmd(t, true /* interactive TTY present */, t.TempDir()) + ic.nonInteractive = true + + cmd.SetIn(strings.NewReader("my-org/my-skills\n")) // input that must be ignored + + err := ic.run(cmd) + if err == nil { + t.Fatal("expected usage error with --non-interactive and no --origin, even on a TTY") + } + + var usageErr *UsageError + if !errors.As(err, &usageErr) { + t.Fatalf("error %T is not a *UsageError", err) + } + + if !strings.Contains(usageErr.Msg, "--non-interactive") { + t.Errorf("error why should cite --non-interactive, got: %q", usageErr.Msg) + } + + if strings.Contains(errBuf.String(), originPromptLabel) { + t.Errorf("--non-interactive must not prompt even on a TTY, stderr: %q", errBuf.String()) + } +} diff --git a/internal/cli/output.go b/internal/cli/output.go new file mode 100644 index 0000000..e21f6a6 --- /dev/null +++ b/internal/cli/output.go @@ -0,0 +1,43 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" +) + +// bindResult is the presentation-layer view of an init outcome. It is the +// single struct both renderers consume; it carries no business logic. +type bindResult struct { + OK bool `json:"ok"` + Origin string `json:"origin"` + Scope string `json:"scope"` + ConfigPath string `json:"configPath"` + Written bool `json:"written"` +} + +// resolveOrderHint is the footer line that teaches the resolution precedence. +// docs/design/cli.md Principle 3 (two-level output: confirmation + next step). +const resolveOrderHint = "β†’ resolve order: SKILLRIG_ORIGIN > ./.skillrig/config.toml > ~/.config/skillrig/config.toml" + +// renderBindResult writes an init result to w. With jsonOut it emits a single +// complete JSON object (all keys present); otherwise a compact human summary +// (≀2 lines including the footer hint). Data goes to stdout (the caller passes +// cmd.OutOrStdout()). +func renderBindResult(w io.Writer, r bindResult, jsonOut bool) error { + if jsonOut { + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + + return enc.Encode(r) + } + + summary := fmt.Sprintf("bound origin %s (%s: %s)\n", r.Origin, r.Scope, r.ConfigPath) + if !r.Written { + summary = fmt.Sprintf("already bound to %s (no change)\n", r.Origin) + } + + _, err := io.WriteString(w, summary+resolveOrderHint+"\n") + + return err +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..7b2c714 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,99 @@ +// Package cli wires the skillrig command tree (cobra) and the presentation +// layer. It must not contain origin/config business logic β€” that lives in +// internal/config. The CLI layer only parses flags, calls into config, and +// renders results or errors-as-navigation. +package cli + +import ( + "errors" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +// globalOpts holds the persistent, command-wide output flags. Shared by value +// of a pointer so subcommands read the parsed values at run time. +type globalOpts struct { + json bool + verbose bool +} + +// newRootCmd builds the root `skillrig` command and its subtree. It is exported +// indirectly via Execute; tests construct it directly to drive commands +// in-process with SetArgs/SetOut/SetErr. +func newRootCmd(opts *globalOpts) *cobra.Command { + root := &cobra.Command{ + Use: "skillrig", + Short: "Manage your org's agent-skills library", + Long: "skillrig points a repository (or your per-user default) at an origin β€”\n" + + "the OWNER/REPO that hosts your team's agent skills β€” and resolves which\n" + + "origin is active for any working directory.", + Example: " # Bind the current repo to an existing origin\n" + + " skillrig init --origin my-org/my-skills\n\n" + + " # Set your personal default origin\n" + + " skillrig init --origin my-org/my-skills --global", + // We render errors and usage ourselves (errors-as-navigation, + // docs/design/cli.md Principle 2 / Rule 5), so silence cobra's built-ins. + SilenceUsage: true, + SilenceErrors: true, + // Bare invocation prints help (cli.md Level-0 progressive discovery). + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + root.PersistentFlags().BoolVar(&opts.json, "json", false, "emit a complete JSON result on stdout instead of human text") + root.PersistentFlags().BoolVar(&opts.verbose, "verbose", false, "print underlying paths / raw causes behind summaries and errors") + + // Subcommands are registered here as they are implemented (init in US1). + registerSubcommands(root, opts) + + return root +} + +// renderError prints an error as navigation: the actionable what/why/fix +// message, plus the raw cause when --verbose is set. Always to stderr; the +// write itself is best-effort. +func renderError(w io.Writer, err error, verbose bool) { + _, _ = io.WriteString(w, errorMessage(err, verbose)) +} + +// errorMessage builds the what/why/fix text for an error. UsageError messages +// are shown verbatim (already authored as navigation); anything else gets a +// generic prefix. The raw cause is appended only under --verbose. +func errorMessage(err error, verbose bool) string { + var usageErr *UsageError + if errors.As(err, &usageErr) { + msg := usageErr.Msg + "\n" + + if verbose && usageErr.Cause != nil { + msg += fmt.Sprintf(" cause: %v\n", usageErr.Cause) + } + + return msg + } + + return fmt.Sprintf("error: %v\n", err) +} + +// Execute builds and runs the root command, renders any error as navigation to +// stderr, and returns the process exit code. main is a thin shim over this. +func Execute() int { + opts := &globalOpts{} + root := newRootCmd(opts) + + err := root.Execute() + if err != nil { + renderError(os.Stderr, err, opts.verbose) + } + + return exitCodeFor(err) +} + +// registerSubcommands attaches the implemented subcommands to root. Kept +// separate so each user story wires its command here as it lands. +func registerSubcommands(root *cobra.Command, opts *globalOpts) { + root.AddCommand(newInitCmd(opts)) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8619056 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,235 @@ +package config + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +// configDirName is the per-repo / per-user config directory. +const configDirName = ".skillrig" + +// configFileName is the config file written inside configDirName. +const configFileName = "config.toml" + +// Env is an injected environment accessor so resolution is a pure function of +// (cwd, env, filesystem) and tests can set SKILLRIG_ORIGIN / XDG_CONFIG_HOME / +// HOME deterministically without mutating process state. +type Env func(key string) string + +// OSEnv reads from the real process environment. Production callers pass this; +// tests pass a map-backed accessor. +func OSEnv(key string) string { return os.Getenv(key) } + +// Config is the on-disk shape of both the project and global config.toml. v0 +// has a single field; unknown keys are ignored on read for forward +// compatibility (data-model.md). +type Config struct { + Origin string `toml:"origin"` +} + +// MalformedError marks a config file that exists but cannot be parsed. The +// resolver treats it as "no origin from this source" and continues down +// precedence (FR-004), recording a diagnostic instead of failing β€” whereas a +// genuine read/I/O error (not this type) is fatal. Callers distinguish the two +// with errors.As(&MalformedError{}). +type MalformedError struct { + Path string + Err error +} + +func (e *MalformedError) Error() string { + return fmt.Sprintf("malformed config %s: %v", e.Path, e.Err) +} + +func (e *MalformedError) Unwrap() error { return e.Err } + +// Load reads and parses the config file at path. A missing file is not an +// error β€” it yields the zero Config (the source simply supplies no origin). A +// malformed file returns a *MalformedError so the resolver can skip the source +// and surface the cause (FR-004); any other read failure is returned as a plain +// I/O error, which the resolver treats as fatal. +func Load(path string) (Config, error) { + //nolint:gosec // G304: path is a config location constructed internally + // (env + walk-up + fixed global path), not attacker-controlled input; + // reading a designated config file is this function's entire purpose. + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return Config{}, nil + } + + return Config{}, fmt.Errorf("read %s: %w", path, err) + } + + var c Config + if err := toml.Unmarshal(data, &c); err != nil { + return Config{}, &MalformedError{Path: path, Err: err} + } + + return c, nil +} + +// Save writes origin-only TOML to path atomically: a temp file in the *same* +// directory (so os.Rename stays on one filesystem, research D9) is written then +// renamed over the destination. Parent directories are created as needed. +func Save(path string, o Origin) error { + c := Config{Origin: o.String()} + + data, err := toml.Marshal(c) + if err != nil { + return fmt.Errorf("encode config: %w", err) + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + + tmp, err := os.CreateTemp(dir, configFileName+".tmp-*") + if err != nil { + return fmt.Errorf("create temp in %s: %w", dir, err) + } + + tmpName := tmp.Name() + // Best-effort cleanup if we bail before the rename. + defer func() { _ = os.Remove(tmpName) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + + return fmt.Errorf("write %s: %w", tmpName, err) + } + + if err := tmp.Close(); err != nil { + return fmt.Errorf("close %s: %w", tmpName, err) + } + + if err := os.Chmod(tmpName, 0o600); err != nil { + return fmt.Errorf("chmod %s: %w", tmpName, err) + } + + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("install %s: %w", path, err) + } + + return nil +} + +// GlobalConfigPath returns the per-user global config path: $XDG_CONFIG_HOME/ +// skillrig/config.toml when XDG_CONFIG_HOME is set, else ~/.config/skillrig/ +// config.toml (research D2 β€” git-style, not os.UserConfigDir). The home dir is +// taken from env("HOME") when available (deterministic in tests), falling back +// to os.UserHomeDir for real invocations on platforms where it differs. +func GlobalConfigPath(env Env) (string, error) { + if xdg := strings.TrimSpace(env("XDG_CONFIG_HOME")); xdg != "" { + return filepath.Join(xdg, "skillrig", configFileName), nil + } + + home := env("HOME") + if home == "" { + h, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("locate home directory: %w", err) + } + + home = h + } + + return filepath.Join(home, ".config", "skillrig", configFileName), nil +} + +// FindProjectConfig walks up from cwd to the nearest ancestor containing +// .skillrig/config.toml and returns its path. The boolean reports whether one +// was found. This is a pure filesystem walk (no git subprocess) so resolution +// works offline, pre-`git init`, and in sandboxes (research D3). +// +// A missing candidate (fs.ErrNotExist) is the normal walk-up case and is +// skipped silently. Any other stat failure β€” e.g. permission denied on an +// ancestor β€” is a genuine I/O error and is returned, so the resolver fails fast +// rather than masking an unreadable project config as "not found". +func FindProjectConfig(cwd string) (string, bool, error) { + dir := cwd + + for { + candidate := filepath.Join(dir, configDirName, configFileName) + + info, err := os.Stat(candidate) + switch { + case err == nil && !info.IsDir(): + return candidate, true, nil + case err != nil && !errors.Is(err, fs.ErrNotExist): + return "", false, fmt.Errorf("stat %s: %w", candidate, err) + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", false, nil + } + + dir = parent + } +} + +// errEmptyGitRoot is returned when `git rev-parse` succeeds but yields no path. +var errEmptyGitRoot = errors.New("git rev-parse returned an empty root") + +// ProjectWriteTarget returns where `skillrig init` should write the project +// config: /.skillrig/config.toml when cwd is inside a git work +// tree (located via an offline `git rev-parse --show-toplevel`), else +// /.skillrig/config.toml (research D3, FR-010). +// +// The cwd fallback applies ONLY to expected conditions β€” git not installed +// (exec.ErrNotFound) or cwd not being a repository (a clean non-zero git exit). +// An unexpected failure β€” context cancellation/timeout, or any other error β€” +// is returned, so init never silently writes config to the wrong directory. +func ProjectWriteTarget(ctx context.Context, cwd string) (string, error) { + root, err := gitRoot(ctx, cwd) + + switch { + case err == nil: + return filepath.Join(root, configDirName, configFileName), nil + case ctx.Err() != nil: + // Cancellation/timeout (may surface as a kill-signal ExitError) is fatal. + return "", fmt.Errorf("locate git repo root: %w", ctx.Err()) + case errors.Is(err, exec.ErrNotFound): + // git is not installed β€” fall back to cwd. + default: + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + // Not a clean non-zero exit (e.g. empty root, exec failure) β€” surface it. + return "", fmt.Errorf("locate git repo root: %w", err) + } + // git ran and returned non-zero (cwd is not a repository) β€” fall back. + } + + return filepath.Join(cwd, configDirName, configFileName), nil +} + +// gitRoot returns the work-tree root for cwd via `git rev-parse --show-toplevel` +// (offline β€” reads local .git, no network). Every failure is returned as an +// error; ProjectWriteTarget decides which errors are expected (fall back) vs +// fatal (propagate). +func gitRoot(ctx context.Context, cwd string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") + cmd.Dir = cwd + + out, err := cmd.Output() + if err != nil { + return "", err + } + + root := strings.TrimSpace(string(out)) + if root == "" { + return "", errEmptyGitRoot + } + + return root, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..84e1232 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,264 @@ +package config + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +// mapEnv builds an injected Env accessor from a map (parallel-safe; no process +// env mutation). +func mapEnv(m map[string]string) Env { + return func(k string) string { return m[k] } +} + +func TestSaveLoadRoundTrip(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, configDirName, configFileName) + origin := Origin{Owner: "my-org", Repo: "my-skills"} + + if err := Save(path, origin); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if got.Origin != origin.String() { + t.Errorf("round-trip origin = %q, want %q", got.Origin, origin.String()) + } +} + +// TestSaveMatchesFixture anchors Save's byte-for-byte output to the committed +// ground-truth fixture (Constitution III). go-toml/v2 emits TOML literal +// strings (single-quoted) for values needing no escaping; the fixture is the +// real output, regenerated from Save (review finding G1). +func TestSaveMatchesFixture(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, configFileName) + + if err := Save(path, Origin{Owner: "my-org", Repo: "my-skills"}); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read written file: %v", err) + } + + want, err := os.ReadFile(filepath.Join("..", "..", "test", "fixtures", "config.toml")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + if string(got) != string(want) { + t.Errorf("Save output = %q, want fixture %q", got, want) + } +} + +func TestLoadMissingFileIsEmpty(t *testing.T) { + t.Parallel() + + got, err := Load(filepath.Join(t.TempDir(), "does-not-exist.toml")) + if err != nil { + t.Fatalf("Load missing file should not error, got: %v", err) + } + + if got.Origin != "" { + t.Errorf("missing file origin = %q, want empty", got.Origin) + } +} + +func TestLoadMalformedErrors(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), configFileName) + if err := os.WriteFile(path, []byte("this is = not valid = toml ]["), 0o644); err != nil { + t.Fatalf("write malformed: %v", err) + } + + _, err := Load(path) + if err == nil { + t.Fatal("Load malformed file should error") + } + + // FR-004: a malformed file is a *MalformedError so the resolver can skip it + // (vs. a plain I/O error, which is fatal). + var malformed *MalformedError + if !errors.As(err, &malformed) { + t.Errorf("Load error %T is not a *MalformedError", err) + } +} + +func TestLoadIgnoresUnknownKeys(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), configFileName) + contents := "origin = \"my-org/my-skills\"\nfuture_key = \"ignored\"\n[clients]\ntarget = \"x\"\n" + + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + got, err := Load(path) + if err != nil { + t.Fatalf("Load with unknown keys should not error: %v", err) + } + + if got.Origin != "my-org/my-skills" { + t.Errorf("origin = %q, want my-org/my-skills", got.Origin) + } +} + +func TestGlobalConfigPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + env map[string]string + want string + }{ + { + name: "XDG_CONFIG_HOME set", + env: map[string]string{"XDG_CONFIG_HOME": "/cfg"}, + want: filepath.Join("/cfg", "skillrig", "config.toml"), + }, + { + name: "falls back to HOME/.config", + env: map[string]string{"HOME": "/home/u"}, + want: filepath.Join("/home/u", ".config", "skillrig", "config.toml"), + }, + { + name: "blank XDG falls through to HOME", + env: map[string]string{"XDG_CONFIG_HOME": " ", "HOME": "/home/u"}, + want: filepath.Join("/home/u", ".config", "skillrig", "config.toml"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := GlobalConfigPath(mapEnv(tc.env)) + if err != nil { + t.Fatalf("GlobalConfigPath: %v", err) + } + + if got != tc.want { + t.Errorf("GlobalConfigPath = %q, want %q", got, tc.want) + } + }) + } +} + +func TestFindProjectConfigWalkUp(t *testing.T) { + t.Parallel() + + root := t.TempDir() + cfgPath := filepath.Join(root, configDirName, configFileName) + + if err := Save(cfgPath, Origin{Owner: "my-org", Repo: "my-skills"}); err != nil { + t.Fatalf("Save: %v", err) + } + + sub := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatalf("mkdir sub: %v", err) + } + + got, found, err := FindProjectConfig(sub) + if err != nil { + t.Fatalf("FindProjectConfig: %v", err) + } + + if !found { + t.Fatal("FindProjectConfig from subdir: not found, want found via walk-up") + } + + if got != cfgPath { + t.Errorf("FindProjectConfig = %q, want %q", got, cfgPath) + } +} + +func TestFindProjectConfigNoneReturnsFalse(t *testing.T) { + t.Parallel() + + _, found, err := FindProjectConfig(t.TempDir()) + if err != nil { + t.Fatalf("FindProjectConfig: %v", err) + } + + if found { + t.Error("FindProjectConfig in empty dir should report not found") + } +} + +// TestFindProjectConfigUnstattableIsError pins Qodo #2: a stat failure that is +// NOT fs.ErrNotExist (here, permission denied because an ancestor .skillrig dir +// lacks the execute/search bit) is surfaced as an error, not silently treated +// as "not found". Skipped as root (perms don't restrict). +func TestFindProjectConfigUnstattableIsError(t *testing.T) { + t.Parallel() + + if os.Geteuid() == 0 { + t.Skip("running as root; file permissions do not restrict access") + } + + root := t.TempDir() + skillDir := filepath.Join(root, configDirName) + + if err := os.MkdirAll(skillDir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + // Remove search permission so stat of /config.toml fails EACCES. + if err := os.Chmod(skillDir, 0o000); err != nil { + t.Fatalf("chmod: %v", err) + } + + t.Cleanup(func() { _ = os.Chmod(skillDir, 0o750) }) + + if _, _, err := FindProjectConfig(root); err == nil { + t.Fatal("FindProjectConfig should surface a permission-denied stat as an error, got nil") + } +} + +// TestProjectWriteTargetCancelledCtxIsFatal pins Qodo #4: an unexpected git +// failure (here a cancelled context) is propagated, NOT masked as a cwd +// fallback β€” so init never writes config to the wrong directory. +func TestProjectWriteTargetCancelledCtxIsFatal(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before the call so git rev-parse fails on the dead context + + if _, err := ProjectWriteTarget(ctx, t.TempDir()); err == nil { + t.Fatal("cancelled context must yield a fatal error, not a cwd fallback") + } +} + +// TestProjectWriteTargetNonRepoFallsBackToCwd verifies the expected fallback is +// preserved: outside a git repo (clean non-zero git exit), the write target is +// /.skillrig/config.toml with no error. +func TestProjectWriteTargetNonRepoFallsBackToCwd(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + + got, err := ProjectWriteTarget(context.Background(), cwd) + if err != nil { + t.Fatalf("non-repo cwd should fall back without error, got: %v", err) + } + + want := filepath.Join(cwd, configDirName, configFileName) + if got != want { + t.Errorf("ProjectWriteTarget = %q, want cwd fallback %q", got, want) + } +} diff --git a/internal/config/origin.go b/internal/config/origin.go new file mode 100644 index 0000000..ee548fa --- /dev/null +++ b/internal/config/origin.go @@ -0,0 +1,49 @@ +// Package config holds skillrig's origin/config business logic: the Origin +// value type, reading and writing config.toml, and the single origin resolver. +// It is presentation-free β€” callers in internal/cli format the results. +package config + +import ( + "fmt" + "regexp" + "strings" +) + +// originPattern is the offline shape check for an origin: two non-empty, +// slash-separated segments over the GitHub owner/repo charset (research D6). +// Existence/reachability is intentionally not checked here. +var originPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$`) + +// Origin is an org's skill source in OWNER/REPO form. It is the single value +// this feature reads, validates, records, and resolves. +type Origin struct { + Owner string + Repo string +} + +// String renders the origin as "Owner/Repo". The zero Origin (the SourceNone +// sentinel) renders as "" so a "no origin" result never stringifies to a +// misleading "/" that looks configured. +func (o Origin) String() string { + if o.Owner == "" && o.Repo == "" { + return "" + } + + return o.Owner + "/" + o.Repo +} + +// ParseOrigin trims surrounding whitespace and validates s against the +// OWNER/REPO shape. On failure it returns a usage error that names the expected +// format and echoes the offending value (FR-012). A blank string is rejected; +// callers that treat blank as "unset" (e.g. SKILLRIG_ORIGIN) must check before +// calling. +func ParseOrigin(s string) (Origin, error) { + trimmed := strings.TrimSpace(s) + if !originPattern.MatchString(trimmed) { + return Origin{}, fmt.Errorf("invalid origin %q: expected OWNER/REPO (e.g. my-org/my-skills)", s) + } + + owner, repo, _ := strings.Cut(trimmed, "/") + + return Origin{Owner: owner, Repo: repo}, nil +} diff --git a/internal/config/origin_test.go b/internal/config/origin_test.go new file mode 100644 index 0000000..9da02fd --- /dev/null +++ b/internal/config/origin_test.go @@ -0,0 +1,88 @@ +package config + +import ( + "strings" + "testing" +) + +func TestParseOrigin(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + wantErr bool + wantOwner string + wantRepo string + }{ + {name: "valid", in: "my-org/my-skills", wantOwner: "my-org", wantRepo: "my-skills"}, + {name: "valid with dots and underscores", in: "my.org_1/skills.v2_x", wantOwner: "my.org_1", wantRepo: "skills.v2_x"}, + {name: "surrounding whitespace trimmed", in: " my-org/my-skills\n", wantOwner: "my-org", wantRepo: "my-skills"}, + {name: "empty", in: "", wantErr: true}, + {name: "blank whitespace", in: " ", wantErr: true}, + {name: "no slash", in: "my-org-my-skills", wantErr: true}, + {name: "missing repo", in: "my-org/", wantErr: true}, + {name: "missing owner", in: "/my-skills", wantErr: true}, + {name: "too many segments", in: "my-org/team/skills", wantErr: true}, + {name: "illegal char", in: "my org/my skills", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := ParseOrigin(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("ParseOrigin(%q) = %v, want error", tc.in, got) + } + + return + } + + if err != nil { + t.Fatalf("ParseOrigin(%q) unexpected error: %v", tc.in, err) + } + + if got.Owner != tc.wantOwner || got.Repo != tc.wantRepo { + t.Errorf("ParseOrigin(%q) = %+v, want {Owner:%q Repo:%q}", tc.in, got, tc.wantOwner, tc.wantRepo) + } + }) + } +} + +func TestParseOriginErrorEchoesValue(t *testing.T) { + t.Parallel() + + _, err := ParseOrigin("not-valid") + if err == nil { + t.Fatal("expected error for malformed origin") + } + // FR-012: error names the expected format and echoes the offending value. + msg := err.Error() + for _, want := range []string{"not-valid", "OWNER/REPO"} { + if !strings.Contains(msg, want) { + t.Errorf("error %q missing %q", msg, want) + } + } +} + +func TestOriginString(t *testing.T) { + t.Parallel() + + o := Origin{Owner: "my-org", Repo: "my-skills"} + if got := o.String(); got != "my-org/my-skills" { + t.Errorf("String() = %q, want %q", got, "my-org/my-skills") + } +} + +// TestOriginStringZeroIsEmpty pins Qodo #3: the zero Origin (the SourceNone +// sentinel returned by ResolveOrigin) must stringify to "" β€” never "/" β€” so it +// cannot be mistaken for a configured value. +func TestOriginStringZeroIsEmpty(t *testing.T) { + t.Parallel() + + if got := (Origin{}).String(); got != "" { + t.Errorf("zero Origin String() = %q, want empty string", got) + } +} diff --git a/internal/config/resolve.go b/internal/config/resolve.go new file mode 100644 index 0000000..5d2818e --- /dev/null +++ b/internal/config/resolve.go @@ -0,0 +1,162 @@ +package config + +import ( + "errors" + "fmt" + "strings" +) + +// envOriginKey is the environment variable that overrides all file sources. +const envOriginKey = "SKILLRIG_ORIGIN" + +// Source identifies which input supplied the resolved origin. +type Source string + +const ( + // SourceEnv is the SKILLRIG_ORIGIN environment variable. + SourceEnv Source = "env" + // SourceProject is a project .skillrig/config.toml found via walk-up. + SourceProject Source = "project" + // SourceGlobal is the per-user global config. + SourceGlobal Source = "global" + // SourceNone means no origin was configured in any source β€” a normal, + // first-class outcome the caller turns into the US3 "no origin" error. + SourceNone Source = "none" +) + +// SourceDiagnostic records a config source that was present but unusable β€” a +// malformed file or one whose origin fails OWNER/REPO validation β€” and was +// therefore skipped (FR-004). It is the cause a caller surfaces under --verbose +// instead of a raw parser dump; it is never itself a hard error. +type SourceDiagnostic struct { + // Source is the source the skipped file belonged to (project or global). + Source Source + // Path is the skipped file's path. + Path string + // Reason is the human-readable cause (parse error or invalid origin). + Reason string +} + +// ResolutionResult is the outcome of ResolveOrigin. +type ResolutionResult struct { + // Origin is the resolved origin, or the zero Origin when Source is none. + Origin Origin + // Source names which input supplied the origin. + Source Source + // ConfigPath is the file that supplied it (empty for env and none). + ConfigPath string + // Diagnostics lists every source that was present but skipped because it + // was unusable (malformed / invalid origin), in precedence order. It is + // populated regardless of the final Source so a caller can explain, under + // --verbose, why a higher-precedence source did not win (FR-004). + Diagnostics []SourceDiagnostic +} + +// ResolveOrigin determines the active origin for cwd and env, applying +// precedence SKILLRIG_ORIGIN > project config (nearest ancestor) > global +// config (FR-002). It is the single resolver every command uses (AP-06): a pure +// function of (cwd, env, filesystem) with no network, time, or global state. +// +// A blank SKILLRIG_ORIGIN is treated as unset. A malformed or origin-less file +// source yields no origin and resolution continues down precedence (FR-004); it +// is not an error. Source==none with the zero Origin is a normal return. An +// explicitly set but malformed SKILLRIG_ORIGIN is the one hard error: it is a +// deliberate override that must be valid. +func ResolveOrigin(cwd string, env Env) (ResolutionResult, error) { + var diags []SourceDiagnostic + + if raw := strings.TrimSpace(env(envOriginKey)); raw != "" { + origin, err := ParseOrigin(raw) + if err != nil { + return ResolutionResult{}, fmt.Errorf("%s: %w", envOriginKey, err) + } + + return ResolutionResult{Origin: origin, Source: SourceEnv}, nil + } + + path, found, err := FindProjectConfig(cwd) + if err != nil { + return ResolutionResult{}, err + } + + if found { + origin, ok, diag, err := originFromFile(path, SourceProject) + if err != nil { + return ResolutionResult{}, err + } + + if diag != nil { + diags = append(diags, *diag) + } + + if ok { + return ResolutionResult{Origin: origin, Source: SourceProject, ConfigPath: path, Diagnostics: diags}, nil + } + } + + globalPath, err := GlobalConfigPath(env) + if err != nil { + return ResolutionResult{}, err + } + + origin, ok, diag, err := originFromFile(globalPath, SourceGlobal) + if err != nil { + return ResolutionResult{}, err + } + + if diag != nil { + diags = append(diags, *diag) + } + + if ok { + return ResolutionResult{Origin: origin, Source: SourceGlobal, ConfigPath: globalPath, Diagnostics: diags}, nil + } + + return ResolutionResult{Source: SourceNone, Diagnostics: diags}, nil +} + +// originFromFile loads one config source and classifies the outcome so the +// caller can apply FR-004 precisely: +// +// - ok == true β†’ a valid origin (no diagnostic, no error) +// - ok == false, diag != nil β†’ file present but unusable (malformed, or an +// origin that fails OWNER/REPO): record the diagnostic and continue +// - ok == false, diag == nil β†’ source supplies no origin (absent file, or +// a parseable file with no origin key): a normal, silent fall-through +// - err != nil β†’ a genuine I/O error (e.g. unreadable file): +// fatal, returned to the caller (contract resolve.md) +func originFromFile(path string, source Source) (Origin, bool, *SourceDiagnostic, error) { + cfg, loadErr := Load(path) + + var malformed *MalformedError + + switch { + case loadErr == nil: + // File read and parsed; classify its origin below. + case errors.As(loadErr, &malformed): + // FR-004: a malformed file is deliberately non-fatal β€” reported as a + // skippable diagnostic, not propagated as an error. + return skip(source, path, loadErr.Error()) + default: + // A genuine I/O error (e.g. unreadable file) is fatal (contract resolve.md). + return Origin{}, false, nil, loadErr + } + + if strings.TrimSpace(cfg.Origin) == "" { + return Origin{}, false, nil, nil + } + + origin, parseErr := ParseOrigin(cfg.Origin) + if parseErr != nil { + // Present file whose origin fails OWNER/REPO: skippable diagnostic. + return skip(source, path, parseErr.Error()) + } + + return origin, true, nil, nil +} + +// skip builds the "present but unusable, continue down precedence" result: no +// origin, a recorded diagnostic, and no error (the skip is intentional, FR-004). +func skip(source Source, path, reason string) (Origin, bool, *SourceDiagnostic, error) { + return Origin{}, false, &SourceDiagnostic{Source: source, Path: path, Reason: reason}, nil +} diff --git a/internal/config/resolve_test.go b/internal/config/resolve_test.go new file mode 100644 index 0000000..568cf7a --- /dev/null +++ b/internal/config/resolve_test.go @@ -0,0 +1,323 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// writeProject writes a project config.toml at dir/.skillrig/config.toml. When +// malformed is true it writes unparseable bytes instead (matrix row 7). +func writeProject(t *testing.T, dir, origin string, malformed bool) string { + t.Helper() + + path := filepath.Join(dir, configDirName, configFileName) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir project: %v", err) + } + + data := "origin = '" + origin + "'\n" + if malformed { + data = "this is = not ][ valid toml" + } + + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatalf("write project: %v", err) + } + + return path +} + +// writeGlobal writes a global config at home/.config/skillrig/config.toml. +func writeGlobal(t *testing.T, home, origin string) string { + t.Helper() + + path := filepath.Join(home, ".config", "skillrig", configFileName) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir global: %v", err) + } + + if err := os.WriteFile(path, []byte("origin = '"+origin+"'\n"), 0o600); err != nil { + t.Fatalf("write global: %v", err) + } + + return path +} + +// TestResolveOrigin_Precedence is table-driven over the recorded matrix in +// data-model.md (rows 1–7). Each row materializes real files in temp dirs and +// resolves with an injected env β€” no mocks (Constitution III). +func TestResolveOrigin_Precedence(t *testing.T) { + t.Parallel() + + type row struct { + name string + envOrigin string // SKILLRIG_ORIGIN value ("" = unset) + projectOrigin string // "" = no project file + projectMalformed bool + globalOrigin string // "" = no global file + wantOrigin string + wantSource Source + wantPathIsGlobal bool // ConfigPath should be the global file + wantPathIsProj bool // ConfigPath should be the project file + } + + rows := []row{ + {name: "row1_none", wantSource: SourceNone}, + {name: "row2_project", projectOrigin: "my-org/my-skills", wantOrigin: "my-org/my-skills", wantSource: SourceProject, wantPathIsProj: true}, + {name: "row3_env_beats_project", envOrigin: "ci-org/ci-skills", projectOrigin: "my-org/my-skills", wantOrigin: "ci-org/ci-skills", wantSource: SourceEnv}, + {name: "row4_global", globalOrigin: "personal/skills", wantOrigin: "personal/skills", wantSource: SourceGlobal, wantPathIsGlobal: true}, + {name: "row5_project_beats_global", projectOrigin: "client-a/skills", globalOrigin: "personal/skills", wantOrigin: "client-a/skills", wantSource: SourceProject, wantPathIsProj: true}, + {name: "row6_blank_env_is_unset", envOrigin: " ", projectOrigin: "my-org/my-skills", wantOrigin: "my-org/my-skills", wantSource: SourceProject, wantPathIsProj: true}, + {name: "row7_malformed_project_skipped", projectOrigin: "ignored", projectMalformed: true, globalOrigin: "personal/skills", wantOrigin: "personal/skills", wantSource: SourceGlobal, wantPathIsGlobal: true}, + } + + for _, tc := range rows { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + home := t.TempDir() + + var projPath, globalPath string + if tc.projectOrigin != "" { + projPath = writeProject(t, cwd, tc.projectOrigin, tc.projectMalformed) + } + + if tc.globalOrigin != "" { + globalPath = writeGlobal(t, home, tc.globalOrigin) + } + + env := mapEnv(map[string]string{ + "SKILLRIG_ORIGIN": tc.envOrigin, + "HOME": home, + }) + + got, err := ResolveOrigin(cwd, env) + if err != nil { + t.Fatalf("ResolveOrigin: %v", err) + } + + if got.Origin.String() != tc.wantOrigin { + t.Errorf("Origin = %q, want %q", got.Origin.String(), tc.wantOrigin) + } + + if got.Source != tc.wantSource { + t.Errorf("Source = %q, want %q", got.Source, tc.wantSource) + } + + switch { + case tc.wantPathIsProj: + if got.ConfigPath != projPath { + t.Errorf("ConfigPath = %q, want project %q", got.ConfigPath, projPath) + } + case tc.wantPathIsGlobal: + if got.ConfigPath != globalPath { + t.Errorf("ConfigPath = %q, want global %q", got.ConfigPath, globalPath) + } + default: + if got.ConfigPath != "" { + t.Errorf("ConfigPath = %q, want empty", got.ConfigPath) + } + } + }) + } +} + +// TestResolveOrigin_FromSubdir proves walk-up resolution (US2 / SC-002): a +// project config at the repo root resolves from a nested subdirectory. +func TestResolveOrigin_FromSubdir(t *testing.T) { + t.Parallel() + + root := t.TempDir() + home := t.TempDir() + projPath := writeProject(t, root, "my-org/my-skills", false) + + sub := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(sub, 0o750); err != nil { + t.Fatalf("mkdir sub: %v", err) + } + + got, err := ResolveOrigin(sub, mapEnv(map[string]string{"HOME": home})) + if err != nil { + t.Fatalf("ResolveOrigin: %v", err) + } + + if got.Source != SourceProject || got.Origin.String() != "my-org/my-skills" { + t.Errorf("from subdir: got %+v, want project my-org/my-skills", got) + } + + if got.ConfigPath != projPath { + t.Errorf("ConfigPath = %q, want %q", got.ConfigPath, projPath) + } +} + +// TestResolveOrigin_MalformedProjectRecordsDiagnostic verifies FR-004: a +// malformed project file is skipped (resolution continues to global) AND its +// cause is recorded as a diagnostic for a --verbose caller β€” not silently +// swallowed. +func TestResolveOrigin_MalformedProjectRecordsDiagnostic(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + home := t.TempDir() + projPath := writeProject(t, cwd, "ignored", true) // malformed TOML + writeGlobal(t, home, "personal/skills") + + got, err := ResolveOrigin(cwd, mapEnv(map[string]string{"HOME": home})) + if err != nil { + t.Fatalf("malformed project must not be fatal, got error: %v", err) + } + + if got.Source != SourceGlobal || got.Origin.String() != "personal/skills" { + t.Errorf("got %+v, want global personal/skills (project skipped)", got) + } + + if len(got.Diagnostics) != 1 { + t.Fatalf("Diagnostics = %v, want exactly 1 (the skipped malformed project)", got.Diagnostics) + } + + d := got.Diagnostics[0] + if d.Source != SourceProject || d.Path != projPath || !strings.Contains(d.Reason, "malformed") { + t.Errorf("diagnostic = %+v, want project %s with a 'malformed' reason", d, projPath) + } +} + +// TestResolveOrigin_InvalidShapeRecordsDiagnostic verifies a parseable file +// whose origin fails OWNER/REPO validation is also skipped with a diagnostic. +func TestResolveOrigin_InvalidShapeRecordsDiagnostic(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + home := t.TempDir() + projPath := writeProject(t, cwd, "not a valid shape", false) // parses, bad origin + writeGlobal(t, home, "personal/skills") + + got, err := ResolveOrigin(cwd, mapEnv(map[string]string{"HOME": home})) + if err != nil { + t.Fatalf("invalid-shape project must not be fatal, got error: %v", err) + } + + if got.Source != SourceGlobal { + t.Errorf("Source = %q, want global (project skipped)", got.Source) + } + + if len(got.Diagnostics) != 1 || got.Diagnostics[0].Path != projPath { + t.Errorf("Diagnostics = %+v, want 1 entry for %s", got.Diagnostics, projPath) + } +} + +// TestResolveOrigin_OriginlessNoDiagnostic verifies a parseable file that +// simply lacks an origin key is a quiet fall-through (forward-compat), not a +// diagnostic. +func TestResolveOrigin_OriginlessNoDiagnostic(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + home := t.TempDir() + + path := filepath.Join(cwd, configDirName, configFileName) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + + if err := os.WriteFile(path, []byte("future_key = \"x\"\n"), 0o600); err != nil { + t.Fatalf("write origin-less: %v", err) + } + + writeGlobal(t, home, "personal/skills") + + got, err := ResolveOrigin(cwd, mapEnv(map[string]string{"HOME": home})) + if err != nil { + t.Fatalf("origin-less file must not be fatal: %v", err) + } + + if got.Source != SourceGlobal { + t.Errorf("Source = %q, want global", got.Source) + } + + if len(got.Diagnostics) != 0 { + t.Errorf("Diagnostics = %+v, want none for an origin-less (parseable) file", got.Diagnostics) + } +} + +// TestResolveOrigin_UnreadableFileIsFatal verifies a genuine I/O error (an +// existing but unreadable file) is returned as a fatal error, not skipped +// (contract resolve.md). Skipped when running as root (perms don't restrict). +func TestResolveOrigin_UnreadableFileIsFatal(t *testing.T) { + t.Parallel() + + if os.Geteuid() == 0 { + t.Skip("running as root; file permissions do not restrict access") + } + + cwd := t.TempDir() + path := writeProject(t, cwd, "my-org/my-skills", false) + + if err := os.Chmod(path, 0o000); err != nil { + t.Fatalf("chmod 000: %v", err) + } + + t.Cleanup(func() { _ = os.Chmod(path, 0o600) }) // let TempDir cleanup remove it + + _, err := ResolveOrigin(cwd, mapEnv(map[string]string{"HOME": t.TempDir()})) + if err == nil { + t.Fatal("unreadable project config should be a fatal error, got nil") + } +} + +// TestResolveOrigin_UnreadableProjectIsFatalDespiteGlobal pins the (contract- +// specified, debatable) semantic that an unreadable project config is fatal +// even when a valid global default exists β€” resolution does not fall through an +// I/O error (contract resolve.md; adversarial finding A3). +func TestResolveOrigin_UnreadableProjectIsFatalDespiteGlobal(t *testing.T) { + t.Parallel() + + if os.Geteuid() == 0 { + t.Skip("running as root; file permissions do not restrict access") + } + + cwd := t.TempDir() + home := t.TempDir() + path := writeProject(t, cwd, "my-org/my-skills", false) + writeGlobal(t, home, "personal/skills") // a usable global that must NOT mask the error + + if err := os.Chmod(path, 0o000); err != nil { + t.Fatalf("chmod 000: %v", err) + } + + t.Cleanup(func() { _ = os.Chmod(path, 0o600) }) + + if _, err := ResolveOrigin(cwd, mapEnv(map[string]string{"HOME": home})); err == nil { + t.Fatal("unreadable project config must be fatal even with a valid global, got nil") + } +} + +// TestResolveOrigin_MalformedEnvIsFatal verifies the resolver's one hard-error +// branch: an explicitly set but malformed SKILLRIG_ORIGIN is a deliberate +// override that must be valid β€” it errors rather than falling through to file +// sources (adversarial finding A1). Distinct from a blank env, which is unset +// (matrix row 6). +func TestResolveOrigin_MalformedEnvIsFatal(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + home := t.TempDir() + // A valid project origin exists; the malformed env override must NOT fall + // through to it β€” it must hard-error. + writeProject(t, cwd, "my-org/my-skills", false) + + _, err := ResolveOrigin(cwd, mapEnv(map[string]string{ + "SKILLRIG_ORIGIN": "not a valid origin", + "HOME": home, + })) + if err == nil { + t.Fatal("malformed SKILLRIG_ORIGIN should be a fatal error, got nil") + } + + // Error names the offending variable and the underlying cause. + if !strings.Contains(err.Error(), "SKILLRIG_ORIGIN") { + t.Errorf("error %q should name SKILLRIG_ORIGIN", err) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..26e056e --- /dev/null +++ b/main.go @@ -0,0 +1,14 @@ +// Command skillrig is the SpecLedger skill-management CLI. main is a thin shim: +// all command logic lives in internal/cli, and we map the returned exit code +// to os.Exit so the binary's status is load-bearing for scripts and agents. +package main + +import ( + "os" + + "github.com/skillrig/cli/internal/cli" +) + +func main() { + os.Exit(cli.Execute()) +} diff --git a/mise.toml b/mise.toml index 6760090..dbd7ae2 100644 --- a/mise.toml +++ b/mise.toml @@ -2,4 +2,5 @@ # Issue tracking is now built into sl CLI (sl issue commands) [tools] +"github:kevindutra/crit" = "latest" # Add your project tools here diff --git a/skills-lock.json b/skills-lock.json index ae7e3d4..7579569 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,36 @@ { "version": 1, "skills": { + "golang-cli": { + "source": "samber/cc-skills-golang", + "ref": "HEAD", + "sourceType": "github", + "computedHash": "46b26038d586a31fb81085be47e9959c03123e331227222c1f5310d66e2cb0b6" + }, + "golang-code-style": { + "source": "samber/cc-skills-golang", + "ref": "HEAD", + "sourceType": "github", + "computedHash": "525439afec53774c634877111e79853abf623dd96cd2602b52eca212058e242c" + }, + "golang-lint": { + "source": "samber/cc-skills-golang", + "ref": "HEAD", + "sourceType": "github", + "computedHash": "354e2875b38b2591ba6abe5bbb11d9c59c0fd6d0dcb035923677c84cf6c13ef9" + }, + "golang-spf13-cobra": { + "source": "samber/cc-skills-golang", + "ref": "HEAD", + "sourceType": "github", + "computedHash": "4a8e12511278bb32a91ab9fc6e80c87158fc6b56431adfbaf0ab2a4c77c09d88" + }, + "golang-testing": { + "source": "samber/cc-skills-golang", + "ref": "HEAD", + "sourceType": "github", + "computedHash": "ffd3dd94831f0edce89c5749c211bfec48bd9a2eb178b2f985dd18b00a9cfefe" + }, "skill-creator": { "source": "anthropics/skills", "ref": "HEAD", diff --git a/specledger/001-init-origin-resolution/checklists/requirements.md b/specledger/001-init-origin-resolution/checklists/requirements.md new file mode 100644 index 0000000..3d8b2e9 --- /dev/null +++ b/specledger/001-init-origin-resolution/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: CLI Initialization & Origin Resolution + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-24 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation passed on first iteration; no [NEEDS CLARIFICATION] markers were needed (informed defaults documented in Assumptions). +- Domain-level contract terms retained intentionally (`OWNER/REPO` origin shape, environment override, stdout/stderr split, exit-status classes, project vs. global config) β€” these are user-facing observable behavior governed by `docs/design/cli.md` and the architecture, not implementation choices. Concrete file formats/libraries are deferred to `/specledger.plan`. +- Constitution alignment (II Quickstart-as-Contract, III Ground-Truth Anchoring, IX Skill–CLI Co-Evolution) captured in a dedicated spec section so it carries into planning and tasks. +- Items marked incomplete require spec updates before `/specledger.clarify` or `/specledger.plan`. None remain. diff --git a/specledger/001-init-origin-resolution/contracts/init.md b/specledger/001-init-origin-resolution/contracts/init.md new file mode 100644 index 0000000..801bf95 --- /dev/null +++ b/specledger/001-init-origin-resolution/contracts/init.md @@ -0,0 +1,81 @@ +# Contract: `skillrig init` + +**Pattern**: Environment (idempotent, consume-only) β€” [cli.md](../../../docs/design/cli.md) Pattern Classification. +**Purpose**: Bind this repo (or the per-user global default) to an **existing** origin by recording it in config. Does NOT bootstrap/scaffold an origin (architecture Β§2d). + +## Synopsis + +``` +skillrig init [--origin OWNER/REPO] [--global] [--non-interactive] [--json] [--verbose] +``` + +## Flags + +| Flag | Type | Default | Meaning | +|------|------|---------|---------| +| `--origin` | string | "" | Origin to bind, `OWNER/REPO`. If omitted: prompt in an interactive TTY; error in non-interactive. | +| `--global` | bool | false | Write the per-user global default (`$XDG_CONFIG_HOME/skillrig/config.toml` or `~/.config/skillrig/config.toml`) instead of the repo project config. | +| `--non-interactive` | bool | false | Force non-interactive mode: never prompt. If required flags such `--origin` are omitted, fail (exit 1) instead of prompting, **even on an interactive TTY** (FR-006c). For scripts/agents that must not block on input. | +| `--json` | bool | false | Emit the complete result object on stdout instead of compact human text. | +| `--verbose` | bool | false | Print the underlying file path(s) / raw cause behind any summary or error. | + +`Args`: none (`cobra.NoArgs`); origin is a flag. + +> **Project write target (FR-005/FR-010):** without `--global`, the project config is written at the **git repository root** β€” located via `git rev-parse --show-toplevel`, a fully **offline** call β€” as `/.skillrig/config.toml`, so a repo has a single canonical config regardless of the cwd subdirectory. When the cwd is **not** inside a git repository, it falls back to `./.skillrig/config.toml` in the cwd. `git` is a required dependency of the framework (see plan.md β†’ Technical Context). The resolver (`contracts/resolve.md`) finds this file from any subdirectory via walk-up, keeping write and read symmetric. + +## Help (Progressive Discovery β€” cli.md Principle 1, Rule 1) + +`Long` description + **β‰₯2 examples**: + +``` +Examples: + # Bind the current repo to an existing origin + skillrig init --origin my-org/my-skills + + # Set your personal default origin (used when a repo has none) + skillrig init --origin my-org/my-skills --global +``` + +## Behavior + +1. Resolve write target: `--global` β†’ global config path; else the **git repo root** via `git rev-parse --show-toplevel` (offline) β†’ `/.skillrig/config.toml`; if not inside a git repo, fall back to `./.skillrig/config.toml` in cwd (create `.skillrig/` if missing β€” FR-010). +2. Determine origin: `--origin` value; else if `--non-interactive` is set β†’ usage error without prompting (FR-006c); else if interactive TTY β†’ prompt once on stderr; else (no TTY) usage error (FR-006a). +3. `ParseOrigin` β†’ on invalid shape, usage error, no write (FR-012). +4. Load existing config at target (if any). Compare: + - none present β†’ write, `written=true`. + - equal origin β†’ no-op, `written=false` (idempotent, FR-008). + - different origin β†’ rewrite with new origin, `written=true` (FR-009). +5. Write atomically (temp + rename, research D9). +6. Emit result (see Output). + +Only the origin is collected; no other metadata (FR-006b). + +## Output + +**Human (default, stdout, compact β€” ≀2 lines incl. footer hint):** +``` +bound origin my-org/my-skills (project: ./.skillrig/config.toml) +β†’ resolve order: SKILLRIG_ORIGIN > ./.skillrig/config.toml > ~/.config/skillrig/config.toml +``` +(idempotent no-op prints `already bound to my-org/my-skills (no change)`.) + +**`--json` (stdout, complete + parseable):** +```json +{ "ok": true, "origin": "my-org/my-skills", "scope": "project", "configPath": "/abs/.skillrig/config.toml", "written": true } +``` +Keys always present: `ok, origin, scope, configPath, written`. `scope ∈ {project, global}`. + +## Errors (stderr; prose what/why/fix; raw cause preserved β€” cli.md Principle 2) + +| Condition | Exit | Message shape | +|-----------|------|---------------| +| `--origin` omitted, non-interactive **session** (no TTY) | 1 | what: no origin given; why: non-interactive session (no TTY); fix: pass `--origin OWNER/REPO` or set `SKILLRIG_ORIGIN`. | +| `--origin` omitted, `--non-interactive` **forced** (even on a TTY) | 1 | what: no origin given; why: non-interactive mode requested (`--non-interactive`); fix: pass `--origin OWNER/REPO` or set `SKILLRIG_ORIGIN`. | +| Malformed origin | 1 | what: invalid origin ``; why: expected `OWNER/REPO`; fix: e.g. `skillrig init --origin my-org/my-skills`. | +| Config dir/file not writable | 1 | what: cannot write ``; why: ``; fix: check permissions / path. | + +Exit `0` on success (including idempotent no-op). Codes `2`/`3` not used by this command. + +## Test mapping (Constitution II) + +Each row of the Output/Errors/Behavior tables has a `TestQuickstart_*` scenario in `quickstart.md`. Output-shape assertions: human line-count bound; `--json` `json.Unmarshal` + all-keys-present; error asserts the three parts as distinct checks + exit code. diff --git a/specledger/001-init-origin-resolution/contracts/resolve.md b/specledger/001-init-origin-resolution/contracts/resolve.md new file mode 100644 index 0000000..dc0ea0f --- /dev/null +++ b/specledger/001-init-origin-resolution/contracts/resolve.md @@ -0,0 +1,41 @@ +# Contract: `config.ResolveOrigin` (the single origin resolver) + +**Scope**: internal Go API (`internal/config`), not a CLI command. The **one** implementation every current and future command calls (architecture AP-06 β€” never re-derive precedence per command). + +## Signature + +```go +// ResolveOrigin determines the active origin for the given working directory +// and environment, applying precedence env > project > global. +func ResolveOrigin(cwd string, env Env) (ResolutionResult, error) +``` + +- `env` is an injected accessor (e.g. `func(key string) string`) so tests set `SKILLRIG_ORIGIN` deterministically without mutating process env (golang-testing: table-driven, parallel-safe). +- Returns `ResolutionResult{Origin, Source, ConfigPath, Diagnostics}` (see data-model.md). `Diagnostics` carries every source that was present but skipped because it was unusable, so the cause is never silently swallowed. + +## Precedence (FR-002) + +1. **`SKILLRIG_ORIGIN`** β€” if set and non-blank β†’ parse; `Source=env`. +2. **Project** β€” walk up from `cwd` to the nearest ancestor with `.skillrig/config.toml`; if found and it yields a valid origin β†’ `Source=project`, `ConfigPath` set. +3. **Global** β€” `$XDG_CONFIG_HOME/skillrig/config.toml` else `~/.config/skillrig/config.toml`; if it yields a valid origin β†’ `Source=global`. +4. Otherwise β†’ `Source=none`, zero Origin. + +A lower source is consulted **only** when every higher source is absent/blank/unusable. Behavior is the recorded matrix in `data-model.md` (rows 1–7). + +## Error / robustness semantics + +- Blank `SKILLRIG_ORIGIN` β†’ treated as unset; fall through (matrix row 6). +- Unparseable or invalid-origin config file at a source β†’ that source yields "none"; resolution **continues** down precedence (FR-004, matrix row 7). The cause is recorded as a `SourceDiagnostic` in `ResolutionResult.Diagnostics` (not discarded), so a `--verbose` caller can surface it as a clear message β€” never a raw parser dump. An **origin-less but parseable** file (forward-compat: has other keys, no `origin`) is a quiet fall-through, not a diagnostic. +- `Source=none` is a normal return (not a Go error). The **caller** (e.g. a future command, or `init`'s sibling commands) converts `none` into the actionable "no origin configured" CLI error (exit 1, US3/FR-003), optionally citing any `Diagnostics`. +- A genuine I/O error (e.g. unreadable file due to permissions) is returned as a Go `error` for the caller to surface with guidance β€” it is **fatal**, distinguished from a skippable malformed file by the typed `config.MalformedError`. + +> **Implementation note (skillrig-init):** `ResolveOrigin` never silently swallows a bad source. Per source it returns one of: a usable origin; a skippable diagnostic (malformed / invalid origin) with no error; a quiet no-origin fall-through (absent / origin-less); or a fatal I/O error. A future command wiring `--verbose` reads `Diagnostics` β€” do not re-introduce a silent skip. + +## Determinism + +Pure function of (`cwd`, `env`, filesystem state). No network, no time, no global mutable state β†’ fully deterministic and table-testable. This is the property later offline gates (`verify`) depend on. + +## Test mapping + +- Unit: table-driven test over matrix rows 1–7 (`TestResolveOrigin_Precedence`), each asserting `Origin`, `Source`, and `ConfigPath`. +- Integration: exercised end-to-end through `skillrig init` + a follow-on resolution check in `quickstart.md` (e.g. env override beating project config). diff --git a/specledger/001-init-origin-resolution/data-model.md b/specledger/001-init-origin-resolution/data-model.md new file mode 100644 index 0000000..9c09dd6 --- /dev/null +++ b/specledger/001-init-origin-resolution/data-model.md @@ -0,0 +1,96 @@ +# Data Model: CLI Initialization & Origin Resolution + +> **Ground-Truth Anchoring (Constitution III)**: the `config.toml` shape below is the canonical fixture β€” a real file written by `skillrig init` and then read back β€” not invented from prose. The resolution-precedence matrix is the recorded ground-truth table the resolver tests assert against. Both live as test fixtures (`test/fixtures/config.toml`, `test/fixtures/precedence.json` or a table-driven literal). + +## Entities + +### Origin +The org's skill source, in `OWNER/REPO` form. The single value this feature reads, validates, records, and resolves. + +| Field | Type | Rules | +|-------|------|-------| +| `Owner` | string | non-empty, charset `[A-Za-z0-9._-]` | +| `Repo` | string | non-empty, charset `[A-Za-z0-9._-]` | + +- Constructed via `ParseOrigin(s string) (Origin, error)`: trims whitespace, matches `^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$`. On failure returns a usage error naming the expected format and echoing the offending value (FR-012). +- `String()` renders `Owner/Repo`. + +### ProjectConfig β†’ `.skillrig/config.toml` (committed, hand-editable INPUT) +| Field | TOML key | Type | Notes | +|-------|----------|------|-------| +| `Origin` | `origin` | string | `OWNER/REPO`; the only field this feature reads/writes | + +> Forward-compatibility: unknown keys in `config.toml` are **ignored** on read (not an error), so fields added later (client targets, adoption policy β€” architecture Β§2d) don't break this version. Detailed/extended config structure is documented on the project docs website, not restated here (spec clarification). + +### GlobalConfig β†’ `$XDG_CONFIG_HOME/skillrig/config.toml` or `~/.config/skillrig/config.toml` +Same shape as ProjectConfig; the per-user default origin. Written only with `--global`. + +### EnvOverride +`SKILLRIG_ORIGIN` environment variable. Highest precedence. A blank/whitespace-only value is treated as **unset** (not invalid). + +### ResolutionResult (in-memory, returned by `ResolveOrigin`) +| Field | Type | Notes | +|-------|------|-------| +| `Origin` | Origin | the resolved origin (zero value if none) | +| `Source` | enum `env` \| `project` \| `global` \| `none` | which source supplied it | +| `ConfigPath` | string | path of the file used (empty for `env`/`none`) | +| `Diagnostics` | []SourceDiagnostic | sources that were present but **skipped** because unusable (malformed file, or origin failing `OWNER/REPO`), in precedence order; the cause a caller surfaces under `--verbose` (FR-004). Empty when nothing was skipped. | + +- `Source == none` is a distinct, first-class outcome (FR-003) callers convert into the actionable "no origin configured" error (US3). +- `Diagnostics` is populated regardless of the final `Source` (e.g. a malformed project file skipped on the way to a valid global origin still appears here), so a `--verbose` caller can explain why a higher-precedence source did not win. A `SourceDiagnostic` is `{Source, Path, Reason}`. A genuine I/O error (e.g. an unreadable file) is **not** a diagnostic β€” it is returned as a Go `error` and is fatal. + +## Canonical fixture β€” `config.toml` (ground truth) + +```toml +# .skillrig/config.toml β€” written by `skillrig init` +origin = 'my-org/my-skills' +``` + +That is the entire v0 file: one `origin` key. The byte-for-byte output of `init` is asserted against this fixture (round-trip: write β†’ read β†’ equal). + +> **Ground-truth note (review G1):** the canonical bytes use TOML *literal-string* +> (single-quote) form β€” `origin = 'my-org/my-skills'` β€” because that is the real +> output emitted by `github.com/pelletier/go-toml/v2` (research D1) for a value +> needing no escaping. Single- and double-quoted TOML are semantically identical; +> the committed fixture (`test/fixtures/config.toml`) and `TestSaveMatchesFixture` +> anchor the real output, regenerated from `Save` rather than hand-written. + +## Resolution precedence matrix (recorded ground truth) + +`ResolveOrigin(cwd, env)` precedence β€” a lower source supplies the origin only when all higher sources are absent/empty (FR-002). `βœ“` = present, `–` = absent/blank. + +| # | `SKILLRIG_ORIGIN` | project `.skillrig/config.toml` | global config | β†’ Resolved origin | β†’ Source | +|---|-------------------|----------------------------------|---------------|-------------------|----------| +| 1 | – | – | – | (none) | `none` | +| 2 | – | βœ“ `my-org/my-skills` | – | `my-org/my-skills` | `project` | +| 3 | βœ“ `ci-org/ci-skills` | βœ“ `my-org/my-skills` | – | `ci-org/ci-skills` | `env` | +| 4 | – | – | βœ“ `personal/skills` | `personal/skills` | `global` | +| 5 | – | βœ“ `client-a/skills` | βœ“ `personal/skills` | `client-a/skills` | `project` | +| 6 | βœ“ (blank) | βœ“ `my-org/my-skills` | – | `my-org/my-skills` | `project` (blank env = unset) | +| 7 | – | (malformed/unparseable file) | βœ“ `personal/skills` | `personal/skills` | `global` (bad project source skipped, FR-004) | + +Rows map directly to table-driven resolver unit tests and to quickstart precedence scenarios. + +## State / lifecycle + +`init` is the only state transition and is idempotent: + +- **no config β†’ write** : `Source=none` β†’ file created, `written=true`. +- **same origin β†’ no-op** : existing == requested β†’ `written=false`, success (FR-008). +- **different origin β†’ replace** : existing != requested β†’ file rewritten with new origin, `written=true` (FR-009). + +No deletion, no other transitions in scope. + +**Write target** (where the project config lands): with `--global` β†’ the global config path; otherwise the **git repository root** located via `git rev-parse --show-toplevel` (offline) β†’ `/.skillrig/config.toml`, so a repo has one canonical config regardless of the cwd subdirectory. When the cwd is **not** inside a git repo, it falls back to `cwd/.skillrig/config.toml`. `git` is a required dependency (see plan.md β†’ Technical Context). This keeps write and read symmetric: the resolver walks up from `cwd` and finds the same root file (see `contracts/resolve.md`). + +## Validation rules (consolidated) + +| Rule | Where | Failure β†’ | +|------|-------|-----------| +| Origin matches `OWNER/REPO` | `ParseOrigin` (init write + resolved value) | usage error, exit 1, no write (FR-012) | +| Blank `SKILLRIG_ORIGIN` = unset | resolver | fall through precedence | +| Unparseable/origin-less config = "none from this source" | resolver | skip source, continue; clear diagnostic, not raw dump (FR-004) | +| No origin in any source | resolver β†’ caller | actionable "no origin configured" error, exit 1 (US3, FR-003) | +| No TTY + no `--origin` (auto non-interactive) | `init` | usage error, exit 1 (FR-006a) | +| `--non-interactive` forced + no `--origin` (even on a TTY) | `init` | usage error, exit 1, no prompt (FR-006c) | +| Project write target = git root (offline `rev-parse`), else cwd | `init` | path resolution; `git` required on PATH | diff --git a/specledger/001-init-origin-resolution/issues.jsonl b/specledger/001-init-origin-resolution/issues.jsonl new file mode 100644 index 0000000..9e02fa8 --- /dev/null +++ b/specledger/001-init-origin-resolution/issues.jsonl @@ -0,0 +1,26 @@ +{"id":"SL-227789","title":"CLI Initialization \u0026 Origin Resolution","description":"WHY: Deliver the first slice of the generic skillrig CLI β€” skillrig init plus the single origin-resolution primitive every later command depends on. Offline config bootstrap only.","status":"closed","priority":1,"issue_type":"epic","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:28:23.826302+08:00","updated_at":"2026-05-26T10:04:28.075821+08:00","closed_at":"2026-05-26T10:04:28.075821+08:00","labels":["spec:001-init-origin-resolution","component:cli"]} +{"id":"SL-a9fb37","title":"Setup: Go module + Cobra skeleton + lint gate","description":"WHY: Establish the project skeleton and baseline command experience every later command inherits.","status":"closed","priority":1,"issue_type":"feature","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:29:05.248881+08:00","updated_at":"2026-05-25T14:28:36.02823+08:00","closed_at":"2026-05-25T14:28:36.02823+08:00","blocks":["SL-b881e9","SL-ec18e7","SL-ba7eaa","SL-d990f5"],"labels":["spec:001-init-origin-resolution","phase:setup","component:cli"],"design":"go.mod (module github.com/skillrig/cli, Go 1.24+); main.go β†’ internal/cli.Execute(); internal/cli/{root,exit,output}.go; .golangci.yml. cobra root with persistent --json/--verbose, SilenceUsage/SilenceErrors. Per plan.md + cli.md Progressive Discovery.","parentId":"SL-227789"} +{"id":"SL-b881e9","title":"Foundational: origin parse + config I/O + ResolveOrigin","description":"WHY: The shared primitives (origin parsing, config read/write, the single resolver) that all user stories depend on.","status":"closed","priority":1,"issue_type":"feature","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:29:05.273779+08:00","updated_at":"2026-05-25T14:36:01.630014+08:00","closed_at":"2026-05-25T14:36:01.630013+08:00","blocked_by":["SL-a9fb37"],"blocks":["SL-ec18e7","SL-ba7eaa","SL-d990f5"],"labels":["spec:001-init-origin-resolution","phase:foundational","component:config"],"design":"internal/config/{origin,config,resolve}.go. ParseOrigin (OWNER/REPO); ProjectConfig/GlobalConfig TOML via pelletier/go-toml/v2, atomic temp+rename; ResolveOrigin(cwd,env) env\u003eproject-walkup\u003eglobal (AP-06, single impl). Ground-truth fixtures per Constitution III.","parentId":"SL-227789"} +{"id":"SL-ec18e7","title":"US1: Bind a repo to an existing origin (skillrig init)","description":"WHY: Entry point to the product β€” record the origin so the repo is self-describing for any clone/agent/CI.","status":"closed","priority":1,"issue_type":"feature","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:29:05.291492+08:00","updated_at":"2026-05-25T14:45:18.888323+08:00","closed_at":"2026-05-25T14:45:18.888323+08:00","blocked_by":["SL-a9fb37","SL-b881e9"],"blocks":["SL-60678c"],"labels":["spec:001-init-origin-resolution","phase:us1","story:US1","component:cli"],"design":"internal/cli/init.go (Environment pattern). Flags --origin/--global/--json/--verbose/--non-interactive; git-root write w/ cwd fallback; prompt-if-missing (stdlib bufio, interactive TTY only); idempotent; emits result via output helper. Quickstart: BindProject, BindProjectJSON, IdempotentRebind, RebindDifferent, Global, PromptInteractive, Help.","parentId":"SL-227789"} +{"id":"SL-ba7eaa","title":"US2: Resolve the origin by precedence","description":"WHY: Predictable env\u003eproject\u003eglobal resolution makes the recorded config trustworthy everywhere.","status":"closed","priority":2,"issue_type":"feature","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:29:05.307669+08:00","updated_at":"2026-05-25T14:46:20.458009+08:00","closed_at":"2026-05-25T14:46:20.458009+08:00","blocked_by":["SL-a9fb37","SL-b881e9"],"blocks":["SL-60678c"],"labels":["spec:001-init-origin-resolution","phase:us2","story:US2","component:config"],"design":"Validates internal/config/resolve.go against data-model precedence matrix. TestResolveOrigin_* rows 1-7 + FromSubdir (table-driven, injected env, real fixture files on temp fs).","parentId":"SL-227789"} +{"id":"SL-d990f5","title":"US3: Actionable failures (errors-as-navigation)","description":"WHY: No-origin / malformed / missing-arg failures must state what failed, why, and the fix β€” no cryptic errors.","status":"closed","priority":3,"issue_type":"feature","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:29:05.325258+08:00","updated_at":"2026-05-25T14:47:58.717414+08:00","closed_at":"2026-05-25T14:47:58.717414+08:00","blocked_by":["SL-a9fb37","SL-b881e9"],"blocks":["SL-60678c"],"labels":["spec:001-init-origin-resolution","phase:us3","story:US3","component:cli"],"design":"Errors to stderr with what/why/fix + exit 1 (cli.md Principle 2). Quickstart: MalformedOrigin, NoOriginNonInteractive. Covers FR-012, FR-006a, FR-014.","parentId":"SL-227789"} +{"id":"SL-60678c","title":"Polish: agent skill + lint/test gate + docs","description":"WHY: Cross-cutting completion β€” Skill-CLI Co-Evolution, code-quality gate, and usage docs.","status":"closed","priority":3,"issue_type":"feature","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:29:05.340448+08:00","updated_at":"2026-05-26T10:04:28.057233+08:00","closed_at":"2026-05-26T10:04:28.057232+08:00","blocked_by":["SL-ec18e7","SL-ba7eaa","SL-d990f5"],"labels":["spec:001-init-origin-resolution","phase:polish","component:cli"],"design":"Constitution IX skillrig-init agent skill; gofmt/go vet/golangci-lint clean; full go test ./... (all TestQuickstart_*); README/usage note referencing docs website for config structure.","parentId":"SL-227789"} +{"id":"SL-1eabbd","title":"Initialize Go module and main.go entrypoint","description":"WHY: Project skeleton needed before any command code.","status":"closed","priority":1,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:06.158748+08:00","updated_at":"2026-05-25T14:28:15.620822+08:00","closed_at":"2026-05-25T14:28:15.620822+08:00","definition_of_done":{"items":[{"item":"go.mod created (module path, go 1.24)","checked":true,"verified_at":"2026-05-25T14:28:15.565232+08:00"},{"item":"main.go delegates to internal/cli.Execute and maps to os.Exit","checked":true,"verified_at":"2026-05-25T14:28:15.585104+08:00"},{"item":"go build ./... clean","checked":true,"verified_at":"2026-05-25T14:28:15.602026+08:00"}]},"labels":["spec:001-init-origin-resolution","phase:setup","component:cli"],"design":"go.mod: module github.com/skillrig/cli, go 1.24. main.go (package main): call internal/cli.Execute() and os.Exit(code). No logic in main.","acceptance_criteria":"go build ./... succeeds; running the binary with no args prints root help; exit 0.","parentId":"SL-a9fb37"} +{"id":"SL-19f6f3","title":"Cobra root command with persistent --json/--verbose","description":"WHY: Establish the progressive-discovery command skeleton all commands extend.","status":"closed","priority":1,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:06.179061+08:00","updated_at":"2026-05-25T14:28:21.965605+08:00","closed_at":"2026-05-25T14:28:21.965605+08:00","definition_of_done":{"items":[{"item":"root.go with Long + \u003e=2 examples","checked":true,"verified_at":"2026-05-25T14:28:21.913318+08:00"},{"item":"persistent --json/--verbose","checked":true,"verified_at":"2026-05-25T14:28:21.931479+08:00"},{"item":"SilenceUsage+SilenceErrors set","checked":true,"verified_at":"2026-05-25T14:28:21.948333+08:00"}]},"labels":["spec:001-init-origin-resolution","phase:setup","component:cli","requirement:FR-013"],"design":"internal/cli/root.go: cobra root Use 'skillrig', Long desc, Example block; persistent flags --json,--verbose; SilenceUsage=true, SilenceErrors=true; bare invocation prints help (cli.md Level-0). Export Execute().","acceptance_criteria":"skillrig --help shows Long + examples; --json/--verbose are persistent; Cobra does not print usage on RunE error.","parentId":"SL-a9fb37"} +{"id":"SL-3e3fa9","title":"Exit-code constants and error-to-exit mapping","description":"WHY: Load-bearing exit codes; usage/config errors must exit 1.","status":"closed","priority":1,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:06.196267+08:00","updated_at":"2026-05-25T14:28:22.022333+08:00","closed_at":"2026-05-25T14:28:22.022333+08:00","definition_of_done":{"items":[{"item":"exit.go with ExitOK/ExitUsage (+reserved 2/3)","checked":true,"verified_at":"2026-05-25T14:28:21.982617+08:00"},{"item":"UsageError type wraps raw cause","checked":true,"verified_at":"2026-05-25T14:28:21.99344+08:00"},{"item":"main maps error to exit code","checked":true,"verified_at":"2026-05-25T14:28:22.009357+08:00"}]},"labels":["spec:001-init-origin-resolution","phase:setup","component:cli","requirement:FR-017"],"design":"internal/cli/exit.go: ExitOK=0, ExitUsage=1; reserved ExitVerification=2, ExitPrereq=3 (commented, unused). UsageError type wrapping a cause. main.go maps returned error β†’ exit code (UsageErrorβ†’1, else 1).","acceptance_criteria":"Successful command exits 0; usage/config error exits 1; codes 2/3 declared but unused.","parentId":"SL-a9fb37"} +{"id":"SL-d11cfb","title":"Lint gate: .golangci.yml + gofmt/go vet","description":"WHY: Constitution V code-quality gate before merge.","status":"closed","priority":2,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:06.207731+08:00","updated_at":"2026-05-25T14:28:30.833432+08:00","closed_at":"2026-05-25T14:28:30.833432+08:00","definition_of_done":{"items":[{"item":".golangci.yml committed","checked":true,"verified_at":"2026-05-25T14:28:30.800768+08:00"},{"item":"lint/vet/fmt run clean","checked":true,"verified_at":"2026-05-25T14:28:30.816642+08:00"}]},"labels":["spec:001-init-origin-resolution","phase:setup","component:ci"],"design":".golangci.yml per golang-lint skill (gofmt, govet, staticcheck, revive baseline). Makefile or CI step: gofmt -l, go vet ./..., golangci-lint run.","acceptance_criteria":"golangci-lint run is clean on the skeleton; gofmt -l prints nothing; go vet ./... clean.","parentId":"SL-a9fb37"} +{"id":"SL-eb2528","title":"Output helper: human-compact vs --json (presentation layer)","description":"WHY: Two-level output; presentation must not leak into execution logic.","status":"closed","priority":2,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:06.218473+08:00","updated_at":"2026-05-25T14:28:22.056671+08:00","closed_at":"2026-05-25T14:28:22.056671+08:00","definition_of_done":{"items":[{"item":"output.go renders human + json from one result struct","checked":true,"verified_at":"2026-05-25T14:28:22.034055+08:00"},{"item":"no business logic in presentation","checked":true,"verified_at":"2026-05-25T14:28:22.045081+08:00"}]},"labels":["spec:001-init-origin-resolution","phase:setup","component:cli","requirement:FR-015","requirement:FR-016"],"design":"internal/cli/output.go: render(result, jsonFlag) β†’ compact human (stdout) + footer hint, or complete JSON object. No business logic. Result struct {ok,origin,scope,configPath,written}.","acceptance_criteria":"Human output is compact (\u003c=2 lines incl footer); --json emits complete object with all keys; data to stdout.","parentId":"SL-a9fb37"} +{"id":"SL-e2130a","title":"Origin type + ParseOrigin (OWNER/REPO) with unit tests","description":"WHY: Single validation point for the origin value.","status":"closed","priority":1,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:06.229609+08:00","updated_at":"2026-05-25T14:30:17.641362+08:00","closed_at":"2026-05-25T14:30:17.641362+08:00","definition_of_done":{"items":[{"item":"origin.go ParseOrigin + String","checked":true,"verified_at":"2026-05-25T14:30:17.607387+08:00"},{"item":"table-driven unit tests pass (gofmt/vet clean)","checked":true,"verified_at":"2026-05-25T14:30:17.624039+08:00"}]},"blocks":["SL-e69c8b","SL-2e4214"],"labels":["spec:001-init-origin-resolution","phase:foundational","component:config","requirement:FR-012"],"design":"internal/config/origin.go: Origin{Owner,Repo}; ParseOrigin(s) trims, matches ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$, errors with expected-format + offending value; String(). Table-driven unit tests (valid, malformed, whitespace, empty).","acceptance_criteria":"Valid OWNER/REPO parses; malformed rejected with format-naming error; whitespace trimmed; blank rejected.","parentId":"SL-b881e9"} +{"id":"SL-2ac8c8","title":"Config structs + TOML load/save (atomic write)","description":"WHY: Read/write the project and global config files.","status":"closed","priority":1,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:06.245197+08:00","updated_at":"2026-05-25T14:35:54.937387+08:00","closed_at":"2026-05-25T14:35:54.937387+08:00","definition_of_done":{"items":[{"item":"config.go load/save with atomic temp+rename","checked":true,"verified_at":"2026-05-25T14:35:54.886911+08:00"},{"item":"paths: project + XDG/~/.config global","checked":true,"verified_at":"2026-05-25T14:35:54.906056+08:00"},{"item":"round-trip unit tests pass","checked":true,"verified_at":"2026-05-25T14:35:54.924938+08:00"}]},"blocks":["SL-e69c8b","SL-2e4214"],"labels":["spec:001-init-origin-resolution","phase:foundational","component:config","requirement:FR-010"],"design":"internal/config/config.go: ProjectConfig/GlobalConfig{Origin string `toml:origin`}; paths: project ./.skillrig/config.toml (+git-root for write), global $XDG_CONFIG_HOME|~/.config/skillrig/config.toml via os.UserHomeDir; pelletier/go-toml/v2; atomic temp-in-target-dir + os.Rename; MkdirAll; unknown keys ignored on read. Unit tests (round-trip, missing, malformed).","acceptance_criteria":"Save writes origin-only TOML matching fixture; Load round-trips; missing/origin-less file β†’ empty (no error); unknown keys ignored.","parentId":"SL-b881e9"} +{"id":"SL-e69c8b","title":"ResolveOrigin single resolver (env \u003e project \u003e global)","description":"WHY: AP-06 β€” exactly one resolver every command calls.","status":"closed","priority":1,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:06.256557+08:00","updated_at":"2026-05-25T14:35:55.032897+08:00","closed_at":"2026-05-25T14:35:55.032897+08:00","definition_of_done":{"items":[{"item":"resolve.go single ResolveOrigin (AP-06)","checked":true,"verified_at":"2026-05-25T14:35:54.993557+08:00"},{"item":"pure/deterministic, injected env","checked":true,"verified_at":"2026-05-25T14:35:55.008029+08:00"},{"item":"depends on ParseOrigin + config load","checked":true,"verified_at":"2026-05-25T14:35:55.019525+08:00"}]},"blocked_by":["SL-e2130a","SL-2ac8c8"],"blocks":["SL-ca8e55"],"labels":["spec:001-init-origin-resolution","phase:foundational","component:config","requirement:FR-001","requirement:FR-003","requirement:FR-004"],"design":"internal/config/resolve.go: ResolveOrigin(cwd, env) β†’ ResolutionResult{Origin,Source,ConfigPath}. env SKILLRIG_ORIGIN (blank=unset) \u003e project walk-up to nearest .skillrig/config.toml \u003e global. Malformed source β†’ skip+continue (FR-004). Source=none is a normal return. Pure fn (cwd,env,fs).","acceptance_criteria":"Precedence env\u003eproject\u003eglobal; blank env unset; malformed source skipped; none returned when nothing configured.","parentId":"SL-b881e9"} +{"id":"SL-60a982","title":"Ground-truth fixtures: config.toml sample + precedence matrix","description":"WHY: Constitution III β€” anchor data model/tests to real output, not invented.","status":"closed","priority":2,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:06.267747+08:00","updated_at":"2026-05-25T14:35:54.980792+08:00","closed_at":"2026-05-25T14:35:54.980792+08:00","definition_of_done":{"items":[{"item":"real config.toml fixture committed","checked":true,"verified_at":"2026-05-25T14:35:54.954094+08:00"},{"item":"precedence matrix encoded for resolver tests","checked":true,"verified_at":"2026-05-25T14:35:54.967623+08:00"}]},"blocks":["SL-ca8e55"],"labels":["spec:001-init-origin-resolution","phase:foundational","component:config"],"design":"Capture a real config.toml written by Save as test/fixtures/config.toml; encode the data-model precedence matrix (rows 1-7 + FromSubdir) as the table-driven test source of truth.","acceptance_criteria":"Fixture config.toml is the actual Save output; precedence table matches data-model.md rows.","parentId":"SL-b881e9"} +{"id":"SL-4958e2","title":"Quickstart integration test harness (build + exec binary)","description":"WHY: TestQuickstart_* must exercise the real binary in isolation.","status":"closed","priority":1,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:52.441801+08:00","updated_at":"2026-05-25T14:44:59.526184+08:00","closed_at":"2026-05-25T14:44:59.526184+08:00","definition_of_done":{"items":[{"item":"TestMain builds binary","checked":true,"verified_at":"2026-05-25T14:44:59.489412+08:00"},{"item":"TempDir + HOME/XDG isolation helper","checked":true,"verified_at":"2026-05-25T14:44:59.508323+08:00"}]},"blocks":["SL-db8e96","SL-03ebb3","SL-3b4985"],"labels":["spec:001-init-origin-resolution","phase:us1","story:US1","test:integration","component:cli"],"notes":"Scope boundary: this task provides ONLY the base harness β€” TestMain build-once + TempDir/HOME/XDG isolation helper. The git-repo fixture helper (git init -q in tempdirs + nested subdirs, skip-if-git-absent) and the git-root write-target scenarios (TestQuickstart_BindFromGitSubdir / BindNonGitCwdFallback) are owned by SL-3b4985 (which this task blocks). Do NOT duplicate the git-fixture helper here; build on this harness from SL-3b4985.","design":"test/quickstart_test.go: TestMain builds the binary once (go build -o tmp/skillrig .); helper runs it via os/exec in t.TempDir() with HOME/XDG_CONFIG_HOME pointed at temp; per-scenario SKILLRIG_ORIGIN via exec Env.","acceptance_criteria":"Harness builds binary once and runs isolated, parallel-safe scenarios; stdout/stderr/exit captured.","parentId":"SL-ec18e7"} +{"id":"SL-db8e96","title":"Write failing TestQuickstart_* for init (US1) with output-shape asserts","description":"WHY: Quickstart-as-Contract β€” tests first, must fail before impl.","status":"closed","priority":1,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:52.462142+08:00","updated_at":"2026-05-26T13:18:46.111056+08:00","closed_at":"2026-05-25T14:44:59.584927+08:00","definition_of_done":{"items":[{"item":"quickstart.md scenario(s) match this story's user stories","checked":true,"verified_at":"2026-05-25T14:44:59.538357+08:00"},{"item":"TestQuickstart_BindProject/JSON/IdempotentRebind/RebindDifferent/Global/Help/PromptInteractive written and initially failing","checked":true,"verified_at":"2026-05-25T14:44:59.556657+08:00"},{"item":"output-shape assertions (human line-count, json keys) present","checked":true,"verified_at":"2026-05-25T14:44:59.572198+08:00"}]},"blocked_by":["SL-4958e2"],"blocks":["SL-2e4214"],"labels":["spec:001-init-origin-resolution","phase:us1","story:US1","test:integration","component:cli"],"notes":"Post-checkpoint clarification (adversarial finding A5): the DoD item 'TestQuickstart_PromptInteractive written' refers to the interactive-prompt scenario, which was realized IN-PROCESS as internal/cli/init_test.go::TestInit_PromptInteractive (injected interactive=true + SetIn) rather than as a binary-exec E2E test. Reason: the pty test dependency (creack/pty) is disallowed by the minimal-deps policy; the quickstart harness note explicitly sanctions 'a pty OR the harness's interactive shim'. Behavior is fully covered; only the test name/package differs from the DoD wording.","design":"test/quickstart_test.go: BindProject, BindProjectJSON, IdempotentRebind, RebindDifferent, Global, Help, PromptInteractive. Output-shape: human len(lines)\u003c=2; --json Unmarshal + keys ok/origin/scope/configPath/written present; help has Long+\u003e=2 examples.","acceptance_criteria":"All US1 quickstart scenarios encoded as tests; they FAIL before init exists; output-shape assertions included.","parentId":"SL-ec18e7"} +{"id":"SL-2e4214","title":"Implement skillrig init command","description":"WHY: Bind a repo (or global default) to an existing origin.","status":"closed","priority":1,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:52.478008+08:00","updated_at":"2026-05-25T14:45:08.178066+08:00","closed_at":"2026-05-25T14:45:08.178066+08:00","definition_of_done":{"items":[{"item":"quickstart.md scenario(s) match this story's user stories","checked":true,"verified_at":"2026-05-25T14:45:08.113836+08:00"},{"item":"TestQuickstart_BindProject/JSON/IdempotentRebind/RebindDifferent/Global/Help/PromptInteractive/BindFromGitSubdir/BindNonGitCwdFallback pass (go test)","checked":true,"verified_at":"2026-05-25T14:45:08.130731+08:00"},{"item":"init collects only origin; git-root write target (git rev-parse --show-toplevel, offline) with cwd fallback","checked":true,"verified_at":"2026-05-25T14:45:08.14746+08:00"},{"item":"--non-interactive forces fail-fast without prompt (FR-006c): TestQuickstart_NonInteractiveFlag passes","checked":true,"verified_at":"2026-05-25T14:45:08.159904+08:00"}]},"blocked_by":["SL-db8e96","SL-e2130a","SL-2ac8c8"],"blocks":["SL-05dbc5","SL-3b4985"],"labels":["spec:001-init-origin-resolution","phase:us1","story:US1","component:cli","requirement:FR-005","requirement:FR-006","requirement:FR-006a","requirement:FR-006b","requirement:FR-006c","requirement:FR-007","requirement:FR-008","requirement:FR-009","requirement:FR-013","requirement:FR-016"],"design":"internal/cli/init.go (Environment pattern): flags --origin/--global/--json/--verbose/--non-interactive; resolve write target (git-root via git rev-parse --show-toplevel, cwd fallback) or global path; origin from flag else prompt (stdlib bufio) iff interactive TTY and not --non-interactive; ParseOrigin; idempotent compare (written false on no-op); config.Save atomic; emit via output helper. \u003e=2 Examples in Long.","acceptance_criteria":"Binds project + global; idempotent re-bind; rebind replaces; --json complete; collects origin only (FR-006b).","parentId":"SL-ec18e7"} +{"id":"SL-ca8e55","title":"TestResolveOrigin precedence table (rows 1-7 + FromSubdir)","description":"WHY: Validate env\u003eproject\u003eglobal precedence is correct and deterministic.","status":"closed","priority":2,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:52.490235+08:00","updated_at":"2026-05-25T14:46:20.440979+08:00","closed_at":"2026-05-25T14:46:20.440979+08:00","definition_of_done":{"items":[{"item":"quickstart.md scenario(s) match this story's user stories","checked":true,"verified_at":"2026-05-25T14:46:20.389323+08:00"},{"item":"TestResolveOrigin_Precedence rows 1-7 + FromSubdir pass (go test)","checked":true,"verified_at":"2026-05-25T14:46:20.407579+08:00"},{"item":"uses real fixture files + injected env (no mocks)","checked":true,"verified_at":"2026-05-25T14:46:20.42663+08:00"}]},"blocked_by":["SL-60a982","SL-e69c8b"],"labels":["spec:001-init-origin-resolution","phase:us2","story:US2","test:integration","component:config","requirement:FR-002"],"design":"internal/config/resolve_test.go: table-driven over data-model matrix rows 1-7 (incl. blank-env-unset, malformed-skip) + TestResolveOrigin_FromSubdir (walk-up). Real fixture files in temp dirs; injected env. Assert Origin, Source, ConfigPath per row.","acceptance_criteria":"All 7 rows + FromSubdir pass; resolver matches the recorded matrix exactly.","parentId":"SL-ba7eaa"} +{"id":"SL-03ebb3","title":"Write failing error-path tests (US3) with three-part assertions","description":"WHY: Failures must be actionable β€” assert the shape, not a substring.","status":"closed","priority":3,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:52.502403+08:00","updated_at":"2026-05-25T14:47:58.661234+08:00","closed_at":"2026-05-25T14:47:58.661234+08:00","definition_of_done":{"items":[{"item":"quickstart.md scenario(s) match this story's user stories","checked":true,"verified_at":"2026-05-25T14:47:58.627378+08:00"},{"item":"TestQuickstart_MalformedOrigin + NoOriginNonInteractive + NonInteractiveFlag written, initially failing","checked":true,"verified_at":"2026-05-25T14:47:58.639246+08:00"},{"item":"three-part (what/why/fix) distinct assertions + exit 1; --non-interactive overrides TTY (FR-006c)","checked":true,"verified_at":"2026-05-25T14:47:58.650531+08:00"}]},"blocked_by":["SL-4958e2"],"blocks":["SL-05dbc5"],"labels":["spec:001-init-origin-resolution","phase:us3","story:US3","test:integration","component:cli","requirement:FR-006a","requirement:FR-006c"],"design":"test/quickstart_test.go: TestQuickstart_MalformedOrigin, TestQuickstart_NoOriginNonInteractive. Assert exit 1, empty stdout, and stderr contains THREE distinct parts (what failed / why / fix) as separate checks.","acceptance_criteria":"Error scenarios encoded; assert exit code + 3 distinct error parts; fail before impl.","parentId":"SL-d990f5"} +{"id":"SL-05dbc5","title":"Implement errors-as-navigation for init/resolve failures","description":"WHY: Cryptic errors waste agent cycles; every failure points to the fix.","status":"closed","priority":3,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:52.514021+08:00","updated_at":"2026-05-25T14:47:58.706493+08:00","closed_at":"2026-05-25T14:47:58.706493+08:00","definition_of_done":{"items":[{"item":"quickstart.md scenario(s) match this story's user stories","checked":true,"verified_at":"2026-05-25T14:47:58.672326+08:00"},{"item":"TestQuickstart_MalformedOrigin + NoOriginNonInteractive pass","checked":true,"verified_at":"2026-05-25T14:47:58.684309+08:00"},{"item":"errors to stderr, raw cause preserved, exit 1","checked":true,"verified_at":"2026-05-25T14:47:58.69501+08:00"}]},"blocked_by":["SL-03ebb3","SL-2e4214"],"labels":["spec:001-init-origin-resolution","phase:us3","story:US3","component:cli","requirement:FR-014"],"design":"Render errors to stderr with what failed + real cause (preserved) + suggested fix; exit 1. Cases: malformed origin (echo value + expected format + example), no-origin-configured (init or SKILLRIG_ORIGIN), missing --origin in non-interactive (name the flag). cli.md Principle 2.","acceptance_criteria":"All three failures exit 1 with what/why/fix to stderr; raw cause preserved.","parentId":"SL-d990f5"} +{"id":"SL-0990e2","title":"Author skillrig-init agent skill (Skill-CLI Co-Evolution)","description":"WHY: Constitution IX β€” every CLI feature ships a skill with accurate trigger keywords.","status":"closed","priority":3,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:52.525211+08:00","updated_at":"2026-05-26T10:04:17.393814+08:00","closed_at":"2026-05-26T10:04:17.393814+08:00","definition_of_done":{"items":[{"item":"skill authored with init/origin trigger keywords","checked":true,"verified_at":"2026-05-25T14:51:21.755583+08:00"},{"item":"trigger accuracy verified via skill-creator evals","checked":true,"verified_at":"2026-05-26T10:04:17.37402+08:00"}]},"labels":["spec:001-init-origin-resolution","phase:polish","component:skill"],"design":"Create the agent skill for init/origin. Description keywords reflect real phrasing: 'point this repo at our skills library', 'set the origin', 'SKILLRIG_ORIGIN', 'no origin configured'. Use skill-creator to test trigger accuracy + run evals.","acceptance_criteria":"Skill exists with accurate description/keywords; trigger eval passes.","parentId":"SL-60678c"} +{"id":"SL-1819a2","title":"Full gate green: gofmt/vet/golangci-lint + go test ./...","description":"WHY: Feature DONE only when the whole quickstart suite + lint pass.","status":"closed","priority":3,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:52.535668+08:00","updated_at":"2026-05-25T14:51:21.707969+08:00","closed_at":"2026-05-25T14:51:21.707969+08:00","definition_of_done":{"items":[{"item":"gofmt/go vet/golangci-lint clean","checked":true,"verified_at":"2026-05-25T14:51:21.677925+08:00"},{"item":"go test ./... all green (quickstart suite passes)","checked":true,"verified_at":"2026-05-25T14:51:21.693237+08:00"}]},"labels":["spec:001-init-origin-resolution","phase:polish","component:ci"],"design":"Run gofmt -l (empty), go vet ./..., golangci-lint run (clean), go test ./... (all TestQuickstart_* + TestResolveOrigin_* green).","acceptance_criteria":"All lints clean; go test ./... green incl. full quickstart suite.","parentId":"SL-60678c"} +{"id":"SL-804dc6","title":"Usage docs: README note referencing config docs website","description":"WHY: Document init/origin usage; config structure lives on the docs website (not restated).","status":"closed","priority":3,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-24T23:30:52.546297+08:00","updated_at":"2026-05-25T14:51:21.743429+08:00","closed_at":"2026-05-25T14:51:21.743429+08:00","definition_of_done":{"items":[{"item":"README usage note added","checked":true,"verified_at":"2026-05-25T14:51:21.719541+08:00"},{"item":"links to docs website for config structure","checked":true,"verified_at":"2026-05-25T14:51:21.73133+08:00"}]},"labels":["spec:001-init-origin-resolution","phase:polish","component:docs"],"design":"README/usage section: skillrig init examples, precedence order, SKILLRIG_ORIGIN; link to the docs website for full config.toml structure (per spec clarification).","acceptance_criteria":"README documents init + precedence + env override; links to docs website for config structure.","parentId":"SL-60678c"} +{"id":"SL-3b4985","title":"Git-repo fixtures + git-root write-target integration tests","description":"WHY: init writes the project config at the git repo root (git rev-parse --show-toplevel, offline); git is a required dependency. Tests must prove the git-root write target and the non-git cwd fallback.","status":"closed","priority":1,"issue_type":"task","spec_context":"001-init-origin-resolution","created_at":"2026-05-25T10:29:44.632553+08:00","updated_at":"2026-05-25T14:45:08.096196+08:00","closed_at":"2026-05-25T14:45:08.096196+08:00","blocked_by":["SL-4958e2"],"blocks":["SL-2e4214"],"labels":["spec:001-init-origin-resolution","phase:us1","story:US1","component:test","requirement:FR-005","requirement:FR-010"],"design":"Test harness helpers that create throwaway git repos in t.TempDir() via 'git init -q' + nested subdirs; skip-with-message if git absent. Cover TestQuickstart_BindFromGitSubdir (config lands at repo root, not cwd subdir; resolver walk-up symmetry) and TestQuickstart_BindNonGitCwdFallback (cwd fallback when not in a git repo).","acceptance_criteria":"git fixtures build real repos in tempdirs; TestQuickstart_BindFromGitSubdir + BindNonGitCwdFallback pass; git documented as required dependency.","parentId":"SL-ec18e7"} diff --git a/specledger/001-init-origin-resolution/plan.md b/specledger/001-init-origin-resolution/plan.md new file mode 100644 index 0000000..bcd38a8 --- /dev/null +++ b/specledger/001-init-origin-resolution/plan.md @@ -0,0 +1,84 @@ +# Implementation Plan: CLI Initialization & Origin Resolution + +**Branch**: `001-init-origin-resolution` | **Date**: 2026-05-24 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specledger/001-init-origin-resolution/spec.md` + +## Summary + +Deliver the first slice of the generic `skillrig` CLI: a `skillrig init` command (Environment pattern) that records a chosen origin into project or global config, plus the **single** origin-resolution primitive (`config.ResolveOrigin`) every later command depends on. Resolution precedence is env `SKILLRIG_ORIGIN` > project `.skillrig/config.toml` > global `~/.config/skillrig/config.toml`. Scope is offline config bootstrap only β€” no network, no auth, no lockfile, no other verbs. This branch also establishes the Go/Cobra project skeleton and the baseline command experience (progressive help, errors-as-navigation, two-level output, load-bearing exit codes) that all subsequent commands inherit, per [docs/design/cli.md](../../docs/design/cli.md) and architecture Β§2/Β§2b/Β§2d. + +## Technical Context + +**Language/Version**: Go 1.24+ (toolchain in this environment is 1.24.4; 1.25 also fine) β€” single static binary; cross-OS/arch via goreleaser later, out of scope here +**Primary Dependencies**: `github.com/spf13/cobra` (command tree); `github.com/pelletier/go-toml/v2` (config read/write β€” see research.md). Dependencies kept minimal (consume-only, static binary). +**Runtime dependency (required)**: **`git`** must be on `PATH`. `init` locates the project config write target at the repo root via `git rev-parse --show-toplevel` β€” a fully **offline** call (reads the local `.git`, no network/fetch/clone). Outside a git repo, it falls back to `cwd/.skillrig/config.toml`. This is the framework's one external-tool dependency for this feature; later features (`bump --pr`) add more git/`gh` use. +**Storage**: Local files only β€” project `.skillrig/config.toml` (written at the git repo root), global `~/.config/skillrig/config.toml` (XDG-aware). No database, no network. +**Testing**: Go standard `go test`. Two tiers β€” (a) in-process Cobra unit tests via `SetArgs`/`SetOut`/`SetErr` + table-driven resolver tests; (b) `TestQuickstart_*` integration tests that build and exec the real binary (Constitution II/III). +**Target Platform**: macOS/Linux/Windows terminals, CI, and agent runners. Symlink/Windows concerns are not in this feature's scope. +**Project Type**: single project (CLI binary). +**Performance Goals**: Sub-100ms for `init` and resolution (fully offline; cli.md records no per-command duration metadata, so this is a soft target, not an SC). +**Constraints**: Offline-only (incl. `git rev-parse` β€” local, no fetch/clone); no auth/credentials; consume-only (no bootstrap of an origin); idempotent; deterministic; `git` required on `PATH` (write-target resolution). Exit codes for this feature: `0` success, `1` usage/config error. Codes `2` (verification) and `3` (prerequisite) are reserved for later commands. +**Scale/Scope**: Small β€” one command (`init`), one resolver primitive, the root command skeleton, and config read/write. ~Hundreds of LOC. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.* + +Verify compliance with principles from `.specledger/memory/constitution.md` (v2.1.0): + +- [x] **I. Specification-First**: spec.md complete, clarified (reviewer comments resolved), prioritized user stories P1–P3. +- [x] **II. Quickstart-as-Contract**: quickstart.md authored as executable scenarios mapping 1:1 to `TestQuickstart_` integration tests; includes output-shape assertions (compact human line-count bound; `--json` parseable + structurally complete; error output asserts what/why/fix as distinct checks + exit code). +- [x] **III. Ground-Truth Anchoring**: data-model fixtures derived from a real captured `config.toml` and a recorded resolution-precedence matrix (golang-testing fixtures/table-driven/integration patterns). No network boundary here, so no httptest/go-vcr. +- [x] **IV. Agent-First CLI Design**: `init` classified as the **Environment** pattern (idempotent, consume-only); progressive `--help` with β‰₯2 examples; errors-as-navigation (what/why/fix, raw error preserved, stderr); two-level output (human compact default + footer hint, `--json` complete); standard flags `--json`/`--verbose`; load-bearing exit codes. The origin resolver is **one** implementation (AP-06). +- [x] **V. Code Quality (Go)**: `gofmt` + `go vet` + `golangci-lint` gate; idiomatic Go; execution logic (config/resolve) independent of presentation (cli output). +- [x] **VI. YAGNI**: origin-only; no `config` command; no extra metadata (repo tags/suggestions); codes 2/3 deferred. +- [x] **VII. Shortest Path to MVP**: minimum skeleton + `init` + resolver; nothing speculative. +- [x] **VIII. Simplicity Over Cleverness**: boring, obvious Go; plain struct + TOML marshal; no reflection tricks. +- [x] **IX. Skill–CLI Co-Evolution**: a corresponding agent skill (`skillrig-init` usage skill) is planned as a task; description keywords cover "point repo at our skills library / set origin / SKILLRIG_ORIGIN" + the no-origin failure mode. + +**Complexity Violations**: None identified. + +## Project Structure + +### Documentation (this feature) + +```text +specledger/001-init-origin-resolution/ +β”œβ”€β”€ plan.md # This file +β”œβ”€β”€ research.md # Phase 0 output +β”œβ”€β”€ data-model.md # Phase 1 output +β”œβ”€β”€ quickstart.md # Phase 1 output β€” executable integration-test scenarios (Constitution II) +β”œβ”€β”€ contracts/ # Phase 1 output β€” CLI command + resolver contracts +β”‚ β”œβ”€β”€ init.md # `skillrig init` command surface +β”‚ └── resolve.md # ResolveOrigin precedence contract +└── tasks.md # Phase 2 output (/specledger.tasks β€” not created here) +``` + +### Source Code (repository root) + +Module path: `github.com/skillrig/cli`. + +```text +. +β”œβ”€β”€ main.go # package main; thin β†’ internal/cli.Execute(); os.Exit(code) +β”œβ”€β”€ go.mod / go.sum +β”œβ”€β”€ .golangci.yml # lint config (Constitution V) +β”œβ”€β”€ internal/ +β”‚ β”œβ”€β”€ cli/ +β”‚ β”‚ β”œβ”€β”€ root.go # cobra root cmd; persistent --json/--verbose; help template +β”‚ β”‚ β”œβ”€β”€ init.go # `skillrig init` (Environment pattern) +β”‚ β”‚ β”œβ”€β”€ exit.go # exit-code constants: ExitOK=0, ExitUsage=1 (2/3 reserved, documented) +β”‚ β”‚ └── output.go # presentation: human compact + footer hint vs --json (no business logic) +β”‚ └── config/ +β”‚ β”œβ”€β”€ origin.go # Origin type + ParseOrigin (OWNER/REPO validation) +β”‚ β”œβ”€β”€ config.go # ProjectConfig/GlobalConfig structs; paths; TOML load/save (atomic write) +β”‚ └── resolve.go # ResolveOrigin(cwd, env) β€” THE single resolver (AP-06) +└── test/ + └── quickstart_test.go # TestQuickstart_* β€” build + exec the real binary (Constitution II) +``` + +**Structure Decision**: Single Go module rooted at the repo. Business logic lives in `internal/config` (origin parsing, config I/O, resolution); the CLI/presentation layer lives in `internal/cli` and must not leak output-format concerns into `internal/config` (Constitution V; cli.md Execution-vs-Presentation). `ResolveOrigin` is the sole resolver (AP-06) so every future command (`search`, `add`, `verify`, …) calls it rather than re-reading config. `main.go` is a thin shim that maps the returned error/exit code to `os.Exit`. The architecture's `skillcore` is **not** introduced in this feature (no tree-SHA/integrity work yet) β€” only `config`. + +## Complexity Tracking + +> No constitutional violations to justify. Table intentionally empty. diff --git a/specledger/001-init-origin-resolution/quickstart.md b/specledger/001-init-origin-resolution/quickstart.md new file mode 100644 index 0000000..3cd6874 --- /dev/null +++ b/specledger/001-init-origin-resolution/quickstart.md @@ -0,0 +1,177 @@ +# Quickstart: CLI Initialization & Origin Resolution + +> **Quickstart-as-Contract (Constitution II)**: every scenario below is an executable acceptance test. Each maps **1:1** to a Go test named `TestQuickstart_` (CLI scenarios exec the built binary) or `TestResolveOrigin_` (resolver scenarios run the single resolver against real fixture files on a temp filesystem β€” no mocks). A scenario is DONE only when its test passes. Output-shape assertions are mandatory where noted. + +## Setup (test harness) + +- `TestMain` builds the binary once (`go build -o $TMP/skillrig .`) and runs all CLI scenarios against it via `os/exec`. +- Each scenario runs in its own `t.TempDir()` as `cwd`, with `HOME`/`XDG_CONFIG_HOME` pointed at the temp dir so project and global config are isolated and parallel-safe. +- `SKILLRIG_ORIGIN` is set per-scenario via the exec `Env` (never the process env). +- **Git fixtures**: git-root scenarios initialise a throwaway repo in the tempdir (`git init -q`) and create nested subdirs, then run `skillrig init` from a subdir to assert the config lands at the repo root. A non-git tempdir asserts the cwd fallback. `git` is a required dependency of both the harness and skillrig itself (`git rev-parse --show-toplevel`, offline only β€” no fetch/clone). A helper skips git scenarios with a clear message if `git` is absent from PATH. + +Conventions: **stdout** = data, **stderr** = errors/prompts, **exit** = code. + +--- + +## Part A β€” `skillrig init` (CLI E2E, exec the binary) + +### TestQuickstart_BindProject (US1 / FR-005, FR-010) +``` +$ skillrig init --origin my-org/my-skills +``` +- **exit**: 0 +- **stdout** (human, compact): line 1 contains `bound origin my-org/my-skills` and `project`; line 2 is the `β†’ resolve order:` footer hint. +- **file** `./.skillrig/config.toml` equals fixture (`test/fixtures/config.toml`): + ```toml + origin = 'my-org/my-skills' + ``` + (TOML literal-string form β€” the real `go-toml/v2` output; see data-model.md ground-truth note G1.) +- **shape assert**: `len(stdoutLines) <= 2`. + +### TestQuickstart_BindProjectJSON (US1 / FR-016 β€” output-shape) +``` +$ skillrig init --origin my-org/my-skills --json +``` +- **exit**: 0 +- **stdout**: single JSON object. +- **shape assert**: `json.Unmarshal` succeeds AND keys `ok, origin, scope, configPath, written` all present; `ok==true`, `origin=="my-org/my-skills"`, `scope=="project"`, `written==true`. + +### TestQuickstart_IdempotentRebind (US1 / FR-008) +``` +$ skillrig init --origin my-org/my-skills +$ skillrig init --origin my-org/my-skills # second run +``` +- **exit** (both): 0 +- **2nd stdout**: contains `already bound` / `no change`. +- **2nd `--json`** (variant) β†’ `written==false`. +- **file**: unchanged between runs (byte-equal). + +### TestQuickstart_RebindDifferent (US1 / FR-009) +``` +$ skillrig init --origin my-org/my-skills +$ skillrig init --origin other-org/other-skills +``` +- **exit**: 0 +- **file** now equals `origin = "other-org/other-skills"` (cleanly replaced, no duplicate keys). + +### TestQuickstart_Global (US1 / FR-007) +``` +$ skillrig init --origin my-org/my-skills --global +``` +- **exit**: 0 +- **file**: `$XDG_CONFIG_HOME/skillrig/config.toml` (or `~/.config/skillrig/config.toml`) equals the origin fixture. +- **assert**: `./.skillrig/config.toml` does **not** exist (repo config untouched). +- **`--json`**: `scope=="global"`. + +### TestQuickstart_BindFromGitSubdir (US1 / FR-005, FR-010 β€” git-root write target) +> Initialise a git repo in the tempdir and run `init` from a nested subdir. (Skipped with a clear message if `git` is not on PATH.) +``` +$ git init -q && mkdir -p a/b/c +$ cd a/b/c && skillrig init --origin my-org/my-skills +``` +- **exit**: 0 +- **file**: `/.skillrig/config.toml` equals the origin fixture β€” written at the **git root**, NOT at `a/b/c/.skillrig/config.toml`. +- **assert**: no `.skillrig/` dir is created under `a/b/c`. +- **resolve-symmetry**: `ResolveOrigin(cwd=a/b/c, env)` finds it via walk-up β†’ `Source==project`, `ConfigPath==/.skillrig/config.toml`. + +### TestQuickstart_BindNonGitCwdFallback (US1 / FR-010 β€” cwd fallback) +> Non-git tempdir: write target falls back to cwd. +``` +$ skillrig init --origin my-org/my-skills # tempdir is NOT a git repo +``` +- **exit**: 0 +- **file**: `./.skillrig/config.toml` (in cwd) equals the origin fixture. + +### TestQuickstart_MalformedOrigin (US3 / FR-012 β€” error-shape) +``` +$ skillrig init --origin not-a-valid-origin +``` +- **exit**: 1 +- **stdout**: empty. +- **stderr** asserts **three distinct parts**: (a) names the failure / echoes `not-a-valid-origin`; (b) states expected `OWNER/REPO`; (c) shows a concrete fix example. +- **assert**: no `.skillrig/config.toml` written. + +### TestQuickstart_NoOriginNonInteractive (US3 / FR-006a β€” error-shape) +``` +$ skillrig init # stdin is NOT a TTY (piped /dev/null) +``` +- **exit**: 1 +- **stderr** three parts: (a) no origin given; (b) non-interactive session (no TTY); (c) fix = pass `--origin OWNER/REPO` or set `SKILLRIG_ORIGIN`. +- **assert**: no config written. + +### TestQuickstart_NonInteractiveFlag (US3 / FR-006c β€” forced no-prompt, error-shape) +> stdin **is** an interactive TTY (pty), but `--non-interactive` forces fail-fast. Asserts the flag overrides TTY auto-detection β€” the distinct intent behind FR-006c. +``` +$ skillrig init --non-interactive # interactive TTY present, no --origin +``` +- **exit**: 1 +- **stderr** three parts: (a) no origin given; (b) non-interactive mode requested (`--non-interactive`); (c) fix = pass `--origin OWNER/REPO` or set `SKILLRIG_ORIGIN`. +- **assert**: the prompt string `Origin (OWNER/REPO):` is **NOT** emitted (no blocking read); no config written. + +### TestQuickstart_PromptInteractive (US1 / FR-006a β€” interactive path) +> Interactive TTY simulated by feeding stdin and signaling interactive mode in the harness (e.g. a pty or the harness's interactive shim). +``` +$ printf 'my-org/my-skills\n' | skillrig init # interactive +``` +- **exit**: 0 +- **stderr**: contains the prompt `Origin (OWNER/REPO):`. +- **file**: `./.skillrig/config.toml` equals the origin fixture. + +### TestQuickstart_Help (FR-013 β€” Progressive Discovery) +``` +$ skillrig init --help +``` +- **exit**: 0 +- **shape assert**: help text contains a `Long` description AND **β‰₯2** `skillrig init` example lines. + +--- + +## Part B β€” Origin resolution precedence (resolver integration, real fixture files) + +Backed by the recorded matrix in `data-model.md`. `TestResolveOrigin_Precedence` is table-driven over rows 1–7; each row materializes the indicated real files in a temp dir and calls `ResolveOrigin(cwd, env)`. + +### TestResolveOrigin_Row1_None (FR-003) +No env, no project, no global β†’ `Source==none`, zero origin. (Caller turns this into the US3 error.) + +### TestResolveOrigin_Row2_Project +project `origin=my-org/my-skills`, no env/global β†’ `my-org/my-skills`, `Source==project`. + +### TestResolveOrigin_Row3_EnvBeatsProject (US2 β€” key precedence case) +`SKILLRIG_ORIGIN=ci-org/ci-skills` + project `my-org/my-skills` β†’ `ci-org/ci-skills`, `Source==env`. + +### TestResolveOrigin_Row4_Global +only global `personal/skills` β†’ `personal/skills`, `Source==global`. + +### TestResolveOrigin_Row5_ProjectBeatsGlobal (US2 β€” contractor case) +project `client-a/skills` + global `personal/skills` β†’ `client-a/skills`, `Source==project`. + +### TestResolveOrigin_Row6_BlankEnvIsUnset +`SKILLRIG_ORIGIN=""` (blank) + project `my-org/my-skills` β†’ `my-org/my-skills`, `Source==project`. + +### TestResolveOrigin_Row7_MalformedProjectSkipped (FR-004) +unparseable project `config.toml` + global `personal/skills` β†’ `personal/skills`, `Source==global`; resolution does not error on the bad file. + +### TestResolveOrigin_FromSubdir (US2 / SC-002) +project config at `/.skillrig/config.toml`; call `ResolveOrigin` with `cwd=/a/b/c` β†’ resolves `my-org/my-skills` via walk-up, `Source==project`. + +--- + +## Coverage map (scenario β†’ requirement) + +| Scenario | Covers | +|----------|--------| +| BindProject / BindProjectJSON | US1, FR-005, FR-010, FR-016 | +| IdempotentRebind | FR-008, SC-005 | +| RebindDifferent | FR-009 | +| Global | FR-007 | +| BindFromGitSubdir | US1, FR-005, FR-010, SC-002 (git-root write target) | +| BindNonGitCwdFallback | US1, FR-010 (cwd fallback when not in a git repo) | +| MalformedOrigin | FR-012, FR-014, SC-004 | +| NoOriginNonInteractive | FR-006a, FR-014, SC-004 | +| NonInteractiveFlag | US3, FR-006c, FR-014, SC-004 | +| PromptInteractive | FR-006a | +| Help | FR-013, SC-006 | +| ResolveOrigin_Row1–7 | FR-001, FR-002, FR-003, FR-004, SC-003 | +| ResolveOrigin_FromSubdir | US2, SC-002 | + +All scenarios are offline and deterministic (Constitution III / IV). The full suite passing == feature DONE (Constitution II). diff --git a/specledger/001-init-origin-resolution/research.md b/specledger/001-init-origin-resolution/research.md new file mode 100644 index 0000000..c81a977 --- /dev/null +++ b/specledger/001-init-origin-resolution/research.md @@ -0,0 +1,103 @@ +# Phase 0 Research: CLI Initialization & Origin Resolution + +## Prior Work + +`sl issue list --all` β†’ no issues; this is the first feature in the project. No prior plan/tasks to reconcile. Authoritative references: `architecture.md` Β§2 (command surface), Β§2b (consume-only), Β§2d (origin discovery + config/lock split + `init` semantics); `docs/design/cli.md` (binding CLI contract); constitution v2.1.0. + +--- + +## D1 β€” TOML library + +**Decision**: `github.com/pelletier/go-toml/v2`. +**Rationale**: Actively maintained, fast, `encoding/json`-style `Marshal`/`Unmarshal` with struct tags, good error messages (helps FR-004 "clear diagnostic, not raw parser dump"), no cgo (keeps the static-binary goal). Matches architecture's `config.toml` decision. +**Alternatives**: `BurntSushi/toml` (mature but slower, less ergonomic v2 API); hand-rolled parser (rejected β€” YAGNI, error-prone on edge cases). + +## D2 β€” Global config location + +**Decision**: `$XDG_CONFIG_HOME/skillrig/config.toml`, falling back to `~/.config/skillrig/config.toml` on **all** platforms. Resolve `~` via stdlib `os.UserHomeDir()` (cross-platform). Do NOT use `os.UserConfigDir()`. +**Rationale**: Architecture Β§2d names `~/.config/skillrig/config.toml` explicitly, git-style. `os.UserConfigDir()` returns `~/Library/Application Support` on macOS and `%AppData%` on Windows, which would diverge from the documented path. Consistency with the documented contract beats per-OS idiom here. + +**OS / shell support (v0 note β€” review comment ec7c2bdb).** `XDG_CONFIG_HOME` is an environment variable from the freedesktop XDG Base Directory spec β€” it is **not shell-specific** (any process inherits it if the environment sets it; shells/login managers commonly export it on Linux): +- **Linux/BSD**: native convention. Honored when set, else `~/.config`. βœ… idiomatic. +- **macOS**: XDG is not an OS standard (Apple uses `~/Library/Application Support`), but `XDG_CONFIG_HOME` is usually unset so we land on `~/.config`, which `git`/`gh` also use. βœ… works, widely accepted for CLI tools. +- **Windows**: `~/.config` is non-idiomatic (the convention is `%AppData%`). `os.UserHomeDir()` returns `%USERPROFILE%`, so we'd write `%USERPROFILE%\.config\skillrig\config.toml`. ⚠️ functional but unconventional β€” **explicit v0 caveat**; revisit Windows path idiom if/when Windows is a first-class target. +**Alternatives**: `os.UserConfigDir()` (rejected β€” diverges from the architecture path on macOS/Windows). `adrg/xdg` library (handles per-OS XDG resolution incl. Windows Known Folders) β€” **deferred**: adds a dependency for what `os.UserHomeDir()` + the `XDG_CONFIG_HOME` env check cover in v0; reconsider when Windows idiomatic paths matter. + +## D3 β€” Project config discovery (resolution) vs write location (init) + +**Decision** (revised per review comment 0aec14ed): +- **Resolution** walks up from `cwd` to the nearest ancestor containing `.skillrig/config.toml` (git-style discovery), so any subdirectory of a bound repo resolves the same origin (supports SC-002 "self-describing repo"). This is a **pure-filesystem** walk β€” no subprocess, deterministic, and it still works in a not-yet-`git init`'d directory or an agent sandbox. +- **`init` (write)** writes to `.skillrig/config.toml` at the **git repository root** when the cwd is inside a git work tree (`git rev-parse --show-toplevel`), otherwise falls back to `./.skillrig/config.toml` at cwd (creating `.skillrig/` if needed, FR-010). +**Rationale**: The earlier "no git dependency" framing was wrong for this framework β€” skillrig is **git-native** (it vendors skills from git origins, architecture Β§2/Β§5), so git is a safe baseline assumption and using it to anchor the write at the repo root avoids the foot-gun of writing `.skillrig/` into a random subdirectory. Resolution stays git-*independent* on purpose (faster, no subprocess, robust pre-`git init`), so the two jobs use the right tool each: write = locate the repo root (git when available); resolve = filesystem walk-up. +**Alternatives**: cwd-only write (rejected β€” writes config in a subdir if run from one); cwd-only resolution (rejected β€” breaks resolution from subdirectories); requiring git for *resolution* (rejected β€” resolution must work offline/subprocess-free and in non-repo dirs). +**Open**: if `git` is genuinely absent and cwd is not a repo, `init` falls back to cwd β€” confirm this fallback is acceptable vs. erroring (leaning: fall back, since the resolver finds it by walk-up regardless). + +## D4 β€” Interactive vs non-interactive model (flags + detection) + +**Decision** (revised per review comment 8019052e): +- **Flags carry the values**; missing required values (the origin) are *prompted for* when interactive. +- **Auto-detect** interactivity: a session is interactive iff **stdin is a character device** β€” `fileInfo, _ := os.Stdin.Stat(); interactive := fileInfo.Mode()&os.ModeCharDevice != 0` (stdlib). +- **Explicit `--non-interactive` flag** overrides detection: it forces non-interactive even on a TTY, and when a required value is missing it **errors describing exactly which flag(s) to pass** (e.g. "missing origin: pass `--origin OWNER/REPO`"). This gives agents/CI a deterministic "never prompt" switch independent of TTY heuristics. +- Resolution of "should I prompt?": prompt **iff** value missing AND interactive (TTY) AND not `--non-interactive`; otherwise error with the missing-flag guidance (FR-006a). +**Rationale**: TTY detection alone is a heuristic; an explicit `--non-interactive` flag is the contract agents rely on (agentic-cli-design P2) and makes the missing-input error path testable. Stdlib detection avoids a dependency. +**Alternatives**: `golang.org/x/term.IsTerminal` (adds a dep for what stdlib covers); detection-only with no explicit flag (rejected β€” agents want an explicit switch). + +## D5 β€” Prompting mechanism βœ… (S1 spike resolved) + +**Decision**: When prompting is warranted (D4), prompt once on **stderr** (`Origin (OWNER/REPO): `) and read one line via stdlib **`bufio.Scanner`**. Empty/invalid β†’ usage/config error (exit 1), no re-prompt loop. Prompt on stderr keeps stdout clean for machine consumers (cli.md Rule 5). +**Rationale (resolved by spike S1 β€” measured)**: see [research/2026-05-24-interactive-prompt-library.md](research/2026-05-24-interactive-prompt-library.md). Measured stripped-binary deltas over our cobra+go-toml/v2 baseline (2.57 MB): stdlib **+0**, promptui **+768 B**, survey **+0.23 MB / +39 modules**, huh **+0.61 MB / +39 modules**. v0 has exactly **one** single-line prompt β€” a TUI form lib has no UX payoff yet (YAGNI/VI, shortest-path/VII), and stdlib `bufio` is the least likely to ever block an agent on non-TTY stdin (returns EOF immediately) β€” best fit for IV/agent-safety + V/minimal-deps. +**Deferred (not now)**: when a command first needs a genuine multi-field interactive form, adopt **`charmbracelet/huh`** (now `v1.0.0` stable; `WithAccessible`/`WithInput`/`RunAccessible(w,r)` give a non-TTY-safe, testable path) β€” not the unmaintained `survey`. Re-open the spike then to re-confirm the ~0.6 MB / 39-module cost is justified. +**Alternatives rejected for v0**: `survey` (unmaintained, +0.23 MB, +39 deps); `promptui` (near-zero size but +5 deps and low maintenance for no benefit over stdlib on one line); `huh` (deferred per above). + +## D6 β€” OWNER/REPO validation + +**Decision**: Trim surrounding whitespace, then require the pattern `^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$` β€” exactly two non-empty, slash-separated segments. Reject anything else with a usage/config error (exit 1) that names the expected format and echoes the offending value (FR-012). An empty/blank `SKILLRIG_ORIGIN` is treated as **unset**, not invalid (spec Edge Cases). +**Rationale**: Matches GitHub `owner/repo` charset; offline shape-only check (no existence/reachability β€” deferred per spec Assumptions). Distinguishing "blank env = unset" preserves precedence fall-through. +**Alternatives**: Full GitHub-name RFC validation (rejected β€” over-strict, not needed offline); accepting `owner/repo/path` (rejected β€” that grammar belongs to skill refs, not origin config). + +## D7 β€” Exit-code & error mapping (cli.md is authoritative) + +**Decision**: `ExitOK=0`, `ExitUsage=1`. Codes `2` (verification) and `3` (prerequisite) are declared as reserved constants with comments but unused here. Commands use Cobra `RunE`; the root sets `SilenceUsage=true` and `SilenceErrors=true` so we render errors ourselves (errors-as-navigation), then `main.go` maps the returned error to an exit code. A typed `UsageError` (wrapping the raw cause) carries `ExitUsage`; anything else also exits `1` for this feature's surface. +**Rationale**: cli.md's exit codes are load-bearing and override agentic-cli-design's `2=invalid-args/3=auth/4=retryable` scheme (documented conflict β€” defer to cli.md). Silencing Cobra's built-in usage/error noise lets us honor the what/why/fix contract and the stdout/stderr split. +**Alternatives**: Cobra default error printing (rejected β€” dumps usage to stdout and a bare error, violating cli.md Principle 2 and Rule 5); adopting the skill's code numbers (rejected β€” collides with cli.md's meaning of 2/3). + +## D8 β€” Output shape: human (default) vs `--json` + +**Decision**: +- **Human (default)**: a compact confirmation to stdout, e.g. `bound origin my-org/my-skills (project: ./.skillrig/config.toml)`, plus a single footer-hint line (cli.md Principle 3). Line count is bounded (≀ ~2 lines) β€” asserted in tests. +- **`--json`**: a single complete object to stdout: `{ "ok": true, "origin": "my-org/my-skills", "scope": "project|global", "configPath": "…", "written": true|false }`. Parseable + structurally complete (all keys present). +- **Errors**: prose (what/why/fix) to **stderr** regardless of `--json`, with the raw cause preserved. We do NOT emit JSON errors (defer to cli.md over agentic-cli-design P1). +**Rationale**: Matches cli.md two-level output and the spec's FR-016. `written:false` distinguishes an idempotent no-op re-bind from a fresh write (FR-008) for machine consumers. +**Alternatives**: JSON-by-default (rejected β€” cli.md keeps human compact as default); structured JSON errors (rejected β€” cli.md mandates prose errors-as-navigation). + +## D9 β€” Atomic config write + +**Decision**: Write to a temp file **in the same target directory** (so it's on the same filesystem/volume) then `os.Rename` over the destination. Preserve a trailing newline; stable key ordering via the struct field order. File mode `0o600`, dir `0o750`. + +> **Implementation reconciliation (impl session):** shipped with `0o600`/`0o750` rather than the originally drafted `0o644`/`0o755`, to satisfy the gosec G301/G302 baseline (golang-lint skill config). Impact is effectively nil: git tracks only the executable bit, so a committed project `config.toml` normalizes to `644` on clone regardless; the local/global file being user-only (`0600`) is strictly safer for a config that may later hold more than a public `OWNER/REPO`. No requirement depends on group/world readability. +**Rationale**: Prevents a torn/corrupt `config.toml` on crash and avoids partial writes the resolver might later read (FR-004). Mirrors architecture open-Q10's lockfile-atomicity guidance, applied to config. +**OS requirements (review comment 2bf31175)**: +- **POSIX (Linux/macOS)**: `rename(2)` is atomic when source and dest are on the same filesystem β€” hence the temp file goes in the **target dir**, never `os.TempDir()` (which may be a different mount, turning `os.Rename` into a cross-device `EXDEV` error). βœ… +- **Windows**: `os.Rename` maps to `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING` in modern Go, so replace-over-existing works; but a concurrent open handle on the dest (AV scanners, the file open in an editor) can cause a sharing-violation. Acceptable for v0's single-writer `init`; note as a Windows caveat. +- Create parent dirs (`os.MkdirAll`) before writing; `fsync` the temp file before rename for durability if we later care (deferred β€” YAGNI for a tiny config). +**Alternatives**: Direct truncating write (rejected β€” torn-write risk); temp in `os.TempDir()` (rejected β€” cross-device rename failure); file locking (rejected β€” single-writer `init`, not needed; YAGNI). + +## D10 β€” Cobra command structure + +**Decision**: One root command `skillrig` (Long description + Example block; `RunE` prints help when called bare per cli.md Level-0). Persistent flags `--json` and `--verbose` on root. One subcommand `init` with local flags `--origin` and `--global`, `Args: cobra.NoArgs` (origin is a flag, not positional, since it's optional/interactive), a `Long` description, and β‰₯2 `Example` lines (cli.md Rule 1 / Principle 1). Unit tests drive commands in-process via `SetArgs`/`SetOut`/`SetErr` (golang-spf13-cobra skill). +**Rationale**: Establishes the progressive-discovery skeleton future commands extend. `--origin` as a flag (not positional) reads naturally with `--global` and the prompt fallback. +**Alternatives**: positional `skillrig init ` (viable, but a flag composes better with `--global` and optional/interactive entry; revisit if usage shows positional is preferred β€” a desire path per cli.md). + +**Config library β€” viper? (review comment be52d37c)**: **Decision: no viper for v0.** Viper *does* support TOML (it reads via `pelletier/go-toml`), and `cobra`+`viper`+`huh` are mutually compatible (cobra = commands, viper = config layering, huh = interactive forms β€” three orthogonal libs). But viper's value is generic multi-source config merging, whereas our origin resolution has **specific, contract-bound semantics** (architecture Β§2d): a named env var (`SKILLRIG_ORIGIN`), filesystem **walk-up** to the nearest project `.skillrig/config.toml`, a fixed global path, blank-env-as-unset, malformed-source-skip, and a single `ResolveOrigin` per AP-06. Viper's `AutomaticEnv`/config-merge does not model walk-up or our precedence cleanly and would fight the contract. Keep `go-toml/v2` (D1) + the hand-rolled resolver (D-contract `resolve.md`) β€” exact control, fewer deps, trivially table-testable. Revisit viper only if config grows many keys/sources. + +--- + +## Open evaluations / spikes + +- **S1 β€” Interactive prompt library (D5)**: βœ… **resolved** by [research/2026-05-24-interactive-prompt-library.md](research/2026-05-24-interactive-prompt-library.md). Outcome: stdlib `bufio` for v0; defer `charmbracelet/huh` (v1.0.0) to a future multi-field interactive flow. Measured: huh +0.61 MB / +39 modules vs a +0 stdlib baseline. + +Everything is resolved; no remaining `NEEDS CLARIFICATION`. No external network/integration boundaries in this feature, so no httptest/go-vcr (Constitution III applies to the **config.toml** ground-truth fixture instead). + +## Review trail + +Revised per crit review of `research.md` (Session 2026-05-24): D2 (XDG OS/shell support + `os.UserHomeDir`/`adrg/xdg` note), D3 (git is a fair assumption β€” git-root write, fs walk-up resolve), D4 (explicit `--non-interactive` flag + flag/prompt model), D5 (huh spike S1), D9 (POSIX same-fs + Windows `MoveFileEx` caveats), D10 (viper evaluated, rejected for v0; TOML supported; cobra/viper/huh compatible). diff --git a/specledger/001-init-origin-resolution/research/2026-05-24-interactive-prompt-library.md b/specledger/001-init-origin-resolution/research/2026-05-24-interactive-prompt-library.md new file mode 100644 index 0000000..64c1b4a --- /dev/null +++ b/specledger/001-init-origin-resolution/research/2026-05-24-interactive-prompt-library.md @@ -0,0 +1,76 @@ +# Research: Interactive prompt library for the skillrig CLI + +**Date**: 2026-05-24 +**Context**: Spike S1 from [research.md](../research.md) D5. `skillrig init` needs exactly **one** interactive prompt today β€” ask for the origin (`OWNER/REPO`) when run interactively without `--origin`. Deciding stdlib-now vs adopt-`huh`-now before locking D5. +**Time-box**: short (~30 min). **Confidence**: high (binary sizes are real measurements on this machine). + +## Question + +Which interactive-prompt approach should `skillrig` use for v0 β€” stdlib `bufio`, `manifoldco/promptui`, `AlecAivazis/survey`, or `charmbracelet/huh` β€” given Constitution V (minimal deps / single static binary), VI (YAGNI), VII (shortest path to MVP), and IV (agent-first, non-interactive-safe)? + +## Findings + +### Finding 1: Binary-size cost (measured, Go 1.24.4 darwin/arm64, `-ldflags="-s -w"`, CGO off) + +Each variant = a minimal Cobra command importing our real deps (`spf13/cobra` + `pelletier/go-toml/v2`) plus the prompt lib, built isolated and measured. + +| Option | Binary size | Ξ” vs baseline | Transitive modules (go.sum `/go.mod` lines) | +|--------|-------------|---------------|----------------------------------------------| +| **baseline** (cobra + go-toml/v2; == stdlib `bufio`) | 2,704,354 B (2.57 MB) | β€” | 8 | +| **promptui** | 2,705,122 B (2.57 MB) | **+768 B (~0)** | 13 (+5) | +| **survey** v2 | 2,955,394 B (2.81 MB) | +251 KB (+0.23 MB) | 47 (+39) | +| **huh** | 3,347,714 B (3.19 MB) | **+643 KB (+0.61 MB)** | 47 (+39) | + +stdlib `bufio` adds **zero** bytes and **zero** modules (it's already in the standard library). `huh` is the heaviest: +0.61 MB (~24% over baseline) and **+39 transitive modules** (bubbletea + lipgloss + bubbles + termenv + …). Surprisingly, `promptui` adds almost no binary size (+768 B) but still +5 modules. + +### Finding 2: Cobra compatibility + +All four run fine inside a Cobra `RunE` β€” they're invoked imperatively (`scanner.Scan()`, `promptui.Prompt.Run()`, `survey.AskOne()`, `huh.NewForm(...).Run()`), each blocks until the user answers, then returns control to `RunE`. `huh`/`bubbletea` take over the terminal (alt-screen / raw mode) for the duration but restore it on exit β€” no lifecycle conflict with Cobra. **No compatibility blocker for any option.** (Confidence: high for stdlib/promptui/survey; medium-high for huh β€” standard pattern, not re-verified end-to-end here.) + +### Finding 3: Non-TTY / agent safety (the load-bearing dimension) + +Per research D4, the prompt is reached **only** when value missing AND stdin is a TTY AND not `--non-interactive`. So in CI/agent/pipe contexts we never enter any library's prompt path β€” the library choice can't hang an agent **as long as the D4 gate is correct**. Defense-in-depth by option if the gate were ever bypassed: +- **stdlib `bufio.Scanner`**: on non-TTY/closed stdin, `Scan()` returns `false` immediately (EOF) β€” no hang, simplest failure mode. βœ… safest. +- **promptui / survey**: detect non-TTY and return an error (don't hang), but add their own TTY handling. +- **huh / bubbletea**: expects a real TTY; without one it errors rather than hangs, but it's the most terminal-machinery-heavy path. ⚠️ most moving parts in exactly the environment (agents) we care most about not breaking. + +Net: stdlib is the least likely to ever block an agent β€” directly serves cli.md's non-interactive-default posture and architecture R3 (same binary for humans/agents/CI). + +### Finding 4: Maintenance & API stability + +- **stdlib**: Go team, never breaks. βœ… +- **promptui** (`manifoldco`): low/with stale activity; works but quiet. +- **survey** (`AlecAivazis`): effectively **unmaintained** (community-archived sentiment); +39 deps for a stale lib β€” poor risk profile. +- **huh** (`charmbracelet`): **actively maintained** and at a **stable `v1.0.0`** (pulls `bubbletea v1.3.6`) β€” the API-churn worry is lower than assumed; heaviest dep tree, but a real v1 contract. + +### Finding 6: huh API depth (from module-cache exploration, `go get` + `go doc` + source) + +Read directly from `huh@v1.0.0` in the module cache. Details that matter for a *future* adoption decision and that correct earlier hedges: +- **Accessible mode** β€” `form.WithAccessible(true)` (and per-field `RunAccessible(w io.Writer, r io.Reader)`) **drops the Bubble Tea TUI renderer and uses basic terminal prompting**. The README gates it on an `ACCESSIBLE` env var. This means huh can degrade to plain prompts β€” friendlier for screen readers *and* less raw-TTY-bound, which softens (but doesn't remove) the non-TTY concern in Finding 3. +- **Injectable I/O** β€” `WithInput(io.Reader)` / `WithOutput(io.Writer)`, plus `RunAccessible(w, r)`, make huh forms **deterministically testable** (feed a `strings.Reader`) without a pty β€” compatible with our golang-testing approach. +- **Bounded execution** β€” `WithTimeout(d)` and `RunWithContext(ctx)` exist (note `ErrTimeout` / `ErrTimeoutUnsupported in accessible mode`, `ErrUserAborted`). +- **Rich field set** β€” `Input`, `Text`, `Select`, `MultiSelect`, `Confirm`, `FilePicker`, `Note`, plus `Group`/`Form` and grid `Layout`s. None of which v0's single origin prompt needs β€” reinforces Finding 5 (YAGNI now), but is exactly the payoff when a multi-field flow arrives. + +### Finding 5: UX payoff vs YAGNI for v0 + +The entire v0 interactive surface is **one single-line prompt** ("Origin (OWNER/REPO): "). `huh`'s value β€” multi-field forms, validation UI, selects, theming β€” has **no payoff for one line**. It pays off when a command has a genuine multi-field interactive flow (a richer future onboarding, multi-select skill pickers, etc.). Adopting a 39-module TUI framework now to read one line is textbook YAGNI / not-shortest-path. + +## Decisions + +- **Decision (resolves S1)**: Use **stdlib `bufio`** for the v0 origin prompt. Confirms research.md D5's provisional baseline. +- **Defer `huh`** to a future feature that introduces a real multi-field/interactive flow. At that point +0.61 MB / +39 modules buys actual UX; today it doesn't. Record `huh` (now **`v1.0.0` stable**) as the **preferred** choice *when* a TUI form is warranted β€” actively maintained, best UX, `WithAccessible`/`WithInput`/`RunAccessible(w,r)` give a non-TTY-safe + testable path β€” over the stale `survey`. +- **Reject `survey`** outright (unmaintained, +0.23 MB, +39 deps). **Don't adopt `promptui`** either: near-zero size but +5 deps and low maintenance for no benefit over stdlib on a single line read. + +## Recommendations + +1. Lock D5 on **stdlib `bufio.Scanner`**, prompt to **stderr**, single read, invalid/empty β†’ usage error (exit 1, no retry loop). Remove the ⚠️ SPIKE marker from D5 and mark S1 resolved with this data. +2. Keep the D4 gate (`--non-interactive` + TTY detection) as the real agent-safety mechanism β€” it's library-independent. +3. Add a forward note: when a command first needs a multi-field interactive form, re-open this spike and adopt `charmbracelet/huh` (not survey/promptui), accepting the measured ~0.6 MB / 39-module cost then. + +## References + +- Measured locally (Go 1.24.4, darwin/arm64, `CGO_ENABLED=0 go build -ldflags="-s -w"`); scratch modules built & discarded. +- [research.md](../research.md) D4 (interactive model), D5 (prompting), D1 (go-toml/v2) +- [docs/design/cli.md](../../../docs/design/cli.md) β€” Principle 2 (errors-as-navigation), non-interactive defaults, Rule 5 (stderr/stdout) +- architecture.md Β§2 (single static binary, R3/R4) +- Constitution v2.1.0 β€” V (minimal deps), VI (YAGNI), VII (shortest path), IV (agent-first) diff --git a/specledger/001-init-origin-resolution/reviews/001-review.md b/specledger/001-init-origin-resolution/reviews/001-review.md new file mode 100644 index 0000000..eb5b9de --- /dev/null +++ b/specledger/001-init-origin-resolution/reviews/001-review.md @@ -0,0 +1,62 @@ +--- +date: 2026-05-25 +total_requirements: 20 +total_tasks: 19 +coverage_pct: 100% +critical_issues: 0 +--- + +# Specification Analysis Report β€” 001-init-origin-resolution + +**Feature:** CLI Initialization & Origin Resolution +**Command:** `/specledger.verify` (cross-artifact consistency & quality analysis) +**Artifacts reviewed:** spec.md, plan.md, tasks.md (issue store), quickstart.md, data-model.md, contracts/{init,resolve}.md, constitution v2.1.0 + +> This review reflects the artifacts **after** the agreed remediation was applied +> (FR-006c forced non-interactive mode, git-root write target made explicit, `git` +> declared a required dependency, requirement labels backfilled, "bind"β†’`init` +> naming reconciled). The findings below are recorded for traceability; the +> "Resolution" column states how each was closed. + +## Findings + +| ID | Category | Severity | Location(s) | Summary | Resolution | +|----|----------|----------|-------------|---------|------------| +| I1 | Inconsistency | MEDIUM | init task vs contracts/init.md, quickstart.md | `--non-interactive` flag existed in the init task but not in the contract/quickstart, and the intent (force fail-fast even on a TTY, not TTY auto-detect) was unstated. | **Resolved** β€” added FR-006c to spec, US3 acceptance scenario 4, `--non-interactive` to contract Synopsis/Flags/Behavior/Errors, and `TestQuickstart_NonInteractiveFlag` to quickstart. | +| I2 | Inconsistency / Underspec | MEDIUM | init task vs contracts/init.md, spec, plan | Init resolved the write target via `git rev-parse --show-toplevel` (git-root), but the contract said write to cwd and `git` was never declared a dependency. | **Resolved** β€” git-root write target documented in init.md/data-model.md; `git` declared a required (offline) dependency in plan.md; added `TestQuickstart_BindFromGitSubdir` + `BindNonGitCwdFallback` and a dedicated git-fixture task (SL-3b4985). | +| T1 | Coverage (traceability) | MEDIUM | all task issues | Only 6 of ~19 functional requirements carried a `requirement:` label; the rest relied on quickstart + task design. | **Resolved** β€” backfilled `requirement:` labels (FR-003/004 on resolver; FR-006/006a/006b/006c/007/008/009/013/016 on init; FR-013 on root; FR-015/016 on output helper; FR-017 on exit codes; FR-006a/006c on US3 tests). | +| D1 | Naming drift | LOW | spec.md "bind command" vs init | Spec uses the abstract verb "bind"; concrete command is `skillrig init`. | **Resolved** β€” added a clarification mapping "the bind command" β†’ `skillrig init` and confirming no separate `config` command (config is hand-edited input). | +| G1 | Ground-Truth (Const. III) | LOW | data-model.md | `config.toml` fixture described as "real captured output" though no binary exists yet (format is trivially one `origin=` line). | **Accepted** β€” SL-60a982 regenerates the fixture from real `init` output once built; round-trip test enforces it. | +| C1 | Coverage gap | LOW | spec FR-011 | "MUST NOT scaffold/bootstrap an origin" has no positive/negative test. | **Accepted** β€” a "don't do X" constraint; init.md notes consume-only, no network/scaffold. | + +## Coverage Summary (functional requirements β†’ coverage) + +100% of functional requirements (FR-001–017 + FR-006a/006b/006c = 20) have β‰₯1 task +or quickstart scenario. After remediation, label-based traceability covers the +core write/resolve/error requirements. Quickstart-as-Contract (Constitution II): +all user stories map to scenarios; every scenario traces to a user story; each is +executable; each has a backing test task (SL-db8e96, SL-ca8e55, SL-03ebb3, +SL-3b4985); story task SL-2e4214 carries quickstart-match + test-passing DoD. + +## Constitution Alignment + +No MUST violations. II βœ… (executable quickstart, output-shape asserts, no +story↔scenario drift), III βš οΈβ†’accepted (G1), IV βœ… (Environment pattern, +progressive help, two-level output, exit codes), V/VI/VII/VIII βœ…, IX βœ… +(SL-0990e2 skill task with trigger evals). + +## Metrics + +- Total functional requirements: **20** (FR-001–017 + FR-006a/006b/006c) +- Coverage (β‰₯1 task or scenario): **20/20 = 100%** +- Total leaf tasks: **19** across 6 phases (was 18; +SL-3b4985 git fixtures) +- Quickstart scenarios: **20** (added BindFromGitSubdir, BindNonGitCwdFallback, NonInteractiveFlag) +- Ambiguity count: **0** +- Duplication count: **0** +- **Critical issues: 0** + +## Next Actions + +- No CRITICAL/HIGH blockers β€” proceed to `/specledger.implement`. +- Build order unchanged: Setup β†’ Foundational β†’ US1 (incl. git fixtures SL-3b4985) β†’ US2 β†’ US3 β†’ Polish. +- Ensure CI/dev environments have `git` on PATH (now a declared required dependency). diff --git a/specledger/001-init-origin-resolution/sessions/001-init-origin-resolution-checkpoint.md b/specledger/001-init-origin-resolution/sessions/001-init-origin-resolution-checkpoint.md new file mode 100644 index 0000000..979941f --- /dev/null +++ b/specledger/001-init-origin-resolution/sessions/001-init-origin-resolution-checkpoint.md @@ -0,0 +1,116 @@ +# Session Log: 001-init-origin-resolution + +## Divergence Review: 2026-05-26 10:23 + +Scope: full post-implementation checkpoint (implementation session knowledge). +Adversarial fresh-eyes agent intentionally deferred to a separate session. + +### Divergences + +| # | Severity | Type | Category | Issue/Artifact | Description | +|---|----------|------|----------|----------------|-------------| +| 1 | ~~MEDIUM~~ **RESOLVED** | oversight | Contract gap (latent) | contracts/resolve.md / spec FR-004 | ~~`ResolveOrigin` β†’ `usableOrigin` collapses every bad-source failure to `(Origin{}, false)` and discards the parse error, so the contract's "malformed-file diagnostic via `--verbose`" cannot be honored.~~ **Fixed in this session** (post-checkpoint): added `config.MalformedError` (typed) + `ResolutionResult.Diagnostics []SourceDiagnostic`. `originFromFile` now classifies each source as usable / skippable-with-diagnostic (malformed or invalid origin) / quiet-fall-through (absent or origin-less) / fatal I/O error. Diagnostics are recorded regardless of final Source. Covered by `TestResolveOrigin_{MalformedProjectRecordsDiagnostic,InvalidShapeRecordsDiagnostic,OriginlessNoDiagnostic,UnreadableFileIsFatal}` + `TestLoadMalformedErrors` (errors.As *MalformedError). contracts/resolve.md + data-model.md updated. | +| 2 | ~~LOW~~ **RESOLVED** | conscious | Architecture/design drift | research.md D9 vs internal/config/config.go | ~~Config written with mode `0o600`/`0o750`; research D9 specified `0o644`/`0o755`, and D9 was not updated.~~ **Reconciled this session**: research.md D9 now documents the shipped `0o600`/`0o750` with rationale (gosec G301/G302; git normalizes the committed mode regardless; user-only is safer). | +| 3 | LOW | conscious | Test realization | quickstart.md Part A / TestQuickstart_PromptInteractive | The interactive-prompt scenario is realized **in-process** (`internal/cli/init_test.go` β†’ `TestInit_PromptInteractive`, injected `interactive=true` + `SetIn`) rather than as a binary-exec E2E test, because the pty test dependency (`creack/pty`) was denied (project mandates minimal deps). The quickstart harness note explicitly sanctions "a pty **or the harness's interactive shim**", so this is within bounds. The one line not exercised E2E is the real `os.Stdin` char-device TTY detection; FR-006c's "override even on a TTY" is covered in-process by `TestInit_NonInteractiveFlagOverridesTTY`. | +| 4 | LOW | conscious | Ground-truth (G1) | data-model.md / quickstart.md / test/fixtures/config.toml | `Save` emits TOML literal-string form `origin = 'my-org/my-skills'` (single quotes β€” go-toml/v2 default), not the double-quoted form originally shown in the docs. Fixture regenerated from real output and the two illustrative doc blocks updated with a G1 note. `TestSaveMatchesFixture` anchors it. (Review finding G1 pre-authorized regenerating the fixture from real `init` output.) | +| 5 | LOW | conscious | Scope boundary | plan.md / spec FR-001 | `ResolveOrigin` has **no production caller** β€” only `internal/config/resolve.go` defines it; no CLI command invokes it. This is by design: no consuming command (search/add/verify) is in scope. The resolver is fully unit-tested (`TestResolveOrigin_Precedence` rows 1–7 + `FromSubdir`) but never exercised end-to-end through the binary. First consuming command (next feature) provides E2E coverage. | +| 6 | LOW | oversight | Test coverage | quickstart.md TestQuickstart_BindFromGitSubdir | The quickstart lists a "resolve-symmetry" sub-assertion inside BindFromGitSubdir (`ResolveOrigin(cwd=a/b/c)` β†’ `Source==project`, `ConfigPath==/...`). The E2E test asserts file placement at the git root + no subdir `.skillrig`, but omits the resolve-symmetry check (an exec test cannot call the Go function). Walk-up resolution is covered independently by the in-process `TestResolveOrigin_FromSubdir`, so the behavior is verified β€” just not within the same test. | +| 7 | LOW | conscious | Coverage (accepted) | spec FR-011 | "MUST NOT scaffold/bootstrap an origin" has no positive/negative test β€” a "don't do X" constraint. Pre-accepted in the verify review (finding C1); `init` is consume-only with no network/scaffold code path. | + +### Force-Closed Issues (DoD Bypassed) + +None. All 26 closed issues have fully checked Definition of Done (47/47 DoD items checked). No `--force` closes were used. + +### Issues Encountered & Resolutions +- pty dependency (`creack/pty`) denied by sandbox (minimal-deps policy) β†’ interactive prompt covered by an in-process injection shim (quickstart-sanctioned). [#3] +- go-toml/v2 emits single-quoted TOML literals β†’ anchored fixture + docs to real output instead of forcing double quotes. [#4] +- Strict golang-lint baseline (gosec/wsl_v5/noctx) β†’ tightened config perms [#2], added a `_test.go` gosec exclusion rule, used `exec.CommandContext` + `t.Context()` in tests, single targeted `//nolint:gosec` on `config.Load` (G304, documented). + +### Items Requiring Action Before Merge +1. ~~[MEDIUM] Carry divergence #1 forward~~ β€” **DONE this session**: resolver now surfaces skipped-source diagnostics (`ResolutionResult.Diagnostics`) and distinguishes fatal I/O from skippable-malformed (`config.MalformedError`). FR-004 contract gap closed; a future `--verbose` caller has a field to read. Followed up because deferring it risked a later agent (with less context) re-introducing a silent skip. +2. ~~[LOW] Reconcile divergence #2~~ β€” **DONE this session**: research.md D9 updated to document the shipped `0o600`/`0o750` with rationale. +3. [LOW] Optional: when a pty test dependency becomes acceptable, add an E2E `TestQuickstart_PromptInteractive` to cover the real TTY char-device detection line (#3), and add the resolve-symmetry assertion to BindFromGitSubdir (#6). + +### Tests & Checks +- Status: PASS +- Commands run: `go test -count=1 ./...` Β· `gofmt -l .` Β· `go vet ./...` Β· `golangci-lint run ./...` +- Results: all packages pass (internal/cli, internal/config, test); gofmt clean; vet clean; golangci-lint **0 issues**. + +### Progress Summary +- Closed: 26 issues (19 leaf tasks + 6 phase parents + 1 epic) +- In Progress: 0 issues +- Open/Remaining: 0 issues +- Force-Closed: 0 issues (DoD bypassed) + +### Uncommitted Changes +- None related to this feature. Implementation committed as `1e9beda` (feat(001): implement skillrig init + single origin resolver). +- Pre-existing untracked files unrelated to this feature: `docs/design/{README,commands,hooks,testing,tf-cli-ref}.md`, `docs/guides/vcr-cassettes.md`. + +--- + +## Adversarial Checkpoint Review (fresh-eyes): 2026-05-26 10:50 + +Scope: context-free adversarial pass (no implementation-session knowledge). Run against the working tree **including the uncommitted FR-004 diagnostics change** (config.go, resolve.go, contracts/resolve.md, data-model.md + tests). Goal: find problems, not confirm success. + +### Gate status +All clean on the working tree: `go test -count=1 ./...` PASS (cli, config, test) Β· `gofmt -l` clean Β· `go vet ./...` exit 0 Β· `golangci-lint run` **0 issues**. + +### DoD / force-close audit +26 closed issues, 0 open, 0 in_progress. **No force-closed issues** β€” every `definition_of_done` item is `checked: true` with a `verified_at`. Highest-signal category empty. + +### Findings (fresh-eyes; cross-referenced to the self-review above) + +| # | Severity | Type | Finding | +|---|----------|------|---------| +| A1 | MEDIUM | oversight | **Malformed `SKILLRIG_ORIGIN` hard-error branch is untested.** `resolve.go:47-51` makes an explicitly-set-but-malformed env override the resolver's *one hard error* (vs. the skip-and-continue of file sources). No test sets a malformed `SKILLRIG_ORIGIN`; the precedence matrix has only blank-env (row 6). The FR-004 diagnostics work added 4 new tests but **none cover this branch** β€” the error-wrapping (`"%s: %w"`, `envOriginKey` prefix) and the "deliberate override must be valid" hard-fail are unasserted. Add a matrix row / test. | +| A2 | MEDIUM | conscious (scope) | **FR-003 / US3-AS1 no-origin rendering is unreachable end-to-end**, and the new `Diagnostics` field inherits the same status. No registered command calls `ResolveOrigin` (`root.go:97-99` wires only `init`, which *writes*). `Source==none` β†’ actionable two-fix error, and now `Diagnostics` β†’ `--verbose` surfacing, both exist only at the data level with **no production caller** (consistent with self-review #5). Directly qualifies **SC-004**: the resolution no-origin path cannot be hit by a user in this slice. Acceptable as a primitive-for-later, but the spec presents it as reachable. | +| A3 | LOW | conscious | **FR-004 fix introduces a behavior change for the future caller to vet:** a genuine I/O error on a *project* config (e.g. unreadable, perms) is now **fatal** and aborts resolution β€” even when a valid global default exists. Previously all `Load` failures were skipped β†’ fall-through. The new malformed-vs-I/O split (`MalformedError`) is a real improvement, but "unreadable project config is fatal despite a usable global" is a debatable semantic the wiring command should confirm. `UnreadableFileIsFatal` covers project only (global unreadable-fatal shares the path, untested). | +| A4 | LOW | oversight | `internal/config/origin.go:30` **`Origin.IsZero()` is dead code** β€” no caller in `internal/`, `test/`, or `main.go`. `golangci-lint`'s `unused` does not flag exported methods. Delete or wire it. | +| A5 | LOW | conscious (stale DoD) | Issue **SL-db8e96** DoD reads *"TestQuickstart_…PromptInteractive written"* β†’ `checked: true`, but no test of that name exists; it was relocated in-process to `TestInit_PromptInteractive` (self-review #3). Relocation is justified and documented in code, but the DoD checkbox describes a deliverable that never existed under that name/package. | +| A6 | LOW | conscious | `stdinIsTTY` (`init.go:199-206`) classifies `/dev/null` as interactive (char device). `skillrig init project>global | +| SL-ca8e55 (US2 tests) | TestResolveOrigin rows 1–7 + FromSubdir pass; real fixtures, no mocks | +| SL-05dbc5 (US3 errors) | MalformedOrigin + NoOriginNonInteractive pass; what/why/fix to stderr; exit 1 | +| SL-1819a2 (gate) | gofmt/vet/golangci-lint clean; go test ./... green (full quickstart suite) | +| SL-0990e2 (skill) | skillrig-init agent skill with verified trigger keywords (Constitution IX) | + +--- + +> Index only. All task detail (design / acceptance criteria / DoD) lives in the `sl issue` store β€” query with the hints above. diff --git a/specledger/specledger.yaml b/specledger/specledger.yaml index 0f1ab91..736eacb 100644 --- a/specledger/specledger.yaml +++ b/specledger/specledger.yaml @@ -1,9 +1,10 @@ version: 1.0.0 project: + id: fb36c2cc-f114-4b82-b7ea-f5e792256344 name: skillrig-cli short_code: sk created: 2026-05-24T13:39:16.690278+08:00 - modified: 2026-05-24T13:39:16.690286+08:00 + modified: 2026-05-24T19:59:59.93992+08:00 version: 0.1.0 playbook: name: specledger diff --git a/test/fixtures/config.toml b/test/fixtures/config.toml new file mode 100644 index 0000000..bdb6f7d --- /dev/null +++ b/test/fixtures/config.toml @@ -0,0 +1 @@ +origin = 'my-org/my-skills' diff --git a/test/quickstart_test.go b/test/quickstart_test.go new file mode 100644 index 0000000..6a414cb --- /dev/null +++ b/test/quickstart_test.go @@ -0,0 +1,478 @@ +// Package quickstart holds the TestQuickstart_* integration suite. Per +// Constitution II (Quickstart-as-Contract) each scenario in +// specledger/001-init-origin-resolution/quickstart.md maps 1:1 to a test here. +// CLI scenarios build the real binary once (TestMain) and exec it in an +// isolated temp HOME/cwd with SKILLRIG_ORIGIN supplied per scenario. +package quickstart + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// binPath is the built skillrig binary, shared across all scenarios. +var binPath string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "skillrig-bin-*") + if err != nil { + panic(err) + } + + binPath = filepath.Join(dir, "skillrig") + + build := exec.CommandContext(context.Background(), "go", "build", "-o", binPath, ".") + build.Dir = ".." // module root, relative to this package dir (test/) + + if out, err := build.CombinedOutput(); err != nil { + _, _ = os.Stderr.WriteString("build failed: " + err.Error() + "\n" + string(out)) + + os.Exit(1) + } + + code := m.Run() + + _ = os.RemoveAll(dir) + + os.Exit(code) +} + +// runOpts configures one invocation of the binary. +type runOpts struct { + args []string + cwd string // working directory (required for write scenarios) + home string // HOME / XDG base (defaults to a fresh temp dir) + env map[string]string // extra env, e.g. SKILLRIG_ORIGIN + stdin string // piped to the child (always a pipe, never a TTY) +} + +// runResult captures the observable contract: stdout, stderr, exit code. +type runResult struct { + stdout string + stderr string + exit int +} + +// run execs the binary with isolated HOME/XDG so project and global config +// never touch the real user environment. stdin is always a pipe (never a char +// device), so the binary's TTY detection classifies every scenario here as +// non-interactive β€” the interactive prompt path is covered in-process +// (internal/cli, the quickstart's sanctioned "interactive shim"). +func run(t *testing.T, opts runOpts) runResult { + t.Helper() + + home := opts.home + if home == "" { + home = t.TempDir() + } + + cwd := opts.cwd + if cwd == "" { + cwd = t.TempDir() + } + + cmd := exec.CommandContext(t.Context(), binPath, opts.args...) + cmd.Dir = cwd + // Build a clean env: keep PATH (git lookup) but never inherit the tester's + // SKILLRIG_ORIGIN. HOME + XDG_CONFIG_HOME are pinned to the temp home. + env := []string{ + "PATH=" + os.Getenv("PATH"), + "HOME=" + home, + "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"), + } + for k, v := range opts.env { + env = append(env, k+"="+v) + } + + cmd.Env = env + cmd.Stdin = strings.NewReader(opts.stdin) + + var stdout, stderr bytes.Buffer + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + exit := 0 + + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exit = exitErr.ExitCode() + } else { + t.Fatalf("exec %v: %v", opts.args, err) + } + } + + return runResult{stdout: stdout.String(), stderr: stderr.String(), exit: exit} +} + +// nonEmptyLines splits s on newlines and drops the trailing empty element so a +// final "\n" does not inflate the line count for shape assertions. +func nonEmptyLines(s string) []string { + return strings.Split(strings.TrimRight(s, "\n"), "\n") +} + +// requireGit skips a scenario with a clear message when git is unavailable. git +// is a declared required dependency (plan.md), but the suite stays honest where +// it is missing rather than failing opaquely. +func requireGit(t *testing.T) { + t.Helper() + + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH; skipping git-root write-target scenario") + } +} + +// gitInit initialises a throwaway repo in dir (offline, quiet). +func gitInit(t *testing.T, dir string) { + t.Helper() + + cmd := exec.CommandContext(t.Context(), "git", "init", "-q") + cmd.Dir = dir + + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init in %s: %v\n%s", dir, err, out) + } +} + +// realPath resolves symlinks (macOS /var β†’ /private/var) so path assertions +// match git rev-parse --show-toplevel output. +func realPath(t *testing.T, p string) string { + t.Helper() + + resolved, err := filepath.EvalSymlinks(p) + if err != nil { + t.Fatalf("EvalSymlinks(%s): %v", p, err) + } + + return resolved +} + +func TestQuickstart_BindProject(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + res := run(t, runOpts{args: []string{"init", "--origin", "my-org/my-skills"}, cwd: cwd}) + + if res.exit != 0 { + t.Fatalf("exit = %d, want 0 (stderr: %s)", res.exit, res.stderr) + } + + lines := nonEmptyLines(res.stdout) + if len(lines) > 2 { + t.Errorf("stdout has %d lines, want <= 2:\n%s", len(lines), res.stdout) + } + + if !strings.Contains(lines[0], "my-org/my-skills") || !strings.Contains(lines[0], "project") { + t.Errorf("line 1 = %q, want it to mention bound origin + project", lines[0]) + } + + if !strings.Contains(res.stdout, "β†’ resolve order:") { + t.Errorf("stdout missing resolve-order footer hint:\n%s", res.stdout) + } + + gotFile := readFile(t, filepath.Join(cwd, ".skillrig", "config.toml")) + wantFile := readFile(t, filepath.Join("fixtures", "config.toml")) + + if gotFile != wantFile { + t.Errorf("config.toml = %q, want fixture %q", gotFile, wantFile) + } +} + +func TestQuickstart_BindProjectJSON(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + res := run(t, runOpts{args: []string{"init", "--origin", "my-org/my-skills", "--json"}, cwd: cwd}) + + if res.exit != 0 { + t.Fatalf("exit = %d, want 0 (stderr: %s)", res.exit, res.stderr) + } + + var obj map[string]any + if err := json.Unmarshal([]byte(res.stdout), &obj); err != nil { + t.Fatalf("stdout is not a single JSON object: %v\n%s", err, res.stdout) + } + + for _, key := range []string{"ok", "origin", "scope", "configPath", "written"} { + if _, ok := obj[key]; !ok { + t.Errorf("JSON missing key %q: %v", key, obj) + } + } + + if obj["ok"] != true { + t.Errorf("ok = %v, want true", obj["ok"]) + } + + if obj["origin"] != "my-org/my-skills" { + t.Errorf("origin = %v, want my-org/my-skills", obj["origin"]) + } + + if obj["scope"] != "project" { + t.Errorf("scope = %v, want project", obj["scope"]) + } + + if obj["written"] != true { + t.Errorf("written = %v, want true", obj["written"]) + } +} + +func TestQuickstart_IdempotentRebind(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + first := run(t, runOpts{args: []string{"init", "--origin", "my-org/my-skills"}, cwd: cwd}) + + if first.exit != 0 { + t.Fatalf("first run exit = %d, want 0 (stderr: %s)", first.exit, first.stderr) + } + + before := readFile(t, filepath.Join(cwd, ".skillrig", "config.toml")) + + second := run(t, runOpts{args: []string{"init", "--origin", "my-org/my-skills"}, cwd: cwd}) + if second.exit != 0 { + t.Fatalf("second run exit = %d, want 0 (stderr: %s)", second.exit, second.stderr) + } + + if !strings.Contains(second.stdout, "already bound") && !strings.Contains(second.stdout, "no change") { + t.Errorf("second run stdout should note no change, got:\n%s", second.stdout) + } + + after := readFile(t, filepath.Join(cwd, ".skillrig", "config.toml")) + if before != after { + t.Errorf("file changed on idempotent rebind:\n before=%q\n after =%q", before, after) + } + + jsonRes := run(t, runOpts{args: []string{"init", "--origin", "my-org/my-skills", "--json"}, cwd: cwd}) + + var obj map[string]any + if err := json.Unmarshal([]byte(jsonRes.stdout), &obj); err != nil { + t.Fatalf("json variant: %v\n%s", err, jsonRes.stdout) + } + + if obj["written"] != false { + t.Errorf("written = %v on idempotent rebind, want false", obj["written"]) + } +} + +func TestQuickstart_RebindDifferent(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + run(t, runOpts{args: []string{"init", "--origin", "my-org/my-skills"}, cwd: cwd}) + + res := run(t, runOpts{args: []string{"init", "--origin", "other-org/other-skills"}, cwd: cwd}) + if res.exit != 0 { + t.Fatalf("exit = %d, want 0 (stderr: %s)", res.exit, res.stderr) + } + + got := readFile(t, filepath.Join(cwd, ".skillrig", "config.toml")) + want := "origin = 'other-org/other-skills'\n" + + if got != want { + t.Errorf("after rebind config = %q, want %q (cleanly replaced)", got, want) + } +} + +func TestQuickstart_Global(t *testing.T) { + t.Parallel() + + home := t.TempDir() + cwd := t.TempDir() + res := run(t, runOpts{ + args: []string{"init", "--origin", "my-org/my-skills", "--global", "--json"}, + cwd: cwd, + home: home, + }) + + if res.exit != 0 { + t.Fatalf("exit = %d, want 0 (stderr: %s)", res.exit, res.stderr) + } + + globalPath := filepath.Join(home, ".config", "skillrig", "config.toml") + if readFile(t, globalPath) != readFile(t, filepath.Join("fixtures", "config.toml")) { + t.Errorf("global config at %s does not equal fixture", globalPath) + } + + if _, err := os.Stat(filepath.Join(cwd, ".skillrig", "config.toml")); !os.IsNotExist(err) { + t.Errorf("project config should not exist for --global, stat err = %v", err) + } + + var obj map[string]any + if err := json.Unmarshal([]byte(res.stdout), &obj); err != nil { + t.Fatalf("json: %v\n%s", err, res.stdout) + } + + if obj["scope"] != "global" { + t.Errorf("scope = %v, want global", obj["scope"]) + } +} + +func TestQuickstart_Help(t *testing.T) { + t.Parallel() + + res := run(t, runOpts{args: []string{"init", "--help"}}) + if res.exit != 0 { + t.Fatalf("exit = %d, want 0", res.exit) + } + + examples := strings.Count(res.stdout, "skillrig init") + if examples < 2 { + t.Errorf("help shows %d 'skillrig init' lines, want >= 2 examples:\n%s", examples, res.stdout) + } +} + +func TestQuickstart_BindFromGitSubdir(t *testing.T) { + t.Parallel() + requireGit(t) + + repo := t.TempDir() + gitInit(t, repo) + + sub := filepath.Join(repo, "a", "b", "c") + if err := os.MkdirAll(sub, 0o750); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + + res := run(t, runOpts{args: []string{"init", "--origin", "my-org/my-skills"}, cwd: sub}) + if res.exit != 0 { + t.Fatalf("exit = %d, want 0 (stderr: %s)", res.exit, res.stderr) + } + + rootCfg := filepath.Join(realPath(t, repo), ".skillrig", "config.toml") + if _, err := os.Stat(rootCfg); err != nil { + t.Errorf("config not written at git root %s: %v", rootCfg, err) + } + + if _, err := os.Stat(filepath.Join(sub, ".skillrig")); !os.IsNotExist(err) { + t.Errorf("no .skillrig should be created under subdir, stat err = %v", err) + } +} + +func TestQuickstart_BindNonGitCwdFallback(t *testing.T) { + t.Parallel() + + // A fresh temp dir is not inside a git repo, so the write target falls back + // to cwd/.skillrig/config.toml. + cwd := t.TempDir() + res := run(t, runOpts{args: []string{"init", "--origin", "my-org/my-skills"}, cwd: cwd}) + + if res.exit != 0 { + t.Fatalf("exit = %d, want 0 (stderr: %s)", res.exit, res.stderr) + } + + if _, err := os.Stat(filepath.Join(cwd, ".skillrig", "config.toml")); err != nil { + t.Errorf("config not written at cwd fallback: %v", err) + } +} + +func TestQuickstart_MalformedOrigin(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + res := run(t, runOpts{args: []string{"init", "--origin", "not-a-valid-origin"}, cwd: cwd}) + + if res.exit != 1 { + t.Errorf("exit = %d, want 1", res.exit) + } + + if res.stdout != "" { + t.Errorf("stdout = %q, want empty", res.stdout) + } + + // Three distinct parts (what / why / fix), each asserted separately. + if !strings.Contains(res.stderr, "not-a-valid-origin") { + t.Errorf("stderr (what) should echo the offending value, got: %q", res.stderr) + } + + if !strings.Contains(res.stderr, "OWNER/REPO") { + t.Errorf("stderr (why) should state expected OWNER/REPO, got: %q", res.stderr) + } + + if !strings.Contains(res.stderr, "my-org/my-skills") { + t.Errorf("stderr (fix) should show a concrete example, got: %q", res.stderr) + } + + if _, err := os.Stat(filepath.Join(cwd, ".skillrig", "config.toml")); !os.IsNotExist(err) { + t.Errorf("no config should be written on malformed origin, stat err = %v", err) + } +} + +func TestQuickstart_NoOriginNonInteractive(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + // stdin is a pipe (not a char device) β†’ non-interactive session, no TTY. + res := run(t, runOpts{args: []string{"init"}, cwd: cwd, stdin: ""}) + + if res.exit != 1 { + t.Errorf("exit = %d, want 1", res.exit) + } + + if res.stdout != "" { + t.Errorf("stdout = %q, want empty", res.stdout) + } + + if !strings.Contains(res.stderr, "no origin") { + t.Errorf("stderr (what) should say no origin given, got: %q", res.stderr) + } + + if !strings.Contains(res.stderr, "no TTY") { + t.Errorf("stderr (why) should cite non-interactive session/no TTY, got: %q", res.stderr) + } + + if !strings.Contains(res.stderr, "--origin") || !strings.Contains(res.stderr, "SKILLRIG_ORIGIN") { + t.Errorf("stderr (fix) should suggest --origin or SKILLRIG_ORIGIN, got: %q", res.stderr) + } + + if _, err := os.Stat(filepath.Join(cwd, ".skillrig", "config.toml")); !os.IsNotExist(err) { + t.Errorf("no config should be written, stat err = %v", err) + } +} + +func TestQuickstart_NonInteractiveFlag(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + res := run(t, runOpts{args: []string{"init", "--non-interactive"}, cwd: cwd}) + + if res.exit != 1 { + t.Errorf("exit = %d, want 1", res.exit) + } + + if !strings.Contains(res.stderr, "no origin") { + t.Errorf("stderr (what) should say no origin given, got: %q", res.stderr) + } + + if !strings.Contains(res.stderr, "--non-interactive") { + t.Errorf("stderr (why) should cite --non-interactive, got: %q", res.stderr) + } + + if !strings.Contains(res.stderr, "--origin") || !strings.Contains(res.stderr, "SKILLRIG_ORIGIN") { + t.Errorf("stderr (fix) should suggest --origin or SKILLRIG_ORIGIN, got: %q", res.stderr) + } + + // FR-006c: forced fail-fast must not emit the prompt. + if strings.Contains(res.stderr, "Origin (OWNER/REPO):") { + t.Errorf("--non-interactive must not prompt, but stderr contains the prompt: %q", res.stderr) + } +} + +// readFile reads a file and fails the test on error. +func readFile(t *testing.T, path string) string { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + + return string(data) +}