See CONTRIBUTING.md for build setup, testing, and PR workflow.
The standard development loop in this repo: make changes, run bin/ci, fix
what it catches, repeat until green, then push. Treat bin/ci as your
inner-loop companion, not a final hurdle.
basecamp-cli/
├── cmd/basecamp/ # Main entrypoint
├── internal/
│ ├── appctx/ # Application context
│ ├── auth/ # OAuth authentication
│ ├── cli/ # CLI framework
│ ├── commands/ # Command implementations
│ ├── completion/ # Shell completion
│ ├── config/ # Configuration management
│ ├── dateparse/ # Date parsing
│ ├── hostutil/ # Host utilities
│ ├── models/ # Data models
│ ├── names/ # Name resolution
│ ├── observability/ # Tracing and metrics
│ ├── output/ # Output formatting
│ ├── presenter/ # Output presentation
│ ├── resilience/ # Retry and backoff
│ ├── sdk/ # Basecamp SDK wrapper
│ ├── tui/ # Terminal UI
│ └── version/ # Version info
├── e2e/ # BATS integration tests
├── skills/ # Agent skills
├── hooks/ # Agent lifecycle hooks (both plugin agents)
├── .claude-plugin/ # Claude Code integration
└── .codex-plugin/ # Codex plugin manifest
Coding-agent integration lives in internal/harness (agent registry, detection, plugin and
skill health checks) and internal/commands/wizard_agents.go (basecamp setup claude|codex|grok|agents). Claude Code and Codex each get a native plugin from the
basecamp/claude-plugins marketplace and have registrations of their own (claude.go,
codex.go). The plugin is basecamp-cli@37signals. During the deprecation window the marketplace keeps
basecamp as an alias with the same source; setup treats basecamp@37signals as stale and
replaces it by key, and the SessionStart hook (routed through agent-hook pre-commit-snapshot
so older CLIs stay silent) tells alias installs once to switch. That is safe only while
basecamp in the marketplace means this plugin: drop the isStalePluginKey /
CodexLegacyPluginKey handling before the marketplace points basecamp at the hosted
connector. Grok Build has no plugin: it reads the shared ~/.agents/skills/basecamp skill
directly, so it is a row of harness.SkillAgent (name, id, home env var, home directory,
binary) in skill_agent.go, and everything in internal/commands that touches a shared-skill
agent — the setup handler, the BASECAMP_SETUP_AGENT values, doctor's remediation — loops over
harness.SkillAgents() rather than naming it. A new shared-skill agent is a new row; the skill
picker's (Global) row, the prose lists in the installers and the docs are the places to update
by hand. setup <id> never fabricates an agent's home directory: a skill-only agent that is not
detected is reported missing, not created.
API documentation: https://github.com/basecamp/bc3-api
Key endpoints used by the CLI:
/projects.json- List projects/buckets/{id}/todolists/{id}/todos.json- Todos in a list/buckets/{id}/todos/{id}/completion.json- Complete todo/people.json- List people/my/profile.json- Current user
Search: Use basecamp search "query" for full-text search across projects. The Recordings API (basecamp recordings) is for browsing by type/status without a search term.
bin/ci is the local CI gate. It runs every check that remote CI runs:
formatting, vetting, linting, unit tests, e2e tests, naming conventions,
CLI surface snapshots, skill drift detection, SDK provenance, and go mod tidy.
Run it early and often — after finishing a feature, after fixing a bug, before
pushing. If you're about to git push and haven't run bin/ci in this
session, stop and run it first.
Skill drift: make check-skill-drift runs over skills/basecamp/SKILL.md,
skills/basecamp-doctor/SKILL.md and skills/basecamp-connect/SKILL.md, checking that the commands and flags each one
references still exist in the .surface snapshot. It catches stale references, not
missing coverage, so adding a command breaks none of them.
A reference has to resolve exactly: basecamp setup <removed> no longer passes by
falling back to basecamp setup. Words past a resolved command are allowed only where
they cannot name a subcommand — after a command that takes positional arguments, and
after a leaf that has none — so argument values and prose still pass; a word that runs
on into a filename or key (upload report.pdf, config set project_id) is read as the
argument it is. A word naming a command in .surface-breaking, the record of removals,
fails before either escape can take it, which is how removal is caught. Two limits
remain. Under a command that has both arguments and subcommands (basecamp recordings),
a subcommand that never existed passes as an argument value. And .surface cannot say
whether a group also runs bare (basecamp skill prints the skill file), so a word after
one fails even where the CLI would accept it; hidden commands are not in .surface at
all. Acknowledge a deliberate case with the baseline entry its DRIFT line names, in
.surface-skill-drift. make test-skill-drift runs the check against fixtures that hold
all of this.
Update the skill the change actually affects;
basecamp-doctor deliberately covers only doctor, setup and auth remediation;
basecamp-connect covers basecamp auth agent connect and basecamp connect, and its
evals run against its own SKILL.md (make -C skill-evals eval-connect).
Hint commands: a hint is an instruction an operator is about to run, so
TestHintCommandsResolve holds the commands hints name to the same exactness, against
the real command tree rather than .surface. It reads hint text in internal/commands
and internal/connector/setup — hints written inline, built into a variable, returned
by a helper, passed through a wrapper, or assigned to a hint-named field — and every
string in connect.go. Asking
the command lets it be exact where the script cannot: a word after a group passes only
if the group is not the root, runs, and its own argument validator accepts the word — which a subcommand
that never existed can still satisfy, under a group like recordings that takes one. It
checks commands, not flags.
bin/ci # The single command — run thisWhen iterating on a specific area, use targeted make targets for faster
feedback, then finish with bin/ci before pushing:
make build # Build binary to ./bin/basecamp
make test # Go unit tests
make test-e2e # BATS end-to-end tests
make lint # Linter
make check # All checks (what bin/ci runs)Dead code: make deadcode reports unreachable functions whole-program, rooted
at the shipped binary and again with -tags dev. It is a report, not a gate, and
is deliberately outside make check. The linter's unused cannot do this job —
it runs per-package and counts every exported identifier in a non-main package as
used, which is how 600 lines of exported, zero-caller internal/tui API survived
it. Root it at the binary, not ./...: only main packages are roots, so ./...
reports everything no main reaches — mostly the dev-tagged workspace tree — as
unreachable.
It also analyzes one GOOS/GOARCH at a time, while we release five. A host run says
nothing about the others: code behind another platform's build tag is never loaded,
and a function whose only caller sits behind one looks unreachable. Before deleting
anything platform-adjacent, check the other targets — install the tool for the host
and set GOOS for the analysis (GOOS=windows deadcode ./cmd/basecamp), since
GOOS=windows go run cross-compiles the tool itself and fails.
Read the output before acting on it: a zero-caller exported symbol is a candidate,
not a verdict, and the dev-tagged tree is legitimately partial.
Requirements: Go 1.26+, bats-core for e2e tests.
For local development against BC3:
BASECAMP_BASE_URL=http://3.basecamp.localhost:3001 basecamp auth loginOAuth endpoints are discovered via .well-known/oauth-authorization-server.
make bench # Run all benchmarks
make bench-cpu # Run with CPU profiling
make bench-mem # Run with memory profiling
make bench-save # Save baseline for comparison
make bench-compare # Compare against baselineThe CLI depends on the Basecamp SDK (github.com/basecamp/basecamp-sdk/go). When the
SDK adds new API operations, the CLI must add corresponding commands.
Tracking: internal/version/sdk-provenance.json records the SDK version +
API revision the CLI is built against. API-COVERAGE.md tracks endpoint coverage.
Workflow (see also .claude/skills/sdk-bump.md):
make bump-sdk— updates go.mod + provenance (never edit go.mod directly)go build ./...— detect breaking changes- Fix any compilation errors in
internal/commands/andinternal/sdk/ - Check for new SDK services/methods → create corresponding CLI commands
make test— verify all pass including catalog parity- Update
API-COVERAGE.mdwith new endpoint coverage
Completeness bar: every new SDK service method needs:
- Command file in
internal/commands/ - Catalog entry in
commands.go - Registration in
internal/cli/root.go+commands_test.go - API-COVERAGE.md row
Andon cord: if the SDK lacks a Go service wrapper for a generated endpoint, stop and open an issue on basecamp-sdk — never call the raw generated client from CLI code.
See STYLE.md for the Go conventions used here. Read it when writing or reviewing Go;
it is not imported, so it stays out of context for sessions that never touch Go.