doesitfire 0.1.2__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.
doesitfire/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """doesitfire: test whether agent skills (SKILL.md) actually activate in real coding agents."""
2
+
3
+ __version__ = "0.1.2"
doesitfire/agents.py ADDED
@@ -0,0 +1,310 @@
1
+ """How to run each agent CLI headless and how to tell, from its JSON events,
2
+ whether it activated a skill.
3
+
4
+ - Claude Code: a ``Skill`` tool_use in ``claude -p --output-format stream-json``.
5
+ - OpenCode: its native ``skill`` tool in ``opencode run --format json``.
6
+ - Codex: no activation event exists, so we look for the agent reading
7
+ ``<skill>/SKILL.md`` in the ``command_execution`` items of ``codex exec --json``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass, field
15
+
16
+ from .config import AgentConfig
17
+ from .providers import Provider, container_url, resolve_key
18
+
19
+ CLI_VERSIONS = {"claude-code": "2.1.317", "codex": "0.166.0", "opencode": "1.19.5"}
20
+ SKILLS_DIRS = {"claude-code": ".claude/skills", "codex": ".agents/skills", "opencode": ".opencode/skills"}
21
+ EXPERIMENTAL = {"codex"} # activation is inferred from a file read, not reported by the CLI
22
+
23
+
24
+ @dataclass
25
+ class Invocation:
26
+ argv: list[str]
27
+ env: dict[str, str] = field(default_factory=dict) # plain values (no secrets)
28
+ secret_env: dict[str, str] = field(default_factory=dict) # passed to docker by name only
29
+ allow: list[str] = field(default_factory=list) # base URLs the egress proxy must allow
30
+
31
+
32
+ @dataclass
33
+ class Observation:
34
+ activated: list[str] = field(default_factory=list) # skill names, in order, deduplicated
35
+ input_tokens: int = 0
36
+ output_tokens: int = 0
37
+ reported_cost: float | None = None
38
+ error: str | None = None
39
+ tool_calls: int = 0
40
+ usage_seen: bool = False
41
+
42
+ def add(self, skill: str) -> None:
43
+ if skill not in self.activated:
44
+ self.activated.append(skill)
45
+
46
+
47
+ class Agent:
48
+ name = ""
49
+
50
+ def __init__(self, cfg: AgentConfig, provider: Provider, max_turns: int):
51
+ self.cfg = cfg
52
+ self.provider = provider
53
+ self.max_turns = max_turns
54
+
55
+ @property
56
+ def skills_dir(self) -> str:
57
+ return SKILLS_DIRS[self.name]
58
+
59
+ def build(self, prompt: str) -> Invocation: # pragma: no cover - abstract
60
+ raise NotImplementedError
61
+
62
+ def feed(self, line: str, obs: Observation, skills: set[str]) -> None:
63
+ """Update the observation with one line of the agent's stdout."""
64
+ line = line.strip()
65
+ if not line.startswith("{"):
66
+ return
67
+ try:
68
+ event = json.loads(line)
69
+ except json.JSONDecodeError:
70
+ return
71
+ if isinstance(event, dict):
72
+ self.on_event(event, obs, skills)
73
+
74
+ def on_event(self, event: dict, obs: Observation, skills: set[str]) -> None: # pragma: no cover
75
+ raise NotImplementedError
76
+
77
+ def parse(self, stdout: str, skills: set[str]) -> Observation:
78
+ obs = Observation()
79
+ for line in stdout.splitlines():
80
+ self.feed(line, obs, skills)
81
+ return obs
82
+
83
+ def should_stop(self, obs: Observation, skills: set[str]) -> bool:
84
+ """Stop early once the outcome is known: a tested skill fired, or the turn cap was hit.
85
+
86
+ Only for local models; with paid providers we let the CLI finish so it reports usage.
87
+ """
88
+ if not self.provider.local:
89
+ return False
90
+ return any(s in skills for s in obs.activated) or obs.tool_calls > self.max_turns
91
+
92
+
93
+ def _norm_skill(value) -> str:
94
+ name = str(value or "").strip().lstrip("/").lower()
95
+ return name.split(":")[-1].split()[0] if name else ""
96
+
97
+
98
+ class ClaudeCode(Agent):
99
+ name = "claude-code"
100
+
101
+ def build(self, prompt: str) -> Invocation:
102
+ p = self.provider
103
+ base = container_url(p.anthropic_base or "")
104
+ env = {
105
+ "ANTHROPIC_BASE_URL": base,
106
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
107
+ "DISABLE_AUTOUPDATER": "1",
108
+ "DISABLE_TELEMETRY": "1",
109
+ "DISABLE_ERROR_REPORTING": "1",
110
+ "API_TIMEOUT_MS": "900000",
111
+ }
112
+ secret: dict[str, str] = {}
113
+ key = resolve_key(self.name, p)
114
+ if p.name == "ollama":
115
+ env["ANTHROPIC_AUTH_TOKEN"] = "ollama"
116
+ elif p.name == "openrouter" and key:
117
+ secret["ANTHROPIC_AUTH_TOKEN"] = key[1]
118
+ env["ANTHROPIC_API_KEY"] = ""
119
+ elif key:
120
+ secret[key[0]] = key[1]
121
+ if "claude" not in self.cfg.model.lower():
122
+ # Non-Claude models: route every internal model slot to the same model, and give
123
+ # the skill listing the budget a 200k-context Claude model would get. Without it
124
+ # Claude Code lists skills by name only, which no real user would see.
125
+ for slot in (
126
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
127
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
128
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
129
+ "CLAUDE_CODE_SUBAGENT_MODEL",
130
+ ):
131
+ env[slot] = self.cfg.model
132
+ env["SLASH_COMMAND_TOOL_CHAR_BUDGET"] = "8000"
133
+ tools = "Skill,Read,Glob,Grep"
134
+ argv = [
135
+ "claude",
136
+ "-p",
137
+ prompt,
138
+ "--output-format",
139
+ "stream-json",
140
+ "--verbose",
141
+ "--model",
142
+ self.cfg.model,
143
+ "--max-turns",
144
+ str(self.max_turns),
145
+ "--tools",
146
+ tools,
147
+ "--allowedTools",
148
+ tools,
149
+ "--disallowedTools",
150
+ "Bash,Write,Edit,NotebookEdit,WebFetch,WebSearch,Task",
151
+ "--strict-mcp-config",
152
+ ]
153
+ return Invocation(argv, env, secret, [base])
154
+
155
+ def on_event(self, event: dict, obs: Observation, skills: set[str]) -> None:
156
+ etype = event.get("type")
157
+ if etype == "assistant":
158
+ for block in (event.get("message") or {}).get("content") or []:
159
+ if isinstance(block, dict) and block.get("type") == "tool_use":
160
+ obs.tool_calls += 1
161
+ if block.get("name") == "Skill":
162
+ args = block.get("input") or {}
163
+ name = _norm_skill(args.get("skill") or args.get("command") or args.get("name"))
164
+ if name:
165
+ obs.add(name)
166
+ elif etype == "result":
167
+ usage = event.get("usage") or {}
168
+ obs.usage_seen = True
169
+ obs.input_tokens += (
170
+ int(usage.get("input_tokens") or 0)
171
+ + int(usage.get("cache_read_input_tokens") or 0)
172
+ + int(usage.get("cache_creation_input_tokens") or 0)
173
+ )
174
+ obs.output_tokens += int(usage.get("output_tokens") or 0)
175
+ if event.get("total_cost_usd") is not None:
176
+ obs.reported_cost = float(event["total_cost_usd"])
177
+ if event.get("is_error") and event.get("subtype") not in ("error_max_turns",):
178
+ obs.error = str(event.get("result") or event.get("subtype") or "error")[:300]
179
+ elif etype == "system" and event.get("subtype") == "api_retry" and event.get("attempt", 0) >= 3:
180
+ obs.error = f"model API retrying (status {event.get('error_status')}, {event.get('error')})"
181
+
182
+
183
+ class Codex(Agent):
184
+ name = "codex"
185
+
186
+ def build(self, prompt: str) -> Invocation:
187
+ p = self.provider
188
+ base = container_url(p.openai_base)
189
+ provider_toml = f'{{name="doesitfire",base_url="{base}",wire_api="responses"'
190
+ secret: dict[str, str] = {}
191
+ key = resolve_key(self.name, p)
192
+ if key:
193
+ secret[key[0]] = key[1]
194
+ provider_toml += f',env_key="{key[0]}"'
195
+ provider_toml += "}"
196
+ argv = [
197
+ "codex",
198
+ "exec",
199
+ "--json",
200
+ "--skip-git-repo-check",
201
+ "--ephemeral",
202
+ # Codex's own sandbox (bubblewrap) cannot start in an unprivileged container. The
203
+ # container is the sandbox: read-only root, no capabilities, no network but the model.
204
+ "--dangerously-bypass-approvals-and-sandbox",
205
+ "-c",
206
+ "analytics.enabled=false",
207
+ "-c",
208
+ "model_provider=doesitfire",
209
+ "-c",
210
+ f"model_providers.doesitfire={provider_toml}",
211
+ "-m",
212
+ self.cfg.model,
213
+ ]
214
+ argv.append(prompt)
215
+ return Invocation(argv, {"NO_COLOR": "1"}, secret, [base])
216
+
217
+ @staticmethod
218
+ def skills_in_command(command: str, skills: set[str]) -> list[str]:
219
+ found: list[str] = []
220
+ for match in re.finditer(r"([A-Za-z0-9_.-]+)/SKILL\.md", command):
221
+ name = match.group(1).lower()
222
+ if name not in found:
223
+ found.append(name)
224
+ return found
225
+
226
+ def on_event(self, event: dict, obs: Observation, skills: set[str]) -> None:
227
+ etype = event.get("type")
228
+ item = event.get("item") or {}
229
+ if etype == "item.started" and item.get("type") in ("command_execution", "mcp_tool_call"):
230
+ obs.tool_calls += 1
231
+ text = item.get("command") or json.dumps(item.get("arguments") or "")
232
+ for name in self.skills_in_command(str(text), skills):
233
+ obs.add(name)
234
+ elif etype == "turn.completed":
235
+ usage = event.get("usage") or {}
236
+ obs.usage_seen = True
237
+ obs.input_tokens += int(usage.get("input_tokens") or 0)
238
+ obs.output_tokens += int(usage.get("output_tokens") or 0)
239
+ elif etype in ("turn.failed", "error") and not str(event.get("message", "")).startswith("Reconnecting"):
240
+ msg = (event.get("error") or {}).get("message") if etype == "turn.failed" else event.get("message")
241
+ obs.error = str(msg or etype)[:300]
242
+
243
+
244
+ class OpenCode(Agent):
245
+ name = "opencode"
246
+
247
+ def build(self, prompt: str) -> Invocation:
248
+ p = self.provider
249
+ secret: dict[str, str] = {}
250
+ key = resolve_key(self.name, p)
251
+ if p.name == "anthropic":
252
+ npm, base = "@ai-sdk/anthropic", container_url((p.anthropic_base or "") + "/v1")
253
+ else:
254
+ npm, base = "@ai-sdk/openai-compatible", container_url(p.openai_base)
255
+ options = {"baseURL": base, "apiKey": "ollama"}
256
+ if key:
257
+ secret[key[0]] = key[1]
258
+ options["apiKey"] = "{env:" + key[0] + "}"
259
+ model_cfg: dict = {"name": self.cfg.model}
260
+ config = {
261
+ "$schema": "https://opencode.ai/config.json",
262
+ "autoupdate": False,
263
+ "share": "disabled",
264
+ "provider": {
265
+ "doesitfire": {
266
+ "npm": npm,
267
+ "name": "doesitfire",
268
+ "options": options,
269
+ "models": {self.cfg.model: model_cfg},
270
+ }
271
+ },
272
+ "permission": {"edit": "deny", "bash": "deny", "webfetch": "deny"},
273
+ "agent": {"build": {"steps": self.max_turns}, "title": {"disable": True}},
274
+ }
275
+ env = {
276
+ "OPENCODE_CONFIG_CONTENT": json.dumps(config),
277
+ "OPENCODE_DISABLE_MODELS_FETCH": "1",
278
+ "OPENCODE_DISABLE_AUTOUPDATE": "1",
279
+ "OPENCODE_DISABLE_LSP_DOWNLOAD": "1",
280
+ }
281
+ argv = ["opencode", "run", "--format", "json", "-m", f"doesitfire/{self.cfg.model}", prompt]
282
+ return Invocation(argv, env, secret, [base])
283
+
284
+ def on_event(self, event: dict, obs: Observation, skills: set[str]) -> None:
285
+ etype = event.get("type")
286
+ part = event.get("part") or {}
287
+ if etype == "tool_use":
288
+ obs.tool_calls += 1
289
+ if part.get("tool") == "skill":
290
+ name = _norm_skill(((part.get("state") or {}).get("input") or {}).get("name"))
291
+ if name:
292
+ obs.add(name)
293
+ elif etype == "step_finish":
294
+ tokens = part.get("tokens") or {}
295
+ cache = tokens.get("cache") or {}
296
+ obs.usage_seen = True
297
+ obs.input_tokens += int(tokens.get("input") or 0) + int(cache.get("read") or 0)
298
+ obs.output_tokens += int(tokens.get("output") or 0) + int(tokens.get("reasoning") or 0)
299
+ if part.get("cost") is not None:
300
+ obs.reported_cost = (obs.reported_cost or 0.0) + float(part["cost"])
301
+ elif etype == "error":
302
+ err = event.get("error") or {}
303
+ obs.error = str((err.get("data") or {}).get("message") or err.get("name") or "error")[:300]
304
+
305
+
306
+ AGENT_CLASSES: dict[str, type[Agent]] = {"claude-code": ClaudeCode, "codex": Codex, "opencode": OpenCode}
307
+
308
+
309
+ def make_agent(cfg: AgentConfig, provider: Provider, max_turns: int) -> Agent:
310
+ return AGENT_CLASSES[cfg.name](cfg, provider, max_turns)
doesitfire/cli.py ADDED
@@ -0,0 +1,263 @@
1
+ """doesitfire command line: init, run, report, demo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ import webbrowser
11
+ from importlib import resources
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.markup import escape
18
+
19
+ from . import __version__
20
+ from .config import CONFIG_NAME, find_skill_dirs, load_config, render_template
21
+ from .errors import BudgetExceeded, DoesItFireError
22
+ from .metrics import check_thresholds, summarize
23
+
24
+ app = typer.Typer(
25
+ add_completion=False,
26
+ no_args_is_help=True,
27
+ rich_markup_mode="rich",
28
+ help="Does your SKILL.md actually fire? Test skill activation in Claude Code, Codex and OpenCode.",
29
+ )
30
+ console = Console()
31
+ err = Console(stderr=True)
32
+
33
+
34
+ def _version(value: bool) -> None:
35
+ if value:
36
+ console.print(f"doesitfire {__version__}")
37
+ raise typer.Exit()
38
+
39
+
40
+ @app.callback()
41
+ def _root(
42
+ version: Annotated[
43
+ bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the version.")
44
+ ] = False,
45
+ ) -> None:
46
+ pass
47
+
48
+
49
+ def write_outputs(results: dict, out: Path) -> dict[str, Path]:
50
+ from .html import write_html
51
+ from .report import write_badges, write_junit
52
+
53
+ out.mkdir(parents=True, exist_ok=True)
54
+ paths = {
55
+ "json": out / "results.json",
56
+ "html": out / "report.html",
57
+ "junit": out / "junit.xml",
58
+ "badge": out / "badge.svg",
59
+ "badge_json": out / "badge.json",
60
+ }
61
+ paths["json"].write_text(json.dumps(results, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
62
+ write_html(results, paths["html"])
63
+ write_junit(results, paths["junit"])
64
+ write_badges(results, out)
65
+ return paths
66
+
67
+
68
+ def _open(path: Path) -> None:
69
+ if os.environ.get("CI") or not sys.stdout.isatty():
70
+ return
71
+ with contextlib.suppress(Exception): # opening a browser is best effort
72
+ webbrowser.open(path.resolve().as_uri())
73
+
74
+
75
+ @app.command()
76
+ def init(
77
+ directory: Annotated[Path, typer.Argument(help="Project root to scan for skills.")] = Path("."),
78
+ force: Annotated[bool, typer.Option("--force", help="Overwrite an existing doesitfire.yaml.")] = False,
79
+ ) -> None:
80
+ """Create a doesitfire.yaml for the skills found under DIRECTORY."""
81
+ directory = directory.resolve()
82
+ target = directory / CONFIG_NAME
83
+ if target.exists() and not force:
84
+ raise DoesItFireError(f"{target} already exists. Use --force to overwrite it.")
85
+ skills = find_skill_dirs(directory)
86
+ target.write_text(render_template(skills, directory), encoding="utf-8")
87
+ console.print(f"Wrote [bold]{target}[/bold] with {len(skills) or 'a placeholder for'} skill(s).")
88
+ console.print("Next: replace the TODO prompts, then run [bold]doesitfire run[/bold].")
89
+
90
+
91
+ @app.command()
92
+ def run(
93
+ config: Annotated[Path, typer.Option("--config", "-c", help="Path to doesitfire.yaml.")] = Path(CONFIG_NAME),
94
+ out: Annotated[Path, typer.Option("--out", "-o", help="Output directory.")] = Path("doesitfire-results"),
95
+ agents: Annotated[str | None, typer.Option(help="Comma-separated subset of agents to run.")] = None,
96
+ skills: Annotated[str | None, typer.Option(help="Comma-separated subset of skills to run.")] = None,
97
+ repeats: Annotated[int | None, typer.Option(help="Runs per prompt and agent (overrides the config).")] = None,
98
+ max_cost: Annotated[float, typer.Option("--max-cost", help="Stop before spending more than this many USD.")] = 1.0,
99
+ min_recall: Annotated[
100
+ float | None, typer.Option(help="Exit 1 if any agent x skill recall is below this (0-1).")
101
+ ] = None,
102
+ max_false_fire: Annotated[
103
+ float | None, typer.Option(help="Exit 1 if any false-fire rate is above this (0-1).")
104
+ ] = None,
105
+ rebuild: Annotated[bool, typer.Option(help="Rebuild the agent Docker image.")] = False,
106
+ resume: Annotated[bool, typer.Option(help="Continue an interrupted run from OUT/results.partial.json.")] = False,
107
+ open_report: Annotated[bool, typer.Option("--open/--no-open", help="Open the HTML report when done.")] = True,
108
+ ) -> None:
109
+ """Run every agent on every prompt and report how often each skill fires."""
110
+ from .runner import run_matrix
111
+
112
+ cfg = load_config(config)
113
+ if agents:
114
+ wanted = {a.strip() for a in agents.split(",") if a.strip()}
115
+ unknown = wanted - {a.name for a in cfg.agents}
116
+ if unknown:
117
+ raise DoesItFireError(f"Not configured in {config}: {', '.join(sorted(unknown))}.")
118
+ cfg.agents = [a for a in cfg.agents if a.name in wanted]
119
+ if skills:
120
+ wanted = {s.strip() for s in skills.split(",") if s.strip()}
121
+ unknown = wanted - {s.name for s in cfg.skills}
122
+ if unknown:
123
+ raise DoesItFireError(f"Not in {config}: {', '.join(sorted(unknown))}.")
124
+ cfg.skills = [s for s in cfg.skills if s.name in wanted]
125
+ if repeats is not None:
126
+ if repeats < 1:
127
+ raise DoesItFireError("--repeats must be at least 1.")
128
+ cfg.repeats = repeats
129
+ if max_cost < 0:
130
+ raise DoesItFireError("--max-cost cannot be negative.")
131
+
132
+ n = cfg.repeats * len(cfg.agents) * sum(len(s.should_fire) + len(s.should_not_fire) for s in cfg.skills)
133
+ console.print(
134
+ f"doesitfire {__version__}: {len(cfg.skills)} skill(s) x {len(cfg.agents)} agent(s), "
135
+ f"{cfg.repeats} repeat(s) = {n} sandboxed runs. Budget {max_cost:.2f} USD."
136
+ )
137
+
138
+ def progress(p: dict) -> None:
139
+ t = p["trial"]
140
+ mark = (
141
+ "[red]error[/red]" if t["error"] else ("[magenta]fired[/magenta]" if t["fired"] else "[dim]no fire[/dim]")
142
+ )
143
+ good = (t["fired"] == (t["expect"] == "fire")) and not t["error"]
144
+ tick = "[green]ok[/green]" if good else "[yellow]!![/yellow]"
145
+ console.print(
146
+ f"[dim]{p['done']:>4}/{p['total']}[/dim] {tick} {t['agent']:<11} {t['skill']:<20} {mark:<22} "
147
+ f"[dim]{t['duration_s']:>5.0f}s {escape(' '.join(t['prompt'].split())[:60])}[/dim]"
148
+ + (f"\n [red]{escape(t['error'])}[/red]" if t["error"] else "")
149
+ )
150
+
151
+ out.mkdir(parents=True, exist_ok=True)
152
+ partial_file = out / "results.partial.json"
153
+
154
+ def checkpoint(results: dict) -> None:
155
+ partial_file.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8")
156
+
157
+ previous = None
158
+ if resume:
159
+ if not partial_file.is_file():
160
+ raise DoesItFireError(f"Nothing to resume: {partial_file} does not exist.")
161
+ previous = _load_results(partial_file)
162
+ partial = None
163
+ try:
164
+ results = run_matrix(
165
+ cfg,
166
+ max_cost=max_cost,
167
+ log=console.print,
168
+ progress=progress,
169
+ checkpoint=checkpoint,
170
+ logs_dir=out / "logs",
171
+ resume=previous,
172
+ rebuild=rebuild,
173
+ )
174
+ except BudgetExceeded as exc:
175
+ partial = exc
176
+ results = exc.results or {}
177
+ if not results:
178
+ raise DoesItFireError("No results.")
179
+ from .report import print_matrix
180
+
181
+ paths = write_outputs(results, out)
182
+ partial_file.unlink(missing_ok=True)
183
+ summary = print_matrix(results, console)
184
+ console.print(f"Report: {paths['html']} JSON: {paths['json']} JUnit: {paths['junit']} Badge: {paths['badge']}")
185
+ if open_report:
186
+ _open(paths["html"])
187
+ if partial is not None:
188
+ err.print(f"[bold red]Budget limit reached, results are partial:[/bold red] {partial}")
189
+ raise typer.Exit(partial.exit_code)
190
+ failures = check_thresholds(summary, min_recall, max_false_fire)
191
+ if failures:
192
+ err.print("[bold red]Thresholds not met:[/bold red]\n " + "\n ".join(failures))
193
+ raise typer.Exit(1)
194
+
195
+
196
+ @app.command()
197
+ def report(
198
+ results_file: Annotated[Path, typer.Argument(help="A results.json written by 'doesitfire run'.")],
199
+ out: Annotated[
200
+ Path | None, typer.Option("--out", "-o", help="Output directory (default: next to the JSON).")
201
+ ] = None,
202
+ open_report: Annotated[bool, typer.Option("--open/--no-open")] = False,
203
+ ) -> None:
204
+ """Re-render the matrix, HTML, JUnit and badge from a results.json."""
205
+ from .report import print_matrix
206
+
207
+ results = _load_results(results_file)
208
+ paths = write_outputs(results, out or results_file.parent)
209
+ print_matrix(results, console)
210
+ console.print(f"Report: {paths['html']}")
211
+ if open_report:
212
+ _open(paths["html"])
213
+
214
+
215
+ def _load_results(path: Path) -> dict:
216
+ try:
217
+ data = json.loads(path.read_text(encoding="utf-8"))
218
+ except FileNotFoundError:
219
+ raise DoesItFireError(f"{path} not found.") from None
220
+ except json.JSONDecodeError as exc:
221
+ raise DoesItFireError(f"{path} is not valid JSON: {exc}") from None
222
+ if not isinstance(data, dict) or data.get("tool") != "doesitfire":
223
+ raise DoesItFireError(f"{path} is not a doesitfire results file.")
224
+ summarize(data) # validates the structure early
225
+ return data
226
+
227
+
228
+ @app.command()
229
+ def demo(
230
+ out: Annotated[
231
+ Path | None, typer.Option("--out", "-o", help="Where to write the report (default: a temp dir).")
232
+ ] = None,
233
+ open_report: Annotated[bool, typer.Option("--open/--no-open", help="Open the HTML report.")] = True,
234
+ ) -> None:
235
+ """Show a RECORDED real run of the three example skills. No keys, no Docker, no model."""
236
+ from .report import print_matrix
237
+
238
+ data = resources.files("doesitfire") / "data" / "demo-results.json"
239
+ results = json.loads(data.read_text(encoding="utf-8"))
240
+ target = out or Path(tempfile.mkdtemp(prefix="doesitfire-demo-"))
241
+ paths = write_outputs(results, target)
242
+ print_matrix(results, console)
243
+ console.print(f"Report: {paths['html']}")
244
+ console.print("Run it yourself: [bold]doesitfire run -c examples/doesitfire.yaml[/bold] (needs Docker + Ollama).")
245
+ if open_report:
246
+ _open(paths["html"])
247
+
248
+
249
+ def main() -> None:
250
+ try:
251
+ app()
252
+ except DoesItFireError as exc:
253
+ err.print(f"[bold red]Error:[/bold red] {escape(str(exc))}")
254
+ sys.exit(exc.exit_code)
255
+ except KeyboardInterrupt:
256
+ err.print("[yellow]Interrupted.[/yellow]")
257
+ sys.exit(130)
258
+
259
+
260
+ if __name__ == "__main__": # pragma: no cover
261
+ main()
262
+
263
+ __all__ = ["app", "main"]