Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
50de5ca
Stats line: drop prefix, use middle dot separator
jeremy Feb 25, 2026
2ad4b0c
Hints gating: rename Next → Hints, off by default
jeremy Feb 25, 2026
2b2fa5e
Root command: zero API calls
jeremy Feb 25, 2026
ff2881c
Commands listing: grouped render with aligned columns
jeremy Feb 25, 2026
96aec91
Search results: humanized output
jeremy Feb 25, 2026
787f6f1
Projects list: show IDs, drop redundant status column
jeremy Feb 25, 2026
5b3dc37
Add basecamp upgrade command
jeremy Feb 25, 2026
9c45fea
TUI search: fix query dedup, guard against chrome overflow
jeremy Feb 25, 2026
1864329
Search: preserve raw results for machine formats, Subject fallback
jeremy Feb 25, 2026
c600510
Address PR review feedback: testability, io.Writer, UTF-8 safety
jeremy Feb 25, 2026
5800002
Add preference fields (hints, stats, verbose) to Config
jeremy Feb 26, 2026
e780c30
Resolve behavior preferences from config before applying flags
jeremy Feb 26, 2026
6929060
Wire hints/stats/verbose into config set and config show
jeremy Feb 26, 2026
86f1d94
Apply De Morgan's law to satisfy staticcheck QF1001
jeremy Feb 26, 2026
7583062
Fix OAuth scope value in wizard: "write" → "full"
jeremy Feb 27, 2026
56ac27f
Fix breadcrumb key in people command: account → account_id
jeremy Feb 27, 2026
ff194fa
Use rune-safe truncation in table cell formatting
jeremy Feb 27, 2026
9a654f8
Use AdaptiveColor in renderer instead of hardcoded Dark variants
jeremy Feb 27, 2026
240d4bc
Improve breadcrumb styling: commands in Data, descriptions in Subtle
jeremy Feb 27, 2026
5a22bfa
Replace raw ANSI in doctor with lipgloss; add zsh eval check
jeremy Feb 27, 2026
7fb3c98
Style auth login output with themed renderer
jeremy Feb 27, 2026
aec9d8e
Wrap upgrade output in response envelope via app.OK()
jeremy Feb 27, 2026
d980676
Live theme reloading via fsnotify
jeremy Feb 27, 2026
913afac
Hide unimplemented mcp serve; show help on bare mcp
jeremy Feb 27, 2026
0a220cb
Show help on bare tools command instead of error
jeremy Feb 27, 2026
77adaf4
Remove sh -c editor invocation to prevent shell injection
jeremy Feb 27, 2026
2982066
Fix --page flag description across all list commands
jeremy Feb 27, 2026
dde06f1
Clarify --project flag accepts names, not just IDs
jeremy Feb 27, 2026
a4d06a5
Return success when migration already completed
jeremy Feb 27, 2026
f15c227
Return structured JSON from profile create
jeremy Feb 27, 2026
f6e8885
List valid keys in config set error; tighten dir permissions
jeremy Feb 27, 2026
004ca6f
Tighten directory permissions to 0700
jeremy Feb 27, 2026
5ecccc8
Remove experimental badge; use typed view identity checks
jeremy Feb 27, 2026
07098ac
Add multi-column FullHelp to key TUI views
jeremy Feb 27, 2026
87f9add
Truncate list descriptions with ellipsis instead of hiding
jeremy Feb 27, 2026
617f29b
Drop -V shorthand from doctor; use explicit HTTP client
jeremy Feb 27, 2026
e7c5dd5
Add usage examples to search command
jeremy Feb 27, 2026
5202c4e
Fix README and install docs for launch accuracy
jeremy Feb 27, 2026
43d4396
Fix misleading test case name in root_test
jeremy Feb 27, 2026
730c545
Drop unused appOut return from executeUpgradeCommand
jeremy Feb 27, 2026
91ff77d
Mark fsnotify as direct dependency in go.mod
jeremy Feb 27, 2026
e972a29
Add validation for expanded show types
jeremy Feb 27, 2026
1fb44d9
Handle atomic saves and symlink changes in theme watcher
jeremy Feb 27, 2026
2e1c30d
Use rune-safe Truncate helper for list descriptions
jeremy Feb 27, 2026
6b23a6f
Make gosec blocking and restore dependency-review job
jeremy Feb 27, 2026
2cd34f1
Strengthen release quality gate to match local make check
jeremy Feb 27, 2026
f6e1fa3
Remove prerelease and private-repo messaging
jeremy Feb 27, 2026
7d4dc73
Verify checksums and cosign signatures in install script
jeremy Feb 27, 2026
1cad5ca
Fix Makefile lint target to resolve golangci-lint via GOPATH fallback
jeremy Feb 27, 2026
4ad99d3
Upgrade benchmark regression annotation from warning to error
jeremy Feb 27, 2026
605543e
Prefer GOBIN over PATH for golangci-lint resolution
jeremy Feb 27, 2026
35441fd
Add install-only and govulncheck to release quality gate
jeremy Feb 27, 2026
155abee
Exit 1 on benchmark regression detection
jeremy Feb 27, 2026
d3aa06b
Harden install script checksum and cosign verification
jeremy Feb 27, 2026
9e44de3
Gate release on full security suite via reusable workflow
jeremy Feb 27, 2026
5fd043e
Use fixed-string grep for checksum file matching
jeremy Feb 27, 2026
b22bd38
Nil out themeWatcher on close to prevent double-close and stale re-arm
jeremy Feb 27, 2026
bb28b5f
Fix gosec private module resolution and dependency-review availability
jeremy Feb 27, 2026
332e873
Use -no-fail for gosec and mark dependency-review non-blocking
jeremy Feb 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ permissions:
contents: write
packages: write # Required for Docker push to ghcr.io
id-token: write # Required for keyless cosign signing via OIDC
security-events: write # Required for SARIF upload in security scan
pull-requests: read

jobs:
security:
name: Security scan
uses: ./.github/workflows/security.yml
secrets: inherit

test:
name: Test before release
runs-on: ubuntu-latest
Expand Down Expand Up @@ -39,12 +46,38 @@ jobs:
- name: Configure git for private modules
run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/"

- name: Install golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.9.0
install-only: true

- name: Cache BATS
id: cache-bats
uses: actions/cache@v5
with:
path: /usr/local/libexec/bats-core
key: bats-1.11.0

- name: Install BATS
if: steps.cache-bats.outputs.cache-hit != 'true'
run: |
git clone --depth 1 --branch v1.11.0 https://github.com/bats-core/bats-core.git /tmp/bats-core
sudo /tmp/bats-core/install.sh /usr/local

- name: Run release quality gate
run: make fmt-check vet test provenance-check check-naming
run: |
make fmt-check vet lint test test-e2e provenance-check check-naming
go test -race -count=1 ./...

- name: Run govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

release:
name: Release
needs: test
needs: [test, security]
runs-on: ubuntu-latest
env:
GOPRIVATE: github.com/basecamp/basecamp-sdk
Expand Down
17 changes: 12 additions & 5 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
schedule:
# Weekly scan on Monday at 6am UTC
- cron: '0 6 * * 1'
workflow_call: # Allow release.yml to invoke the full security suite
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -84,9 +85,9 @@ jobs:
run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/"

- name: Run gosec
uses: securego/gosec@v2.23.0
with:
args: '-no-fail -fmt sarif -out gosec-results.sarif ./...'
run: |
go install github.com/securego/gosec/v2/cmd/gosec@v2.23.0
gosec -no-fail -fmt sarif -out gosec-results.sarif ./...

- name: Upload gosec scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
Expand All @@ -97,5 +98,11 @@ jobs:

# NOTE: govulncheck runs in test.yml - not duplicated here

# TODO: Restore dependency-review job when repo goes public
# Requires GitHub Advanced Security, noisy during private dev
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
continue-on-error: true # Requires GitHub Advanced Security (not available on all plans)
steps:
- uses: actions/checkout@v6
- uses: actions/dependency-review-action@v4
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,8 @@ jobs:
run: |
benchstat benchmarks-baseline.txt benchmarks.txt > comparison.txt 2>&1 || true
if grep -E '\+[2-9][0-9]\.[0-9]+%|\+[1-9][0-9][0-9]+' comparison.txt; then
echo "::warning::Potential performance regression detected (>20% slower in some benchmarks)"
echo "::error::Performance regression detected (>20% slower). See benchmark comparison in step summary."
exit 1
fi

- name: Save benchmark baseline
Expand Down
6 changes: 3 additions & 3 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ release:
basecamp auth login
```

Requires `gh` CLI with access to the basecamp org. Other platforms: grab the matching archive from the assets below.
Other platforms: download the matching archive from the assets below.

# Homebrew cask — deferred until repo goes public or cask auth for private repos is solved
# Homebrew cask — deferred until cask tap is set up
# homebrew_casks:
# - name: basecamp
# repository:
Expand All @@ -113,7 +113,7 @@ release:
# - basecamp
# skip_upload: auto

# Scoop (Windows) — deferred, no internal Windows users
# Scoop (Windows) — deferred, no Windows users yet
# scoops:
# - name: basecamp
# repository:
Expand Down
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,18 @@ fmt-check:
@test -z "$$($(GOFMT) -s -l . | tee /dev/stderr)" || (echo "Code is not formatted. Run 'make fmt'" && exit 1)

# Run linter (requires golangci-lint)
# Prefer GOBIN/GOPATH binary (matches active Go toolchain) over PATH (may be Homebrew/system)
GOLANGCI_LINT := $(shell go env GOBIN)/golangci-lint
ifeq ($(wildcard $(GOLANGCI_LINT)),)
GOLANGCI_LINT := $(shell go env GOPATH)/bin/golangci-lint
endif
ifeq ($(wildcard $(GOLANGCI_LINT)),)
GOLANGCI_LINT := $(shell command -v golangci-lint 2>/dev/null)
endif

.PHONY: lint
lint:
golangci-lint run ./...
$(GOLANGCI_LINT) run ./...

# Tidy dependencies
.PHONY: tidy
Expand Down
15 changes: 4 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# <img src="assets/basecamp-badge.svg" height="28" alt="Basecamp"> Basecamp CLI

> **Prerelease.** This is an early internal release for 37signals dogfooding. The repo is private — all install methods below require GitHub access. Expect rough edges; file issues as you find them.

`basecamp` is the official command-line interface for Basecamp. Manage projects, todos, messages, and more from your terminal or through AI agents.

- Works standalone or with any AI agent (Claude, Codex, Copilot, Gemini)
Expand All @@ -20,7 +18,7 @@ That's it. You now have full access to Basecamp from your terminal.
<details>
<summary>Other installation methods</summary>

**Go install** (requires `GOPRIVATE=github.com/basecamp/*`):
**Go install:**
```bash
go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest
```
Expand All @@ -30,11 +28,6 @@ go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest
curl -fsSL https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.sh | bash
```

**Windows (Scoop):**
```bash
scoop bucket add basecamp https://github.com/basecamp/homebrew-tap
scoop install basecamp
```
</details>

## Usage
Expand Down Expand Up @@ -77,8 +70,8 @@ Breadcrumbs suggest next commands, making it easy for humans and agents to navig
OAuth 2.1 with automatic token refresh. First login opens your browser:

```bash
basecamp auth login # Full read/write access
basecamp auth login --scope read # Read-only access
basecamp auth login # Read-only access (default)
basecamp auth login --scope full # Full read/write access
basecamp auth token # Print token for scripts
```

Expand Down Expand Up @@ -120,7 +113,7 @@ See [install.md](install.md) for step-by-step setup instructions.

```bash
basecamp doctor # Check CLI health and diagnose issues
basecamp doctor -V # Verbose output with details
basecamp doctor --verbose # Verbose output with details
```

## Development
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/term v0.2.1
github.com/fsnotify/fsnotify v1.8.0
github.com/gofrs/flock v0.13.0
github.com/mattn/go-runewidth v0.0.16
github.com/spf13/cobra v1.10.2
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
Expand Down
2 changes: 1 addition & 1 deletion install.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Opens browser for OAuth. Grant access when prompted.
**Verify:**
```bash
basecamp auth status
# Expected: Authenticated as your@email.com
# Expected: Authenticated (scope: read)
```

---
Expand Down
7 changes: 6 additions & 1 deletion internal/appctx/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ type GlobalFlags struct {
Verbose int // 0=off, 1=operations, 2=operations+requests (stacks with -v -v or -vv)
Stats bool
NoStats bool // Explicit disable (overrides --stats and dev default)
Hints bool
NoHints bool // Explicit disable (overrides --hints and dev default)
CacheDir string
}

Expand Down Expand Up @@ -211,6 +213,9 @@ func (a *App) OK(data any, opts ...output.ResponseOption) error {
stats := a.Collector.Summary()
opts = append(opts, output.WithStats(&stats))
}
if !a.Flags.Hints || a.Flags.NoHints {
opts = append(opts, output.WithoutBreadcrumbs())
}
return a.Output.OK(data, opts...)
}

Expand Down Expand Up @@ -297,7 +302,7 @@ func (a *App) printStatsToStderr(stats *observability.SessionMetrics) {

parts := stats.FormatParts()
if len(parts) > 0 {
fmt.Fprintf(os.Stderr, "\nStats: %s\n", strings.Join(parts, " | "))
fmt.Fprintf(os.Stderr, "\n%s\n", strings.Join(parts, " · "))
}
}

Expand Down
38 changes: 37 additions & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ func NewRootCmd() *cobra.Command {
}
}

// Resolve behavior preferences: explicit flag > config > version.IsDev()
resolvePreferences(cmd, cfg, &flags)

// Create app and store in context
app := appctx.NewApp(cfg)
app.Flags = flags
Expand Down Expand Up @@ -104,9 +107,12 @@ func NewRootCmd() *cobra.Command {

// Behavior flags
cmd.PersistentFlags().CountVarP(&flags.Verbose, "verbose", "v", "Verbose output (-v for ops, -vv for requests)")
cmd.PersistentFlags().BoolVar(&flags.Stats, "stats", version.IsDev(), "Show session statistics (default: on in dev builds)")
cmd.PersistentFlags().BoolVar(&flags.Stats, "stats", false, "Show session statistics (persisted via: basecamp config set stats true)")
cmd.PersistentFlags().BoolVar(&flags.NoStats, "no-stats", false, "Disable session statistics")
cmd.MarkFlagsMutuallyExclusive("stats", "no-stats")
cmd.PersistentFlags().BoolVar(&flags.Hints, "hints", false, "Show follow-up hints (persisted via: basecamp config set hints true)")
cmd.PersistentFlags().BoolVar(&flags.NoHints, "no-hints", false, "Disable follow-up hints")
cmd.MarkFlagsMutuallyExclusive("hints", "no-hints")
cmd.PersistentFlags().StringVar(&flags.CacheDir, "cache-dir", "", "Cache directory")

// Register tab completion for flags.
Expand Down Expand Up @@ -177,6 +183,7 @@ func Execute() {
cmd.AddCommand(commands.NewCompletionCmd())
cmd.AddCommand(commands.NewSetupCmd())
cmd.AddCommand(commands.NewDoctorCmd())
cmd.AddCommand(commands.NewUpgradeCmd())
cmd.AddCommand(commands.NewMigrateCmd())
cmd.AddCommand(commands.NewProfileCmd())
cmd.AddCommand(commands.NewTUICmd())
Expand Down Expand Up @@ -417,3 +424,32 @@ func transformCobraError(err error) error {

return err
}

// resolvePreferences resolves behavior flag values using the precedence chain:
// explicit flag > config > version.IsDev()
//
// Flags register with default=false so we can detect explicit usage via Changed().
// When no flag is passed, we check config, then fall back to version.IsDev().
func resolvePreferences(cmd *cobra.Command, cfg *config.Config, flags *appctx.GlobalFlags) {
pf := cmd.PersistentFlags()

if !pf.Changed("stats") && (!pf.Changed("no-stats") || !flags.NoStats) {
if cfg.Stats != nil {
flags.Stats = *cfg.Stats
} else {
flags.Stats = version.IsDev()
}
}

if !pf.Changed("hints") && (!pf.Changed("no-hints") || !flags.NoHints) {
if cfg.Hints != nil {
flags.Hints = *cfg.Hints
} else {
flags.Hints = version.IsDev()
}
}

if !pf.Changed("verbose") && cfg.Verbose != nil {
flags.Verbose = *cfg.Verbose
}
}
Loading
Loading