ralph-code 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ralph/__init__.py +20 -0
- ralph/__main__.py +34 -0
- ralph/app.py +1328 -0
- ralph/claude_runner.py +22 -0
- ralph/colors.py +183 -0
- ralph/config.py +227 -0
- ralph/git_manager.py +304 -0
- ralph/harness.py +393 -0
- ralph/harness_runner.py +972 -0
- ralph/prd_manager.py +348 -0
- ralph/schemas/ralph_tasks_schema.json +95 -0
- ralph/schemas/task_schema.json +92 -0
- ralph/spinner.py +287 -0
- ralph/storage.py +77 -0
- ralph/tasks.py +298 -0
- ralph/user_stories.py +283 -0
- ralph/workflow.py +1036 -0
- ralph_code-0.5.0.dist-info/METADATA +79 -0
- ralph_code-0.5.0.dist-info/RECORD +23 -0
- ralph_code-0.5.0.dist-info/WHEEL +5 -0
- ralph_code-0.5.0.dist-info/entry_points.txt +2 -0
- ralph_code-0.5.0.dist-info/licenses/LICENSE +21 -0
- ralph_code-0.5.0.dist-info/top_level.txt +1 -0
ralph/git_manager.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Git operations for ralph-coding application."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GitError(Exception):
|
|
8
|
+
"""Exception raised for git operation failures."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitManager:
|
|
13
|
+
"""Manages git operations for a project."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, project_dir: Path) -> None:
|
|
16
|
+
self.project_dir = project_dir
|
|
17
|
+
|
|
18
|
+
def _run_git(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
19
|
+
"""Run a git command in the project directory."""
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
["git", *args],
|
|
23
|
+
cwd=self.project_dir,
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
check=check,
|
|
27
|
+
)
|
|
28
|
+
return result
|
|
29
|
+
except subprocess.CalledProcessError as e:
|
|
30
|
+
raise GitError(f"Git command failed: git {' '.join(args)}\n{e.stderr}")
|
|
31
|
+
|
|
32
|
+
def is_git_repo(self) -> bool:
|
|
33
|
+
"""Check if the project directory is a git repository."""
|
|
34
|
+
result = self._run_git("rev-parse", "--is-inside-work-tree", check=False)
|
|
35
|
+
return result.returncode == 0
|
|
36
|
+
|
|
37
|
+
def init_repo(self) -> None:
|
|
38
|
+
"""Initialize a new git repository."""
|
|
39
|
+
if not self.is_git_repo():
|
|
40
|
+
self._run_git("init")
|
|
41
|
+
|
|
42
|
+
def get_current_branch(self) -> str:
|
|
43
|
+
"""Get the name of the current branch."""
|
|
44
|
+
result = self._run_git("branch", "--show-current")
|
|
45
|
+
return str(result.stdout).strip()
|
|
46
|
+
|
|
47
|
+
def branch_exists(self, branch_name: str) -> bool:
|
|
48
|
+
"""Check if a branch exists (local or remote)."""
|
|
49
|
+
# Check local branches
|
|
50
|
+
result = self._run_git("branch", "--list", branch_name, check=False)
|
|
51
|
+
if result.stdout.strip():
|
|
52
|
+
return True
|
|
53
|
+
# Check remote branches
|
|
54
|
+
result = self._run_git("branch", "-r", "--list", f"*/{branch_name}", check=False)
|
|
55
|
+
return bool(result.stdout.strip())
|
|
56
|
+
|
|
57
|
+
def create_branch(self, branch_name: str) -> None:
|
|
58
|
+
"""Create a new branch if it doesn't exist."""
|
|
59
|
+
if not self.branch_exists(branch_name):
|
|
60
|
+
self._run_git("branch", branch_name)
|
|
61
|
+
|
|
62
|
+
def _has_conflicting_branch(self, branch_name: str) -> str | None:
|
|
63
|
+
"""Check if there's a branch that conflicts with the given name.
|
|
64
|
+
|
|
65
|
+
Git can't have both 'foo' and 'foo/bar' as branch names because
|
|
66
|
+
they conflict in the refs namespace.
|
|
67
|
+
|
|
68
|
+
Returns the conflicting branch name if found, None otherwise.
|
|
69
|
+
"""
|
|
70
|
+
# Check if any existing branch is a prefix of the new branch
|
|
71
|
+
# e.g., 'ralph' conflicts with 'ralph/feature'
|
|
72
|
+
parts = branch_name.split("/")
|
|
73
|
+
for i in range(1, len(parts)):
|
|
74
|
+
prefix = "/".join(parts[:i])
|
|
75
|
+
if self.branch_exists(prefix):
|
|
76
|
+
return prefix
|
|
77
|
+
|
|
78
|
+
# Check if the new branch would be a prefix of existing branches
|
|
79
|
+
# e.g., 'ralph/feature' conflicts with existing 'ralph/feature/sub'
|
|
80
|
+
result = self._run_git("branch", "--list", f"{branch_name}/*", check=False)
|
|
81
|
+
if result.stdout.strip():
|
|
82
|
+
return branch_name
|
|
83
|
+
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def checkout_branch(self, branch_name: str) -> None:
|
|
87
|
+
"""Checkout a branch, creating it if necessary."""
|
|
88
|
+
if self.branch_exists(branch_name):
|
|
89
|
+
self._run_git("checkout", branch_name)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# Check for conflicting branch names before creating
|
|
93
|
+
conflict = self._has_conflicting_branch(branch_name)
|
|
94
|
+
if conflict:
|
|
95
|
+
# Check if this is the ralph prefix conflict
|
|
96
|
+
parts = branch_name.split("/")
|
|
97
|
+
if len(parts) > 1 and parts[0] == conflict:
|
|
98
|
+
raise GitError(
|
|
99
|
+
f"Cannot create branch '{branch_name}': a branch named '{conflict}' already exists.\n\n"
|
|
100
|
+
f"Git cannot have both '{conflict}' and '{branch_name}' as branch names.\n\n"
|
|
101
|
+
f"To fix this, rename the '{conflict}' branch:\n"
|
|
102
|
+
f" git branch -m {conflict} main\n\n"
|
|
103
|
+
f"Or change the branch prefix in Settings > Branch prefix."
|
|
104
|
+
)
|
|
105
|
+
raise GitError(
|
|
106
|
+
f"Cannot create branch '{branch_name}': conflicts with existing branch '{conflict}'. "
|
|
107
|
+
f"Git cannot have both '{conflict}' and '{branch_name}' as branch names."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
self._run_git("checkout", "-b", branch_name)
|
|
111
|
+
|
|
112
|
+
def ensure_on_branch(self, branch_name: str) -> None:
|
|
113
|
+
"""Ensure we're on the specified branch, creating it if needed."""
|
|
114
|
+
self.init_repo()
|
|
115
|
+
current = self.get_current_branch()
|
|
116
|
+
if current != branch_name:
|
|
117
|
+
self.checkout_branch(branch_name)
|
|
118
|
+
|
|
119
|
+
def has_changes(self) -> bool:
|
|
120
|
+
"""Check if there are uncommitted changes (excluding ralph files)."""
|
|
121
|
+
return bool(self.get_stageable_files())
|
|
122
|
+
|
|
123
|
+
def get_status(self) -> str:
|
|
124
|
+
"""Get the current git status."""
|
|
125
|
+
result = self._run_git("status", "--short")
|
|
126
|
+
return str(result.stdout)
|
|
127
|
+
|
|
128
|
+
# Files/patterns that should never be committed by ralph
|
|
129
|
+
EXCLUDE_PATTERNS = [
|
|
130
|
+
".ralph/",
|
|
131
|
+
".claude/",
|
|
132
|
+
"progress.md",
|
|
133
|
+
"learnings.md",
|
|
134
|
+
"*.log",
|
|
135
|
+
"*.pyc",
|
|
136
|
+
"__pycache__/",
|
|
137
|
+
".env",
|
|
138
|
+
".DS_Store",
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
def _is_gitignored(self, filepath: str) -> bool:
|
|
142
|
+
"""Check if a file is ignored by .gitignore."""
|
|
143
|
+
result = self._run_git("check-ignore", "-q", filepath, check=False)
|
|
144
|
+
return result.returncode == 0
|
|
145
|
+
|
|
146
|
+
def _should_stage(self, filepath: str) -> bool:
|
|
147
|
+
"""Check if a file should be staged (not in exclude patterns or .gitignore)."""
|
|
148
|
+
# Check hardcoded exclude patterns first
|
|
149
|
+
for pattern in self.EXCLUDE_PATTERNS:
|
|
150
|
+
if pattern.endswith("/"):
|
|
151
|
+
# Directory pattern
|
|
152
|
+
if filepath.startswith(pattern) or f"/{pattern}" in filepath:
|
|
153
|
+
return False
|
|
154
|
+
elif pattern.startswith("*"):
|
|
155
|
+
# Wildcard pattern
|
|
156
|
+
if filepath.endswith(pattern[1:]):
|
|
157
|
+
return False
|
|
158
|
+
else:
|
|
159
|
+
# Exact match
|
|
160
|
+
if filepath == pattern or filepath.endswith(f"/{pattern}"):
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
# Also check .gitignore
|
|
164
|
+
if self._is_gitignored(filepath):
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
def get_stageable_files(self) -> list[str]:
|
|
170
|
+
"""Get list of changed files that should be staged (excluding ralph files)."""
|
|
171
|
+
result = self._run_git("status", "--porcelain")
|
|
172
|
+
files = []
|
|
173
|
+
for line in result.stdout.strip().split("\n"):
|
|
174
|
+
if not line:
|
|
175
|
+
continue
|
|
176
|
+
# Status is first 2 chars, filename starts at position 3
|
|
177
|
+
filepath = line[3:].strip()
|
|
178
|
+
# Handle renamed files (old -> new)
|
|
179
|
+
if " -> " in filepath:
|
|
180
|
+
filepath = filepath.split(" -> ")[1]
|
|
181
|
+
if self._should_stage(filepath):
|
|
182
|
+
files.append(filepath)
|
|
183
|
+
return files
|
|
184
|
+
|
|
185
|
+
def stage_all(self) -> None:
|
|
186
|
+
"""Stage all changes except excluded patterns."""
|
|
187
|
+
files = self.get_stageable_files()
|
|
188
|
+
if files:
|
|
189
|
+
self._stage_files_in_batches(files)
|
|
190
|
+
|
|
191
|
+
def stage_files(self, files: list[str]) -> None:
|
|
192
|
+
"""Stage specific files (filtered through exclusions)."""
|
|
193
|
+
filtered = [f for f in files if self._should_stage(f)]
|
|
194
|
+
if filtered:
|
|
195
|
+
self._stage_files_in_batches(filtered)
|
|
196
|
+
|
|
197
|
+
def _stage_files_in_batches(self, files: list[str], batch_size: int = 50) -> None:
|
|
198
|
+
"""Stage files in batches to avoid command line length limits."""
|
|
199
|
+
for i in range(0, len(files), batch_size):
|
|
200
|
+
batch = files[i:i + batch_size]
|
|
201
|
+
self._run_git("add", *batch)
|
|
202
|
+
|
|
203
|
+
def commit(self, message: str) -> str:
|
|
204
|
+
"""Create a commit with the given message and return the commit hash."""
|
|
205
|
+
self._run_git("commit", "-m", message)
|
|
206
|
+
result = self._run_git("rev-parse", "HEAD")
|
|
207
|
+
return str(result.stdout).strip()
|
|
208
|
+
|
|
209
|
+
def commit_all(self, message: str) -> str | None:
|
|
210
|
+
"""Stage all changes and commit with the given message."""
|
|
211
|
+
if not self.has_changes():
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
self.stage_all()
|
|
215
|
+
return self.commit(message)
|
|
216
|
+
|
|
217
|
+
def has_staged_changes(self) -> bool:
|
|
218
|
+
"""Check if there are any staged changes ready to commit."""
|
|
219
|
+
result = self._run_git("diff", "--cached", "--quiet", check=False)
|
|
220
|
+
return result.returncode != 0
|
|
221
|
+
|
|
222
|
+
def commit_staged(self, message: str) -> str | None:
|
|
223
|
+
"""Commit only staged changes (doesn't stage anything new).
|
|
224
|
+
|
|
225
|
+
Returns the commit hash if a commit was made, None if nothing was staged.
|
|
226
|
+
"""
|
|
227
|
+
if not self.has_staged_changes():
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
return self.commit(message)
|
|
231
|
+
|
|
232
|
+
def get_unstaged_files(self) -> list[str]:
|
|
233
|
+
"""Get list of files with unstaged changes (modified + untracked).
|
|
234
|
+
|
|
235
|
+
Returns files that have changes not yet added to the staging area.
|
|
236
|
+
This includes both modified tracked files and new untracked files.
|
|
237
|
+
"""
|
|
238
|
+
files = []
|
|
239
|
+
|
|
240
|
+
# Get modified but unstaged files
|
|
241
|
+
result = self._run_git("diff", "--name-only")
|
|
242
|
+
for f in result.stdout.strip().split("\n"):
|
|
243
|
+
if f:
|
|
244
|
+
files.append(f)
|
|
245
|
+
|
|
246
|
+
# Get untracked files
|
|
247
|
+
result = self._run_git("ls-files", "--others", "--exclude-standard")
|
|
248
|
+
for f in result.stdout.strip().split("\n"):
|
|
249
|
+
if f:
|
|
250
|
+
files.append(f)
|
|
251
|
+
|
|
252
|
+
return files
|
|
253
|
+
|
|
254
|
+
def get_last_commit_message(self) -> str:
|
|
255
|
+
"""Get the message of the last commit."""
|
|
256
|
+
result = self._run_git("log", "-1", "--pretty=%B", check=False)
|
|
257
|
+
return str(result.stdout).strip()
|
|
258
|
+
|
|
259
|
+
def get_diff(self, staged: bool = False) -> str:
|
|
260
|
+
"""Get the current diff for stageable files only."""
|
|
261
|
+
files = self.get_stageable_files()
|
|
262
|
+
if not files:
|
|
263
|
+
return ""
|
|
264
|
+
|
|
265
|
+
# Process files in batches to avoid command line length limits
|
|
266
|
+
batch_size = 50
|
|
267
|
+
diff_parts = []
|
|
268
|
+
for i in range(0, len(files), batch_size):
|
|
269
|
+
batch = files[i:i + batch_size]
|
|
270
|
+
args = ["diff"]
|
|
271
|
+
if staged:
|
|
272
|
+
args.append("--staged")
|
|
273
|
+
args.append("--")
|
|
274
|
+
args.extend(batch)
|
|
275
|
+
result = self._run_git(*args, check=False)
|
|
276
|
+
if result.stdout:
|
|
277
|
+
diff_parts.append(str(result.stdout))
|
|
278
|
+
|
|
279
|
+
return "\n".join(diff_parts)
|
|
280
|
+
|
|
281
|
+
def get_untracked_files(self) -> list[str]:
|
|
282
|
+
"""Get a list of untracked files."""
|
|
283
|
+
result = self._run_git("ls-files", "--others", "--exclude-standard")
|
|
284
|
+
return [f for f in result.stdout.strip().split("\n") if f]
|
|
285
|
+
|
|
286
|
+
def get_modified_files(self) -> list[str]:
|
|
287
|
+
"""Get a list of modified files."""
|
|
288
|
+
result = self._run_git("diff", "--name-only")
|
|
289
|
+
return [f for f in result.stdout.strip().split("\n") if f]
|
|
290
|
+
|
|
291
|
+
def reset_file(self, filepath: str) -> None:
|
|
292
|
+
"""Reset a specific file to the last committed state."""
|
|
293
|
+
self._run_git("checkout", "--", filepath)
|
|
294
|
+
|
|
295
|
+
def stash(self, message: str = "") -> None:
|
|
296
|
+
"""Stash current changes."""
|
|
297
|
+
if message:
|
|
298
|
+
self._run_git("stash", "push", "-m", message)
|
|
299
|
+
else:
|
|
300
|
+
self._run_git("stash")
|
|
301
|
+
|
|
302
|
+
def stash_pop(self) -> None:
|
|
303
|
+
"""Pop the most recent stash."""
|
|
304
|
+
self._run_git("stash", "pop")
|
ralph/harness.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Harness abstraction layer for CLI tools in ralph-coding application.
|
|
2
|
+
|
|
3
|
+
This module provides the core abstraction for integrating different AI CLI tools
|
|
4
|
+
with Ralph. It defines:
|
|
5
|
+
|
|
6
|
+
- Harness: A dataclass representing a CLI tool with validation and model querying
|
|
7
|
+
- HarnessDetector: Discovers available harnesses on the system PATH
|
|
8
|
+
- HarnessType: Type literal for known harness categories (claude, codex, custom)
|
|
9
|
+
|
|
10
|
+
The harness system allows Ralph to work with different AI backends (Claude, Codex,
|
|
11
|
+
or custom tools) without changing its core workflow logic. Each harness type has
|
|
12
|
+
specific CLI flag patterns and model mappings handled by HarnessRunner.
|
|
13
|
+
|
|
14
|
+
Example usage:
|
|
15
|
+
# Create a harness from configuration
|
|
16
|
+
harness = Harness.from_config("claude")
|
|
17
|
+
|
|
18
|
+
# Check availability
|
|
19
|
+
if harness.is_available:
|
|
20
|
+
models = harness.get_supported_models()
|
|
21
|
+
|
|
22
|
+
# Auto-detect available harnesses
|
|
23
|
+
detector = HarnessDetector()
|
|
24
|
+
available = detector.detect_all()
|
|
25
|
+
|
|
26
|
+
See HARNESS_ARCHITECTURE.md for detailed documentation on adding custom harnesses.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import shutil
|
|
31
|
+
import subprocess
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Literal
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Type literal defining the supported harness categories.
|
|
37
|
+
# - "claude": Anthropic's Claude CLI tool
|
|
38
|
+
# - "codex": OpenAI's Codex CLI tool
|
|
39
|
+
# - "custom": Any other CLI tool (uses Claude-like flags by default)
|
|
40
|
+
HarnessType = Literal["claude", "codex", "custom"]
|
|
41
|
+
|
|
42
|
+
# Default models for known harness types.
|
|
43
|
+
# Each tuple is (model_name, label) where label indicates the model tier:
|
|
44
|
+
# - "Light": Faster, cheaper models for quick tasks (PRD generation, verification)
|
|
45
|
+
# - "Standard": Full-featured models for implementation tasks
|
|
46
|
+
# These defaults are used when CLI model querying fails or isn't supported.
|
|
47
|
+
DEFAULT_MODELS: dict[HarnessType, list[tuple[str, str]]] = {
|
|
48
|
+
"claude": [
|
|
49
|
+
("haiku", "Light"),
|
|
50
|
+
("sonnet", "Standard"),
|
|
51
|
+
("opus", "Standard"),
|
|
52
|
+
],
|
|
53
|
+
"codex": [
|
|
54
|
+
("gpt-5.1-codex-mini", "Light"),
|
|
55
|
+
("gpt-5.2-codex", "Standard"),
|
|
56
|
+
("gpt-5.1-codex-max", "Standard"),
|
|
57
|
+
("gpt-5.2", "Standard"),
|
|
58
|
+
],
|
|
59
|
+
"custom": [], # Custom harnesses must provide models via CLI or manual entry
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
DEFAULT_WORKER_MODEL: dict[HarnessType, str] = {
|
|
63
|
+
"claude": "opus",
|
|
64
|
+
"codex": "gpt-5.2-codex",
|
|
65
|
+
"custom": "",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
DEFAULT_SUMMARY_MODEL: dict[HarnessType, str] = {
|
|
69
|
+
"claude": "haiku",
|
|
70
|
+
"codex": "gpt-5.2",
|
|
71
|
+
"custom": "",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Harness:
|
|
77
|
+
"""
|
|
78
|
+
Represents a CLI harness tool for AI-assisted coding.
|
|
79
|
+
|
|
80
|
+
A harness is an abstraction over different CLI tools (like claude, codex, etc.)
|
|
81
|
+
that can be used to run AI-powered code generation tasks.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
name: str
|
|
85
|
+
path: str
|
|
86
|
+
type: HarnessType
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def is_available(self) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
Check if the harness is available (path exists and is executable).
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
True if the harness executable exists and is executable, False otherwise.
|
|
95
|
+
"""
|
|
96
|
+
# If path is just a command name (no directory), check if it's on PATH
|
|
97
|
+
if os.path.dirname(self.path) == "":
|
|
98
|
+
resolved = shutil.which(self.path)
|
|
99
|
+
if resolved is None:
|
|
100
|
+
return False
|
|
101
|
+
return os.access(resolved, os.X_OK)
|
|
102
|
+
|
|
103
|
+
# Otherwise check the absolute/relative path directly
|
|
104
|
+
if not os.path.exists(self.path):
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
return os.access(self.path, os.X_OK)
|
|
108
|
+
|
|
109
|
+
def get_supported_models(self) -> list[tuple[str, str]]:
|
|
110
|
+
"""
|
|
111
|
+
Get the list of models supported by this harness.
|
|
112
|
+
|
|
113
|
+
For known harness types (claude, codex), this attempts to query the CLI
|
|
114
|
+
for available models. If that fails or for custom harnesses, it returns
|
|
115
|
+
sensible defaults based on the harness type.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of (model_name, label) tuples where label is "Standard" or "Light".
|
|
119
|
+
"""
|
|
120
|
+
# Try to query the harness CLI for models
|
|
121
|
+
queried_models = self._query_models_from_cli()
|
|
122
|
+
if queried_models:
|
|
123
|
+
return queried_models
|
|
124
|
+
|
|
125
|
+
# Fall back to defaults for the harness type
|
|
126
|
+
return DEFAULT_MODELS.get(self.type, []).copy()
|
|
127
|
+
|
|
128
|
+
def get_default_model(self) -> str | None:
|
|
129
|
+
"""
|
|
130
|
+
Get the default model for this harness type.
|
|
131
|
+
|
|
132
|
+
Returns the first "Standard" labeled model, or the first model if no
|
|
133
|
+
standard model exists. Returns None if no models are available.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Model name string, or None if no models available.
|
|
137
|
+
"""
|
|
138
|
+
models = self.get_supported_models()
|
|
139
|
+
if not models:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
# Prefer a "Standard" model as default
|
|
143
|
+
for model_name, label in models:
|
|
144
|
+
if label == "Standard":
|
|
145
|
+
return model_name
|
|
146
|
+
|
|
147
|
+
# Fall back to first available model
|
|
148
|
+
return models[0][0]
|
|
149
|
+
|
|
150
|
+
def get_default_worker_model(self) -> str | None:
|
|
151
|
+
"""Get the default worker model for this harness type."""
|
|
152
|
+
preferred = DEFAULT_WORKER_MODEL.get(self.type)
|
|
153
|
+
if preferred:
|
|
154
|
+
return preferred
|
|
155
|
+
return self.get_default_model()
|
|
156
|
+
|
|
157
|
+
def get_default_summary_model(self) -> str | None:
|
|
158
|
+
"""Get the default summary model for this harness type."""
|
|
159
|
+
preferred = DEFAULT_SUMMARY_MODEL.get(self.type)
|
|
160
|
+
if preferred:
|
|
161
|
+
return preferred
|
|
162
|
+
models = self.get_supported_models()
|
|
163
|
+
if not models:
|
|
164
|
+
return None
|
|
165
|
+
for model_name, label in models:
|
|
166
|
+
if label == "Light":
|
|
167
|
+
return model_name
|
|
168
|
+
return models[0][0]
|
|
169
|
+
|
|
170
|
+
def is_model_supported(self, model: str) -> bool:
|
|
171
|
+
"""
|
|
172
|
+
Check if a model name is supported by this harness.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
model: Model name to check.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
True if the model is in the supported models list, False otherwise.
|
|
179
|
+
"""
|
|
180
|
+
models = self.get_supported_models()
|
|
181
|
+
# Empty list means any model is acceptable (custom harness)
|
|
182
|
+
if not models:
|
|
183
|
+
return True
|
|
184
|
+
return any(model_name == model for model_name, _ in models)
|
|
185
|
+
|
|
186
|
+
def _query_models_from_cli(self) -> list[tuple[str, str]]:
|
|
187
|
+
"""
|
|
188
|
+
Attempt to query the harness CLI for available models.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List of (model_name, label) tuples if successful, empty list otherwise.
|
|
192
|
+
"""
|
|
193
|
+
if not self.is_available:
|
|
194
|
+
return []
|
|
195
|
+
|
|
196
|
+
# Different harnesses have different ways to list models
|
|
197
|
+
# Claude Code uses: claude --help (models are in help text)
|
|
198
|
+
# We try common patterns
|
|
199
|
+
try:
|
|
200
|
+
if self.type == "claude":
|
|
201
|
+
return self._query_claude_models()
|
|
202
|
+
elif self.type == "codex":
|
|
203
|
+
return self._query_codex_models()
|
|
204
|
+
else:
|
|
205
|
+
# Custom harnesses: try generic --list-models flag
|
|
206
|
+
return self._query_generic_models()
|
|
207
|
+
except (subprocess.SubprocessError, OSError):
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
def _query_claude_models(self) -> list[tuple[str, str]]:
|
|
211
|
+
"""Query Claude CLI for available models."""
|
|
212
|
+
# Claude Code doesn't have a direct --list-models command
|
|
213
|
+
# The models are fixed: haiku, sonnet, opus
|
|
214
|
+
# We could parse --help output but the models are well-known
|
|
215
|
+
return DEFAULT_MODELS["claude"].copy()
|
|
216
|
+
|
|
217
|
+
def _query_codex_models(self) -> list[tuple[str, str]]:
|
|
218
|
+
"""Query Codex CLI for available models."""
|
|
219
|
+
# Codex/OpenAI CLI model listing would go here
|
|
220
|
+
# For now return defaults
|
|
221
|
+
return DEFAULT_MODELS["codex"].copy()
|
|
222
|
+
|
|
223
|
+
def _query_generic_models(self) -> list[tuple[str, str]]:
|
|
224
|
+
"""Try generic model listing for custom harnesses."""
|
|
225
|
+
# Try common flags that tools might support
|
|
226
|
+
for flag in ["--list-models", "--models", "models"]:
|
|
227
|
+
try:
|
|
228
|
+
result = subprocess.run(
|
|
229
|
+
[self.path, flag],
|
|
230
|
+
capture_output=True,
|
|
231
|
+
text=True,
|
|
232
|
+
timeout=10,
|
|
233
|
+
)
|
|
234
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
235
|
+
# Parse output - assume one model per line
|
|
236
|
+
models: list[tuple[str, str]] = [
|
|
237
|
+
(line.strip(), "Standard") # Default label for queried models
|
|
238
|
+
for line in result.stdout.strip().split("\n")
|
|
239
|
+
if line.strip() and not line.startswith("#")
|
|
240
|
+
]
|
|
241
|
+
if models:
|
|
242
|
+
return models
|
|
243
|
+
except (subprocess.SubprocessError, OSError):
|
|
244
|
+
continue
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def from_config(
|
|
249
|
+
cls,
|
|
250
|
+
harness_path: str,
|
|
251
|
+
harness_type: HarnessType | None = None,
|
|
252
|
+
) -> "Harness":
|
|
253
|
+
"""
|
|
254
|
+
Create a Harness from a configuration path.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
harness_path: Path to the harness executable or command name.
|
|
258
|
+
harness_type: Type of harness. If None, inferred from the path.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
A new Harness instance.
|
|
262
|
+
"""
|
|
263
|
+
# Infer type from path if not provided
|
|
264
|
+
if harness_type is None:
|
|
265
|
+
harness_type = cls._infer_type(harness_path)
|
|
266
|
+
|
|
267
|
+
# Extract name from path
|
|
268
|
+
name = os.path.basename(harness_path)
|
|
269
|
+
|
|
270
|
+
return cls(name=name, path=harness_path, type=harness_type)
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _infer_type(harness_path: str) -> HarnessType:
|
|
274
|
+
"""
|
|
275
|
+
Infer the harness type from the path.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
harness_path: Path to the harness executable.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
The inferred harness type.
|
|
282
|
+
"""
|
|
283
|
+
basename = os.path.basename(harness_path).lower()
|
|
284
|
+
|
|
285
|
+
if "claude" in basename:
|
|
286
|
+
return "claude"
|
|
287
|
+
elif "codex" in basename or "openai" in basename:
|
|
288
|
+
return "codex"
|
|
289
|
+
else:
|
|
290
|
+
return "custom"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class HarnessDetector:
|
|
294
|
+
"""Detects available CLI harnesses on the system PATH.
|
|
295
|
+
|
|
296
|
+
This class provides platform-aware detection of known harness tools
|
|
297
|
+
(claude, codex) by searching the system PATH using shutil.which().
|
|
298
|
+
|
|
299
|
+
The detector is used by:
|
|
300
|
+
- Settings UI: To show available harnesses for selection
|
|
301
|
+
- Startup validation: To verify the configured harness exists
|
|
302
|
+
|
|
303
|
+
Example:
|
|
304
|
+
detector = HarnessDetector()
|
|
305
|
+
|
|
306
|
+
# Find all available harnesses
|
|
307
|
+
all_harnesses = detector.detect_all()
|
|
308
|
+
|
|
309
|
+
# Find a specific harness
|
|
310
|
+
claude = detector.detect("claude")
|
|
311
|
+
if claude and claude.is_available:
|
|
312
|
+
print(f"Found Claude at {claude.path}")
|
|
313
|
+
|
|
314
|
+
Note:
|
|
315
|
+
Detection only checks if executables exist and are accessible.
|
|
316
|
+
It does not validate that they are functional AI tools.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
# Known harness executables to search for.
|
|
320
|
+
# Format: (executable_name, harness_type)
|
|
321
|
+
# Add new known harnesses here to enable auto-detection.
|
|
322
|
+
KNOWN_HARNESSES: list[tuple[str, HarnessType]] = [
|
|
323
|
+
("claude", "claude"),
|
|
324
|
+
("codex", "codex"),
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
def __init__(self) -> None:
|
|
328
|
+
"""Initialize the HarnessDetector."""
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
def detect_all(self) -> list[Harness]:
|
|
332
|
+
"""
|
|
333
|
+
Detect all available harnesses on the system PATH.
|
|
334
|
+
|
|
335
|
+
Searches for known harness executables (claude, codex) using
|
|
336
|
+
platform-aware PATH searching (shutil.which).
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
List of detected Harness objects with full paths.
|
|
340
|
+
Returns an empty list if no harnesses are found.
|
|
341
|
+
"""
|
|
342
|
+
detected: list[Harness] = []
|
|
343
|
+
|
|
344
|
+
for executable_name, harness_type in self.KNOWN_HARNESSES:
|
|
345
|
+
harness = self._detect_harness(executable_name, harness_type)
|
|
346
|
+
if harness is not None:
|
|
347
|
+
detected.append(harness)
|
|
348
|
+
|
|
349
|
+
return detected
|
|
350
|
+
|
|
351
|
+
def _detect_harness(
|
|
352
|
+
self, executable_name: str, harness_type: HarnessType
|
|
353
|
+
) -> Harness | None:
|
|
354
|
+
"""
|
|
355
|
+
Detect a specific harness executable on the PATH.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
executable_name: Name of the executable to search for.
|
|
359
|
+
harness_type: The type of harness this executable represents.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
A Harness object if found, None otherwise.
|
|
363
|
+
"""
|
|
364
|
+
path = shutil.which(executable_name)
|
|
365
|
+
if path is None:
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
return Harness(
|
|
369
|
+
name=executable_name,
|
|
370
|
+
path=path,
|
|
371
|
+
type=harness_type,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def detect(self, executable_name: str) -> Harness | None:
|
|
375
|
+
"""
|
|
376
|
+
Detect a specific harness by executable name.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
executable_name: Name of the executable to search for (e.g., 'claude').
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
A Harness object if found, None otherwise.
|
|
383
|
+
"""
|
|
384
|
+
path = shutil.which(executable_name)
|
|
385
|
+
if path is None:
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
harness_type = Harness._infer_type(executable_name)
|
|
389
|
+
return Harness(
|
|
390
|
+
name=executable_name,
|
|
391
|
+
path=path,
|
|
392
|
+
type=harness_type,
|
|
393
|
+
)
|