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
14 changes: 11 additions & 3 deletions .claude-plugin/hooks/post-commit-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,26 @@ fi
# Check if commit succeeded by looking for output patterns
tool_output=$(echo "$input" | jq -r '.tool_output // empty' 2>/dev/null)

# Skip if commit failed — detect error indicators before checking for success.
# Only match lines that look like git/hook errors, not commit subject lines
# (e.g. "[branch abc1234] Fix failed login" should not trigger this guard).
# We strip the "[branch hash] subject" success line before scanning for errors.
filtered_output=$(echo "$tool_output" | grep -v '^\[.*[a-f0-9]\{7,\}\]')
if echo "$filtered_output" | grep -qiE '(^|[[:space:]])(error|fatal|aborted|rejected)[[:space:]:]|hook[[:space:]].*[[:space:]]failed|pre-commit[[:space:]].*[[:space:]]failed|^error:'; then
exit 0
fi

# Verify commit actually succeeded - look for commit hash pattern or "create mode"
if [[ ! "$tool_output" =~ \[.*[a-f0-9]{7,}\] ]] && [[ ! "$tool_output" =~ "create mode" ]]; then
# Commit likely failed, don't suggest linking
exit 0
fi

# Look for todo references in the commit message or branch name
branch=$(git branch --show-current 2>/dev/null || true)
last_commit_msg=$(git log -1 --format=%s 2>/dev/null || true)

# Patterns: BC-12345, todo-12345, basecamp-12345, #12345
todo_patterns='BC-[0-9]+|todo-[0-9]+|basecamp-[0-9]+|#[0-9]{5,}'
# Patterns: BC-12345, todo-12345, basecamp-12345
todo_patterns='BC-[0-9]+|todo-[0-9]+|basecamp-[0-9]+'

found_in_branch=$(echo "$branch" | grep -oEi "$todo_patterns" | head -1 || true)
found_in_msg=$(echo "$last_commit_msg" | grep -oEi "$todo_patterns" | head -1 || true)
Expand Down
22 changes: 21 additions & 1 deletion .claude-plugin/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

set -euo pipefail

# Require jq for JSON parsing
if ! command -v jq &>/dev/null; then
exit 0
fi

# Find basecamp - prefer PATH, fall back to plugin's bin directory
if command -v basecamp &>/dev/null; then
BASECAMP_BIN="basecamp"
Expand All @@ -23,6 +28,9 @@ EOF
fi
fi

# Get CLI version (--version prints "basecamp version X.Y.Z")
cli_version=$("$BASECAMP_BIN" --version 2>/dev/null | awk '{print $NF}' || true)

# Check if we have any Basecamp configuration
config_output=$("$BASECAMP_BIN" config show --json 2>/dev/null || echo '{}')
has_config=$(echo "$config_output" | jq -r '.data // empty' 2>/dev/null)
Expand All @@ -43,6 +51,17 @@ fi

# Build context message
context="Basecamp context loaded:"

if [[ -n "$cli_version" ]]; then
context+="\n CLI: v${cli_version}"
fi

# Show active profile if using named profiles
active_profile=$("$BASECAMP_BIN" profile show --json 2>/dev/null | jq -r '.data.name // empty' 2>/dev/null || true)
if [[ -n "$active_profile" ]]; then
context+="\n Profile: $active_profile"
fi

context+="\n Account: $account_id"

if [[ -n "$project_id" ]]; then
Expand All @@ -68,6 +87,7 @@ $(echo -e "$context")
Use \`basecamp\` commands to interact with Basecamp:
basecamp todos list # List todos in current project
basecamp search "query" # Search across projects
basecamp comment "msg" --on ID # Comment on a recording
basecamp reports assigned # See what's assigned to you
basecamp clock <id> --hours 1.5 # Log time on a recording
</hook-output>
EOF
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,11 @@ basecamp auth token # Print token for scripts

`basecamp` works with any AI agent that can run shell commands.

**Claude Code:** The `.claude-plugin/` is discovered automatically when you clone this repo. For standalone use, point at `skills/basecamp/SKILL.md`.
**Claude Code:** `claude plugin install basecamp` — installs the plugin with skills, hooks, and agent workflow support.

**Other agents:** Point your agent at [`skills/basecamp/SKILL.md`](skills/basecamp/SKILL.md) for full Basecamp workflow coverage.
**Other agents:** Point your agent at [`skills/basecamp/SKILL.md`](skills/basecamp/SKILL.md) for Basecamp workflow coverage.

**One-liner** to install skills locally (any agent):
```bash
curl -fsSL https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install-skills.sh | bash
```
**Agent discovery:** Every command supports `--help --agent` for structured JSON output (flags, gotchas, subcommands). Use `basecamp commands --json` for the full catalog.

See [install.md](install.md) for step-by-step setup instructions.

Expand Down
30 changes: 7 additions & 23 deletions install.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ Install the Basecamp CLI and connect it to your AI agent. Execute all steps auto

- [ ] Install Basecamp CLI
- [ ] Authenticate with Basecamp
- [ ] Install skills (optional)
- [ ] Connect agent (optional)

---
Expand Down Expand Up @@ -71,22 +70,6 @@ basecamp auth status

---

## Step 3: Install Skills (Optional)

Skills provide AI agents with Basecamp workflow knowledge.

```bash
curl -fsSL https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install-skills.sh | bash
```

**Verify:**
```bash
ls ${BASECAMP_DIR:-$HOME/.local/share/basecamp}/skills/*/SKILL.md
# Expected: List of SKILL.md files
```

---

EXECUTE NOW: Start with Step 1. Mark TODO items complete as you go. Stop when `basecamp --version && basecamp auth status` both succeed.

---
Expand All @@ -97,20 +80,21 @@ EXECUTE NOW: Start with Step 1. Mark TODO items complete as you go. Stop when `b

### Claude Code

The `.claude-plugin/` is discovered automatically when you clone the repo. No extra setup needed.
```bash
claude plugin install basecamp
```

This installs the plugin with skills, hooks, and agent workflow support.

### Other Agents

Point your agent at the skill file for full Basecamp workflow coverage:
```
~/.local/share/basecamp/skills/basecamp/SKILL.md
```

Or if you cloned the repo:
```
skills/basecamp/SKILL.md
```

Every command supports `--help --agent` for structured JSON discovery.

---

## Quick Test
Expand Down
96 changes: 96 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand All @@ -9,6 +10,7 @@ import (
"strings"

"github.com/spf13/cobra"
"github.com/spf13/pflag"

"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/commands"
Expand Down Expand Up @@ -136,6 +138,16 @@ func NewRootCmd() *cobra.Command {
_ = cmd.RegisterFlagCompletionFunc("account", completer.AccountCompletion())
_ = cmd.RegisterFlagCompletionFunc("profile", completer.ProfileCompletion())

// Override help to support --help --agent for structured JSON output.
defaultHelp := cmd.HelpFunc()
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
if agent, _ := c.Root().PersistentFlags().GetBool("agent"); agent {
emitAgentHelp(c)
return
}
defaultHelp(c, args)
})

return cmd
}

Expand Down Expand Up @@ -199,6 +211,7 @@ func Execute() {
cmd.AddCommand(commands.NewUpgradeCmd())
cmd.AddCommand(commands.NewMigrateCmd())
cmd.AddCommand(commands.NewProfileCmd())
cmd.AddCommand(commands.NewSkillCmd())
cmd.AddCommand(commands.NewTUICmd())

// Use ExecuteC to get the executed command (for correct context access)
Expand Down Expand Up @@ -477,3 +490,86 @@ func resolvePreferences(cmd *cobra.Command, cfg *config.Config, flags *appctx.Gl
flags.Verbose = *cfg.Verbose
}
}

// agentHelpInfo is the structured help output for --help --agent.
type agentHelpInfo struct {
Command string `json:"command"`
Path string `json:"path"`
Short string `json:"short"`
Long string `json:"long,omitempty"`
Usage string `json:"usage"`
Notes []string `json:"notes,omitempty"`
Subcommands []agentSubcommand `json:"subcommands,omitempty"`
Flags []agentFlag `json:"flags,omitempty"`
InheritedFlags []agentFlag `json:"inherited_flags,omitempty"`
}

type agentSubcommand struct {
Name string `json:"name"`
Short string `json:"short"`
Path string `json:"path"`
}

type agentFlag struct {
Name string `json:"name"`
Shorthand string `json:"shorthand,omitempty"`
Type string `json:"type"`
Default string `json:"default"`
Usage string `json:"usage"`
}

// emitAgentHelp writes structured JSON help for the given command to stdout.
func emitAgentHelp(cmd *cobra.Command) {
info := agentHelpInfo{
Command: cmd.Name(),
Path: cmd.CommandPath(),
Short: cmd.Short,
Long: cmd.Long,
Usage: cmd.UseLine(),
}

// Extract notes from Annotations["agent_notes"]
if notes, ok := cmd.Annotations["agent_notes"]; ok && notes != "" {
for _, line := range strings.Split(notes, "\n") {
line = strings.TrimSpace(line)
if line != "" {
info.Notes = append(info.Notes, line)
}
}
}

// Subcommands
for _, sub := range cmd.Commands() {
if sub.IsAvailableCommand() || sub.Name() == "help" {
info.Subcommands = append(info.Subcommands, agentSubcommand{
Name: sub.Name(),
Short: sub.Short,
Path: sub.CommandPath(),
})
}
}

// Local flags
cmd.NonInheritedFlags().VisitAll(func(f *pflag.Flag) {
info.Flags = append(info.Flags, agentFlag{
Name: f.Name,
Shorthand: f.Shorthand,
Type: f.Value.Type(),
Default: f.DefValue,
Usage: f.Usage,
})
})

// Inherited flags
cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
info.InheritedFlags = append(info.InheritedFlags, agentFlag{
Name: f.Name,
Shorthand: f.Shorthand,
Type: f.Value.Type(),
Default: f.DefValue,
Usage: f.Usage,
})
})

_ = json.NewEncoder(cmd.OutOrStdout()).Encode(info)
}
3 changes: 2 additions & 1 deletion internal/commands/boost.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ Use 'basecamp boost list <recording-id>' to see boosts on a recording.
Use 'basecamp boost show <boost-id>' to view a specific boost.
Use 'basecamp boost create <recording-id> "emoji"' to boost a recording.
Use 'basecamp boost delete <boost-id>' to remove a boost.`,
Args: cobra.MinimumNArgs(0),
Annotations: map[string]string{"agent_notes": "Boost content is typically an emoji but can be text\nbasecamp react is a shortcut for boost create"},
Args: cobra.MinimumNArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
Expand Down
3 changes: 2 additions & 1 deletion internal/commands/campfire.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ func NewCampfireCmd() *cobra.Command {
Use 'basecamp campfire list' to see campfires in a project.
Use 'basecamp campfire messages' to view recent messages.
Use 'basecamp campfire post "message"' to post a message.`,
Args: cobra.MinimumNArgs(0),
Annotations: map[string]string{"agent_notes": "Each project has one campfire (the chat room)\nContent supports Markdown — converted to HTML automatically\nCampfire is project-scoped, no cross-project campfire queries"},
Args: cobra.MinimumNArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if err := ensureAccount(cmd, app); err != nil {
Expand Down
7 changes: 4 additions & 3 deletions internal/commands/cards.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ func NewCardsCmd() *cobra.Command {
var all bool

cmd := &cobra.Command{
Use: "cards",
Short: "Manage cards in Card Tables",
Long: "List, show, create, and manage cards in Card Tables (Kanban boards).",
Use: "cards",
Short: "Manage cards in Card Tables",
Long: "List, show, create, and manage cards in Card Tables (Kanban boards).",
Annotations: map[string]string{"agent_notes": "Cards do NOT support --assignee filtering like todos — fetch all and filter client-side\nIf a project has multiple card tables, you must specify --card-table <id>\nAssign/unassign shortcuts work on cards: basecamp assign <card_id> --to <person>\nCross-project cards: basecamp recordings cards --json"},
RunE: func(cmd *cobra.Command, args []string) error {
// Default to list when called without subcommand
return runCardsList(cmd, project, column, cardTable, limit, page, all)
Expand Down
1 change: 1 addition & 0 deletions internal/commands/checkins.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func NewCheckinsCmd() *cobra.Command {

Check-ins are recurring questions that collect answers from team members
on a schedule (e.g., "What did you work on today?").`,
Annotations: map[string]string{"agent_notes": "Each project has one questionnaire (check-in container)\nQuestions are asked on a recurring schedule\nAnswers are posted by team members in response"},
RunE: func(cmd *cobra.Command, args []string) error {
// Default to show questionnaire when called without subcommand
return runCheckinsShow(cmd, project, questionnaireID)
Expand Down
1 change: 1 addition & 0 deletions internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,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: "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
1 change: 1 addition & 0 deletions internal/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func TestCatalogMatchesRegisteredCommands(t *testing.T) {
root.AddCommand(commands.NewDoctorCmd())
root.AddCommand(commands.NewUpgradeCmd())
root.AddCommand(commands.NewMigrateCmd())
root.AddCommand(commands.NewSkillCmd())
root.AddCommand(commands.NewTUICmd())

// Trigger Cobra's auto-addition of help subcommand
Expand Down
8 changes: 5 additions & 3 deletions internal/commands/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ func NewCommentsCmd() *cobra.Command {
var all bool

cmd := &cobra.Command{
Use: "comments",
Short: "List and manage comments",
Long: "List, show, and update comments on recordings.",
Use: "comments",
Short: "List and manage comments",
Long: "List, show, and update comments on recordings.",
Annotations: map[string]string{"agent_notes": "Comments are flat — reply to parent recording, not to other comments\nURL fragments (#__recording_456) are comment IDs — comment on the parent recording_id, not the comment_id\nComments are on recordings (todos, messages, cards, etc.) — not on other comments"},
RunE: func(cmd *cobra.Command, args []string) error {
// Default to list when called without subcommand
return runCommentsList(cmd, recordingID, limit, page, all)
Expand Down Expand Up @@ -262,6 +263,7 @@ func NewCommentCmd() *cobra.Command {
Long: `Add a comment to one or more Basecamp recordings (todos, messages, etc.)

Supports batch commenting on multiple recordings at once.`,
Annotations: map[string]string{"agent_notes": "Comments are flat — reply to parent recording, not to other comments\nURL fragments (#__recording_456) are comment IDs — comment on the parent recording_id, not the comment_id\nComments are on recordings (todos, messages, cards, etc.) — not on other comments"},
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())

Expand Down
1 change: 1 addition & 0 deletions internal/commands/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Config locations:
- Global: ~/.config/basecamp/config.json
- Repo: <git-root>/.basecamp/config.json
- Local: .basecamp/config.json`,
Annotations: map[string]string{"agent_notes": "config init creates .basecamp/config.json in the current directory\nconfig project interactively selects a project and saves it\nPer-repo config is committed to git — share project defaults with your team\nbasecamp api is an escape hatch for endpoints not yet wrapped by a dedicated command"},
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigShow(cmd)
},
Expand Down
Loading
Loading