Skip to content

Commit 0bf0eb9

Browse files
committed
feat: add trace lifecycle manager — hawk bundles, enables, and controls trace
- sessioncapture/trace_manager.go: IsInstalled, Enable, Disable, AutoSetup, Status - hawk brew formula will declare trace as dependency (auto-install) - AutoSetup called on session start: enables trace if installed but not active - Users can /trace-enable and /trace-disable from within hawk - trace remains standalone (works with any agent independently)
1 parent 4ab50eb commit 0bf0eb9

3 files changed

Lines changed: 287 additions & 0 deletions

File tree

sessioncapture/INSTALL.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# trace Integration with hawk
2+
3+
## How it works
4+
5+
When you install hawk via Homebrew, trace is automatically installed as a dependency:
6+
7+
```ruby
8+
# In GrayCodeAI/homebrew-tap/Formula/hawk.rb
9+
class Hawk < Formula
10+
depends_on "GrayCodeAI/tap/trace" # ← bundled automatically
11+
end
12+
```
13+
14+
## Automatic behavior
15+
16+
1. **Install hawk** → trace is installed alongside it
17+
2. **Run hawk in a git repo** → trace auto-enables (installs git hooks)
18+
3. **Every commit** → trace captures the session silently
19+
4. **No config needed** — zero setup for the user
20+
21+
## User controls (from within hawk)
22+
23+
```
24+
/trace-enable Enable session capture for this project
25+
/trace-disable Disable session capture for this project
26+
/trace-status Show current capture status
27+
```
28+
29+
Or from CLI:
30+
31+
```bash
32+
hawk config trace.enabled true # enable
33+
hawk config trace.enabled false # disable
34+
```
35+
36+
## Architecture
37+
38+
```
39+
brew install hawk
40+
└── also installs: trace (as dependency)
41+
42+
hawk starts session
43+
└── sessioncapture.AutoSetup()
44+
├── trace installed? YES
45+
├── trace enabled? NO → runs "trace enable --agent hawk"
46+
└── done (hooks installed, recording active)
47+
48+
hawk makes commits
49+
└── .git/hooks/post-commit (installed by trace)
50+
└── trace captures session → stores on shadow branch
51+
52+
User wants to disable:
53+
└── /trace-disable → runs "trace disable" → hooks removed
54+
```
55+
56+
## trace stays standalone
57+
58+
- trace has its own repo, its own binary, its own release cycle
59+
- trace works with ANY agent (Claude Code, Cursor, Codex, hawk)
60+
- hawk just manages trace's lifecycle as a convenience
61+
- Users can also install/manage trace independently

sessioncapture/trace_manager.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Package sessioncapture manages trace integration — auto-installing,
2+
// enabling, disabling, and checking status of trace for session recording.
3+
//
4+
// trace remains a standalone binary. hawk bundles it and manages its lifecycle.
5+
package sessioncapture
6+
7+
import (
8+
"fmt"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"strings"
13+
)
14+
15+
// TraceManager handles trace lifecycle from within hawk.
16+
type TraceManager struct {
17+
ProjectDir string
18+
}
19+
20+
// NewTraceManager creates a manager for the given project directory.
21+
func NewTraceManager(projectDir string) *TraceManager {
22+
return &TraceManager{ProjectDir: projectDir}
23+
}
24+
25+
// IsInstalled checks if the trace binary is available in PATH.
26+
func (tm *TraceManager) IsInstalled() bool {
27+
_, err := exec.LookPath("trace")
28+
return err == nil
29+
}
30+
31+
// IsEnabled checks if trace is enabled in the current project.
32+
func (tm *TraceManager) IsEnabled() bool {
33+
// trace stores config in .trace/ at the repo root
34+
settingsPath := filepath.Join(tm.ProjectDir, ".trace", "settings.json")
35+
_, err := os.Stat(settingsPath)
36+
return err == nil
37+
}
38+
39+
// Enable activates trace in the current project with hawk as the agent.
40+
func (tm *TraceManager) Enable() error {
41+
if !tm.IsInstalled() {
42+
return fmt.Errorf("trace is not installed — run: brew install GrayCodeAI/tap/trace")
43+
}
44+
45+
cmd := exec.Command("trace", "enable", "--agent", "hawk")
46+
cmd.Dir = tm.ProjectDir
47+
output, err := cmd.CombinedOutput()
48+
if err != nil {
49+
return fmt.Errorf("trace enable failed: %s\n%s", err, string(output))
50+
}
51+
return nil
52+
}
53+
54+
// Disable deactivates trace in the current project (removes hooks).
55+
func (tm *TraceManager) Disable() error {
56+
if !tm.IsInstalled() {
57+
return nil // nothing to disable
58+
}
59+
60+
cmd := exec.Command("trace", "disable")
61+
cmd.Dir = tm.ProjectDir
62+
output, err := cmd.CombinedOutput()
63+
if err != nil {
64+
return fmt.Errorf("trace disable failed: %s\n%s", err, string(output))
65+
}
66+
return nil
67+
}
68+
69+
// Status returns the current trace status for this project.
70+
func (tm *TraceManager) Status() string {
71+
if !tm.IsInstalled() {
72+
return "not installed"
73+
}
74+
if !tm.IsEnabled() {
75+
return "installed but not enabled"
76+
}
77+
78+
cmd := exec.Command("trace", "status")
79+
cmd.Dir = tm.ProjectDir
80+
output, err := cmd.CombinedOutput()
81+
if err != nil {
82+
return "enabled (status check failed)"
83+
}
84+
return strings.TrimSpace(string(output))
85+
}
86+
87+
// AutoSetup is called when hawk starts a session. It:
88+
// 1. Checks if trace is installed (bundled with hawk via brew)
89+
// 2. If installed but not enabled → enables it automatically
90+
// 3. If not installed → skips silently (hawk works fine without it)
91+
func (tm *TraceManager) AutoSetup() error {
92+
if !tm.IsInstalled() {
93+
// trace not available — hawk works fine without it
94+
return nil
95+
}
96+
if tm.IsEnabled() {
97+
// already enabled — nothing to do
98+
return nil
99+
}
100+
// Auto-enable trace for this project
101+
return tm.Enable()
102+
}
103+
104+
// FormatStatus returns a human-readable status line for hawk's UI.
105+
func (tm *TraceManager) FormatStatus() string {
106+
if !tm.IsInstalled() {
107+
return "Session capture: disabled (trace not installed)"
108+
}
109+
if !tm.IsEnabled() {
110+
return "Session capture: disabled (run /trace-enable to activate)"
111+
}
112+
return "Session capture: active (trace recording sessions)"
113+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package sessioncapture
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestIsInstalled(t *testing.T) {
10+
tm := NewTraceManager(".")
11+
// Just verify it doesn't panic — result depends on environment
12+
_ = tm.IsInstalled()
13+
}
14+
15+
func TestIsEnabled_NotEnabled(t *testing.T) {
16+
dir := t.TempDir()
17+
tm := NewTraceManager(dir)
18+
if tm.IsEnabled() {
19+
t.Error("expected not enabled in empty dir")
20+
}
21+
}
22+
23+
func TestIsEnabled_Enabled(t *testing.T) {
24+
dir := t.TempDir()
25+
// Create .trace/settings.json to simulate enabled state
26+
traceDir := filepath.Join(dir, ".trace")
27+
os.MkdirAll(traceDir, 0o755)
28+
os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(`{"enabled":true}`), 0o644)
29+
30+
tm := NewTraceManager(dir)
31+
if !tm.IsEnabled() {
32+
t.Error("expected enabled when .trace/settings.json exists")
33+
}
34+
}
35+
36+
func TestStatus_NotInstalled(t *testing.T) {
37+
// Use a PATH that won't have trace
38+
origPath := os.Getenv("PATH")
39+
os.Setenv("PATH", "/nonexistent")
40+
defer os.Setenv("PATH", origPath)
41+
42+
tm := NewTraceManager(".")
43+
status := tm.Status()
44+
if status != "not installed" {
45+
t.Errorf("expected 'not installed', got %q", status)
46+
}
47+
}
48+
49+
func TestStatus_InstalledNotEnabled(t *testing.T) {
50+
tm := NewTraceManager(t.TempDir())
51+
if !tm.IsInstalled() {
52+
t.Skip("trace not installed in this environment")
53+
}
54+
status := tm.Status()
55+
if status == "not installed" {
56+
t.Error("trace is installed but status says not installed")
57+
}
58+
}
59+
60+
func TestFormatStatus_NotInstalled(t *testing.T) {
61+
origPath := os.Getenv("PATH")
62+
os.Setenv("PATH", "/nonexistent")
63+
defer os.Setenv("PATH", origPath)
64+
65+
tm := NewTraceManager(".")
66+
result := tm.FormatStatus()
67+
if result != "Session capture: disabled (trace not installed)" {
68+
t.Errorf("unexpected format: %q", result)
69+
}
70+
}
71+
72+
func TestFormatStatus_Enabled(t *testing.T) {
73+
dir := t.TempDir()
74+
os.MkdirAll(filepath.Join(dir, ".trace"), 0o755)
75+
os.WriteFile(filepath.Join(dir, ".trace", "settings.json"), []byte(`{}`), 0o644)
76+
77+
tm := NewTraceManager(dir)
78+
if !tm.IsInstalled() {
79+
t.Skip("trace not installed")
80+
}
81+
result := tm.FormatStatus()
82+
if result != "Session capture: active (trace recording sessions)" {
83+
t.Errorf("unexpected: %q", result)
84+
}
85+
}
86+
87+
func TestAutoSetup_NoTrace(t *testing.T) {
88+
origPath := os.Getenv("PATH")
89+
os.Setenv("PATH", "/nonexistent")
90+
defer os.Setenv("PATH", origPath)
91+
92+
tm := NewTraceManager(t.TempDir())
93+
err := tm.AutoSetup()
94+
if err != nil {
95+
t.Errorf("AutoSetup should silently skip when trace not installed, got: %v", err)
96+
}
97+
}
98+
99+
func TestAutoSetup_AlreadyEnabled(t *testing.T) {
100+
dir := t.TempDir()
101+
os.MkdirAll(filepath.Join(dir, ".trace"), 0o755)
102+
os.WriteFile(filepath.Join(dir, ".trace", "settings.json"), []byte(`{}`), 0o644)
103+
104+
tm := NewTraceManager(dir)
105+
if !tm.IsInstalled() {
106+
t.Skip("trace not installed")
107+
}
108+
// Should be a no-op
109+
err := tm.AutoSetup()
110+
if err != nil {
111+
t.Errorf("AutoSetup should skip when already enabled, got: %v", err)
112+
}
113+
}

0 commit comments

Comments
 (0)