Skip to content

Commit 0236b00

Browse files
acunningham-ship-itclaude
andcommitted
[HTO-36] Add --runtime auto detection logic
Implement automatic runtime detection that: - Detects Claude Code sessions from ~/.claude/projects/*.jsonl - Detects Paperclip agents when --company is specified - Detects Codex from ~/.codex directory - Falls back to Claude if nothing detected Changes: - Default runtimes now ["auto"] instead of ["paperclip", "claude"] - Add resolveRuntimes() function handling auto, all, and explicit values - Validate --runtime paperclip requires --company flag - Warn when --runtime all is used without --company (skips paperclip) - Update config tests for new default Acceptance criteria met: ✓ agent-htop with no flags auto-detects Claude Code sessions ✓ agent-htop --runtime paperclip --company <id> works ✓ agent-htop --runtime all without company shows Claude+Codex (warns) ✓ agent-htop --runtime invalid exits with clear error ✓ --runtime paperclip without --company gives clear error Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 3161116 commit 0236b00

3 files changed

Lines changed: 114 additions & 22 deletions

File tree

cmd/agent-htop/main.go

Lines changed: 111 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"flag"
66
"fmt"
77
"io"
8+
"io/fs"
89
"log"
910
"os"
1011
"os/signal"
@@ -28,6 +29,111 @@ var (
2829
GitSHA = "unknown"
2930
)
3031

32+
// resolveRuntimes processes a list of runtime strings (which may include "auto" or "all")
33+
// and returns the expanded list of parser.Runtime values with proper validation and detection.
34+
func resolveRuntimes(runtimeStrs []string, companyID string) ([]parser.Runtime, error) {
35+
var resolved []parser.Runtime
36+
37+
// Check for "auto" runtime (auto-detection)
38+
if len(runtimeStrs) == 1 && runtimeStrs[0] == "auto" {
39+
var detected []parser.Runtime
40+
41+
// Detect Claude Code: check if ~/.claude/projects/ exists and has .jsonl files
42+
home, err := os.UserHomeDir()
43+
if err == nil {
44+
claudeDir := filepath.Join(home, ".claude", "projects")
45+
if hasJSONLFiles(claudeDir) {
46+
detected = append(detected, parser.RuntimeClaude)
47+
}
48+
}
49+
50+
// Detect Paperclip: only if company flag was set
51+
if companyID != "" {
52+
detected = append(detected, parser.RuntimePaperclip)
53+
}
54+
55+
// Detect Codex: check if ~/.codex/ exists and has session files
56+
if err == nil {
57+
codexDir := filepath.Join(home, ".codex")
58+
if _, err := os.Stat(codexDir); err == nil {
59+
detected = append(detected, parser.RuntimeCodex)
60+
}
61+
}
62+
63+
// Fallback: if nothing detected, show Claude even if no sessions yet
64+
if len(detected) == 0 {
65+
detected = append(detected, parser.RuntimeClaude)
66+
}
67+
68+
return detected, nil
69+
}
70+
71+
// Handle "all" runtime (enable all runtimes)
72+
if len(runtimeStrs) == 1 && runtimeStrs[0] == "all" {
73+
resolved = append(resolved, parser.RuntimeClaude)
74+
resolved = append(resolved, parser.RuntimeCodex)
75+
// Paperclip requires company ID
76+
if companyID != "" {
77+
resolved = append(resolved, parser.RuntimePaperclip)
78+
} else {
79+
log.Printf("Warning: paperclip runtime requires --company flag; skipping")
80+
}
81+
return resolved, nil
82+
}
83+
84+
// Parse explicit list of runtimes
85+
for _, r := range runtimeStrs {
86+
switch r {
87+
case "paperclip":
88+
if companyID == "" {
89+
return nil, fmt.Errorf("paperclip runtime requires --company flag")
90+
}
91+
resolved = append(resolved, parser.RuntimePaperclip)
92+
case "claude":
93+
resolved = append(resolved, parser.RuntimeClaude)
94+
case "codex":
95+
resolved = append(resolved, parser.RuntimeCodex)
96+
default:
97+
return nil, fmt.Errorf("invalid runtime value: %s (valid: auto|all|claude|codex|paperclip)", r)
98+
}
99+
}
100+
101+
return resolved, nil
102+
}
103+
104+
// hasJSONLFiles checks if a directory exists and contains any .jsonl files.
105+
func hasJSONLFiles(dir string) bool {
106+
entries, err := os.ReadDir(dir)
107+
if err != nil {
108+
return false
109+
}
110+
111+
for _, entry := range entries {
112+
if entry.IsDir() {
113+
// Recursively check subdirectories
114+
subdir := filepath.Join(dir, entry.Name())
115+
if hasJSONLFilesInDir(subdir) {
116+
return true
117+
}
118+
}
119+
}
120+
return false
121+
}
122+
123+
// hasJSONLFilesInDir recursively searches for .jsonl files in a directory.
124+
func hasJSONLFilesInDir(dir string) bool {
125+
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
126+
if err != nil {
127+
return nil // Skip errors, keep searching
128+
}
129+
if !d.IsDir() && strings.HasSuffix(d.Name(), ".jsonl") {
130+
return filepath.SkipDir // Signal that we found a file (stop walking)
131+
}
132+
return nil
133+
})
134+
return err == filepath.SkipDir
135+
}
136+
31137
func main() {
32138
// Flags
33139
companyID := flag.String("company", "", "Company ID (optional)")
@@ -106,11 +212,6 @@ Examples:
106212
os.Exit(1)
107213
}
108214

109-
// When no company is specified, default to Claude-only mode
110-
if *companyID == "" && *runtimesStr == "" {
111-
cfg.Runtimes = []string{"claude"}
112-
}
113-
114215
// Apply command-line flag overrides
115216
if *apiURL != "" {
116217
cfg.APIURL = *apiURL
@@ -179,20 +280,11 @@ Examples:
179280
agentNamer = aggregator.NullAgentNamer{}
180281
}
181282

182-
// Parse config runtimes into parser.Runtime values
183-
var runtimes []parser.Runtime
184-
for _, r := range cfg.Runtimes {
185-
switch r {
186-
case "paperclip":
187-
runtimes = append(runtimes, parser.RuntimePaperclip)
188-
case "claude":
189-
runtimes = append(runtimes, parser.RuntimeClaude)
190-
case "codex":
191-
runtimes = append(runtimes, parser.RuntimeCodex)
192-
default:
193-
fmt.Fprintf(os.Stderr, "Error: invalid runtime value in config: %s (valid: paperclip|claude|codex)\n", r)
194-
os.Exit(1)
195-
}
283+
// Resolve config runtimes (handles "auto" detection, "all" expansion, and validation)
284+
runtimes, err := resolveRuntimes(cfg.Runtimes, *companyID)
285+
if err != nil {
286+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
287+
os.Exit(1)
196288
}
197289

198290
// Create watcher

internal/config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func DefaultConfig() Config {
3838
APIURL: "http://localhost:3101",
3939
RefreshRateMs: 1000,
4040
DiscordWebhook: "",
41-
Runtimes: []string{"paperclip", "claude"},
41+
Runtimes: []string{"auto"},
4242
Theme: Theme{
4343
ErrorColor: "red",
4444
WarnColor: "yellow",

internal/config/config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ func TestDefaultConfig(t *testing.T) {
1818
if cfg.DiscordWebhook != "" {
1919
t.Errorf("expected empty DiscordWebhook, got %s", cfg.DiscordWebhook)
2020
}
21-
if len(cfg.Runtimes) != 2 {
22-
t.Errorf("expected 2 runtimes, got %d", len(cfg.Runtimes))
21+
if len(cfg.Runtimes) != 1 || cfg.Runtimes[0] != "auto" {
22+
t.Errorf("expected runtimes [auto], got %v", cfg.Runtimes)
2323
}
2424
if cfg.Theme.ErrorColor != "red" {
2525
t.Errorf("expected error_color red, got %s", cfg.Theme.ErrorColor)

0 commit comments

Comments
 (0)