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/__init__.py +0 -0
- witan_code/__main__.py +4 -0
- witan_code/_detach.py +27 -0
- witan_code/bridge.py +392 -0
- witan_code/bridge_extractors.py +642 -0
- witan_code/cli.py +722 -0
- witan_code/config.py +65 -0
- witan_code/context.py +98 -0
- witan_code/edges.py +171 -0
- witan_code/elicit.py +92 -0
- witan_code/extensions/pi/codegraph.ts +121 -0
- witan_code/graph.py +271 -0
- witan_code/hooks.py +116 -0
- witan_code/indexer.py +1042 -0
- witan_code/maintenance.py +130 -0
- witan_code/package_map.py +74 -0
- witan_code/queries/bridge.gq +193 -0
- witan_code/queries/code_mutations.gq +81 -0
- witan_code/queries/code_read.gq +154 -0
- witan_code/queries/delete.gq +16 -0
- witan_code/queries_ts/bash.scm +9 -0
- witan_code/queries_ts/hcl.scm +16 -0
- witan_code/queries_ts/python.scm +29 -0
- witan_code/queries_ts/sql.scm +20 -0
- witan_code/queries_ts/typescript.scm +51 -0
- witan_code/queries_ts/yaml.scm +5 -0
- witan_code/repo.py +282 -0
- witan_code/schema/bridge-schema.pg +82 -0
- witan_code/schema/code-schema.pg +51 -0
- witan_code/server.py +859 -0
- witan_code/setup.py +230 -0
- witan_code/skills/witan-code/SKILL.md +97 -0
- witan_code/stitch.py +177 -0
- witan_code/store.py +116 -0
- witan_code/visualize.py +347 -0
- witan_code-0.2.0.dist-info/METADATA +476 -0
- witan_code-0.2.0.dist-info/RECORD +39 -0
- witan_code-0.2.0.dist-info/WHEEL +4 -0
- witan_code-0.2.0.dist-info/entry_points.txt +2 -0
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()
|