skillrunes 0.1.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.
- skillrunes/__init__.py +3 -0
- skillrunes/__main__.py +11 -0
- skillrunes/cli.py +367 -0
- skillrunes/client.py +283 -0
- skillrunes/config.py +31 -0
- skillrunes/dashboard/__init__.py +5 -0
- skillrunes/dashboard/server.py +170 -0
- skillrunes/dashboard/static/.gitkeep +0 -0
- skillrunes/dashboard/templates/.gitkeep +0 -0
- skillrunes/dashboard/templates/index.html +635 -0
- skillrunes/extract.py +295 -0
- skillrunes/ingest/__init__.py +77 -0
- skillrunes/ingest/base.py +60 -0
- skillrunes/ingest/claude_code.py +229 -0
- skillrunes/ingest/codex.py +40 -0
- skillrunes/models.py +203 -0
- skillrunes/store.py +214 -0
- skillrunes/synthesize.py +129 -0
- skillrunes/utils.py +92 -0
- skillrunes-0.1.0.dist-info/METADATA +130 -0
- skillrunes-0.1.0.dist-info/RECORD +23 -0
- skillrunes-0.1.0.dist-info/WHEEL +4 -0
- skillrunes-0.1.0.dist-info/entry_points.txt +2 -0
skillrunes/__init__.py
ADDED
skillrunes/__main__.py
ADDED
skillrunes/cli.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Typer CLI commands for SkillRunes.
|
|
2
|
+
|
|
3
|
+
Three commands:
|
|
4
|
+
skillrunes init — initialise the .skillrunes/ store for this project
|
|
5
|
+
skillrunes run — analyse new sessions and update SKILL.md
|
|
6
|
+
skillrunes visualize — launch the local observability dashboard
|
|
7
|
+
|
|
8
|
+
No model calls are made from this module. Extraction is delegated to
|
|
9
|
+
extract.py and synthesize.py. All file I/O is delegated to store.py.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import webbrowser
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from dotenv import load_dotenv
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
|
20
|
+
from rich.rule import Rule
|
|
21
|
+
from rich.syntax import Syntax
|
|
22
|
+
|
|
23
|
+
import skillrunes.store as store
|
|
24
|
+
from skillrunes.config import DEFAULT_PROVIDER, PROVIDER_ENV_VAR, SUPPORTED_PROVIDERS
|
|
25
|
+
from skillrunes.extract import analyze_session
|
|
26
|
+
from skillrunes.ingest import get_provider
|
|
27
|
+
from skillrunes.ingest.base import ProviderNotImplementedError
|
|
28
|
+
from skillrunes.synthesize import generate_skill_file
|
|
29
|
+
from skillrunes.utils import compute_metrics, render_skill_diff
|
|
30
|
+
|
|
31
|
+
load_dotenv()
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
err_console = Console(stderr=True)
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(
|
|
37
|
+
name="skillrunes",
|
|
38
|
+
help="Analyse coding-agent sessions and recursively improve a SKILL.md file.",
|
|
39
|
+
no_args_is_help=True,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# init
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def init() -> None:
|
|
49
|
+
"""Initialise the .skillrunes/ store for the current project."""
|
|
50
|
+
cwd = Path.cwd()
|
|
51
|
+
store_path = cwd / ".skillrunes"
|
|
52
|
+
|
|
53
|
+
store.init_store()
|
|
54
|
+
|
|
55
|
+
console.print(
|
|
56
|
+
Panel(
|
|
57
|
+
f"[green]Initialised[/green] [bold]{store_path}[/bold]\n\n"
|
|
58
|
+
"Next steps:\n"
|
|
59
|
+
" 1. Copy [bold].env.example[/bold] → [bold].env[/bold] and set "
|
|
60
|
+
"[bold]ANTHROPIC_API_KEY[/bold]\n"
|
|
61
|
+
" (or skip this step — SkillRunes will fall back to the Claude CLI)\n"
|
|
62
|
+
" 2. Run [bold]skillrunes run[/bold] to analyse sessions and generate SKILL.md",
|
|
63
|
+
title="SkillRunes initialised",
|
|
64
|
+
border_style="green",
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# run
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@app.command()
|
|
74
|
+
def run(
|
|
75
|
+
dry_run: bool = typer.Option(
|
|
76
|
+
False, "--dry-run", help="Show what would change without writing any files."
|
|
77
|
+
),
|
|
78
|
+
provider_name: str | None = typer.Option(
|
|
79
|
+
None,
|
|
80
|
+
"--provider",
|
|
81
|
+
help=(
|
|
82
|
+
"Transcript provider to use "
|
|
83
|
+
f"({', '.join(SUPPORTED_PROVIDERS)}). "
|
|
84
|
+
f"Defaults to ${PROVIDER_ENV_VAR} or {DEFAULT_PROVIDER}."
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Analyse new sessions and update SKILL.md."""
|
|
89
|
+
|
|
90
|
+
# ── 1. Resolve and display cwd immediately ───────────────────────────────
|
|
91
|
+
cwd = Path.cwd()
|
|
92
|
+
console.print(Rule(f"[bold]SkillRunes[/bold] • {cwd}", style="dim"))
|
|
93
|
+
try:
|
|
94
|
+
provider = get_provider(provider_name)
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
97
|
+
raise typer.Exit(1) from exc
|
|
98
|
+
|
|
99
|
+
console.print(
|
|
100
|
+
f"[dim]Active provider:[/dim] [bold]{provider.display_name}[/bold] "
|
|
101
|
+
f"[dim]({provider.name})[/dim]"
|
|
102
|
+
)
|
|
103
|
+
if dry_run:
|
|
104
|
+
console.print("[yellow]Dry run — no files will be written.[/yellow]\n")
|
|
105
|
+
|
|
106
|
+
# ── 2. Ensure store is initialised ───────────────────────────────────────
|
|
107
|
+
store.init_store()
|
|
108
|
+
|
|
109
|
+
# ── 3. Find new sessions ─────────────────────────────────────────────────
|
|
110
|
+
console.print(f"[dim]Scanning sessions for:[/dim] [bold]{cwd}[/bold]")
|
|
111
|
+
try:
|
|
112
|
+
new_session_paths = provider.find_new_sessions(cwd)
|
|
113
|
+
except ProviderNotImplementedError as exc:
|
|
114
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
115
|
+
raise typer.Exit(1) from exc
|
|
116
|
+
|
|
117
|
+
if not new_session_paths:
|
|
118
|
+
console.print(
|
|
119
|
+
"\n[yellow]No new sessions found for this directory.[/yellow]\n"
|
|
120
|
+
"Are you running [bold]skillrunes run[/bold] from the right project folder?\n"
|
|
121
|
+
f"Expected transcripts in: [dim]{provider.expected_location(cwd)}[/dim]"
|
|
122
|
+
)
|
|
123
|
+
raise typer.Exit(0)
|
|
124
|
+
|
|
125
|
+
console.print(
|
|
126
|
+
f"Found [bold green]{len(new_session_paths)}[/bold green] new "
|
|
127
|
+
f"{'session' if len(new_session_paths) == 1 else 'sessions'} to analyse.\n"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# ── 4. Parse + archive + extract each session ────────────────────────────
|
|
131
|
+
new_summaries = []
|
|
132
|
+
|
|
133
|
+
with Progress(
|
|
134
|
+
SpinnerColumn(),
|
|
135
|
+
TextColumn("[progress.description]{task.description}"),
|
|
136
|
+
BarColumn(),
|
|
137
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
138
|
+
console=console,
|
|
139
|
+
transient=True,
|
|
140
|
+
) as progress:
|
|
141
|
+
task = progress.add_task("Analysing sessions…", total=len(new_session_paths))
|
|
142
|
+
|
|
143
|
+
for path in new_session_paths:
|
|
144
|
+
sid = provider.session_id_from_path(path)
|
|
145
|
+
progress.update(task, description=f"Analysing [dim]{sid[:8]}…[/dim]")
|
|
146
|
+
|
|
147
|
+
messages, raw_lines = provider.parse_session_file(path)
|
|
148
|
+
|
|
149
|
+
if not messages:
|
|
150
|
+
progress.advance(task)
|
|
151
|
+
console.print(
|
|
152
|
+
f" [yellow]Skipped[/yellow] {sid[:8]}… — no messages after filtering"
|
|
153
|
+
)
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
# Archive raw lines before any model call so originals are preserved
|
|
157
|
+
# even if the provider later deletes or rotates the transcript.
|
|
158
|
+
if not dry_run:
|
|
159
|
+
store.archive_raw_session(sid, raw_lines)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
summary = analyze_session(messages, sid, raw_lines)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
err_console.print(
|
|
165
|
+
f" [red]Error[/red] extracting {sid[:8]}…: {exc}"
|
|
166
|
+
)
|
|
167
|
+
progress.advance(task)
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
new_summaries.append(summary)
|
|
171
|
+
|
|
172
|
+
# Show a summary panel for each extracted session
|
|
173
|
+
_print_session_panel(summary)
|
|
174
|
+
progress.advance(task)
|
|
175
|
+
|
|
176
|
+
if not new_summaries:
|
|
177
|
+
console.print("[yellow]No sessions were successfully extracted.[/yellow]")
|
|
178
|
+
raise typer.Exit(1)
|
|
179
|
+
|
|
180
|
+
# ── 5. Synthesise updated skill file ─────────────────────────────────────
|
|
181
|
+
console.print()
|
|
182
|
+
console.print("[dim]Synthesising SKILL.md…[/dim]")
|
|
183
|
+
|
|
184
|
+
current_skill_version = store.load_current_skill()
|
|
185
|
+
current_skill_content = current_skill_version.content if current_skill_version else None
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
new_skill_content, change_summary = generate_skill_file(
|
|
189
|
+
new_summaries, current_skill_content
|
|
190
|
+
)
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
err_console.print(f"[red]Error[/red] synthesising SKILL.md: {exc}")
|
|
193
|
+
raise typer.Exit(1) from exc
|
|
194
|
+
|
|
195
|
+
# ── 6. Show diff ─────────────────────────────────────────────────────────
|
|
196
|
+
_print_skill_diff(current_skill_content, new_skill_content, current_skill_version)
|
|
197
|
+
|
|
198
|
+
# ── 7. Persist (skipped on --dry-run) ────────────────────────────────────
|
|
199
|
+
if dry_run:
|
|
200
|
+
console.print(
|
|
201
|
+
"\n[yellow]Dry run — nothing written.[/yellow] "
|
|
202
|
+
"Remove [bold]--dry-run[/bold] to save."
|
|
203
|
+
)
|
|
204
|
+
raise typer.Exit(0)
|
|
205
|
+
|
|
206
|
+
# Save session summaries and updated metrics
|
|
207
|
+
for summary in new_summaries:
|
|
208
|
+
store.save_session_summary(summary)
|
|
209
|
+
|
|
210
|
+
all_summaries = store.load_all_summaries()
|
|
211
|
+
store.save_metrics(compute_metrics(all_summaries))
|
|
212
|
+
|
|
213
|
+
# Save new skill version (archives to .skillrunes/skills/ then writes SKILL.md)
|
|
214
|
+
skill_version = store.save_skill_version(new_skill_content, change_summary)
|
|
215
|
+
console.print(
|
|
216
|
+
f"\n[green]✓[/green] SKILL.md written "
|
|
217
|
+
f"([bold]v{skill_version.version}[/bold] • {len(new_skill_content)} chars)"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# ── 8. Auto-copy to provider-specific skill location when supported ──────
|
|
221
|
+
if provider.name == "claude_code":
|
|
222
|
+
_sync_to_claude_dir(cwd, new_skill_content, dry_run=False)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# visualize
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
@app.command()
|
|
230
|
+
def visualize() -> None:
|
|
231
|
+
"""Launch the local observability dashboard on http://localhost:4823."""
|
|
232
|
+
try:
|
|
233
|
+
import uvicorn
|
|
234
|
+
except ImportError:
|
|
235
|
+
err_console.print(
|
|
236
|
+
"[red]Error:[/red] uvicorn is not installed. "
|
|
237
|
+
"Run [bold]pip install skillrunes[/bold] to install all dependencies."
|
|
238
|
+
)
|
|
239
|
+
raise typer.Exit(1)
|
|
240
|
+
|
|
241
|
+
from skillrunes.config import DASHBOARD_HOST, DASHBOARD_PORT
|
|
242
|
+
|
|
243
|
+
store.init_store()
|
|
244
|
+
|
|
245
|
+
url = f"http://{DASHBOARD_HOST}:{DASHBOARD_PORT}"
|
|
246
|
+
console.print(
|
|
247
|
+
Panel(
|
|
248
|
+
f"Dashboard starting at [bold cyan]{url}[/bold cyan]\n"
|
|
249
|
+
"Press [bold]Ctrl+C[/bold] to stop.",
|
|
250
|
+
title="SkillRunes Dashboard",
|
|
251
|
+
border_style="cyan",
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
webbrowser.open(url)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
uvicorn.run(
|
|
259
|
+
"skillrunes.dashboard.server:app",
|
|
260
|
+
host=DASHBOARD_HOST,
|
|
261
|
+
port=DASHBOARD_PORT,
|
|
262
|
+
log_level="warning",
|
|
263
|
+
)
|
|
264
|
+
except OSError as exc:
|
|
265
|
+
if "address already in use" in str(exc).lower():
|
|
266
|
+
err_console.print(
|
|
267
|
+
f"[red]Error:[/red] Port {DASHBOARD_PORT} is already in use. "
|
|
268
|
+
"Kill the existing process or change DASHBOARD_PORT in config.py."
|
|
269
|
+
)
|
|
270
|
+
raise typer.Exit(1) from exc
|
|
271
|
+
raise
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Internal helpers
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
def _print_session_panel(summary: "SessionSummary") -> None: # type: ignore[name-defined]
|
|
279
|
+
lines: list[str] = [
|
|
280
|
+
f"[dim]Session:[/dim] {summary.session_id[:8]}…",
|
|
281
|
+
f"[dim]Goal:[/dim] {summary.goal}",
|
|
282
|
+
f"[dim]Tokens:[/dim] {summary.token_count:,} "
|
|
283
|
+
f"[dim]Duration:[/dim] {summary.duration_seconds:.0f}s "
|
|
284
|
+
f"[dim]Messages:[/dim] {summary.message_count}",
|
|
285
|
+
]
|
|
286
|
+
if summary.what_worked:
|
|
287
|
+
lines.append(
|
|
288
|
+
"[green]✓ Worked:[/green] " + "; ".join(summary.what_worked[:2])
|
|
289
|
+
+ ("…" if len(summary.what_worked) > 2 else "")
|
|
290
|
+
)
|
|
291
|
+
if summary.what_failed:
|
|
292
|
+
lines.append(
|
|
293
|
+
"[red]✗ Failed:[/red] " + "; ".join(summary.what_failed[:2])
|
|
294
|
+
+ ("…" if len(summary.what_failed) > 2 else "")
|
|
295
|
+
)
|
|
296
|
+
if summary.patterns:
|
|
297
|
+
lines.append(
|
|
298
|
+
"[cyan]◆ Pattern:[/cyan] " + summary.patterns[0]
|
|
299
|
+
+ (f" (+{len(summary.patterns) - 1} more)" if len(summary.patterns) > 1 else "")
|
|
300
|
+
)
|
|
301
|
+
console.print(Panel("\n".join(lines), border_style="dim"))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _print_skill_diff(
|
|
305
|
+
old_content: str | None,
|
|
306
|
+
new_content: str,
|
|
307
|
+
current_version: "SkillVersion | None", # type: ignore[name-defined]
|
|
308
|
+
) -> None:
|
|
309
|
+
if old_content is None:
|
|
310
|
+
console.print(
|
|
311
|
+
Panel(
|
|
312
|
+
f"[green]New SKILL.md will be created[/green] "
|
|
313
|
+
f"({len(new_content)} chars)",
|
|
314
|
+
title="SKILL.md • v1 (new)",
|
|
315
|
+
border_style="green",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
version_num = (current_version.version + 1) if current_version else 1
|
|
321
|
+
diff_text = render_skill_diff(old_content, new_content, version_num)
|
|
322
|
+
|
|
323
|
+
if not diff_text.strip():
|
|
324
|
+
console.print("[dim]No changes to SKILL.md.[/dim]")
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
# Render unified diff with colour using Rich Syntax
|
|
328
|
+
syntax = Syntax(diff_text, "diff", theme="monokai", line_numbers=False)
|
|
329
|
+
console.print(
|
|
330
|
+
Panel(
|
|
331
|
+
syntax,
|
|
332
|
+
title=f"SKILL.md diff • v{version_num - 1} → v{version_num}",
|
|
333
|
+
border_style="blue",
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _sync_to_claude_dir(cwd: Path, skill_content: str, *, dry_run: bool) -> None:
|
|
339
|
+
"""Copy SKILL.md to .claude/SKILL.md if the .claude/ directory exists.
|
|
340
|
+
|
|
341
|
+
This closes the product loop: SKILL.md is generated → Claude Code loads it
|
|
342
|
+
on the next session automatically without the user having to do anything.
|
|
343
|
+
|
|
344
|
+
If .claude/ does not exist, print a tip explaining how to enable this.
|
|
345
|
+
"""
|
|
346
|
+
claude_dir = cwd / ".claude"
|
|
347
|
+
|
|
348
|
+
if not claude_dir.is_dir():
|
|
349
|
+
console.print(
|
|
350
|
+
"\n[dim]Tip:[/dim] Create a [bold].claude/[/bold] folder in your project root "
|
|
351
|
+
"and SkillRunes will automatically place SKILL.md there for Claude Code to load."
|
|
352
|
+
)
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
if dry_run:
|
|
356
|
+
console.print(
|
|
357
|
+
f"\n[dim]Dry run:[/dim] would copy SKILL.md → "
|
|
358
|
+
f"[bold]{claude_dir / 'SKILL.md'}[/bold]"
|
|
359
|
+
)
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
dest = claude_dir / "SKILL.md"
|
|
363
|
+
dest.write_text(skill_content, encoding="utf-8")
|
|
364
|
+
console.print(
|
|
365
|
+
f"[green]✓[/green] Copied SKILL.md → [bold]{dest}[/bold] "
|
|
366
|
+
f"[dim](Claude Code will load this on the next session)[/dim]"
|
|
367
|
+
)
|
skillrunes/client.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Unified model client abstraction for SkillRunes.
|
|
2
|
+
|
|
3
|
+
Two execution paths, selected at call time based on environment:
|
|
4
|
+
|
|
5
|
+
SDK path (ANTHROPIC_API_KEY present in environment):
|
|
6
|
+
Uses anthropic.Anthropic() directly.
|
|
7
|
+
extract_structured() → tool_use with forced tool_choice.
|
|
8
|
+
generate_text() → messages.create, returns text content.
|
|
9
|
+
|
|
10
|
+
Subprocess path (no ANTHROPIC_API_KEY):
|
|
11
|
+
Invokes `claude --print` using Claude Code's own authentication
|
|
12
|
+
(OAuth / keychain — whatever Claude Code is logged in as).
|
|
13
|
+
extract_structured() → --json-schema flag for structured output.
|
|
14
|
+
generate_text() → plain text output.
|
|
15
|
+
Both paths show a Rich spinner since output is not streamed.
|
|
16
|
+
|
|
17
|
+
All modules that need model calls must call get_client() from here and use the
|
|
18
|
+
returned ModelClient. Never instantiate anthropic.Anthropic() or subprocess.run
|
|
19
|
+
(['claude', ...]) directly from extract.py or synthesize.py.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import anthropic
|
|
29
|
+
from anthropic.types import TextBlock, ToolParam
|
|
30
|
+
from dotenv import load_dotenv
|
|
31
|
+
|
|
32
|
+
from skillrunes.config import MODEL
|
|
33
|
+
|
|
34
|
+
load_dotenv()
|
|
35
|
+
|
|
36
|
+
_SUBPROCESS_INSTALL_HINT = (
|
|
37
|
+
"Claude Code not found in PATH. "
|
|
38
|
+
"Install it via: npm install -g @anthropic-ai/claude-code"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Subprocess timeout in seconds — generous for large sessions
|
|
42
|
+
_SUBPROCESS_TIMEOUT = 120
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ModelClient:
|
|
46
|
+
"""Wraps SDK or subprocess path behind a single interface.
|
|
47
|
+
|
|
48
|
+
Instantiate via get_client() rather than directly, so callers are
|
|
49
|
+
decoupled from the selection logic.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, *, sdk_client: anthropic.Anthropic | None = None) -> None:
|
|
53
|
+
self._sdk = sdk_client
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def mode(self) -> str:
|
|
57
|
+
return "sdk" if self._sdk is not None else "subprocess"
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Structured extraction — used by extract.py
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def extract_structured(
|
|
64
|
+
self,
|
|
65
|
+
system: str,
|
|
66
|
+
user: str,
|
|
67
|
+
tool_schema: ToolParam,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
"""Return a dict matching tool_schema by forcing structured output.
|
|
70
|
+
|
|
71
|
+
SDK path: uses tool_use with tool_choice={"type":"tool","name":"..."}.
|
|
72
|
+
Subprocess: uses --json-schema with the schema's input_schema section.
|
|
73
|
+
"""
|
|
74
|
+
if self._sdk is not None:
|
|
75
|
+
return self._sdk_extract(system, user, tool_schema)
|
|
76
|
+
return self._subprocess_extract(system, user, tool_schema)
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Free-form text generation — used by synthesize.py
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def generate_text(self, system: str, user: str) -> str:
|
|
83
|
+
"""Return a plain text response.
|
|
84
|
+
|
|
85
|
+
SDK path: messages.create, joins text content blocks.
|
|
86
|
+
Subprocess: --print with default text output format.
|
|
87
|
+
"""
|
|
88
|
+
if self._sdk is not None:
|
|
89
|
+
return self._sdk_generate(system, user)
|
|
90
|
+
return self._subprocess_generate(system, user)
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# SDK implementations
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _sdk_extract(
|
|
97
|
+
self,
|
|
98
|
+
system: str,
|
|
99
|
+
user: str,
|
|
100
|
+
tool_schema: ToolParam,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
assert self._sdk is not None
|
|
103
|
+
tool_name: str = tool_schema["name"] # type: ignore[index]
|
|
104
|
+
response = self._sdk.messages.create(
|
|
105
|
+
model=MODEL,
|
|
106
|
+
max_tokens=2048,
|
|
107
|
+
system=system,
|
|
108
|
+
tools=[tool_schema],
|
|
109
|
+
tool_choice={"type": "tool", "name": tool_name},
|
|
110
|
+
messages=[{"role": "user", "content": user}],
|
|
111
|
+
)
|
|
112
|
+
for block in response.content:
|
|
113
|
+
if block.type == "tool_use" and block.name == tool_name:
|
|
114
|
+
return block.input # type: ignore[return-value]
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"Model did not return a {tool_name!r} tool call. "
|
|
117
|
+
f"stop_reason={response.stop_reason!r}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _sdk_generate(self, system: str, user: str) -> str:
|
|
121
|
+
assert self._sdk is not None
|
|
122
|
+
response = self._sdk.messages.create(
|
|
123
|
+
model=MODEL,
|
|
124
|
+
max_tokens=4096,
|
|
125
|
+
system=system,
|
|
126
|
+
messages=[{"role": "user", "content": user}],
|
|
127
|
+
)
|
|
128
|
+
parts = [
|
|
129
|
+
block.text
|
|
130
|
+
for block in response.content
|
|
131
|
+
if isinstance(block, TextBlock)
|
|
132
|
+
]
|
|
133
|
+
return "\n".join(parts)
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# Subprocess implementations
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def _subprocess_extract(
|
|
140
|
+
self,
|
|
141
|
+
system: str,
|
|
142
|
+
user: str,
|
|
143
|
+
tool_schema: ToolParam,
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
"""Call claude CLI for structured JSON extraction.
|
|
146
|
+
|
|
147
|
+
--system-prompt combined with --json-schema silently returns empty output
|
|
148
|
+
in some environments. Instead we embed the system instructions and schema
|
|
149
|
+
directly into the user message so only the base --print flag is needed.
|
|
150
|
+
The model is instructed to respond with ONLY a raw JSON object.
|
|
151
|
+
"""
|
|
152
|
+
input_schema = tool_schema.get("input_schema", {}) # type: ignore[union-attr]
|
|
153
|
+
schema_str = json.dumps(input_schema, indent=2)
|
|
154
|
+
combined_input = (
|
|
155
|
+
f"<instructions>\n{system}\n</instructions>\n\n"
|
|
156
|
+
f"<json_schema>\n{schema_str}\n</json_schema>\n\n"
|
|
157
|
+
"IMPORTANT: Respond ONLY with a valid JSON object matching the schema "
|
|
158
|
+
"above. No prose, no markdown fences, no explanation.\n\n"
|
|
159
|
+
f"<session_transcript>\n{user}\n</session_transcript>"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
cmd = ["claude", "--print", "--no-session-persistence"]
|
|
163
|
+
raw = self._run_subprocess(cmd, stdin_input=combined_input)
|
|
164
|
+
raw = _strip_markdown_fences(raw)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
return json.loads(raw)
|
|
168
|
+
except json.JSONDecodeError as exc:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"claude subprocess returned non-JSON output for extraction: {exc}\n"
|
|
171
|
+
f"Output was: {raw[:300]}"
|
|
172
|
+
) from exc
|
|
173
|
+
|
|
174
|
+
def _subprocess_generate(self, system: str, user: str) -> str:
|
|
175
|
+
"""Call claude CLI for free-form text generation.
|
|
176
|
+
|
|
177
|
+
--system-prompt works reliably here because we do NOT combine it with
|
|
178
|
+
--json-schema (that combination produces empty output). The user message
|
|
179
|
+
is passed via stdin rather than as a positional argument.
|
|
180
|
+
"""
|
|
181
|
+
cmd = ["claude", "--print", "--no-session-persistence", "--system-prompt", system]
|
|
182
|
+
return self._run_subprocess(cmd, stdin_input=user)
|
|
183
|
+
|
|
184
|
+
def _run_subprocess(
|
|
185
|
+
self, cmd: list[str], stdin_input: str | None = None
|
|
186
|
+
) -> str:
|
|
187
|
+
"""Run a claude CLI command and return stdout text.
|
|
188
|
+
|
|
189
|
+
User input is passed via stdin rather than as a positional argument —
|
|
190
|
+
the claude CLI silently returns empty output when --system-prompt is long
|
|
191
|
+
and the prompt is a positional argument; stdin always works.
|
|
192
|
+
|
|
193
|
+
Claude Code injects CLAUDECODE=1 and related env vars that cause child
|
|
194
|
+
claude processes to detect nesting and return empty output. We strip
|
|
195
|
+
those vars so the subprocess runs as a fresh non-nested call.
|
|
196
|
+
"""
|
|
197
|
+
_require_claude_in_path()
|
|
198
|
+
|
|
199
|
+
clean_env = _subprocess_env()
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
result = subprocess.run(
|
|
203
|
+
cmd,
|
|
204
|
+
input=stdin_input or "",
|
|
205
|
+
capture_output=True,
|
|
206
|
+
text=True,
|
|
207
|
+
timeout=_SUBPROCESS_TIMEOUT,
|
|
208
|
+
env=clean_env,
|
|
209
|
+
)
|
|
210
|
+
except subprocess.TimeoutExpired as exc:
|
|
211
|
+
raise RuntimeError(
|
|
212
|
+
f"claude subprocess timed out after {_SUBPROCESS_TIMEOUT}s"
|
|
213
|
+
) from exc
|
|
214
|
+
|
|
215
|
+
if result.returncode != 0:
|
|
216
|
+
raise RuntimeError(
|
|
217
|
+
f"claude subprocess exited with code {result.returncode}.\n"
|
|
218
|
+
f"stderr: {result.stderr.strip()[:500]}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return result.stdout.strip()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
# Module-level helpers
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def _require_claude_in_path() -> None:
|
|
229
|
+
if shutil.which("claude") is None:
|
|
230
|
+
raise EnvironmentError(_SUBPROCESS_INSTALL_HINT)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
_CLAUDE_CODE_ENV_PREFIXES = (
|
|
234
|
+
"CLAUDECODE",
|
|
235
|
+
"CLAUDE_CODE_",
|
|
236
|
+
"CLAUDE_EFFORT",
|
|
237
|
+
"AI_AGENT",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _subprocess_env() -> dict[str, str]:
|
|
242
|
+
"""Return os.environ with Claude Code nesting-detection vars stripped.
|
|
243
|
+
|
|
244
|
+
When skillrunes is run from inside Claude Code, CLAUDECODE=1 and related
|
|
245
|
+
vars cause a child `claude --print` process to detect nesting and return
|
|
246
|
+
empty output. Clearing them lets the subprocess run as a fresh session.
|
|
247
|
+
"""
|
|
248
|
+
return {
|
|
249
|
+
k: v
|
|
250
|
+
for k, v in os.environ.items()
|
|
251
|
+
if not any(k.startswith(prefix) for prefix in _CLAUDE_CODE_ENV_PREFIXES)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _strip_markdown_fences(text: str) -> str:
|
|
256
|
+
"""Remove ```json ... ``` or ``` ... ``` wrappers from model output."""
|
|
257
|
+
stripped = text.strip()
|
|
258
|
+
if stripped.startswith("```"):
|
|
259
|
+
lines = stripped.splitlines()
|
|
260
|
+
# Drop first line (``` or ```json) and last line (```)
|
|
261
|
+
inner = lines[1:-1] if lines[-1].strip() == "```" else lines[1:]
|
|
262
|
+
return "\n".join(inner).strip()
|
|
263
|
+
return stripped
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_client() -> ModelClient:
|
|
267
|
+
"""Return a ModelClient using the best available auth method.
|
|
268
|
+
|
|
269
|
+
Prefers the SDK (ANTHROPIC_API_KEY) for reliability and full feature
|
|
270
|
+
support. Falls back to the claude CLI subprocess if no key is set.
|
|
271
|
+
"""
|
|
272
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
273
|
+
if api_key:
|
|
274
|
+
sdk = anthropic.Anthropic(api_key=api_key)
|
|
275
|
+
return ModelClient(sdk_client=sdk)
|
|
276
|
+
|
|
277
|
+
# No API key — fall back to Claude Code CLI subprocess
|
|
278
|
+
_require_claude_in_path() # fail fast with a clear message
|
|
279
|
+
return ModelClient(sdk_client=None)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# Convenience alias for callers that prefer the product-specific name.
|
|
283
|
+
SkillRunesClient = ModelClient
|
skillrunes/config.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Central configuration constants for SkillRunes.
|
|
2
|
+
|
|
3
|
+
Provider names, model names, paths, and tuneable thresholds live here so
|
|
4
|
+
nothing is hardcoded elsewhere in the codebase.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Transcript provider selection
|
|
8
|
+
PROVIDER_ENV_VAR = "SKILLRUNES_PROVIDER"
|
|
9
|
+
DEFAULT_PROVIDER = "claude_code"
|
|
10
|
+
SUPPORTED_PROVIDERS = ("claude_code", "codex")
|
|
11
|
+
|
|
12
|
+
# Claude model used for all API calls. Update here to change globally.
|
|
13
|
+
MODEL = "claude-sonnet-4-20250514"
|
|
14
|
+
|
|
15
|
+
# Local dashboard server settings
|
|
16
|
+
DASHBOARD_HOST = "127.0.0.1"
|
|
17
|
+
DASHBOARD_PORT = 4823
|
|
18
|
+
|
|
19
|
+
# Hidden state directory written inside the current project
|
|
20
|
+
STORE_DIR_NAME = ".skillrunes"
|
|
21
|
+
|
|
22
|
+
# Sessions with more messages than this are split into chunks before analysis
|
|
23
|
+
MAX_MESSAGES_PER_CHUNK = 200
|
|
24
|
+
|
|
25
|
+
# Root folder where Claude Code writes session transcripts
|
|
26
|
+
CLAUDE_PROJECTS_ROOT = "~/.claude/projects"
|
|
27
|
+
|
|
28
|
+
# Known local Codex transcript roots. The directory may exist before a stable
|
|
29
|
+
# JSONL transcript format is available, so CodexProvider still raises a clear
|
|
30
|
+
# not-implemented error until parsing support is added.
|
|
31
|
+
CODEX_SESSION_ROOTS = ("~/.codex/sessions", "~/.Codex/sessions", "~/.Codex/projects")
|