@ericrisco/rsc 0.1.32 → 0.1.34

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.
Files changed (38) hide show
  1. package/README.md +4 -4
  2. package/manifest.json +24 -5
  3. package/package.json +1 -1
  4. package/scripts/lib/domains.js +1 -1
  5. package/skills/analyze/SKILL.md +1 -0
  6. package/skills/author-skill/SKILL.md +20 -0
  7. package/skills/author-skill/references/description-recipe.md +2 -0
  8. package/skills/debug/SKILL.md +1 -1
  9. package/skills/implement/SKILL.md +72 -2
  10. package/skills/implement/references/per-task-review.md +46 -0
  11. package/skills/implement/scripts/review-package +59 -0
  12. package/skills/implement/scripts/sdd-workspace +47 -0
  13. package/skills/implement/scripts/task-brief +77 -0
  14. package/skills/parallel/SKILL.md +29 -0
  15. package/skills/plan/references/plan-template.md +18 -0
  16. package/skills/roast-me/SKILL.md +124 -0
  17. package/skills/roast-me/evals/README.md +76 -0
  18. package/skills/roast-me/evals/cases.yaml +75 -0
  19. package/skills/roast-me/prompts/analyze.md +90 -0
  20. package/skills/roast-me/prompts/compute.md +100 -0
  21. package/skills/roast-me/prompts/roast.md +181 -0
  22. package/skills/roast-me/tools/adapters/__init__.py +1 -0
  23. package/skills/roast-me/tools/adapters/__pycache__/__init__.cpython-312.pyc +0 -0
  24. package/skills/roast-me/tools/adapters/__pycache__/base.cpython-312.pyc +0 -0
  25. package/skills/roast-me/tools/adapters/__pycache__/claude.cpython-312.pyc +0 -0
  26. package/skills/roast-me/tools/adapters/__pycache__/codex.cpython-312.pyc +0 -0
  27. package/skills/roast-me/tools/adapters/__pycache__/gemini.cpython-312.pyc +0 -0
  28. package/skills/roast-me/tools/adapters/__pycache__/registry.cpython-312.pyc +0 -0
  29. package/skills/roast-me/tools/adapters/base.py +65 -0
  30. package/skills/roast-me/tools/adapters/claude.py +140 -0
  31. package/skills/roast-me/tools/adapters/codex.py +206 -0
  32. package/skills/roast-me/tools/adapters/gemini.py +142 -0
  33. package/skills/roast-me/tools/adapters/registry.py +68 -0
  34. package/skills/roast-me/tools/extract_prompts.py +519 -0
  35. package/skills/ship/SKILL.md +9 -1
  36. package/skills/specify/SKILL.md +21 -1
  37. package/skills/tasks/SKILL.md +25 -0
  38. package/skills/worktrees/SKILL.md +25 -0
@@ -0,0 +1,65 @@
1
+ """Base adapter contract for roast-me transcript discovery.
2
+
3
+ Every runtime adapter implements two methods:
4
+ discover() -> list[Path] — find local session files; return [] if none found
5
+ parse(path) -> list[dict] — yield raw prompt-like records from one file
6
+
7
+ The adapter contract is intentionally minimal. Higher-level normalisation
8
+ (truncation, field enrichment, error/correction detection) happens in
9
+ extract_prompts.py, not here.
10
+
11
+ An adapter MUST:
12
+ - Never raise on missing files or unexpected formats — return [] instead.
13
+ - Tag every record with {"runtime": self.RUNTIME_ID}.
14
+ - Return only records that look like user-initiated prompts (not tool results,
15
+ not system messages).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from abc import ABC, abstractmethod
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ #: Shared correction detector — a user message matching this right after a prompt
26
+ #: signals the previous turn went wrong. Single source of truth for every adapter
27
+ #: (and re-used by extract_prompts.py) so the heuristic never drifts between runtimes.
28
+ CORRECTION_RE = re.compile(
29
+ r"\b(no[,.]?\s|wrong|instead|actually|don'?t|shouldn'?t|stop|not that|"
30
+ r"I said|I meant|I asked|that'?s not|please don'?t|why did you|"
31
+ r"you should have|that was wrong|incorrect|try again|redo|"
32
+ r"that broke|you broke|revert|undo)\b",
33
+ re.IGNORECASE,
34
+ )
35
+
36
+
37
+ class BaseAdapter(ABC):
38
+ """Contract every runtime adapter must satisfy."""
39
+
40
+ #: Short identifier used to tag records and in CLI --runtime flag.
41
+ RUNTIME_ID: str = "unknown"
42
+
43
+ @abstractmethod
44
+ def discover(self) -> list[Path]:
45
+ """Return a list of session file paths found for this runtime.
46
+
47
+ Must return [] (not raise) when the runtime is not installed or no
48
+ data directory exists.
49
+ """
50
+
51
+ @abstractmethod
52
+ def parse(self, path: Path) -> list[dict[str, Any]]:
53
+ """Parse one session file and return a list of raw prompt records.
54
+
55
+ Each record must include at minimum:
56
+ - "runtime": str — the RUNTIME_ID of this adapter
57
+ - "prompt_text": str — the user's message text
58
+ - "timestamp": float | None — unix timestamp of the message (or None)
59
+ - "session_file": str — stringified path of the source file
60
+
61
+ Additional fields are welcomed and used by extract_prompts.py if present
62
+ (e.g. "model", "cost_usd").
63
+
64
+ Must return [] (not raise) on parse failures.
65
+ """
@@ -0,0 +1,140 @@
1
+ """Claude Code adapter — first-class reference implementation.
2
+
3
+ Session files: ~/.claude/projects/*/*.jsonl
4
+ Format: JSON Lines, one object per line.
5
+
6
+ Each line is a conversation event with a "type" field:
7
+ "user" — user message (prompt or tool result)
8
+ "assistant" — model response
9
+
10
+ User prompt records are lines where:
11
+ - type == "user"
12
+ - message.isMeta is falsy
13
+ - message.content contains at least one text block (not only tool_result blocks)
14
+
15
+ This is the canonical adapter; its parsing logic is the reference for all others.
16
+
17
+ Confirmed: path and format verified against live ~/.claude/projects structure on
18
+ macOS (2026-06). The JSONL schema is stable across Claude Code versions.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from .base import BaseAdapter
29
+
30
+
31
+ class ClaudeAdapter(BaseAdapter):
32
+ RUNTIME_ID = "claude"
33
+
34
+ # Location of Claude Code project session files.
35
+ PROJECTS_DIR = Path.home() / ".claude" / "projects"
36
+
37
+ def discover(self) -> list[Path]:
38
+ """Find all *.jsonl session files under ~/.claude/projects/."""
39
+ if not self.PROJECTS_DIR.exists():
40
+ return []
41
+ files: list[Path] = []
42
+ try:
43
+ for jsonl in self.PROJECTS_DIR.rglob("*.jsonl"):
44
+ try:
45
+ if jsonl.is_file():
46
+ files.append(jsonl)
47
+ except OSError:
48
+ continue
49
+ except OSError:
50
+ return []
51
+ return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
52
+
53
+ def parse(self, path: Path) -> list[dict[str, Any]]:
54
+ """Parse one Claude Code .jsonl session file."""
55
+ records: list[dict[str, Any]] = []
56
+ try:
57
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
58
+ except OSError:
59
+ return []
60
+
61
+ for line in lines:
62
+ line = line.strip()
63
+ if not line:
64
+ continue
65
+ try:
66
+ obj = json.loads(line)
67
+ except json.JSONDecodeError:
68
+ continue
69
+
70
+ if obj.get("type") != "user":
71
+ continue
72
+
73
+ msg = obj.get("message", {})
74
+ if msg.get("isMeta"):
75
+ continue
76
+
77
+ content = msg.get("content", [])
78
+ text = _extract_text(content)
79
+ if not text.strip():
80
+ continue
81
+ # Skip messages that are only tool_result blocks
82
+ if _is_only_tool_results(content):
83
+ continue
84
+
85
+ ts = _parse_timestamp(obj)
86
+ records.append({
87
+ "runtime": self.RUNTIME_ID,
88
+ "session_file": str(path),
89
+ "prompt_text": text,
90
+ "timestamp": ts,
91
+ # Forward the raw object so the main extractor can inspect
92
+ # adjacent lines for error/correction context.
93
+ "_raw": obj,
94
+ })
95
+
96
+ return records
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Helpers
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def _extract_text(content: Any) -> str:
104
+ if isinstance(content, str):
105
+ return content
106
+ if isinstance(content, list):
107
+ parts = []
108
+ for block in content:
109
+ if isinstance(block, dict) and block.get("type") == "text":
110
+ parts.append(block.get("text", ""))
111
+ return "\n".join(parts)
112
+ return ""
113
+
114
+
115
+ def _is_only_tool_results(content: Any) -> bool:
116
+ if not isinstance(content, list):
117
+ return False
118
+ has_text = any(
119
+ isinstance(b, dict) and b.get("type") == "text" for b in content
120
+ )
121
+ has_tool_result = any(
122
+ isinstance(b, dict) and b.get("type") == "tool_result" for b in content
123
+ )
124
+ return has_tool_result and not has_text
125
+
126
+
127
+ def _parse_timestamp(obj: dict) -> float | None:
128
+ ts = obj.get("timestamp")
129
+ if ts is None:
130
+ return None
131
+ if isinstance(ts, (int, float)):
132
+ return float(ts)
133
+ if isinstance(ts, str):
134
+ try:
135
+ import datetime
136
+ dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
137
+ return dt.timestamp()
138
+ except (ValueError, AttributeError):
139
+ pass
140
+ return None
@@ -0,0 +1,206 @@
1
+ """Codex CLI adapter (@openai/codex).
2
+
3
+ Session files: ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>.jsonl
4
+ Format: JSON Lines. Each line is {"timestamp", "type", "payload"}.
5
+
6
+ CONFIRMED (2026-06, against live ~/.codex/sessions on macOS):
7
+ - Base path / layout / naming: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
8
+ - Line envelope: {"timestamp": ISO8601, "type": str, "payload": {...}}
9
+ - The genuine user prompt is an `event_msg` line whose payload.type is
10
+ "user_message", carrying the typed text in payload.message. (Injected
11
+ AGENTS.md / developer preambles appear only as response_item/message lines,
12
+ so keying on user_message excludes them.)
13
+ - Assistant turns: event_msg/agent_message (payload.message) and
14
+ response_item/message role=assistant.
15
+ - Tool calls: response_item/function_call (payload.name).
16
+ - Tool results: response_item/function_call_output (payload.output) — shell
17
+ execs include a "Process exited with code N" line; N != 0 (or common error
18
+ markers) means the command failed.
19
+ - Model: turn_context.model / session_meta payload.
20
+
21
+ Degradation: if ~/.codex/ does not exist, discover() returns []; any unexpected
22
+ line shape is skipped, so parse() never raises.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import datetime
28
+ import json
29
+ import re
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from .base import CORRECTION_RE, BaseAdapter
34
+
35
+ # A shell tool result failed when it reports a non-zero exit code, or carries a
36
+ # common error marker with no explicit success code. Conservative on purpose —
37
+ # better to miss a soft error than to invent one.
38
+ _EXIT_CODE_RE = re.compile(r"exited with code\s+(\d+)", re.IGNORECASE)
39
+ _ERROR_MARKER_RE = re.compile(
40
+ r"\b(command not found|no such file|permission denied|traceback \(most recent|"
41
+ r"fatal:|panic:|segmentation fault|unhandled exception)\b",
42
+ re.IGNORECASE,
43
+ )
44
+
45
+
46
+ def _looks_like_error(output: str) -> bool:
47
+ if not output:
48
+ return False
49
+ m = _EXIT_CODE_RE.search(output)
50
+ if m:
51
+ return m.group(1) != "0"
52
+ return bool(_ERROR_MARKER_RE.search(output))
53
+
54
+
55
+ def _ts(value: Any) -> float | None:
56
+ if isinstance(value, (int, float)):
57
+ return float(value)
58
+ if isinstance(value, str):
59
+ try:
60
+ return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
61
+ except (ValueError, AttributeError):
62
+ return None
63
+ return None
64
+
65
+
66
+ class CodexAdapter(BaseAdapter):
67
+ RUNTIME_ID = "codex"
68
+
69
+ SESSIONS_DIR = Path.home() / ".codex" / "sessions"
70
+
71
+ def discover(self) -> list[Path]:
72
+ """Find all rollout-*.jsonl files under ~/.codex/sessions/."""
73
+ if not self.SESSIONS_DIR.exists():
74
+ return []
75
+ files: list[Path] = []
76
+ try:
77
+ for jsonl in self.SESSIONS_DIR.rglob("rollout-*.jsonl"):
78
+ try:
79
+ if jsonl.is_file():
80
+ files.append(jsonl)
81
+ except OSError:
82
+ continue
83
+ except OSError:
84
+ return []
85
+ return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
86
+
87
+ def parse(self, path: Path) -> list[dict[str, Any]]:
88
+ """Parse one Codex session JSONL file into user-prompt records with
89
+ session-aware error/correction context."""
90
+ try:
91
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
92
+ except OSError:
93
+ return []
94
+
95
+ # 1) Flatten the file into an ordered timeline of turns.
96
+ timeline: list[dict[str, Any]] = []
97
+ model = ""
98
+ pending_tool = ""
99
+ for line in lines:
100
+ line = line.strip()
101
+ if not line:
102
+ continue
103
+ try:
104
+ obj = json.loads(line)
105
+ except json.JSONDecodeError:
106
+ continue
107
+ if not isinstance(obj, dict):
108
+ continue
109
+
110
+ top_type = obj.get("type")
111
+ payload = obj.get("payload")
112
+ if not isinstance(payload, dict):
113
+ payload = {}
114
+ ts = _ts(obj.get("timestamp"))
115
+
116
+ # Track the active model from session metadata / turn context.
117
+ if top_type in ("session_meta", "turn_context"):
118
+ m = payload.get("model")
119
+ if isinstance(m, str) and m:
120
+ model = m
121
+ continue
122
+
123
+ if top_type == "event_msg":
124
+ p_type = payload.get("type")
125
+ if p_type == "user_message":
126
+ text = payload.get("message", "")
127
+ if isinstance(text, str) and text.strip():
128
+ timeline.append({"kind": "user", "text": text, "ts": ts})
129
+ elif p_type == "agent_message":
130
+ text = payload.get("message", "")
131
+ if isinstance(text, str) and text.strip():
132
+ timeline.append({"kind": "assistant", "text": text, "ts": ts})
133
+ continue
134
+
135
+ if top_type == "response_item":
136
+ p_type = payload.get("type")
137
+ if p_type == "function_call":
138
+ name = payload.get("name", "")
139
+ pending_tool = name if isinstance(name, str) else ""
140
+ elif p_type == "function_call_output":
141
+ output = payload.get("output", "")
142
+ output = output if isinstance(output, str) else json.dumps(output)
143
+ timeline.append({
144
+ "kind": "tool_output",
145
+ "tool": pending_tool,
146
+ "is_error": _looks_like_error(output),
147
+ "text": output,
148
+ "ts": ts,
149
+ })
150
+ pending_tool = ""
151
+ # response_item/message (assistant/user/developer) is intentionally
152
+ # ignored: genuine prompts come from event_msg/user_message, and the
153
+ # assistant context comes from event_msg/agent_message.
154
+ continue
155
+
156
+ # 2) Walk the timeline; enrich each user prompt with what follows it.
157
+ user_indices = [i for i, t in enumerate(timeline) if t["kind"] == "user"]
158
+ records: list[dict[str, Any]] = []
159
+ for n, idx in enumerate(user_indices):
160
+ turn = timeline[idx]
161
+ next_user = user_indices[n + 1] if n + 1 < len(user_indices) else len(timeline)
162
+
163
+ followed_by_error = False
164
+ error_tool = ""
165
+ error_text = ""
166
+ for j in range(idx + 1, next_user):
167
+ t = timeline[j]
168
+ if t["kind"] == "tool_output" and t.get("is_error"):
169
+ followed_by_error = True
170
+ error_tool = t.get("tool", "")
171
+ error_text = t.get("text", "")
172
+ break
173
+
174
+ followed_by_correction = False
175
+ correction_text = ""
176
+ if next_user < len(timeline):
177
+ nxt = timeline[next_user]["text"]
178
+ if CORRECTION_RE.search(nxt):
179
+ followed_by_correction = True
180
+ correction_text = nxt
181
+
182
+ # context_before: last assistant text strictly before this prompt.
183
+ context_before = ""
184
+ for j in range(idx - 1, -1, -1):
185
+ if timeline[j]["kind"] == "assistant":
186
+ context_before = timeline[j]["text"]
187
+ break
188
+
189
+ error_was_recovered = followed_by_error and not followed_by_correction
190
+
191
+ records.append({
192
+ "runtime": self.RUNTIME_ID,
193
+ "session_file": str(path),
194
+ "prompt_text": turn["text"],
195
+ "timestamp": turn.get("ts"),
196
+ "model": model,
197
+ "context_before": context_before,
198
+ "followed_by_error": followed_by_error,
199
+ "error_was_recovered": error_was_recovered,
200
+ "followed_by_correction": followed_by_correction,
201
+ "correction_text": correction_text,
202
+ "error_tool": error_tool,
203
+ "error_text": error_text,
204
+ })
205
+
206
+ return records
@@ -0,0 +1,142 @@
1
+ """Gemini CLI adapter (@google/gemini-cli).
2
+
3
+ Session files: ~/.gemini/tmp/<project_hash>/chats/session-*.json
4
+ Format: a SINGLE JSON object per file (not JSONL).
5
+
6
+ CONFIRMED (2026-06, against live ~/.gemini/tmp on macOS):
7
+ - Base path / layout: ~/.gemini/tmp/<project_hash>/chats/*.json
8
+ - File shape: {"sessionId", "projectHash", "startTime", "lastUpdated",
9
+ "messages": [ {"id", "timestamp", "type", "content", ...} ]}
10
+ - messages[].type is "user" or "gemini"; content is a plain string.
11
+ - gemini (assistant) messages also carry "model", "thoughts", "tokens".
12
+ - The format does NOT persist tool calls/errors as messages, so tool-error
13
+ detection is not possible here — followed_by_error stays False (honest);
14
+ correction detection (next user message) still works.
15
+
16
+ Degradation: if ~/.gemini/ does not exist, discover() returns []; a file that
17
+ is not the expected object shape yields no records (parse never raises).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import datetime
23
+ import json
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from .base import CORRECTION_RE, BaseAdapter
28
+
29
+
30
+ def _ts(value: Any) -> float | None:
31
+ if isinstance(value, (int, float)):
32
+ return float(value)
33
+ if isinstance(value, str):
34
+ try:
35
+ return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
36
+ except (ValueError, AttributeError):
37
+ return None
38
+ return None
39
+
40
+
41
+ class GeminiAdapter(BaseAdapter):
42
+ RUNTIME_ID = "gemini"
43
+
44
+ GEMINI_TMP_DIR = Path.home() / ".gemini" / "tmp"
45
+
46
+ def discover(self) -> list[Path]:
47
+ """Find all session *.json chat files under ~/.gemini/tmp/<hash>/chats/."""
48
+ if not self.GEMINI_TMP_DIR.exists():
49
+ return []
50
+ files: list[Path] = []
51
+ try:
52
+ for jf in self.GEMINI_TMP_DIR.rglob("chats/*.json"):
53
+ try:
54
+ if jf.is_file():
55
+ files.append(jf)
56
+ except OSError:
57
+ continue
58
+ except OSError:
59
+ return []
60
+ return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
61
+
62
+ def parse(self, path: Path) -> list[dict[str, Any]]:
63
+ """Parse one Gemini chat JSON file into user-prompt records with
64
+ correction context. Tool errors are not represented in this format."""
65
+ try:
66
+ obj = json.loads(path.read_text(encoding="utf-8", errors="replace"))
67
+ except (OSError, json.JSONDecodeError):
68
+ return []
69
+ if not isinstance(obj, dict):
70
+ return []
71
+
72
+ messages = obj.get("messages")
73
+ if not isinstance(messages, list):
74
+ return []
75
+
76
+ # Normalise into an ordered timeline of {kind, text, model, ts}.
77
+ timeline: list[dict[str, Any]] = []
78
+ for m in messages:
79
+ if not isinstance(m, dict):
80
+ continue
81
+ mtype = m.get("type")
82
+ content = m.get("content", "")
83
+ if not isinstance(content, str) or not content.strip():
84
+ continue
85
+ ts = _ts(m.get("timestamp"))
86
+ if mtype == "user":
87
+ timeline.append({"kind": "user", "text": content, "ts": ts})
88
+ elif mtype == "gemini":
89
+ model = m.get("model")
90
+ timeline.append({
91
+ "kind": "assistant",
92
+ "text": content,
93
+ "ts": ts,
94
+ "model": str(model) if model else "",
95
+ })
96
+
97
+ user_indices = [i for i, t in enumerate(timeline) if t["kind"] == "user"]
98
+ records: list[dict[str, Any]] = []
99
+ for n, idx in enumerate(user_indices):
100
+ turn = timeline[idx]
101
+ next_user = user_indices[n + 1] if n + 1 < len(user_indices) else len(timeline)
102
+
103
+ # Model that answered this prompt: the first assistant reply after it.
104
+ model = ""
105
+ context_after_assistant = ""
106
+ for j in range(idx + 1, next_user):
107
+ if timeline[j]["kind"] == "assistant":
108
+ model = timeline[j].get("model", "") or model
109
+ if not context_after_assistant:
110
+ context_after_assistant = timeline[j]["text"]
111
+ # context_before: last assistant text strictly before this prompt.
112
+ context_before = ""
113
+ for j in range(idx - 1, -1, -1):
114
+ if timeline[j]["kind"] == "assistant":
115
+ context_before = timeline[j]["text"]
116
+ break
117
+
118
+ followed_by_correction = False
119
+ correction_text = ""
120
+ if next_user < len(timeline):
121
+ nxt = timeline[next_user]["text"]
122
+ if CORRECTION_RE.search(nxt):
123
+ followed_by_correction = True
124
+ correction_text = nxt
125
+
126
+ records.append({
127
+ "runtime": self.RUNTIME_ID,
128
+ "session_file": str(path),
129
+ "prompt_text": turn["text"],
130
+ "timestamp": turn.get("ts"),
131
+ "model": model,
132
+ "context_before": context_before,
133
+ # This format does not record tool errors — be honest, not invent.
134
+ "followed_by_error": False,
135
+ "error_was_recovered": False,
136
+ "followed_by_correction": followed_by_correction,
137
+ "correction_text": correction_text,
138
+ "error_tool": "",
139
+ "error_text": "",
140
+ })
141
+
142
+ return records
@@ -0,0 +1,68 @@
1
+ """Registry mapping runtime IDs to adapter classes.
2
+
3
+ Usage:
4
+
5
+ from adapters.registry import get_adapters
6
+
7
+ # Get adapters to run for a given --runtime flag value.
8
+ for adapter in get_adapters("auto"):
9
+ files = adapter.discover()
10
+ ...
11
+
12
+ auto mode: runs EVERY registered adapter and merges results. If an adapter
13
+ finds no files (discover() returns []), it is silently skipped. Records from
14
+ each adapter are tagged with their RUNTIME_ID.
15
+
16
+ Unknown runtime: raises ValueError with a clear message so the caller can
17
+ print it and exit 0 with an empty result (never crash).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .base import BaseAdapter
23
+ from .claude import ClaudeAdapter
24
+ from .codex import CodexAdapter
25
+ from .gemini import GeminiAdapter
26
+
27
+ # All registered adapters. Add new runtimes here.
28
+ _ALL_ADAPTERS: list[type[BaseAdapter]] = [
29
+ ClaudeAdapter,
30
+ CodexAdapter,
31
+ GeminiAdapter,
32
+ ]
33
+
34
+ # Map runtime ID -> adapter class
35
+ _REGISTRY: dict[str, type[BaseAdapter]] = {
36
+ cls.RUNTIME_ID: cls for cls in _ALL_ADAPTERS
37
+ }
38
+
39
+
40
+ def get_adapters(runtime: str) -> list[BaseAdapter]:
41
+ """Return instantiated adapters for the given runtime identifier.
42
+
43
+ Args:
44
+ runtime: One of "auto", or a specific runtime ID such as "claude",
45
+ "codex", "gemini".
46
+
47
+ Returns:
48
+ A list of adapter instances. In "auto" mode, all registered adapters
49
+ are returned so the caller can try each one and keep those with data.
50
+
51
+ Raises:
52
+ ValueError: If the runtime ID is not recognised.
53
+ """
54
+ runtime = runtime.strip().lower()
55
+ if runtime == "auto":
56
+ return [cls() for cls in _ALL_ADAPTERS]
57
+ if runtime not in _REGISTRY:
58
+ known = ", ".join(sorted(_REGISTRY.keys()))
59
+ raise ValueError(
60
+ f"Unknown runtime '{runtime}'. Known runtimes: {known}. "
61
+ f"Use 'auto' to try all of them."
62
+ )
63
+ return [_REGISTRY[runtime]()]
64
+
65
+
66
+ def list_runtime_ids() -> list[str]:
67
+ """Return all registered runtime IDs (excluding 'auto')."""
68
+ return sorted(_REGISTRY.keys())