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/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
+ )