Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6,306 changes: 6,306 additions & 0 deletions .surface

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@STYLE.md

# Basecamp CLI Development Context

## Getting Started
Expand Down
33 changes: 27 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ all: check

# Build the binary
.PHONY: build
build:
build: check-toolchain
$(GOBUILD) $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY) ./cmd/basecamp

# Build with PGO optimization (requires default.pgo)
Expand Down Expand Up @@ -90,7 +90,7 @@ build-bsd:

# Run tests
.PHONY: test
test:
test: check-toolchain
BASECAMP_NO_KEYRING=1 $(GOTEST) -v ./...

# Run end-to-end tests against Go binary
Expand All @@ -100,15 +100,20 @@ test-e2e: build

# Run tests with race detector
.PHONY: race-test
race-test:
race-test: check-toolchain
BASECAMP_NO_KEYRING=1 $(GOTEST) -race -count=1 ./...

# Run tests with coverage
.PHONY: test-coverage
test-coverage:
test-coverage: check-toolchain
$(GOTEST) -v -coverprofile=coverage.out ./...
$(GOCMD) tool cover -html=coverage.out -o coverage.html

# Coverage with browser open
.PHONY: coverage
coverage: test-coverage
@command -v open >/dev/null 2>&1 && open coverage.html || true

# ============================================================================
# Benchmarking & Performance
# ============================================================================
Expand Down Expand Up @@ -173,6 +178,20 @@ clean-pgo:
rm -rf profiles/
rm -f benchmarks-*.txt

# Guard against Go toolchain mismatch (mise environment)
.PHONY: check-toolchain
check-toolchain:
@GOV=$$($(GOCMD) version | awk '{print $$3}'); \
ROOT=$$($(GOCMD) env GOROOT); \
ROOTV=$$($$ROOT/bin/go version | awk '{print $$3}'); \
if [ "$$GOV" != "$$ROOTV" ]; then \
echo "ERROR: Go toolchain mismatch"; \
echo " PATH go: $$GOV ($$(which go))"; \
echo " GOROOT go: $$ROOTV ($$ROOT/bin/go)"; \
echo "Fix: eval \"\$$(mise hook-env)\" && make <target>"; \
exit 1; \
fi

# Bump SDK dependency and update provenance
.PHONY: bump-sdk
bump-sdk:
Expand Down Expand Up @@ -207,7 +226,7 @@ provenance-check:

# Run go vet
.PHONY: vet
vet:
vet: check-toolchain
$(GOVET) ./...

# Format code
Expand Down Expand Up @@ -242,7 +261,7 @@ tidy:
# Verify go.mod/go.sum are tidy (CI gate)
# Restores original files on any failure so the check is non-mutating.
.PHONY: tidy-check
tidy-check:
tidy-check: check-toolchain
@set -e; cp go.mod go.mod.tidycheck; cp go.sum go.sum.tidycheck; \
restore() { mv go.mod.tidycheck go.mod; mv go.sum.tidycheck go.sum; }; \
if ! $(GOMOD) tidy; then \
Expand Down Expand Up @@ -440,6 +459,7 @@ help:
@echo " test-e2e Run end-to-end tests against Go binary"
@echo " race-test Run tests with race detector"
@echo " test-coverage Run tests with coverage report"
@echo " coverage Run tests with coverage and open in browser"
@echo ""
@echo "Performance:"
@echo " bench Run all benchmarks"
Expand All @@ -453,6 +473,7 @@ help:
@echo " clean-pgo Remove PGO artifacts"
@echo ""
@echo "Code Quality:"
@echo " check-toolchain Guard against Go toolchain mismatch"
@echo " vet Run go vet"
@echo " fmt Format code"
@echo " fmt-check Check code formatting"
Expand Down
34 changes: 34 additions & 0 deletions STYLE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Basecamp CLI Style Guide

Conventions for contributors and agents working on basecamp-cli.

## Command Constructors

Exported `NewXxxCmd() *cobra.Command` — one per top-level command group in `internal/commands/`.
Subcommands are unexported `newXxxYyyCmd()` added via `cmd.AddCommand()`.

## Output

Success: `app.OK(data, ...options)` with optional `WithBreadcrumbs`, `WithSummary`, `WithContext`.
Errors: `output.ErrUsage()`, `output.ErrNotFound()`, SDK error conversion via `output.AsError()`.

## Config Resolution

6-layer precedence: flags > env > local > repo > global > system > defaults.
Trust boundaries enforced via `config.TrustStore`.
Source tracking via `cfg.Sources["field_name"]` records provenance of each value.

## Catalog

Static `commandCategories()` in `commands.go`. Every registered command must appear.
`TestCatalogMatchesRegisteredCommands` enforces bidirectional parity.

## Method Ordering

Invocation order: constructor, RunE, then helpers called by RunE.
Export order: public before private.

## File Organization

One file per top-level command group in `internal/commands/`.
Shortcut commands (e.g., `todo`, `done`, `comment`) live alongside their parent group.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
charm.land/bubbletea/v2 v2.0.1
charm.land/lipgloss/v2 v2.0.0
github.com/basecamp/basecamp-sdk/go v0.2.2
github.com/basecamp/cli v0.1.0
github.com/basecamp/cli v0.1.1
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/x/ansi v0.11.6
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/basecamp-sdk/go v0.2.2 h1:wfMrjTytLCLsBG2SrQh5UDvGgj3QHVwg6KRvkL+ayeg=
github.com/basecamp/basecamp-sdk/go v0.2.2/go.mod h1:WmckHy36EAqP+BW//1J9QdMi16l3PNx2XP0vt/kSlXE=
github.com/basecamp/cli v0.1.0 h1:0fA06OgHD0oObY3aCC8E6QS2jNxCmwYfUeUwK/zyNQw=
github.com/basecamp/cli v0.1.0/go.mod h1:NTHe+keCTGI2qM5sMXdkUN0QgU3zGbwnBxcmg8vD5QU=
github.com/basecamp/cli v0.1.1 h1:FAF3M09xo1m7gJJXf38glCkT50ZUuvz+31f+c3R3zcc=
github.com/basecamp/cli v0.1.1/go.mod h1:NTHe+keCTGI2qM5sMXdkUN0QgU3zGbwnBxcmg8vD5QU=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
Expand Down
17 changes: 13 additions & 4 deletions internal/appctx/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,7 @@ func NewApp(cfg *config.Config) *App {
// Create resilience components for cross-process state coordination
// State is stored in <cacheDir>/resilience/state.json
// If CacheDir is empty, NewStore uses the default (~/.cache/basecamp/resilience/)
var resilienceDir string
if cfg.CacheDir != "" {
resilienceDir = filepath.Join(cfg.CacheDir, resilience.DefaultDirName)
}
resilienceDir := resolveResilienceDir(cfg)
resilienceStore := resilience.NewStore(resilienceDir)
resilienceCfg := resilience.DefaultConfig()
gatingHooks := resilience.NewGatingHooksFromConfig(resilienceStore, resilienceCfg)
Expand Down Expand Up @@ -400,3 +397,15 @@ func (a *App) Resolve() *resolve.Resolver {
}),
)
}

// resolveResilienceDir determines the resilience state directory.
// When cache_dir was explicitly overridden (via flag, env, or config file —
// any source tracked in cfg.Sources["cache_dir"]), resilience state co-locates
// under the cache tree for backward compatibility. Otherwise, NewStore("")
// calls defaultStateDir() which uses XDG_STATE_HOME on Linux/BSD.
func resolveResilienceDir(cfg *config.Config) string {
if cfg.Sources["cache_dir"] != "" {
return filepath.Join(cfg.CacheDir, resilience.DefaultDirName)
}
return ""
}
31 changes: 31 additions & 0 deletions internal/appctx/resilience_dir_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package appctx

import (
"testing"

"github.com/basecamp/basecamp-cli/internal/config"
"github.com/basecamp/basecamp-cli/internal/resilience"
)

func TestResolveResilienceDirDefault(t *testing.T) {
cfg := &config.Config{
CacheDir: "/home/user/.cache/basecamp",
Sources: map[string]string{},
}
got := resolveResilienceDir(cfg)
if got != "" {
t.Errorf("resolveResilienceDir() = %q, want empty (delegate to defaultStateDir)", got)
}
}

func TestResolveResilienceDirExplicit(t *testing.T) {
cfg := &config.Config{
CacheDir: "/custom/cache",
Sources: map[string]string{"cache_dir": "flag"},
}
got := resolveResilienceDir(cfg)
want := "/custom/cache/" + resilience.DefaultDirName
if got != want {
t.Errorf("resolveResilienceDir() = %q, want %q", got, want)
}
}
3 changes: 2 additions & 1 deletion internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ func commandCategories() []CommandCategory {
{Name: "doctor", Category: "auth", Description: "Check CLI health and diagnose issues"},
{Name: "upgrade", Category: "auth", Description: "Upgrade to the latest version"},
{Name: "migrate", Category: "auth", Description: "Migrate data from legacy bcq installation"},
{Name: "profile", Category: "auth", Description: "Manage named profiles", Actions: []string{"list", "show", "create", "delete", "set-default"}},
},
},
{
Expand All @@ -128,7 +129,7 @@ func commandCategories() []CommandCategory {
{Name: "completion", Category: "additional", Description: "Generate shell completions", Actions: []string{"bash", "zsh", "fish", "powershell", "refresh", "status"}},
{Name: "mcp", Category: "additional", Description: "MCP server integration", Actions: []string{"server"}},
{Name: "tools", Category: "additional", Description: "Manage project dock tools", Actions: []string{"show", "create", "update", "trash", "enable", "disable", "reposition"}},
{Name: "skill", Category: "additional", Description: "Print the embedded agent skill file"},
{Name: "skill", Category: "additional", Description: "Manage the embedded agent skill file", Actions: []string{"install"}},
{Name: "tui", Category: "additional", Description: "Launch the Basecamp workspace", Experimental: true},
{Name: "api", Category: "additional", Description: "Raw API access"},
{Name: "help", Category: "additional", Description: "Show help"},
Expand Down
90 changes: 48 additions & 42 deletions internal/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,59 @@ import (
"sort"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"

"github.com/basecamp/basecamp-cli/internal/cli"
"github.com/basecamp/basecamp-cli/internal/commands"
)

func TestCatalogMatchesRegisteredCommands(t *testing.T) {
// Build root command with all subcommands (mirrors cli.Execute)
root := buildRootWithAllCommands()

// Get registered command names
registered := make(map[string]bool)
for _, cmd := range root.Commands() {
registered[cmd.Name()] = true
}
// version is accessed via --version flag, not as a subcommand, but we
// include it in the catalog for documentation. Add it to registered set.
registered["version"] = true

// Get catalog command names
catalog := make(map[string]bool)
for _, name := range commands.CatalogCommandNames() {
catalog[name] = true
}

// Find commands in catalog but not registered
var missingFromRegistered []string
for name := range catalog {
if !registered[name] {
missingFromRegistered = append(missingFromRegistered, name)
}
}

// Find commands registered but not in catalog
var missingFromCatalog []string
for name := range registered {
if !catalog[name] {
missingFromCatalog = append(missingFromCatalog, name)
}
}

// Sort for deterministic output
sort.Strings(missingFromRegistered)
sort.Strings(missingFromCatalog)

// Report failures
assert.Empty(t, missingFromRegistered, "Commands in catalog but not registered: %v", missingFromRegistered)
assert.Empty(t, missingFromCatalog, "Commands registered but not in catalog: %v", missingFromCatalog)
}

// buildRootWithAllCommands creates a root command with all subcommands registered,
// mirroring cli.Execute. Shared by TestCatalog and TestSurfaceSnapshot.
func buildRootWithAllCommands() *cobra.Command {
root := cli.NewRootCmd()
root.AddCommand(commands.NewAuthCmd())
root.AddCommand(commands.NewProjectsCmd())
Expand Down Expand Up @@ -71,46 +116,7 @@ func TestCatalogMatchesRegisteredCommands(t *testing.T) {
root.AddCommand(commands.NewMigrateCmd())
root.AddCommand(commands.NewSkillCmd())
root.AddCommand(commands.NewTUICmd())

// Trigger Cobra's auto-addition of help subcommand
root.AddCommand(commands.NewProfileCmd())
root.InitDefaultHelpCmd()

// Get registered command names
registered := make(map[string]bool)
for _, cmd := range root.Commands() {
registered[cmd.Name()] = true
}
// version is accessed via --version flag, not as a subcommand, but we
// include it in the catalog for documentation. Add it to registered set.
registered["version"] = true

// Get catalog command names
catalog := make(map[string]bool)
for _, name := range commands.CatalogCommandNames() {
catalog[name] = true
}

// Find commands in catalog but not registered
var missingFromRegistered []string
for name := range catalog {
if !registered[name] {
missingFromRegistered = append(missingFromRegistered, name)
}
}

// Find commands registered but not in catalog
var missingFromCatalog []string
for name := range registered {
if !catalog[name] {
missingFromCatalog = append(missingFromCatalog, name)
}
}

// Sort for deterministic output
sort.Strings(missingFromRegistered)
sort.Strings(missingFromCatalog)

// Report failures
assert.Empty(t, missingFromRegistered, "Commands in catalog but not registered: %v", missingFromRegistered)
assert.Empty(t, missingFromCatalog, "Commands registered but not in catalog: %v", missingFromCatalog)
return root
}
18 changes: 18 additions & 0 deletions internal/commands/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"errors"
"fmt"
"os"
"strconv"
"strings"

Expand All @@ -11,6 +12,7 @@ import (
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"

"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/editor"
"github.com/basecamp/basecamp-cli/internal/output"
"github.com/basecamp/basecamp-cli/internal/richtext"
)
Expand Down Expand Up @@ -255,6 +257,7 @@ You can pass either a comment ID or a Basecamp URL:
// NewCommentCmd creates the comment command (shortcut for creating comments).
func NewCommentCmd() *cobra.Command {
var content string
var edit bool
var recordingIDs []string

cmd := &cobra.Command{
Expand All @@ -267,6 +270,20 @@ Supports batch commenting on multiple recordings at once.`,
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())

if edit && content != "" {
return output.ErrUsage("cannot combine --edit and --content")
}
if edit {
fi, err := os.Stdin.Stat()
if err != nil || (fi.Mode()&os.ModeCharDevice) == 0 {
return output.ErrUsage("cannot use --edit when stdin is not a terminal")
}
content, err = editor.Open("")
if err != nil {
return output.ErrUsage(err.Error())
}
}

// Validate user input first, before checking account
if content == "" {
return output.ErrUsage("Comment content required")
Expand Down Expand Up @@ -374,6 +391,7 @@ Supports batch commenting on multiple recordings at once.`,
}

cmd.Flags().StringVarP(&content, "content", "c", "", "Comment content (required)")
cmd.Flags().BoolVar(&edit, "edit", false, "Open $EDITOR to compose content")
cmd.Flags().StringSliceVarP(&recordingIDs, "on", "r", nil, "Recording ID(s) to comment on (required)")
// Note: Required flags are validated manually in RunE for better error messages

Expand Down
Loading
Loading