From 54c3a32b9041f341f6acd5e16bb4e34ae645a4de Mon Sep 17 00:00:00 2001 From: Matthias Wenz Date: Fri, 19 Jun 2026 12:16:34 +0200 Subject: [PATCH] feat(claude-code): add opt-in trail status line Add a native Go status line for Claude Code that shows a clickable "Trail #N " OSC-8 hyperlink when the current branch has an Entire trail, replacing the standalone Pi `.mjs` script. - New `entire hooks claude-code statusline` verb: serves a cached result on every poll (fast, in-process branch/remote resolution) and spawns a detached, throttled background refresh that does the authenticated trail lookup and rewrites the cache. No Node dependency; reuses GetCurrentBranch, gitremote.ResolveRemoteRepo, NewAuthenticatedAPIClient and findTrailByBranch. - The verb is fast-pathed in hook_registry.go, bypassing the lifecycle dispatcher's enabled-checks/perf-spans/per-invocation logging since it is polled several times per second. - Opt-in via `entire enable [--agent claude-code] --claude-statusline`. Never clobbers an existing statusLine (errors with ErrStatuslineConflict unless -f/--force); removed by `entire agent remove claude-code`. Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: a6deb758f25e --- cmd/entire/cli/agent/claudecode/hooks.go | 6 + cmd/entire/cli/agent/claudecode/lifecycle.go | 1 + cmd/entire/cli/agent/claudecode/statusline.go | 187 +++++++++ .../cli/agent/claudecode/statusline_test.go | 231 +++++++++++ cmd/entire/cli/claude_statusline.go | 364 ++++++++++++++++++ cmd/entire/cli/claude_statusline_test.go | 259 +++++++++++++ cmd/entire/cli/hook_registry.go | 6 + cmd/entire/cli/setup.go | 79 +++- 8 files changed, 1125 insertions(+), 8 deletions(-) create mode 100644 cmd/entire/cli/agent/claudecode/statusline.go create mode 100644 cmd/entire/cli/agent/claudecode/statusline_test.go create mode 100644 cmd/entire/cli/claude_statusline.go create mode 100644 cmd/entire/cli/claude_statusline_test.go diff --git a/cmd/entire/cli/agent/claudecode/hooks.go b/cmd/entire/cli/agent/claudecode/hooks.go index 5890d430aa..89288de6cf 100644 --- a/cmd/entire/cli/agent/claudecode/hooks.go +++ b/cmd/entire/cli/agent/claudecode/hooks.go @@ -25,6 +25,7 @@ const ( HookNamePreTask = "pre-task" HookNamePostTask = "post-task" HookNamePostTodo = "post-todo" + HookNameStatusLine = "statusline" ) // ClaudeSettingsFileName is the settings file used by Claude Code. @@ -265,6 +266,11 @@ func marshalHookType(rawHooks map[string]json.RawMessage, hookType string, match // UninstallHooks removes Entire hooks from Claude Code settings. func (c *ClaudeCodeAgent) UninstallHooks(ctx context.Context) error { + // Remove the Entire trail status line too (no-op if absent or foreign). + if err := c.UninstallStatusline(ctx); err != nil { + return err + } + // Use repo root to find .claude directory when run from a subdirectory repoRoot, err := paths.WorktreeRoot(ctx) if err != nil { diff --git a/cmd/entire/cli/agent/claudecode/lifecycle.go b/cmd/entire/cli/agent/claudecode/lifecycle.go index 2d92de120c..c8d76c957a 100644 --- a/cmd/entire/cli/agent/claudecode/lifecycle.go +++ b/cmd/entire/cli/agent/claudecode/lifecycle.go @@ -62,6 +62,7 @@ func (c *ClaudeCodeAgent) HookNames() []string { HookNamePreTask, HookNamePostTask, HookNamePostTodo, + HookNameStatusLine, } } diff --git a/cmd/entire/cli/agent/claudecode/statusline.go b/cmd/entire/cli/agent/claudecode/statusline.go new file mode 100644 index 0000000000..8b71f3121e --- /dev/null +++ b/cmd/entire/cli/agent/claudecode/statusline.go @@ -0,0 +1,187 @@ +package claudecode + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// ErrStatuslineConflict is returned by InstallStatusline when a non-Entire +// statusLine is already configured and force was not requested. Callers can +// detect it with errors.Is to warn-and-skip rather than fail. +var ErrStatuslineConflict = errors.New("a non-Entire statusLine is already configured") + +// statuslineCommandMarker is the substring that identifies an Entire-owned +// statusLine command. It survives both the production (`entire …`) and +// local-dev (`${CLAUDE_PROJECT_DIR}/scripts/entire-dev …`) command forms, so +// AreHooksInstalled-style ownership checks can recognise our entry regardless +// of how it was installed. +const statuslineCommandMarker = "hooks claude-code statusline" + +// claudeStatusLine mirrors the shape Claude Code expects under the top-level +// "statusLine" key in settings.json. +type claudeStatusLine struct { + Type string `json:"type"` + Command string `json:"command"` + Padding int `json:"padding"` +} + +// statuslineCommand returns the command Claude Code should run to render the +// Entire trail status line. In local-dev mode it routes through +// scripts/entire-dev (same prefix as the hooks) so an uncommitted CLI is used. +func statuslineCommand(localDev bool) string { + if localDev { + return localDevHookCmdPrefix + "hooks claude-code statusline" + } + return "entire hooks claude-code statusline" +} + +// claudeSettingsPath resolves /.claude/settings.json, falling back +// to a CWD-relative path when not in a git repository (e.g. during tests). +func claudeSettingsPath(ctx context.Context) string { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + //nolint:forbidigo // explicit fallback when WorktreeRoot fails (tests run outside git repos) + if cwd, cwdErr := os.Getwd(); cwdErr == nil { + repoRoot = cwd + } else { + repoRoot = "." + } + } + return filepath.Join(repoRoot, ".claude", ClaudeSettingsFileName) +} + +// InstallStatusline sets the Entire trail status line in .claude/settings.json. +// +// It never clobbers a user's own statusLine: if a foreign statusLine is already +// configured it returns an error (unless force is true) so the caller can warn +// and skip. If an Entire-owned statusLine is already present it is rewritten +// only when it differs (e.g. switching between production and local-dev). +// +// Returns true when settings.json was written, false when it was already +// up-to-date. +func (c *ClaudeCodeAgent) InstallStatusline(ctx context.Context, localDev bool, force bool) (bool, error) { + settingsPath := claudeSettingsPath(ctx) + + rawSettings, err := readClaudeSettingsMap(settingsPath) + if err != nil { + return false, err + } + + desired := claudeStatusLine{Type: "command", Command: statuslineCommand(localDev), Padding: 0} + + if existingRaw, ok := rawSettings["statusLine"]; ok { + var existing claudeStatusLine + // A statusLine may legitimately be a bare string in some configs; if it + // doesn't parse as our object shape, treat it as foreign. + if err := json.Unmarshal(existingRaw, &existing); err == nil && strings.Contains(existing.Command, statuslineCommandMarker) { + if existing == desired { + return false, nil // already up-to-date + } + // Entire-owned but stale (prod↔local-dev) — rewrite below. + } else if !force { + return false, fmt.Errorf("%w in %s; pass --force to replace", ErrStatuslineConflict, settingsPath) + } + } + + desiredJSON, err := jsonutil.MarshalWithNoHTMLEscape(desired) + if err != nil { + return false, fmt.Errorf("failed to marshal statusLine: %w", err) + } + rawSettings["statusLine"] = desiredJSON + + if err := writeClaudeSettingsMap(settingsPath, rawSettings); err != nil { + return false, err + } + return true, nil +} + +// UninstallStatusline removes the Entire trail status line from +// .claude/settings.json. A foreign statusLine is left untouched. +func (c *ClaudeCodeAgent) UninstallStatusline(ctx context.Context) error { + settingsPath := claudeSettingsPath(ctx) + + data, err := os.ReadFile(settingsPath) //nolint:gosec // path constructed from repo root + fixed file name + if err != nil { + return nil //nolint:nilerr // no settings file means nothing to uninstall + } + + var rawSettings map[string]json.RawMessage + if err := json.Unmarshal(data, &rawSettings); err != nil { + return fmt.Errorf("failed to parse settings.json: %w", err) + } + + existingRaw, ok := rawSettings["statusLine"] + if !ok { + return nil + } + var existing claudeStatusLine + //nolint:nilerr // unparseable/foreign statusLine → intentionally leave it untouched + if err := json.Unmarshal(existingRaw, &existing); err != nil || !strings.Contains(existing.Command, statuslineCommandMarker) { + return nil // foreign statusLine — leave it alone + } + + delete(rawSettings, "statusLine") + return writeClaudeSettingsMap(settingsPath, rawSettings) +} + +// IsStatuslineInstalled reports whether an Entire-owned statusLine is present. +func (c *ClaudeCodeAgent) IsStatuslineInstalled(ctx context.Context) bool { + data, err := os.ReadFile(claudeSettingsPath(ctx)) + if err != nil { + return false + } + var rawSettings map[string]json.RawMessage + if err := json.Unmarshal(data, &rawSettings); err != nil { + return false + } + existingRaw, ok := rawSettings["statusLine"] + if !ok { + return false + } + var existing claudeStatusLine + if err := json.Unmarshal(existingRaw, &existing); err != nil { + return false + } + return strings.Contains(existing.Command, statuslineCommandMarker) +} + +// readClaudeSettingsMap reads settings.json into a raw key map, preserving all +// unknown keys. A missing file yields an empty map. +func readClaudeSettingsMap(settingsPath string) (map[string]json.RawMessage, error) { + data, err := os.ReadFile(settingsPath) //nolint:gosec // path constructed from repo root + fixed file name + if err != nil { + return make(map[string]json.RawMessage), nil //nolint:nilerr // missing file → empty settings + } + var rawSettings map[string]json.RawMessage + if err := json.Unmarshal(data, &rawSettings); err != nil { + return nil, fmt.Errorf("failed to parse settings.json: %w", err) + } + if rawSettings == nil { + rawSettings = make(map[string]json.RawMessage) + } + return rawSettings, nil +} + +// writeClaudeSettingsMap writes the raw settings map back to disk with the same +// formatting and permissions used by hook installation. +func writeClaudeSettingsMap(settingsPath string, rawSettings map[string]json.RawMessage) error { + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { + return fmt.Errorf("failed to create .claude directory: %w", err) + } + output, err := jsonutil.MarshalIndentWithNewline(rawSettings, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + if err := os.WriteFile(settingsPath, output, 0o600); err != nil { + return fmt.Errorf("failed to write settings.json: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/agent/claudecode/statusline_test.go b/cmd/entire/cli/agent/claudecode/statusline_test.go new file mode 100644 index 0000000000..c07dcb4f18 --- /dev/null +++ b/cmd/entire/cli/agent/claudecode/statusline_test.go @@ -0,0 +1,231 @@ +package claudecode + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +// prodStatuslineCommand is the production statusLine command Entire installs. +const prodStatuslineCommand = "entire hooks claude-code statusline" + +// readStatusLine reads the statusLine block from /.claude/settings.json. +// Returns ok=false when the key is absent. +func readStatusLine(t *testing.T, dir string) (claudeStatusLine, bool) { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, ".claude", ClaudeSettingsFileName)) + if err != nil { + t.Fatalf("read settings.json: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("parse settings.json: %v", err) + } + slRaw, ok := raw["statusLine"] + if !ok { + return claudeStatusLine{}, false + } + var sl claudeStatusLine + if err := json.Unmarshal(slRaw, &sl); err != nil { + t.Fatalf("parse statusLine: %v", err) + } + return sl, true +} + +func TestInstallStatusline_FreshInstall(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ag := &ClaudeCodeAgent{} + changed, err := ag.InstallStatusline(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallStatusline() error = %v", err) + } + if !changed { + t.Fatal("expected changed = true on fresh install") + } + + sl, ok := readStatusLine(t, dir) + if !ok { + t.Fatal("statusLine not written") + } + if sl.Type != "command" { + t.Errorf("statusLine.type = %q, want command", sl.Type) + } + if sl.Command != prodStatuslineCommand { + t.Errorf("statusLine.command = %q, want production command", sl.Command) + } + if !ag.IsStatuslineInstalled(context.Background()) { + t.Error("IsStatuslineInstalled() = false after install") + } +} + +func TestInstallStatusline_Idempotent(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ag := &ClaudeCodeAgent{} + if _, err := ag.InstallStatusline(context.Background(), false, false); err != nil { + t.Fatalf("first InstallStatusline() error = %v", err) + } + changed, err := ag.InstallStatusline(context.Background(), false, false) + if err != nil { + t.Fatalf("second InstallStatusline() error = %v", err) + } + if changed { + t.Error("expected changed = false on idempotent re-install") + } +} + +func TestInstallStatusline_LocalDevRewrite(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ag := &ClaudeCodeAgent{} + if _, err := ag.InstallStatusline(context.Background(), false, false); err != nil { + t.Fatalf("prod InstallStatusline() error = %v", err) + } + changed, err := ag.InstallStatusline(context.Background(), true, false) + if err != nil { + t.Fatalf("local-dev InstallStatusline() error = %v", err) + } + if !changed { + t.Error("expected changed = true when switching prod -> local-dev") + } + sl, _ := readStatusLine(t, dir) + if sl.Command != localDevHookCmdPrefix+"hooks claude-code statusline" { + t.Errorf("statusLine.command = %q, want local-dev command", sl.Command) + } +} + +func TestInstallStatusline_ForeignNotClobbered(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + writeForeignSettings(t, dir, `{"statusLine":{"type":"command","command":"my-custom.sh"}}`) + + ag := &ClaudeCodeAgent{} + changed, err := ag.InstallStatusline(context.Background(), false, false) + if !errors.Is(err, ErrStatuslineConflict) { + t.Fatalf("InstallStatusline() error = %v, want ErrStatuslineConflict", err) + } + if changed { + t.Error("expected changed = false when refusing to clobber") + } + sl, _ := readStatusLine(t, dir) + if sl.Command != "my-custom.sh" { + t.Errorf("foreign statusLine was modified: command = %q", sl.Command) + } +} + +func TestInstallStatusline_ForceReplacesForeign(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + writeForeignSettings(t, dir, `{"statusLine":{"type":"command","command":"my-custom.sh"}}`) + + ag := &ClaudeCodeAgent{} + changed, err := ag.InstallStatusline(context.Background(), false, true) + if err != nil { + t.Fatalf("InstallStatusline(force) error = %v", err) + } + if !changed { + t.Error("expected changed = true when forcing replacement") + } + sl, _ := readStatusLine(t, dir) + if sl.Command != prodStatuslineCommand { + t.Errorf("statusLine.command = %q, want Entire command after force", sl.Command) + } +} + +func TestInstallStatusline_PreservesOtherKeys(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + writeForeignSettings(t, dir, `{"model":"opus","permissions":{"deny":["Read(./secret)"]}}`) + + ag := &ClaudeCodeAgent{} + if _, err := ag.InstallStatusline(context.Background(), false, false); err != nil { + t.Fatalf("InstallStatusline() error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, ".claude", ClaudeSettingsFileName)) + if err != nil { + t.Fatalf("read settings: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("parse settings: %v", err) + } + if _, ok := raw["model"]; !ok { + t.Error("model key was dropped") + } + if _, ok := raw["permissions"]; !ok { + t.Error("permissions key was dropped") + } + if _, ok := raw["statusLine"]; !ok { + t.Error("statusLine key was not added") + } +} + +func TestUninstallStatusline_RemovesEntireOwned(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ag := &ClaudeCodeAgent{} + if _, err := ag.InstallStatusline(context.Background(), false, false); err != nil { + t.Fatalf("InstallStatusline() error = %v", err) + } + if err := ag.UninstallStatusline(context.Background()); err != nil { + t.Fatalf("UninstallStatusline() error = %v", err) + } + if _, ok := readStatusLine(t, dir); ok { + t.Error("statusLine still present after uninstall") + } +} + +func TestUninstallStatusline_LeavesForeign(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + writeForeignSettings(t, dir, `{"statusLine":{"type":"command","command":"my-custom.sh"}}`) + + ag := &ClaudeCodeAgent{} + if err := ag.UninstallStatusline(context.Background()); err != nil { + t.Fatalf("UninstallStatusline() error = %v", err) + } + sl, ok := readStatusLine(t, dir) + if !ok || sl.Command != "my-custom.sh" { + t.Errorf("foreign statusLine was removed: ok=%v command=%q", ok, sl.Command) + } +} + +func TestUninstallHooks_RemovesStatusline(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + ag := &ClaudeCodeAgent{} + if _, err := ag.InstallHooks(context.Background(), false, false); err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + if _, err := ag.InstallStatusline(context.Background(), false, false); err != nil { + t.Fatalf("InstallStatusline() error = %v", err) + } + if err := ag.UninstallHooks(context.Background()); err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + if _, ok := readStatusLine(t, dir); ok { + t.Error("statusLine should be removed by UninstallHooks") + } +} + +// writeForeignSettings writes raw JSON to /.claude/settings.json. +func writeForeignSettings(t *testing.T, dir, content string) { + t.Helper() + claudeDir := filepath.Join(dir, ".claude") + if err := os.MkdirAll(claudeDir, 0o750); err != nil { + t.Fatalf("mkdir .claude: %v", err) + } + if err := os.WriteFile(filepath.Join(claudeDir, ClaudeSettingsFileName), []byte(content), 0o600); err != nil { + t.Fatalf("write settings: %v", err) + } +} diff --git a/cmd/entire/cli/claude_statusline.go b/cmd/entire/cli/claude_statusline.go new file mode 100644 index 0000000000..8ce0da3b51 --- /dev/null +++ b/cmd/entire/cli/claude_statusline.go @@ -0,0 +1,364 @@ +package cli + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + "github.com/entireio/cli/cmd/entire/cli/api" + "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/cmd/entire/cli/execx" + "github.com/entireio/cli/cmd/entire/cli/gitremote" + "github.com/entireio/cli/internal/entireclient/userdirs" +) + +// Claude Code polls the status line several times per second, so the serve path +// must never block on git or network. It serves a cached result immediately and +// spawns a detached background refresh (throttled) that performs the slow +// authenticated trail lookup and rewrites the cache for the next poll. +const ( + // statuslineFreshDuration is how long a cached result is served without + // triggering a background refresh. + statuslineFreshDuration = 60 * time.Second + // statuslineRefreshLock throttles how often a background refresh may start. + statuslineRefreshLock = 10 * time.Second + // statuslineRefreshTimeout caps the background lookup. + statuslineRefreshTimeout = 15 * time.Second +) + +// ANSI / OSC-8 escape codes for the rendered status line. +const ( + ansiReset = "\x1b[0m" + ansiCyan = "\x1b[36m" + ansiDim = "\x1b[2m" +) + +// statuslineInfiniteAge is returned as the cache age when there is no usable +// cache, so the caller always treats it as stale. +const statuslineInfiniteAge = time.Duration(math.MaxInt64) + +// statuslineWebBase is the web origin used to build trail links, matching the +// canonical entire.io trail path used by the Pi trails extension. +const statuslineWebBase = "https://entire.io" + +// statuslineInput is the subset of Claude Code's status-line stdin JSON we need. +type statuslineInput struct { + CWD string `json:"cwd"` + Workspace struct { + CurrentDir string `json:"current_dir"` + } `json:"workspace"` +} + +// statuslineResult is the resolved trail state cached for fast rendering. +type statuslineResult struct { + Status string `json:"status"` // "found", "no-trail", "auth", "error" + Number int `json:"number,omitempty"` + TrailStatus string `json:"trail_status,omitempty"` + URL string `json:"url,omitempty"` + Message string `json:"message,omitempty"` +} + +// statuslineCachePayload is what we persist between polls. +type statuslineCachePayload struct { + TS int64 `json:"ts"` // unix milliseconds + Result statuslineResult `json:"result"` +} + +// newClaudeStatuslineCmd builds the `entire hooks claude-code statusline` verb. +// It bypasses the generic hook dispatcher (executeAgentHook) and its +// per-invocation logging because the status line is polled very frequently and +// must stay fast. The no-op PersistentPreRunE/PostRunE shadow the parent +// claude-code hooks command's logging setup. +func newClaudeStatuslineCmd() *cobra.Command { + var refresh bool + var refreshCwd, refreshCacheFile string + + cmd := &cobra.Command{ + Use: claudecode.HookNameStatusLine, + Short: "Render the Entire trail status line for Claude Code", + Hidden: true, + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { return nil }, + RunE: func(cmd *cobra.Command, _ []string) error { + if refresh { + return runClaudeStatuslineRefresh(cmd.Context(), refreshCwd, refreshCacheFile) + } + return runClaudeStatuslineServe(cmd) + }, + } + cmd.Flags().BoolVar(&refresh, "refresh", false, "Perform the slow trail lookup and rewrite the cache (internal)") + cmd.Flags().StringVar(&refreshCwd, "cwd", "", "Working directory for the refresh lookup (internal)") + cmd.Flags().StringVar(&refreshCacheFile, "cache-file", "", "Cache file to rewrite (internal)") + return cmd +} + +// runClaudeStatuslineServe is the fast path run on every poll: it does only +// local, in-process work (branch/remote resolution + cache read) and serves the +// cached result, spawning a detached refresh when the cache is stale. +func runClaudeStatuslineServe(cmd *cobra.Command) error { + ctx := cmd.Context() + + cwd := readStatuslineCWD(cmd.InOrStdin()) + if cwd != "" { + _ = os.Chdir(cwd) //nolint:errcheck // best-effort; fall back to process CWD + } + + forge, owner, repo, branch, ok := resolveStatuslineRepo(ctx) + if !ok { + return nil // not a repo, detached HEAD, or unsupported remote → render nothing + } + + effectiveCwd := cwd + if effectiveCwd == "" { + if wd, err := os.Getwd(); err == nil { //nolint:forbidigo // need the real CWD to hand to the detached refresh + effectiveCwd = wd + } + } + + cacheFile := statuslineCacheFile(forge, owner, repo, branch) + cached, age := readStatuslineCache(cacheFile) + if age > statuslineFreshDuration { + spawnStatuslineRefresh(effectiveCwd, cacheFile) + } + if cached != nil { + if out := renderStatusline(*cached); out != "" { + fmt.Fprint(cmd.OutOrStdout(), out) + } + } + return nil +} + +// runClaudeStatuslineRefresh performs the slow authenticated trail lookup and +// rewrites the cache. It prints nothing and is invoked detached. +func runClaudeStatuslineRefresh(ctx context.Context, cwd, cacheFile string) error { + if cacheFile == "" { + return nil + } + if cwd != "" { + _ = os.Chdir(cwd) //nolint:errcheck // best-effort + } + + forge, owner, repo, branch, ok := resolveStatuslineRepo(ctx) + if !ok { + return nil + } + + ctx, cancel := context.WithTimeout(ctx, statuslineRefreshTimeout) + defer cancel() + + writeStatuslineCache(cacheFile, resolveStatuslineTrail(ctx, forge, owner, repo, branch)) + return nil +} + +// resolveStatuslineRepo resolves the current branch and forge/owner/repo from +// the process CWD using in-process go-git calls. Returns ok=false when there is +// nothing to show (not a repo, detached HEAD, or a remote on an unsupported +// forge). +func resolveStatuslineRepo(ctx context.Context) (forge, owner, repo, branch string, ok bool) { + branch, err := GetCurrentBranch(ctx) + if err != nil || branch == "" { + return "", "", "", "", false + } + forge, owner, repo, err = gitremote.ResolveRemoteRepo(ctx, "origin") + if err != nil || forge == "" { + return "", "", "", "", false + } + return forge, owner, repo, branch, true +} + +// resolveStatuslineTrail performs the authenticated lookup for the branch's +// trail and maps the outcome to a cacheable result. +func resolveStatuslineTrail(ctx context.Context, forge, owner, repo, branch string) statuslineResult { + client, err := NewAuthenticatedAPIClient(ctx, false) + if err != nil { + if errors.Is(err, auth.ErrNotLoggedIn) { + return statuslineResult{Status: "auth"} + } + return statuslineResult{Status: "error", Message: shortStatuslineError(err.Error())} + } + + trail, err := findTrailByBranch(ctx, client, forge, owner, repo, branch) + if err != nil { + if isStatuslineAuthError(err.Error()) { + return statuslineResult{Status: "auth"} + } + return statuslineResult{Status: "error", Message: shortStatuslineError(err.Error())} + } + if trail == nil { + return statuslineResult{Status: "no-trail"} + } + return statuslineResult{ + Status: "found", + Number: trail.Number, + TrailStatus: trail.Status, + URL: statuslineTrailURL(forge, owner, repo, trail, branch), + } +} + +// renderStatusline produces the terminal string for a resolved result. +// "no-trail" and unknown states render empty (segment is simply absent). +func renderStatusline(r statuslineResult) string { + switch r.Status { + case "found": + label := fmt.Sprintf("Trail #%d", r.Number) + if r.TrailStatus != "" { + label += " " + r.TrailStatus + } + if r.URL != "" { + label = osc8Hyperlink(r.URL, label) + } + return ansiCyan + label + ansiReset + case "auth": + return ansiDim + "Trail: run `entire login`" + ansiReset + case "error": + if r.Message != "" { + return ansiDim + "Trail: " + r.Message + ansiReset + } + return "" + default: + return "" + } +} + +// osc8Hyperlink wraps label in an OSC-8 terminal hyperlink pointing at url. +func osc8Hyperlink(url, label string) string { + return "\x1b]8;;" + url + "\x07" + label + "\x1b]8;;\x07" +} + +// statuslineTrailURL builds the web URL for a trail, mirroring the Pi trails +// extension scheme: https://entire.io////trails/[/]. +func statuslineTrailURL(forge, owner, repo string, trail *api.TrailResource, branch string) string { + if trail == nil || trail.Number == 0 { + return "" + } + titleOrBranch := trail.Title + if titleOrBranch == "" { + titleOrBranch = branch + } + u := fmt.Sprintf("%s/%s/%s/%s/trails/%d", statuslineWebBase, forge, owner, repo, trail.Number) + if slug := slugifyTitle(titleOrBranch); slug != "" { + u += "/" + slug + } + return u +} + +// readStatuslineCWD extracts the working directory from Claude Code's status +// line stdin JSON, preferring workspace.current_dir. +func readStatuslineCWD(r io.Reader) string { + data, err := io.ReadAll(io.LimitReader(r, 1<<20)) + if err != nil || len(data) == 0 { + return "" + } + var in statuslineInput + if json.Unmarshal(data, &in) != nil { + return "" + } + if in.Workspace.CurrentDir != "" { + return in.Workspace.CurrentDir + } + return in.CWD +} + +// statuslineCacheFile returns the cache path for a forge/owner/repo + branch. +func statuslineCacheFile(forge, owner, repo, branch string) string { + sum := sha256.Sum256([]byte(forge + "/" + owner + "/" + repo + "|" + branch)) + key := hex.EncodeToString(sum[:])[:16] + return filepath.Join(userdirs.Cache(), "statusline", key+".json") +} + +// readStatuslineCache reads a cached result, returning (nil, infinite age) when +// the cache is missing or unusable. +func readStatuslineCache(cacheFile string) (*statuslineResult, time.Duration) { + data, err := os.ReadFile(cacheFile) //nolint:gosec // path derived from cache dir + hashed key + if err != nil { + return nil, statuslineInfiniteAge + } + var payload statuslineCachePayload + if json.Unmarshal(data, &payload) != nil || payload.TS == 0 { + return nil, statuslineInfiniteAge + } + return &payload.Result, time.Since(time.UnixMilli(payload.TS)) +} + +// writeStatuslineCache atomically writes a result to the cache file. +func writeStatuslineCache(cacheFile string, result statuslineResult) { + payload := statuslineCachePayload{TS: time.Now().UnixMilli(), Result: result} + data, err := json.Marshal(payload) + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(cacheFile), 0o700); err != nil { + return + } + tmp := cacheFile + ".tmp" + if os.WriteFile(tmp, data, 0o600) != nil { + return + } + _ = os.Rename(tmp, cacheFile) //nolint:errcheck // a failed swap just means a re-resolve next poll +} + +// spawnStatuslineRefresh launches the detached background refresh. It is a +// package var so tests can stub it instead of spawning real subprocesses. +var spawnStatuslineRefresh = maybeSpawnStatuslineRefresh + +// maybeSpawnStatuslineRefresh starts a detached background refresh unless one +// started recently (throttled by a lock file's mtime). +func maybeSpawnStatuslineRefresh(cwd, cacheFile string) { + lock := cacheFile + ".lock" + if info, err := os.Stat(lock); err == nil && time.Since(info.ModTime()) < statuslineRefreshLock { + return // a refresh is already in flight + } + if err := os.MkdirAll(filepath.Dir(lock), 0o700); err == nil { + _ = os.WriteFile(lock, []byte(time.Now().Format(time.RFC3339Nano)), 0o600) //nolint:errcheck // best-effort lock + } + + exe, err := os.Executable() + if err != nil || exe == "" { + exe = "entire" + } + // Detach from the parent (Setsid) and from this turn's context so the + // refresh survives after the fast poll returns. + child := execx.NonInteractive(context.Background(), exe, + "hooks", "claude-code", claudecode.HookNameStatusLine, + "--refresh", "--cwd", cwd, "--cache-file", cacheFile) + _ = child.Start() //nolint:errcheck // best-effort; the link just appears a poll later +} + +// isStatuslineAuthError reports whether an error message indicates the user is +// not authenticated, so the status line can show the login hint. +func isStatuslineAuthError(msg string) bool { + m := strings.ToLower(msg) + for _, needle := range []string{"not logged in", "unauthorized", "authentication required", "run 'entire login'", "please log in", "401"} { + if strings.Contains(m, needle) { + return true + } + } + return false +} + +// shortStatuslineError trims an error to a single short line that fits the +// status bar. +func shortStatuslineError(msg string) string { + line := strings.TrimSpace(msg) + if i := strings.IndexByte(line, '\n'); i >= 0 { + line = line[:i] + } + if len(line) > 60 { + line = line[:60] + } + if line == "" { + return "lookup failed" + } + return line +} diff --git a/cmd/entire/cli/claude_statusline_test.go b/cmd/entire/cli/claude_statusline_test.go new file mode 100644 index 0000000000..3f10a8d432 --- /dev/null +++ b/cmd/entire/cli/claude_statusline_test.go @@ -0,0 +1,259 @@ +package cli + +import ( + "bytes" + "context" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/api" + "github.com/entireio/cli/cmd/entire/cli/gitremote" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +func TestRenderStatusline(t *testing.T) { + t.Parallel() + tests := []struct { + name string + result statuslineResult + wantSubstr []string + wantEmpty bool + }{ + { + name: "found with status and url", + result: statuslineResult{Status: "found", Number: 7, TrailStatus: "open", URL: "https://entire.io/gh/acme/widgets/trails/7"}, + wantSubstr: []string{"Trail #7", "open", "https://entire.io/gh/acme/widgets/trails/7", ansiCyan}, + }, + { + name: "found without status", + result: statuslineResult{Status: "found", Number: 3}, + wantSubstr: []string{"Trail #3"}, + }, + { + name: "auth", + result: statuslineResult{Status: "auth"}, + wantSubstr: []string{"entire login"}, + }, + { + name: "no-trail renders empty", + result: statuslineResult{Status: "no-trail"}, + wantEmpty: true, + }, + { + name: "error with message", + result: statuslineResult{Status: "error", Message: "boom"}, + wantSubstr: []string{"Trail:", "boom"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := renderStatusline(tt.result) + if tt.wantEmpty { + if got != "" { + t.Fatalf("renderStatusline() = %q, want empty", got) + } + return + } + for _, sub := range tt.wantSubstr { + if !strings.Contains(got, sub) { + t.Errorf("renderStatusline() = %q, want substring %q", got, sub) + } + } + }) + } +} + +func TestStatuslineTrailURL(t *testing.T) { + t.Parallel() + got := statuslineTrailURL("gh", "acme", "widgets", &api.TrailResource{Number: 12, Title: "Add Login Flow"}, "feature") + want := "https://entire.io/gh/acme/widgets/trails/12/add-login-flow" + if got != want { + t.Fatalf("statuslineTrailURL() = %q, want %q", got, want) + } + + // Falls back to branch when title is empty. + got = statuslineTrailURL("gh", "acme", "widgets", &api.TrailResource{Number: 4}, "my-branch") + if want := "https://entire.io/gh/acme/widgets/trails/4/my-branch"; got != want { + t.Fatalf("statuslineTrailURL() = %q, want %q", got, want) + } + + // Number 0 (not a real trail) → empty. + if got := statuslineTrailURL("gh", "acme", "widgets", &api.TrailResource{Number: 0}, "b"); got != "" { + t.Fatalf("statuslineTrailURL() = %q, want empty for number 0", got) + } +} + +func TestStatuslineCacheFile(t *testing.T) { + t.Parallel() + a := statuslineCacheFile("gh", "acme", "widgets", "main") + b := statuslineCacheFile("gh", "acme", "widgets", "main") + if a != b { + t.Fatalf("cache file not stable: %q vs %q", a, b) + } + if c := statuslineCacheFile("gh", "acme", "widgets", "other"); c == a { + t.Fatal("expected different cache file for different branch") + } + if !strings.HasSuffix(a, ".json") { + t.Errorf("cache file %q does not end in .json", a) + } +} + +func TestReadStatuslineCWD(t *testing.T) { + t.Parallel() + tests := []struct { + name, input, want string + }{ + {"workspace preferred", `{"cwd":"/a","workspace":{"current_dir":"/b"}}`, "/b"}, + {"cwd fallback", `{"cwd":"/a"}`, "/a"}, + {"empty", ``, ""}, + {"garbage", `not json`, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := readStatuslineCWD(strings.NewReader(tt.input)); got != tt.want { + t.Fatalf("readStatuslineCWD(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestStatuslineCacheRoundTrip(t *testing.T) { + t.Parallel() + cacheFile := filepath.Join(t.TempDir(), "x.json") + + // Missing cache → nil, infinite age. + if r, age := readStatuslineCache(cacheFile); r != nil || age != statuslineInfiniteAge { + t.Fatalf("missing cache: got (%v, %v)", r, age) + } + + writeStatuslineCache(cacheFile, statuslineResult{Status: "found", Number: 9, TrailStatus: "open"}) + r, age := readStatuslineCache(cacheFile) + if r == nil { + t.Fatal("expected cached result after write") + } + if r.Number != 9 || r.Status != "found" { + t.Fatalf("round-trip mismatch: %+v", r) + } + if age > statuslineFreshDuration { + t.Fatalf("freshly written cache reported stale age %v", age) + } +} + +func TestIsStatuslineAuthError(t *testing.T) { + t.Parallel() + for _, msg := range []string{"not logged in (run 'entire login')", "HTTP 401 Unauthorized", "authentication required"} { + if !isStatuslineAuthError(msg) { + t.Errorf("isStatuslineAuthError(%q) = false, want true", msg) + } + } + if isStatuslineAuthError("connection refused") { + t.Error("isStatuslineAuthError(connection refused) = true, want false") + } +} + +func TestShortStatuslineError(t *testing.T) { + t.Parallel() + if got := shortStatuslineError("first line\nsecond line"); got != "first line" { + t.Errorf("shortStatuslineError multiline = %q", got) + } + if got := shortStatuslineError(""); got != "lookup failed" { + t.Errorf("shortStatuslineError empty = %q", got) + } + long := strings.Repeat("x", 100) + if got := shortStatuslineError(long); len(got) != 60 { + t.Errorf("shortStatuslineError long len = %d, want 60", len(got)) + } +} + +// setupStatuslineRepo creates an isolated git repo with a github origin and one +// commit, chdir's into it, and returns the resolved forge/owner/repo/branch. +// Not parallel-safe (uses t.Chdir). +func setupStatuslineRepo(t *testing.T, repoName string) (forge, owner, repo, branch, repoDir string) { + t.Helper() + repoDir = t.TempDir() + testutil.InitRepo(t, repoDir) + + cmd := exec.CommandContext(context.Background(), "git", "remote", "add", "origin", "git@github.com:acme/"+repoName+".git") + cmd.Dir = repoDir + cmd.Env = testutil.GitIsolatedEnv() + if err := cmd.Run(); err != nil { + t.Fatalf("git remote add: %v", err) + } + + testutil.WriteFile(t, repoDir, "f.txt", "hi") + testutil.GitAdd(t, repoDir, "f.txt") + testutil.GitCommit(t, repoDir, "init") + t.Chdir(repoDir) + + ctx := context.Background() + var err error + branch, err = GetCurrentBranch(ctx) + if err != nil { + t.Fatalf("GetCurrentBranch: %v", err) + } + forge, owner, repo, err = gitremote.ResolveRemoteRepo(ctx, "origin") + if err != nil { + t.Fatalf("ResolveRemoteRepo: %v", err) + } + return forge, owner, repo, branch, repoDir +} + +func TestClaudeStatuslineServe_FreshCacheHit(t *testing.T) { + forge, owner, repo, branch, repoDir := setupStatuslineRepo(t, "widgets") + + // Refresh must NOT be spawned for a fresh cache. + orig := spawnStatuslineRefresh + t.Cleanup(func() { spawnStatuslineRefresh = orig }) + spawned := false + spawnStatuslineRefresh = func(_, _ string) { spawned = true } + + writeStatuslineCache(statuslineCacheFile(forge, owner, repo, branch), statuslineResult{ + Status: "found", Number: 7, TrailStatus: "open", URL: "https://entire.io/gh/acme/widgets/trails/7", + }) + + out := runStatuslineServeCmd(t, repoDir) + if !strings.Contains(out, "Trail #7") || !strings.Contains(out, "trails/7") { + t.Fatalf("serve output = %q, want Trail #7 link", out) + } + if spawned { + t.Error("refresh was spawned for a fresh cache") + } +} + +func TestClaudeStatuslineServe_StaleCacheSpawnsRefresh(t *testing.T) { + _, _, _, _, repoDir := setupStatuslineRepo(t, "gadgets") + + orig := spawnStatuslineRefresh + t.Cleanup(func() { spawnStatuslineRefresh = orig }) + var gotCacheFile string + spawnStatuslineRefresh = func(_, cacheFile string) { gotCacheFile = cacheFile } + + // No cache seeded → stale → refresh spawned, output empty. + out := runStatuslineServeCmd(t, repoDir) + if out != "" { + t.Fatalf("serve output = %q, want empty on cache miss", out) + } + if gotCacheFile == "" { + t.Fatal("expected refresh to be spawned on cache miss") + } +} + +// runStatuslineServeCmd executes the statusline serve path with stdin pointing +// at repoDir and returns captured stdout. +func runStatuslineServeCmd(t *testing.T, repoDir string) string { + t.Helper() + cmd := newClaudeStatuslineCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetIn(strings.NewReader(`{"workspace":{"current_dir":"` + repoDir + `"}}`)) + cmd.SetArgs([]string{}) + if err := cmd.Execute(); err != nil { + t.Fatalf("statusline serve execute: %v", err) + } + return buf.String() +} diff --git a/cmd/entire/cli/hook_registry.go b/cmd/entire/cli/hook_registry.go index 0ca44bdae8..db3433f9c4 100644 --- a/cmd/entire/cli/hook_registry.go +++ b/cmd/entire/cli/hook_registry.go @@ -168,6 +168,12 @@ func executeAgentHook(cmd *cobra.Command, agentName types.AgentName, hookName st // It uses the lifecycle dispatcher (ParseHookEvent → DispatchLifecycleEvent) as the primary path. // PostTodo is handled directly as it's Claude-specific and not part of the lifecycle dispatcher. func newAgentHookVerbCmdWithLogging(agentName types.AgentName, hookName string) *cobra.Command { + // The status line is polled several times per second and must stay fast, so + // it bypasses executeAgentHook (enabled checks, perf spans, per-invocation + // hook logging) entirely with its own dedicated command. + if agentName == agent.AgentNameClaudeCode && hookName == claudecode.HookNameStatusLine { + return newClaudeStatuslineCmd() + } return &cobra.Command{ Use: hookName, Hidden: true, diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 623e4865fb..d7bc75a8d5 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -11,6 +11,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" "github.com/entireio/cli/cmd/entire/cli/agent/external" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/gitremote" @@ -45,6 +46,7 @@ const ( flagAbsoluteGitHookPath = "absolute-git-hook-path" flagForce = "force" flagLocalDev = "local-dev" + flagClaudeStatusline = "claude-statusline" checkpointProviderGitHub = "github" ) @@ -70,6 +72,10 @@ type EnableOptions struct { // presentation of the final state (commit, push, done). SuppressDoneMessage bool Yes bool + // ClaudeStatusline opts in to installing the Entire trail status line into + // Claude Code's settings.json. It never clobbers a user's existing + // statusLine unless ForceHooks is also set. + ClaudeStatusline bool } // applyStrategyOptions sets strategy_options on settings from CLI flags. @@ -131,11 +137,11 @@ func enableUsesSetupFlow(cmd *cobra.Command, agentName string) bool { if agentName != "" || hasStrategyFlags(cmd) { return true } - return hasGlobalSettingsFlags(cmd) || cmd.Flags().Changed("yes") + return hasGlobalSettingsFlags(cmd) || cmd.Flags().Changed("yes") || cmd.Flags().Changed(flagClaudeStatusline) } func enableNeedsAgentManagement(cmd *cobra.Command) bool { - return hasGlobalSettingsFlags(cmd) || cmd.Flags().Changed("yes") + return hasGlobalSettingsFlags(cmd) || cmd.Flags().Changed("yes") || cmd.Flags().Changed(flagClaudeStatusline) } // updateStrategyOptions applies strategy flags to settings without re-running agent setup. @@ -576,7 +582,7 @@ func applyAgentChanges(ctx context.Context, w io.Writer, selectedAgentNames []st } var successfullyAddedAgents []agent.Agent for _, ag := range addedAgents { - if _, err := setupAgentHooks(ctx, w, ag, opts.LocalDev, opts.ForceHooks); err != nil { + if _, err := setupAgentHooks(ctx, w, ag, opts.hookOptions()); err != nil { errs = append(errs, fmt.Errorf("failed to setup %s hooks: %w", ag.Type(), err)) } else { successfullyAddedAgents = append(successfullyAddedAgents, ag) @@ -585,7 +591,7 @@ func applyAgentChanges(ctx context.Context, w io.Writer, selectedAgentNames []st var successfullyReinstalledAgents []agent.Agent for _, ag := range reinstalledAgents { - if _, err := setupAgentHooks(ctx, w, ag, opts.LocalDev, opts.ForceHooks); err != nil { + if _, err := setupAgentHooks(ctx, w, ag, opts.hookOptions()); err != nil { errs = append(errs, fmt.Errorf("failed to setup %s hooks: %w", ag.Type(), err)) } else { successfullyReinstalledAgents = append(successfullyReinstalledAgents, ag) @@ -901,6 +907,7 @@ for you and (optionally) create a matching GitHub repository via the gh CLI.`, cmd.Flags().BoolVar(&opts.UseProjectSettings, "project", false, "Write settings to .entire/settings.json even if it already exists") cmd.Flags().StringVar(&agentName, agentFlagName, "", "Agent to set up hooks for (e.g., "+strings.Join(agent.StringList(), ", ")+"; external agents on $PATH are also available). Enables non-interactive mode.") cmd.Flags().BoolVarP(&opts.ForceHooks, flagForce, "f", false, "Force reinstall hooks (removes existing Entire hooks first)") + cmd.Flags().BoolVar(&opts.ClaudeStatusline, flagClaudeStatusline, false, "Install the Entire trail status line into Claude Code (skips if a statusLine already exists unless -f)") cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push") cmd.Flags().StringVar(&opts.CheckpointRemote, flagCheckpointRemote, "", "Checkpoint remote in provider:owner/repo format (e.g., github:org/checkpoints-repo)") cmd.Flags().BoolVar(&opts.Telemetry, flagTelemetry, true, "Enable anonymous usage analytics") @@ -1059,7 +1066,7 @@ func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent // Setup agent hooks for all selected agents for _, ag := range agents { - if _, err := setupAgentHooks(ctx, w, ag, opts.LocalDev, opts.ForceHooks); err != nil { + if _, err := setupAgentHooks(ctx, w, ag, opts.hookOptions()); err != nil { return fmt.Errorf("failed to setup %s hooks: %w", ag.Type(), err) } } @@ -1345,13 +1352,13 @@ func uninstallDeselectedAgentHooks(ctx context.Context, w io.Writer, selectedAge // setupAgentHooks sets up hooks for a given agent. // Returns the number of hooks installed (0 if already installed). -func setupAgentHooks(ctx context.Context, w io.Writer, ag agent.Agent, localDev, forceHooks bool) (int, error) { +func setupAgentHooks(ctx context.Context, w io.Writer, ag agent.Agent, opts AgentHookOptions) (int, error) { hookAgent, ok := agent.AsHookSupport(ag) if !ok { return 0, fmt.Errorf("agent %s does not support hooks", ag.Name()) } - count, err := hookAgent.InstallHooks(ctx, localDev, forceHooks) + count, err := hookAgent.InstallHooks(ctx, opts.LocalDev, opts.ForceHooks) if err != nil { return 0, fmt.Errorf("failed to install %s hooks: %w", ag.Name(), err) } @@ -1362,9 +1369,65 @@ func setupAgentHooks(ctx context.Context, w io.Writer, ag agent.Agent, localDev, } reportSearchSubagentScaffold(w, ag, scaffoldResult) + if opts.ClaudeStatusline { + if err := installClaudeStatusline(ctx, w, ag, opts.LocalDev, opts.ForceHooks); err != nil { + return 0, err + } + } + return count, nil } +// AgentHookOptions carries the per-agent hook-installation choices into +// setupAgentHooks without threading the full EnableOptions through. +type AgentHookOptions struct { + LocalDev bool + ForceHooks bool + ClaudeStatusline bool +} + +// hookOptions distills the hook-installation choices from EnableOptions. +func (opts *EnableOptions) hookOptions() AgentHookOptions { + return AgentHookOptions{ + LocalDev: opts.LocalDev, + ForceHooks: opts.ForceHooks, + ClaudeStatusline: opts.ClaudeStatusline, + } +} + +// claudeStatuslineInstaller is implemented by the Claude Code agent. Declared +// locally to avoid a generic agent-package interface for a single agent. +type claudeStatuslineInstaller interface { + InstallStatusline(ctx context.Context, localDev, force bool) (bool, error) +} + +// installClaudeStatusline opts the Claude Code status line in. It is a no-op for +// other agents and never fails enable on a foreign statusLine: it prints a hint +// and continues so the rest of setup proceeds. +func installClaudeStatusline(ctx context.Context, w io.Writer, ag agent.Agent, localDev, force bool) error { + if ag.Name() != agent.AgentNameClaudeCode { + return nil // claude-code only; silently ignore for other agents + } + inst, ok := ag.(claudeStatuslineInstaller) + if !ok { + return nil + } + changed, err := inst.InstallStatusline(ctx, localDev, force) + if err != nil { + if errors.Is(err, claudecode.ErrStatuslineConflict) { + fmt.Fprintln(w, " statusLine already configured; skipping — re-run with -f/--force to replace") + return nil + } + return fmt.Errorf("failed to install Claude Code status line: %w", err) + } + if changed { + fmt.Fprintln(w, " ✓ Installed Claude Code trail status line") + } else { + fmt.Fprintln(w, " Claude Code trail status line already installed") + } + return nil +} + // detectOrSelectAgent tries to auto-detect agents, or prompts the user to select. // Returns the detected/selected agents and any error. // @@ -1575,7 +1638,7 @@ func setupAgentHooksNonInteractive(ctx context.Context, w io.Writer, ag agent.Ag fmt.Fprintf(w, " Agent: %s\n", ag.Type()) // Install agent hooks (agent hooks don't depend on settings) - installedHooks, err := setupAgentHooks(ctx, w, ag, opts.LocalDev, opts.ForceHooks) + installedHooks, err := setupAgentHooks(ctx, w, ag, opts.hookOptions()) if err != nil { return fmt.Errorf("failed to setup %s hooks: %w", agentName, err) }