witan-code 0.2.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.
witan_code/cli.py ADDED
@@ -0,0 +1,722 @@
1
+ """The ``witan-code`` CLI (code graph + cross-repo bridge).
2
+
3
+ Exposed standalone as ``witan-code`` and mounted as ``witan code`` (see
4
+ pyproject ``[project.scripts]``).
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ import cyclopts
11
+
12
+ from . import indexer
13
+
14
+ app = cyclopts.App(
15
+ name="witan-code",
16
+ help="witan-code — tree-sitter code graph + cross-repo bridge.",
17
+ )
18
+
19
+ BindingKind = Literal["env_var", "package", "service", "endpoint"]
20
+
21
+
22
+ @app.command
23
+ def index(path: Path = Path(".")) -> None:
24
+ """Incrementally index PATH (file or directory). Unchanged files are skipped."""
25
+ stats = indexer.index_path(path, force=False)
26
+ _print_summary("index", path, stats)
27
+
28
+
29
+ @app.command
30
+ def reindex(path: Path = Path(".")) -> None:
31
+ """Force re-index PATH, ignoring content hashes."""
32
+ stats = indexer.index_path(path, force=True)
33
+ _print_summary("reindex", path, stats)
34
+
35
+
36
+ @app.command
37
+ def deps(
38
+ kind: BindingKind | None = None,
39
+ repo: str | None = None,
40
+ html: Path | None = None,
41
+ open_browser: bool = False,
42
+ min_precision: Literal["precise", "heuristic", "fuzzy"] = "heuristic",
43
+ ) -> None:
44
+ """Visualize cross-repo dependencies from the shared bridge store.
45
+
46
+ Prints a Rich summary of "repo A depends on repo B" links (A consumes a
47
+ contract B provides). Pass --html PATH to also emit an interactive graph.
48
+
49
+ Parameters
50
+ ----------
51
+ kind:
52
+ Filter to one contract kind (env_var/package/service/endpoint).
53
+ repo:
54
+ Keep only links touching a repo whose slug contains this substring.
55
+ html:
56
+ Write a self-contained interactive HTML graph to this path.
57
+ open_browser:
58
+ Open the generated HTML in the default browser.
59
+ min_precision:
60
+ Minimum edge precision tier (docs/EDGE_PRECISION_TIERS.md). Default
61
+ `heuristic` preserves prior behavior (every consumer/provider link
62
+ this command has always shown). `precise` keeps only edges also
63
+ covered by a Stage-2 canonical-symbol join — see `witan code stitch`.
64
+ """
65
+ from . import config as cfg_module
66
+ from . import store as store_module
67
+ from . import visualize
68
+ from .graph import OmnigraphClient
69
+
70
+ cfg = cfg_module.load()
71
+ store = store_module.bridge_store(cfg)
72
+ if not store.exists():
73
+ print("No bridge store yet — run `witan code index` in your repos first.")
74
+ return
75
+
76
+ client = OmnigraphClient(str(store), cfg.queries_dir)
77
+ rows = client.read("bridge.gq", "all_bindings", {})
78
+ repo_symbol_rows = (
79
+ client.read("bridge.gq", "all_repo_symbols", {})
80
+ if min_precision == "precise"
81
+ else None
82
+ )
83
+ graph = visualize.build_graph(
84
+ rows,
85
+ kind=kind,
86
+ repo=repo,
87
+ min_precision=min_precision,
88
+ repo_symbol_rows=repo_symbol_rows,
89
+ )
90
+ visualize.render_rich(graph)
91
+
92
+ if html is not None:
93
+ out = visualize.render_html(graph, html)
94
+ print(f"\nwrote {out}")
95
+ if open_browser:
96
+ import webbrowser
97
+
98
+ webbrowser.open(out.resolve().as_uri())
99
+
100
+
101
+ @app.command
102
+ def symbols(
103
+ repo: str | None = None,
104
+ role: Literal["exported", "external"] | None = None,
105
+ scheme: str | None = None,
106
+ ) -> None:
107
+ """Print a repo's symbol table from the bridge store (docs/SYMBOL_TABLE.md).
108
+
109
+ One row per (role, symbol): `exported` rows are the repo's public contract
110
+ surface; `external` rows are unresolved references Stage 2 joins against
111
+ other repos' exports.
112
+
113
+ Parameters
114
+ ----------
115
+ repo:
116
+ Canonical repo URI. Defaults to the repo detected from the CWD.
117
+ role:
118
+ Filter to exported or external rows.
119
+ scheme:
120
+ Filter to one symbol scheme (http/env/pkg/svc).
121
+ """
122
+ from rich.console import Console
123
+ from rich.table import Table
124
+
125
+ from . import config as cfg_module
126
+ from . import repo as repo_module
127
+ from . import store as store_module
128
+ from .graph import OmnigraphClient
129
+
130
+ console = Console()
131
+ cfg = cfg_module.load()
132
+ store = store_module.bridge_store(cfg)
133
+ if not store.exists():
134
+ console.print(
135
+ "No bridge store yet — run `witan code index` in your repos first."
136
+ )
137
+ return
138
+
139
+ repo = repo or repo_module.detect()
140
+ if not repo:
141
+ console.print("No repo detected — pass --repo <canonical URI>.")
142
+ return
143
+
144
+ client = OmnigraphClient(str(store), cfg.queries_dir)
145
+ rows = client.read("bridge.gq", "repo_symbols", {"repo": repo})
146
+ rows = [
147
+ r
148
+ for r in rows
149
+ if (role is None or r.get("role") == role)
150
+ and (scheme is None or r.get("scheme") == scheme)
151
+ ]
152
+ if not rows:
153
+ console.print(f"[dim]No symbol table rows for {repo}.[/dim]")
154
+ return
155
+
156
+ table = Table(title=f"Symbol table — {repo}", header_style="bold")
157
+ for col in ("role", "symbol", "kind", "refs", "conf", "where"):
158
+ table.add_column(col)
159
+ for r in rows:
160
+ conf = r.get("confidence")
161
+ where = (
162
+ f"{r.get('file') or ''}:{r.get('line')}"
163
+ if r.get("line")
164
+ else (r.get("file") or "")
165
+ )
166
+ table.add_row(
167
+ r.get("role", ""),
168
+ r.get("symbol", ""),
169
+ r.get("kind", ""),
170
+ str(r.get("n_refs", "")),
171
+ f"{conf:.2f}" if isinstance(conf, (int, float)) else "",
172
+ where,
173
+ )
174
+ console.print(table)
175
+
176
+
177
+ @app.command
178
+ def stitch(repo: str | None = None, *, unresolved: bool = False) -> None:
179
+ """Print Stage-2 precise cross-repo edges from the bridge store (docs/SYMBOL_TABLE.md).
180
+
181
+ Joins every repo's unresolved external symbols against other repos'
182
+ exported symbols by canonical symbol string — distinct from the coarser
183
+ `witan code deps` heuristic (kind, key_norm) grouping.
184
+
185
+ Parameters
186
+ ----------
187
+ repo:
188
+ Keep only edges/gaps touching this repo. Omit to see the whole store.
189
+ unresolved:
190
+ Print external references with no precise match instead of edges —
191
+ gaps in indexing coverage (a provider isn't indexed yet, or none
192
+ exists in this SOA).
193
+ """
194
+ from rich.console import Console
195
+ from rich.table import Table
196
+
197
+ from . import config as cfg_module
198
+ from . import store as store_module
199
+ from . import stitch as stitch_module
200
+ from .graph import OmnigraphClient
201
+
202
+ console = Console()
203
+ cfg = cfg_module.load()
204
+ store = store_module.bridge_store(cfg)
205
+ if not store.exists():
206
+ console.print(
207
+ "No bridge store yet — run `witan code index` in your repos first."
208
+ )
209
+ return
210
+
211
+ client = OmnigraphClient(str(store), cfg.queries_dir)
212
+ rows = client.read("bridge.gq", "all_repo_symbols", {})
213
+ edges, unresolved_rows = stitch_module.resolve(rows)
214
+
215
+ if unresolved:
216
+ if repo is not None:
217
+ unresolved_rows = [r for r in unresolved_rows if r["repo"] == repo]
218
+ if not unresolved_rows:
219
+ console.print("[dim]No unresolved external symbols.[/dim]")
220
+ return
221
+ table = Table(title="Unresolved external symbols", header_style="bold")
222
+ for col in ("repo", "symbol", "kind", "refs"):
223
+ table.add_column(col)
224
+ for r in sorted(
225
+ unresolved_rows, key=lambda r: (r["repo"] or "", r["symbol"] or "")
226
+ ):
227
+ n_refs = r.get("n_refs")
228
+ table.add_row(
229
+ r["repo"] or "",
230
+ r["symbol"] or "",
231
+ r["kind"] or "",
232
+ str(n_refs) if n_refs is not None else "",
233
+ )
234
+ console.print(table)
235
+ return
236
+
237
+ if repo is not None:
238
+ edges = [e for e in edges if repo in (e.consumer_repo, e.provider_repo)]
239
+ if not edges:
240
+ console.print("[dim]No precise cross-repo edges.[/dim]")
241
+ return
242
+ table = Table(title="Precise cross-repo edges (Stage 2)", header_style="bold")
243
+ for col in ("consumer", "provider", "kind", "matches", "preferred", "ambiguous"):
244
+ table.add_column(col)
245
+ for e in sorted(
246
+ edges,
247
+ key=lambda e: (e.consumer_repo or "", e.provider_repo or "", e.kind or ""),
248
+ ):
249
+ table.add_row(
250
+ e.consumer_repo or "",
251
+ e.provider_repo or "",
252
+ e.kind or "",
253
+ str(e.match_count),
254
+ "yes" if e.preferred else "",
255
+ "yes" if e.ambiguous_version else "",
256
+ )
257
+ console.print(table)
258
+
259
+
260
+ @app.command(name="inject-context")
261
+ def inject_context_cmd() -> None:
262
+ """Print a short code-graph status block for the UserPromptSubmit hook.
263
+
264
+ Registered as the bare ``UserPromptSubmit`` hook command; always exits 0
265
+ and prints nothing when there's no store or in-flight index for the
266
+ current repo.
267
+ """
268
+ import sys
269
+
270
+ from . import context as context_module
271
+
272
+ try:
273
+ text = context_module.inject_context()
274
+ except Exception: # noqa: BLE001 — must never fail the hook
275
+ return
276
+ if text:
277
+ sys.stdout.write(text) # inject_context() already ends with "\n"
278
+
279
+
280
+ @app.command
281
+ def serve() -> None:
282
+ """Run the code-graph MCP server standalone (code_* tools only)."""
283
+ from .server import mcp
284
+
285
+ mcp.run()
286
+
287
+
288
+ def _resolve_store(store: str | None, *, bridge: bool = False) -> Path | None:
289
+ """Resolve a store path — explicit, the shared bridge, or the current
290
+ repo's — printing and returning ``None`` if it doesn't exist yet (nothing
291
+ to compact). Expands ``~`` in an explicit path so ``--store ~/…`` isn't
292
+ treated as missing.
293
+ """
294
+ from . import config as cfg_module
295
+ from . import repo as repo_module
296
+
297
+ cfg = cfg_module.load()
298
+ if store is not None:
299
+ path = Path(store).expanduser()
300
+ elif bridge:
301
+ path = cfg_module.bridge_store_path(cfg.code_dir)
302
+ else:
303
+ slug = repo_module.detect()
304
+ if slug is None:
305
+ print("No repo detected — pass --store PATH or --bridge.")
306
+ return None
307
+ path = cfg_module.store_path(slug, cfg.code_dir)
308
+ if not path.exists():
309
+ print(f"No store at {path} — nothing to do.")
310
+ return None
311
+ return path
312
+
313
+
314
+ def _maintenance_client(store: Path):
315
+ from . import config as cfg_module
316
+ from .graph import OmnigraphClient
317
+
318
+ return OmnigraphClient(str(store), cfg_module.load().queries_dir)
319
+
320
+
321
+ @app.command
322
+ def optimize(*, store: str | None = None, bridge: bool = False) -> None:
323
+ """Compact a code-graph store's Lance fragments (non-destructive).
324
+
325
+ Collapses the many tiny fragments that accrue from every index/reindex so
326
+ opening the store stays cheap. Safe to run repeatedly; takes the store's
327
+ write lock.
328
+
329
+ Parameters
330
+ ----------
331
+ store: Store path to optimize (default: the current repo's store).
332
+ bridge: Optimize the shared cross-repo bridge store instead.
333
+ """
334
+ path = _resolve_store(store, bridge=bridge)
335
+ if path is None:
336
+ return
337
+ print(f"Optimizing {path} …")
338
+ _maintenance_client(path).optimize()
339
+ print("Optimized. (run `witan-code cleanup` to reclaim disk)")
340
+
341
+
342
+ @app.command
343
+ def cleanup(
344
+ *,
345
+ store: str | None = None,
346
+ bridge: bool = False,
347
+ keep: int = 10,
348
+ older_than: str | None = None,
349
+ yes: bool = False,
350
+ ) -> None:
351
+ """Remove old Lance versions from a code-graph store (**destructive**).
352
+
353
+ ``optimize`` compacts fragments but leaves old versions behind; this GCs
354
+ them, keeping the most recent ``keep`` versions per table (and/or those
355
+ newer than ``older_than``). Irreversible, so it requires ``--yes``.
356
+
357
+ Parameters
358
+ ----------
359
+ store: Store path to clean (default: the current repo's store).
360
+ bridge: Clean the shared cross-repo bridge store instead.
361
+ keep: Number of recent versions to keep per table.
362
+ older_than: Also keep versions newer than this Go-style duration (e.g. 7d).
363
+ yes: Confirm the destructive operation (required to actually run).
364
+ """
365
+ path = _resolve_store(store, bridge=bridge)
366
+ if path is None:
367
+ return
368
+ if not yes:
369
+ print(
370
+ f"cleanup is destructive — would keep the {keep} most recent "
371
+ f"version(s) per table"
372
+ + (f" and anything newer than {older_than}" if older_than else "")
373
+ + f" in {path}.\nRe-run with --yes to proceed."
374
+ )
375
+ return
376
+ print(f"Cleaning up {path} (keep={keep}) …")
377
+ _maintenance_client(path).cleanup(keep=keep, older_than=older_than)
378
+ print("Cleaned up.")
379
+
380
+
381
+ @app.command
382
+ def checkpoint() -> None:
383
+ """Opportunistically compact the current repo's store(s) (Stop hook).
384
+
385
+ Spawns a throttled, detached ``witan-code optimize`` for the current
386
+ repo's store and the shared bridge store, each at most once per
387
+ ``WITAN_CODE_OPTIMIZE_INTERVAL``, if either exists and is due. Best-effort
388
+ and non-blocking: always exits 0 and never raises, so a maintenance
389
+ failure can't fail the Stop hook. Registered as the bare ``Stop`` hook
390
+ command; not usually run by hand.
391
+ """
392
+ from . import config as cfg_module
393
+ from . import maintenance as maintenance_module
394
+ from . import repo as repo_module
395
+
396
+ cfg = cfg_module.load()
397
+ slug = repo_module.detect()
398
+ if slug is not None:
399
+ try:
400
+ maintenance_module.spawn_background_optimize(
401
+ cfg_module.store_path(slug, cfg.code_dir)
402
+ )
403
+ except Exception: # noqa: BLE001 — maintenance must never fail the hook
404
+ pass
405
+ try:
406
+ maintenance_module.spawn_background_optimize(
407
+ cfg_module.bridge_store_path(cfg.code_dir)
408
+ )
409
+ except Exception: # noqa: BLE001 — maintenance must never fail the hook
410
+ pass
411
+
412
+
413
+ @app.command(name="session-init")
414
+ def session_init_cmd() -> None:
415
+ """Seed/refresh the whole repo's code graph in the background (SessionStart hook).
416
+
417
+ Detached and non-blocking — returns immediately regardless of repo size.
418
+ A per-repo lock (shared with ``inject-context``'s "indexing in progress"
419
+ check) prevents overlapping sessions from indexing at once. Registered as
420
+ the bare ``SessionStart`` hook command; not usually run by hand.
421
+ """
422
+ from . import hooks as hooks_module
423
+
424
+ try:
425
+ hooks_module.session_init()
426
+ except Exception: # noqa: BLE001 — must never fail the hook
427
+ pass
428
+
429
+
430
+ @app.command(name="_index-and-unlock", show=False)
431
+ def _index_and_unlock_cmd(target: Path, lock: Path) -> None:
432
+ """Internal — run only by the detached child ``session-init`` spawns."""
433
+ from . import hooks as hooks_module
434
+
435
+ hooks_module.index_and_unlock(target, lock)
436
+
437
+
438
+ @app.command(name="reindex-hook")
439
+ def reindex_hook_cmd() -> None:
440
+ """Incrementally reindex the file named in stdin's hook JSON (PostToolUse hook).
441
+
442
+ Reads the Claude Code hook payload from stdin, extracts
443
+ ``tool_input.file_path`` (or ``path``/``filename``), and reindexes it if
444
+ it exists and is a known source type — foreground and fast (one file), so
445
+ the agent sees the change land immediately. Best-effort: a missing or
446
+ malformed payload is a silent no-op. Registered as the bare
447
+ ``PostToolUse`` (matcher ``Edit|Write``) hook command; not usually run by
448
+ hand.
449
+ """
450
+ import sys
451
+
452
+ from . import hooks as hooks_module
453
+
454
+ try:
455
+ hooks_module.reindex_hook(sys.stdin.read())
456
+ except Exception: # noqa: BLE001 — must never fail the hook
457
+ pass
458
+
459
+
460
+ _AGENT_NAMES = {
461
+ "claude": "Claude Code",
462
+ "pi": "Pi",
463
+ "copilot": "GitHub Copilot",
464
+ "opencode": "OpenCode",
465
+ }
466
+ AgentName = Literal["claude", "pi", "copilot", "opencode", "all"]
467
+
468
+
469
+ def _report_setup(name: str, result, *, dry_run: bool) -> None:
470
+ print(f"\n{_AGENT_NAMES.get(name, name)}")
471
+ for path in result.planned:
472
+ tag = " (dry-run)" if dry_run else ""
473
+ print(f" -> {path}{tag}")
474
+ for path, reason in result.skipped:
475
+ print(f" skip {path} — {reason}")
476
+
477
+
478
+ @app.command
479
+ def setup(
480
+ *,
481
+ agent: AgentName = "claude",
482
+ author: str | None = None,
483
+ dry_run: bool = False,
484
+ ) -> None:
485
+ """Install witan-code for one or all supported coding agents.
486
+
487
+ Installs the omnigraph binary to ~/.local/bin/, copies the bundled skill
488
+ and Pi extension to the agent's config directories, registers the four
489
+ hooks (bare CLI commands — no wrapper scripts to copy), and merges the
490
+ witan-code MCP server entry into the agent's config file. Independent of
491
+ `witan setup` — running both is fine (each only touches its own entries);
492
+ running just this one is enough for a witan-code-only install.
493
+
494
+ Re-run after every upgrade to refresh installed files.
495
+
496
+ Parameters
497
+ ----------
498
+ agent: Target agent — claude | pi | copilot | opencode | all.
499
+ author: Name written to graph nodes (default: git config user.name or $USER).
500
+ dry_run: Print what would happen without writing anything.
501
+ """
502
+ import os
503
+ import subprocess
504
+
505
+ from agent_config_kit import (
506
+ apply,
507
+ apply_all,
508
+ detect_installed_platforms,
509
+ known_platforms,
510
+ )
511
+
512
+ from .setup import install_omnigraph, witan_code_bundle
513
+
514
+ pkg_dir = Path(__file__).parent
515
+
516
+ if author is None:
517
+ try:
518
+ author = subprocess.check_output(
519
+ ["git", "config", "user.name"],
520
+ text=True,
521
+ stderr=subprocess.DEVNULL,
522
+ ).strip()
523
+ except (subprocess.CalledProcessError, FileNotFoundError):
524
+ author = ""
525
+ author = author or os.environ.get("USER", "unknown")
526
+
527
+ print("omnigraph binary")
528
+ install_omnigraph(dry_run)
529
+
530
+ bundle = witan_code_bundle(pkg_dir, author)
531
+
532
+ if agent == "all":
533
+ for name, result in apply_all(bundle, dry_run=dry_run).items():
534
+ _report_setup(name, result, dry_run=dry_run)
535
+ for name in sorted(set(known_platforms()) - set(detect_installed_platforms())):
536
+ print(f"\n{_AGENT_NAMES.get(name, name)} — not detected, skipping")
537
+ else:
538
+ _report_setup(agent, apply(agent, bundle, dry_run=dry_run), dry_run=dry_run)
539
+
540
+ if dry_run:
541
+ print("\n(dry-run — no files written)")
542
+ else:
543
+ print("\nDone. Restart your agent(s) to pick up the new MCP server and hooks.")
544
+
545
+
546
+ def _print_summary(action: str, path: Path, stats: indexer.IndexStats) -> None:
547
+ print(
548
+ f"{action} {path}: "
549
+ f"scanned={stats.scanned} indexed={stats.indexed} "
550
+ f"skipped={stats.skipped} symbols={stats.symbols} "
551
+ f"edges={stats.edges} bindings={stats.bindings} errors={stats.errors}"
552
+ )
553
+
554
+
555
+ @app.command
556
+ def branches(*, prune: bool = False) -> None:
557
+ """List omnigraph branches per indexed repo store.
558
+
559
+ Non-default git branches index onto same-named omnigraph branches
560
+ (docs/BRANCH_INDEXING.md). Branch stores are re-derivable caches, so
561
+ lifecycle is deletion, not merge.
562
+
563
+ Parameters
564
+ ----------
565
+ prune:
566
+ Delete the CURRENT repo's store branches whose git branch no longer
567
+ exists locally, plus the ``_detached`` scratch branch. Other repos'
568
+ stores are only listed (their git refs aren't visible from here).
569
+ """
570
+ from . import config as cfg_module
571
+ from . import repo as repo_module
572
+ from .graph import OmnigraphClient
573
+
574
+ cfg = cfg_module.load()
575
+ if not cfg.code_dir.is_dir():
576
+ print(f"No code stores at {cfg.code_dir}.")
577
+ return
578
+
579
+ current_slug = repo_module.detect()
580
+ current_store = (
581
+ cfg_module.store_path(current_slug, cfg.code_dir) if current_slug else None
582
+ )
583
+ git_branches = repo_module.local_branches() if prune else None
584
+
585
+ stores = [
586
+ p
587
+ for p in sorted(cfg.code_dir.glob("*.omni"))
588
+ if p.name != cfg_module.BRIDGE_STORE_NAME
589
+ ]
590
+ for store in stores:
591
+ client = OmnigraphClient(str(store), cfg.queries_dir)
592
+ try:
593
+ names = client.list_branches()
594
+ except Exception as exc: # noqa: BLE001 — one bad store shouldn't abort
595
+ print(f"{_store_repo(store)}: <error: {exc}>")
596
+ continue
597
+ extra = [n for n in names if n != "main"]
598
+ print(f"{_store_repo(store)}: main" + ("," + ",".join(extra) if extra else ""))
599
+
600
+ if not (
601
+ prune
602
+ and current_store is not None
603
+ and store == current_store
604
+ and git_branches is not None
605
+ ):
606
+ continue
607
+ for name in extra:
608
+ if name == repo_module.DETACHED_BRANCH or name not in git_branches:
609
+ client.delete_branch(name)
610
+ print(f" pruned {name}")
611
+
612
+
613
+ # ── Indexed repositories ─────────────────────────────────────────────────────
614
+
615
+
616
+ @app.command
617
+ def repos() -> None:
618
+ """List the repositories that have a code graph indexed."""
619
+ from rich.console import Console
620
+ from rich.table import Table
621
+
622
+ from . import config as cfg_module
623
+
624
+ console = Console()
625
+ code_dir = cfg_module.load().code_dir
626
+ if not code_dir.is_dir():
627
+ console.print(f"[dim]No code stores at {code_dir}.[/dim]")
628
+ return
629
+ # Exclude the shared cross-repo bridge store — it isn't a repo.
630
+ stores = [
631
+ p
632
+ for p in sorted(code_dir.glob("*.omni"))
633
+ if p.name != cfg_module.BRIDGE_STORE_NAME
634
+ ]
635
+ if not stores:
636
+ console.print(f"[dim]No indexed repositories in {code_dir}.[/dim]")
637
+ return
638
+
639
+ table = Table(title="Indexed repositories", header_style="bold")
640
+ _short_cols = {"files", "size", "last indexed"}
641
+ for col in ("repo", "files", "size", "last indexed"):
642
+ if col in _short_cols:
643
+ table.add_column(col, no_wrap=True)
644
+ else:
645
+ table.add_column(col, overflow="fold", no_wrap=False)
646
+ for store in stores:
647
+ repo_uri, file_count = _code_store_stats(store)
648
+ size, mtime = _dir_stats(store)
649
+ table.add_row(repo_uri, file_count, _human_size(size), mtime)
650
+ console.print(table)
651
+
652
+
653
+ def _code_store_stats(store: Path) -> tuple[str, str]:
654
+ """Return (repo_uri, file_count); repo URI comes from the sidecar."""
655
+ repo_uri = _store_repo(store)
656
+ try:
657
+ from . import config as cfg_module
658
+ from .graph import OmnigraphClient
659
+
660
+ client = OmnigraphClient(str(store), cfg_module.load().queries_dir)
661
+ rows = client.read("code_read.gq", "all_file_hashes", {})
662
+ return repo_uri, str(len(rows))
663
+ except Exception: # noqa: BLE001 — degrade gracefully
664
+ return repo_uri, "?"
665
+
666
+
667
+ def _store_repo(store: Path) -> str:
668
+ """Canonical repo URI for a store: the exact sidecar if present, else a
669
+ best-effort reconstruction from the (lossily) sanitized filename."""
670
+ from . import store as store_module
671
+
672
+ sidecar = store_module.repo_sidecar(store)
673
+ if sidecar.exists():
674
+ return sidecar.read_text().strip()
675
+ return _repo_from_stem(store.stem)
676
+
677
+
678
+ def _repo_from_stem(stem: str) -> str:
679
+ """Best-effort canonical repo URI from a sanitized store filename.
680
+
681
+ The store name is ``sanitize_slug(repo)`` (``[/:]+`` collapsed to ``_``), so
682
+ a 0-file store has no CodeFile to read the exact repo from. For the common
683
+ ``scheme://host/path`` slug, reconstruct it: ``https_github.com_org_repo`` →
684
+ ``https://github.com/org/repo``. A schemeless local slug is returned as-is.
685
+ """
686
+ for scheme in ("https", "http", "ssh"):
687
+ prefix = f"{scheme}_"
688
+ if stem.startswith(prefix):
689
+ return f"{scheme}://{stem[len(prefix) :].replace('_', '/')}"
690
+ return stem
691
+
692
+
693
+ def _dir_stats(path: Path) -> tuple[int, str]:
694
+ """Return (total_bytes, last-modified string) in a single directory walk."""
695
+ import datetime
696
+
697
+ total = 0
698
+ latest = path.stat().st_mtime
699
+ for f in path.rglob("*"):
700
+ if f.is_file():
701
+ st = f.stat()
702
+ total += st.st_size
703
+ if st.st_mtime > latest:
704
+ latest = st.st_mtime
705
+ return total, datetime.datetime.fromtimestamp(latest).strftime("%Y-%m-%d %H:%M")
706
+
707
+
708
+ def _human_size(n: int) -> str:
709
+ size = float(n)
710
+ for unit in ("B", "KB", "MB", "GB"):
711
+ if size < 1024 or unit == "GB":
712
+ return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
713
+ size /= 1024
714
+ return f"{size:.1f}GB"
715
+
716
+
717
+ def cli() -> None:
718
+ app()
719
+
720
+
721
+ if __name__ == "__main__":
722
+ cli()