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 hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand Down
4 changes: 2 additions & 2 deletions internal/commands/agent_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
56 changes: 41 additions & 15 deletions internal/commands/agent_hook_rename.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package commands

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

"github.com/spf13/cobra"

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

Expand All @@ -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 ""
}
Expand All @@ -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)
Expand All @@ -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)
}
Expand All @@ -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
Expand All @@ -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
Expand Down
101 changes: 101 additions & 0 deletions internal/commands/agent_hook_version.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
Loading
Loading