From 64ed16cd4a916f26cc01ac32ef4f57b778b5b06e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 22 Oct 2026 21:54:10 -0400 Subject: [PATCH 1/4] Tell a session once when the plugin and basecamp command are from different releases Neither Claude Code nor Codex updates a third-party plugin unless the user turns that on, and a plugin changes only when its version string does, so a plugin installed once can stay put while Homebrew moves the CLI on. Doctor reports the mismatch, but only to someone who runs it. The plugin's SessionStart hook already runs the installed binary at every session start. It now reads the version stamped in this install's manifest, compares it with the CLI's, and once per pairing says which side to update: `basecamp upgrade` when the CLI is behind, the agent's own plugin update when the plugin is. Dev, prerelease and go-install builds, versions that aren't semver, and pre-rename installs (which get the rename notice) stay silent. An old binary can't run this check, so the skill also says that an unknown command means the CLI is older than the skill: run `basecamp upgrade`. --- hooks/hooks.json | 2 +- internal/commands/agent_hook.go | 4 +- internal/commands/agent_hook_rename.go | 34 +++-- internal/commands/agent_hook_version.go | 99 +++++++++++++ internal/commands/agent_hook_version_test.go | 140 +++++++++++++++++++ internal/release/manifests_test.go | 15 +- skills/basecamp/SKILL.md | 3 + 7 files changed, 276 insertions(+), 21 deletions(-) create mode 100644 internal/commands/agent_hook_version.go create mode 100644 internal/commands/agent_hook_version_test.go diff --git a/hooks/hooks.json b/hooks/hooks.json index 25c88656c..8aeecfcd7 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Basecamp agent hooks: commit-reference nudges, a one-time notice for installs under the plugin's pre-rename id, and after a compaction a notice to reload the basecamp-connect skill in a session that ran it.", + "description": "Basecamp agent hooks: commit-reference nudges, a one-time notice for installs under the plugin's pre-rename id, a once-per-pairing notice when the plugin and the basecamp command come from different releases, and after a compaction a notice to reload the basecamp-connect skill in a session that ran it.", "hooks": { "SessionStart": [ { diff --git a/internal/commands/agent_hook.go b/internal/commands/agent_hook.go index 494ec1702..af7bb2466 100644 --- a/internal/commands/agent_hook.go +++ b/internal/commands/agent_hook.go @@ -170,8 +170,8 @@ func newAgentHookPreCommitSnapshotCmd() *cobra.Command { 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 and, after a compaction, the connector's reload - // notice: CLIs that predate them already have this subcommand and + // rename and version notices and, after a compaction, the + // connector's reload notice: CLIs that predate them 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". diff --git a/internal/commands/agent_hook_rename.go b/internal/commands/agent_hook_rename.go index b20bd7964..2a48db982 100644 --- a/internal/commands/agent_hook_rename.go +++ b/internal/commands/agent_hook_rename.go @@ -43,15 +43,18 @@ func pluginRenameNotice(cmd *cobra.Command) string { } // emitSessionStartNotices writes the SessionStart hook's one JSON reply: the -// rename notice, shown to the user and to the model, and after a compaction -// the connector's reload instruction, to the model only. Nothing to say -// writes nothing. +// rename or version notice, shown to the user and to the model, and after a +// compaction the connector's reload instruction, to the model only. Nothing +// to say writes nothing. func emitSessionStartNotices(cmd *cobra.Command, input agentHookInput) { - rename := pluginRenameNotice(cmd) + notice := pluginRenameNotice(cmd) + if notice == "" { + notice = pluginVersionNotice(cmd) + } reload := connectorReloadNotice(input) var contexts []string - if rename != "" { - contexts = append(contexts, "Mention this to the user once: "+rename) + if notice != "" { + contexts = append(contexts, "Mention this to the user once: "+notice) } if reload != "" { contexts = append(contexts, reload) @@ -65,8 +68,8 @@ func emitSessionStartNotices(cmd *cobra.Command, input agentHookInput) { "additionalContext": strings.Join(contexts, "\n\n"), }, } - if rename != "" { - reply["systemMessage"] = rename + if notice != "" { + reply["systemMessage"] = notice } _ = json.NewEncoder(cmd.OutOrStdout()).Encode(reply) } @@ -101,19 +104,28 @@ func isLegacyPluginRoot(root string) bool { // 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 agent := pluginRootAgent(root); agent != "" { + return "basecamp setup " + agent + } + return "basecamp setup agents" +} + +// pluginRootAgent names the agent whose home holds root, "claude" or +// "codex", or "" when neither does. +func pluginRootAgent(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 + return 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 "codex" } } - return "basecamp setup agents" + return "" } // claimOnce creates marker, reporting whether this call created it. Any diff --git a/internal/commands/agent_hook_version.go b/internal/commands/agent_hook_version.go new file mode 100644 index 000000000..e89a3d1da --- /dev/null +++ b/internal/commands/agent_hook_version.go @@ -0,0 +1,99 @@ +package commands + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "golang.org/x/mod/semver" + + "github.com/basecamp/basecamp-cli/internal/harness" + "github.com/basecamp/basecamp-cli/internal/version" +) + +// pluginVersionNoticeMarker prefixes the marker recording that the version +// notice was shown for one pairing of plugin and CLI versions. +const pluginVersionNoticeMarker = "plugin-version-notice-" + +// pluginVersionNotice tells someone whose plugin and basecamp command come +// from different releases which one to update. The skills name commands, so +// a plugin behind the CLI misses ones it has, and a CLI behind the plugin +// lacks ones the skills run. +// +// Neither agent updates a third-party plugin unless the user turns that on, +// and the plugin changes only when its version string does, so a plugin +// installed once can stay where it was indefinitely while the CLI moves on. +// This hook is the one place that reaches such a user without their asking: +// the agent runs it at every session start. It speaks once per pairing of +// versions, so updating either side, or both drifting again, says it anew. +// +// It reads the version from this install's own manifest rather than the +// agent's records, so it describes the copy this session loaded. A pre-rename +// install gets the rename notice instead, whose setup command also updates it, +// and versions that aren't semver stay silent: doctor still reports them. +// It returns the notice, or "" when there is none to show. +func pluginVersionNotice(cmd *cobra.Command) string { + if version.IsDev() { + return "" + } + root := agentPluginRoot() + if root == "" || isLegacyPluginRoot(root) { + return "" + } + pluginVersion := pluginManifestVersion(root) + plugin, cli := normalizeSemver(pluginVersion), normalizeSemver(version.Version) + if !semver.IsValid(plugin) || !semver.IsValid(cli) || semver.Compare(plugin, cli) == 0 { + return "" + } + // A prerelease or go-install build is ahead of every plugin the + // marketplace offers, so there is nothing to update to. + if semver.Prerelease(cli) != "" { + return "" + } + dir := agentPluginDataDir() + if dir == "" { + dir = agentHookStateDir(cmd.Context()) + } + // Valid semver holds only [0-9A-Za-z.+-], so the marker stays one name. + if dir == "" || !claimOnce(filepath.Join(dir, pluginVersionNoticeMarker+plugin+"-"+cli)) { + return "" + } + if semver.Compare(cli, plugin) < 0 { + return "The Basecamp plugin (" + pluginVersion + ") is newer than the `basecamp` command (" + version.Version + "), " + + "so its skills may run commands this CLI doesn't have. Run `basecamp upgrade`." + } + return "The Basecamp plugin (" + pluginVersion + ") is older than the `basecamp` command (" + version.Version + "), " + + "so its skills don't know this CLI's newer commands. " + pluginUpdateRemedy(root) +} + +// pluginUpdateRemedy says how to bring forward the plugin installed at root. +func pluginUpdateRemedy(root string) string { + switch pluginRootAgent(root) { + case "claude": + return "Run `claude plugin update " + harness.ClaudeExpectedPluginKey + "`, then restart Claude Code. " + + "To keep it current: " + harness.AutoUpdateHint + "." + case "codex": + return "Run `basecamp setup codex`, then restart Codex." + default: + return "Update the " + harness.ClaudePluginName + " plugin, then restart your agent." + } +} + +// pluginManifestVersion reads the version the install at root was stamped +// with. Both manifests carry the release's version; either will do. +func pluginManifestVersion(root string) string { + for _, manifest := range []string{".claude-plugin", ".codex-plugin"} { + data, err := os.ReadFile(filepath.Join(root, manifest, "plugin.json")) //nolint:gosec // G304: the agent's own plugin root + if err != nil { + continue + } + var parsed struct { + Version string `json:"version"` + } + if json.Unmarshal(data, &parsed) == nil && parsed.Version != "" { + return parsed.Version + } + } + return "" +} diff --git a/internal/commands/agent_hook_version_test.go b/internal/commands/agent_hook_version_test.go new file mode 100644 index 000000000..bcfcad27a --- /dev/null +++ b/internal/commands/agent_hook_version_test.go @@ -0,0 +1,140 @@ +package commands + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/version" +) + +// installPluginAt fakes an agent's cached install of the plugin at root, +// stamped with pluginVersion, and points the hook at it. +func installPluginAt(t *testing.T, envRoot, root, manifestDir, pluginVersion string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(root, manifestDir), 0o755)) + manifest := `{"name":"basecamp-cli","version":"` + pluginVersion + `"}` + require.NoError(t, os.WriteFile(filepath.Join(root, manifestDir, "plugin.json"), []byte(manifest), 0o644)) + for _, name := range []string{"CLAUDE_PLUGIN_ROOT", "PLUGIN_ROOT", "CLAUDE_PLUGIN_DATA", "PLUGIN_DATA"} { + t.Setenv(name, "") + } + t.Setenv(envRoot, root) + data := "CLAUDE_PLUGIN_DATA" + if envRoot == "PLUGIN_ROOT" { + data = "PLUGIN_DATA" + } + t.Setenv(data, t.TempDir()) +} + +func setCLIVersion(t *testing.T, v string) { + t.Helper() + original := version.Version + version.Version = v + t.Cleanup(func() { version.Version = original }) +} + +func claudePluginRoot(t *testing.T, pluginVersion string) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + return filepath.Join(home, ".claude", "plugins", "cache", "37signals", "basecamp-cli", pluginVersion) +} + +func sessionStartSystemMessage(t *testing.T, output string) string { + t.Helper() + var payload struct { + SystemMessage string `json:"systemMessage"` + } + require.NoError(t, json.Unmarshal([]byte(output), &payload)) + return payload.SystemMessage +} + +func TestAgentHookVersionNoticeOncePerPairWhenPluginIsBehind(t *testing.T) { + setCLIVersion(t, "0.17.0") + installPluginAt(t, "CLAUDE_PLUGIN_ROOT", claudePluginRoot(t, "0.14.0"), ".claude-plugin", "0.14.0") + + output := runAgentHookPreservingHome(t, sessionStartPayload) + message := sessionStartSystemMessage(t, output) + assert.Contains(t, message, "plugin (0.14.0) is older than the `basecamp` command (0.17.0)") + assert.Contains(t, message, "claude plugin update basecamp-cli@37signals") + assert.Contains(t, message, "Enable auto-update") + _, context := hookSpecificOutput(t, output) + assert.Contains(t, context, "Mention this to the user once: "+message) + + assert.Empty(t, runAgentHookPreservingHome(t, sessionStartPayload), "the notice appears once per pairing") + + setCLIVersion(t, "0.18.0") + assert.Contains(t, sessionStartSystemMessage(t, runAgentHookPreservingHome(t, sessionStartPayload)), + "(0.18.0)", "a new pairing speaks again") +} + +func TestAgentHookVersionNoticeSaysUpgradeWhenCLIIsBehind(t *testing.T) { + setCLIVersion(t, "0.16.0") + installPluginAt(t, "CLAUDE_PLUGIN_ROOT", claudePluginRoot(t, "0.17.0"), ".claude-plugin", "0.17.0") + + message := sessionStartSystemMessage(t, runAgentHookPreservingHome(t, sessionStartPayload)) + assert.Contains(t, message, "plugin (0.17.0) is newer than the `basecamp` command (0.16.0)") + assert.Contains(t, message, "Run `basecamp upgrade`.") + assert.NotContains(t, message, "claude plugin update") +} + +func TestAgentHookVersionNoticeNamesCodexSetup(t *testing.T) { + setCLIVersion(t, "0.17.0") + home := t.TempDir() + t.Setenv("HOME", home) + root := filepath.Join(home, ".codex", "plugins", "cache", "37signals", "basecamp-cli", "0.15.0") + installPluginAt(t, "PLUGIN_ROOT", root, ".codex-plugin", "0.15.0") + + message := sessionStartSystemMessage(t, runAgentHookPreservingHome(t, sessionStartPayload)) + assert.Contains(t, message, "plugin (0.15.0) is older") + assert.Contains(t, message, "basecamp setup codex") + assert.NotContains(t, message, "claude plugin update") +} + +func TestAgentHookVersionNoticeSilentWhenNothingToSay(t *testing.T) { + cases := []struct { + name, cli, plugin string + }{ + {"same release", "0.17.0", "0.17.0"}, + {"same release spelled with a v", "v0.17.0", "0.17.0"}, + {"dev build", "dev", "0.17.0"}, + {"prerelease CLI", "0.18.0-rc.1", "0.17.0"}, + {"go install pseudo-version", "0.17.1-0.20261022120000-abcdef123456", "0.17.0"}, + {"plugin version not semver", "0.17.0", "main"}, + {"plugin without a version", "0.17.0", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + setCLIVersion(t, tc.cli) + installPluginAt(t, "CLAUDE_PLUGIN_ROOT", claudePluginRoot(t, "x"), ".claude-plugin", tc.plugin) + assert.Empty(t, runAgentHookPreservingHome(t, sessionStartPayload)) + }) + } +} + +// A pre-rename install gets the rename notice, whose setup command also +// brings it to the newest version, and never both. +func TestAgentHookVersionNoticeDefersToRenameNotice(t *testing.T) { + setCLIVersion(t, "0.17.0") + home := t.TempDir() + t.Setenv("HOME", home) + root := filepath.Join(home, ".claude", "plugins", "cache", "37signals", "basecamp", "0.12.0") + installPluginAt(t, "CLAUDE_PLUGIN_ROOT", root, ".claude-plugin", "0.12.0") + + message := sessionStartSystemMessage(t, runAgentHookPreservingHome(t, sessionStartPayload)) + assert.Contains(t, message, "now `basecamp-cli`") + assert.NotContains(t, message, "older than") + assert.Empty(t, runAgentHookPreservingHome(t, sessionStartPayload), "the rename notice is the only one") +} + +// The notice rides only on SessionStart: a tool call's hook stays silent. +func TestAgentHookVersionNoticeOnlyAtSessionStart(t *testing.T) { + setCLIVersion(t, "0.17.0") + installPluginAt(t, "CLAUDE_PLUGIN_ROOT", claudePluginRoot(t, "0.14.0"), ".claude-plugin", "0.14.0") + + assert.Empty(t, runAgentHookPreservingHome(t, `{"hook_event_name":"PreToolUse","session_id":"s","tool_use_id":"t","tool_input":{"command":"ls"}}`)) +} diff --git a/internal/release/manifests_test.go b/internal/release/manifests_test.go index 84f100530..0b334e991 100644 --- a/internal/release/manifests_test.go +++ b/internal/release/manifests_test.go @@ -147,17 +147,18 @@ func TestHooksFileCommandsInvokeBasecamp(t *testing.T) { require.NoError(t, json.Unmarshal(data, &config)) require.NotEmpty(t, config.Hooks) // Plugins must not inject Basecamp context into every agent session. The - // one SessionStart hook allowed speaks only in two cases: the rename - // notice, once, when the plugin runs under its pre-rename id, and the - // connector's reload notice, after a compaction of a session that ran - // basecamp-connect. It goes through pre-commit-snapshot, which every + // one SessionStart hook allowed speaks only in three cases: the rename + // notice, once, when the plugin runs under its pre-rename id; the version + // notice, once per pairing, when the plugin and the CLI come from + // different releases; and the connector's reload notice, after a + // compaction of a session that ran basecamp-connect. It goes through pre-commit-snapshot, which every // hook-capable CLI has and which older ones answer silently: a new // subcommand would fail every session start for anyone whose CLI is // older than the plugin. sessionStart := config.Hooks["SessionStart"] - require.Len(t, sessionStart, 1, "exactly one SessionStart matcher: the rename and connector reload notices") - require.Len(t, sessionStart[0].Hooks, 1, "exactly one SessionStart hook: the rename and connector reload notices") - assert.Equal(t, "basecamp agent-hook pre-commit-snapshot", sessionStart[0].Hooks[0].Command, "only the rename and connector reload notices may run at session start") + require.Len(t, sessionStart, 1, "exactly one SessionStart matcher: the rename, version and connector reload notices") + require.Len(t, sessionStart[0].Hooks, 1, "exactly one SessionStart hook: the rename, version and connector reload notices") + assert.Equal(t, "basecamp agent-hook pre-commit-snapshot", sessionStart[0].Hooks[0].Command, "only the rename, version and connector reload notices may run at session start") for event, matchers := range config.Hooks { require.NotEmpty(t, matchers, event) diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index ffd4197ba..af01623d5 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -298,6 +298,9 @@ Structured failures include `error`, `code`, `retryable`, and often `hint`. known verdicts and unclassified failures. Inspect `code`, `error`, and `hint` before choosing recovery, and do not blindly repeat the same call. - For usage errors, inspect leaf help and supply the named positional argument. +- If the CLI rejects a command or flag this skill names as unknown, the installed + `basecamp` is older than the skill. Do not work around it: compare + `basecamp --version` and tell the user to run `basecamp upgrade`. - For ambiguous names, use an ID or add the missing project/container scope. - For auth or connectivity diagnosis, use `basecamp doctor --json` and `basecamp auth status --json` before changing credentials. From b7960d8793df8c733ecfe82ae64fe7fc3cc7ca40 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 02:25:18 -0400 Subject: [PATCH 2/4] Keep notice markers out of snapshot cleanup; upgrade an rc behind its release; soften the skill line Review findings: - Without a plugin data directory the once-markers fell back to the commit-snapshot directory, whose cleanup removes every file older than an hour, so the version notice (and the rename notice, which shared the fallback) came back. Both now fall back to agent-hook-notices in the CLI's cache. - A prerelease CLI behind the plugin (an rc left installed past its release) was silent; it now gets `basecamp upgrade`. Only a prerelease ahead of the plugin has nothing to update to. - An unknown command doesn't show which side is older: the skill names a removed command in its own triggers. The skill now says to check help first, and to let `basecamp doctor --json`, which compares the versions, name the side to update. --- internal/commands/agent_hook_rename.go | 22 +++++++++-- internal/commands/agent_hook_version.go | 15 ++++---- internal/commands/agent_hook_version_test.go | 40 +++++++++++++++++++- skills/basecamp/SKILL.md | 7 ++-- 4 files changed, 68 insertions(+), 16 deletions(-) diff --git a/internal/commands/agent_hook_rename.go b/internal/commands/agent_hook_rename.go index 2a48db982..7419cb8fd 100644 --- a/internal/commands/agent_hook_rename.go +++ b/internal/commands/agent_hook_rename.go @@ -1,6 +1,7 @@ package commands import ( + "context" "encoding/json" "os" "path/filepath" @@ -8,6 +9,7 @@ import ( "github.com/spf13/cobra" + "github.com/basecamp/basecamp-cli/internal/appctx" "github.com/basecamp/basecamp-cli/internal/harness" ) @@ -30,10 +32,7 @@ func pluginRenameNotice(cmd *cobra.Command) string { if !isLegacyPluginRoot(root) { return "" } - dir := agentPluginDataDir() - if dir == "" { - dir = agentHookStateDir(cmd.Context()) - } + dir := agentHookNoticeDir(cmd.Context()) if dir == "" || !claimOnce(filepath.Join(dir, pluginRenameNoticeMarker)) { return "" } @@ -84,6 +83,21 @@ func agentPluginRoot() string { return os.Getenv("PLUGIN_ROOT") } +// agentHookNoticeDir holds the markers that keep a notice to once: the +// plugin's data directory, else a directory of the CLI's cache of its own. +// Not the commit-snapshot directory, whose cleanup removes every file older +// than an hour and would bring a notice back. +func agentHookNoticeDir(ctx context.Context) string { + if dir := agentPluginDataDir(); dir != "" { + return dir + } + app := appctx.FromContext(ctx) + if app == nil || app.Config == nil || app.Config.CacheDir == "" { + return "" + } + return filepath.Join(app.Config.CacheDir, "agent-hook-notices") +} + func agentPluginDataDir() string { if dir := os.Getenv("CLAUDE_PLUGIN_DATA"); dir != "" { return dir diff --git a/internal/commands/agent_hook_version.go b/internal/commands/agent_hook_version.go index e89a3d1da..0577e2183 100644 --- a/internal/commands/agent_hook_version.go +++ b/internal/commands/agent_hook_version.go @@ -46,20 +46,19 @@ func pluginVersionNotice(cmd *cobra.Command) string { if !semver.IsValid(plugin) || !semver.IsValid(cli) || semver.Compare(plugin, cli) == 0 { return "" } - // A prerelease or go-install build is ahead of every plugin the - // marketplace offers, so there is nothing to update to. - if semver.Prerelease(cli) != "" { + cliBehind := semver.Compare(cli, plugin) < 0 + // A prerelease or go-install build ahead of the plugin is ahead of every + // plugin the marketplace offers, so there is no plugin to update to. One + // behind it, an rc left installed past its release, still needs upgrading. + if semver.Prerelease(cli) != "" && !cliBehind { return "" } - dir := agentPluginDataDir() - if dir == "" { - dir = agentHookStateDir(cmd.Context()) - } + dir := agentHookNoticeDir(cmd.Context()) // Valid semver holds only [0-9A-Za-z.+-], so the marker stays one name. if dir == "" || !claimOnce(filepath.Join(dir, pluginVersionNoticeMarker+plugin+"-"+cli)) { return "" } - if semver.Compare(cli, plugin) < 0 { + if cliBehind { return "The Basecamp plugin (" + pluginVersion + ") is newer than the `basecamp` command (" + version.Version + "), " + "so its skills may run commands this CLI doesn't have. Run `basecamp upgrade`." } diff --git a/internal/commands/agent_hook_version_test.go b/internal/commands/agent_hook_version_test.go index bcfcad27a..5f1832a3c 100644 --- a/internal/commands/agent_hook_version_test.go +++ b/internal/commands/agent_hook_version_test.go @@ -5,10 +5,12 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/basecamp/basecamp-cli/internal/config" "github.com/basecamp/basecamp-cli/internal/version" ) @@ -102,7 +104,7 @@ func TestAgentHookVersionNoticeSilentWhenNothingToSay(t *testing.T) { {"same release", "0.17.0", "0.17.0"}, {"same release spelled with a v", "v0.17.0", "0.17.0"}, {"dev build", "dev", "0.17.0"}, - {"prerelease CLI", "0.18.0-rc.1", "0.17.0"}, + {"prerelease CLI ahead of the plugin", "0.18.0-rc.1", "0.17.0"}, {"go install pseudo-version", "0.17.1-0.20261022120000-abcdef123456", "0.17.0"}, {"plugin version not semver", "0.17.0", "main"}, {"plugin without a version", "0.17.0", ""}, @@ -138,3 +140,39 @@ func TestAgentHookVersionNoticeOnlyAtSessionStart(t *testing.T) { assert.Empty(t, runAgentHookPreservingHome(t, `{"hook_event_name":"PreToolUse","session_id":"s","tool_use_id":"t","tool_input":{"command":"ls"}}`)) } + +// An rc left installed past its release is behind the plugin, and upgrading +// it is the remedy, prerelease or not. +func TestAgentHookVersionNoticeSaysUpgradeForPrereleaseBehindPlugin(t *testing.T) { + setCLIVersion(t, "0.17.0-rc.1") + installPluginAt(t, "CLAUDE_PLUGIN_ROOT", claudePluginRoot(t, "0.17.0"), ".claude-plugin", "0.17.0") + + message := sessionStartSystemMessage(t, runAgentHookPreservingHome(t, sessionStartPayload)) + assert.Contains(t, message, "plugin (0.17.0) is newer than the `basecamp` command (0.17.0-rc.1)") + assert.Contains(t, message, "Run `basecamp upgrade`.") +} + +// Without a plugin data directory the marker lives in the CLI's cache, and +// must survive the commit-snapshot cleanup, which removes every file in its +// own directory older than an hour. +func TestAgentHookVersionNoticeMarkerSurvivesSnapshotCleanup(t *testing.T) { + setCLIVersion(t, "0.17.0") + installPluginAt(t, "CLAUDE_PLUGIN_ROOT", claudePluginRoot(t, "0.14.0"), ".claude-plugin", "0.14.0") + t.Setenv("CLAUDE_PLUGIN_DATA", "") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("BASECAMP_CACHE_DIR", "") + + require.NotEmpty(t, runAgentHookPreservingHome(t, sessionStartPayload)) + + cache := config.Default().CacheDir + aged := time.Now().Add(-2 * agentHookSnapshotTTL) + require.NoError(t, filepath.Walk(cache, func(path string, _ os.FileInfo, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, aged, aged) + })) + removeExpiredAgentHookSnapshots(filepath.Join(cache, "agent-hook")) + + assert.Empty(t, runAgentHookPreservingHome(t, sessionStartPayload), "the notice stays shown once") +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index af01623d5..9245a1768 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -298,9 +298,10 @@ Structured failures include `error`, `code`, `retryable`, and often `hint`. known verdicts and unclassified failures. Inspect `code`, `error`, and `hint` before choosing recovery, and do not blindly repeat the same call. - For usage errors, inspect leaf help and supply the named positional argument. -- If the CLI rejects a command or flag this skill names as unknown, the installed - `basecamp` is older than the skill. Do not work around it: compare - `basecamp --version` and tell the user to run `basecamp upgrade`. +- If the CLI rejects a command or flag as unknown, check the group's help for + its current form first. If this skill names it and the help lacks it, the CLI + and this skill may come from different releases: `basecamp doctor --json` + compares their versions and names the side to update. - For ambiguous names, use an ID or add the missing project/container scope. - For auth or connectivity diagnosis, use `basecamp doctor --json` and `basecamp auth status --json` before changing credentials. From 149934e41f1057f095c0521f7ef892b372dfe888 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 02:29:32 -0400 Subject: [PATCH 3/4] Name the CLI as the side to update when it is behind the Codex plugin Review finding: doctor's Codex version check said `basecamp setup codex` for every mismatch, but setup can't bring forward a CLI that is behind the plugin; only an upgrade can. It now says `basecamp upgrade` then, as the Claude Code check already does. The skill line no longer leans on doctor's remedy, which an older CLI still gets wrong: it has the agent read both versions from doctor and name the older side itself. --- internal/harness/codex.go | 15 ++++++++++++++- internal/harness/codex_test.go | 13 +++++++++++++ skills/basecamp/SKILL.md | 3 ++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/harness/codex.go b/internal/harness/codex.go index 42781f64f..9c7776ef2 100644 --- a/internal/harness/codex.go +++ b/internal/harness/codex.go @@ -11,6 +11,8 @@ import ( "path/filepath" "time" + "golang.org/x/mod/semver" + "github.com/basecamp/basecamp-cli/internal/version" ) @@ -275,8 +277,19 @@ func codexPluginVersionCheck(state codexPluginState, found bool, err error) *Sta Name: "Codex Plugin Version", Status: "warn", Message: fmt.Sprintf("Mismatched (plugin %s, CLI %s)", state.Version, version.Version), - Hint: "Run: basecamp setup codex", + Hint: codexPluginVersionMismatchHint(state.Version, version.Version), + } +} + +// codexPluginVersionMismatchHint says which side of a mismatch to bring +// forward: setup upgrades a plugin behind the CLI, and only an upgrade +// brings a CLI behind the plugin the commands its skills run. +func codexPluginVersionMismatchHint(installed, cli string) string { + plugin, current := normalizeSemver(installed), normalizeSemver(cli) + if semver.IsValid(plugin) && semver.IsValid(current) && semver.Compare(current, plugin) < 0 { + return "Run: basecamp upgrade" } + return "Run: basecamp setup codex" } func queryCodexPlugin(parent context.Context) (codexPluginState, bool, error) { diff --git a/internal/harness/codex_test.go b/internal/harness/codex_test.go index 38ba92cd9..a37255be5 100644 --- a/internal/harness/codex_test.go +++ b/internal/harness/codex_test.go @@ -118,6 +118,19 @@ func TestCheckCodexPluginVersionMismatch(t *testing.T) { assert.Equal(t, "Run: basecamp setup codex", check.Hint) } +func TestCheckCodexPluginVersionCLIBehindPlugin(t *testing.T) { + original := version.Version + version.Version = "0.7.2" + t.Cleanup(func() { version.Version = original }) + stubCodexList(t, codexListFixture("0.8.0", true, true), nil) + + check := CheckCodexPluginVersion() + + assert.Equal(t, "warn", check.Status) + assert.Contains(t, check.Message, "plugin 0.8.0, CLI 0.7.2") + assert.Equal(t, "Run: basecamp upgrade", check.Hint) +} + func TestCheckCodexPluginVersionSkipsMissingPlugin(t *testing.T) { stubCodexList(t, `{"installed":[],"available":[]}`, nil) diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 9245a1768..479bce387 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -301,7 +301,8 @@ Structured failures include `error`, `code`, `retryable`, and often `hint`. - If the CLI rejects a command or flag as unknown, check the group's help for its current form first. If this skill names it and the help lacks it, the CLI and this skill may come from different releases: `basecamp doctor --json` - compares their versions and names the side to update. + reports both versions. Name the older side to the user: `basecamp upgrade` + for the CLI, or updating the plugin. - For ambiguous names, use an ID or add the missing project/container scope. - For auth or connectivity diagnosis, use `basecamp doctor --json` and `basecamp auth status --json` before changing credentials. From 1e8b29f7174a4b9e22f3545e6aec1f558f79861e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 02:35:39 -0400 Subject: [PATCH 4/4] Key the version notice's marker by agent Review finding: without plugin data directories, Claude Code and Codex share the fallback notice directory, so the first agent's notice silenced the other's, whose remedy differs. The marker now names the agent. --- internal/commands/agent_hook_version.go | 5 ++++- internal/commands/agent_hook_version_test.go | 21 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/commands/agent_hook_version.go b/internal/commands/agent_hook_version.go index 0577e2183..509942d6a 100644 --- a/internal/commands/agent_hook_version.go +++ b/internal/commands/agent_hook_version.go @@ -54,8 +54,11 @@ func pluginVersionNotice(cmd *cobra.Command) string { return "" } dir := agentHookNoticeDir(cmd.Context()) + // The marker names the agent too: without a plugin data directory both + // agents share the fallback, and one's notice mustn't silence the other's. // Valid semver holds only [0-9A-Za-z.+-], so the marker stays one name. - if dir == "" || !claimOnce(filepath.Join(dir, pluginVersionNoticeMarker+plugin+"-"+cli)) { + marker := pluginVersionNoticeMarker + pluginRootAgent(root) + "-" + plugin + "-" + cli + if dir == "" || !claimOnce(filepath.Join(dir, marker)) { return "" } if cliBehind { diff --git a/internal/commands/agent_hook_version_test.go b/internal/commands/agent_hook_version_test.go index 5f1832a3c..5f01b18eb 100644 --- a/internal/commands/agent_hook_version_test.go +++ b/internal/commands/agent_hook_version_test.go @@ -176,3 +176,24 @@ func TestAgentHookVersionNoticeMarkerSurvivesSnapshotCleanup(t *testing.T) { assert.Empty(t, runAgentHookPreservingHome(t, sessionStartPayload), "the notice stays shown once") } + +// Without plugin data directories Claude Code and Codex share the fallback, +// and each still gets its own notice once. +func TestAgentHookVersionNoticeOncePerAgentInSharedFallback(t *testing.T) { + setCLIVersion(t, "0.17.0") + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("BASECAMP_CACHE_DIR", "") + claude := filepath.Join(home, ".claude", "plugins", "cache", "37signals", "basecamp-cli", "0.14.0") + codex := filepath.Join(home, ".codex", "plugins", "cache", "37signals", "basecamp-cli", "0.14.0") + + installPluginAt(t, "CLAUDE_PLUGIN_ROOT", claude, ".claude-plugin", "0.14.0") + t.Setenv("CLAUDE_PLUGIN_DATA", "") + assert.Contains(t, sessionStartSystemMessage(t, runAgentHookPreservingHome(t, sessionStartPayload)), "claude plugin update") + + installPluginAt(t, "PLUGIN_ROOT", codex, ".codex-plugin", "0.14.0") + t.Setenv("PLUGIN_DATA", "") + assert.Contains(t, sessionStartSystemMessage(t, runAgentHookPreservingHome(t, sessionStartPayload)), "basecamp setup codex") + assert.Empty(t, runAgentHookPreservingHome(t, sessionStartPayload), "and each only once") +}