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..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 "" } @@ -43,15 +42,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 +67,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) } @@ -81,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 @@ -101,19 +118,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..509942d6a --- /dev/null +++ b/internal/commands/agent_hook_version.go @@ -0,0 +1,101 @@ +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 "" + } + 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 := 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. + marker := pluginVersionNoticeMarker + pluginRootAgent(root) + "-" + plugin + "-" + cli + if dir == "" || !claimOnce(filepath.Join(dir, marker)) { + return "" + } + 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`." + } + 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..5f01b18eb --- /dev/null +++ b/internal/commands/agent_hook_version_test.go @@ -0,0 +1,199 @@ +package commands + +import ( + "encoding/json" + "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" +) + +// 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 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", ""}, + } + 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"}}`)) +} + +// 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") +} + +// 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") +} 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/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..479bce387 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -298,6 +298,11 @@ 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 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` + 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.