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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "basecamp",
"name": "basecamp-cli",
"version": "0.12.0",
"description": "Basecamp integration for Claude Code. Create todos, track work, link code to projects.",
"author": {
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "basecamp",
"name": "basecamp-cli",
"version": "0.12.0",
"description": "Use Basecamp from Codex to find work, create todos, and keep projects up to date.",
"author": {
Expand Down
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@ Coding-agent integration lives in `internal/harness` (agent registry, detection,
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`). Grok Build has no plugin: it reads the shared `~/.agents/skills/basecamp` skill
`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
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,12 +292,37 @@ Manual Codex installation uses the same marketplace:

```bash
codex plugin marketplace add basecamp/claude-plugins
codex plugin add basecamp@37signals
codex plugin add basecamp-cli@37signals
```

To pick up a newer plugin version later, refresh the marketplace with
`codex plugin marketplace upgrade 37signals` (or re-run `basecamp setup codex`).

**Plugin name:** in the 37signals marketplace this plugin is `basecamp-cli`
(`basecamp-cli@37signals`). It was published as `basecamp`, a name set aside
for the hosted Basecamp connector plugin, which works through Basecamp's own
MCP server rather than this CLI. During a deprecation window the marketplace
keeps `basecamp` as an alias of `basecamp-cli`, so an existing
`basecamp@37signals` install keeps working and updating; once per agent it
says the plugin has been renamed. Switch with `basecamp setup claude` or
`basecamp setup codex` (or `basecamp setup agents`). For Claude Code, setup
replaces `basecamp@37signals` with `basecamp-cli` at the scopes it was
installed in. Codex installs have no scopes, so setup adds `basecamp-cli` and
then removes the old ID. To switch by hand:

```bash
claude plugin marketplace update 37signals
claude plugin install basecamp-cli@37signals
claude plugin uninstall basecamp@37signals

codex plugin marketplace upgrade 37signals
codex plugin add basecamp-cli@37signals
codex plugin remove basecamp@37signals
```

Skills from the plugin are namespaced by its name, so `basecamp:basecamp`
becomes `basecamp-cli:basecamp`. The CLI itself is still `basecamp`.

**Grok Build:** `basecamp setup grok` — installs the shared skill and confirms it is healthy. There is no Grok plugin: Grok reads user skills from `~/.grok/skills/` and from the cross-agent `~/.agents/skills/`, so the shared `~/.agents/skills/basecamp` skill is the whole integration. Grok is detected by `$GROK_HOME` (default `~/.grok`) or a `grok` binary on `PATH`, in `~/.local/bin`, or in `$GROK_HOME/bin` where its installers put it. Start a new Grok session after setup to load the skill.

**Other agents:** Point your agent at [`skills/basecamp/SKILL.md`](skills/basecamp/SKILL.md) for Basecamp workflow coverage.
Expand Down
13 changes: 12 additions & 1 deletion hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
{
"description": "Basecamp agent hooks: commit-reference nudges.",
"description": "Basecamp agent hooks: commit-reference nudges, and a one-time notice for installs under the plugin's pre-rename id.",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "basecamp agent-hook pre-commit-snapshot",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
Expand Down
6 changes: 4 additions & 2 deletions install.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ Interactive setup in Step 1 connects every detected agent. Without a controlling
basecamp setup claude
```

This registers the marketplace and installs the plugin with skills, hooks, and agent workflow support.
This registers the marketplace and installs the `basecamp-cli@37signals` plugin with skills, hooks, and agent workflow support.

The plugin was named `basecamp` until that name was set aside for the hosted Basecamp connector plugin. During a deprecation window `basecamp@37signals` remains an alias that keeps working; re-running `basecamp setup claude` replaces it with `basecamp-cli@37signals` at the same scopes, and `basecamp setup codex` adds `basecamp-cli@37signals` and then removes the old ID (Codex installs have no scopes to preserve).

The hooks call the CLI's `agent-hook` command, so they need a `basecamp` new
enough to have it. If hook errors appear after installing or refreshing the
Expand All @@ -158,7 +160,7 @@ For a manual install:

```bash
codex plugin marketplace add basecamp/claude-plugins
codex plugin add basecamp@37signals
codex plugin add basecamp-cli@37signals
```

To pick up a newer plugin version later, refresh with `codex plugin marketplace upgrade 37signals` (or re-run `basecamp setup codex`).
Expand Down
9 changes: 9 additions & 0 deletions internal/commands/agent_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ func newAgentHookPreCommitSnapshotCmd() *cobra.Command {
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
input, ok := readAgentHookInput(cmd.InOrStdin())
// The plugin's SessionStart hook runs this subcommand too, for the
// rename notice: CLIs that predate the notice already have this
// subcommand and ignore a payload with no tool call, so a plugin
// newer than the CLI stays silent instead of failing every
// session start with "unknown command".
if ok && input.HookEventName == "SessionStart" {
emitPluginRenameNotice(cmd)
return nil
}
if !ok || !agentHookHasSnapshotKey(input) ||
!strings.Contains(strings.ToLower(agentHookCommand(input.ToolInput)), "commit") {
return nil
Expand Down
109 changes: 109 additions & 0 deletions internal/commands/agent_hook_rename.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package commands

import (
"encoding/json"
"os"
"path/filepath"

"github.com/spf13/cobra"

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

// pluginRenameNoticeMarker records that the rename notice was shown for one
// plugin install, so it appears once rather than every session.
const pluginRenameNoticeMarker = "plugin-rename-notice-shown"

// emitPluginRenameNotice tells someone still running this plugin under its
// pre-rename id that it is now basecamp-cli. During the deprecation window the
// marketplace keeps "basecamp" as an alias with the same source, so the plugin
// keeps working; this nudges toward `basecamp setup`, once.
//
// The alias and basecamp-cli install the same files, so the manifest can't
// tell them apart. The plugin root can: agents cache each install under
// <marketplace>/<plugin id>/<version>. Anything other than an install as
// basecamp@37signals stays silent, so the hook injects nothing into normal
// sessions.
func emitPluginRenameNotice(cmd *cobra.Command) {
root := agentPluginRoot()
if !isLegacyPluginRoot(root) {
return
}
dir := agentPluginDataDir()
if dir == "" {
dir = agentHookStateDir(cmd.Context())
}
if dir == "" || !claimOnce(filepath.Join(dir, pluginRenameNoticeMarker)) {
return
}
message := "The Basecamp plugin is now `" + harness.ClaudePluginName + "`. " +
"This copy still runs under its old name `" + harness.ClaudeLegacyPluginKey + "`, which keeps working for now. " +
"Run `" + legacyPluginSetupCommand(root) + "` to switch."
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{
"systemMessage": message,
"hookSpecificOutput": map[string]string{
"hookEventName": "SessionStart",
"additionalContext": "Mention this to the user once: " + message,
},
})
}

// agentPluginRoot is the plugin's install directory as the agent reports it:
// Claude Code sets CLAUDE_PLUGIN_ROOT, Codex sets PLUGIN_ROOT (and the
// Claude name too).
func agentPluginRoot() string {
if root := os.Getenv("CLAUDE_PLUGIN_ROOT"); root != "" {
return root
}
return os.Getenv("PLUGIN_ROOT")
}

func agentPluginDataDir() string {
if dir := os.Getenv("CLAUDE_PLUGIN_DATA"); dir != "" {
return dir
}
return os.Getenv("PLUGIN_DATA")
}

// isLegacyPluginRoot reports whether root is a cached install of
// basecamp@37signals: .../37signals/basecamp/<version>.
func isLegacyPluginRoot(root string) bool {
if root == "" {
return false
}
plugin := filepath.Dir(filepath.Clean(root))
return filepath.Base(plugin) == harness.LegacyPluginName && filepath.Base(filepath.Dir(plugin)) == harness.ClaudeMarketplaceName
}

// legacyPluginSetupCommand names the setup command for the agent that owns
// root, falling back to setting up every detected agent.
func legacyPluginSetupCommand(root string) string {
if home, err := os.UserHomeDir(); err == nil {
for agent, dir := range map[string]string{"claude": ".claude", "codex": ".codex"} {
if rel, err := filepath.Rel(filepath.Join(home, dir), root); err == nil && filepath.IsLocal(rel) {
return "basecamp setup " + agent
}
}
}
if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" {
if rel, err := filepath.Rel(codexHome, root); err == nil && filepath.IsLocal(rel) {
return "basecamp setup codex"
}
}
return "basecamp setup agents"
}

// claimOnce creates marker, reporting whether this call created it. Any
// failure counts as already shown: a notice that can't be recorded would
// otherwise repeat every session.
func claimOnce(marker string) bool {
if err := os.MkdirAll(filepath.Dir(marker), 0o700); err != nil {
return false
}
f, err := os.OpenFile(marker, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec // G304: marker under the agent's plugin data dir
if err != nil {
return false
}
_ = f.Close()
return true
}
81 changes: 81 additions & 0 deletions internal/commands/agent_hook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -558,3 +558,84 @@ func runGit(t *testing.T, dir string, args ...string) {
output, err := cmd.CombinedOutput()
require.NoError(t, err, string(output))
}

func TestAgentHookPluginNoticeOnceForLegacyClaudeInstall(t *testing.T) {
data := t.TempDir()
t.Setenv("PLUGIN_ROOT", "")
t.Setenv("PLUGIN_DATA", "")
t.Setenv("CLAUDE_PLUGIN_DATA", data)
home, _ := os.UserHomeDir()
t.Setenv("CLAUDE_PLUGIN_ROOT", filepath.Join(home, ".claude", "plugins", "cache", "37signals", "basecamp", "0.12.0"))

var payload struct {
SystemMessage string `json:"systemMessage"`
}
output := runAgentHookPreservingHome(t, sessionStartPayload)
require.NoError(t, json.Unmarshal([]byte(output), &payload))
assert.Contains(t, payload.SystemMessage, "now `basecamp-cli`")
assert.Contains(t, payload.SystemMessage, "basecamp setup claude")
event, context := hookSpecificOutput(t, output)
assert.Equal(t, "SessionStart", event)
assert.Contains(t, context, "basecamp setup claude")

assert.Empty(t, runAgentHookPreservingHome(t, sessionStartPayload), "the notice appears once")
}

func TestAgentHookPluginNoticeNamesCodexSetup(t *testing.T) {
t.Setenv("CLAUDE_PLUGIN_ROOT", "")
t.Setenv("CLAUDE_PLUGIN_DATA", "")
t.Setenv("PLUGIN_DATA", t.TempDir())
home, _ := os.UserHomeDir()
t.Setenv("PLUGIN_ROOT", filepath.Join(home, ".codex", "plugins", "cache", "37signals", "basecamp", "0.12.0"))

var payload struct {
SystemMessage string `json:"systemMessage"`
}
require.NoError(t, json.Unmarshal([]byte(runAgentHookPreservingHome(t, sessionStartPayload)), &payload))
assert.Contains(t, payload.SystemMessage, "basecamp setup codex")
}

func TestAgentHookPluginNoticeSilentForRenamedInstall(t *testing.T) {
t.Setenv("CLAUDE_PLUGIN_DATA", t.TempDir())
t.Setenv("PLUGIN_ROOT", "")
for _, root := range []string{
"/home/u/.claude/plugins/cache/37signals/basecamp-cli/0.12.0",
"/home/u/.claude/plugins/cache/other/basecamp/0.12.0",
"",
} {
t.Setenv("CLAUDE_PLUGIN_ROOT", root)
assert.Empty(t, runAgentHookPreservingHome(t, sessionStartPayload), root)
}
}

const sessionStartPayload = `{"hook_event_name":"SessionStart","session_id":"s","source":"startup"}`

// A SessionStart payload carries no tool call, so the snapshot path ignores
// it — which is what keeps older CLIs silent on the plugin's SessionStart hook.
func TestAgentHookSessionStartPayloadTakesNoSnapshot(t *testing.T) {
t.Setenv("CLAUDE_PLUGIN_ROOT", "/home/u/.claude/plugins/cache/37signals/basecamp-cli/0.12.0")
data := t.TempDir()
t.Setenv("CLAUDE_PLUGIN_DATA", data)

assert.Empty(t, runAgentHookPreservingHome(t, sessionStartPayload))
entries, err := os.ReadDir(data)
require.NoError(t, err)
assert.Empty(t, entries)
}

// runAgentHookPreservingHome runs pre-commit-snapshot with input under the
// test's current HOME, so plugin roots built from it stay under it.
func runAgentHookPreservingHome(t *testing.T, input string) string {
t.Helper()
app := appctx.NewApp(config.Default())
t.Cleanup(app.Close)
cmd := NewAgentHookCmd()
var stdout bytes.Buffer
cmd.SetIn(strings.NewReader(input))
cmd.SetOut(&stdout)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"pre-commit-snapshot"})
cmd.SetContext(appctx.WithApp(context.Background(), app))
require.NoError(t, cmd.Execute())
return strings.TrimSpace(stdout.String())
}
2 changes: 1 addition & 1 deletion internal/commands/setup_agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ func TestSetupAgentsCodexPreservesManualOrder(t *testing.T) {
assert.Equal(t, []string{
"codex plugin marketplace add basecamp/claude-plugins",
"codex plugin marketplace upgrade 37signals",
"codex plugin add basecamp@37signals",
"codex plugin add basecamp-cli@37signals",
}, env.Data.ManualCommands)
}

Expand Down
4 changes: 2 additions & 2 deletions internal/commands/wizard_agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,15 @@ func agentSetupHandlersFor(skillAgents []harness.SkillAgent) map[string]agentSet
"claude": {
Labels: []string{
"Add basecamp/claude-plugins marketplace to Claude Code",
"Install the basecamp plugin for Claude Code",
"Install the basecamp-cli plugin for Claude Code",
},
Run: runClaudeSetup,
RunNonInteractive: runClaudeSetupNonInteractive,
},
"codex": {
Labels: []string{
"Add the 37signals marketplace to Codex",
"Install the basecamp plugin for Codex",
"Install the basecamp-cli plugin for Codex",
},
Run: runCodexSetup,
RunNonInteractive: runCodexSetupNonInteractive,
Expand Down
23 changes: 22 additions & 1 deletion internal/commands/wizard_codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const (
codexMarketplaceTimeout = 20 * time.Second
codexInstallTimeout = 20 * time.Second
codexVerifyTimeout = 5 * time.Second
// codexRemoveTimeout bounds `plugin remove`, which only deletes local
// state — no clone — so it needs less than an install.
codexRemoveTimeout = 10 * time.Second
)

// runCodexSetupCommand runs a codex subcommand, capturing stdout for --json
Expand Down Expand Up @@ -100,13 +103,31 @@ func installCodexPlugin(parent context.Context, stderr io.Writer, progress func(
}
}

progress("Installing basecamp plugin…")
progress("Installing " + harness.CodexPluginName + " plugin…")
stdout, stderrOutput, err = runCodexStep(parent, stderr, codexInstallTimeout, codexPath,
"plugin", "add", harness.CodexExpectedPluginKey, "--json")
if err != nil && !codexPluginAlreadyInstalled(stdout, stderrOutput) {
return codexSetupError("plugin add failed: " + codexCommandFailure(stdout, stderrOutput, err))
}

// The plugin was renamed basecamp → basecamp-cli. Remove a pre-rename
// install only once its replacement is installed and enabled, so a failed
// or disabled install never leaves the user without a working plugin.
legacyCtx, cancelLegacy := context.WithTimeout(parent, codexVerifyTimeout)
legacy := harness.CodexLegacyReplaced(legacyCtx)
cancelLegacy()
if legacy {
progress("Removing the pre-rename " + harness.CodexLegacyPluginKey + " plugin…")
removeStdout, removeStderr, removeErr := runCodexStep(parent, stderr, codexRemoveTimeout, codexPath,
"plugin", "remove", harness.CodexLegacyPluginKey, "--json")
Comment thread
jeremy marked this conversation as resolved.
if removeErr != nil {
return &agentSetupError{
Summary: "removing the pre-rename plugin failed: " + codexCommandFailure(removeStdout, removeStderr, removeErr),
Manual: []string{"codex plugin remove " + harness.CodexLegacyPluginKey},
}
}
}

progress("Verifying installation…")
verifyCtx, cancel := context.WithTimeout(parent, codexVerifyTimeout)
defer cancel()
Expand Down
Loading
Loading