diff --git a/CLAUDE.md b/CLAUDE.md index 2a54637ee..4ee13d396 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ This repo contains the CLI for Entire. - `entire/`: Main CLI entry point - `entire/cli`: CLI utilities and helpers - `entire/cli/commands`: actual command implementations -- `entire/cli/agent`: agent implementations (Claude Code, Gemini CLI, OpenCode, Cursor) - see [Agent Integration Checklist](docs/architecture/agent-integration-checklist.md) and [Agent Implementation Guide](docs/architecture/agent-guide.md) +- `entire/cli/agent`: agent implementations (Claude Code, Gemini CLI, OpenCode, Cursor, Factory AI Droid, Autohand Code) - see [Agent Integration Checklist](docs/architecture/agent-integration-checklist.md) and [Agent Implementation Guide](docs/architecture/agent-guide.md) - `entire/cli/strategy`: strategy implementation (manual-commit) - see section below - `entire/cli/checkpoint`: checkpoint storage abstractions (temporary and committed) - `entire/cli/session`: session state management diff --git a/cmd/entire/cli/agent/autohandcode/autohandcode.go b/cmd/entire/cli/agent/autohandcode/autohandcode.go new file mode 100644 index 000000000..1e75bb57f --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/autohandcode.go @@ -0,0 +1,167 @@ +// Package autohandcode implements the Agent interface for Autohand Code CLI. +package autohandcode + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameAutohandCode, NewAutohandCodeAgent) +} + +// AutohandCodeAgent implements the agent.Agent interface for Autohand Code CLI. +// +//nolint:revive // AutohandCodeAgent is clearer than Agent in this context +type AutohandCodeAgent struct{} + +// NewAutohandCodeAgent creates a new Autohand Code agent instance. +func NewAutohandCodeAgent() agent.Agent { + return &AutohandCodeAgent{} +} + +// Name returns the agent registry key. +func (a *AutohandCodeAgent) Name() types.AgentName { return agent.AgentNameAutohandCode } + +// Type returns the agent type identifier. +func (a *AutohandCodeAgent) Type() types.AgentType { return agent.AgentTypeAutohandCode } + +// Description returns a human-readable description. +func (a *AutohandCodeAgent) Description() string { + return "Autohand Code - autonomous coding agent" +} + +// IsPreview returns true as Autohand Code integration is in preview. +func (a *AutohandCodeAgent) IsPreview() bool { return true } + +// ProtectedDirs returns directories that Autohand Code uses for config/state. +func (a *AutohandCodeAgent) ProtectedDirs() []string { return []string{".autohand"} } + +// DetectPresence checks if Autohand Code is configured in the repository. +func (a *AutohandCodeAgent) DetectPresence(ctx context.Context) (bool, error) { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." + } + if _, err := os.Stat(filepath.Join(repoRoot, ".autohand")); err == nil { + return true, nil + } + return false, nil +} + +// ReadTranscript reads the raw JSONL transcript bytes for a session. +func (a *AutohandCodeAgent) ReadTranscript(sessionRef string) ([]byte, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + return data, nil +} + +// ChunkTranscript splits a JSONL transcript at line boundaries. +func (a *AutohandCodeAgent) ChunkTranscript(_ context.Context, content []byte, maxSize int) ([][]byte, error) { + chunks, err := agent.ChunkJSONL(content, maxSize) + if err != nil { + return nil, fmt.Errorf("failed to chunk transcript: %w", err) + } + return chunks, nil +} + +// ReassembleTranscript concatenates JSONL chunks with newlines. +func (a *AutohandCodeAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} + +// GetSessionID extracts the session ID from hook input. +func (a *AutohandCodeAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID } + +// GetSessionDir returns the directory where Autohand Code stores session transcripts. +// Path: ~/.autohand/sessions/ +func (a *AutohandCodeAgent) GetSessionDir(_ string) (string, error) { + if override := os.Getenv("ENTIRE_TEST_AUTOHAND_PROJECT_DIR"); override != "" { + return override, nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + if envHome := os.Getenv("AUTOHAND_HOME"); envHome != "" { + return filepath.Join(envHome, "sessions"), nil + } + return filepath.Join(homeDir, ".autohand", "sessions"), nil +} + +// ResolveSessionFile returns the path to an Autohand Code session file. +// Autohand stores transcripts at ~/.autohand/sessions//conversation.jsonl +func (a *AutohandCodeAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID, "conversation.jsonl") +} + +// ReadSession reads a session from Autohand Code's storage (JSONL transcript file). +func (a *AutohandCodeAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (transcript path) is required") + } + + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: a.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + ModifiedFiles: ExtractModifiedFiles(lines), + }, nil +} + +// WriteSession writes a session to Autohand Code's storage (JSONL transcript file). +func (a *AutohandCodeAgent) WriteSession(_ context.Context, session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + if session.AgentName != "" && session.AgentName != a.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, a.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (transcript path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + if err := os.MkdirAll(filepath.Dir(session.SessionRef), 0o750); err != nil { + return fmt.Errorf("failed to create session directory: %w", err) + } + + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume an Autohand Code session. +func (a *AutohandCodeAgent) FormatResumeCommand(sessionID string) string { + return "autohand resume " + sessionID +} diff --git a/cmd/entire/cli/agent/autohandcode/autohandcode_test.go b/cmd/entire/cli/agent/autohandcode/autohandcode_test.go new file mode 100644 index 000000000..612d069ee --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/autohandcode_test.go @@ -0,0 +1,435 @@ +package autohandcode + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Compile-time interface assertions. +var ( + _ agent.Agent = (*AutohandCodeAgent)(nil) + _ agent.HookSupport = (*AutohandCodeAgent)(nil) + _ agent.TranscriptAnalyzer = (*AutohandCodeAgent)(nil) + _ agent.TokenCalculator = (*AutohandCodeAgent)(nil) + _ agent.SubagentAwareExtractor = (*AutohandCodeAgent)(nil) + _ agent.HookResponseWriter = (*AutohandCodeAgent)(nil) +) + +// TestDetectPresence uses t.Chdir so it cannot be parallel. +func TestDetectPresence(t *testing.T) { + t.Run("autohand directory exists", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + if err := os.Mkdir(".autohand", 0o755); err != nil { + t.Fatalf("failed to create .autohand: %v", err) + } + + ag := &AutohandCodeAgent{} + present, err := ag.DetectPresence(context.Background()) + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true") + } + }) + + t.Run("no autohand directory", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + present, err := ag.DetectPresence(context.Background()) + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if present { + t.Error("DetectPresence() = true, want false") + } + }) +} + +// --- Transcript tests --- + +func TestReadTranscript(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "transcript.jsonl") + content := `{"role":"user","content":"hello"} +{"role":"assistant","content":"hi"}` + if err := os.WriteFile(file, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + ag := &AutohandCodeAgent{} + data, err := ag.ReadTranscript(file) + if err != nil { + t.Fatalf("ReadTranscript() error = %v", err) + } + if string(data) != content { + t.Errorf("ReadTranscript() = %q, want %q", string(data), content) + } +} + +func TestReadTranscript_MissingFile(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + _, err := ag.ReadTranscript("/nonexistent/path/transcript.jsonl") + if err == nil { + t.Error("ReadTranscript() should error on missing file") + } +} + +func TestChunkTranscript_LargeContent(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + + // Build multi-line JSONL that exceeds a small maxSize + var lines []string + for i := range 50 { + lines = append(lines, fmt.Sprintf(`{"role":"user","content":"message %d %s"}`, i, strings.Repeat("x", 200))) + } + content := []byte(strings.Join(lines, "\n")) + + maxSize := 2000 + chunks, err := ag.ChunkTranscript(context.Background(), content, maxSize) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + if len(chunks) < 2 { + t.Errorf("Expected at least 2 chunks for large content, got %d", len(chunks)) + } + + // Verify each chunk is valid JSONL (each line is valid JSON) + for i, chunk := range chunks { + chunkLines := strings.Split(string(chunk), "\n") + for j, line := range chunkLines { + if line == "" { + continue + } + if line[0] != '{' { + t.Errorf("Chunk %d, line %d doesn't look like JSON: %q", i, j, line[:min(len(line), 40)]) + } + } + } +} + +func TestChunkTranscript_RoundTrip(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + + original := `{"role":"user","content":"hello"} +{"role":"assistant","content":"hi there"} +{"role":"user","content":"thanks"}` + + chunks, err := ag.ChunkTranscript(context.Background(), []byte(original), 60) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + + reassembled, err := ag.ReassembleTranscript(chunks) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + + if string(reassembled) != original { + t.Errorf("Round-trip mismatch:\n got: %q\nwant: %q", string(reassembled), original) + } +} + +func TestGetSessionDir(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + + dir, err := ag.GetSessionDir("/any/repo/path") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get home dir: %v", err) + } + + expected := filepath.Join(homeDir, ".autohand", "sessions") + if dir != expected { + t.Errorf("GetSessionDir() = %q, want %q", dir, expected) + } +} + +// TestGetSessionDir_AutohandHomeEnv cannot use t.Parallel() due to t.Setenv. +func TestGetSessionDir_AutohandHomeEnv(t *testing.T) { + ag := &AutohandCodeAgent{} + t.Setenv("AUTOHAND_HOME", "/tmp/custom-autohand") + + dir, err := ag.GetSessionDir("/any/repo/path") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + expected := "/tmp/custom-autohand/sessions" + if dir != expected { + t.Errorf("GetSessionDir() = %q, want %q (AUTOHAND_HOME override)", dir, expected) + } +} + +// TestGetSessionDir_EnvOverride cannot use t.Parallel() due to t.Setenv. +func TestGetSessionDir_EnvOverride(t *testing.T) { + ag := &AutohandCodeAgent{} + override := "/tmp/test-autohand-sessions" + t.Setenv("ENTIRE_TEST_AUTOHAND_PROJECT_DIR", override) + + dir, err := ag.GetSessionDir("/any/repo/path") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if dir != override { + t.Errorf("GetSessionDir() = %q, want %q (env override)", dir, override) + } +} + +func TestResolveSessionFile(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + result := ag.ResolveSessionFile("/home/user/.autohand/sessions", "abc-123") + expected := "/home/user/.autohand/sessions/abc-123/conversation.jsonl" + if result != expected { + t.Errorf("ResolveSessionFile() = %q, want %q", result, expected) + } +} + +// --- ReadSession / WriteSession tests --- + +func TestReadSession(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := filepath.Join(tmpDir, "transcript.jsonl") + + // Write an Autohand-format JSONL transcript with a file-modifying tool call + content := `{"role":"user","content":"create a file","timestamp":"2026-01-01T00:00:00.000Z"} +{"role":"assistant","content":"I will create the file","timestamp":"2026-01-01T00:00:01.000Z","toolCalls":[{"id":"tc_1","name":"write_file","input":{"path":"hello.txt","content":"hi"}}]}` + if err := os.WriteFile(transcriptPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &AutohandCodeAgent{} + session, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test-session-123", + SessionRef: transcriptPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + if session.SessionID != "test-session-123" { + t.Errorf("SessionID = %q, want %q", session.SessionID, "test-session-123") + } + if session.AgentName != agent.AgentNameAutohandCode { + t.Errorf("AgentName = %q, want %q", session.AgentName, agent.AgentNameAutohandCode) + } + if session.SessionRef != transcriptPath { + t.Errorf("SessionRef = %q, want %q", session.SessionRef, transcriptPath) + } + if len(session.NativeData) == 0 { + t.Error("NativeData should not be empty") + } + if len(session.ModifiedFiles) == 0 { + t.Error("ModifiedFiles should contain at least one file") + } +} + +func TestReadSession_EmptyRef(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + _, err := ag.ReadSession(&agent.HookInput{SessionID: "test"}) + if err == nil { + t.Error("ReadSession() should error on empty SessionRef") + } +} + +func TestReadSession_MissingFile(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + _, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test", + SessionRef: "/nonexistent/path/transcript.jsonl", + }) + if err == nil { + t.Error("ReadSession() should error on missing file") + } +} + +func TestWriteSession(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + // Write to a nested path to test directory creation + transcriptPath := filepath.Join(tmpDir, "sessions", "project", "transcript.jsonl") + nativeData := []byte(`{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}`) + + ag := &AutohandCodeAgent{} + err := ag.WriteSession(context.Background(), &agent.AgentSession{ + SessionID: "test-session-456", + AgentName: agent.AgentNameAutohandCode, + SessionRef: transcriptPath, + NativeData: nativeData, + }) + if err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + // Verify file was written correctly + written, err := os.ReadFile(transcriptPath) + if err != nil { + t.Fatalf("failed to read written file: %v", err) + } + if string(written) != string(nativeData) { + t.Errorf("written data = %q, want %q", string(written), string(nativeData)) + } +} + +func TestWriteSession_NilSession(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + if err := ag.WriteSession(context.Background(), nil); err == nil { + t.Error("WriteSession(nil) should error") + } +} + +func TestWriteSession_WrongAgent(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + err := ag.WriteSession(context.Background(), &agent.AgentSession{ + AgentName: "claude-code", + SessionRef: "/tmp/test.jsonl", + NativeData: []byte("data"), + }) + if err == nil { + t.Error("WriteSession() should error for wrong agent name") + } +} + +func TestWriteSession_EmptyRef(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + err := ag.WriteSession(context.Background(), &agent.AgentSession{ + AgentName: agent.AgentNameAutohandCode, + NativeData: []byte("data"), + }) + if err == nil { + t.Error("WriteSession() should error on empty SessionRef") + } +} + +func TestWriteSession_EmptyNativeData(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + err := ag.WriteSession(context.Background(), &agent.AgentSession{ + AgentName: agent.AgentNameAutohandCode, + SessionRef: "/tmp/test.jsonl", + }) + if err == nil { + t.Error("WriteSession() should error on empty NativeData") + } +} + +func TestReadWriteSession_RoundTrip(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + originalPath := filepath.Join(tmpDir, "original.jsonl") + restoredPath := filepath.Join(tmpDir, "restored.jsonl") + + content := `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"} +{"role":"assistant","content":"hi there","timestamp":"2026-01-01T00:00:01.000Z"}` + if err := os.WriteFile(originalPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write original: %v", err) + } + + ag := &AutohandCodeAgent{} + + // Read from original location + session, err := ag.ReadSession(&agent.HookInput{ + SessionID: "round-trip-test", + SessionRef: originalPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + // Write to new location + session.SessionRef = restoredPath + if err := ag.WriteSession(context.Background(), session); err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + // Verify content matches + restored, err := os.ReadFile(restoredPath) + if err != nil { + t.Fatalf("failed to read restored: %v", err) + } + if string(restored) != content { + t.Errorf("round-trip mismatch:\n got: %q\nwant: %q", string(restored), content) + } +} + +func TestFormatResumeCommand(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + cmd := ag.FormatResumeCommand("abc-123") + expected := "autohand resume abc-123" + if cmd != expected { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, expected) + } +} + +func TestName(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + if ag.Name() != agent.AgentNameAutohandCode { + t.Errorf("Name() = %q, want %q", ag.Name(), agent.AgentNameAutohandCode) + } +} + +func TestType(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + if ag.Type() != agent.AgentTypeAutohandCode { + t.Errorf("Type() = %q, want %q", ag.Type(), agent.AgentTypeAutohandCode) + } +} + +func TestDescription(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + desc := ag.Description() + if desc == "" { + t.Error("Description() should not be empty") + } +} + +func TestIsPreview(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + if !ag.IsPreview() { + t.Error("IsPreview() = false, want true") + } +} + +func TestProtectedDirs(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + dirs := ag.ProtectedDirs() + if len(dirs) != 1 || dirs[0] != ".autohand" { + t.Errorf("ProtectedDirs() = %v, want [.autohand]", dirs) + } +} diff --git a/cmd/entire/cli/agent/autohandcode/hooks.go b/cmd/entire/cli/agent/autohandcode/hooks.go new file mode 100644 index 000000000..5d25f99ab --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/hooks.go @@ -0,0 +1,326 @@ +package autohandcode + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure AutohandCodeAgent implements HookSupport +var _ agent.HookSupport = (*AutohandCodeAgent)(nil) + +// Autohand Code hook names - these become subcommands under `entire hooks autohand-code` +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameStop = "stop" + HookNamePrePrompt = "pre-prompt" + HookNameSubagentStop = "subagent-stop" + HookNameNotification = "notification" +) + +// AutohandConfigFileName is the config file used by Autohand Code. +const AutohandConfigFileName = "config.json" + +// metadataDenyRule blocks Autohand from reading Entire session metadata +var metadataDenyRule = AutohandPermissionRule{ + Tool: "read_file", + Pattern: ".entire/metadata/**", + Action: "deny", +} + +// entireHookPrefix identifies Entire hooks in the config +const entireHookPrefix = "entire hooks autohand-code " + +// entireHookLocalDevPrefix identifies local dev Entire hooks +const entireHookLocalDevPrefix = "go run ${AUTOHAND_PROJECT_DIR}/cmd/entire/main.go hooks autohand-code " + +// InstallHooks installs Autohand Code hooks in .autohand/config.json. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +func (a *AutohandCodeAgent) InstallHooks(ctx context.Context, localDev bool, force bool) (int, error) { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when WorktreeRoot() fails (tests run outside git repos) + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + configPath := filepath.Join(repoRoot, ".autohand", AutohandConfigFileName) + + // Read existing config if it exists + var rawConfig map[string]json.RawMessage + existingData, readErr := os.ReadFile(configPath) //nolint:gosec // path is constructed from cwd + fixed path + if readErr == nil { + if err := json.Unmarshal(existingData, &rawConfig); err != nil { + return 0, fmt.Errorf("failed to parse existing config.json: %w", err) + } + } else { + rawConfig = make(map[string]json.RawMessage) + } + + // Parse hooks section + var hooksSettings AutohandHooksSettings + if hooksRaw, ok := rawConfig["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &hooksSettings); err != nil { + return 0, fmt.Errorf("failed to parse hooks in config.json: %w", err) + } + } + + // If force is true, remove all existing Entire hooks first + if force { + hooksSettings.Hooks = removeEntireHooks(hooksSettings.Hooks) + } + + // Define hook commands + var prefix string + if localDev { + prefix = entireHookLocalDevPrefix + } else { + prefix = entireHookPrefix + } + + count := 0 + + // Add hooks if they don't exist + hookDefs := []struct { + event string + verb string + desc string + }{ + {event: "session-start", verb: "session-start", desc: "Entire: session start checkpoint"}, + {event: "pre-prompt", verb: "pre-prompt", desc: "Entire: capture user prompt"}, + {event: "stop", verb: "stop", desc: "Entire: save checkpoint on agent stop"}, + {event: "session-end", verb: "session-end", desc: "Entire: session end cleanup"}, + {event: "subagent-stop", verb: "subagent-stop", desc: "Entire: subagent checkpoint"}, + } + + for _, def := range hookDefs { + cmd := prefix + def.verb + if !hookCommandExists(hooksSettings.Hooks, cmd) { + enabled := true + hooksSettings.Hooks = append(hooksSettings.Hooks, AutohandHookDef{ + Event: def.event, + Command: cmd, + Description: def.desc, + Enabled: &enabled, + Timeout: 10000, // 10s for checkpoint operations + }) + count++ + } + } + + // Add permissions deny rule if not present + permissionsChanged := false + var rawPermissions map[string]json.RawMessage + if permRaw, ok := rawConfig["permissions"]; ok { + if err := json.Unmarshal(permRaw, &rawPermissions); err != nil { + return 0, fmt.Errorf("failed to parse permissions in config.json: %w", err) + } + } + if rawPermissions == nil { + rawPermissions = make(map[string]json.RawMessage) + } + + var rules []AutohandPermissionRule + if rulesRaw, ok := rawPermissions["rules"]; ok { + if err := json.Unmarshal(rulesRaw, &rules); err != nil { + return 0, fmt.Errorf("failed to parse permissions.rules in config.json: %w", err) + } + } + if !permissionRuleExists(rules, metadataDenyRule) { + rules = append(rules, metadataDenyRule) + rulesJSON, err := json.Marshal(rules) + if err != nil { + return 0, fmt.Errorf("failed to marshal permission rules: %w", err) + } + rawPermissions["rules"] = rulesJSON + permissionsChanged = true + } + + if count == 0 && !permissionsChanged { + return 0, nil // All hooks and permissions already installed + } + + // Marshal hooks back + hooksJSON, err := json.Marshal(hooksSettings) + if err != nil { + return 0, fmt.Errorf("failed to marshal hooks: %w", err) + } + rawConfig["hooks"] = hooksJSON + + // Marshal permissions back + permJSON, err := json.Marshal(rawPermissions) + if err != nil { + return 0, fmt.Errorf("failed to marshal permissions: %w", err) + } + rawConfig["permissions"] = permJSON + + // Write back to file + if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create .autohand directory: %w", err) + } + + output, err := jsonutil.MarshalIndentWithNewline(rawConfig, "", " ") + if err != nil { + return 0, fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, output, 0o600); err != nil { + return 0, fmt.Errorf("failed to write config.json: %w", err) + } + + return count, nil +} + +// UninstallHooks removes Entire hooks from Autohand Code config. +func (a *AutohandCodeAgent) UninstallHooks(ctx context.Context) error { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + configPath := filepath.Join(repoRoot, ".autohand", AutohandConfigFileName) + data, err := os.ReadFile(configPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return nil //nolint:nilerr // No config file means nothing to uninstall + } + + var rawConfig map[string]json.RawMessage + if err := json.Unmarshal(data, &rawConfig); err != nil { + return fmt.Errorf("failed to parse config.json: %w", err) + } + + // Parse and filter hooks + var hooksSettings AutohandHooksSettings + if hooksRaw, ok := rawConfig["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &hooksSettings); err != nil { + return fmt.Errorf("failed to parse hooks: %w", err) + } + } + + hooksSettings.Hooks = removeEntireHooks(hooksSettings.Hooks) + + // Remove the metadata deny rule from permissions + var rawPermissions map[string]json.RawMessage + if permRaw, ok := rawConfig["permissions"]; ok { + if err := json.Unmarshal(permRaw, &rawPermissions); err != nil { + rawPermissions = nil + } + } + + if rawPermissions != nil { + if rulesRaw, ok := rawPermissions["rules"]; ok { + var rules []AutohandPermissionRule + if err := json.Unmarshal(rulesRaw, &rules); err == nil { + filteredRules := make([]AutohandPermissionRule, 0, len(rules)) + for _, rule := range rules { + if rule != metadataDenyRule { + filteredRules = append(filteredRules, rule) + } + } + if len(filteredRules) > 0 { + rulesJSON, err := json.Marshal(filteredRules) + if err == nil { + rawPermissions["rules"] = rulesJSON + } + } else { + delete(rawPermissions, "rules") + } + } + } + if len(rawPermissions) > 0 { + permJSON, err := json.Marshal(rawPermissions) + if err == nil { + rawConfig["permissions"] = permJSON + } + } else { + delete(rawConfig, "permissions") + } + } + + // Marshal hooks back + if len(hooksSettings.Hooks) > 0 || hooksSettings.Enabled != nil { + hooksJSON, err := json.Marshal(hooksSettings) + if err != nil { + return fmt.Errorf("failed to marshal hooks: %w", err) + } + rawConfig["hooks"] = hooksJSON + } else { + delete(rawConfig, "hooks") + } + + // Write back + output, err := jsonutil.MarshalIndentWithNewline(rawConfig, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + if err := os.WriteFile(configPath, output, 0o600); err != nil { + return fmt.Errorf("failed to write config.json: %w", err) + } + return nil +} + +// AreHooksInstalled checks if Entire hooks are installed. +func (a *AutohandCodeAgent) AreHooksInstalled(ctx context.Context) bool { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + configPath := filepath.Join(repoRoot, ".autohand", AutohandConfigFileName) + data, err := os.ReadFile(configPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return false + } + + var config AutohandConfig + if err := json.Unmarshal(data, &config); err != nil { + return false + } + + // Check for at least one of our hooks (new or old format) + return hookCommandExists(config.Hooks.Hooks, entireHookPrefix+"stop") || + hookCommandExists(config.Hooks.Hooks, entireHookLocalDevPrefix+"stop") +} + +// Helper functions + +func hookCommandExists(hooks []AutohandHookDef, command string) bool { + for _, hook := range hooks { + if hook.Command == command { + return true + } + } + return false +} + +func isEntireHook(command string) bool { + return strings.HasPrefix(command, entireHookPrefix) || + strings.HasPrefix(command, entireHookLocalDevPrefix) +} + +func removeEntireHooks(hooks []AutohandHookDef) []AutohandHookDef { + result := make([]AutohandHookDef, 0, len(hooks)) + for _, hook := range hooks { + if !isEntireHook(hook.Command) { + result = append(result, hook) + } + } + return result +} + +func permissionRuleExists(rules []AutohandPermissionRule, target AutohandPermissionRule) bool { + for _, rule := range rules { + if rule == target { + return true + } + } + return false +} diff --git a/cmd/entire/cli/agent/autohandcode/hooks_test.go b/cmd/entire/cli/agent/autohandcode/hooks_test.go new file mode 100644 index 000000000..464500eba --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/hooks_test.go @@ -0,0 +1,542 @@ +package autohandcode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestInstallHooks_FreshInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + count, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // 5 hooks: session-start, pre-prompt, stop, session-end, subagent-stop + if count != 5 { + t.Errorf("InstallHooks() count = %d, want 5", count) + } + + // Verify config.json was created with hooks + config := readAutohandConfig(t, tempDir) + + if len(config.Hooks.Hooks) != 5 { + t.Errorf("Hook count = %d, want 5", len(config.Hooks.Hooks)) + } + + // Verify hook events + assertAutohandHookExists(t, config.Hooks.Hooks, "session-start", entireHookPrefix+"session-start", "session-start") + assertAutohandHookExists(t, config.Hooks.Hooks, "pre-prompt", entireHookPrefix+"pre-prompt", "pre-prompt") + assertAutohandHookExists(t, config.Hooks.Hooks, "stop", entireHookPrefix+"stop", "stop") + assertAutohandHookExists(t, config.Hooks.Hooks, "session-end", entireHookPrefix+"session-end", "session-end") + assertAutohandHookExists(t, config.Hooks.Hooks, "subagent-stop", entireHookPrefix+"subagent-stop", "subagent-stop") + + // Verify AreHooksInstalled returns true + if !ag.AreHooksInstalled(context.Background()) { + t.Error("AreHooksInstalled() should return true after install") + } +} + +func TestInstallHooks_Idempotent(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + + // First install + count1, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + if count1 != 5 { + t.Errorf("first InstallHooks() count = %d, want 5", count1) + } + + // Second install should add 0 hooks + count2, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + if count2 != 0 { + t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count2) + } + + // Verify still only 5 hooks total + config := readAutohandConfig(t, tempDir) + if len(config.Hooks.Hooks) != 5 { + t.Errorf("Hook count = %d after double install, want 5", len(config.Hooks.Hooks)) + } +} + +func TestInstallHooks_LocalDev(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + _, err := ag.InstallHooks(context.Background(), true, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + config := readAutohandConfig(t, tempDir) + + // Verify local dev commands use AUTOHAND_PROJECT_DIR format + assertAutohandHookExists(t, config.Hooks.Hooks, "session-start", + entireHookLocalDevPrefix+"session-start", "session-start localDev") + assertAutohandHookExists(t, config.Hooks.Hooks, "stop", + entireHookLocalDevPrefix+"stop", "stop localDev") + assertAutohandHookExists(t, config.Hooks.Hooks, "pre-prompt", + entireHookLocalDevPrefix+"pre-prompt", "pre-prompt localDev") + assertAutohandHookExists(t, config.Hooks.Hooks, "session-end", + entireHookLocalDevPrefix+"session-end", "session-end localDev") + assertAutohandHookExists(t, config.Hooks.Hooks, "subagent-stop", + entireHookLocalDevPrefix+"subagent-stop", "subagent-stop localDev") +} + +func TestInstallHooks_Force(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + + // First install + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Force reinstall should replace hooks + count, err := ag.InstallHooks(context.Background(), false, true) + if err != nil { + t.Fatalf("force InstallHooks() error = %v", err) + } + if count != 5 { + t.Errorf("force InstallHooks() count = %d, want 5", count) + } +} + +func TestInstallHooks_PermissionsDeny_FreshInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + rules := readAutohandPermissionRules(t, tempDir) + + // Verify permissions.rules contains our deny rule + if !containsDenyRule(rules, metadataDenyRule) { + t.Errorf("permissions.rules = %v, want to contain deny rule for .entire/metadata/**", rules) + } +} + +func TestInstallHooks_PermissionsDeny_Idempotent(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + // First install + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Second install + _, err = ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + + rules := readAutohandPermissionRules(t, tempDir) + + // Count occurrences of our rule + count := 0 + for _, rule := range rules { + if rule == metadataDenyRule { + count++ + } + } + if count != 1 { + t.Errorf("permissions.rules contains %d copies of deny rule, want 1", count) + } +} + +func TestInstallHooks_PermissionsDeny_PreservesUserRules(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create config.json with existing user permission rule + writeAutohandConfigFile(t, tempDir, `{ + "permissions": { + "rules": [{"tool":"exec_command","pattern":"rm -rf *","action":"deny"}] + } +}`) + + ag := &AutohandCodeAgent{} + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + rules := readAutohandPermissionRules(t, tempDir) + + // Verify both rules exist + userRule := AutohandPermissionRule{Tool: "exec_command", Pattern: "rm -rf *", Action: "deny"} + if !containsDenyRule(rules, userRule) { + t.Errorf("permissions.rules = %v, want to contain user rule", rules) + } + if !containsDenyRule(rules, metadataDenyRule) { + t.Errorf("permissions.rules = %v, want to contain Entire rule", rules) + } +} + +func TestInstallHooks_PreservesUnknownFields(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create config.json with unknown top-level fields + writeAutohandConfigFile(t, tempDir, `{ + "model": "gpt-4", + "customSetting": {"nested": "value"} +}`) + + ag := &AutohandCodeAgent{} + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Read raw config to check for unknown fields + configPath := filepath.Join(tempDir, ".autohand", AutohandConfigFileName) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config.json: %v", err) + } + + var rawConfig map[string]json.RawMessage + if err := json.Unmarshal(data, &rawConfig); err != nil { + t.Fatalf("failed to parse config.json: %v", err) + } + + // Verify "model" field is preserved + if _, ok := rawConfig["model"]; !ok { + t.Errorf("model field was not preserved") + } + + // Verify "customSetting" field is preserved + if _, ok := rawConfig["customSetting"]; !ok { + t.Errorf("customSetting field was not preserved") + } +} + +//nolint:tparallel // Parent uses t.Chdir() which prevents t.Parallel(); subtests only read from pre-loaded data +func TestInstallHooks_PreservesUserHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create config with user hooks on the same events we use + writeAutohandConfigFile(t, tempDir, `{ + "hooks": { + "hooks": [ + {"event":"stop","command":"echo user stop hook","description":"User stop hook","enabled":true} + ] + } +}`) + + ag := &AutohandCodeAgent{} + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + config := readAutohandConfig(t, tempDir) + + t.Run("user hook preserved", func(t *testing.T) { + t.Parallel() + assertAutohandHookExists(t, config.Hooks.Hooks, "stop", "echo user stop hook", "user stop hook") + }) + + t.Run("entire hook added", func(t *testing.T) { + t.Parallel() + assertAutohandHookExists(t, config.Hooks.Hooks, "stop", entireHookPrefix+"stop", "entire stop hook") + }) +} + +func TestUninstallHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + + // First install + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Verify hooks are installed + if !ag.AreHooksInstalled(context.Background()) { + t.Error("hooks should be installed before uninstall") + } + + // Uninstall + err = ag.UninstallHooks(context.Background()) + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + // Verify hooks are removed + if ag.AreHooksInstalled(context.Background()) { + t.Error("hooks should not be installed after uninstall") + } +} + +func TestUninstallHooks_NoConfigFile(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + + // Should not error when no config file exists + err := ag.UninstallHooks(context.Background()) + if err != nil { + t.Fatalf("UninstallHooks() should not error when no config file: %v", err) + } +} + +func TestUninstallHooks_PreservesUserHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create config with both user and entire hooks + writeAutohandConfigFile(t, tempDir, `{ + "hooks": { + "hooks": [ + {"event":"stop","command":"echo user hook","description":"User hook","enabled":true}, + {"event":"stop","command":"entire hooks autohand-code stop","description":"Entire","enabled":true} + ] + } +}`) + + ag := &AutohandCodeAgent{} + err := ag.UninstallHooks(context.Background()) + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + config := readAutohandConfig(t, tempDir) + + // Verify only user hooks remain + if len(config.Hooks.Hooks) != 1 { + t.Errorf("Hook count = %d after uninstall, want 1 (user only)", len(config.Hooks.Hooks)) + } + + // Verify it is the user hook + if len(config.Hooks.Hooks) > 0 { + if config.Hooks.Hooks[0].Command != "echo user hook" { + t.Error("user hook was removed during uninstall") + } + } +} + +func TestUninstallHooks_RemovesDenyRule(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + + // First install (which adds the deny rule) + _, err := ag.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Verify deny rule was added + rules := readAutohandPermissionRules(t, tempDir) + if !containsDenyRule(rules, metadataDenyRule) { + t.Fatal("deny rule should be present after install") + } + + // Uninstall + err = ag.UninstallHooks(context.Background()) + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + // Verify deny rule was removed + rules = readAutohandPermissionRules(t, tempDir) + if containsDenyRule(rules, metadataDenyRule) { + t.Error("deny rule should be removed after uninstall") + } +} + +func TestUninstallHooks_PreservesUserDenyRules(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create config with user deny rule and entire deny rule + writeAutohandConfigFile(t, tempDir, `{ + "permissions": { + "rules": [ + {"tool":"exec_command","pattern":"rm -rf *","action":"deny"}, + {"tool":"read_file","pattern":".entire/metadata/**","action":"deny"} + ] + }, + "hooks": { + "hooks": [ + {"event":"stop","command":"entire hooks autohand-code stop","description":"Entire","enabled":true} + ] + } +}`) + + ag := &AutohandCodeAgent{} + err := ag.UninstallHooks(context.Background()) + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + rules := readAutohandPermissionRules(t, tempDir) + + // Verify user deny rule is preserved + userRule := AutohandPermissionRule{Tool: "exec_command", Pattern: "rm -rf *", Action: "deny"} + if !containsDenyRule(rules, userRule) { + t.Errorf("user deny rule was removed, got: %v", rules) + } + + // Verify entire deny rule is removed + if containsDenyRule(rules, metadataDenyRule) { + t.Errorf("entire deny rule should be removed, got: %v", rules) + } +} + +func TestAreHooksInstalled_NotInstalled(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AutohandCodeAgent{} + if ag.AreHooksInstalled(context.Background()) { + t.Error("AreHooksInstalled() should return false when no config exists") + } +} + +func TestAreHooksInstalled_EmptyConfig(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + writeAutohandConfigFile(t, tempDir, `{}`) + + ag := &AutohandCodeAgent{} + if ag.AreHooksInstalled(context.Background()) { + t.Error("AreHooksInstalled() should return false with empty config") + } +} + +func TestAreHooksInstalled_Installed(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + writeAutohandConfigFile(t, tempDir, `{ + "hooks": { + "hooks": [ + {"event":"stop","command":"entire hooks autohand-code stop","description":"Entire","enabled":true} + ] + } +}`) + + ag := &AutohandCodeAgent{} + if !ag.AreHooksInstalled(context.Background()) { + t.Error("AreHooksInstalled() should return true when stop hook is present") + } +} + +// --- Helper functions --- + +func writeAutohandConfigFile(t *testing.T, tempDir, content string) { + t.Helper() + autohandDir := filepath.Join(tempDir, ".autohand") + if err := os.MkdirAll(autohandDir, 0o755); err != nil { + t.Fatalf("failed to create .autohand dir: %v", err) + } + configPath := filepath.Join(autohandDir, AutohandConfigFileName) + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write config.json: %v", err) + } +} + +func readAutohandConfig(t *testing.T, tempDir string) AutohandConfig { + t.Helper() + configPath := filepath.Join(tempDir, ".autohand", AutohandConfigFileName) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config.json: %v", err) + } + + var config AutohandConfig + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("failed to parse config.json: %v", err) + } + return config +} + +func readAutohandPermissionRules(t *testing.T, tempDir string) []AutohandPermissionRule { + t.Helper() + configPath := filepath.Join(tempDir, ".autohand", AutohandConfigFileName) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config.json: %v", err) + } + + var rawConfig map[string]json.RawMessage + if err := json.Unmarshal(data, &rawConfig); err != nil { + t.Fatalf("failed to parse config.json: %v", err) + } + + permRaw, ok := rawConfig["permissions"] + if !ok { + return nil + } + + var rawPermissions map[string]json.RawMessage + if err := json.Unmarshal(permRaw, &rawPermissions); err != nil { + t.Fatalf("failed to parse permissions: %v", err) + } + + rulesRaw, ok := rawPermissions["rules"] + if !ok { + return nil + } + + var rules []AutohandPermissionRule + if err := json.Unmarshal(rulesRaw, &rules); err != nil { + t.Fatalf("failed to parse rules: %v", err) + } + return rules +} + +func containsDenyRule(rules []AutohandPermissionRule, target AutohandPermissionRule) bool { + for _, rule := range rules { + if rule == target { + return true + } + } + return false +} + +func assertAutohandHookExists(t *testing.T, hooks []AutohandHookDef, event, command, description string) { + t.Helper() + for _, h := range hooks { + if h.Event == event && h.Command == command { + return + } + } + t.Errorf("%s hook not found (event=%q, command=%q)", description, event, command) +} diff --git a/cmd/entire/cli/agent/autohandcode/lifecycle.go b/cmd/entire/cli/agent/autohandcode/lifecycle.go new file mode 100644 index 000000000..693c92001 --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/lifecycle.go @@ -0,0 +1,249 @@ +package autohandcode + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/textutil" + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +// Compile-time interface assertions. +var ( + _ agent.TranscriptAnalyzer = (*AutohandCodeAgent)(nil) + _ agent.TokenCalculator = (*AutohandCodeAgent)(nil) + _ agent.SubagentAwareExtractor = (*AutohandCodeAgent)(nil) + _ agent.HookResponseWriter = (*AutohandCodeAgent)(nil) +) + +// WriteHookResponse outputs the hook response as plain text to stdout. +// Autohand parses JSON stdout only for control flow (pre-tool hooks). +// For lifecycle hooks, plain text is safe and displays in the terminal. +func (a *AutohandCodeAgent) WriteHookResponse(message string) error { + if _, err := fmt.Fprintln(os.Stdout, message); err != nil { + return fmt.Errorf("failed to write hook response: %w", err) + } + return nil +} + +// HookNames returns the hook verbs Autohand Code supports. +// These become subcommands: entire hooks autohand-code +func (a *AutohandCodeAgent) HookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameStop, + HookNamePrePrompt, + HookNameSubagentStop, + HookNameNotification, + } +} + +// ParseHookEvent translates an Autohand Code hook into a normalized lifecycle Event. +// Returns nil if the hook has no lifecycle significance. +func (a *AutohandCodeAgent) ParseHookEvent(_ context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { + switch hookName { + case HookNameSessionStart: + return a.parseSessionStart(stdin) + case HookNamePrePrompt: + return a.parseTurnStart(stdin) + case HookNameStop: + return a.parseTurnEnd(stdin) + case HookNameSessionEnd: + return a.parseSessionEnd(stdin) + case HookNameSubagentStop: + return a.parseSubagentEnd(stdin) + case HookNameNotification: + // Acknowledged hook with no lifecycle action + return nil, nil //nolint:nilnil // nil event = no lifecycle action + default: + return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action + } +} + +// --- TranscriptAnalyzer --- + +// GetTranscriptPosition returns the current line count of the JSONL transcript. +func (a *AutohandCodeAgent) GetTranscriptPosition(path string) (int, error) { + _, pos, err := ParseAutohandTranscript(path, 0) + if err != nil { + return 0, err + } + return pos, nil +} + +// ExtractModifiedFilesFromOffset extracts files modified since a given line offset. +func (a *AutohandCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { + lines, currentPos, err := ParseAutohandTranscript(path, startOffset) + if err != nil { + return nil, 0, fmt.Errorf("failed to parse transcript: %w", err) + } + files := ExtractModifiedFiles(lines) + return files, currentPos, nil +} + +// ExtractPrompts extracts user prompts from the transcript starting at the given line offset. +func (a *AutohandCodeAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + lines, _, err := ParseAutohandTranscript(sessionRef, fromOffset) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + var prompts []string + for i := range lines { + if lines[i].Type != transcript.TypeUser { + continue + } + content := transcript.ExtractUserContent(lines[i].Message) + if content != "" { + prompts = append(prompts, textutil.StripIDEContextTags(content)) + } + } + return prompts, nil +} + +// ExtractSummary extracts the last assistant message as a session summary. +func (a *AutohandCodeAgent) ExtractSummary(sessionRef string) (string, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input + if err != nil { + return "", fmt.Errorf("failed to read transcript: %w", err) + } + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + return "", fmt.Errorf("failed to parse transcript: %w", err) + } + + for i := len(lines) - 1; i >= 0; i-- { + if lines[i].Type != transcript.TypeAssistant { + continue + } + // For Autohand, the Message field contains the full message object. + // Try to extract the content field as a plain text string. + var msg autohandMessage + if err := json.Unmarshal(lines[i].Message, &msg); err != nil { + continue + } + var textContent string + if err := json.Unmarshal(msg.Content, &textContent); err == nil && textContent != "" { + return textContent, nil + } + // Also try the shared AssistantMessage format (content blocks) + var assistantMsg transcript.AssistantMessage + if err := json.Unmarshal(lines[i].Message, &assistantMsg); err != nil { + continue + } + for _, block := range assistantMsg.Content { + if block.Type == transcript.ContentTypeText && block.Text != "" { + return block.Text, nil + } + } + } + return "", nil +} + +// --- TokenCalculator --- + +// CalculateTokenUsage computes token usage from pre-loaded transcript bytes starting at the given line offset. +func (a *AutohandCodeAgent) CalculateTokenUsage(transcriptData []byte, fromOffset int) (*agent.TokenUsage, error) { + return CalculateTotalTokenUsageFromBytes(transcriptData, fromOffset, "") +} + +// --- SubagentAwareExtractor --- + +// ExtractAllModifiedFiles extracts files modified by both the main agent and any spawned subagents. +func (a *AutohandCodeAgent) ExtractAllModifiedFiles(transcriptData []byte, fromOffset int, subagentsDir string) ([]string, error) { + return ExtractAllModifiedFilesFromBytes(transcriptData, fromOffset, subagentsDir) +} + +// CalculateTotalTokenUsage computes token usage including all spawned subagents. +func (a *AutohandCodeAgent) CalculateTotalTokenUsage(transcriptData []byte, fromOffset int, subagentsDir string) (*agent.TokenUsage, error) { + return CalculateTotalTokenUsageFromBytes(transcriptData, fromOffset, subagentsDir) +} + +// --- Internal hook parsing functions --- + +// resolveTranscriptPath computes the transcript path from session_id. +// Autohand stores transcripts at ~/.autohand/sessions//conversation.jsonl +func (a *AutohandCodeAgent) resolveTranscriptPath(sessionID string) string { + if sessionID == "" { + return "" + } + sessionDir, err := a.GetSessionDir("") + if err != nil { + return "" + } + return filepath.Join(sessionDir, sessionID, "conversation.jsonl") +} + +func (a *AutohandCodeAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[hookInputBase](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionStart, + SessionID: raw.SessionID, + SessionRef: a.resolveTranscriptPath(raw.SessionID), + Timestamp: time.Now(), + }, nil +} + +func (a *AutohandCodeAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[hookInputPrompt](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.TurnStart, + SessionID: raw.SessionID, + SessionRef: a.resolveTranscriptPath(raw.SessionID), + Prompt: raw.Instruction, + Timestamp: time.Now(), + }, nil +} + +func (a *AutohandCodeAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[hookInputStop](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.TurnEnd, + SessionID: raw.SessionID, + SessionRef: a.resolveTranscriptPath(raw.SessionID), + Timestamp: time.Now(), + }, nil +} + +func (a *AutohandCodeAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[hookInputBase](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionEnd, + SessionID: raw.SessionID, + SessionRef: a.resolveTranscriptPath(raw.SessionID), + Timestamp: time.Now(), + }, nil +} + +func (a *AutohandCodeAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[hookInputSubagent](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SubagentEnd, + SessionID: raw.SessionID, + SessionRef: a.resolveTranscriptPath(raw.SessionID), + SubagentID: raw.SubagentID, + Timestamp: time.Now(), + }, nil +} diff --git a/cmd/entire/cli/agent/autohandcode/lifecycle_test.go b/cmd/entire/cli/agent/autohandcode/lifecycle_test.go new file mode 100644 index 000000000..60081fd97 --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/lifecycle_test.go @@ -0,0 +1,292 @@ +package autohandcode + +import ( + "context" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestParseHookEvent_SessionStart(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + input := `{"session_id": "test-session", "cwd": "/workspace", "hook_event_name": "session-start"}` + + event, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.SessionStart { + t.Errorf("expected SessionStart, got %v", event.Type) + } + if event.SessionID != "test-session" { + t.Errorf("expected session_id 'test-session', got %q", event.SessionID) + } + // SessionRef should be resolved from session_id + if event.SessionRef == "" { + t.Error("expected non-empty SessionRef") + } +} + +func TestParseHookEvent_TurnStart(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + input := `{"session_id": "sess-1", "cwd": "/workspace", "hook_event_name": "pre-prompt", "instruction": "Fix the bug"}` + + event, err := ag.ParseHookEvent(context.Background(), HookNamePrePrompt, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.Type != agent.TurnStart { + t.Errorf("expected TurnStart, got %v", event.Type) + } + if event.Prompt != "Fix the bug" { + t.Errorf("expected prompt 'Fix the bug', got %q", event.Prompt) + } + if event.SessionID != "sess-1" { + t.Errorf("expected session_id 'sess-1', got %q", event.SessionID) + } +} + +func TestParseHookEvent_TurnStart_NoInstruction(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + // pre-prompt with no instruction field + input := `{"session_id": "sess-empty", "cwd": "/workspace", "hook_event_name": "pre-prompt"}` + + event, err := ag.ParseHookEvent(context.Background(), HookNamePrePrompt, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.Type != agent.TurnStart { + t.Errorf("expected TurnStart, got %v", event.Type) + } + if event.Prompt != "" { + t.Errorf("expected empty prompt, got %q", event.Prompt) + } +} + +func TestParseHookEvent_TurnEnd(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + input := `{"session_id": "sess-2", "cwd": "/workspace", "hook_event_name": "stop", "tokens_used": 1000, "tool_calls_count": 5}` + + event, err := ag.ParseHookEvent(context.Background(), HookNameStop, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.Type != agent.TurnEnd { + t.Errorf("expected TurnEnd, got %v", event.Type) + } + if event.SessionID != "sess-2" { + t.Errorf("expected session_id 'sess-2', got %q", event.SessionID) + } +} + +func TestParseHookEvent_SessionEnd(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + input := `{"session_id": "sess-3", "cwd": "/workspace", "hook_event_name": "session-end"}` + + event, err := ag.ParseHookEvent(context.Background(), HookNameSessionEnd, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.Type != agent.SessionEnd { + t.Errorf("expected SessionEnd, got %v", event.Type) + } +} + +func TestParseHookEvent_SubagentEnd(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + input := `{"session_id": "sess-5", "cwd": "/workspace", "hook_event_name": "subagent-stop", "subagent_id": "agent-789", "subagent_name": "helper"}` + + event, err := ag.ParseHookEvent(context.Background(), HookNameSubagentStop, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.Type != agent.SubagentEnd { + t.Errorf("expected SubagentEnd, got %v", event.Type) + } + if event.SubagentID != "agent-789" { + t.Errorf("expected SubagentID 'agent-789', got %q", event.SubagentID) + } +} + +func TestParseHookEvent_Notification(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + event, err := ag.ParseHookEvent(context.Background(), HookNameNotification, strings.NewReader(`{"session_id":"s"}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event != nil { + t.Errorf("expected nil event for notification, got %+v", event) + } +} + +func TestParseHookEvent_UnknownHook(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + event, err := ag.ParseHookEvent(context.Background(), "unknown-hook", strings.NewReader(`{}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event != nil { + t.Errorf("expected nil event for unknown hook, got %+v", event) + } +} + +func TestParseHookEvent_EmptyInput(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + _, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader("")) + if err == nil { + t.Fatal("expected error for empty input") + } +} + +func TestParseHookEvent_MalformedJSON(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + _, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader("not json")) + if err == nil { + t.Fatal("expected error for malformed JSON") + } +} + +func TestHookNames(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + names := ag.HookNames() + + expected := []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameStop, + HookNamePrePrompt, + HookNameSubagentStop, + HookNameNotification, + } + + if len(names) != len(expected) { + t.Fatalf("HookNames() returned %d names, want %d", len(names), len(expected)) + } + + nameSet := make(map[string]bool) + for _, n := range names { + nameSet[n] = true + } + + for _, exp := range expected { + if !nameSet[exp] { + t.Errorf("HookNames() missing %q", exp) + } + } +} + +func TestWriteHookResponse(t *testing.T) { + t.Parallel() + + ag := &AutohandCodeAgent{} + // WriteHookResponse writes to os.Stdout, so we just verify it does not error. + // A more thorough test would capture stdout, but this is sufficient for coverage. + err := ag.WriteHookResponse("test message") + if err != nil { + t.Fatalf("WriteHookResponse() error = %v", err) + } +} + +func TestParseHookEvent_AllHookTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hookName string + input string + expectedType agent.EventType + nilEvent bool + }{ + { + name: "session-start", + hookName: HookNameSessionStart, + input: `{"session_id":"s1","cwd":"/workspace","hook_event_name":"session-start"}`, + expectedType: agent.SessionStart, + }, + { + name: "pre-prompt", + hookName: HookNamePrePrompt, + input: `{"session_id":"s2","cwd":"/workspace","hook_event_name":"pre-prompt","instruction":"hello"}`, + expectedType: agent.TurnStart, + }, + { + name: "stop", + hookName: HookNameStop, + input: `{"session_id":"s3","cwd":"/workspace","hook_event_name":"stop","tokens_used":100}`, + expectedType: agent.TurnEnd, + }, + { + name: "session-end", + hookName: HookNameSessionEnd, + input: `{"session_id":"s4","cwd":"/workspace","hook_event_name":"session-end"}`, + expectedType: agent.SessionEnd, + }, + { + name: "subagent-stop", + hookName: HookNameSubagentStop, + input: `{"session_id":"s5","cwd":"/workspace","hook_event_name":"subagent-stop","subagent_id":"sub1"}`, + expectedType: agent.SubagentEnd, + }, + { + name: "notification returns nil", + hookName: HookNameNotification, + input: `{"session_id":"s6","cwd":"/workspace","hook_event_name":"notification"}`, + nilEvent: true, + }, + { + name: "unknown returns nil", + hookName: "totally-unknown", + input: `{"session_id":"s7"}`, + nilEvent: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ag := &AutohandCodeAgent{} + event, err := ag.ParseHookEvent(context.Background(), tt.hookName, strings.NewReader(tt.input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.nilEvent { + if event != nil { + t.Errorf("expected nil event, got %+v", event) + } + return + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != tt.expectedType { + t.Errorf("expected %v, got %v", tt.expectedType, event.Type) + } + }) + } +} diff --git a/cmd/entire/cli/agent/autohandcode/transcript.go b/cmd/entire/cli/agent/autohandcode/transcript.go new file mode 100644 index 000000000..1a00f1ce7 --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/transcript.go @@ -0,0 +1,322 @@ +package autohandcode + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "slices" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +// TranscriptLine is an alias to the shared transcript.Line type. +type TranscriptLine = transcript.Line + +// ParseAutohandTranscript parses an Autohand JSONL file into normalized transcript.Line entries. +// Autohand messages have a direct role field, unlike Droid's envelope format. +// Non-message entries (system role, tool role) are filtered to keep only user/assistant. +func ParseAutohandTranscript(path string, startLine int) ([]transcript.Line, int, error) { + file, err := os.Open(path) //nolint:gosec // path is a controlled transcript file path + if err != nil { + return nil, 0, fmt.Errorf("failed to open transcript: %w", err) + } + defer func() { _ = file.Close() }() + + return parseAutohandTranscriptFromReader(file, startLine) +} + +// ParseAutohandTranscriptFromBytes parses Autohand JSONL content from a byte slice. +// startLine skips the first N raw JSONL lines before parsing (0 = parse all). +// Returns parsed lines, total raw line count, and any error. +func ParseAutohandTranscriptFromBytes(content []byte, startLine int) ([]transcript.Line, int, error) { + return parseAutohandTranscriptFromReader(bytes.NewReader(content), startLine) +} + +func parseAutohandTranscriptFromReader(r io.Reader, startLine int) ([]transcript.Line, int, error) { + reader := bufio.NewReader(r) + var lines []transcript.Line + totalLines := 0 + + for { + lineBytes, err := reader.ReadBytes('\n') + if err != nil && err != io.EOF { + return nil, 0, fmt.Errorf("failed to read transcript: %w", err) + } + + if len(lineBytes) == 0 { + if err == io.EOF { + break + } + continue + } + + if totalLines >= startLine { + if line, ok := parseAutohandLine(lineBytes); ok { + lines = append(lines, line) + } + } + totalLines++ + + if err == io.EOF { + break + } + } + + return lines, totalLines, nil +} + +// parseAutohandLine converts a single Autohand JSONL line into a normalized transcript.Line. +// Returns false if the line is not a user or assistant message. +func parseAutohandLine(lineBytes []byte) (transcript.Line, bool) { + var msg autohandMessage + if err := json.Unmarshal(lineBytes, &msg); err != nil { + return transcript.Line{}, false + } + + // Only process "user" and "assistant" messages — skip "tool", "system", etc. + if msg.Role != transcript.TypeUser && msg.Role != transcript.TypeAssistant { + return transcript.Line{}, false + } + + // Build a normalized message for downstream consumers. + // For Autohand, we store the full message object so that: + // - ExtractModifiedFiles can access toolCalls + // - ExtractUserContent can access the content field + // - ExtractSummary can access the content field + normalizedMsg, err := json.Marshal(msg) + if err != nil { + return transcript.Line{}, false + } + + return transcript.Line{ + Type: msg.Role, + UUID: msg.Timestamp, // Use timestamp as a pseudo-UUID (Autohand has no message IDs) + Message: normalizedMsg, + }, true +} + +// ExtractModifiedFiles extracts files modified by tool calls from transcript. +// Autohand stores tool calls in a separate toolCalls array on assistant messages. +func ExtractModifiedFiles(lines []TranscriptLine) []string { + fileSet := make(map[string]bool) + var files []string + + for _, line := range lines { + if line.Type != transcript.TypeAssistant { + continue + } + + var msg autohandMessage + if err := json.Unmarshal(line.Message, &msg); err != nil { + continue + } + + for _, tc := range msg.ToolCalls { + if !slices.Contains(FileModificationTools, tc.Name) { + continue + } + + var input autohandToolInput + if err := json.Unmarshal(tc.Input, &input); err != nil { + continue + } + + file := input.Path + if file == "" { + file = input.File + } + + if file != "" && !fileSet[file] { + fileSet[file] = true + files = append(files, file) + } + } + } + + return files +} + +// CalculateTokenUsage calculates token usage from an Autohand transcript. +// Autohand stores token metadata in the _meta field of assistant messages. +func CalculateTokenUsage(transcriptLines []TranscriptLine) *agent.TokenUsage { + usage := &agent.TokenUsage{} + + for _, line := range transcriptLines { + if line.Type != transcript.TypeAssistant { + continue + } + + var msg autohandMessage + if err := json.Unmarshal(line.Message, &msg); err != nil { + continue + } + + if msg.Meta == nil { + continue + } + + // Extract token info from _meta + if inputTokens, ok := extractIntFromMeta(msg.Meta, "input_tokens"); ok { + usage.InputTokens += inputTokens + } + if outputTokens, ok := extractIntFromMeta(msg.Meta, "output_tokens"); ok { + usage.OutputTokens += outputTokens + } + if cacheCreation, ok := extractIntFromMeta(msg.Meta, "cache_creation_input_tokens"); ok { + usage.CacheCreationTokens += cacheCreation + } + if cacheRead, ok := extractIntFromMeta(msg.Meta, "cache_read_input_tokens"); ok { + usage.CacheReadTokens += cacheRead + } + usage.APICallCount++ + } + + return usage +} + +// extractIntFromMeta safely extracts an integer from a map[string]any. +func extractIntFromMeta(meta map[string]any, key string) (int, bool) { + v, ok := meta[key] + if !ok { + return 0, false + } + switch val := v.(type) { + case float64: + return int(val), true + case int: + return val, true + case json.Number: + n, err := val.Int64() + if err != nil { + return 0, false + } + return int(n), true + default: + return 0, false + } +} + +// CalculateTokenUsageFromFile calculates token usage from a transcript file. +func CalculateTokenUsageFromFile(path string, startLine int) (*agent.TokenUsage, error) { + if path == "" { + return &agent.TokenUsage{}, nil + } + + lines, _, err := ParseAutohandTranscript(path, startLine) + if err != nil { + return nil, err + } + + return CalculateTokenUsage(lines), nil +} + +// CalculateTotalTokenUsageFromBytes calculates token usage from pre-loaded transcript bytes, +// including subagents. +func CalculateTotalTokenUsageFromBytes(data []byte, startLine int, subagentsDir string) (*agent.TokenUsage, error) { + if len(data) == 0 { + return &agent.TokenUsage{}, nil + } + + parsed, _, err := ParseAutohandTranscriptFromBytes(data, startLine) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + mainUsage := CalculateTokenUsage(parsed) + + agentIDs := ExtractSpawnedAgentIDs(parsed) + if len(agentIDs) > 0 && subagentsDir != "" { + subagentUsage := &agent.TokenUsage{} + for agentID := range agentIDs { + agentPath := filepath.Join(subagentsDir, fmt.Sprintf("agent-%s.jsonl", agentID)) + agentUsage, err := CalculateTokenUsageFromFile(agentPath, 0) + if err != nil { + continue + } + subagentUsage.InputTokens += agentUsage.InputTokens + subagentUsage.CacheCreationTokens += agentUsage.CacheCreationTokens + subagentUsage.CacheReadTokens += agentUsage.CacheReadTokens + subagentUsage.OutputTokens += agentUsage.OutputTokens + subagentUsage.APICallCount += agentUsage.APICallCount + } + if subagentUsage.APICallCount > 0 { + mainUsage.SubagentTokens = subagentUsage + } + } + + return mainUsage, nil +} + +// ExtractAllModifiedFilesFromBytes extracts files modified by both the main agent and +// any subagents from pre-loaded transcript bytes. +func ExtractAllModifiedFilesFromBytes(data []byte, startLine int, subagentsDir string) ([]string, error) { + if len(data) == 0 { + return nil, nil + } + + parsed, _, err := ParseAutohandTranscriptFromBytes(data, startLine) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + files := ExtractModifiedFiles(parsed) + fileSet := make(map[string]bool, len(files)) + for _, f := range files { + fileSet[f] = true + } + + agentIDs := ExtractSpawnedAgentIDs(parsed) + if subagentsDir == "" { + return files, nil + } + for agentID := range agentIDs { + agentPath := filepath.Join(subagentsDir, fmt.Sprintf("agent-%s.jsonl", agentID)) + agentLines, _, agentErr := ParseAutohandTranscript(agentPath, 0) + if agentErr != nil { + continue + } + for _, f := range ExtractModifiedFiles(agentLines) { + if !fileSet[f] { + fileSet[f] = true + files = append(files, f) + } + } + } + + return files, nil +} + +// ExtractSpawnedAgentIDs scans assistant messages for Task/task tool calls and +// returns a map of spawned agent IDs, keyed and valued by the tool call ID. +func ExtractSpawnedAgentIDs(transcriptLines []TranscriptLine) map[string]string { + agentIDs := make(map[string]string) + + for _, line := range transcriptLines { + if line.Type != transcript.TypeAssistant { + continue + } + + var msg autohandMessage + if err := json.Unmarshal(line.Message, &msg); err != nil { + continue + } + + // Look for Task tool calls that spawned subagents + for _, tc := range msg.ToolCalls { + if tc.Name != "task" && tc.Name != "Task" { + continue + } + // The tool call ID is used as the subagent reference + if tc.ID != "" { + agentIDs[tc.ID] = tc.ID + } + } + } + + return agentIDs +} diff --git a/cmd/entire/cli/agent/autohandcode/transcript_test.go b/cmd/entire/cli/agent/autohandcode/transcript_test.go new file mode 100644 index 000000000..4c2ba7218 --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/transcript_test.go @@ -0,0 +1,1087 @@ +package autohandcode + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +func TestParseAutohandTranscript_BasicMessages(t *testing.T) { + t.Parallel() + + data := []byte( + `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"hi there","timestamp":"2026-01-01T00:00:01.000Z"}` + "\n", + ) + + lines, totalLines, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("ParseAutohandTranscriptFromBytes() error = %v", err) + } + + if totalLines != 2 { + t.Errorf("totalLines = %d, want 2", totalLines) + } + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2", len(lines)) + } + + if lines[0].Type != transcript.TypeUser { + t.Errorf("lines[0].Type = %q, want %q", lines[0].Type, transcript.TypeUser) + } + if lines[1].Type != transcript.TypeAssistant { + t.Errorf("lines[1].Type = %q, want %q", lines[1].Type, transcript.TypeAssistant) + } +} + +func TestParseAutohandTranscript_FiltersNonUserAssistant(t *testing.T) { + t.Parallel() + + // Autohand format has tool and system roles that should be filtered out + data := []byte( + `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"I will create the file","timestamp":"2026-01-01T00:00:01.000Z","toolCalls":[{"id":"tc_1","name":"write_file","input":{"path":"foo.ts"}}]}` + "\n" + + `{"role":"tool","content":"File written","name":"write_file","tool_call_id":"tc_1"}` + "\n" + + `{"role":"system","content":"System prompt"}` + "\n" + + `{"role":"assistant","content":"Done!","timestamp":"2026-01-01T00:00:02.000Z"}` + "\n", + ) + + lines, totalLines, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("ParseAutohandTranscriptFromBytes() error = %v", err) + } + + if totalLines != 5 { + t.Errorf("totalLines = %d, want 5", totalLines) + } + // Only user and assistant messages (tool and system should be filtered) + if len(lines) != 3 { + t.Fatalf("got %d lines, want 3 (tool and system should be filtered)", len(lines)) + } + + if lines[0].Type != transcript.TypeUser { + t.Errorf("lines[0].Type = %q, want %q", lines[0].Type, transcript.TypeUser) + } + if lines[1].Type != transcript.TypeAssistant { + t.Errorf("lines[1].Type = %q, want %q", lines[1].Type, transcript.TypeAssistant) + } + if lines[2].Type != transcript.TypeAssistant { + t.Errorf("lines[2].Type = %q, want %q", lines[2].Type, transcript.TypeAssistant) + } +} + +func TestParseAutohandTranscript_StartLineOffset(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + path := tmpDir + "/transcript.jsonl" + + data := []byte( + `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"hi","timestamp":"2026-01-01T00:00:01.000Z"}` + "\n" + + `{"role":"user","content":"bye","timestamp":"2026-01-01T00:00:02.000Z"}` + "\n", + ) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("failed to write: %v", err) + } + + // Read from line 1 onward (skip first message) + lines, totalLines, err := ParseAutohandTranscript(path, 1) + if err != nil { + t.Fatalf("ParseAutohandTranscript() error = %v", err) + } + + if totalLines != 3 { + t.Errorf("totalLines = %d, want 3", totalLines) + } + + if len(lines) != 2 { + t.Fatalf("got %d lines from offset 1, want 2", len(lines)) + } + if lines[0].Type != transcript.TypeAssistant { + t.Errorf("lines[0].Type = %q, want %q", lines[0].Type, transcript.TypeAssistant) + } + if lines[1].Type != transcript.TypeUser { + t.Errorf("lines[1].Type = %q, want %q", lines[1].Type, transcript.TypeUser) + } +} + +func TestParseAutohandTranscriptFromBytes_StartLineBeyondEnd(t *testing.T) { + t.Parallel() + + data := []byte( + `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n", + ) + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 100) + if err != nil { + t.Fatalf("ParseAutohandTranscriptFromBytes(100) error = %v", err) + } + if len(lines) != 0 { + t.Fatalf("startLine=100: got %d lines, want 0", len(lines)) + } +} + +func TestParseAutohandTranscript_MalformedLines(t *testing.T) { + t.Parallel() + + // Transcript with some broken JSON lines interspersed with valid ones + data := []byte( + `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"broken json` + "\n" + + `not even close to json` + "\n" + + `{"role":"assistant","content":"hi","timestamp":"2026-01-01T00:00:01.000Z"}` + "\n", + ) + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("ParseAutohandTranscriptFromBytes() error = %v", err) + } + + // Only the 2 valid user/assistant lines should be parsed + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2 (malformed lines should be silently skipped)", len(lines)) + } + if lines[0].Type != transcript.TypeUser { + t.Errorf("lines[0].Type = %q, want %q", lines[0].Type, transcript.TypeUser) + } + if lines[1].Type != transcript.TypeAssistant { + t.Errorf("lines[1].Type = %q, want %q", lines[1].Type, transcript.TypeAssistant) + } +} + +func TestParseAutohandLine_Roles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantType string + wantOK bool + }{ + { + name: "user role", + input: `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}`, + wantType: transcript.TypeUser, + wantOK: true, + }, + { + name: "assistant role", + input: `{"role":"assistant","content":"hi","timestamp":"2026-01-01T00:00:01.000Z"}`, + wantType: transcript.TypeAssistant, + wantOK: true, + }, + { + name: "tool role filtered", + input: `{"role":"tool","content":"result","name":"write_file","tool_call_id":"tc_1"}`, + wantOK: false, + }, + { + name: "system role filtered", + input: `{"role":"system","content":"system prompt"}`, + wantOK: false, + }, + { + name: "empty JSON", + input: `{}`, + wantOK: false, + }, + { + name: "invalid JSON", + input: `not json`, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + line, ok := parseAutohandLine([]byte(tt.input)) + if ok != tt.wantOK { + t.Errorf("parseAutohandLine() ok = %v, want %v", ok, tt.wantOK) + } + if ok && line.Type != tt.wantType { + t.Errorf("parseAutohandLine() type = %q, want %q", line.Type, tt.wantType) + } + }) + } +} + +func TestParseAutohandLine_TimestampAsUUID(t *testing.T) { + t.Parallel() + + input := []byte(`{"role":"user","content":"hello","timestamp":"2026-01-01T12:00:00.000Z"}`) + line, ok := parseAutohandLine(input) + if !ok { + t.Fatal("expected ok = true") + } + // Autohand uses timestamp as pseudo-UUID since there are no message IDs + if line.UUID != "2026-01-01T12:00:00.000Z" { + t.Errorf("UUID = %q, want %q (timestamp as UUID)", line.UUID, "2026-01-01T12:00:00.000Z") + } +} + +// --- ExtractModifiedFiles tests --- + +func TestExtractModifiedFiles_WriteFile(t *testing.T) { + t.Parallel() + + data := []byte(`{"role":"assistant","content":"creating file","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"write_file","input":{"path":"foo.ts","content":"..."}}]}` + "\n") + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 || files[0] != "foo.ts" { + t.Errorf("ExtractModifiedFiles() = %v, want [foo.ts]", files) + } +} + +func TestExtractModifiedFiles_EditFile(t *testing.T) { + t.Parallel() + + data := []byte(`{"role":"assistant","content":"editing file","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"edit_file","input":{"path":"bar.go","old":"x","new":"y"}}]}` + "\n") + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 || files[0] != "bar.go" { + t.Errorf("ExtractModifiedFiles() = %v, want [bar.go]", files) + } +} + +func TestExtractModifiedFiles_CreateFile(t *testing.T) { + t.Parallel() + + data := []byte(`{"role":"assistant","content":"creating file","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"create_file","input":{"path":"new.py","content":"..."}}]}` + "\n") + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 || files[0] != "new.py" { + t.Errorf("ExtractModifiedFiles() = %v, want [new.py]", files) + } +} + +func TestExtractModifiedFiles_PatchFile(t *testing.T) { + t.Parallel() + + data := []byte(`{"role":"assistant","content":"patching file","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"patch_file","input":{"path":"app.js","patch":"..."}}]}` + "\n") + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 || files[0] != "app.js" { + t.Errorf("ExtractModifiedFiles() = %v, want [app.js]", files) + } +} + +func TestExtractModifiedFiles_FileFieldFallback(t *testing.T) { + t.Parallel() + + // Some tools use "file" instead of "path" + data := []byte(`{"role":"assistant","content":"creating","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"write_file","input":{"file":"alt.txt","content":"..."}}]}` + "\n") + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 || files[0] != "alt.txt" { + t.Errorf("ExtractModifiedFiles() = %v, want [alt.txt]", files) + } +} + +func TestExtractModifiedFiles_MultipleToolCalls(t *testing.T) { + t.Parallel() + + data := []byte( + `{"role":"assistant","content":"creating files","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"write_file","input":{"path":"foo.go"}},{"id":"tc_2","name":"edit_file","input":{"path":"bar.go"}}]}` + "\n" + + `{"role":"assistant","content":"more","timestamp":"2026-01-01T00:00:01.000Z","toolCalls":[{"id":"tc_3","name":"create_file","input":{"path":"baz.go"}}]}` + "\n", + ) + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 3 { + t.Fatalf("ExtractModifiedFiles() got %d files, want 3", len(files)) + } + + hasFile := func(name string) bool { + for _, f := range files { + if f == name { + return true + } + } + return false + } + + if !hasFile("foo.go") { + t.Error("missing foo.go") + } + if !hasFile("bar.go") { + t.Error("missing bar.go") + } + if !hasFile("baz.go") { + t.Error("missing baz.go") + } +} + +func TestExtractModifiedFiles_Deduplication(t *testing.T) { + t.Parallel() + + data := []byte( + `{"role":"assistant","content":"first write","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"write_file","input":{"path":"same.go"}}]}` + "\n" + + `{"role":"assistant","content":"second write","timestamp":"2026-01-01T00:00:01.000Z","toolCalls":[{"id":"tc_2","name":"write_file","input":{"path":"same.go"}}]}` + "\n", + ) + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 1 { + t.Errorf("ExtractModifiedFiles() got %d files, want 1 (deduplicated)", len(files)) + } +} + +func TestExtractModifiedFiles_IgnoresNonFileTools(t *testing.T) { + t.Parallel() + + data := []byte( + `{"role":"assistant","content":"running command","timestamp":"2026-01-01T00:00:00.000Z","toolCalls":[{"id":"tc_1","name":"exec_command","input":{"command":"ls"}}]}` + "\n" + + `{"role":"assistant","content":"reading","timestamp":"2026-01-01T00:00:01.000Z","toolCalls":[{"id":"tc_2","name":"read_file","input":{"path":"readme.md"}}]}` + "\n", + ) + + lines, _, err := ParseAutohandTranscriptFromBytes(data, 0) + if err != nil { + t.Fatalf("parse error: %v", err) + } + files := ExtractModifiedFiles(lines) + + if len(files) != 0 { + t.Errorf("ExtractModifiedFiles() = %v, want empty (non-file tools should be ignored)", files) + } +} + +func TestExtractModifiedFiles_Empty(t *testing.T) { + t.Parallel() + + files := ExtractModifiedFiles(nil) + if files != nil { + t.Errorf("ExtractModifiedFiles(nil) = %v, want nil", files) + } +} + +// --- CalculateTokenUsage tests --- + +func TestCalculateTokenUsage_WithMeta(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeAssistant, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"first response"`), + Meta: map[string]any{ + "input_tokens": float64(100), + "output_tokens": float64(50), + "cache_creation_input_tokens": float64(200), + "cache_read_input_tokens": float64(30), + }, + }), + }, + { + Type: transcript.TypeAssistant, + UUID: "ts2", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"second response"`), + Meta: map[string]any{ + "input_tokens": float64(50), + "output_tokens": float64(25), + }, + }), + }, + } + + usage := CalculateTokenUsage(lines) + + if usage.APICallCount != 2 { + t.Errorf("APICallCount = %d, want 2", usage.APICallCount) + } + if usage.InputTokens != 150 { + t.Errorf("InputTokens = %d, want 150", usage.InputTokens) + } + if usage.OutputTokens != 75 { + t.Errorf("OutputTokens = %d, want 75", usage.OutputTokens) + } + if usage.CacheCreationTokens != 200 { + t.Errorf("CacheCreationTokens = %d, want 200", usage.CacheCreationTokens) + } + if usage.CacheReadTokens != 30 { + t.Errorf("CacheReadTokens = %d, want 30", usage.CacheReadTokens) + } +} + +func TestCalculateTokenUsage_NoMeta(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeAssistant, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"response without meta"`), + }), + }, + } + + usage := CalculateTokenUsage(lines) + + if usage.APICallCount != 0 { + t.Errorf("APICallCount = %d, want 0 (no _meta means no API call counted)", usage.APICallCount) + } + if usage.InputTokens != 0 { + t.Errorf("InputTokens = %d, want 0", usage.InputTokens) + } +} + +func TestCalculateTokenUsage_IgnoresUserMessages(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeUser, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{Role: "user", Content: json.RawMessage(`"hello"`)}), + }, + { + Type: transcript.TypeAssistant, + UUID: "ts2", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"response"`), + Meta: map[string]any{ + "input_tokens": float64(10), + "output_tokens": float64(20), + }, + }), + }, + } + + usage := CalculateTokenUsage(lines) + + if usage.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1", usage.APICallCount) + } +} + +func TestCalculateTokenUsageFromFile(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + content := + `{"role":"user","content":"hello","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"response","timestamp":"2026-01-01T00:00:01.000Z","_meta":{"input_tokens":100,"output_tokens":50}}` + "\n" + + `{"role":"user","content":"bye","timestamp":"2026-01-01T00:00:02.000Z"}` + "\n" + + `{"role":"assistant","content":"goodbye","timestamp":"2026-01-01T00:00:03.000Z","_meta":{"input_tokens":200,"output_tokens":100}}` + "\n" + + if err := os.WriteFile(transcriptPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + // Test from line 0 - all turns + usage1, err := CalculateTokenUsageFromFile(transcriptPath, 0) + if err != nil { + t.Fatalf("CalculateTokenUsageFromFile(0) error: %v", err) + } + if usage1.InputTokens != 300 { + t.Errorf("From line 0: InputTokens = %d, want 300", usage1.InputTokens) + } + if usage1.OutputTokens != 150 { + t.Errorf("From line 0: OutputTokens = %d, want 150", usage1.OutputTokens) + } + if usage1.APICallCount != 2 { + t.Errorf("From line 0: APICallCount = %d, want 2", usage1.APICallCount) + } + + // Test from line 2 - second turn only + usage2, err := CalculateTokenUsageFromFile(transcriptPath, 2) + if err != nil { + t.Fatalf("CalculateTokenUsageFromFile(2) error: %v", err) + } + if usage2.InputTokens != 200 { + t.Errorf("From line 2: InputTokens = %d, want 200", usage2.InputTokens) + } + if usage2.OutputTokens != 100 { + t.Errorf("From line 2: OutputTokens = %d, want 100", usage2.OutputTokens) + } + if usage2.APICallCount != 1 { + t.Errorf("From line 2: APICallCount = %d, want 1", usage2.APICallCount) + } +} + +func TestCalculateTokenUsageFromFile_EmptyPath(t *testing.T) { + t.Parallel() + + usage, err := CalculateTokenUsageFromFile("", 0) + if err != nil { + t.Fatalf("CalculateTokenUsageFromFile('') error: %v", err) + } + if usage.APICallCount != 0 { + t.Errorf("APICallCount = %d, want 0", usage.APICallCount) + } +} + +// --- ExtractSpawnedAgentIDs tests --- + +func TestExtractSpawnedAgentIDs_FromTaskCalls(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeAssistant, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"spawning agent"`), + ToolCalls: []autohandToolCall{ + {ID: "agent-abc", Name: "Task", Input: json.RawMessage(`{"prompt":"do something"}`)}, + }, + }), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 1 { + t.Fatalf("Expected 1 agent ID, got %d", len(agentIDs)) + } + if _, ok := agentIDs["agent-abc"]; !ok { + t.Errorf("Expected agent ID 'agent-abc', got %v", agentIDs) + } +} + +func TestExtractSpawnedAgentIDs_LowercaseTask(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeAssistant, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"spawning"`), + ToolCalls: []autohandToolCall{ + {ID: "agent-def", Name: "task", Input: json.RawMessage(`{"prompt":"hello"}`)}, + }, + }), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 1 { + t.Fatalf("Expected 1 agent ID, got %d", len(agentIDs)) + } + if _, ok := agentIDs["agent-def"]; !ok { + t.Errorf("Expected agent ID 'agent-def', got %v", agentIDs) + } +} + +func TestExtractSpawnedAgentIDs_MultipleAgents(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeAssistant, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"first task"`), + ToolCalls: []autohandToolCall{ + {ID: "agent-1", Name: "Task", Input: json.RawMessage(`{}`)}, + }, + }), + }, + { + Type: transcript.TypeAssistant, + UUID: "ts2", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"second task"`), + ToolCalls: []autohandToolCall{ + {ID: "agent-2", Name: "Task", Input: json.RawMessage(`{}`)}, + }, + }), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 2 { + t.Fatalf("Expected 2 agent IDs, got %d", len(agentIDs)) + } +} + +func TestExtractSpawnedAgentIDs_NoTaskCalls(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeAssistant, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"just writing"`), + ToolCalls: []autohandToolCall{ + {ID: "tc_1", Name: "write_file", Input: json.RawMessage(`{"path":"foo.go"}`)}, + }, + }), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 0 { + t.Errorf("Expected 0 agent IDs, got %d: %v", len(agentIDs), agentIDs) + } +} + +func TestExtractSpawnedAgentIDs_IgnoresUserMessages(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + { + Type: transcript.TypeUser, + UUID: "ts1", + Message: mustMarshal(t, autohandMessage{Role: "user", Content: json.RawMessage(`"hello"`)}), + }, + } + + agentIDs := ExtractSpawnedAgentIDs(lines) + + if len(agentIDs) != 0 { + t.Errorf("Expected 0 agent IDs, got %d", len(agentIDs)) + } +} + +// --- ExtractAllModifiedFilesFromBytes tests --- + +func TestExtractAllModifiedFilesFromBytes_IncludesSubagentFiles(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + subagentsDir := tmpDir + "/tasks" + + if err := os.MkdirAll(subagentsDir, 0o755); err != nil { + t.Fatalf("failed to create subagents dir: %v", err) + } + + // Main transcript: Write to main.go + Task call spawning subagent + mainTranscript := makeAutohandWriteToolLine(t, "2026-01-01T00:00:00.000Z", "/repo/main.go") + "\n" + + makeAutohandTaskToolLine(t, "2026-01-01T00:00:01.000Z", "sub1") + "\n" + + // Subagent transcript: Write to helper.go + Edit to utils.go + subTranscript := makeAutohandWriteToolLine(t, "2026-01-01T00:00:02.000Z", "/repo/helper.go") + "\n" + + makeAutohandEditToolLine(t, "2026-01-01T00:00:03.000Z", "/repo/utils.go") + "\n" + if err := os.WriteFile(subagentsDir+"/agent-sub1.jsonl", []byte(subTranscript), 0o600); err != nil { + t.Fatalf("failed to write subagent transcript: %v", err) + } + + files, err := ExtractAllModifiedFilesFromBytes([]byte(mainTranscript), 0, subagentsDir) + if err != nil { + t.Fatalf("ExtractAllModifiedFilesFromBytes() error: %v", err) + } + + if len(files) != 3 { + t.Errorf("expected 3 files, got %d: %v", len(files), files) + } + + wantFiles := map[string]bool{ + "/repo/main.go": true, + "/repo/helper.go": true, + "/repo/utils.go": true, + } + for _, f := range files { + if !wantFiles[f] { + t.Errorf("unexpected file %q in result", f) + } + delete(wantFiles, f) + } + for f := range wantFiles { + t.Errorf("missing expected file %q", f) + } +} + +func TestExtractAllModifiedFilesFromBytes_DeduplicatesAcrossAgents(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + subagentsDir := tmpDir + "/tasks" + + if err := os.MkdirAll(subagentsDir, 0o755); err != nil { + t.Fatalf("failed to create subagents dir: %v", err) + } + + mainTranscript := makeAutohandWriteToolLine(t, "2026-01-01T00:00:00.000Z", "/repo/shared.go") + "\n" + + makeAutohandTaskToolLine(t, "2026-01-01T00:00:01.000Z", "sub1") + "\n" + + subTranscript := makeAutohandEditToolLine(t, "2026-01-01T00:00:02.000Z", "/repo/shared.go") + "\n" + if err := os.WriteFile(subagentsDir+"/agent-sub1.jsonl", []byte(subTranscript), 0o600); err != nil { + t.Fatalf("failed to write subagent transcript: %v", err) + } + + files, err := ExtractAllModifiedFilesFromBytes([]byte(mainTranscript), 0, subagentsDir) + if err != nil { + t.Fatalf("ExtractAllModifiedFilesFromBytes() error: %v", err) + } + + if len(files) != 1 { + t.Errorf("expected 1 file (deduplicated), got %d: %v", len(files), files) + } +} + +func TestExtractAllModifiedFilesFromBytes_NoSubagents(t *testing.T) { + t.Parallel() + + mainTranscript := makeAutohandWriteToolLine(t, "2026-01-01T00:00:00.000Z", "/repo/solo.go") + "\n" + + files, err := ExtractAllModifiedFilesFromBytes([]byte(mainTranscript), 0, "/nonexistent") + if err != nil { + t.Fatalf("ExtractAllModifiedFilesFromBytes() error: %v", err) + } + + if len(files) != 1 || files[0] != "/repo/solo.go" { + t.Errorf("expected [/repo/solo.go], got %v", files) + } +} + +func TestExtractAllModifiedFilesFromBytes_EmptyData(t *testing.T) { + t.Parallel() + + files, err := ExtractAllModifiedFilesFromBytes(nil, 0, "") + if err != nil { + t.Fatalf("ExtractAllModifiedFilesFromBytes() error: %v", err) + } + if files != nil { + t.Errorf("expected nil, got %v", files) + } +} + +// --- CalculateTotalTokenUsageFromBytes tests --- + +func TestCalculateTotalTokenUsageFromBytes_WithSubagents(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + subagentsDir := tmpDir + "/tasks" + + if err := os.MkdirAll(subagentsDir, 0o755); err != nil { + t.Fatalf("failed to create subagents dir: %v", err) + } + + mainTranscript := makeAutohandTokenLine(t, "2026-01-01T00:00:00.000Z", 100, 50) + "\n" + + makeAutohandTaskToolLine(t, "2026-01-01T00:00:01.000Z", "sub1") + "\n" + + subTranscript := makeAutohandTokenLine(t, "2026-01-01T00:00:02.000Z", 200, 80) + "\n" + + makeAutohandTokenLine(t, "2026-01-01T00:00:03.000Z", 150, 60) + "\n" + if err := os.WriteFile(subagentsDir+"/agent-sub1.jsonl", []byte(subTranscript), 0o600); err != nil { + t.Fatalf("failed to write subagent transcript: %v", err) + } + + usage, err := CalculateTotalTokenUsageFromBytes([]byte(mainTranscript), 0, subagentsDir) + if err != nil { + t.Fatalf("CalculateTotalTokenUsageFromBytes() error: %v", err) + } + + // Main agent tokens + if usage.InputTokens != 100 { + t.Errorf("main InputTokens = %d, want 100", usage.InputTokens) + } + if usage.OutputTokens != 50 { + t.Errorf("main OutputTokens = %d, want 50", usage.OutputTokens) + } + if usage.APICallCount != 1 { + t.Errorf("main APICallCount = %d, want 1", usage.APICallCount) + } + + // Subagent tokens + if usage.SubagentTokens == nil { + t.Fatal("SubagentTokens is nil") + } + if usage.SubagentTokens.InputTokens != 350 { + t.Errorf("subagent InputTokens = %d, want 350 (200+150)", usage.SubagentTokens.InputTokens) + } + if usage.SubagentTokens.OutputTokens != 140 { + t.Errorf("subagent OutputTokens = %d, want 140 (80+60)", usage.SubagentTokens.OutputTokens) + } + if usage.SubagentTokens.APICallCount != 2 { + t.Errorf("subagent APICallCount = %d, want 2", usage.SubagentTokens.APICallCount) + } +} + +func TestCalculateTotalTokenUsageFromBytes_EmptyData(t *testing.T) { + t.Parallel() + + usage, err := CalculateTotalTokenUsageFromBytes(nil, 0, "") + if err != nil { + t.Fatalf("CalculateTotalTokenUsageFromBytes() error: %v", err) + } + if usage.APICallCount != 0 { + t.Errorf("APICallCount = %d, want 0", usage.APICallCount) + } +} + +// --- ExtractPrompts tests --- + +func TestExtractPrompts(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + content := + `{"role":"user","content":"Fix the login bug","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"I'll fix the bug.","timestamp":"2026-01-01T00:00:01.000Z"}` + "\n" + + `{"role":"user","content":"Now add tests","timestamp":"2026-01-01T00:00:02.000Z"}` + "\n" + + if err := os.WriteFile(transcriptPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &AutohandCodeAgent{} + prompts, err := ag.ExtractPrompts(transcriptPath, 0) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + + if len(prompts) != 2 { + t.Fatalf("ExtractPrompts() got %d prompts, want 2", len(prompts)) + } + if prompts[0] != "Fix the login bug" { + t.Errorf("prompts[0] = %q, want %q", prompts[0], "Fix the login bug") + } + if prompts[1] != "Now add tests" { + t.Errorf("prompts[1] = %q, want %q", prompts[1], "Now add tests") + } +} + +func TestExtractPrompts_WithOffset(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + content := + `{"role":"user","content":"First prompt","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"Done.","timestamp":"2026-01-01T00:00:01.000Z"}` + "\n" + + `{"role":"user","content":"Second prompt","timestamp":"2026-01-01T00:00:02.000Z"}` + "\n" + + `{"role":"assistant","content":"Done again.","timestamp":"2026-01-01T00:00:03.000Z"}` + "\n" + + if err := os.WriteFile(transcriptPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &AutohandCodeAgent{} + // Skip first 2 lines (first user+assistant turn) + prompts, err := ag.ExtractPrompts(transcriptPath, 2) + if err != nil { + t.Fatalf("ExtractPrompts() error = %v", err) + } + + if len(prompts) != 1 { + t.Fatalf("ExtractPrompts() got %d prompts, want 1", len(prompts)) + } + if prompts[0] != "Second prompt" { + t.Errorf("prompts[0] = %q, want %q", prompts[0], "Second prompt") + } +} + +// --- ExtractSummary tests --- + +func TestExtractSummary(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + + content := + `{"role":"user","content":"Fix the bug","timestamp":"2026-01-01T00:00:00.000Z"}` + "\n" + + `{"role":"assistant","content":"Working on it...","timestamp":"2026-01-01T00:00:01.000Z"}` + "\n" + + `{"role":"user","content":"Thanks","timestamp":"2026-01-01T00:00:02.000Z"}` + "\n" + + `{"role":"assistant","content":"All done! The bug is fixed.","timestamp":"2026-01-01T00:00:03.000Z"}` + "\n" + + if err := os.WriteFile(transcriptPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &AutohandCodeAgent{} + summary, err := ag.ExtractSummary(transcriptPath) + if err != nil { + t.Fatalf("ExtractSummary() error = %v", err) + } + + if summary != "All done! The bug is fixed." { + t.Errorf("ExtractSummary() = %q, want %q", summary, "All done! The bug is fixed.") + } +} + +func TestExtractSummary_EmptyTranscript(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + transcriptPath := tmpDir + "/transcript.jsonl" + if err := os.WriteFile(transcriptPath, []byte(""), 0o600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + ag := &AutohandCodeAgent{} + summary, err := ag.ExtractSummary(transcriptPath) + if err != nil { + t.Fatalf("ExtractSummary() error = %v", err) + } + + if summary != "" { + t.Errorf("ExtractSummary() = %q, want empty string", summary) + } +} + +// --- Test helpers --- + +// mustMarshal is a test helper that marshals a value to JSON or fails the test. +func mustMarshal(t *testing.T, v interface{}) []byte { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + return data +} + +// makeAutohandWriteToolLine returns an Autohand-format JSONL line with a write_file tool call. +func makeAutohandWriteToolLine(t *testing.T, timestamp, filePath string) string { + t.Helper() + msg := autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"creating file"`), + Timestamp: timestamp, + ToolCalls: []autohandToolCall{ + { + ID: "tc_" + timestamp, + Name: ToolWriteFile, + Input: json.RawMessage(`{"path":"` + filePath + `","content":"..."}`), + }, + }, + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + return string(data) +} + +// makeAutohandEditToolLine returns an Autohand-format JSONL line with an edit_file tool call. +func makeAutohandEditToolLine(t *testing.T, timestamp, filePath string) string { + t.Helper() + msg := autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"editing file"`), + Timestamp: timestamp, + ToolCalls: []autohandToolCall{ + { + ID: "tc_" + timestamp, + Name: ToolEditFile, + Input: json.RawMessage(`{"path":"` + filePath + `","old":"x","new":"y"}`), + }, + }, + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + return string(data) +} + +// makeAutohandTaskToolLine returns an Autohand-format JSONL line with a Task tool call. +func makeAutohandTaskToolLine(t *testing.T, timestamp, agentID string) string { + t.Helper() + msg := autohandMessage{ + Role: "assistant", + Content: json.RawMessage(`"spawning subagent"`), + Timestamp: timestamp, + ToolCalls: []autohandToolCall{ + { + ID: agentID, + Name: "Task", + Input: json.RawMessage(`{"prompt":"do something"}`), + }, + }, + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + return string(data) +} + +// makeAutohandTokenLine returns an Autohand-format JSONL line with _meta token data. +func makeAutohandTokenLine(t *testing.T, timestamp string, inputTokens, outputTokens int) string { + t.Helper() + + // Build the _meta map with proper typing for JSON marshal/unmarshal + meta := map[string]any{ + "input_tokens": inputTokens, + "output_tokens": outputTokens, + } + + msg := map[string]any{ + "role": "assistant", + "content": "response", + "timestamp": timestamp, + "_meta": meta, + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + return string(data) +} + +// writeJSONLFile is a test helper that writes JSONL transcript lines to a file. +func writeJSONLFile(t *testing.T, path string, lines ...string) { + t.Helper() + var buf strings.Builder + for _, line := range lines { + buf.WriteString(line) + buf.WriteByte('\n') + } + if err := os.WriteFile(path, []byte(buf.String()), 0o600); err != nil { + t.Fatalf("failed to write JSONL file %s: %v", path, err) + } +} diff --git a/cmd/entire/cli/agent/autohandcode/types.go b/cmd/entire/cli/agent/autohandcode/types.go new file mode 100644 index 000000000..a60779ec8 --- /dev/null +++ b/cmd/entire/cli/agent/autohandcode/types.go @@ -0,0 +1,139 @@ +package autohandcode + +import "encoding/json" + +// AutohandConfig represents the .autohand/config.json structure. +type AutohandConfig struct { + Hooks AutohandHooksSettings `json:"hooks"` +} + +// AutohandHooksSettings contains the hooks configuration. +type AutohandHooksSettings struct { + Enabled *bool `json:"enabled,omitempty"` + Hooks []AutohandHookDef `json:"hooks,omitempty"` +} + +// AutohandHookDef represents a single hook definition. +type AutohandHookDef struct { + Event string `json:"event"` + Command string `json:"command"` + Description string `json:"description,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Timeout int `json:"timeout,omitempty"` + Async bool `json:"async,omitempty"` + Matcher string `json:"matcher,omitempty"` + Filter *AutohandFilter `json:"filter,omitempty"` +} + +// AutohandFilter limits when a hook fires. +type AutohandFilter struct { + Tool []string `json:"tool,omitempty"` + Path []string `json:"path,omitempty"` +} + +// AutohandPermissionRule represents a permission rule in config. +type AutohandPermissionRule struct { + Tool string `json:"tool"` + Pattern string `json:"pattern,omitempty"` + Action string `json:"action"` +} + +// hookInputBase is the JSON structure piped to hook stdin for all events. +type hookInputBase struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` +} + +// hookInputPrompt extends hookInputBase for pre-prompt events. +type hookInputPrompt struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + Instruction string `json:"instruction"` + MentionedFiles []string `json:"mentioned_files"` +} + +// hookInputStop extends hookInputBase for stop/post-response events. +type hookInputStop struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + TokensUsed int `json:"tokens_used"` + ToolCallsCount int `json:"tool_calls_count"` + TurnToolCalls int `json:"turn_tool_calls"` + TurnDuration int `json:"turn_duration"` + Duration int `json:"duration"` +} + +// hookInputTool extends hookInputBase for pre-tool/post-tool events. +type hookInputTool struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` + ToolUseID string `json:"tool_use_id"` + ToolResponse string `json:"tool_response"` + ToolSuccess *bool `json:"tool_success"` +} + +// hookInputSubagent extends hookInputBase for subagent-stop events. +type hookInputSubagent struct { + SessionID string `json:"session_id"` + CWD string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + SubagentID string `json:"subagent_id"` + SubagentName string `json:"subagent_name"` + SubagentType string `json:"subagent_type"` + SubagentSuccess *bool `json:"subagent_success"` + SubagentError string `json:"subagent_error"` + SubagentDuration int `json:"subagent_duration"` +} + +// Tool names used in Autohand Code transcripts for file modification. +const ( + ToolWriteFile = "write_file" + ToolEditFile = "edit_file" + ToolCreateFile = "create_file" + ToolPatchFile = "patch_file" +) + +// FileModificationTools lists tools that create or modify files. +var FileModificationTools = []string{ + ToolWriteFile, + ToolEditFile, + ToolCreateFile, + ToolPatchFile, +} + +// autohandToolCall represents a tool call in the Autohand transcript format. +type autohandToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` +} + +// autohandToolInput represents the input to a file modification tool. +type autohandToolInput struct { + Path string `json:"path"` + File string `json:"file"` +} + +// autohandMessage represents a single JSONL line in the Autohand transcript. +type autohandMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Timestamp string `json:"timestamp"` + ToolCalls []autohandToolCall `json:"toolCalls,omitempty"` + Name string `json:"name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Meta map[string]any `json:"_meta,omitempty"` +} + +// autohandTokenMeta is extracted from _meta for token tracking. +type autohandTokenMeta struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 098be93a1..f020adaa6 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -101,6 +101,7 @@ const ( AgentNameGemini types.AgentName = "gemini" AgentNameOpenCode types.AgentName = "opencode" AgentNameFactoryAIDroid types.AgentName = "factoryai-droid" + AgentNameAutohandCode types.AgentName = "autohand-code" ) // Agent type constants (type identifiers stored in metadata/trailers) @@ -110,6 +111,7 @@ const ( AgentTypeGemini types.AgentType = "Gemini CLI" AgentTypeOpenCode types.AgentType = "OpenCode" AgentTypeFactoryAIDroid types.AgentType = "Factory AI Droid" + AgentTypeAutohandCode types.AgentType = "Autohand Code" AgentTypeUnknown types.AgentType = "Agent" // Fallback for backwards compatibility ) diff --git a/cmd/entire/cli/config.go b/cmd/entire/cli/config.go index 21fcba405..1d2dc1dcb 100644 --- a/cmd/entire/cli/config.go +++ b/cmd/entire/cli/config.go @@ -11,6 +11,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/strategy" // Import agents to register them + _ "github.com/entireio/cli/cmd/entire/cli/agent/autohandcode" _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" _ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" ) diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index 7611a1945..00433a32e 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -3,6 +3,7 @@ package cli import ( "github.com/entireio/cli/cmd/entire/cli/agent" // Import agents to ensure they are registered before we iterate + _ "github.com/entireio/cli/cmd/entire/cli/agent/autohandcode" _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" _ "github.com/entireio/cli/cmd/entire/cli/agent/cursor" _ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" diff --git a/cmd/entire/cli/integration_test/setup_autohand_hooks_test.go b/cmd/entire/cli/integration_test/setup_autohand_hooks_test.go new file mode 100644 index 000000000..4820ebc7a --- /dev/null +++ b/cmd/entire/cli/integration_test/setup_autohand_hooks_test.go @@ -0,0 +1,173 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent/autohandcode" +) + +// Use the real Autohand types to avoid schema drift. +type AutohandConfig = autohandcode.AutohandConfig + +// TestSetupAutohandHooks_AddsAllRequiredHooks is a smoke test verifying that +// `entire enable --agent autohand-code` adds all required hooks to the correct file. +func TestSetupAutohandHooks_AddsAllRequiredHooks(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + env.InitEntire() + + // Create initial commit (required for setup) + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + + // Run entire enable --agent autohand-code (non-interactive) + output, err := env.RunCLIWithError("enable", "--agent", "autohand-code") + if err != nil { + t.Fatalf("enable autohand-code command failed: %v\nOutput: %s", err, output) + } + + // Read the generated config.json + config := readAutohandConfigFile(t, env) + + // Verify all hooks exist (5 total) + expectedEvents := map[string]bool{ + "session-start": false, + "pre-prompt": false, + "stop": false, + "session-end": false, + "subagent-stop": false, + } + for _, hook := range config.Hooks.Hooks { + if _, ok := expectedEvents[hook.Event]; ok { + if strings.Contains(hook.Command, "entire hooks autohand-code") { + expectedEvents[hook.Event] = true + } + } + } + for event, found := range expectedEvents { + if !found { + t.Errorf("%s hook should exist", event) + } + } + + // Verify permissions.rules contains metadata deny rule + configPath := filepath.Join(env.RepoDir, ".autohand", autohandcode.AutohandConfigFileName) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config.json: %v", err) + } + content := string(data) + if !strings.Contains(content, ".entire/metadata/**") { + t.Error("config.json should contain permission deny rule for .entire/metadata/**") + } +} + +// TestSetupAutohandHooks_PreservesExistingSettings verifies that +// enable autohand-code doesn't nuke existing settings or user-configured hooks. +func TestSetupAutohandHooks_PreservesExistingSettings(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + env.InitEntire() + + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + + // Create existing config with custom fields and user hooks + autohandDir := filepath.Join(env.RepoDir, ".autohand") + if err := os.MkdirAll(autohandDir, 0o755); err != nil { + t.Fatalf("failed to create .autohand dir: %v", err) + } + + existingConfig := `{ + "customSetting": "should-be-preserved", + "hooks": { + "enabled": true, + "hooks": [ + {"event": "stop", "command": "echo user-stop-hook", "description": "My custom hook"} + ] + } +}` + configPath := filepath.Join(autohandDir, autohandcode.AutohandConfigFileName) + if err := os.WriteFile(configPath, []byte(existingConfig), 0o644); err != nil { + t.Fatalf("failed to write existing config: %v", err) + } + + // Run enable autohand-code + output, err := env.RunCLIWithError("enable", "--agent", "autohand-code") + if err != nil { + t.Fatalf("enable autohand-code failed: %v\nOutput: %s", err, output) + } + + // Verify custom setting is preserved + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config.json: %v", err) + } + + var rawConfig map[string]interface{} + if err := json.Unmarshal(data, &rawConfig); err != nil { + t.Fatalf("failed to parse config.json: %v", err) + } + + if rawConfig["customSetting"] != "should-be-preserved" { + t.Error("customSetting should be preserved after enable autohand-code") + } + + // Verify user hooks are preserved + config := readAutohandConfigFile(t, env) + + foundUserHook := false + for _, hook := range config.Hooks.Hooks { + if hook.Command == "echo user-stop-hook" { + foundUserHook = true + } + } + if !foundUserHook { + t.Error("existing user hook 'echo user-stop-hook' should be preserved") + } + + // Our hooks should also be added + foundSessionStart := false + foundPrePrompt := false + for _, hook := range config.Hooks.Hooks { + if hook.Event == "session-start" && strings.Contains(hook.Command, "entire hooks autohand-code") { + foundSessionStart = true + } + if hook.Event == "pre-prompt" && strings.Contains(hook.Command, "entire hooks autohand-code") { + foundPrePrompt = true + } + } + if !foundSessionStart { + t.Error("session-start hook should be added") + } + if !foundPrePrompt { + t.Error("pre-prompt hook should be added") + } +} + +// Helper functions + +func readAutohandConfigFile(t *testing.T, env *TestEnv) AutohandConfig { + t.Helper() + configPath := filepath.Join(env.RepoDir, ".autohand", autohandcode.AutohandConfigFileName) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read %s at %s: %v", autohandcode.AutohandConfigFileName, configPath, err) + } + + var config AutohandConfig + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("failed to parse config.json: %v", err) + } + return config +}