graphsnip 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.
graphsnip/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """graphsnip: a token-saving layer on top of Graphify."""
2
+
3
+ __version__ = "0.1.0"
graphsnip/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from graphsnip.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
graphsnip/baseline.py ADDED
@@ -0,0 +1,89 @@
1
+ """The honest baseline.
2
+
3
+ Graphify's own benchmark compares a query against reading the *entire corpus*,
4
+ which no agent actually does. graphsnip instead prices what an agent without
5
+ the graph would realistically do for the same question: grep for the terms,
6
+ then read the best-matching files in full (capped at `max_files`).
7
+
8
+ Both numbers are reported; the headline uses this one because it is the
9
+ defensible one.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ from graphsnip.project import MAX_FILE_BYTES, Project
16
+ from graphsnip.tokens import count_tokens
17
+
18
+ _STOP = set("""
19
+ a an and are as at be by can do does for from how i in is it its of on or that the this to was what when where which who why with
20
+ use used using work works get gets make makes find show tell explain code file files function functions class classes method
21
+ methods please about into over under all any not our your their there then than also
22
+ """.split())
23
+ _WORD = re.compile(r"[A-Za-z][A-Za-z0-9_]{2,}")
24
+ _CAMEL = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
25
+
26
+
27
+ def query_terms(text: str, limit: int = 8) -> list[str]:
28
+ terms: list[str] = []
29
+ for w in _WORD.findall(text or ""):
30
+ parts = [w.lower()]
31
+ if "_" in w or re.search(r"[a-z][A-Z]", w):
32
+ parts.append(w.lower())
33
+ parts += [p.lower() for p in _CAMEL.findall(w.replace("_", " ")) if len(p) >= 3]
34
+ for p in parts:
35
+ if p not in _STOP and len(p) >= 3 and p not in terms:
36
+ terms.append(p)
37
+ return terms[:limit]
38
+
39
+
40
+ class Baseline:
41
+ def __init__(self, project: Project):
42
+ self.p = project
43
+ self._text: dict[str, str] | None = None
44
+ self._tok: dict[str, int] = {}
45
+
46
+ def _index(self) -> dict[str, str]:
47
+ if self._text is None:
48
+ idx: dict[str, str] = {}
49
+ for f in self.p.files()[:20000]:
50
+ try:
51
+ if f.stat().st_size > MAX_FILE_BYTES:
52
+ continue
53
+ idx[self.p.rel(f)] = f.read_text(encoding="utf-8", errors="replace")
54
+ except Exception:
55
+ continue
56
+ self._text = idx
57
+ return self._text
58
+
59
+ def file_tokens(self, rel: str) -> int:
60
+ if rel not in self._tok:
61
+ t = self._index().get(rel)
62
+ if t is None:
63
+ t = self.p.read(rel) or ""
64
+ self._tok[rel] = count_tokens(t)
65
+ return self._tok[rel]
66
+
67
+ def grep_read(self, terms: list[str], max_files: int = 8) -> tuple[int, list[str]]:
68
+ """Tokens for: grep each term, read the top `max_files` matching files in full."""
69
+ if not terms:
70
+ return 0, []
71
+ scored: list[tuple[int, str]] = []
72
+ for rel, text in self._index().items():
73
+ low = text.lower()
74
+ s = 0
75
+ for t in terms:
76
+ c = low.count(t)
77
+ if c:
78
+ s += min(c, 10)
79
+ if s:
80
+ scored.append((s, rel))
81
+ scored.sort(key=lambda x: (-x[0], x[1]))
82
+ chosen = [rel for _s, rel in scored[:max_files]]
83
+ tokens = sum(self.file_tokens(r) for r in chosen) + 150 # + the grep listing itself
84
+ return tokens, chosen
85
+
86
+ def symbol_name_files(self, name: str, max_files: int = 10) -> tuple[int, list[str]]:
87
+ """Tokens for: grep a symbol name, read every matching file (capped)."""
88
+ n = re.sub(r"\(.*\)\s*$", "", name).lstrip(".").split(".")[-1].lower()
89
+ return self.grep_read([n], max_files=max_files) if len(n) >= 2 else (0, [])
graphsnip/bench.py ADDED
@@ -0,0 +1,208 @@
1
+ """Offline, reproducible benchmark.
2
+
3
+ For each task it prices three approaches and checks the answer is actually there:
4
+
5
+ naive grep the terms, read the best-matching files in full (capped) <- realistic baseline
6
+ graphify Graphify's own `query` (budgeted) <- graph map, no source code
7
+ graphsnip pack / find+symbol / impact+symbol <- map + exact code
8
+
9
+ Quality is checked, not assumed: a task counts as answered only when the
10
+ expected symbol shows up in the output (and, for graphsnip, with its source).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from graphsnip.baseline import Baseline, query_terms
20
+ from graphsnip.engine import Engine
21
+ from graphsnip.project import Project
22
+ from graphsnip.tokens import count_tokens, method
23
+
24
+ TYPES = ("understand", "impact", "locate")
25
+
26
+
27
+ def _short(label: str) -> str:
28
+ return re.sub(r"\([^()]*\)\s*$", "", label).strip()
29
+
30
+
31
+ def _resolvable(G, project: Project, name: str) -> bool:
32
+ """Auto-generated tasks must be answerable by the tools; user-written tasks are never filtered."""
33
+ try:
34
+ from graphify.affected import resolve_seed_outcome
35
+ from graphify.serve import _resolve_single_node
36
+
37
+ nid, err = _resolve_single_node(G, name.split(".")[-1] if "." not in name else name)
38
+ seed, _ = resolve_seed_outcome(G, name.split(".")[-1], project.root)
39
+ return nid is not None and not err and seed is not None
40
+ except Exception:
41
+ return False
42
+
43
+
44
+ def default_tasks(project: Project, n: int = 8) -> list[dict]:
45
+ eng = Engine(project.root, source="bench", auto_refresh=False)
46
+ G = project.graph()
47
+ seen_files: dict[str, int] = {}
48
+ picks = []
49
+ idx = project.label_index()
50
+ from graphsnip.project import bare
51
+
52
+ for row in project.top_symbols(80):
53
+ lab = _short(str(row["label"]))
54
+ if len(lab) < 4 or lab.startswith("_") or row["file"] in seen_files and seen_files[row["file"]] >= 1:
55
+ continue
56
+ if len(idx.get(bare(lab), [])) != 1: # ambiguous names make impact/symbol lookups refuse; skip them
57
+ continue
58
+ if not _resolvable(G, project, eng._display(G, row["id"]).replace("()", "")):
59
+ continue
60
+ seen_files[row["file"]] = seen_files.get(row["file"], 0) + 1
61
+ picks.append(row)
62
+ if len(picks) >= n:
63
+ break
64
+ tasks = []
65
+ for i, row in enumerate(picks):
66
+ name = _short(eng._display(G, row["id"]))
67
+ t = TYPES[i % 3]
68
+ q = {"understand": f"how does {name} work and what does it depend on",
69
+ "impact": f"what breaks if I change {name}",
70
+ "locate": f"where is {name} defined and where is it used"}[t]
71
+ tasks.append({"id": f"t{i + 1}", "type": t, "question": q, "expect": [name.split(".")[-1]],
72
+ "target_file": row["file"]})
73
+ return tasks
74
+
75
+
76
+ def load_tasks(project: Project, path: str | None) -> list[dict]:
77
+ p = Path(path) if path else project.gs_dir / "tasks.json"
78
+ if p.exists():
79
+ data = json.loads(p.read_text(encoding="utf-8"))
80
+ tasks = data["tasks"] if isinstance(data, dict) else data
81
+ for i, t in enumerate(tasks):
82
+ t.setdefault("id", f"t{i + 1}")
83
+ t.setdefault("type", "understand")
84
+ t.setdefault("expect", [])
85
+ return tasks
86
+ return default_tasks(project)
87
+
88
+
89
+ def save_default_tasks(project: Project) -> Path:
90
+ project.gs_dir.mkdir(parents=True, exist_ok=True)
91
+ p = project.gs_dir / "tasks.json"
92
+ p.write_text(json.dumps({"_note": "Edit freely. type: understand | impact | locate. expect: symbol names that must appear in a correct answer.",
93
+ "tasks": default_tasks(project)}, indent=2) + "\n", encoding="utf-8")
94
+ return p
95
+
96
+
97
+ def run_bench(root: str = ".", tasks_path: str | None = None, quiet: bool = False) -> dict:
98
+ eng = Engine(root, source="bench")
99
+ p = eng.p
100
+ p.ensure_graph(True)
101
+ G = p.graph()
102
+ tasks = load_tasks(p, tasks_path)
103
+ base = Baseline(p)
104
+ eng.ledger.clear(source="bench")
105
+ from graphify.serve import _query_graph_text
106
+
107
+ corpus_tokens = sum(base.file_tokens(p.rel(f)) for f in p.files()[:20000])
108
+ rows = []
109
+ t0 = time.time()
110
+ for t in tasks:
111
+ q = t["question"]
112
+ expect = [e.lower() for e in t.get("expect", [])]
113
+ e2 = Engine(root, source="bench", auto_refresh=False) # fresh session per task
114
+ naive_tokens, naive_files = base.grep_read(query_terms(q))
115
+ naive_text = " ".join((p.read(f) or "") for f in naive_files).lower()
116
+ naive_hit = all(x in naive_text for x in expect) if expect else bool(naive_files)
117
+
118
+ gtext = _query_graph_text(G, q, mode="bfs", depth=3, token_budget=2000, graph_path=str(p.graph_json))
119
+ gtokens = 0 if gtext.startswith("No matching") else count_tokens(gtext)
120
+ ghit = all(x in gtext.lower() for x in expect) if expect else bool(gtokens)
121
+ # what an agent using Graphify alone then does: read the files its top hits point to
122
+ g_files: list[str] = []
123
+ for m in re.finditer(r"src=([^\s\]]+)", gtext):
124
+ f = m.group(1)
125
+ if f and f not in g_files and (p.root / f).is_file():
126
+ g_files.append(f)
127
+ if len(g_files) >= 3:
128
+ break
129
+ gread_tokens = (gtokens + sum(base.file_tokens(f) for f in g_files)) if gtokens else 0
130
+
131
+ typ = t.get("type", "understand")
132
+ name = (t.get("expect") or [q])[0]
133
+ parts = []
134
+ if typ == "understand":
135
+ parts.append(e2.pack(q, budget=3500))
136
+ elif typ == "impact":
137
+ parts.append(e2.impact(name))
138
+ parts.append(e2.symbol(name))
139
+ else:
140
+ parts.append(e2.find(q, budget=1200))
141
+ parts.append(e2.symbol(name))
142
+ gs_text = "\n".join(r.text for r in parts if r.ok)
143
+ gs_tokens = sum(r.returned for r in parts if r.ok)
144
+ has_code = "```" in gs_text
145
+ gs_hit = (all(x in gs_text.lower() for x in expect) if expect else bool(gs_tokens)) and has_code
146
+ # second read of the same symbol in the same session
147
+ again = e2.symbol(name)
148
+ rows.append({
149
+ "id": t["id"], "type": typ, "question": q, "expect": t.get("expect", []),
150
+ "naive_tokens": naive_tokens, "naive_files": len(naive_files), "naive_hit": naive_hit,
151
+ "graphify_tokens": gtokens, "graphify_hit": ghit, "graphify_read_tokens": gread_tokens,
152
+ "graphsnip_tokens": gs_tokens, "graphsnip_hit": gs_hit, "graphsnip_has_code": has_code,
153
+ "reread_tokens": again.returned if again.ok else None,
154
+ "reduction_vs_naive": round(naive_tokens / gs_tokens, 1) if gs_tokens else None,
155
+ })
156
+ ok = [r for r in rows if r["graphsnip_tokens"]]
157
+ tot_n = sum(r["naive_tokens"] for r in ok)
158
+ tot_c = sum(r["graphsnip_tokens"] for r in ok)
159
+ tot_g = sum(r["graphify_tokens"] for r in rows)
160
+ tot_gr = sum(r["graphify_read_tokens"] for r in rows)
161
+ result = {
162
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
163
+ "root": str(p.root),
164
+ "token_method": method(),
165
+ "baseline_definition": "grep the question's terms, read the top 8 matching files in full",
166
+ "graph": p.graph_stats(),
167
+ "corpus_tokens": corpus_tokens,
168
+ "tasks": rows,
169
+ "totals": {
170
+ "naive_tokens": tot_n, "graphsnip_tokens": tot_c, "graphify_tokens": tot_g, "graphify_read_tokens": tot_gr,
171
+ "saved_vs_graphify_read_pct": round(100 * (tot_gr - tot_c) / tot_gr, 1) if tot_gr else 0.0,
172
+ "saved_tokens": tot_n - tot_c,
173
+ "saved_pct": round(100 * (tot_n - tot_c) / tot_n, 1) if tot_n else 0.0,
174
+ "ratio": round(tot_n / tot_c, 1) if tot_c else 0.0,
175
+ "vs_corpus_ratio": round(corpus_tokens / (tot_c / len(ok)), 1) if ok and tot_c else 0.0,
176
+ "answered": sum(1 for r in rows if r["graphsnip_hit"]),
177
+ "naive_answered": sum(1 for r in rows if r["naive_hit"]),
178
+ "graphify_answered": sum(1 for r in rows if r["graphify_hit"]),
179
+ "tasks": len(rows),
180
+ "seconds": round(time.time() - t0, 1),
181
+ },
182
+ }
183
+ p.gs_dir.mkdir(parents=True, exist_ok=True)
184
+ (p.gs_dir / "bench.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
185
+ return result
186
+
187
+
188
+ def format_table(res: dict) -> str:
189
+ rows = res["tasks"]
190
+ t = res["totals"]
191
+ w = max(8, min(44, max(len(r["question"]) for r in rows)))
192
+ hdr = f"{'task':<{w}} {'grep+read':>9} {'graphify':>8} {'gfy+read':>8} {'graphsnip':>9} {'saved':>6} ok"
193
+ out = [hdr, "-" * len(hdr)]
194
+ for r in rows:
195
+ saved = f"{100 * (1 - r['graphsnip_tokens'] / r['naive_tokens']):.0f}%" if r["naive_tokens"] and r["graphsnip_tokens"] else "-"
196
+ out.append(f"{r['question'][:w]:<{w}} {r['naive_tokens']:>9,} {r['graphify_tokens']:>8,} {r['graphify_read_tokens']:>8,} "
197
+ f"{r['graphsnip_tokens']:>9,} {saved:>6} {'✓' if r['graphsnip_hit'] else '✗'}")
198
+ out.append("-" * len(hdr))
199
+ out.append(f"{'TOTAL':<{w}} {t['naive_tokens']:>9,} {t['graphify_tokens']:>8,} {t['graphify_read_tokens']:>8,} "
200
+ f"{t['graphsnip_tokens']:>9,} {t['saved_pct']:>5}% {t['answered']}/{t['tasks']}")
201
+ out.append(f"\ngrep+read = {res['baseline_definition']} (what an agent does with no graph)")
202
+ out.append("graphify = Graphify `query` alone: a map with no source code")
203
+ out.append("gfy+read = that map, then reading the whole files its top hits point to (3 files max)")
204
+ out.append("graphsnip = exact code, ranked and budgeted (pack / find+symbol / impact+symbol)")
205
+ out.append(f"Saved column is vs grep+read; vs graphify+read it is {t['saved_vs_graphify_read_pct']}%. Tokens: {res['token_method']}.")
206
+ out.append(f"Answered (expected symbol + source present): graphsnip {t['answered']}/{t['tasks']}, "
207
+ f"grep+read {t['naive_answered']}/{t['tasks']}.")
208
+ return "\n".join(out)
graphsnip/cli.py ADDED
@@ -0,0 +1,368 @@
1
+ """graphsnip command line."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import importlib.metadata as md
6
+ import json
7
+ import shutil
8
+ import sys
9
+ import webbrowser
10
+
11
+ from graphsnip import __version__
12
+ from graphsnip import ledger as _ledger
13
+ from graphsnip.engine import Engine, Result
14
+ from graphsnip.project import GraphError, Project
15
+
16
+
17
+ def _common() -> argparse.ArgumentParser:
18
+ c = argparse.ArgumentParser(add_help=False)
19
+ c.add_argument("--root", default=".", help="project root (default: current directory)")
20
+ return c
21
+
22
+
23
+ def _emit(r: Result, quiet: bool = False) -> int:
24
+ print(r.text)
25
+ if r.ok and not quiet:
26
+ pct = f"{100 * (1 - r.returned / r.baseline):.0f}%" if r.baseline else "n/a"
27
+ note = " (duplicate, suppressed)" if r.deduped else ""
28
+ sys.stderr.write(f"\n[graphsnip] ~{r.returned:,} tokens returned vs ~{r.baseline:,} for grep+read → saved {pct}{note}\n")
29
+ return 0 if r.ok else 1
30
+
31
+
32
+ def cmd_build(a) -> int:
33
+ p = Project(a.root)
34
+ s = p.build(force=a.force)
35
+ print(f"Graph ready ({s['mode']}, {s['seconds']}s): {s['nodes']:,} nodes, {s['edges']:,} edges, "
36
+ f"{s['communities']:,} communities, {s['symbols']:,} code symbols.")
37
+ print(f" map: {p.graph_html}")
38
+ print(f" report: {p.report_md}")
39
+ return 0
40
+
41
+
42
+ def cmd_status(a) -> int:
43
+ p = Project(a.root)
44
+ if not p.has_graph():
45
+ print("No graph yet. Run `graphsnip build` or `graphsnip init`.")
46
+ return 1
47
+ s = p.graph_stats()
48
+ print(f"Project: {p.root}")
49
+ print(f"Graph: {s['nodes']:,} nodes · {s['edges']:,} edges · {s['communities']:,} communities · {s['symbols']:,} code symbols")
50
+ print(f"Stale: {'yes, run `graphsnip build` (the server refreshes automatically)' if p.is_stale(0) else 'no'}")
51
+ print(f"Mode: {p.config()['mode']}")
52
+ return 0
53
+
54
+
55
+ def cmd_find(a) -> int:
56
+ return _emit(Engine(a.root, source="cli").find(a.question, budget=a.budget, depth=a.depth), a.quiet)
57
+
58
+
59
+ def cmd_symbol(a) -> int:
60
+ return _emit(Engine(a.root, source="cli").symbol(a.name, max_lines=a.max_lines, docs="full" if a.full_docs else "short"), a.quiet)
61
+
62
+
63
+ def cmd_pack(a) -> int:
64
+ return _emit(Engine(a.root, source="cli").pack(a.task, budget=a.budget), a.quiet)
65
+
66
+
67
+ def cmd_impact(a) -> int:
68
+ return _emit(Engine(a.root, source="cli").impact(a.name, depth=a.depth), a.quiet)
69
+
70
+
71
+ def cmd_outline(a) -> int:
72
+ return _emit(Engine(a.root, source="cli").outline(a.path), a.quiet)
73
+
74
+
75
+ def cmd_stats(a) -> int:
76
+ p = Project(a.root)
77
+ recs = _ledger.Ledger(p.gs_dir).read()
78
+ price = float(p.config().get("price_per_mtok", 3.0))
79
+ if a.json:
80
+ print(json.dumps({"live": _ledger.summarize(recs, price, {"agent", "cli", "shell"}),
81
+ "bench": _ledger.summarize(recs, price, {"bench"})}, indent=2, default=str))
82
+ return 0
83
+ for title, src in (("Live usage (agents + CLI + shell)", {"agent", "cli", "shell"}), ("Benchmark", {"bench"})):
84
+ s = _ledger.summarize(recs, price, src)
85
+ print(f"\n{title}")
86
+ if not s["calls"]:
87
+ print(" nothing recorded yet")
88
+ continue
89
+ print(f" {s['calls']} calls · {s['returned']:,} tokens returned vs {s['baseline']:,} baseline")
90
+ print(f" saved {s['saved']:,} tokens ({s['pct']}%, {s['ratio']}x) ≈ ${s['usd_saved']} at ${price:g}/M input tokens")
91
+ for tool, t in sorted(s["by_tool"].items(), key=lambda kv: -(kv[1]["baseline"] - kv[1]["returned"])):
92
+ print(f" {tool:<8} {t['calls']:>4} calls {t['returned']:>9,} vs {t['baseline']:>10,}")
93
+ if s["hooks"]:
94
+ print(" hooks: " + ", ".join(f"{k} ×{v}" for k, v in s["hooks"].items()))
95
+ return 0
96
+
97
+
98
+ def cmd_report(a) -> int:
99
+ from graphsnip.dashboard import write_dashboard
100
+
101
+ p = Project(a.root)
102
+ if not p.has_graph():
103
+ print("No graph yet. Run `graphsnip build` first.")
104
+ return 1
105
+ out = write_dashboard(p, bundle=a.bundle)
106
+ print(f"Dashboard written: {out}")
107
+ if a.bundle:
108
+ print("The folder is self-contained (dashboard.html + graph.html): zip or share it as is.")
109
+ if a.open:
110
+ webbrowser.open(out.as_uri())
111
+ return 0
112
+
113
+
114
+ def cmd_bench(a) -> int:
115
+ from graphsnip.bench import format_table, run_bench, save_default_tasks
116
+
117
+ p = Project(a.root)
118
+ p.ensure_graph(True)
119
+ if a.init_tasks or (not (p.gs_dir / "tasks.json").exists() and not a.tasks):
120
+ path = save_default_tasks(p)
121
+ print(f"Wrote starter tasks to {path} (edit them to use your own questions).\n")
122
+ res = run_bench(a.root, a.tasks)
123
+ if a.json:
124
+ print(json.dumps(res, indent=2))
125
+ else:
126
+ print(format_table(res))
127
+ return 0
128
+
129
+
130
+ def cmd_demo(a) -> int:
131
+ from graphsnip.bench import format_table, run_bench, save_default_tasks
132
+ from graphsnip.dashboard import write_dashboard
133
+
134
+ p = Project(a.root)
135
+ if not p.has_graph():
136
+ print("Building the Graphify graph (code only, local, no LLM)…")
137
+ s = p.build()
138
+ print(f" {s['nodes']:,} nodes, {s['edges']:,} edges in {s['seconds']}s\n")
139
+ if not (p.gs_dir / "tasks.json").exists():
140
+ save_default_tasks(p)
141
+ print("Running benchmark…\n")
142
+ res = run_bench(a.root)
143
+ print(format_table(res))
144
+ out = write_dashboard(p, bundle=a.bundle)
145
+ print(f"\nDashboard: {out}")
146
+ if a.open:
147
+ webbrowser.open(out.as_uri())
148
+ return 0
149
+
150
+
151
+ def cmd_serve(a) -> int:
152
+ from graphsnip.mcp_server import serve
153
+
154
+ serve(a.root)
155
+ return 0
156
+
157
+
158
+ def cmd_hook(a) -> int:
159
+ from graphsnip.hooks import main_stdin
160
+
161
+ return main_stdin(a.root)
162
+
163
+
164
+ def cmd_run(a) -> int:
165
+ from graphsnip.shellfilter import run
166
+
167
+ argv = list(a.cmd)
168
+ if argv and argv[0] == "--":
169
+ argv = argv[1:]
170
+ return run(argv, a.root)
171
+
172
+
173
+ def _clients(s: str) -> list[str]:
174
+ return [x.strip().lower() for x in s.split(",") if x.strip()]
175
+
176
+
177
+ def cmd_install(a) -> int:
178
+ from graphsnip.installer import install
179
+
180
+ try:
181
+ for ln in install(a.root, _clients(a.clients), dry_run=a.dry_run, allow_tools=a.allow_tools,
182
+ codex_scope=a.codex_scope, strict=a.strict, portable=a.portable):
183
+ print(ln)
184
+ except (ValueError, RuntimeError) as e:
185
+ print(f"error: {e}", file=sys.stderr)
186
+ return 2
187
+ if not a.dry_run:
188
+ print("\nRestart your agent/IDE so it picks up the MCP server. `graphsnip doctor` checks the setup.")
189
+ return 0
190
+
191
+
192
+ def cmd_uninstall(a) -> int:
193
+ from graphsnip.installer import uninstall
194
+
195
+ for ln in uninstall(a.root, _clients(a.clients), a.codex_scope):
196
+ print(ln)
197
+ return 0
198
+
199
+
200
+ def cmd_init(a) -> int:
201
+ from graphsnip.bench import format_table, run_bench, save_default_tasks
202
+ from graphsnip.dashboard import write_dashboard
203
+ from graphsnip.installer import install
204
+
205
+ p = Project(a.root)
206
+ if a.dry_run:
207
+ print("1/4 [dry-run] skipping graph build")
208
+ elif not a.no_build:
209
+ print("1/4 Building the Graphify graph (code only, local, no LLM)…")
210
+ s = p.build()
211
+ print(f" {s['nodes']:,} nodes, {s['edges']:,} edges, {s['communities']:,} communities in {s['seconds']}s")
212
+ print("2/4 Installing agent integrations…")
213
+ try:
214
+ for ln in install(a.root, _clients(a.clients), dry_run=a.dry_run, allow_tools=a.allow_tools,
215
+ codex_scope=a.codex_scope, strict=a.strict, portable=a.portable):
216
+ print(" " + ln)
217
+ except (ValueError, RuntimeError) as e:
218
+ print(f"error: {e}", file=sys.stderr)
219
+ return 2
220
+ if a.dry_run or not p.has_graph():
221
+ return 0
222
+ if not a.no_bench:
223
+ print("3/4 Benchmarking on this repo…")
224
+ save_default_tasks(p) if not (p.gs_dir / "tasks.json").exists() else None
225
+ res = run_bench(a.root)
226
+ print("\n" + format_table(res) + "\n")
227
+ print("4/4 Writing the dashboard…")
228
+ out = write_dashboard(p)
229
+ print(f" {out}")
230
+ print("\nDone. Restart your agent/IDE, then ask it a code question. `graphsnip report --open` shows the savings.")
231
+ return 0
232
+
233
+
234
+ def cmd_doctor(a) -> int:
235
+ from graphsnip.installer import CLIENTS, HOOK_MARK
236
+
237
+ p = Project(a.root)
238
+ ok = True
239
+
240
+ def line(good: bool, msg: str, fix: str = ""):
241
+ nonlocal ok
242
+ ok &= good
243
+ print(f"{'✓' if good else '✗'} {msg}" + (f" → {fix}" if (fix and not good) else ""))
244
+
245
+ line(sys.version_info >= (3, 10), f"Python {sys.version.split()[0]}", "needs Python 3.10+")
246
+ try:
247
+ gv = md.version("graphifyy")
248
+ line(True, f"Graphify {gv}")
249
+ except Exception:
250
+ line(False, "Graphify not installed", "pip install graphifyy")
251
+ try:
252
+ import tree_sitter # noqa: F401
253
+ line(True, "tree-sitter available (exact symbol slicing)")
254
+ except Exception:
255
+ line(False, "tree-sitter missing; slicing falls back to heuristics", "pip install graphifyy")
256
+ exe = shutil.which("graphsnip")
257
+ line(bool(exe), f"`graphsnip` on PATH ({exe})" if exe else "`graphsnip` not on PATH (configs will use `python -m graphsnip`)", "pipx install graphsnip")
258
+ line(p.has_graph(), f"graph present ({p.graph_json})" if p.has_graph() else "no graph yet", "graphsnip build")
259
+ if p.has_graph():
260
+ line(not p.is_stale(0), "graph is up to date" if not p.is_stale(0) else "graph is older than some source files", "graphsnip build (the server also refreshes itself)")
261
+ s = p.graph_stats()
262
+ line(s["symbols"] > 0, f"{s['symbols']:,} code symbols indexed")
263
+ mcp_claude = (p.root / ".mcp.json")
264
+ st = p.root / ".claude" / "settings.json"
265
+ found = {
266
+ "claude": mcp_claude.exists() and "graphsnip" in mcp_claude.read_text(encoding="utf-8", errors="ignore"),
267
+ "copilot": (p.root / ".vscode" / "mcp.json").exists() and "graphsnip" in (p.root / ".vscode" / "mcp.json").read_text(encoding="utf-8", errors="ignore"),
268
+ "codex": (p.root / ".codex" / "config.toml").exists() and "graphsnip" in (p.root / ".codex" / "config.toml").read_text(encoding="utf-8", errors="ignore"),
269
+ }
270
+ for c in CLIENTS:
271
+ print(f"{'✓' if found[c] else '·'} {c}: {'MCP server registered' if found[c] else 'not installed here (graphsnip install --clients ' + c + ')'}")
272
+ hook_on = st.exists() and HOOK_MARK in st.read_text(encoding="utf-8", errors="ignore")
273
+ print(f"{'✓' if hook_on else '·'} claude hook: {'installed' if hook_on else 'not installed'} (mode: {p.config()['mode']})")
274
+ print("\nAll good." if ok else "\nSome checks failed; see arrows above.")
275
+ return 0 if ok else 1
276
+
277
+
278
+ def main(argv=None) -> int:
279
+ common = _common()
280
+ ap = argparse.ArgumentParser(prog="graphsnip", description="Token-saving layer on top of Graphify.")
281
+ ap.add_argument("--version", action="version", version=f"graphsnip {__version__}")
282
+ sub = ap.add_subparsers(dest="cmd")
283
+
284
+ def add(name, fn, help_, **kw):
285
+ sp = sub.add_parser(name, parents=[common], help=help_, **kw)
286
+ sp.set_defaults(fn=fn)
287
+ return sp
288
+
289
+ sp = add("init", cmd_init, "build graph + install integrations + benchmark + dashboard")
290
+ sp.add_argument("--clients", default="all", help="claude,copilot,codex or all (default: all)")
291
+ sp.add_argument("--dry-run", action="store_true")
292
+ sp.add_argument("--allow-tools", action="store_true", help="Claude Code: pre-approve the read-only graphsnip tools")
293
+ sp.add_argument("--strict", action="store_true", help="hooks deny redirectable calls instead of nudging")
294
+ sp.add_argument("--portable", action="store_true", help="write bare `graphsnip` into configs (safe to commit) instead of this machine's path")
295
+ sp.add_argument("--codex-scope", choices=["project", "user"], default="project")
296
+ sp.add_argument("--no-build", action="store_true")
297
+ sp.add_argument("--no-bench", action="store_true")
298
+
299
+ sp = add("build", cmd_build, "build or refresh the Graphify graph (code only, no LLM)")
300
+ sp.add_argument("--force", action="store_true")
301
+ add("status", cmd_status, "show graph status")
302
+ sp = add("find", cmd_find, "locate symbols and connections (budgeted map)")
303
+ sp.add_argument("question")
304
+ sp.add_argument("--budget", type=int, default=1500)
305
+ sp.add_argument("--depth", type=int, default=2)
306
+ sp.add_argument("--quiet", action="store_true")
307
+ sp = add("symbol", cmd_symbol, "read one function/class with callers and callees")
308
+ sp.add_argument("name")
309
+ sp.add_argument("--max-lines", type=int, default=120)
310
+ sp.add_argument("--full-docs", action="store_true")
311
+ sp.add_argument("--quiet", action="store_true")
312
+ sp = add("pack", cmd_pack, "token-budgeted context pack for a task")
313
+ sp.add_argument("task")
314
+ sp.add_argument("--budget", type=int, default=4000)
315
+ sp.add_argument("--quiet", action="store_true")
316
+ sp = add("impact", cmd_impact, "what depends on a symbol")
317
+ sp.add_argument("name")
318
+ sp.add_argument("--depth", type=int, default=2)
319
+ sp.add_argument("--quiet", action="store_true")
320
+ sp = add("outline", cmd_outline, "symbols in a file with line numbers")
321
+ sp.add_argument("path")
322
+ sp.add_argument("--quiet", action="store_true")
323
+ sp = add("stats", cmd_stats, "token savings so far")
324
+ sp.add_argument("--json", action="store_true")
325
+ sp = add("report", cmd_report, "write the HTML dashboard")
326
+ sp.add_argument("--open", action="store_true")
327
+ sp.add_argument("--bundle", metavar="DIR", help="write a self-contained folder (dashboard + graph.html) for sharing")
328
+ sp = add("bench", cmd_bench, "offline benchmark: grep+read vs Graphify vs graphsnip")
329
+ sp.add_argument("--tasks", help="tasks JSON (default .graphsnip/tasks.json, auto-generated)")
330
+ sp.add_argument("--init-tasks", action="store_true", help="(re)write starter tasks")
331
+ sp.add_argument("--json", action="store_true")
332
+ sp = add("demo", cmd_demo, "one command for a live demo: graph + benchmark + dashboard")
333
+ sp.add_argument("--open", action="store_true")
334
+ sp.add_argument("--bundle", metavar="DIR")
335
+ add("serve", cmd_serve, "run the MCP server over stdio")
336
+ sp = add("hook", cmd_hook, "agent hook entry point (reads JSON on stdin)")
337
+ sp.add_argument("event", choices=["pretooluse"])
338
+ sp = add("run", cmd_run, "run a command and compress its output (exact token savings)")
339
+ sp.add_argument("cmd", nargs=argparse.REMAINDER)
340
+ sp = add("install", cmd_install, "write MCP + hooks + guidance for Claude Code / Copilot / Codex")
341
+ sp.add_argument("--clients", default="all")
342
+ sp.add_argument("--dry-run", action="store_true")
343
+ sp.add_argument("--allow-tools", action="store_true")
344
+ sp.add_argument("--strict", action="store_true")
345
+ sp.add_argument("--portable", action="store_true")
346
+ sp.add_argument("--codex-scope", choices=["project", "user"], default="project")
347
+ sp = add("uninstall", cmd_uninstall, "remove everything install added")
348
+ sp.add_argument("--clients", default="all")
349
+ sp.add_argument("--codex-scope", choices=["project", "user"], default="project")
350
+ add("doctor", cmd_doctor, "check the setup")
351
+
352
+ args = ap.parse_args(argv)
353
+ if not getattr(args, "fn", None):
354
+ ap.print_help()
355
+ return 0
356
+ try:
357
+ return int(args.fn(args) or 0)
358
+ except GraphError as e:
359
+ print(f"error: {e}", file=sys.stderr)
360
+ return 1
361
+ except BrokenPipeError:
362
+ return 0
363
+ except KeyboardInterrupt:
364
+ return 130
365
+
366
+
367
+ if __name__ == "__main__":
368
+ raise SystemExit(main())