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 ADDED
@@ -0,0 +1,3 @@
1
+ """SkillRunes — analyze coding-agent transcripts and recursively improve a SKILL.md file."""
2
+
3
+ __version__ = "0.1.0"
skillrunes/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Entry point for `python -m skillrunes` and the `skillrunes` console script."""
2
+
3
+
4
+ def main() -> None:
5
+ """Invoke the Typer CLI application."""
6
+ from skillrunes.cli import app
7
+ app()
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
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")