Skip to content

Commit d0c313d

Browse files
authored
Tell a session once when the plugin and basecamp command are from different releases (#949)
* 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`. * 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. * 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. * 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.
1 parent 74d6506 commit d0c313d

9 files changed

Lines changed: 384 additions & 26 deletions

File tree

‎hooks/hooks.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"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.",
2+
"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.",
33
"hooks": {
44
"SessionStart": [
55
{

‎internal/commands/agent_hook.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,8 @@ func newAgentHookPreCommitSnapshotCmd() *cobra.Command {
170170
RunE: func(cmd *cobra.Command, args []string) error {
171171
input, ok := readAgentHookInput(cmd.InOrStdin())
172172
// The plugin's SessionStart hook runs this subcommand too, for the
173-
// rename notice and, after a compaction, the connector's reload
174-
// notice: CLIs that predate them already have this subcommand and
173+
// rename and version notices and, after a compaction, the
174+
// connector's reload notice: CLIs that predate them already have this subcommand and
175175
// ignore a payload with no tool call, so a plugin newer than the
176176
// CLI stays silent instead of failing every session start with
177177
// "unknown command".

‎internal/commands/agent_hook_rename.go‎

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package commands
22

33
import (
4+
"context"
45
"encoding/json"
56
"os"
67
"path/filepath"
78
"strings"
89

910
"github.com/spf13/cobra"
1011

12+
"github.com/basecamp/basecamp-cli/internal/appctx"
1113
"github.com/basecamp/basecamp-cli/internal/harness"
1214
)
1315

@@ -30,10 +32,7 @@ func pluginRenameNotice(cmd *cobra.Command) string {
3032
if !isLegacyPluginRoot(root) {
3133
return ""
3234
}
33-
dir := agentPluginDataDir()
34-
if dir == "" {
35-
dir = agentHookStateDir(cmd.Context())
36-
}
35+
dir := agentHookNoticeDir(cmd.Context())
3736
if dir == "" || !claimOnce(filepath.Join(dir, pluginRenameNoticeMarker)) {
3837
return ""
3938
}
@@ -43,15 +42,18 @@ func pluginRenameNotice(cmd *cobra.Command) string {
4342
}
4443

4544
// emitSessionStartNotices writes the SessionStart hook's one JSON reply: the
46-
// rename notice, shown to the user and to the model, and after a compaction
47-
// the connector's reload instruction, to the model only. Nothing to say
48-
// writes nothing.
45+
// rename or version notice, shown to the user and to the model, and after a
46+
// compaction the connector's reload instruction, to the model only. Nothing
47+
// to say writes nothing.
4948
func emitSessionStartNotices(cmd *cobra.Command, input agentHookInput) {
50-
rename := pluginRenameNotice(cmd)
49+
notice := pluginRenameNotice(cmd)
50+
if notice == "" {
51+
notice = pluginVersionNotice(cmd)
52+
}
5153
reload := connectorReloadNotice(input)
5254
var contexts []string
53-
if rename != "" {
54-
contexts = append(contexts, "Mention this to the user once: "+rename)
55+
if notice != "" {
56+
contexts = append(contexts, "Mention this to the user once: "+notice)
5557
}
5658
if reload != "" {
5759
contexts = append(contexts, reload)
@@ -65,8 +67,8 @@ func emitSessionStartNotices(cmd *cobra.Command, input agentHookInput) {
6567
"additionalContext": strings.Join(contexts, "\n\n"),
6668
},
6769
}
68-
if rename != "" {
69-
reply["systemMessage"] = rename
70+
if notice != "" {
71+
reply["systemMessage"] = notice
7072
}
7173
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(reply)
7274
}
@@ -81,6 +83,21 @@ func agentPluginRoot() string {
8183
return os.Getenv("PLUGIN_ROOT")
8284
}
8385

86+
// agentHookNoticeDir holds the markers that keep a notice to once: the
87+
// plugin's data directory, else a directory of the CLI's cache of its own.
88+
// Not the commit-snapshot directory, whose cleanup removes every file older
89+
// than an hour and would bring a notice back.
90+
func agentHookNoticeDir(ctx context.Context) string {
91+
if dir := agentPluginDataDir(); dir != "" {
92+
return dir
93+
}
94+
app := appctx.FromContext(ctx)
95+
if app == nil || app.Config == nil || app.Config.CacheDir == "" {
96+
return ""
97+
}
98+
return filepath.Join(app.Config.CacheDir, "agent-hook-notices")
99+
}
100+
84101
func agentPluginDataDir() string {
85102
if dir := os.Getenv("CLAUDE_PLUGIN_DATA"); dir != "" {
86103
return dir
@@ -101,19 +118,28 @@ func isLegacyPluginRoot(root string) bool {
101118
// legacyPluginSetupCommand names the setup command for the agent that owns
102119
// root, falling back to setting up every detected agent.
103120
func legacyPluginSetupCommand(root string) string {
121+
if agent := pluginRootAgent(root); agent != "" {
122+
return "basecamp setup " + agent
123+
}
124+
return "basecamp setup agents"
125+
}
126+
127+
// pluginRootAgent names the agent whose home holds root, "claude" or
128+
// "codex", or "" when neither does.
129+
func pluginRootAgent(root string) string {
104130
if home, err := os.UserHomeDir(); err == nil {
105131
for agent, dir := range map[string]string{"claude": ".claude", "codex": ".codex"} {
106132
if rel, err := filepath.Rel(filepath.Join(home, dir), root); err == nil && filepath.IsLocal(rel) {
107-
return "basecamp setup " + agent
133+
return agent
108134
}
109135
}
110136
}
111137
if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" {
112138
if rel, err := filepath.Rel(codexHome, root); err == nil && filepath.IsLocal(rel) {
113-
return "basecamp setup codex"
139+
return "codex"
114140
}
115141
}
116-
return "basecamp setup agents"
142+
return ""
117143
}
118144

119145
// claimOnce creates marker, reporting whether this call created it. Any
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package commands
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/spf13/cobra"
9+
"golang.org/x/mod/semver"
10+
11+
"github.com/basecamp/basecamp-cli/internal/harness"
12+
"github.com/basecamp/basecamp-cli/internal/version"
13+
)
14+
15+
// pluginVersionNoticeMarker prefixes the marker recording that the version
16+
// notice was shown for one pairing of plugin and CLI versions.
17+
const pluginVersionNoticeMarker = "plugin-version-notice-"
18+
19+
// pluginVersionNotice tells someone whose plugin and basecamp command come
20+
// from different releases which one to update. The skills name commands, so
21+
// a plugin behind the CLI misses ones it has, and a CLI behind the plugin
22+
// lacks ones the skills run.
23+
//
24+
// Neither agent updates a third-party plugin unless the user turns that on,
25+
// and the plugin changes only when its version string does, so a plugin
26+
// installed once can stay where it was indefinitely while the CLI moves on.
27+
// This hook is the one place that reaches such a user without their asking:
28+
// the agent runs it at every session start. It speaks once per pairing of
29+
// versions, so updating either side, or both drifting again, says it anew.
30+
//
31+
// It reads the version from this install's own manifest rather than the
32+
// agent's records, so it describes the copy this session loaded. A pre-rename
33+
// install gets the rename notice instead, whose setup command also updates it,
34+
// and versions that aren't semver stay silent: doctor still reports them.
35+
// It returns the notice, or "" when there is none to show.
36+
func pluginVersionNotice(cmd *cobra.Command) string {
37+
if version.IsDev() {
38+
return ""
39+
}
40+
root := agentPluginRoot()
41+
if root == "" || isLegacyPluginRoot(root) {
42+
return ""
43+
}
44+
pluginVersion := pluginManifestVersion(root)
45+
plugin, cli := normalizeSemver(pluginVersion), normalizeSemver(version.Version)
46+
if !semver.IsValid(plugin) || !semver.IsValid(cli) || semver.Compare(plugin, cli) == 0 {
47+
return ""
48+
}
49+
cliBehind := semver.Compare(cli, plugin) < 0
50+
// A prerelease or go-install build ahead of the plugin is ahead of every
51+
// plugin the marketplace offers, so there is no plugin to update to. One
52+
// behind it, an rc left installed past its release, still needs upgrading.
53+
if semver.Prerelease(cli) != "" && !cliBehind {
54+
return ""
55+
}
56+
dir := agentHookNoticeDir(cmd.Context())
57+
// The marker names the agent too: without a plugin data directory both
58+
// agents share the fallback, and one's notice mustn't silence the other's.
59+
// Valid semver holds only [0-9A-Za-z.+-], so the marker stays one name.
60+
marker := pluginVersionNoticeMarker + pluginRootAgent(root) + "-" + plugin + "-" + cli
61+
if dir == "" || !claimOnce(filepath.Join(dir, marker)) {
62+
return ""
63+
}
64+
if cliBehind {
65+
return "The Basecamp plugin (" + pluginVersion + ") is newer than the `basecamp` command (" + version.Version + "), " +
66+
"so its skills may run commands this CLI doesn't have. Run `basecamp upgrade`."
67+
}
68+
return "The Basecamp plugin (" + pluginVersion + ") is older than the `basecamp` command (" + version.Version + "), " +
69+
"so its skills don't know this CLI's newer commands. " + pluginUpdateRemedy(root)
70+
}
71+
72+
// pluginUpdateRemedy says how to bring forward the plugin installed at root.
73+
func pluginUpdateRemedy(root string) string {
74+
switch pluginRootAgent(root) {
75+
case "claude":
76+
return "Run `claude plugin update " + harness.ClaudeExpectedPluginKey + "`, then restart Claude Code. " +
77+
"To keep it current: " + harness.AutoUpdateHint + "."
78+
case "codex":
79+
return "Run `basecamp setup codex`, then restart Codex."
80+
default:
81+
return "Update the " + harness.ClaudePluginName + " plugin, then restart your agent."
82+
}
83+
}
84+
85+
// pluginManifestVersion reads the version the install at root was stamped
86+
// with. Both manifests carry the release's version; either will do.
87+
func pluginManifestVersion(root string) string {
88+
for _, manifest := range []string{".claude-plugin", ".codex-plugin"} {
89+
data, err := os.ReadFile(filepath.Join(root, manifest, "plugin.json")) //nolint:gosec // G304: the agent's own plugin root
90+
if err != nil {
91+
continue
92+
}
93+
var parsed struct {
94+
Version string `json:"version"`
95+
}
96+
if json.Unmarshal(data, &parsed) == nil && parsed.Version != "" {
97+
return parsed.Version
98+
}
99+
}
100+
return ""
101+
}

0 commit comments

Comments
 (0)