Skip to content
Draft
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
15 changes: 15 additions & 0 deletions cmd/entire/cli/agent/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,21 @@ func AsSessionBaseDirProvider(ag Agent) (SessionBaseDirProvider, bool) { //nolin
return sbp, true
}

// AsHookVersionSupport returns the agent as HookVersionSupport if it implements
// the interface. This is a built-in-only capability (drift detection lives in
// the host CLI, not in external agent protocols), so no CapabilityDeclarer gate
// applies.
func AsHookVersionSupport(ag Agent) (HookVersionSupport, bool) { //nolint:ireturn // type-assertion helper must return interface
if ag == nil {
return nil, false
}
hv, ok := ag.(HookVersionSupport)
if !ok {
return nil, false
}
return hv, true
}

// AsSubagentAwareExtractor returns the agent as SubagentAwareExtractor if it both
// implements the interface and (for CapabilityDeclarer agents) has declared the capability.
func AsSubagentAwareExtractor(ag Agent) (SubagentAwareExtractor, bool) { //nolint:ireturn // type-assertion helper must return interface
Expand Down
32 changes: 32 additions & 0 deletions cmd/entire/cli/agent/claudecode/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
// InstallHooks installs Claude Code hooks in .claude/settings.json.
// If force is true, removes existing Entire hooks before installing.
// Returns the number of hooks installed.
//
//nolint:maintidx // Hook installation is intentionally centralized here; splitting it further would add churn for a config-assembly path.
func (c *ClaudeCodeAgent) InstallHooks(ctx context.Context, localDev bool, force bool) (int, error) {
// Use repo root instead of CWD to find .claude directory
// This ensures hooks are installed correctly when run from a subdirectory
Expand Down Expand Up @@ -92,6 +94,14 @@
rawPermissions = make(map[string]json.RawMessage)
}

// A missing entireMeta stamp on an existing install predates version
// tracking: the hook payload on disk could be anything. Treat it like
// --force so the stamp is only written alongside a fresh hook install,
// never over stale commands.
if _, stampFound := agent.ReadJSONHookMeta(rawSettings); !stampFound {
force = true
}

// Parse only the hook types we need to modify
var sessionStart, sessionEnd, stop, userPromptSubmit, preToolUse, postToolUse []ClaudeHookMatcher
parseHookType(rawHooks, "SessionStart", &sessionStart)
Expand Down Expand Up @@ -185,6 +195,12 @@
return 0, nil // All hooks and permissions already installed
}

// Always refresh the CLI-version stamp so subsequent drift checks know
// which version last modified the config.
if err := agent.WriteJSONHookMeta(rawSettings); err != nil {
return 0, err

Check failure on line 201 in cmd/entire/cli/agent/claudecode/hooks.go

View workflow job for this annotation

GitHub Actions / lint

error returned from external package is unwrapped: sig: func github.com/entireio/cli/cmd/entire/cli/agent.WriteJSONHookMeta(rawSettings map[string]encoding/json.RawMessage) error (wrapcheck)
}

// Marshal modified hook types back to rawHooks
marshalHookType(rawHooks, "SessionStart", sessionStart)
marshalHookType(rawHooks, "SessionEnd", sessionEnd)
Expand Down Expand Up @@ -366,6 +382,22 @@
return nil
}

// ReadHookMeta returns the CLI-version stamp recorded in .claude/settings.json.
// Missing or unparseable stamp returns ok=false so drift.go can flag it.
func (c *ClaudeCodeAgent) ReadHookMeta(ctx context.Context) (agent.HookMeta, bool, error) {

Check failure on line 387 in cmd/entire/cli/agent/claudecode/hooks.go

View workflow job for this annotation

GitHub Actions / lint

(*ClaudeCodeAgent).ReadHookMeta - result 2 (error) is always nil (unparam)
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
repoRoot = "."
}
settingsPath := filepath.Join(repoRoot, ".claude", ClaudeSettingsFileName)
data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + fixed path
if err != nil {
return agent.HookMeta{}, false, nil //nolint:nilerr // missing file means "no stamp", not a drift-check error
}
meta, ok := agent.ReadJSONHookMetaFromFile(data)
return meta, ok, nil
}

// AreHooksInstalled checks if Entire hooks are installed.
func (c *ClaudeCodeAgent) AreHooksInstalled(ctx context.Context) bool {
// Use repo root to find .claude directory when run from a subdirectory
Expand Down
130 changes: 130 additions & 0 deletions cmd/entire/cli/agent/claudecode/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"testing"

agentpkg "github.com/entireio/cli/cmd/entire/cli/agent"
Expand All @@ -15,6 +16,135 @@ import (
// metadataDenyRuleTest is the rule that blocks Claude from reading Entire metadata
const metadataDenyRuleTest = "Read(./.entire/metadata/**)"

// TestInstallHooks_StampsEntireMeta verifies that a fresh install writes an
// entireMeta.cli_version field matching the running CLI version, and that
// ReadHookMeta round-trips that stamp back out.
func TestInstallHooks_StampsEntireMeta(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)

ag := &ClaudeCodeAgent{}
if _, err := ag.InstallHooks(context.Background(), false, false); err != nil {
t.Fatalf("InstallHooks() error = %v", err)
}

settingsPath := filepath.Join(tempDir, ".claude", "settings.json")
data, err := os.ReadFile(settingsPath)
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)
}
meta, ok := agentpkg.ReadJSONHookMeta(raw)
if !ok {
t.Fatalf("expected entireMeta stamp in %s, got keys %v", settingsPath, keysOf(raw))
}
if meta.CLIVersion != agentpkg.HookMetaVersion() {
t.Fatalf("stamp cli_version = %q, want %q", meta.CLIVersion, agentpkg.HookMetaVersion())
}

// ReadHookMeta must return the same stamp via the typed API.
readMeta, found, err := ag.ReadHookMeta(context.Background())
if err != nil {
t.Fatalf("ReadHookMeta: %v", err)
}
if !found {
t.Fatalf("ReadHookMeta reported no stamp")
}
if readMeta.CLIVersion != meta.CLIVersion {
t.Fatalf("ReadHookMeta cli_version = %q, want %q", readMeta.CLIVersion, meta.CLIVersion)
}
}

// TestInstallHooks_StampsOnPreExistingInstall makes sure an install that would
// otherwise no-op (hooks already present, permissions present) still writes the
// stamp if it is missing — this is how existing users' configs acquire an
// entireMeta field on their next `entire enable` after upgrading.
func TestInstallHooks_StampsOnPreExistingInstall(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)

// Seed a settings.json that already has the Entire hooks and deny rule
// but no entireMeta stamp (simulating a pre-upgrade install).
writeSettingsFile(t, tempDir, `{
"permissions": {
"deny": ["Read(./.entire/metadata/**)"]
},
"hooks": {
"SessionStart": [{"matcher":"","hooks":[{"type":"command","command":"sh -c 'if ! command -v entire >/dev/null 2>&1; then printf \"%s\\n\" \"x\"; exit 0; fi; exec entire hooks claude-code session-start'"}]}],
"SessionEnd": [{"matcher":"","hooks":[{"type":"command","command":"sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks claude-code session-end'"}]}],
"Stop": [{"matcher":"","hooks":[{"type":"command","command":"sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks claude-code stop'"}]}],
"UserPromptSubmit": [{"matcher":"","hooks":[{"type":"command","command":"sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks claude-code user-prompt-submit'"}]}],
"PreToolUse": [{"matcher":"Task","hooks":[{"type":"command","command":"sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks claude-code pre-task'"}]}],
"PostToolUse": [
{"matcher":"Task","hooks":[{"type":"command","command":"sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks claude-code post-task'"}]},
{"matcher":"TodoWrite","hooks":[{"type":"command","command":"sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks claude-code post-todo'"}]}
]
}
}`)

ag := &ClaudeCodeAgent{}
if _, err := ag.InstallHooks(context.Background(), false, false); err != nil {
t.Fatalf("InstallHooks() error = %v", err)
}

meta, found, err := ag.ReadHookMeta(context.Background())
if err != nil {
t.Fatalf("ReadHookMeta: %v", err)
}
if !found {
t.Fatal("expected stamp to be backfilled on pre-existing install")
}
if meta.CLIVersion != agentpkg.HookMetaVersion() {
t.Fatalf("backfilled stamp = %q, want %q", meta.CLIVersion, agentpkg.HookMetaVersion())
}
}

// TestInstallHooks_MissingStampForcesReinstall pins the invariant that a plain
// `entire enable` on a pre-stamp config cannot silently clear drift by writing
// just the stamp: the hook payload itself must be rewritten. We seed a bogus
// (but managed-looking) entire hook command that wouldn't match current
// output, call InstallHooks with force=false, and verify the seeded command
// is gone and the canonical one took its place.
func TestInstallHooks_MissingStampForcesReinstall(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)

// Settings with a managed-prefix hook but stale command shape, and no stamp.
writeSettingsFile(t, tempDir, `{
"hooks": {
"Stop": [{"matcher":"","hooks":[{"type":"command","command":"entire hooks claude-code stop --legacy-arg"}]}]
}
}`)

ag := &ClaudeCodeAgent{}
if _, err := ag.InstallHooks(context.Background(), false, false); err != nil {
t.Fatalf("InstallHooks() error = %v", err)
}

data, err := os.ReadFile(filepath.Join(tempDir, ".claude", "settings.json"))
if err != nil {
t.Fatalf("read settings: %v", err)
}
content := string(data)
if strings.Contains(content, "--legacy-arg") {
t.Errorf("stale command should have been removed, still present in:\n%s", content)
}
if !strings.Contains(content, `"cli_version"`) {
t.Errorf("expected stamp to be written, not found in:\n%s", content)
}
}

func keysOf(m map[string]json.RawMessage) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}

func TestInstallHooks_PermissionsDeny_FreshInstall(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)
Expand Down
38 changes: 29 additions & 9 deletions cmd/entire/cli/agent/codex/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,16 @@

hooksPath := filepath.Join(repoRoot, ".codex", HooksFileName)

// Read existing hooks.json if present
// Read existing hooks.json if present. topLevel preserves unknown keys
// (e.g., $schema) across round-trips and is also where we stamp entireMeta.
var rawHooks map[string]json.RawMessage
topLevel := make(map[string]json.RawMessage)
existingData, readErr := os.ReadFile(hooksPath) //nolint:gosec // path constructed from repo root
if readErr == nil {
var hooksFile map[string]json.RawMessage
if err := json.Unmarshal(existingData, &hooksFile); err != nil {
if err := json.Unmarshal(existingData, &topLevel); err != nil {
return 0, fmt.Errorf("failed to parse existing hooks.json: %w", err)
}
if hooksRaw, ok := hooksFile["hooks"]; ok {
if hooksRaw, ok := topLevel["hooks"]; ok {
if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil {
return 0, fmt.Errorf("failed to parse hooks in hooks.json: %w", err)
}
Expand All @@ -53,6 +54,12 @@
rawHooks = make(map[string]json.RawMessage)
}

// Missing stamp predates version tracking — force a reinstall so the
// stamp lands with fresh hook commands, not on top of unknown old ones.
if _, stampFound := agent.ReadJSONHookMeta(topLevel); !stampFound {
force = true
}

// Parse event types we manage
var sessionStart, userPromptSubmit, stop []MatcherGroup
if err := parseHookType(rawHooks, "SessionStart", &sessionStart); err != nil {
Expand Down Expand Up @@ -118,12 +125,10 @@
marshalHookType(rawHooks, "UserPromptSubmit", userPromptSubmit)
marshalHookType(rawHooks, "Stop", stop)

// Preserve existing top-level keys (e.g., $schema) by reusing the parsed file
topLevel := make(map[string]json.RawMessage)
if readErr == nil {
// Re-parse the original file to preserve all top-level keys
_ = json.Unmarshal(existingData, &topLevel) //nolint:errcheck // best-effort preservation
if err := agent.WriteJSONHookMeta(topLevel); err != nil {
return 0, err

Check failure on line 129 in cmd/entire/cli/agent/codex/hooks.go

View workflow job for this annotation

GitHub Actions / lint

error returned from external package is unwrapped: sig: func github.com/entireio/cli/cmd/entire/cli/agent.WriteJSONHookMeta(rawSettings map[string]encoding/json.RawMessage) error (wrapcheck)
}

hooksJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawHooks)
if err != nil {
return 0, fmt.Errorf("failed to marshal hooks: %w", err)
Expand Down Expand Up @@ -220,6 +225,21 @@
return nil
}

// ReadHookMeta returns the CLI-version stamp recorded in .codex/hooks.json.
func (c *CodexAgent) ReadHookMeta(ctx context.Context) (agent.HookMeta, bool, error) {

Check failure on line 229 in cmd/entire/cli/agent/codex/hooks.go

View workflow job for this annotation

GitHub Actions / lint

(*CodexAgent).ReadHookMeta - result 2 (error) is always nil (unparam)
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
repoRoot = "."
}
hooksPath := filepath.Join(repoRoot, ".codex", HooksFileName)
data, err := os.ReadFile(hooksPath) //nolint:gosec // path constructed from repo root
if err != nil {
return agent.HookMeta{}, false, nil //nolint:nilerr // missing file means "no stamp", not a drift-check error
}
meta, ok := agent.ReadJSONHookMetaFromFile(data)
return meta, ok, nil
}

// AreHooksInstalled checks if Entire hooks are installed in Codex hooks.json.
func (c *CodexAgent) AreHooksInstalled(ctx context.Context) bool {
repoRoot, err := paths.WorktreeRoot(ctx)
Expand Down
25 changes: 25 additions & 0 deletions cmd/entire/cli/agent/copilotcli/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@
rawHooks = make(map[string]json.RawMessage)
}

// Missing stamp predates version tracking — force a reinstall so the
// stamp lands with fresh hook commands, not on top of unknown old ones.
if _, stampFound := agent.ReadJSONHookMeta(rawFile); !stampFound {
force = true
}

// Parse existing entries for each hook type we manage
hookEntries := make(map[string][]CopilotHookEntry)
for _, hookName := range c.HookNames() {
Expand Down Expand Up @@ -130,6 +136,10 @@
return 0, nil
}

if err := agent.WriteJSONHookMeta(rawFile); err != nil {
return 0, err

Check failure on line 140 in cmd/entire/cli/agent/copilotcli/hooks.go

View workflow job for this annotation

GitHub Actions / lint

error returned from external package is unwrapped: sig: func github.com/entireio/cli/cmd/entire/cli/agent.WriteJSONHookMeta(rawSettings map[string]encoding/json.RawMessage) error (wrapcheck)
}

// Marshal modified hook types back into rawHooks
for _, hookName := range c.HookNames() {
key := hookConfigKey[hookName]
Expand Down Expand Up @@ -229,6 +239,21 @@
return nil
}

// ReadHookMeta returns the CLI-version stamp recorded in Copilot CLI's entire.json.
func (c *CopilotCLIAgent) ReadHookMeta(ctx context.Context) (agent.HookMeta, bool, error) {

Check failure on line 243 in cmd/entire/cli/agent/copilotcli/hooks.go

View workflow job for this annotation

GitHub Actions / lint

(*CopilotCLIAgent).ReadHookMeta - result 2 (error) is always nil (unparam)
worktreeRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
worktreeRoot = "."
}
hooksPath := filepath.Join(worktreeRoot, hooksDir, HooksFileName)
data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path
if err != nil {
return agent.HookMeta{}, false, nil //nolint:nilerr // missing file means "no stamp", not a drift-check error
}
meta, ok := agent.ReadJSONHookMetaFromFile(data)
return meta, ok, nil
}

// AreHooksInstalled checks if Entire hooks are installed in the Copilot CLI config.
func (c *CopilotCLIAgent) AreHooksInstalled(ctx context.Context) bool {
worktreeRoot, err := paths.WorktreeRoot(ctx)
Expand Down
25 changes: 25 additions & 0 deletions cmd/entire/cli/agent/cursor/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@
rawHooks = make(map[string]json.RawMessage)
}

// Missing stamp predates version tracking — force a reinstall so the
// stamp lands with fresh hook commands, not on top of unknown old ones.
if _, stampFound := agent.ReadJSONHookMeta(rawFile); !stampFound {
force = true
}

// Parse only the hook types we manage
var sessionStart, sessionEnd, beforeSubmitPrompt, stop, preCompact, subagentStart, subagentStop []CursorHookEntry
parseCursorHookType(rawHooks, "sessionStart", &sessionStart)
Expand Down Expand Up @@ -172,6 +178,10 @@
return 0, nil
}

if err := agent.WriteJSONHookMeta(rawFile); err != nil {
return 0, err
}

// Marshal modified hook types back into rawHooks
marshalCursorHookType(rawHooks, "sessionStart", sessionStart)
marshalCursorHookType(rawHooks, "sessionEnd", sessionEnd)
Expand Down Expand Up @@ -285,6 +295,21 @@
return nil
}

// ReadHookMeta returns the CLI-version stamp recorded in .cursor/hooks.json.
func (c *CursorAgent) ReadHookMeta(ctx context.Context) (agent.HookMeta, bool, error) {

Check failure on line 299 in cmd/entire/cli/agent/cursor/hooks.go

View workflow job for this annotation

GitHub Actions / lint

(*CursorAgent).ReadHookMeta - result 2 (error) is always nil (unparam)
worktreeRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
worktreeRoot = "."
}
hooksPath := filepath.Join(worktreeRoot, ".cursor", HooksFileName)
data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path
if err != nil {
return agent.HookMeta{}, false, nil //nolint:nilerr // missing file means "no stamp", not a drift-check error
}
meta, ok := agent.ReadJSONHookMetaFromFile(data)
return meta, ok, nil
}

// AreHooksInstalled checks if Entire hooks are installed.
func (c *CursorAgent) AreHooksInstalled(ctx context.Context) bool {
worktreeRoot, err := paths.WorktreeRoot(ctx)
Expand Down
Loading
Loading