Module layout and dependency rules for the chunk Go CLI.
chunk-cli/
├── main.go # Entry point: cobra bootstrap + usererr handling
├── skills/ # Skill definitions (go:embed) and skill subdirectories
├── acceptance/ # Acceptance tests
└── internal/
├── cmd/ # Cobra command definitions (thin wrappers)
│ ├── root.go # Root command, registers all subcommands
│ ├── auth.go # auth set, auth status, auth remove
│ ├── buildprompt.go # build-prompt
│ ├── completion.go # completion install/uninstall/zsh
│ ├── config.go # config show/set
│ ├── init.go # init (project setup, settings.json generation)
│ ├── hook.go # hook disable/enable/status
│ ├── sidecar.go # sidecar list/create/exec/add-ssh-key/ssh/sync/env/build/setup/snapshot
│ ├── skills.go # skill install/list
│ ├── task.go # task run/config
│ ├── upgrade.go # upgrade
│ └── validate.go # validate
├── anthropic/ # Anthropic Messages API client
├── buildprompt/ # Three-step pipeline: discover → analyze → generate
├── circleci/ # CircleCI REST API client
├── config/ # User config (XDG_CONFIG_HOME/chunk/config.json)
├── github/ # GitHub GraphQL client (reviews, repos)
├── gitremote/ # Git remote URL parsing for org/repo detection
├── gitutil/ # Git utility helpers
├── httpcl/ # HTTP client library (JSON + retries)
├── iostream/ # I/O stream abstraction
├── sidecar/ # CircleCI sidecar operations
├── skills/ # Skill definitions (go:embed) and installation
├── task/ # Task run config and CircleCI trigger
├── secrets/ # Secret resolution (env var value expansion)
├── session/ # Session ID tracking for Stop hook context
├── settings/ # .claude/settings.json build and merge
├── testing/recorder/ # HTTP recorder for tests
├── tui/ # Terminal UI components (confirm, input, select)
├── ui/ # Colors, formatting, spinner
├── upgrade/ # CLI self-upgrade
└── validate/ # Validation command logic
Dependencies flow strictly downward:
main.go → internal/cmd/ → internal/{business packages} → internal/httpcl/
main.gocreates the root command and handles top-level errorsinternal/cmd/contains thin cobra wrappers that parse flags and delegate- Business packages (
buildprompt/,task/, etc.) contain the logic internal/httpcl/is an independent library — no imports are allowed to otherinternal/packagesconfig/is a leaf — no imports from otherinternal/packages
No upward or lateral imports between business packages, except where a
package naturally composes another (e.g. task/ uses circleci/).
main() → cmd.NewRootCmd(version) → rootCmd.Execute()Errors are caught in main(). If the error is a usererr.Error, only the
user-facing message is printed (no stack trace). Otherwise the raw error
is printed. Both exit with code 1.
Three-step pipeline orchestrated by buildprompt.Run():
1. Discover github/ → FetchReviewActivity() per repo
→ AggregateActivity() → TopN reviewers
→ FilterDetailsByReviewers()
→ writes details.json, details-pr-rankings.csv
2. Analyze GroupByReviewer(comments)
→ anthropic/ → AnalyzeReviews() → Claude
→ writes analysis.md
3. Generate Read analysis.md
→ anthropic/ → GenerateReviewPrompt() → Claude
→ writes review-prompt.md
- If
--orgis provided,--reposis required - If neither is provided, both are auto-detected from the git remote
- Analysis step:
claude-sonnet-4-6 - Generation step:
claude-opus-4-6 - Overridable via
--analyze-model/--prompt-modelflags
Define every environment variable name as a const in the config
package. Use these constants in user-facing messages and t.Setenv calls.
Never use bare os.Getenv("CIRCLE_TOKEN") strings.
const (
EnvCircleToken = "CIRCLE_TOKEN"
EnvAnthropicAPIKey = "ANTHROPIC_API_KEY"
EnvGitHubToken = "GITHUB_TOKEN"
EnvModel = "CODE_REVIEW_CLI_MODEL"
// ...
)Declare all environment variables once in an EnvVars struct with env
struct tags (via go-envconfig). Express defaults as tag values, not
if-empty checks:
type EnvVars struct {
CircleToken string `env:"CIRCLE_TOKEN"`
CircleCIBaseURL string `env:"CIRCLECI_BASE_URL,default=https://circleci.com"`
AnthropicAPIKey string `env:"ANTHROPIC_API_KEY"`
AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL,default=https://api.anthropic.com"`
// ...
}LoadEnv(ctx) populates the struct via envconfig.Process.
When adding a new environment variable:
- Add a
const Env...for user-facing messages and test code. - Add a field to
EnvVarswith anenvtag (anddefault=if needed). - Wire it into
Resolve()or consume it from the struct directly.
Config resolves through a strict priority chain:
flag > env var > config file > default
Resolve() returns a ResolvedConfig struct. Each value is paired with
a source string (e.g. "Environment variable (CIRCLE_TOKEN)") so
status/diagnostic output can show where the value came from.
Not all values support all layers — for example, CircleCI and GitHub
tokens have no flag, so their chain is env var > config file. The
Anthropic API key and model support the full chain.
User config lives at $XDG_CONFIG_HOME/chunk/config.json (default: ~/.config/chunk/config.json):
{
"anthropicAPIKey": "sk-...",
"model": "claude-sonnet-4-6"
}Project config lives in .chunk/config.json (per repository):
{
"orgID": "f22b6566-597d-46d5-ba74-99ef5bb3d85c",
"commands": []
}Client New() functions receive values from the resolved config. They
must not call os.Getenv themselves. This keeps env reading centralised
in config.Resolve and makes clients testable.
| Variable | Used by | Purpose |
|---|---|---|
ANTHROPIC_API_KEY |
anthropic, config, validate | Anthropic authentication |
ANTHROPIC_BASE_URL |
anthropic, validate | API endpoint override |
GITHUB_TOKEN |
github | GitHub authentication |
GITHUB_API_URL |
github | GitHub API endpoint override |
CIRCLE_TOKEN / CIRCLECI_TOKEN |
circleci | CircleCI authentication |
CIRCLECI_ORG_ID |
sidecar | CircleCI organization ID (overrides orgID in .chunk/config.json) |
CIRCLECI_BASE_URL |
circleci | CircleCI endpoint override |
CLAUDE_PROJECT_DIR |
settings | IDE-provided project directory used by generated PreToolUse hooks |
CLAUDE_WORKING_DIR |
validate | Active worktree directory (Stop hook context) |
CHUNK_HOOKS_DISABLED |
validate, hook | Disable Stop-hook validation when set (any non-empty value) |
XDG_CONFIG_HOME |
config | User config directory (default: ~/.config) |
XDG_DATA_HOME |
sidecar | Per-project state directory (default: ~/.local/share) |
chunk init generates .claude/settings.json with a PreToolUse hook that
runs configured validation commands before the AI agent commits code. See
docs/HOOKS.md for details.
chunk validate runs those same commands manually, with SHA256-based content
caching so unchanged files skip re-execution.
Shared HTTP infrastructure used by anthropic/, circleci/, and github/:
- JSON request/response encoding by default
- Automatic retry via
hashicorp/go-retryablehttp(up to 3 retries) - Configurable auth (Bearer token or custom header like
x-api-key) - Fluent request builder:
httpcl.NewRequest(method, path, opts...)
Every error returned from a command carries two perspectives:
- Developer error — the wrapped
errorchain for logs and debugging. - User-facing message — a plain-English sentence shown on stderr.
The userError struct in internal/cmd/usererr.go satisfies both error
and a set of display interfaces:
type userError struct {
msg string // brief user-facing headline
detail string // optional clarification
suggestion string // optional actionable hint
err error // underlying Go error (for errors.Is / As)
}
func (e *userError) UserMessage() string { return e.msg }
func (e *userError) Detail() string { return e.detail }
func (e *userError) Suggestion() string { return e.suggestion }
func (e *userError) Unwrap() error { return e.err }The display interfaces (UserMessage, Detail, Suggestion) are checked
via type assertion at the top-level error handler — they are not imported
as a named interface. This keeps the error type private to cmd.
All formatting happens in main(), never inside command handlers:
func main() {
if err := rootCmd.Execute(); err != nil {
msg, detail, suggestion := errorDetails(err)
fmt.Fprint(os.Stderr, ui.FormatError(msg, detail, suggestion))
os.Exit(1)
}
}errorDetails probes the error for the three display interfaces via duck
typing, then falls back to sensible defaults (the raw .Error() string
as detail, pattern-matched hints as suggestion).
Rules:
- Command handlers must never call
ui.FormatErroror print styled error text themselves. Return the error; let the boundary format it. - Never use a sentinel "silent" error to suppress output. Every non-nil error produces output through the single boundary.
- Helpers like
notAuthorized(action, err)andsshSessionError(err)can inspect an error and return a*userError(or nil to signal "not my error"). The caller chains them:if err := notAuthorized("sync files", err); err != nil { return err }
API client packages export sentinel errors and typed error structs so
callers use errors.Is / errors.As instead of string matching:
// internal/anthropic
var ErrKeyNotFound = errors.New("api key not found")
var ErrTokenLimit = errors.New("prompt exceeds context window")
type StatusError struct {
Op string
StatusCode int
}HTTP client packages must not leak the shared httpcl.HTTPError type to
callers. Instead, wrap it into a package-local StatusError via a
mapErr helper:
func mapErr(op string, err error) error {
var he *hc.HTTPError
if !errors.As(err, &he) { return err }
return &StatusError{Op: op, StatusCode: he.StatusCode}
}The ui package owns all ANSI styling (Bold, Dim, Red, Green,
Warning, Success, FormatError). Business logic in internal/ must
not import it.
Use callback injection for progress reporting via iostream.StatusFunc:
// iostream/status.go
type Level int
const (
LevelStep Level = iota
LevelInfo
LevelWarn
LevelDone
)
type StatusFunc func(level Level, msg string)The cmd layer wires the callback to styled output:
func newStatusFunc(streams iostream.Streams) iostream.StatusFunc {
return func(level iostream.Level, msg string) {
switch level {
case iostream.LevelStep:
streams.ErrPrintln(ui.Bold(msg))
case iostream.LevelInfo:
streams.ErrPrintf(" %s\n", ui.Dim(msg))
case iostream.LevelWarn:
streams.ErrPrintf(" %s\n", ui.Warning(msg))
case iostream.LevelDone:
streams.ErrPrintf(" %s\n", ui.Success(msg))
}
}
}Business logic accepts StatusFunc as a parameter and calls it for
progress output. Tests can pass a no-op or capturing stub.
Use gotest.tools/v3/assert for test assertions:
- Prefer
assert.Checkto keep the test running and collect as many failures as possible in a single run - Use
assert.Assert/assert.NilErroras gates — only when failure means the remaining assertions are pointless or unsafe (e.g. a nil pointer would panic, or a missing resource means nothing else can be verified) - Do not call functions or methods directly inside the assertion; always use a temporary variable
- Use
cmpcomparisons fromgotest.tools/v3/assert/cmpfor semantic matchers over raw boolean expressions
assert.Assert and assert.Check both accept three kinds of argument: a bool
expression, a cmp.Comparison, or an error.
assert.Check calls t.Fail and returns false, allowing the test to continue
collecting failures — prefer it by default. assert.Assert calls
t.FailNow and stops immediately — use it only as a gate.
The canonical pattern: use assert.NilError (or assert.Assert) to gate on
preconditions, then use assert.Check for everything else:
result, err := doSomething()
assert.NilError(t, err) // gate: no point checking result if err != nil
assert.Check(t, result.OK)
assert.Check(t, cmp.Equal(result.Status, "ready"))
assert.Check(t, cmp.Len(result.Items, 3))
assert.Check(t, cmp.Contains(result.Name, "prefix"))Named functions are all fatal. assert.Equal, assert.DeepEqual,
assert.Error, assert.ErrorContains, and assert.ErrorIs all call
t.FailNow. To get the non-fatal equivalent, use assert.Check with the
corresponding cmp comparison:
| Fatal (gate only) | Non-fatal equivalent |
|---|---|
assert.Equal(t, a, b) |
assert.Check(t, cmp.Equal(a, b)) |
assert.DeepEqual(t, a, b) |
assert.Check(t, cmp.DeepEqual(a, b)) |
assert.Error(t, err, "msg") |
assert.Check(t, cmp.Error(err, "msg")) |
assert.ErrorContains(t, err, "sub") |
assert.Check(t, cmp.ErrorContains(err, "sub")) |
assert.ErrorIs(t, err, target) |
assert.Check(t, cmp.ErrorIs(err, target)) |
assert.Assert(t, <bool or cmp>) |
assert.Check(t, <bool or cmp>) |
Note: assert.Assert must be called from the goroutine running the test
function. assert.Check is safe to call from any goroutine.
Never pass a function or method call directly as an assertion argument. Always capture the result in a variable first. This applies to all calls, including error-returning functions, getters, and string conversions.
// ❌ BAD
assert.NilError(t, os.WriteFile(path, data, perm))
assert.Check(t, cmp.Equal(st.Code(), codes.NotFound))
assert.Check(t, cmp.Len(registry.List(), 0))
// ✅ GOOD
err := os.WriteFile(path, data, perm)
assert.NilError(t, err)
stCode := st.Code()
assert.Check(t, cmp.Equal(stCode, codes.NotFound))
sidecars := registry.List()
assert.Check(t, cmp.Len(sidecars, 0))Type conversions (int32(x), string(b)) and built-in functions (len) are
exempt from this rule.
assert.Check (and assert.Assert) accept a trailing
msgAndArgs ...interface{} that is appended to the failure output. Pass a
message when the comparison alone does not make the intent obvious — for
example, when checking a boolean derived from non-obvious logic, when the
variable name is ambiguous, or when the test loops over cases and you need to
identify which iteration failed.
// ❌ Opaque — failure says "false" with no context
assert.Check(t, got.ExpiresAt.Before(deadline))
// ✅ Clear — failure says what the check was verifying
assert.Check(t, got.ExpiresAt.Before(deadline), "token must expire before session deadline")
// ❌ In a loop — impossible to tell which item failed
for _, item := range items {
assert.Check(t, cmp.Equal(item.State, "ready"))
}
// ✅ In a loop — failure identifies the offending item
for _, item := range items {
assert.Check(t, cmp.Equal(item.State, "ready"), "item %q", item.ID)
}Skip the message when the comparison is already self-documenting — cmp.Equal,
cmp.DeepEqual, cmp.Len, and cmp.ErrorIs all produce structured failure
messages that include the values involved, so they rarely need extra annotation.
import (
"gotest.tools/v3/assert"
"gotest.tools/v3/assert/cmp"
)
func TestSomething(t *testing.T) {
// Gate: fail immediately if setup fails — nothing else can run
err := startContainer(ctx)
assert.NilError(t, err)
result, err := doSomething()
assert.NilError(t, err) // gate: result is meaningless if err != nil
// Check everything else — collects all failures in one run
assert.Check(t, cmp.Equal(result.Status, "ok"))
assert.Check(t, cmp.Len(result.Items, 3))
assert.Check(t, result.Ready)
}Use the most specific assertion for the situation. Prefer named functions over
raw boolean expressions when a named function exists. Fall back to
assert.Check(t, <bool>) for comparisons that have no dedicated function — the
expression source code appears verbatim in the failure message, which is good
enough.
| Situation | Preferred form (non-fatal) | Gate form (fatal) |
|---|---|---|
err must be nil |
— | assert.NilError(t, err) |
Two scalar values must be equal (==) |
assert.Check(t, cmp.Equal(actual, expected)) |
assert.Equal(t, actual, expected) |
| Complex values must be equal (go-cmp diff) | assert.Check(t, cmp.DeepEqual(actual, expected)) |
assert.DeepEqual(t, actual, expected) |
| Error must match exact message | assert.Check(t, cmp.Error(err, "msg")) |
assert.Error(t, err, "msg") |
| Error must contain substring | assert.Check(t, cmp.ErrorContains(err, "sub")) |
assert.ErrorContains(t, err, "sub") |
| Error must match sentinel / wrapped error | assert.Check(t, cmp.ErrorIs(err, target)) |
assert.ErrorIs(t, err, target) |
| Anything else | assert.Check(t, <bool or cmp>) |
assert.Assert(t, <bool or cmp>) |
There are no dedicated NotNil or NotEmpty helpers. Use
boolean expressions — the source is included in the failure message:
// Use Assert as a gate when nil would cause a panic below
assert.Assert(t, result != nil)
// Use Check for non-fatal emptiness assertions
assert.Check(t, cmp.Nil(result))
assert.Check(t, len(items) != 0)Express comparisons directly as boolean expressions:
assert.Check(t, x > 0)
assert.Check(t, a >= b)
assert.Check(t, count < limit)To check if a string contains a substring:
result := "this is the haystack"
assert.Check(t, cmp.Contains(result, "needle"))Use cmp comparisons for richer failure messages:
// Length — prints expected vs actual length on failure
assert.Check(t, cmp.Len(items, 3))
// Containment — works for slices, maps, and strings
assert.Check(t, cmp.Contains(slice, item))
assert.Check(t, cmp.Contains(mapping, "key"))
assert.Check(t, cmp.Contains(str, "substr"))Use assert.Check(t, cmp.DeepEqual(...)) for structs, slices, and maps. It uses
go-cmp and produces a clear diff on failure:
assert.Check(t, cmp.DeepEqual(result, myStruct{Name: "title"}))
// assertion failed: ... (diff of the two values)For unordered slice comparison, pass cmpopts.SortSlices from
github.com/google/go-cmp/cmp/cmpopts:
assert.Check(t, cmp.DeepEqual(actual, expected,
cmpopts.SortSlices(func(a, b string) bool { return a < b }),
))For JSON, unmarshal first and use DeepEqual:
var actual, expected MyType
err := json.Unmarshal(data, &actual)
assert.NilError(t, err)
assert.Check(t, cmp.DeepEqual(actual, expected))assert.Check(t, cmp.Regexp(`^\d{4}-\d{2}-\d{2}$`, dateStr))assert.Check (and assert.Assert) accept any cmp.Comparison — a function
that returns a cmp.Result. Use this for domain-specific checks:
withinTolerance := func(got, want, delta float64) cmp.Comparison {
return func() cmp.Result {
if math.Abs(got-want) <= delta {
return cmp.ResultSuccess
}
return cmp.ResultFailure(fmt.Sprintf("%v not within %v of %v", got, delta, want))
}
}
assert.Check(t, withinTolerance(actual, expected, 0.01))