drskill-core 0.3.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.
drskill/cli.py ADDED
@@ -0,0 +1,521 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ import os
5
+ from collections import Counter
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.markup import escape
11
+
12
+ from drskill import deep, interactive, ledger, report, state
13
+ from drskill.ledger import Ack
14
+ from drskill.pipeline import run_scan
15
+
16
+ key_source = interactive.read_key # patched in tests
17
+ line_source = input # patched in tests
18
+
19
+ INIT_TEMPLATE = """\
20
+ # drskill configuration and decision ledger.
21
+ # Commit this file. Acks silence a finding until the skill content changes.
22
+
23
+ [budget]
24
+ catalog_tokens_max = 6000 # per-harness startup catalog budget (approximate tokens)
25
+ body_tokens_warn = 20000 # per-skill body ceiling (approximate tokens)
26
+
27
+ [thresholds]
28
+ near_duplicate = 0.85 # Jaccard similarity that counts as a near duplicate
29
+ description_overlap = 0.6 # cosine similarity that clusters descriptions
30
+ generic_min_distinct_tokens = 2 # fewer distinctive words than this is too vague
31
+ """
32
+
33
+ app = typer.Typer(add_completion=False, help="brew doctor for your agent's skill loadout")
34
+ console = Console()
35
+
36
+
37
+ def _home() -> Path:
38
+ env = os.environ.get("DRSKILL_HOME")
39
+ return Path(env) if env else Path.home()
40
+
41
+
42
+ def _validate_harness(harness: str | None) -> None:
43
+ if harness is None:
44
+ return
45
+ from drskill.harnesses import load_harnesses
46
+
47
+ ids = sorted(h.id for h in load_harnesses())
48
+ if harness not in ids:
49
+ console.print(
50
+ f"[red]error:[/red] unknown harness {escape(harness)}; "
51
+ f"valid ids: {escape(', '.join(ids))}"
52
+ )
53
+ raise typer.Exit(1)
54
+
55
+
56
+ def _warn_if_undetected(
57
+ harness: str | None, root: Path, home: Path, global_mode: bool
58
+ ) -> None:
59
+ if harness is None:
60
+ return
61
+ from drskill.harnesses import detect_harnesses
62
+
63
+ detected = {h.id for h in detect_harnesses(root, home, global_mode)}
64
+ if harness not in detected:
65
+ console.print(
66
+ f"[dim]note: harness {escape(harness)} is not detected on this "
67
+ "machine; scanning its search paths anyway[/dim]"
68
+ )
69
+
70
+
71
+ def _load_config_or_exit(path: Path) -> ledger.Config:
72
+ try:
73
+ return ledger.load_config(path)
74
+ except ledger.LedgerError as e:
75
+ console.print(f"[red]error:[/red] {escape(str(e))}")
76
+ raise typer.Exit(1)
77
+
78
+
79
+ def _load_effective_config_or_exit(
80
+ root: Path, home: Path, global_mode: bool
81
+ ) -> ledger.Config:
82
+ try:
83
+ return ledger.load_effective_config(root, home, global_mode)
84
+ except ledger.LedgerError as e:
85
+ console.print(f"[red]error:[/red] {escape(str(e))}")
86
+ raise typer.Exit(1)
87
+
88
+
89
+ @app.callback()
90
+ def main() -> None:
91
+ pass
92
+
93
+
94
+ def _resolve_refs(refs: list[str], active: list) -> list:
95
+ """Resolve 4-hex finding ids and bare check ids to active findings.
96
+ Exits 1 on no match or on an ambiguous id. Shared by ack and show."""
97
+ import re
98
+
99
+ from drskill.checks import REGISTRY
100
+
101
+ targets: list = []
102
+ for ref in refs:
103
+ if ref in REGISTRY:
104
+ matches = [f for f in active if f.check_id == ref]
105
+ if not matches:
106
+ console.print(f"[red]No active finding matches[/red] {escape(ref)}")
107
+ raise typer.Exit(1)
108
+ targets += [f for f in matches if f not in targets]
109
+ elif re.fullmatch(r"[0-9a-f]{4,64}", ref):
110
+ hits = [f for f in active if f.fingerprint.split(":", 1)[1].startswith(ref)]
111
+ if not hits:
112
+ console.print(f"[red]No active finding matches[/red] id {escape(ref)}")
113
+ raise typer.Exit(1)
114
+ if len(hits) > 1:
115
+ console.print(
116
+ f"[red]Ambiguous id[/red] {escape(ref)}: matches "
117
+ f"{len(hits)} findings; use more characters"
118
+ )
119
+ raise typer.Exit(1)
120
+ if hits[0] not in targets:
121
+ targets.append(hits[0])
122
+ else:
123
+ console.print(
124
+ f"[red]Not a finding id or check id:[/red] {escape(ref)}"
125
+ )
126
+ raise typer.Exit(1)
127
+ return targets
128
+
129
+
130
+ @app.command()
131
+ def scan(
132
+ root: Path = typer.Option(Path("."), "--root", hidden=True),
133
+ global_mode: bool = typer.Option(False, "--global", help="analyze machine-level skills only"),
134
+ ci: bool = typer.Option(False, "--ci", help="exit 2 on unacknowledged warnings"),
135
+ as_json: bool = typer.Option(False, "--json", help="emit findings as JSON"),
136
+ detailed: bool = typer.Option(False, "--detailed", help="also print each harness's skill table"),
137
+ show_all: bool = typer.Option(False, "--all", help="with --detailed, include harnesses with no skills"),
138
+ harness: str | None = typer.Option(None, "--harness", help="scope the scan to one harness"),
139
+ deep_mode: bool = typer.Option(False, "--deep", help="judge flagged pairs with the configured model"),
140
+ max_calls: str = typer.Option("25", "--max-calls", help="model calls per --deep run: a number, or 'all' for no limit"),
141
+ ) -> None:
142
+ """Analyze every detected harness's skill set and report findings."""
143
+ _validate_harness(harness)
144
+ home = _home()
145
+ config = _load_effective_config_or_exit(root, home, global_mode)
146
+ judge = None
147
+ budget: int | None = None
148
+ if deep_mode:
149
+ if max_calls == "all":
150
+ budget = None
151
+ else:
152
+ try:
153
+ budget = int(max_calls)
154
+ if budget < 0:
155
+ raise ValueError
156
+ except ValueError:
157
+ console.print(
158
+ f"[red]--max-calls takes a number or 'all', not[/red] {escape(max_calls)}"
159
+ )
160
+ raise typer.Exit(1)
161
+ from drskill import deep_llm
162
+
163
+ deep.load_user_env(home)
164
+ try:
165
+ judge = deep_llm.build_judge(config.deep.model)
166
+ except deep_llm.DeepUnavailableError as e:
167
+ console.print(f"[red]{escape(str(e))}[/red]")
168
+ raise typer.Exit(1)
169
+ world, findings = run_scan(
170
+ root, home, global_mode, config, harness=harness, judge=judge, max_calls=budget
171
+ )
172
+ active, acked = ledger.filter_findings(findings, config)
173
+ if as_json:
174
+ print(report.to_json(active))
175
+ else:
176
+ _warn_if_undetected(harness, root, home, global_mode)
177
+ spath = state.state_path(root, home, global_mode)
178
+ report.render(
179
+ world, active, acked, console, seen=set(state.load_seen(spath))
180
+ )
181
+ # active plus acked, so an acked finding stays seen if later un-acked.
182
+ # A --harness scan sees only a slice of the project's findings, and
183
+ # writing it would prune every other harness's seen entries.
184
+ if harness is None:
185
+ state.mark_seen(
186
+ spath, [f.fingerprint for f in findings], dt.date.today()
187
+ )
188
+ if deep_mode:
189
+ last_error = getattr(judge, "last_error", None)
190
+ if last_error:
191
+ flat = " ".join(str(last_error).split())
192
+ console.print(
193
+ f"[yellow]deep: model calls are failing; last error: "
194
+ f"{escape(flat)}[/yellow]"
195
+ )
196
+ cache = deep.load_cache(deep.cache_dir(root, home, global_mode))
197
+ remaining = deep.unjudged_count(world, active, cache)
198
+ if remaining:
199
+ plural = "s" if remaining != 1 else ""
200
+ console.print(
201
+ f"deep: {remaining} flagged pair{plural} still unjudged; "
202
+ "raise --max-calls to judge more"
203
+ )
204
+ if detailed:
205
+ console.print()
206
+ report.render_harness_tables(
207
+ world, console, tokens=False, harness=harness, show_all=show_all
208
+ )
209
+ if any(f.severity == "error" for f in active):
210
+ raise typer.Exit(1)
211
+ if ci and any(f.severity == "warning" for f in active):
212
+ raise typer.Exit(2)
213
+
214
+
215
+ @app.command()
216
+ def ack(
217
+ refs: list[str] = typer.Argument(
218
+ None,
219
+ help="finding ids from the report, or a check id followed by skill names",
220
+ ),
221
+ ack_all: bool = typer.Option(
222
+ False, "--all",
223
+ help="ack every active finding, or every finding of the named check",
224
+ ),
225
+ note: str | None = typer.Option(None, "--note"),
226
+ force_local: bool = typer.Option(
227
+ False, "--local", help="record in the project ledger regardless of scope"
228
+ ),
229
+ force_global: bool = typer.Option(
230
+ False, "--global-ack", help="record in the machine ledger (~/.drskill.toml)"
231
+ ),
232
+ root: Path = typer.Option(Path("."), "--root", hidden=True),
233
+ global_mode: bool = typer.Option(False, "--global"),
234
+ ) -> None:
235
+ """Acknowledge findings so they stay silent until the content changes."""
236
+ import re
237
+
238
+ if force_local and force_global:
239
+ console.print("[red]--local and --global-ack are mutually exclusive[/red]")
240
+ raise typer.Exit(1)
241
+ if global_mode and (force_local or force_global):
242
+ console.print("[red]--global mode already writes the machine ledger[/red]")
243
+ raise typer.Exit(1)
244
+ home = _home()
245
+ config = _load_effective_config_or_exit(root, home, global_mode)
246
+ world, findings = run_scan(root, home, global_mode, config)
247
+ active, _ = ledger.filter_findings(findings, config)
248
+ # Notes need no ack; sweeping them into the ledger would hide them and
249
+ # leave a stale ack behind if the verdict cache is ever pruned.
250
+ active = [f for f in active if f.severity != "note"]
251
+ from drskill.checks import REGISTRY
252
+
253
+ refs = refs or []
254
+ targets: list = []
255
+ if ack_all:
256
+ if not refs:
257
+ targets = list(active)
258
+ elif len(refs) == 1 and refs[0] in REGISTRY:
259
+ targets = [f for f in active if f.check_id == refs[0]]
260
+ else:
261
+ console.print("[red]--all takes no arguments, or exactly one check id[/red]")
262
+ raise typer.Exit(1)
263
+ if not targets:
264
+ console.print("[red]No active finding matches[/red]")
265
+ raise typer.Exit(1)
266
+ elif refs and refs[0] in REGISTRY:
267
+ check_id, skills = refs[0], refs[1:]
268
+ wanted = set(skills)
269
+ if wanted:
270
+ exact = [f for f in active if f.check_id == check_id and set(f.contributor_names) == wanted]
271
+ superset = [f for f in active if f.check_id == check_id and wanted <= set(f.contributor_names)]
272
+ matches = exact or superset
273
+ if len(matches) > 1:
274
+ console.print(f"[red]Ambiguous:[/red] {len(matches)} findings match; name all involved skills")
275
+ raise typer.Exit(1)
276
+ else:
277
+ # a bare check id acks the whole class of findings
278
+ matches = [f for f in active if f.check_id == check_id]
279
+ if not matches:
280
+ console.print(f"[red]No active finding matches[/red] {escape(check_id)} {escape(' '.join(skills))}")
281
+ raise typer.Exit(1)
282
+ targets = matches
283
+ elif refs and all(re.fullmatch(r"[0-9a-f]{4,64}", r) for r in refs):
284
+ targets = _resolve_refs(refs, active)
285
+ else:
286
+ console.print(
287
+ "[red]Nothing to ack:[/red] pass finding ids from the report, "
288
+ "a check id with skill names, or --all"
289
+ )
290
+ raise typer.Exit(1)
291
+
292
+ global_ledger = ledger.ledger_path(root, home, True)
293
+ dest_counts: dict[Path, int] = {}
294
+ for f in targets:
295
+ dest = ledger.ack_destination(
296
+ world, f, root, home, global_mode,
297
+ force_local=force_local, force_global=force_global,
298
+ )
299
+ ledger.append_ack(
300
+ dest,
301
+ Ack(check=f.check_id, skills=sorted(f.contributor_names),
302
+ fingerprint=f.fingerprint, note=note, date=dt.date.today()),
303
+ )
304
+ dest_counts[dest] = dest_counts.get(dest, 0) + 1
305
+ label = f"{f.check_id} " + ", ".join(f.contributor_names) if f.contributor_names else f.check_id
306
+ suffix = ""
307
+ if dest == global_ledger and not global_mode:
308
+ suffix = " → ~/.drskill.toml (machine-level skills)"
309
+ console.print(f"Acknowledged [bold]{escape(label)}[/bold]{escape(suffix)}")
310
+ for dest, n in dest_counts.items():
311
+ console.print(f"{n} finding{'s' if n != 1 else ''} → {escape(str(dest))}")
312
+
313
+
314
+ @app.command()
315
+ def show(
316
+ refs: list[str] = typer.Argument(..., help="finding ids or check ids"),
317
+ root: Path = typer.Option(Path("."), "--root", hidden=True),
318
+ global_mode: bool = typer.Option(False, "--global"),
319
+ harness: str | None = typer.Option(None, "--harness"),
320
+ ) -> None:
321
+ """Print the full evidence for specific findings."""
322
+ _validate_harness(harness)
323
+ home = _home()
324
+ config = _load_effective_config_or_exit(root, home, global_mode)
325
+ world, findings = run_scan(root, home, global_mode, config, harness=harness)
326
+ active, _ = ledger.filter_findings(findings, config)
327
+ targets = _resolve_refs(refs, active)
328
+ ordered = report.sort_findings(world, targets, set())
329
+ report.print_findings(
330
+ world, ordered, console, seen={f.fingerprint for f in targets}
331
+ ) # seen = everything: show never tags new
332
+
333
+
334
+ @app.command()
335
+ def review(
336
+ root: Path = typer.Option(Path("."), "--root", hidden=True),
337
+ global_mode: bool = typer.Option(False, "--global"),
338
+ harness: str | None = typer.Option(None, "--harness"),
339
+ ) -> None:
340
+ """Walk the findings one at a time and decide each with one keypress."""
341
+ refusal = interactive.can_interact()
342
+ if refusal:
343
+ console.print(escape(refusal))
344
+ raise typer.Exit(1)
345
+ _validate_harness(harness)
346
+ home = _home()
347
+ config = _load_effective_config_or_exit(root, home, global_mode)
348
+ world, findings = run_scan(root, home, global_mode, config, harness=harness)
349
+ active, _ = ledger.filter_findings(findings, config)
350
+ active = [f for f in active if f.severity != "note"]
351
+ if not active:
352
+ console.print("[green]No findings to review.[/green]")
353
+ return
354
+ spath = state.state_path(root, home, global_mode)
355
+ seen = set(state.load_seen(spath))
356
+ ordered = report.sort_findings(world, active, seen)
357
+ acked: list[tuple] = [] # (finding, destination path)
358
+ fixes: list[str] = []
359
+ displayed: set[str] = set()
360
+ undecided = 0
361
+ quit_early = False
362
+ for idx, f in enumerate(ordered, start=1):
363
+ console.print(f"[dim]{idx} of {len(ordered)}[/dim]")
364
+ report.print_findings(world, [f], console, seen=seen)
365
+ displayed.add(f.fingerprint)
366
+ console.print(
367
+ "[bold]a[/bold] ack · [bold]n[/bold] ack+note · [bold]f[/bold] queue fix"
368
+ " · [bold]s[/bold] skip · [bold]q[/bold] quit"
369
+ )
370
+ while True:
371
+ key = key_source()
372
+ if key in ("a", "n"):
373
+ ack_note = None
374
+ if key == "n":
375
+ try:
376
+ ack_note = line_source("note: ").strip() or None
377
+ except KeyboardInterrupt:
378
+ quit_early = True
379
+ break
380
+ dest = ledger.ack_destination(world, f, root, home, global_mode)
381
+ ledger.append_ack(dest, Ack(
382
+ check=f.check_id, skills=sorted(f.contributor_names),
383
+ fingerprint=f.fingerprint, note=ack_note,
384
+ date=dt.date.today(),
385
+ ))
386
+ acked.append((f, dest))
387
+ break
388
+ if key == "f":
389
+ if f.fix_commands:
390
+ fixes.extend(f.fix_commands)
391
+ else:
392
+ undecided += 1 # nothing to queue; the finding stays open
393
+ break
394
+ if key == "s":
395
+ undecided += 1
396
+ break
397
+ if key in ("q", "\x03"): # q or ctrl-c
398
+ quit_early = True
399
+ break
400
+ console.print("[dim]a/n/f/s/q[/dim]")
401
+ if quit_early:
402
+ undecided += len(ordered) - idx + 1
403
+ break
404
+ _review_summary(acked, fixes, undecided, home)
405
+ if harness is None:
406
+ # only what was displayed becomes seen; keep already-seen entries
407
+ # that still correspond to current findings alive through the prune
408
+ current = {f.fingerprint for f in findings}
409
+ state.mark_seen(spath, displayed | (seen & current), dt.date.today())
410
+
411
+
412
+ def _review_summary(
413
+ acked: list[tuple], fixes: list[str], undecided: int, home: Path
414
+ ) -> None:
415
+ from drskill.report import short_id
416
+
417
+ for f, dest in acked:
418
+ if dest == home / ".drskill.toml":
419
+ where = " → ~/.drskill.toml"
420
+ else:
421
+ where = f" → {dest.name}"
422
+ console.print(
423
+ f"acked [bold]{escape(short_id(f))}[/bold] "
424
+ f"{escape(f.check_id)}{escape(where)}"
425
+ )
426
+ if fixes:
427
+ block = "\n".join(fixes)
428
+ console.print("\nqueued fix commands:\n")
429
+ # display is sanitized; the clipboard gets the raw command text
430
+ console.print(escape(report._sanitize(block)))
431
+ if _to_clipboard(block):
432
+ console.print("[dim](copied to clipboard)[/dim]")
433
+ if undecided:
434
+ console.print(
435
+ f"\n{undecided} finding{'s' if undecided != 1 else ''} left undecided"
436
+ )
437
+
438
+
439
+ def _to_clipboard(text: str) -> bool:
440
+ import shutil
441
+ import subprocess
442
+
443
+ for cmd in (["pbcopy"], ["xclip", "-selection", "clipboard"], ["xsel", "-ib"]):
444
+ if shutil.which(cmd[0]):
445
+ try:
446
+ subprocess.run(cmd, input=text.encode(), check=True, timeout=5)
447
+ return True
448
+ except (OSError, subprocess.SubprocessError):
449
+ return False
450
+ return False
451
+
452
+
453
+ @app.command("list")
454
+ def list_cmd(
455
+ tokens: bool = typer.Option(False, "--tokens"),
456
+ harness: str | None = typer.Option(None, "--harness"),
457
+ show_all: bool = typer.Option(False, "--all", help="include harnesses with no skills"),
458
+ root: Path = typer.Option(Path("."), "--root", hidden=True),
459
+ global_mode: bool = typer.Option(False, "--global"),
460
+ ) -> None:
461
+ """Show each harness's effective skill set."""
462
+ _validate_harness(harness)
463
+ home = _home()
464
+ config = _load_effective_config_or_exit(root, home, global_mode)
465
+ world, _findings = run_scan(root, home, global_mode, config, harness=harness)
466
+ _warn_if_undetected(harness, root, home, global_mode)
467
+ report.render_harness_tables(
468
+ world, console, tokens=tokens, harness=harness, show_all=show_all
469
+ )
470
+
471
+
472
+ @app.command()
473
+ def cache(
474
+ action: str = typer.Argument(..., help="stats or prune"),
475
+ root: Path = typer.Option(Path("."), "--root", hidden=True),
476
+ global_mode: bool = typer.Option(False, "--global", help="use the machine cache"),
477
+ ) -> None:
478
+ """Inspect or prune the committed deep verdict cache."""
479
+ home = _home()
480
+ cdir = deep.cache_dir(root, home, global_mode)
481
+ entries = deep.load_cache(cdir)
482
+ if action == "stats":
483
+ console.print(f"{len(entries)} cached verdicts in {escape(str(cdir))}")
484
+ if not entries:
485
+ return
486
+ for name, count in sorted(Counter(v.verdict for v in entries.values()).items()):
487
+ console.print(f" {escape(name)}: {count}")
488
+ for name, count in sorted(Counter(v.model for v in entries.values()).items()):
489
+ console.print(f" {escape(name)}: {count}")
490
+ dates = sorted(v.date for v in entries.values())
491
+ console.print(f" oldest {escape(dates[0])}, newest {escape(dates[-1])}")
492
+ elif action == "prune":
493
+ config = _load_effective_config_or_exit(root, home, global_mode)
494
+ world, findings = run_scan(root, home, global_mode, config)
495
+ valid = {deep.pair_key(a, b) for a, b in deep.flagged_pairs(world, findings)}
496
+ # Walk the files, not the parsed entries, so corrupt files (which
497
+ # load_cache skips) are pruned instead of lingering forever.
498
+ removed = kept = 0
499
+ for p in sorted(cdir.glob("*.json")) if cdir.is_dir() else []:
500
+ if p.stem in valid and p.stem in entries:
501
+ kept += 1
502
+ else:
503
+ p.unlink()
504
+ removed += 1
505
+ console.print(f"removed {removed} stale verdicts, kept {kept}")
506
+ else:
507
+ console.print(
508
+ f"[red]Unknown action:[/red] {escape(action)} (use stats or prune)"
509
+ )
510
+ raise typer.Exit(1)
511
+
512
+
513
+ @app.command()
514
+ def init(root: Path = typer.Option(Path("."), "--root", hidden=True)) -> None:
515
+ """Write a starter drskill.toml with default budgets and thresholds."""
516
+ path = root / "drskill.toml"
517
+ if path.exists():
518
+ console.print(f"[red]{path} already exists[/red]; not overwriting")
519
+ raise typer.Exit(1)
520
+ path.write_text(INIT_TEMPLATE)
521
+ console.print(f"Wrote {path}")
File without changes