repograph-codeintel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- repograph/__init__.py +3 -0
- repograph/__main__.py +3 -0
- repograph/agent_setup.py +224 -0
- repograph/cli.py +476 -0
- repograph/config.py +145 -0
- repograph/extract.py +616 -0
- repograph/impact.py +558 -0
- repograph/indexer.py +184 -0
- repograph/mcp_server.py +130 -0
- repograph/query.py +485 -0
- repograph/resolve.py +755 -0
- repograph/scanner.py +107 -0
- repograph/store.py +287 -0
- repograph/test_gate.py +169 -0
- repograph_codeintel-0.1.0.dist-info/METADATA +295 -0
- repograph_codeintel-0.1.0.dist-info/RECORD +20 -0
- repograph_codeintel-0.1.0.dist-info/WHEEL +5 -0
- repograph_codeintel-0.1.0.dist-info/entry_points.txt +2 -0
- repograph_codeintel-0.1.0.dist-info/licenses/LICENSE +202 -0
- repograph_codeintel-0.1.0.dist-info/top_level.txt +1 -0
repograph/cli.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .config import INDEX_DIR, load_settings, init_project
|
|
14
|
+
from .impact import analyze_impact
|
|
15
|
+
from .indexer import index_repository
|
|
16
|
+
from .query import (
|
|
17
|
+
architecture_summary,
|
|
18
|
+
dependency_graph,
|
|
19
|
+
find_cycles,
|
|
20
|
+
find_references,
|
|
21
|
+
quick_status,
|
|
22
|
+
search as search_repo,
|
|
23
|
+
)
|
|
24
|
+
from .store import Store
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="repograph",
|
|
28
|
+
help="Evidence-backed codebase intelligence for AI coding agents.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
add_completion=False,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _root(path: Optional[Path]) -> Path:
|
|
35
|
+
return (path or Path.cwd()).expanduser().resolve()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _open_store(root: Path) -> tuple[Store, object]:
|
|
39
|
+
settings = load_settings(root)
|
|
40
|
+
if not settings.db_path.exists():
|
|
41
|
+
typer.echo(f"no index found at {settings.db_path}", err=True)
|
|
42
|
+
typer.echo("run: repograph index .", err=True)
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
store = Store(settings)
|
|
45
|
+
store.init_schema()
|
|
46
|
+
return store, settings
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _emit(data: dict | list, as_json: bool) -> None:
|
|
50
|
+
if as_json:
|
|
51
|
+
typer.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command()
|
|
55
|
+
def init(path: Optional[Path] = typer.Argument(None, help="Repository root (default: cwd).")) -> None:
|
|
56
|
+
"""Create the local .repograph directory and default configuration."""
|
|
57
|
+
root = _root(path)
|
|
58
|
+
settings = init_project(root)
|
|
59
|
+
typer.echo(f"initialized {settings.state_dir}")
|
|
60
|
+
typer.echo(f"next: repograph index {root}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def index(
|
|
65
|
+
path: Optional[Path] = typer.Argument(None, help="Repository root (default: cwd)."),
|
|
66
|
+
force: bool = typer.Option(False, "--force", help="Reparse every file."),
|
|
67
|
+
reset: bool = typer.Option(False, "--reset", help="Drop the existing index first."),
|
|
68
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Discover, parse and index a repository (incremental)."""
|
|
71
|
+
root = _root(path)
|
|
72
|
+
if not root.is_dir():
|
|
73
|
+
typer.echo(f"not a directory: {root}", err=True)
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
settings = init_project(root)
|
|
76
|
+
stats = index_repository(root, force=force, reset=reset, settings=settings)
|
|
77
|
+
data = {
|
|
78
|
+
"root": str(root),
|
|
79
|
+
"files_scanned": stats.files_scanned,
|
|
80
|
+
"added": stats.added,
|
|
81
|
+
"updated": stats.updated,
|
|
82
|
+
"unchanged": stats.unchanged,
|
|
83
|
+
"deleted": stats.deleted,
|
|
84
|
+
"parse_errors": stats.parse_errors,
|
|
85
|
+
"duration_s": stats.duration_s,
|
|
86
|
+
"db_bytes": stats.db_bytes,
|
|
87
|
+
"languages": stats.languages,
|
|
88
|
+
"truncated": stats.truncated,
|
|
89
|
+
"skipped_large": stats.skipped_large,
|
|
90
|
+
"skipped_symlink": stats.skipped_symlink,
|
|
91
|
+
}
|
|
92
|
+
if as_json:
|
|
93
|
+
_emit(data, True)
|
|
94
|
+
return
|
|
95
|
+
typer.echo(f"indexed {stats.files_scanned} files in {stats.duration_s:.2f}s "
|
|
96
|
+
f"({stats.added} added, {stats.updated} updated, {stats.deleted} deleted, "
|
|
97
|
+
f"{stats.parse_errors} parse errors)")
|
|
98
|
+
typer.echo(f"database: {settings.db_path} ({stats.db_bytes / 1024:.0f} KiB)")
|
|
99
|
+
if stats.truncated:
|
|
100
|
+
typer.echo("warning: file limit reached; increase max_files in .repograph/config.json", err=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def status(
|
|
105
|
+
path: Optional[Path] = typer.Argument(None),
|
|
106
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Show index statistics and whether a re-index is needed."""
|
|
109
|
+
root = _root(path)
|
|
110
|
+
store, settings = _open_store(root)
|
|
111
|
+
with store:
|
|
112
|
+
data = quick_status(store, settings)
|
|
113
|
+
if as_json:
|
|
114
|
+
_emit(data, True)
|
|
115
|
+
return
|
|
116
|
+
typer.echo(f"root: {data.get('root')}")
|
|
117
|
+
typer.echo(f"indexed: {data.get('indexed_at')}")
|
|
118
|
+
typer.echo(f"files: {data['files']} symbols: {data['symbols']} "
|
|
119
|
+
f"internal edges: {data['internal_imports']}")
|
|
120
|
+
typer.echo(f"tests: {data['tests']} parse errors: {data['parse_errors']}")
|
|
121
|
+
typer.echo(f"languages: {', '.join(f'{k}={v}' for k, v in data['languages'].items()) or '-'}")
|
|
122
|
+
typer.echo(f"database: {data['db_bytes'] / 1024:.0f} KiB")
|
|
123
|
+
stale = data["stale"]
|
|
124
|
+
state = "stale" if data["needs_reindex"] else "up to date"
|
|
125
|
+
typer.echo(f"worktree: {state} (+{stale['added']} ~{stale['changed']} -{stale['deleted']})")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def search(
|
|
130
|
+
query: str = typer.Argument(..., help="Symbol or text to search for."),
|
|
131
|
+
path: Optional[Path] = typer.Option(None, "--root", help="Repository root."),
|
|
132
|
+
kind: Optional[str] = typer.Option(None, "--kind", help="Filter symbols by kind."),
|
|
133
|
+
limit: int = typer.Option(20, "--limit", min=1, max=200),
|
|
134
|
+
related: bool = typer.Option(False, "--related", help="Include directly related files."),
|
|
135
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Search indexed symbols and source text."""
|
|
138
|
+
root = _root(path)
|
|
139
|
+
store, _ = _open_store(root)
|
|
140
|
+
with store:
|
|
141
|
+
if kind:
|
|
142
|
+
from .query import search_symbols
|
|
143
|
+
|
|
144
|
+
data = {"query": query, "symbols": search_symbols(store, query, kind=kind, limit=limit)}
|
|
145
|
+
else:
|
|
146
|
+
data = search_repo(store, root, query, limit=limit, related=related)
|
|
147
|
+
if as_json:
|
|
148
|
+
_emit(data, True)
|
|
149
|
+
return
|
|
150
|
+
typer.echo(f"symbols ({len(data['symbols'])}):")
|
|
151
|
+
for s in data["symbols"]:
|
|
152
|
+
typer.echo(f" {s['kind']:<9} {s['qualified_name']} {s['path']}:{s['lines'][0]}")
|
|
153
|
+
text = data.get("text", [])
|
|
154
|
+
typer.echo(f"text matches ({len(text)}):")
|
|
155
|
+
for t in text:
|
|
156
|
+
typer.echo(f" [{t.get('kind', 'source')}] {t['path']}:{t['line']}: {t['text']}")
|
|
157
|
+
for r in data.get("related_files", []):
|
|
158
|
+
typer.echo(f" related [{r['relation']}] {r['path']} -> {r['target']}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command()
|
|
163
|
+
def impact(
|
|
164
|
+
paths: Optional[list[str]] = typer.Argument(None, help="Changed files (repo-relative)."),
|
|
165
|
+
path: Optional[Path] = typer.Option(None, "--root", help="Repository root."),
|
|
166
|
+
symbol: Optional[str] = typer.Option(None, "--symbol", help="Analyze a symbol instead of files."),
|
|
167
|
+
diff: bool = typer.Option(False, "--diff", help="Use the current git worktree diff."),
|
|
168
|
+
depth: int = typer.Option(2, "--depth", min=1, max=5),
|
|
169
|
+
tests: bool = typer.Option(True, "--tests/--no-tests"),
|
|
170
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Estimate what a change can affect, with evidence and confidence."""
|
|
173
|
+
root = _root(path)
|
|
174
|
+
store, _ = _open_store(root)
|
|
175
|
+
with store:
|
|
176
|
+
data = analyze_impact(store, root, paths=list(paths or []), symbol=symbol,
|
|
177
|
+
diff=diff, depth=depth, include_tests=tests)
|
|
178
|
+
if as_json:
|
|
179
|
+
_emit(data, True)
|
|
180
|
+
if "error" in data:
|
|
181
|
+
raise typer.Exit(2)
|
|
182
|
+
return
|
|
183
|
+
if "error" in data:
|
|
184
|
+
typer.echo(f"error: {data['error']}", err=True)
|
|
185
|
+
for c in data.get("candidates", []):
|
|
186
|
+
typer.echo(f" candidate: {c['qualified_name']} ({c['path']}:{c['kind']})", err=True)
|
|
187
|
+
raise typer.Exit(2)
|
|
188
|
+
target = data["target"]
|
|
189
|
+
typer.echo(f"changed files ({len(data['changed_files'])}): {', '.join(data['changed_files']) or '-'}")
|
|
190
|
+
typer.echo(f"changed symbols: {data['summary']['changed_symbols']}")
|
|
191
|
+
for s in target["symbols"][:10]:
|
|
192
|
+
typer.echo(f" {s['kind']:<9} {s['qualified_name']} {s['path']}:{s['lines'][0]}")
|
|
193
|
+
typer.echo(f"\ndirect dependents ({len(data['direct_dependents'])}):")
|
|
194
|
+
for d in data["direct_dependents"][:20]:
|
|
195
|
+
reason = d["reasons"][0]
|
|
196
|
+
typer.echo(f" [{d['confidence']}] {d['path']} ({reason['relation']} at line {reason.get('line')})")
|
|
197
|
+
typer.echo(f"transitive dependents: {len(data['transitive_dependents'])}")
|
|
198
|
+
for t in data["transitive_dependents"][:10]:
|
|
199
|
+
typer.echo(f" [{t['confidence']}] depth {t['depth']} {t['path']}")
|
|
200
|
+
typer.echo(f"\nrelated tests ({len(data['related_tests'])}):")
|
|
201
|
+
for t in data["related_tests"][:15]:
|
|
202
|
+
rules = ", ".join(r["rule"] for r in t["rules"])
|
|
203
|
+
typer.echo(f" [{t['confidence']}] {t['path']} ({rules})")
|
|
204
|
+
if data["affected_routes"]:
|
|
205
|
+
typer.echo("\naffected routes:")
|
|
206
|
+
for r in data["affected_routes"][:15]:
|
|
207
|
+
typer.echo(f" {r['method']} {r['path']} {r['file']}:{r['line']}")
|
|
208
|
+
if data["related_models"]:
|
|
209
|
+
typer.echo("\nrelated models:")
|
|
210
|
+
for m in data["related_models"][:15]:
|
|
211
|
+
typer.echo(f" {m['qualified_name']} {m['path']}:{m['line']}")
|
|
212
|
+
if data["architecture"]["documentation_mentions"]:
|
|
213
|
+
typer.echo("\ndocumentation mentions:")
|
|
214
|
+
for d in data["architecture"]["documentation_mentions"][:5]:
|
|
215
|
+
typer.echo(f" {d['path']}:{d['line']}")
|
|
216
|
+
typer.echo("\nblind spots:")
|
|
217
|
+
for b in data["blind_spots"]:
|
|
218
|
+
typer.echo(f" - {b['kind']} ({b['count']}): {b['message']}")
|
|
219
|
+
typer.echo("\nnote: absence of static evidence is not evidence of safety.")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@app.command()
|
|
223
|
+
def tests(
|
|
224
|
+
paths: Optional[list[str]] = typer.Argument(None, help="Changed files (repo-relative)."),
|
|
225
|
+
path: Optional[Path] = typer.Option(None, "--root", help="Repository root."),
|
|
226
|
+
diff: bool = typer.Option(False, "--diff", help="Use the git diff instead of paths."),
|
|
227
|
+
base: str = typer.Option("HEAD", "--base", help="Git ref to diff against."),
|
|
228
|
+
strict: bool = typer.Option(
|
|
229
|
+
False, "--strict", help="Exit 3 when a test cannot be ruled out or none was selected."
|
|
230
|
+
),
|
|
231
|
+
command_only: bool = typer.Option(
|
|
232
|
+
False, "--command", help="Print only the pytest command to run."
|
|
233
|
+
),
|
|
234
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Test files a change reaches, and the ones that cannot be ruled out (heuristic)."""
|
|
237
|
+
from .test_gate import select
|
|
238
|
+
|
|
239
|
+
if not paths and not diff:
|
|
240
|
+
typer.echo("error: provide paths or --diff", err=True)
|
|
241
|
+
raise typer.Exit(2)
|
|
242
|
+
root = _root(path)
|
|
243
|
+
store, _ = _open_store(root)
|
|
244
|
+
with store:
|
|
245
|
+
data = select(store, root, paths=list(paths or []), diff=diff, base=base, strict=strict)
|
|
246
|
+
|
|
247
|
+
if "error" in data:
|
|
248
|
+
typer.echo(f"error: {data['error']}", err=True)
|
|
249
|
+
raise typer.Exit(2)
|
|
250
|
+
if as_json:
|
|
251
|
+
_emit(data, True)
|
|
252
|
+
raise typer.Exit(data["exit_code"])
|
|
253
|
+
if command_only:
|
|
254
|
+
typer.echo(data["command"])
|
|
255
|
+
raise typer.Exit(data["exit_code"])
|
|
256
|
+
|
|
257
|
+
sel = data["selection"]
|
|
258
|
+
typer.echo(f"changed files ({len(data['changed_files'])}):")
|
|
259
|
+
for p in data["changed_files"][:20]:
|
|
260
|
+
typer.echo(f" {p}")
|
|
261
|
+
for p in data["unindexed_changed_files"]:
|
|
262
|
+
typer.echo(f" {p} (not in the index: run `repograph index .`)")
|
|
263
|
+
|
|
264
|
+
fraction = f"{sel['fraction']:.0%} of {sel['total_tests']}" if sel["fraction"] is not None else "no tests"
|
|
265
|
+
typer.echo(f"\nrun these ({sel['run']}, {fraction}):")
|
|
266
|
+
for t in data["run"]:
|
|
267
|
+
typer.echo(f" [{t['confidence']}] {t['path']} ({', '.join(t['rules'])})")
|
|
268
|
+
if not data["run"]:
|
|
269
|
+
typer.echo(" - nothing: no test reaches this change. That is not evidence of safety.")
|
|
270
|
+
|
|
271
|
+
typer.echo(f"\ncannot rule out ({sel['undetermined']}):")
|
|
272
|
+
for u in data["undetermined"][:20]:
|
|
273
|
+
typer.echo(f" {u['path']} ({u['reason']})")
|
|
274
|
+
if data["reachable_not_selected"]:
|
|
275
|
+
typer.echo(f"\nreachable but not selected ({len(data['reachable_not_selected'])}):")
|
|
276
|
+
for p in data["reachable_not_selected"][:20]:
|
|
277
|
+
typer.echo(f" {p}")
|
|
278
|
+
for c in data["caveats"]:
|
|
279
|
+
typer.echo(f" note: {c}")
|
|
280
|
+
|
|
281
|
+
typer.echo(f"\nnext: {data['command']}")
|
|
282
|
+
raise typer.Exit(data["exit_code"])
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@app.command()
|
|
286
|
+
def graph(
|
|
287
|
+
target: Optional[str] = typer.Argument(None, help="Focus file (default: whole repo)."),
|
|
288
|
+
path: Optional[Path] = typer.Option(None, "--root"),
|
|
289
|
+
direction: str = typer.Option("both", "--direction", help="in, out, or both."),
|
|
290
|
+
depth: int = typer.Option(2, "--depth", min=1, max=5),
|
|
291
|
+
fmt: str = typer.Option("json", "--format", help="json or dot."),
|
|
292
|
+
cycles: bool = typer.Option(False, "--cycles", help="Report dependency cycles."),
|
|
293
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Inspect the file dependency graph or its cycles."""
|
|
296
|
+
root = _root(path)
|
|
297
|
+
store, _ = _open_store(root)
|
|
298
|
+
with store:
|
|
299
|
+
if cycles:
|
|
300
|
+
data = {"cycles": find_cycles(store)}
|
|
301
|
+
else:
|
|
302
|
+
data = dependency_graph(store, path=target, direction=direction, depth=depth)
|
|
303
|
+
if as_json or (fmt == "json" and not typer.get_text_stream("stdout").isatty()):
|
|
304
|
+
_emit(data, True)
|
|
305
|
+
return
|
|
306
|
+
if "cycles" in data:
|
|
307
|
+
if not data["cycles"]:
|
|
308
|
+
typer.echo("no dependency cycles found")
|
|
309
|
+
for i, cyc in enumerate(data["cycles"], 1):
|
|
310
|
+
typer.echo(f"cycle {i}: {' -> '.join(cyc)}")
|
|
311
|
+
return
|
|
312
|
+
if fmt == "dot" and not as_json:
|
|
313
|
+
typer.echo("digraph repograph {")
|
|
314
|
+
typer.echo(' rankdir="LR";')
|
|
315
|
+
for n in data["nodes"]:
|
|
316
|
+
typer.echo(f' "{n}";')
|
|
317
|
+
for e in data["edges"]:
|
|
318
|
+
typer.echo(f' "{e["from"]}" -> "{e["to"]}" [label="L{e["line"]}"];')
|
|
319
|
+
typer.echo("}")
|
|
320
|
+
return
|
|
321
|
+
_emit(data, True)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@app.command()
|
|
325
|
+
def refs(
|
|
326
|
+
name: str = typer.Argument(..., help="Symbol name or qualified name."),
|
|
327
|
+
path: Optional[Path] = typer.Option(None, "--root"),
|
|
328
|
+
limit: int = typer.Option(100, "--limit", min=1, max=500),
|
|
329
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Find definitions and resolved references for a symbol."""
|
|
332
|
+
root = _root(path)
|
|
333
|
+
store, _ = _open_store(root)
|
|
334
|
+
with store:
|
|
335
|
+
data = find_references(store, name, limit=limit)
|
|
336
|
+
if as_json:
|
|
337
|
+
_emit(data, True)
|
|
338
|
+
return
|
|
339
|
+
for d in data["definitions"]:
|
|
340
|
+
typer.echo(f"def {d['kind']:<9} {d['qualified_name']} {d['path']}:{d['lines'][0]}")
|
|
341
|
+
for r in data["references"]:
|
|
342
|
+
typer.echo(f"ref [{r['confidence']}] {r['path']}:{r['line']} -> {r['target']}")
|
|
343
|
+
if not data["definitions"]:
|
|
344
|
+
typer.echo("symbol not found in index", err=True)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@app.command()
|
|
348
|
+
def arch(
|
|
349
|
+
path: Optional[Path] = typer.Option(None, "--root"),
|
|
350
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
351
|
+
) -> None:
|
|
352
|
+
"""Summarize repository architecture from the index."""
|
|
353
|
+
root = _root(path)
|
|
354
|
+
store, _ = _open_store(root)
|
|
355
|
+
with store:
|
|
356
|
+
data = architecture_summary(store, root)
|
|
357
|
+
if as_json:
|
|
358
|
+
_emit(data, True)
|
|
359
|
+
return
|
|
360
|
+
typer.echo(f"files: {data['files']} symbols: {data['symbols']} internal edges: {data['internal_edges']}")
|
|
361
|
+
typer.echo(f"unresolved imports: {data['unresolved_imports']} dynamic imports: {data['dynamic_imports']}"
|
|
362
|
+
" (a resolved-edge count is not a census)")
|
|
363
|
+
typer.echo(f"parse errors: {len(data['parse_errors'])}")
|
|
364
|
+
typer.echo("languages: " + ", ".join(f"{k}={v}" for k, v in data["languages"].items()))
|
|
365
|
+
typer.echo("layers: " + ", ".join(f"{k}={v}" for k, v in data["layers"].items()))
|
|
366
|
+
typer.echo("top directories:")
|
|
367
|
+
for name, d in list(data["top_directories"].items())[:10]:
|
|
368
|
+
typer.echo(f" {name:<20} files={d['files']:<5} in={d['inbound']:<5} out={d['outbound']}")
|
|
369
|
+
typer.echo("dependency hubs:")
|
|
370
|
+
for hub in data["dependency_hubs"][:5]:
|
|
371
|
+
typer.echo(f" in={hub['inbound']:<4} {hub['path']}")
|
|
372
|
+
typer.echo(f"entry points: {', '.join(data['entry_points'][:8]) or '-'}")
|
|
373
|
+
typer.echo(f"routes: {len(data['http_routes'])} models: {len(data['database_models'])}")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@app.command()
|
|
377
|
+
def setup(
|
|
378
|
+
path: Optional[Path] = typer.Argument(None, help="Repository root (default: cwd)."),
|
|
379
|
+
client: Optional[list[str]] = typer.Option(
|
|
380
|
+
None, "--client", "-c", help="claude, cursor, codex. Repeatable. Default: detected."
|
|
381
|
+
),
|
|
382
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Apply without asking."),
|
|
383
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show changes and write nothing."),
|
|
384
|
+
no_skill: bool = typer.Option(
|
|
385
|
+
False, "--no-skill", help="Register the server but skip agent instructions."
|
|
386
|
+
),
|
|
387
|
+
) -> None:
|
|
388
|
+
"""Register the MCP server with your agent and install its usage instructions."""
|
|
389
|
+
from pathlib import Path as _Path
|
|
390
|
+
|
|
391
|
+
from .agent_setup import apply, detect_clients, plan
|
|
392
|
+
|
|
393
|
+
root = _root(path)
|
|
394
|
+
clients = client or detect_clients(root, _Path.home())
|
|
395
|
+
try:
|
|
396
|
+
changes = plan(root, clients, include_skill=not no_skill)
|
|
397
|
+
except ValueError as exc:
|
|
398
|
+
typer.echo(f"error: {exc}", err=True)
|
|
399
|
+
raise typer.Exit(1) from exc
|
|
400
|
+
|
|
401
|
+
typer.echo(f"clients: {', '.join(clients)}")
|
|
402
|
+
if "codex" not in clients and (_Path.home() / ".codex").is_dir():
|
|
403
|
+
typer.echo("note: Codex detected; add --client codex to register it in ~/.codex/config.toml")
|
|
404
|
+
for change in changes:
|
|
405
|
+
typer.echo(f" {change.kind:<9} {change.path} ({change.note})")
|
|
406
|
+
|
|
407
|
+
pending = [c for c in changes if c.kind != "unchanged"]
|
|
408
|
+
if not pending:
|
|
409
|
+
typer.echo("already configured; nothing to write.")
|
|
410
|
+
else:
|
|
411
|
+
for change in pending:
|
|
412
|
+
typer.echo("")
|
|
413
|
+
typer.echo(change.diff().rstrip("\n"))
|
|
414
|
+
|
|
415
|
+
if dry_run:
|
|
416
|
+
typer.echo("\ndry run: nothing written.")
|
|
417
|
+
raise typer.Exit(0)
|
|
418
|
+
|
|
419
|
+
if pending:
|
|
420
|
+
if not yes:
|
|
421
|
+
typer.confirm("Apply these changes?", abort=True)
|
|
422
|
+
try:
|
|
423
|
+
written = apply(changes)
|
|
424
|
+
except (OSError, ValueError) as exc:
|
|
425
|
+
typer.echo(f"error: {exc}", err=True)
|
|
426
|
+
raise typer.Exit(1) from exc
|
|
427
|
+
typer.echo("")
|
|
428
|
+
for item in written:
|
|
429
|
+
typer.echo(f"wrote {item}")
|
|
430
|
+
|
|
431
|
+
settings = load_settings(root)
|
|
432
|
+
if not settings.db_path.exists():
|
|
433
|
+
typer.echo(f"\nnext: repograph index {root}")
|
|
434
|
+
typer.echo("next: restart your agent, then ask \"what breaks if I change <symbol>?\"")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@app.command()
|
|
438
|
+
def serve(
|
|
439
|
+
path: Optional[Path] = typer.Option(None, "--root", help="Repository root to expose."),
|
|
440
|
+
http: bool = typer.Option(False, "--http", help="Use Streamable HTTP instead of stdio."),
|
|
441
|
+
host: str = typer.Option("127.0.0.1", "--host"),
|
|
442
|
+
port: int = typer.Option(8765, "--port"),
|
|
443
|
+
) -> None:
|
|
444
|
+
"""Run the MCP server (stdio by default)."""
|
|
445
|
+
from .mcp_server import build_server
|
|
446
|
+
|
|
447
|
+
root = _root(path)
|
|
448
|
+
try:
|
|
449
|
+
server = build_server(root)
|
|
450
|
+
except FileNotFoundError as exc:
|
|
451
|
+
typer.echo(str(exc), err=True)
|
|
452
|
+
raise typer.Exit(1) from exc
|
|
453
|
+
if http:
|
|
454
|
+
server.settings.host = host
|
|
455
|
+
server.settings.port = port
|
|
456
|
+
if host not in ("127.0.0.1", "localhost", "::1"):
|
|
457
|
+
typer.echo("warning: the MCP server has no authentication; do not expose it to untrusted networks",
|
|
458
|
+
err=True)
|
|
459
|
+
typer.echo(f"MCP streamable-http on http://{host}:{port}/mcp", err=True)
|
|
460
|
+
server.run("streamable-http")
|
|
461
|
+
else:
|
|
462
|
+
server.run("stdio")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@app.command()
|
|
466
|
+
def version() -> None:
|
|
467
|
+
"""Print the version."""
|
|
468
|
+
typer.echo(__version__)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def main() -> None:
|
|
472
|
+
app()
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
if __name__ == "__main__":
|
|
476
|
+
main()
|
repograph/config.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Project configuration.
|
|
2
|
+
|
|
3
|
+
Config is intentionally a small JSON file (no extra dependency, no code
|
|
4
|
+
execution). Anything not set here falls back to conservative defaults.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
INDEX_DIR = ".repograph"
|
|
14
|
+
DB_NAME = "index.db"
|
|
15
|
+
CONFIG_NAME = "config.json"
|
|
16
|
+
|
|
17
|
+
DEFAULT_EXCLUDES = [
|
|
18
|
+
".git",
|
|
19
|
+
".hg",
|
|
20
|
+
".svn",
|
|
21
|
+
".repograph",
|
|
22
|
+
".venv",
|
|
23
|
+
"venv",
|
|
24
|
+
"node_modules",
|
|
25
|
+
"__pycache__",
|
|
26
|
+
".mypy_cache",
|
|
27
|
+
".pytest_cache",
|
|
28
|
+
".ruff_cache",
|
|
29
|
+
".tox",
|
|
30
|
+
".nox",
|
|
31
|
+
"dist",
|
|
32
|
+
"build",
|
|
33
|
+
"target",
|
|
34
|
+
"vendor",
|
|
35
|
+
".next",
|
|
36
|
+
".nuxt",
|
|
37
|
+
".turbo",
|
|
38
|
+
".cache",
|
|
39
|
+
"coverage",
|
|
40
|
+
"htmlcov",
|
|
41
|
+
"site-packages",
|
|
42
|
+
"*.min.js",
|
|
43
|
+
"*.min.css",
|
|
44
|
+
"*.map",
|
|
45
|
+
"*.lock",
|
|
46
|
+
"package-lock.json",
|
|
47
|
+
"pnpm-lock.yaml",
|
|
48
|
+
"yarn.lock",
|
|
49
|
+
"poetry.lock",
|
|
50
|
+
"uv.lock",
|
|
51
|
+
".env",
|
|
52
|
+
".env.*",
|
|
53
|
+
"*.pem",
|
|
54
|
+
"*.key",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
# Files that are likely to contain credentials are never indexed or searched.
|
|
58
|
+
SECRET_GLOBS = [".env", ".env.*", "*.pem", "*.key", "*.p12", "id_rsa", "id_ed25519", ".npmrc", ".pypirc"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Settings:
|
|
63
|
+
root: Path
|
|
64
|
+
exclude: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDES))
|
|
65
|
+
max_file_bytes: int = 1_500_000
|
|
66
|
+
max_files: int = 200_000
|
|
67
|
+
follow_symlinks: bool = False
|
|
68
|
+
index_dir: Path | None = None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def db_path(self) -> Path:
|
|
72
|
+
return (self.index_dir or (self.root / INDEX_DIR)) / DB_NAME
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def state_dir(self) -> Path:
|
|
76
|
+
return self.index_dir or (self.root / INDEX_DIR)
|
|
77
|
+
|
|
78
|
+
def is_excluded(self, rel: str) -> bool:
|
|
79
|
+
from fnmatch import fnmatchcase
|
|
80
|
+
|
|
81
|
+
parts = rel.split("/")
|
|
82
|
+
for pat in self.exclude + SECRET_GLOBS:
|
|
83
|
+
if fnmatchcase(rel, pat) or any(fnmatchcase(p, pat) for p in parts):
|
|
84
|
+
return True
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_settings(root: str | Path) -> Settings:
|
|
89
|
+
root = Path(root).expanduser().resolve()
|
|
90
|
+
s = Settings(root=root)
|
|
91
|
+
cfg = root / INDEX_DIR / CONFIG_NAME
|
|
92
|
+
if cfg.is_file():
|
|
93
|
+
try:
|
|
94
|
+
data = json.loads(cfg.read_text(encoding="utf-8"))
|
|
95
|
+
except (OSError, ValueError):
|
|
96
|
+
data = {}
|
|
97
|
+
if isinstance(data.get("exclude"), list):
|
|
98
|
+
# User excludes extend, never replace, the safety defaults.
|
|
99
|
+
s.exclude = list(dict.fromkeys(DEFAULT_EXCLUDES + [str(x) for x in data["exclude"]]))
|
|
100
|
+
for key in ("max_file_bytes", "max_files"):
|
|
101
|
+
if isinstance(data.get(key), int) and data[key] > 0:
|
|
102
|
+
setattr(s, key, data[key])
|
|
103
|
+
s.follow_symlinks = bool(data.get("follow_symlinks", False))
|
|
104
|
+
return s
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def init_project(root: str | Path) -> Settings:
|
|
108
|
+
root = Path(root).expanduser().resolve()
|
|
109
|
+
s = load_settings(root)
|
|
110
|
+
s.state_dir.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
cfg = s.state_dir / CONFIG_NAME
|
|
112
|
+
if not cfg.exists():
|
|
113
|
+
cfg.write_text(
|
|
114
|
+
json.dumps(
|
|
115
|
+
{
|
|
116
|
+
"exclude": [],
|
|
117
|
+
"max_file_bytes": s.max_file_bytes,
|
|
118
|
+
"max_files": s.max_files,
|
|
119
|
+
"follow_symlinks": False,
|
|
120
|
+
},
|
|
121
|
+
indent=2,
|
|
122
|
+
)
|
|
123
|
+
+ "\n",
|
|
124
|
+
encoding="utf-8",
|
|
125
|
+
)
|
|
126
|
+
return s
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def safe_path(root: Path, user_path: str | Path, *, must_exist: bool = True) -> Path:
|
|
130
|
+
"""Resolve a user-supplied path, refusing anything outside the repository.
|
|
131
|
+
|
|
132
|
+
Guards against ``..`` traversal, absolute paths elsewhere, and symlinks
|
|
133
|
+
that point outside the index root.
|
|
134
|
+
"""
|
|
135
|
+
root = root.resolve()
|
|
136
|
+
p = Path(user_path).expanduser()
|
|
137
|
+
candidate = p if p.is_absolute() else root / p
|
|
138
|
+
if must_exist and not candidate.exists():
|
|
139
|
+
raise FileNotFoundError(f"path not found: {user_path}")
|
|
140
|
+
resolved = candidate.resolve()
|
|
141
|
+
try:
|
|
142
|
+
resolved.relative_to(root)
|
|
143
|
+
except ValueError as exc:
|
|
144
|
+
raise PermissionError(f"path escapes repository root: {user_path}") from exc
|
|
145
|
+
return resolved
|