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
3 changes: 3 additions & 0 deletions .surface-breaking
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
ARG basecamp assign 00 <todo_id>
ARG basecamp unassign 00 <todo_id>
CMD basecamp mcp
FLAG basecamp mcp --help type=bool
SUB basecamp mcp
FLAG basecamp checkin answer create --question type=string
FLAG basecamp checkins answer create --question type=string
FLAG basecamp webhook create --url type=string
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ replace-check:

# Run all checks (local CI gate)
.PHONY: check
check: fmt-check vet lint test test-e2e check-naming check-surface check-skill-drift provenance-check tidy-check
check: fmt-check vet lint test test-e2e check-naming check-surface check-skill-drift check-bare-groups provenance-check tidy-check

# Full pre-flight for release: check + replace-check + vuln + race + surface compat
.PHONY: release-check
Expand Down Expand Up @@ -329,6 +329,11 @@ check-surface-compat: build
check-skill-drift:
@scripts/check-skill-drift.sh

# Verify group commands show help bare (no RunE on parents with subcommands)
.PHONY: check-bare-groups
check-bare-groups:
@scripts/check-bare-groups.sh

# Guard against bcq/BCQ creeping back (allowlist in .naming-allowlist)
.PHONY: check-naming
check-naming:
Expand Down
11 changes: 11 additions & 0 deletions STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ Static `commandCategories()` in `commands.go`. Every registered command must app
Invocation order: constructor, RunE, then helpers called by RunE.
Export order: public before private.

## Bare Command Groups

Command groups (resource nouns like `todos`, `projects`, `todosets`) must not set
`RunE` on the parent command. Bare invocation shows help — Cobra does this
automatically when a command has subcommands and no `RunE`.

Shortcut commands (`card`, `search`, `url`) are different: they perform an action
directly and may also have subcommands. These are allowed to set `RunE`.

`scripts/check-bare-groups.sh` enforces this in CI.

## File Organization

One file per top-level command group in `internal/commands/`.
Expand Down
9 changes: 9 additions & 0 deletions e2e/auth.bats
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
load test_helper


# Help

@test "auth without subcommand shows help" {
run basecamp auth
assert_success
assert_output_contains "COMMANDS"
}


# Auth token

@test "basecamp auth token --help shows help" {
Expand Down
9 changes: 9 additions & 0 deletions e2e/boost.bats
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
load test_helper


# Help

@test "boost without subcommand shows help" {
run basecamp boost
assert_success
assert_output_contains "COMMANDS"
}


# react shortcut errors

@test "react without --on or --recording shows usage error" {
Expand Down
9 changes: 9 additions & 0 deletions e2e/campfire.bats
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
load test_helper


# Help

@test "campfire without subcommand shows help" {
run basecamp campfire
assert_success
assert_output_contains "COMMANDS"
}


# Flag parsing errors

@test "campfire --project without value shows error" {
Expand Down
9 changes: 9 additions & 0 deletions e2e/files.bats
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
load test_helper


# Help

@test "files without subcommand shows help" {
run basecamp files
assert_success
assert_output_contains "COMMANDS"
}


# Flag parsing errors

@test "files --project without value shows error" {
Expand Down
9 changes: 9 additions & 0 deletions e2e/messagetypes.bats
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
load test_helper


# Help

@test "messagetypes without subcommand shows help" {
run basecamp messagetypes
assert_success
assert_output_contains "COMMANDS"
}


# Missing context errors

@test "messagetypes show without id shows error" {
Expand Down
9 changes: 9 additions & 0 deletions e2e/subscriptions.bats
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
load test_helper


# Help

@test "subscriptions without subcommand shows help" {
run basecamp subscriptions
assert_success
assert_output_contains "COMMANDS"
}


# Flag parsing errors

@test "subscriptions --project without value shows error" {
Expand Down
9 changes: 9 additions & 0 deletions e2e/templates.bats
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
load test_helper


# Help

@test "templates without subcommand shows help" {
run basecamp templates
assert_success
assert_output_contains "COMMANDS"
}


# Show errors

@test "templates show without id shows error" {
Expand Down
9 changes: 9 additions & 0 deletions e2e/webhooks.bats
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
load test_helper


# Help

@test "webhooks without subcommand shows help" {
run basecamp webhooks
assert_success
assert_output_contains "COMMANDS"
}


# Flag parsing errors

@test "webhooks create --url is not a valid flag" {
Expand Down
4 changes: 0 additions & 4 deletions internal/commands/boost.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ Use 'basecamp boost delete <boost-id>' to remove a boost.
Tip: In the TUI, press 'b' on any item to boost interactively.
'basecamp react' is a shortcut for 'boost create'.`,
Annotations: map[string]string{"agent_notes": "Boosts are tiny messages of support (16 chars max), not just emoji\nbasecamp react is a shortcut for boost create\nIn TUI mode, press 'b' on any item to boost interactively"},
Args: cobra.MinimumNArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}

cmd.PersistentFlags().StringVarP(&project, "project", "p", "", "Project ID or name")
Expand Down
3 changes: 0 additions & 3 deletions internal/commands/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ func NewMCPCmd() *cobra.Command {
Long: `MCP server for AI integration.

The MCP server allows AI assistants like Claude to interact with Basecamp.`,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}

cmd.AddCommand(newMCPServeCmd())
Expand Down
3 changes: 0 additions & 3 deletions internal/commands/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@ Campfire, Schedule, etc. Tool IDs can be found in the project's dock array
Tools can be created by cloning existing ones (e.g., create a second Campfire).
Disabling a tool hides it from the dock but preserves its content.`,
Annotations: map[string]string{"agent_notes": "Dock tools are the sidebar navigation items in a project\nEnable/disable controls visibility without deleting\nEach tool has a type (e.g., Todoset, Schedule, MessageBoard, Vault, Chat::Campfire)"},
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}

cmd.PersistentFlags().StringVarP(&project, "project", "p", "", "Project ID or name")
Expand Down
62 changes: 62 additions & 0 deletions scripts/check-bare-groups.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Verify that command group parents don't set RunE (bare invocation shows help).
#
# Shortcut commands that intentionally perform an action AND have subcommands
# are listed in the allowlist below.
set -euo pipefail

COMMANDS_DIR="internal/commands"

# Commands allowed to have RunE + AddCommand (shortcuts, not groups)
ALLOWLIST=(
NewCardCmd # shortcut: creates a card
NewCompletionCmd # dispatches by shell arg
NewRecordingsCmd # shortcut: lists recordings
NewSearchCmd # shortcut: performs search
NewSetupCmd # wizard entry point
NewSkillCmd # renders skill content
NewTimesheetCmd # shortcut: shows report
NewURLCmd # shortcut: opens URL
)

is_allowed() {
local func="$1"
for allowed in "${ALLOWLIST[@]}"; do
if [[ "$func" == "$allowed" ]]; then
return 0
fi
done
return 1
}

violations=0

# Find exported New*Cmd functions that have both AddCommand and RunE
for file in "$COMMANDS_DIR"/*.go; do
[[ "$file" == *_test.go ]] && continue

# Extract each NewXxxCmd function body (from "func NewXxxCmd" to next "^func ")
while IFS= read -r func_name; do
# Get the function body
body=$(sed -n "/^func ${func_name}(/,/^func /p" "$file" | sed '$d')

has_addcommand=$(echo "$body" | grep -Ec 'AddCommand|cmd\.AddCommand' || true)
has_rune=$(echo "$body" | grep -c 'RunE:' || true)

if [[ "$has_addcommand" -gt 0 && "$has_rune" -gt 0 ]]; then
if ! is_allowed "$func_name"; then
echo "FAIL: $file: $func_name has RunE + AddCommand (group parents should show help, not run)"
((violations++))
fi
fi
done < <(grep -Eo 'func (New[A-Za-z]+Cmd)\(' "$file" | sed 's/func //; s/(//' | sort -u)
done

if [[ "$violations" -gt 0 ]]; then
echo ""
echo "$violations violation(s). Group commands must not set RunE."
echo "If this is an intentional shortcut, add it to the ALLOWLIST in $0."
exit 1
fi

echo "Bare group convention check passed ($(printf '%s\n' "${ALLOWLIST[@]}" | wc -l | tr -d ' ') allowlisted shortcuts)"
Loading