@ericrisco/rsc 0.1.32 → 0.1.33
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.
- package/README.md +4 -4
- package/manifest.json +24 -5
- package/package.json +1 -1
- package/scripts/lib/domains.js +1 -1
- package/skills/analyze/SKILL.md +1 -0
- package/skills/author-skill/SKILL.md +20 -0
- package/skills/author-skill/references/description-recipe.md +2 -0
- package/skills/debug/SKILL.md +1 -1
- package/skills/implement/SKILL.md +72 -2
- package/skills/implement/references/per-task-review.md +46 -0
- package/skills/implement/scripts/review-package +59 -0
- package/skills/implement/scripts/sdd-workspace +47 -0
- package/skills/implement/scripts/task-brief +77 -0
- package/skills/parallel/SKILL.md +29 -0
- package/skills/plan/references/plan-template.md +18 -0
- package/skills/roast-me/SKILL.md +124 -0
- package/skills/roast-me/evals/README.md +76 -0
- package/skills/roast-me/evals/cases.yaml +75 -0
- package/skills/roast-me/prompts/analyze.md +90 -0
- package/skills/roast-me/prompts/compute.md +100 -0
- package/skills/roast-me/prompts/roast.md +181 -0
- package/skills/roast-me/tools/adapters/__init__.py +1 -0
- package/skills/roast-me/tools/adapters/__pycache__/__init__.cpython-312.pyc +0 -0
- package/skills/roast-me/tools/adapters/__pycache__/base.cpython-312.pyc +0 -0
- package/skills/roast-me/tools/adapters/__pycache__/claude.cpython-312.pyc +0 -0
- package/skills/roast-me/tools/adapters/__pycache__/codex.cpython-312.pyc +0 -0
- package/skills/roast-me/tools/adapters/__pycache__/gemini.cpython-312.pyc +0 -0
- package/skills/roast-me/tools/adapters/__pycache__/registry.cpython-312.pyc +0 -0
- package/skills/roast-me/tools/adapters/base.py +53 -0
- package/skills/roast-me/tools/adapters/claude.py +140 -0
- package/skills/roast-me/tools/adapters/codex.py +113 -0
- package/skills/roast-me/tools/adapters/gemini.py +121 -0
- package/skills/roast-me/tools/adapters/registry.py +68 -0
- package/skills/roast-me/tools/extract_prompts.py +520 -0
- package/skills/ship/SKILL.md +9 -1
- package/skills/specify/SKILL.md +21 -1
- package/skills/tasks/SKILL.md +25 -0
- package/skills/worktrees/SKILL.md +25 -0
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaseAdapter(ABC):
|
|
26
|
+
"""Contract every runtime adapter must satisfy."""
|
|
27
|
+
|
|
28
|
+
#: Short identifier used to tag records and in CLI --runtime flag.
|
|
29
|
+
RUNTIME_ID: str = "unknown"
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def discover(self) -> list[Path]:
|
|
33
|
+
"""Return a list of session file paths found for this runtime.
|
|
34
|
+
|
|
35
|
+
Must return [] (not raise) when the runtime is not installed or no
|
|
36
|
+
data directory exists.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def parse(self, path: Path) -> list[dict[str, Any]]:
|
|
41
|
+
"""Parse one session file and return a list of raw prompt records.
|
|
42
|
+
|
|
43
|
+
Each record must include at minimum:
|
|
44
|
+
- "runtime": str — the RUNTIME_ID of this adapter
|
|
45
|
+
- "prompt_text": str — the user's message text
|
|
46
|
+
- "timestamp": float | None — unix timestamp of the message (or None)
|
|
47
|
+
- "session_file": str — stringified path of the source file
|
|
48
|
+
|
|
49
|
+
Additional fields are welcomed and used by extract_prompts.py if present
|
|
50
|
+
(e.g. "model", "cost_usd").
|
|
51
|
+
|
|
52
|
+
Must return [] (not raise) on parse failures.
|
|
53
|
+
"""
|
|
@@ -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,113 @@
|
|
|
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 an event object.
|
|
5
|
+
|
|
6
|
+
CONFIRMED (2026-06, via official CLI reference and community tooling):
|
|
7
|
+
- Base path: ~/.codex/sessions/
|
|
8
|
+
- Subdirectory layout: year/month/day
|
|
9
|
+
- File naming: rollout-<ISO-timestamp>.jsonl
|
|
10
|
+
- Format: JSONL, one event per line
|
|
11
|
+
|
|
12
|
+
STUBBED (format details):
|
|
13
|
+
The internal schema of each JSONL line has not been verified against a live
|
|
14
|
+
Codex installation. Based on community-authored session viewers, events appear
|
|
15
|
+
to carry {"role": "user"|"assistant", "content": "...", ...}. If the role
|
|
16
|
+
field is absent or the schema differs, parse() returns [] gracefully.
|
|
17
|
+
|
|
18
|
+
Degradation: if ~/.codex/ does not exist, discover() returns [].
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .base import BaseAdapter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CodexAdapter(BaseAdapter):
|
|
31
|
+
RUNTIME_ID = "codex"
|
|
32
|
+
|
|
33
|
+
# CONFIRMED: path layout from official CLI docs and community tools.
|
|
34
|
+
SESSIONS_DIR = Path.home() / ".codex" / "sessions"
|
|
35
|
+
|
|
36
|
+
def discover(self) -> list[Path]:
|
|
37
|
+
"""Find all rollout-*.jsonl files under ~/.codex/sessions/."""
|
|
38
|
+
if not self.SESSIONS_DIR.exists():
|
|
39
|
+
return []
|
|
40
|
+
files: list[Path] = []
|
|
41
|
+
try:
|
|
42
|
+
for jsonl in self.SESSIONS_DIR.rglob("rollout-*.jsonl"):
|
|
43
|
+
try:
|
|
44
|
+
if jsonl.is_file():
|
|
45
|
+
files.append(jsonl)
|
|
46
|
+
except OSError:
|
|
47
|
+
continue
|
|
48
|
+
except OSError:
|
|
49
|
+
return []
|
|
50
|
+
return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
|
|
51
|
+
|
|
52
|
+
def parse(self, path: Path) -> list[dict[str, Any]]:
|
|
53
|
+
"""Parse one Codex session JSONL file.
|
|
54
|
+
|
|
55
|
+
STUBBED schema: assumes {"role": "user", "content": "..."} event lines.
|
|
56
|
+
Falls back silently if the schema differs.
|
|
57
|
+
"""
|
|
58
|
+
records: list[dict[str, Any]] = []
|
|
59
|
+
try:
|
|
60
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
61
|
+
except OSError:
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
for line in lines:
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if not line:
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
obj = json.loads(line)
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
# STUBBED: expected schema based on community session viewers.
|
|
74
|
+
role = obj.get("role") or obj.get("type", "")
|
|
75
|
+
if role != "user":
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
content = obj.get("content", "")
|
|
79
|
+
if isinstance(content, list):
|
|
80
|
+
# Some formats use a content array like OpenAI chat API.
|
|
81
|
+
parts = [
|
|
82
|
+
c.get("text", "") if isinstance(c, dict) else str(c)
|
|
83
|
+
for c in content
|
|
84
|
+
if isinstance(c, dict) and c.get("type") == "text"
|
|
85
|
+
]
|
|
86
|
+
text = "\n".join(parts)
|
|
87
|
+
elif isinstance(content, str):
|
|
88
|
+
text = content
|
|
89
|
+
else:
|
|
90
|
+
text = ""
|
|
91
|
+
|
|
92
|
+
if not text.strip():
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
ts = obj.get("timestamp") or obj.get("created_at")
|
|
96
|
+
if isinstance(ts, str):
|
|
97
|
+
try:
|
|
98
|
+
import datetime
|
|
99
|
+
dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
100
|
+
ts = dt.timestamp()
|
|
101
|
+
except (ValueError, AttributeError):
|
|
102
|
+
ts = None
|
|
103
|
+
elif not isinstance(ts, (int, float)):
|
|
104
|
+
ts = None
|
|
105
|
+
|
|
106
|
+
records.append({
|
|
107
|
+
"runtime": self.RUNTIME_ID,
|
|
108
|
+
"session_file": str(path),
|
|
109
|
+
"prompt_text": text,
|
|
110
|
+
"timestamp": ts,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return records
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Gemini CLI adapter (@google/gemini-cli).
|
|
2
|
+
|
|
3
|
+
Session files: ~/.gemini/tmp/<project_hash>/chats/*.jsonl
|
|
4
|
+
Format: JSON Lines. Each line is a MessageRecord.
|
|
5
|
+
|
|
6
|
+
CONFIRMED (2026-06, via official Gemini CLI docs at google-gemini.github.io
|
|
7
|
+
and github.com/google-gemini/gemini-cli session-management.md):
|
|
8
|
+
- Base path: ~/.gemini/tmp/
|
|
9
|
+
- Subdirectory: <project_hash>/chats/
|
|
10
|
+
- File format: JSONL
|
|
11
|
+
- MessageRecord fields include: sessionId, projectHash, model, role,
|
|
12
|
+
content (array of {text: "..."}), and token usage fields.
|
|
13
|
+
|
|
14
|
+
STUBBED (exact MessageRecord schema):
|
|
15
|
+
The exact JSONL schema per line has not been verified against a live Gemini
|
|
16
|
+
CLI installation. The role/content structure is inferred from the official
|
|
17
|
+
session-management docs. If the schema differs, parse() returns [] gracefully.
|
|
18
|
+
|
|
19
|
+
Degradation: if ~/.gemini/ does not exist, discover() returns [].
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .base import BaseAdapter
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GeminiAdapter(BaseAdapter):
|
|
32
|
+
RUNTIME_ID = "gemini"
|
|
33
|
+
|
|
34
|
+
# CONFIRMED: path from official gemini-cli session-management docs.
|
|
35
|
+
GEMINI_TMP_DIR = Path.home() / ".gemini" / "tmp"
|
|
36
|
+
|
|
37
|
+
def discover(self) -> list[Path]:
|
|
38
|
+
"""Find all *.jsonl chat files under ~/.gemini/tmp/<hash>/chats/."""
|
|
39
|
+
if not self.GEMINI_TMP_DIR.exists():
|
|
40
|
+
return []
|
|
41
|
+
files: list[Path] = []
|
|
42
|
+
try:
|
|
43
|
+
# Pattern: ~/.gemini/tmp/<project_hash>/chats/*.jsonl
|
|
44
|
+
for jsonl in self.GEMINI_TMP_DIR.rglob("chats/*.jsonl"):
|
|
45
|
+
try:
|
|
46
|
+
if jsonl.is_file():
|
|
47
|
+
files.append(jsonl)
|
|
48
|
+
except OSError:
|
|
49
|
+
continue
|
|
50
|
+
except OSError:
|
|
51
|
+
return []
|
|
52
|
+
return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
|
|
53
|
+
|
|
54
|
+
def parse(self, path: Path) -> list[dict[str, Any]]:
|
|
55
|
+
"""Parse one Gemini CLI chat JSONL file.
|
|
56
|
+
|
|
57
|
+
STUBBED schema: expected MessageRecord with {role, content: [{text}], ...}.
|
|
58
|
+
Falls back silently if the schema differs.
|
|
59
|
+
"""
|
|
60
|
+
records: list[dict[str, Any]] = []
|
|
61
|
+
try:
|
|
62
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
63
|
+
except OSError:
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
for line in lines:
|
|
67
|
+
line = line.strip()
|
|
68
|
+
if not line:
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
obj = json.loads(line)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
# STUBBED: inferred from gemini-cli session-management docs.
|
|
76
|
+
role = obj.get("role", "")
|
|
77
|
+
if role not in ("user", "USER"):
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
content = obj.get("content", [])
|
|
81
|
+
if isinstance(content, list):
|
|
82
|
+
parts = []
|
|
83
|
+
for block in content:
|
|
84
|
+
if isinstance(block, dict):
|
|
85
|
+
text = block.get("text", "")
|
|
86
|
+
if text:
|
|
87
|
+
parts.append(text)
|
|
88
|
+
text = "\n".join(parts)
|
|
89
|
+
elif isinstance(content, str):
|
|
90
|
+
text = content
|
|
91
|
+
else:
|
|
92
|
+
text = ""
|
|
93
|
+
|
|
94
|
+
if not text.strip():
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
# Gemini MessageRecord may carry token counts and model name.
|
|
98
|
+
model = obj.get("model") or obj.get("modelVersion")
|
|
99
|
+
ts = obj.get("timestamp") or obj.get("createTime")
|
|
100
|
+
if isinstance(ts, str):
|
|
101
|
+
try:
|
|
102
|
+
import datetime
|
|
103
|
+
dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
104
|
+
ts = dt.timestamp()
|
|
105
|
+
except (ValueError, AttributeError):
|
|
106
|
+
ts = None
|
|
107
|
+
elif not isinstance(ts, (int, float)):
|
|
108
|
+
ts = None
|
|
109
|
+
|
|
110
|
+
record: dict[str, Any] = {
|
|
111
|
+
"runtime": self.RUNTIME_ID,
|
|
112
|
+
"session_file": str(path),
|
|
113
|
+
"prompt_text": text,
|
|
114
|
+
"timestamp": ts,
|
|
115
|
+
}
|
|
116
|
+
if model:
|
|
117
|
+
record["model"] = str(model)
|
|
118
|
+
|
|
119
|
+
records.append(record)
|
|
120
|
+
|
|
121
|
+
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())
|