ckgraphify 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.
graphify/__main__.py ADDED
@@ -0,0 +1,2885 @@
1
+ """graphify CLI - `graphify install` sets up the cgraph assistant skill."""
2
+ from __future__ import annotations
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ try:
11
+ from importlib.metadata import version as _pkg_version
12
+ __version__ = _pkg_version("ckgraphify")
13
+ except Exception:
14
+ __version__ = "unknown"
15
+
16
+ # Output directory — override with GRAPHIFY_OUT env var for worktrees or shared-output setups.
17
+ # Accepts a relative name ("graphify-out-feature") or an absolute path ("/shared/graphify-out").
18
+ _GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")
19
+
20
+
21
+ def _default_graph_path() -> str:
22
+ return str(Path(_GRAPHIFY_OUT) / "graph.json")
23
+
24
+
25
+ def _check_skill_version(skill_dst: Path) -> None:
26
+ """Warn if the installed skill is from an older ckgraphify package version."""
27
+ version_file = skill_dst.parent / ".cgraph_version"
28
+ if not version_file.exists():
29
+ return
30
+ if not skill_dst.exists():
31
+ print(" warning: skill dir exists but SKILL.md is missing. Run 'graphify install' to repair.")
32
+ return
33
+ installed = version_file.read_text(encoding="utf-8").strip()
34
+ if installed != __version__:
35
+ print(f" warning: skill is from ckgraphify {installed}, package is {__version__}. Run 'graphify install' to update.", file=sys.stderr)
36
+
37
+
38
+ def _refresh_all_version_stamps() -> None:
39
+ """After a successful install, update .cgraph_version in all other known skill dirs.
40
+
41
+ Prevents stale-version warnings from platforms that were installed previously
42
+ but not explicitly re-installed during this upgrade.
43
+ """
44
+ for cfg in _PLATFORM_CONFIG.values():
45
+ skill_dst = Path.home() / cfg["skill_dst"]
46
+ vf = skill_dst.parent / ".cgraph_version"
47
+ if skill_dst.exists():
48
+ vf.write_text(__version__, encoding="utf-8")
49
+
50
+ _SETTINGS_HOOK = {
51
+ # Claude Code v2.1.117+ removed dedicated Grep/Glob tools; searches now go through Bash.
52
+ # We match on Bash and inspect the command string to avoid firing on every shell call.
53
+ "matcher": "Bash",
54
+ "hooks": [
55
+ {
56
+ "type": "command",
57
+ "command": (
58
+ "CMD=$(python3 -c \""
59
+ "import json,sys; d=json.load(sys.stdin); "
60
+ "print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); "
61
+ "case \"$CMD\" in "
62
+ r"*grep*|*rg\ *|*ripgrep*|*find\ *|*fd\ *|*ack\ *|*ag\ *) "
63
+ " [ -f graphify-out/graph.json ] && "
64
+ r""" echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"cgraph: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."}}' """
65
+ " || true ;; "
66
+ "esac"
67
+ ),
68
+ }
69
+ ],
70
+ }
71
+
72
+ _SKILL_REGISTRATION = (
73
+ "\n# cgraph\n"
74
+ "- **cgraph** (`~/.claude/skills/cgraph/SKILL.md`) "
75
+ "- graph-main, multi-repo call-chain, and business-map skill. Trigger: `/cgraph`\n"
76
+ "When the user types `/cgraph`, invoke the Skill tool "
77
+ "with `skill: \"cgraph\"` before doing anything else.\n"
78
+ )
79
+
80
+
81
+ _PLATFORM_CONFIG: dict[str, dict] = {
82
+ "claude": {
83
+ "skill_file": "skill/skill.md",
84
+ "skill_dst": Path(".claude") / "skills" / "cgraph" / "SKILL.md",
85
+ "claude_md": True,
86
+ },
87
+ "codex": {
88
+ "skill_file": "skill/skill-codex.md",
89
+ "skill_dst": Path(".agents") / "skills" / "cgraph" / "SKILL.md",
90
+ "claude_md": False,
91
+ },
92
+ }
93
+
94
+
95
+ def install(platform: str = "claude") -> None:
96
+ if platform not in _PLATFORM_CONFIG:
97
+ print(
98
+ f"error: unknown platform '{platform}'. Choose from: {', '.join(_PLATFORM_CONFIG)}",
99
+ file=sys.stderr,
100
+ )
101
+ sys.exit(1)
102
+
103
+ cfg = _PLATFORM_CONFIG[platform]
104
+ skill_src = Path(__file__).parent.parent / cfg["skill_file"]
105
+ if not skill_src.exists():
106
+ print(f"error: {cfg['skill_file']} not found in package - reinstall cgraph", file=sys.stderr)
107
+ sys.exit(1)
108
+
109
+ import os as _os
110
+ if platform == "claude" and _os.environ.get("CLAUDE_CONFIG_DIR"):
111
+ _claude_base = Path(_os.environ["CLAUDE_CONFIG_DIR"])
112
+ skill_dst = _claude_base / "skills" / "cgraph" / "SKILL.md"
113
+ else:
114
+ skill_dst = Path.home() / cfg["skill_dst"]
115
+ skill_dst.parent.mkdir(parents=True, exist_ok=True)
116
+ tmp_dst = skill_dst.with_suffix(skill_dst.suffix + ".tmp")
117
+ try:
118
+ shutil.copy(skill_src, tmp_dst)
119
+ os.replace(tmp_dst, skill_dst)
120
+ except Exception:
121
+ try:
122
+ tmp_dst.unlink(missing_ok=True)
123
+ except OSError:
124
+ pass
125
+ raise
126
+ (skill_dst.parent / ".cgraph_version").write_text(__version__, encoding="utf-8")
127
+ print(f" skill installed -> {skill_dst}")
128
+
129
+ if cfg["claude_md"]:
130
+ # Register in ~/.claude/CLAUDE.md (Claude Code only)
131
+ claude_md = Path.home() / ".claude" / "CLAUDE.md"
132
+ if claude_md.exists():
133
+ content = claude_md.read_text(encoding="utf-8")
134
+ if "cgraph" in content:
135
+ print(f" CLAUDE.md -> already registered (no change)")
136
+ else:
137
+ claude_md.write_text(content.rstrip() + _SKILL_REGISTRATION, encoding="utf-8")
138
+ print(f" CLAUDE.md -> skill registered in {claude_md}")
139
+ else:
140
+ claude_md.parent.mkdir(parents=True, exist_ok=True)
141
+ claude_md.write_text(_SKILL_REGISTRATION.lstrip(), encoding="utf-8")
142
+ print(f" CLAUDE.md -> created at {claude_md}")
143
+
144
+ # Refresh version stamps in all other previously-installed skill dirs so
145
+ # stale-version warnings don't fire for platforms not explicitly re-installed.
146
+ _refresh_all_version_stamps()
147
+
148
+ print()
149
+ print("Done. Open your AI coding assistant and type:")
150
+ print()
151
+ print(" /cgraph")
152
+ print()
153
+
154
+
155
+ def _print_install_usage() -> None:
156
+ platforms = ", ".join([*_PLATFORM_CONFIG])
157
+ print("Usage: graphify install [--platform P|P]")
158
+ print(f"Platforms: {platforms}")
159
+
160
+
161
+ _CLAUDE_MD_SECTION = """\
162
+ ## cgraph
163
+
164
+ This project has a cgraph knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
165
+
166
+ Rules:
167
+ - ALWAYS read graphify-out/GRAPH_REPORT.md before reading any source files, running grep/glob searches, or answering codebase questions. The graph is your primary map of the codebase.
168
+ - IF graphify-out/wiki/index.md EXISTS, navigate it instead of reading raw files
169
+ - For cross-module "how does X relate to Y" questions, prefer `graphify query "<question>"`, `graphify path "<A>" "<B>"`, or `graphify explain "<concept>"` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files
170
+ - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
171
+ """
172
+
173
+ _CLAUDE_MD_MARKER = "## cgraph"
174
+
175
+ # AGENTS.md section for Codex.
176
+ _AGENTS_MD_SECTION = """\
177
+ ## cgraph
178
+
179
+ This project has a cgraph knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
180
+
181
+ When the user types `/cgraph` or `$cgraph`, invoke the `skill` tool with `skill: "cgraph"` before doing anything else.
182
+
183
+ Rules:
184
+ - ALWAYS read graphify-out/GRAPH_REPORT.md before reading any source files, running grep/glob searches, or answering codebase questions. The graph is your primary map of the codebase.
185
+ - IF graphify-out/wiki/index.md EXISTS, navigate it instead of reading raw files
186
+ - For cross-module "how does X relate to Y" questions, prefer `graphify query "<question>"`, `graphify path "<A>" "<B>"`, or `graphify explain "<concept>"` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files
187
+ - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
188
+ """
189
+
190
+ _AGENTS_MD_MARKER = "## cgraph"
191
+
192
+
193
+ _CODEX_HOOK = {
194
+ "hooks": {
195
+ "PreToolUse": [
196
+ {
197
+ "matcher": "Bash",
198
+ "hooks": [
199
+ {
200
+ "type": "command",
201
+ # Use the graphify CLI itself so the hook is shell-agnostic:
202
+ # no [ -f ] bash syntax, no python3 vs python Conda issue,
203
+ # no JSON escaping inside PowerShell strings. Works on
204
+ # Windows (PowerShell/cmd.exe), macOS, and Linux.
205
+ "command": "graphify hook-check",
206
+ }
207
+ ],
208
+ }
209
+ ]
210
+ }
211
+ }
212
+
213
+
214
+ def _resolve_graphify_exe() -> str:
215
+ """Return the absolute path to the graphify executable.
216
+
217
+ Falls back to bare 'graphify' if resolution fails. Using an absolute path
218
+ ensures the hook works in environments where the venv Scripts/ directory is
219
+ not on PATH (e.g. VS Code Codex extension on Windows).
220
+ """
221
+ import shutil
222
+ found = shutil.which("graphify")
223
+ if found:
224
+ return found
225
+ # Derive from sys.executable: same Scripts/ (Windows) or bin/ (Unix) dir
226
+ scripts_dir = Path(sys.executable).parent
227
+ for name in ("graphify.exe", "graphify"):
228
+ candidate = scripts_dir / name
229
+ if candidate.exists():
230
+ return str(candidate)
231
+ return "graphify"
232
+
233
+
234
+ def _install_codex_hook(project_dir: Path) -> None:
235
+ """Add cgraph PreToolUse hook to .codex/hooks.json."""
236
+ hooks_path = project_dir / ".codex" / "hooks.json"
237
+ hooks_path.parent.mkdir(parents=True, exist_ok=True)
238
+
239
+ if hooks_path.exists():
240
+ try:
241
+ existing = json.loads(hooks_path.read_text(encoding="utf-8"))
242
+ except json.JSONDecodeError:
243
+ existing = {}
244
+ else:
245
+ existing = {}
246
+
247
+ graphify_exe = _resolve_graphify_exe()
248
+ hook_entry = {
249
+ "hooks": {
250
+ "PreToolUse": [
251
+ {
252
+ "matcher": "Bash",
253
+ "hooks": [{"type": "command", "command": f"{graphify_exe} hook-check"}],
254
+ }
255
+ ]
256
+ }
257
+ }
258
+
259
+ pre_tool = existing.setdefault("hooks", {}).setdefault("PreToolUse", [])
260
+ existing["hooks"]["PreToolUse"] = [h for h in pre_tool if "graphify" not in str(h)]
261
+ existing["hooks"]["PreToolUse"].extend(hook_entry["hooks"]["PreToolUse"])
262
+ hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
263
+ print(f" .codex/hooks.json -> PreToolUse hook registered ({graphify_exe} hook-check)")
264
+
265
+
266
+ def _uninstall_codex_hook(project_dir: Path) -> None:
267
+ """Remove cgraph PreToolUse hook from .codex/hooks.json."""
268
+ hooks_path = project_dir / ".codex" / "hooks.json"
269
+ if not hooks_path.exists():
270
+ return
271
+ try:
272
+ existing = json.loads(hooks_path.read_text(encoding="utf-8"))
273
+ except json.JSONDecodeError:
274
+ return
275
+ pre_tool = existing.get("hooks", {}).get("PreToolUse", [])
276
+ filtered = [h for h in pre_tool if "graphify" not in str(h)]
277
+ existing["hooks"]["PreToolUse"] = filtered
278
+ hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
279
+ print(f" .codex/hooks.json -> PreToolUse hook removed")
280
+
281
+
282
+ def _agents_install(project_dir: Path) -> None:
283
+ """Write the cgraph section to the local AGENTS.md (Codex)."""
284
+ target = (project_dir or Path(".")) / "AGENTS.md"
285
+
286
+ if target.exists():
287
+ content = target.read_text(encoding="utf-8")
288
+ if _AGENTS_MD_MARKER in content:
289
+ print(f"cgraph already configured in AGENTS.md")
290
+ else:
291
+ target.write_text(content.rstrip() + "\n\n" + _AGENTS_MD_SECTION, encoding="utf-8")
292
+ print(f"cgraph section written to {target.resolve()}")
293
+ else:
294
+ target.write_text(_AGENTS_MD_SECTION, encoding="utf-8")
295
+ print(f"cgraph section written to {target.resolve()}")
296
+
297
+ _install_codex_hook(project_dir or Path("."))
298
+
299
+ print()
300
+ print("Codex will now check the knowledge graph before answering")
301
+ print("codebase questions and rebuild it after code changes.")
302
+
303
+
304
+ def _agents_uninstall(project_dir: Path) -> None:
305
+ """Remove the cgraph section from the local AGENTS.md."""
306
+ target = (project_dir or Path(".")) / "AGENTS.md"
307
+
308
+ if not target.exists():
309
+ print("No AGENTS.md found in current directory - nothing to do")
310
+ return
311
+
312
+ content = target.read_text(encoding="utf-8")
313
+ if _AGENTS_MD_MARKER not in content:
314
+ print("cgraph section not found in AGENTS.md - nothing to do")
315
+ return
316
+
317
+ cleaned = re.sub(
318
+ r"\n*## cgraph\n.*?(?=\n## |\Z)",
319
+ "",
320
+ content,
321
+ flags=re.DOTALL,
322
+ ).rstrip()
323
+ if cleaned:
324
+ target.write_text(cleaned + "\n", encoding="utf-8")
325
+ print(f"cgraph section removed from {target.resolve()}")
326
+ else:
327
+ target.unlink()
328
+ print(f"AGENTS.md was empty after removal - deleted {target.resolve()}")
329
+
330
+
331
+
332
+ def claude_install(project_dir: Path | None = None) -> None:
333
+ """Write the cgraph section to the local CLAUDE.md."""
334
+ target = (project_dir or Path(".")) / "CLAUDE.md"
335
+
336
+ if target.exists():
337
+ content = target.read_text(encoding="utf-8")
338
+ if _CLAUDE_MD_MARKER in content:
339
+ print("cgraph already configured in CLAUDE.md")
340
+ return
341
+ new_content = content.rstrip() + "\n\n" + _CLAUDE_MD_SECTION
342
+ else:
343
+ new_content = _CLAUDE_MD_SECTION
344
+
345
+ target.write_text(new_content, encoding="utf-8")
346
+ print(f"cgraph section written to {target.resolve()}")
347
+
348
+ # Also write Claude Code PreToolUse hook to .claude/settings.json
349
+ _install_claude_hook(project_dir or Path("."))
350
+
351
+ print()
352
+ print("Claude Code will now check the knowledge graph before answering")
353
+ print("codebase questions and rebuild it after code changes.")
354
+
355
+
356
+ def _install_claude_hook(project_dir: Path) -> None:
357
+ """Add cgraph PreToolUse hook to .claude/settings.json."""
358
+ settings_path = project_dir / ".claude" / "settings.json"
359
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
360
+
361
+ if settings_path.exists():
362
+ try:
363
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
364
+ except json.JSONDecodeError:
365
+ settings = {}
366
+ else:
367
+ settings = {}
368
+
369
+ hooks = settings.setdefault("hooks", {})
370
+ pre_tool = hooks.setdefault("PreToolUse", [])
371
+
372
+ hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash") and "graphify" in str(h))]
373
+ hooks["PreToolUse"].append(_SETTINGS_HOOK)
374
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
375
+ print(f" .claude/settings.json -> PreToolUse hook registered")
376
+
377
+
378
+ def _uninstall_claude_hook(project_dir: Path) -> None:
379
+ """Remove cgraph PreToolUse hook from .claude/settings.json."""
380
+ settings_path = project_dir / ".claude" / "settings.json"
381
+ if not settings_path.exists():
382
+ return
383
+ try:
384
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
385
+ except json.JSONDecodeError:
386
+ return
387
+ pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
388
+ filtered = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash") and "graphify" in str(h))]
389
+ if len(filtered) == len(pre_tool):
390
+ return
391
+ settings["hooks"]["PreToolUse"] = filtered
392
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
393
+ print(f" .claude/settings.json -> PreToolUse hook removed")
394
+
395
+
396
+ def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None:
397
+ """Remove cgraph from supported assistant configs in the current project."""
398
+ pd = project_dir or Path(".")
399
+ print("Uninstalling cgraph from Claude Code and Codex configs...\n")
400
+
401
+ # Skill-file / config-section uninstallers
402
+ claude_uninstall(pd)
403
+ # AGENTS.md covers codex
404
+ _agents_uninstall(pd)
405
+ _uninstall_codex_hook(pd)
406
+
407
+ # Git hook
408
+ try:
409
+ from graphify.hooks import uninstall as hook_uninstall
410
+ result = hook_uninstall(pd)
411
+ if result:
412
+ print(result)
413
+ except Exception:
414
+ pass
415
+
416
+ if purge:
417
+ import shutil as _shutil
418
+ out = pd / "graphify-out"
419
+ if out.exists():
420
+ _shutil.rmtree(out)
421
+ print(f"\n graphify-out/ -> deleted (--purge)")
422
+ else:
423
+ print("\n graphify-out/ -> not found (nothing to purge)")
424
+
425
+ print("\nDone. Run 'pip uninstall ckgraphify' to remove the package itself.")
426
+
427
+
428
+ def claude_uninstall(project_dir: Path | None = None) -> None:
429
+ """Remove the cgraph section from the local CLAUDE.md."""
430
+ target = (project_dir or Path(".")) / "CLAUDE.md"
431
+
432
+ if not target.exists():
433
+ print("No CLAUDE.md found in current directory - nothing to do")
434
+ return
435
+
436
+ content = target.read_text(encoding="utf-8")
437
+ if _CLAUDE_MD_MARKER not in content:
438
+ print("cgraph section not found in CLAUDE.md - nothing to do")
439
+ return
440
+
441
+ # Remove the ## cgraph section: from the marker to the next ## heading or EOF
442
+ cleaned = re.sub(
443
+ r"\n*## cgraph\n.*?(?=\n## |\Z)",
444
+ "",
445
+ content,
446
+ flags=re.DOTALL,
447
+ ).rstrip()
448
+ if cleaned:
449
+ target.write_text(cleaned + "\n", encoding="utf-8")
450
+ print(f"cgraph section removed from {target.resolve()}")
451
+ else:
452
+ target.unlink()
453
+ print(f"CLAUDE.md was empty after removal - deleted {target.resolve()}")
454
+
455
+ _uninstall_claude_hook(project_dir or Path("."))
456
+
457
+
458
+ def _clone_repo(url: str, branch: str | None = None, out_dir: Path | None = None) -> Path:
459
+ """Clone a GitHub repo to a local cache dir and return the path.
460
+
461
+ Clones into ~/.graphify/repos/<owner>/<repo> by default so repeated
462
+ runs on the same URL reuse the existing clone (git pull instead of clone).
463
+ """
464
+ import subprocess as _sp
465
+ import re as _re
466
+
467
+ # Normalise URL — strip trailing .git if present
468
+ url = url.rstrip("/")
469
+ if not url.endswith(".git"):
470
+ git_url = url + ".git"
471
+ else:
472
+ git_url = url
473
+ url = url[:-4]
474
+
475
+ # Extract owner/repo from URL
476
+ m = _re.search(r"github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$", url)
477
+ if not m:
478
+ print(f"error: not a recognised GitHub URL: {url}", file=sys.stderr)
479
+ sys.exit(1)
480
+ owner, repo = m.group(1), m.group(2)
481
+
482
+ if out_dir:
483
+ dest = out_dir
484
+ else:
485
+ dest = Path.home() / ".graphify" / "repos" / owner / repo
486
+
487
+ if branch and branch.startswith("-"):
488
+ print(f"error: invalid branch name: {branch!r}", file=sys.stderr)
489
+ sys.exit(1)
490
+
491
+ if dest.exists():
492
+ print(f"Repo already cloned at {dest} — pulling latest...", flush=True)
493
+ cmd = ["git", "-C", str(dest), "pull"]
494
+ if branch:
495
+ cmd += ["origin", "--", branch]
496
+ result = _sp.run(cmd, capture_output=True, text=True)
497
+ if result.returncode != 0:
498
+ print(f"warning: git pull failed:\n{result.stderr}", file=sys.stderr)
499
+ else:
500
+ dest.parent.mkdir(parents=True, exist_ok=True)
501
+ print(f"Cloning {url} → {dest} ...", flush=True)
502
+ cmd = ["git", "clone", "--depth", "1"]
503
+ if branch:
504
+ cmd += ["--branch", branch]
505
+ cmd += ["--", git_url, str(dest)]
506
+ result = _sp.run(cmd, capture_output=True, text=True)
507
+ if result.returncode != 0:
508
+ print(f"error: git clone failed:\n{result.stderr}", file=sys.stderr)
509
+ sys.exit(1)
510
+
511
+ print(f"Ready at: {dest}", flush=True)
512
+ return dest
513
+
514
+
515
+ def main() -> None:
516
+ # Check all known skill install locations for a stale version stamp.
517
+ # Skip during install/uninstall (hook writes trigger a fresh check anyway).
518
+ # Skip during hook-check — it runs on every editor tool use and must be silent.
519
+ # Deduplicate paths so platforms sharing the same install dir don't warn twice.
520
+ _silent_cmds = {"install", "uninstall", "hook-check"}
521
+ if not any(arg in _silent_cmds for arg in sys.argv):
522
+ for skill_dst in {Path.home() / cfg["skill_dst"] for cfg in _PLATFORM_CONFIG.values()}:
523
+ _check_skill_version(skill_dst)
524
+
525
+ if len(sys.argv) >= 2 and sys.argv[1] in ("-v", "--version", "version"):
526
+ print(f"graphify {__version__}")
527
+ return
528
+
529
+ if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "-?"):
530
+ print("Usage: graphify <command>")
531
+ print()
532
+ print("Commands:")
533
+ print(" install [--platform P] copy skill to supported assistant config dir (claude|codex)")
534
+ print(" uninstall remove cgraph from Claude Code and Codex configs")
535
+ print(" --purge also delete graphify-out/ directory")
536
+ print(" path \"A\" \"B\" shortest path between two nodes in graph.json")
537
+ print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
538
+ print(" explain \"X\" plain-language explanation of a node and its neighbors")
539
+ print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
540
+ print(" clone <github-url> clone a GitHub repo locally and print its path for /cgraph")
541
+ print(" merge-driver <base> <current> <other> git merge driver: union-merge two graph.json files (set up via hook install)")
542
+ print(" merge-graphs <g1> <g2> merge two or more graph.json files into one cross-repo graph")
543
+ print(" --out <path> output path (default: graphify-out/merged-graph.json)")
544
+ print(" merge-main-graphs <graph-main.json...> merge multiple graph-main boundary graphs")
545
+ print(" --out <path> output path (default: graphify-out/graph-main-merged.json)")
546
+ print(" --report <path> report path (default: graphify-out/graph-main-merged-report.md)")
547
+ print(" main-trace --graph <path> --from <node> [--api <api>] [--max-depth N] [--sources] [--prefer-repo R] [--exclude-repo R]")
548
+ print(" trace a graph-main chain from an exact entry/API")
549
+ print(" business-init --concept <name> [--out graphify-out/business-map.json]")
550
+ print(" create a business-map seed (currently: 健康卡)")
551
+ print(" business-show [--map <path>] [--concept <name>] [--scenario <name>]")
552
+ print(" inspect concepts, scenarios, anchors, and trace hints")
553
+ print(" business-query \"question\" [--map <path>] [--graph <path>] [--trace] [--sources] [--format json|text]")
554
+ print(" match a business question to concept/scenario/flow and optionally trace it")
555
+ print(" business-lint [--map <path>]")
556
+ print(" check business-map modeling policy and report conflicts")
557
+ print(" business-validate [--map <path>] [--graph <path>]")
558
+ print(" validate business-map anchors against a graph-main graph")
559
+ print(" business-trace [--map <path>] [--graph <path>] --concept <name> [--scenario <name>] [--flow <name>] [--max-depth N] [--sources]")
560
+ print(" run main-trace from business-map trace hints")
561
+ print(" bridge-mtop add cross-repo frontend->mtop->backend bridge edges onto a graph")
562
+ print(" --repos-root <path> repos root directory (required)")
563
+ print(" --graph-in <path> input graph path (default: graphify-out/graph.json)")
564
+ print(" --graph-out <path> output graph path (default: graphify-out/graph-bridge-mtop.json)")
565
+ print(" --report <path> write bridge report JSON (default: graphify-out/bridge-mtop-report.json)")
566
+ print(" --max-file-size N skip files larger than N bytes (default: 1000000)")
567
+ print(" --repos A,B,C optional repo-tag allowlist (defaults to graph repo tags)")
568
+ print(" --thin-out <path> optional thin graph output path (mtop-focused subgraph)")
569
+ print(" --thin-hops N neighborhood hops for thin graph (default: 1)")
570
+ print(" main-graph [path] build graph-main.json (auto-detect frontend/backend)")
571
+ print(" backend Java repos auto-switch to backend-normal/backend-sdk detection")
572
+ print(" --branch <branch> checkout a specific branch (default: repo default)")
573
+ print(" --out <dir> clone to a custom directory (default: ~/.graphify/repos/<owner>/<repo>)")
574
+ print(" add <url> fetch a URL and save it to ./raw, then update the graph")
575
+ print(" --author \"Name\" tag the author of the content")
576
+ print(" --contributor \"Name\" tag who added it to the corpus")
577
+ print(" --dir <path> target directory (default: ./raw)")
578
+ print(" watch <path> watch a folder and rebuild the graph on code changes")
579
+ print(" update <path> re-extract code files and update the graph (no LLM needed)")
580
+ print(" --force overwrite graph.json even if the rebuild has fewer nodes")
581
+ print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)")
582
+ print(" --no-cluster skip clustering, write raw extraction only")
583
+ print(" cluster-only <path> rerun clustering on an existing graph.json and regenerate report")
584
+ print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)")
585
+ print(" --graph <path> path to graph.json (default <path>/graphify-out/graph.json)")
586
+ print(" query \"<question>\" BFS traversal of graph.json for a question")
587
+ print(" --dfs use depth-first instead of breadth-first")
588
+ print(" --context C explicit edge-context filter (repeatable)")
589
+ print(" --budget N cap output at N tokens (default 2000)")
590
+ print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
591
+ print(" save-result save a Q&A result to graphify-out/memory/ for graph feedback loop")
592
+ print(" --question Q the question asked")
593
+ print(" --answer A the answer to save")
594
+ print(" --type T query type: query|path_query|explain (default: query)")
595
+ print(" --nodes N1 N2 ... source node labels cited in the answer")
596
+ print(" --memory-dir DIR memory directory (default: graphify-out/memory)")
597
+ print(" check-update <path> check needs_update flag and notify if semantic re-extraction is pending (cron-safe)")
598
+ print(" tree emit a D3 v7 collapsible-tree HTML for graph.json")
599
+ print(" --graph PATH path to graph.json (default graphify-out/graph.json)")
600
+ print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)")
601
+ print(" --root PATH filesystem root for the hierarchy")
602
+ print(" --max-children N cap children per node (default 200)")
603
+ print(" --top-k-edges N per-symbol outbound edges in inspector (default 12)")
604
+ print(" --label NAME project label in header")
605
+ print(" extract <path> headless full extraction (AST + semantic LLM) for CI/scripts")
606
+ print(" --backend B gemini|kimi|claude|openai|ollama (default: whichever API key is set)")
607
+ print(" --model M override backend default model")
608
+ print(" --max-workers N AST extraction subprocess count (default: cpu_count)")
609
+ print(" --token-budget N per-chunk token cap for semantic extraction (default: 60000)")
610
+ print(" --max-concurrency N parallel semantic chunks in flight (default: 4; set 1 for local LLMs)")
611
+ print(" --api-timeout S per-request timeout in seconds for the LLM client (default: 600)")
612
+ print(" --out DIR output dir (default: <path>); writes <DIR>/graphify-out/")
613
+ print(" --google-workspace export .gdoc/.gsheet/.gslides shortcuts via gws before extraction")
614
+ print(" --no-cluster skip clustering, write raw extraction only")
615
+ print(" --global also merge the resulting graph into the global graph")
616
+ print(" --as <tag> repo tag for --global (default: target directory name)")
617
+ print(" global add <graph.json> add/update a project graph in the global graph (~/.graphify/global-graph.json)")
618
+ print(" --as <tag> repo tag (default: parent directory name)")
619
+ print(" global remove <tag> remove a repo's nodes from the global graph")
620
+ print(" global list list repos in the global graph")
621
+ print(" global path print path to the global graph file")
622
+ print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach")
623
+ print(" export callflow-html emit Mermaid-based architecture/call-flow HTML")
624
+ print(" hook install install post-commit/post-checkout git hooks")
625
+ print(" hook uninstall remove git hooks")
626
+ print(" hook status check if git hooks are installed")
627
+ print(" claude install write cgraph section to CLAUDE.md + PreToolUse hook (Claude Code)")
628
+ print(" claude uninstall remove cgraph section from CLAUDE.md + PreToolUse hook")
629
+ print(" codex install write cgraph section to AGENTS.md (Codex)")
630
+ print(" codex uninstall remove cgraph section from AGENTS.md")
631
+ print()
632
+ return
633
+
634
+ cmd = sys.argv[1]
635
+
636
+ # Universal help guard: -h/--help/-? anywhere after the command shows help
637
+ # and stops to prevent flags from silently triggering mutating subcommands.
638
+ # Exempt: free-text commands (user string may contain these tokens), and
639
+ # "install"/"uninstall" which have their own per-subcommand help handlers.
640
+ _FREE_TEXT_CMDS = {"query", "explain", "path", "save-result", "install", "uninstall"}
641
+ if cmd not in _FREE_TEXT_CMDS and any(a in {"-h", "--help", "-?"} for a in sys.argv[2:]):
642
+ print(f"Run 'graphify --help' for full usage.")
643
+ return
644
+
645
+ if cmd == "install":
646
+ default_platform = "claude"
647
+ selected_platform: str | None = None
648
+ args = sys.argv[2:]
649
+ i = 0
650
+ while i < len(args):
651
+ arg = args[i]
652
+ if arg in ("-h", "--help"):
653
+ _print_install_usage()
654
+ return
655
+ if arg.startswith("--platform="):
656
+ candidate = arg.split("=", 1)[1]
657
+ if selected_platform and selected_platform != candidate:
658
+ print("error: specify install platform only once", file=sys.stderr)
659
+ sys.exit(1)
660
+ selected_platform = candidate
661
+ i += 1
662
+ elif arg == "--platform":
663
+ if i + 1 >= len(args):
664
+ print("error: --platform requires a value", file=sys.stderr)
665
+ sys.exit(1)
666
+ candidate = args[i + 1]
667
+ if selected_platform and selected_platform != candidate:
668
+ print("error: specify install platform only once", file=sys.stderr)
669
+ sys.exit(1)
670
+ selected_platform = candidate
671
+ i += 2
672
+ elif arg.startswith("-"):
673
+ print(f"error: unknown install option '{arg}'", file=sys.stderr)
674
+ sys.exit(1)
675
+ else:
676
+ if selected_platform and selected_platform != arg:
677
+ print("error: specify install platform only once", file=sys.stderr)
678
+ sys.exit(1)
679
+ selected_platform = arg
680
+ i += 1
681
+ chosen_platform = selected_platform or default_platform
682
+ install(platform=chosen_platform)
683
+ elif cmd == "uninstall":
684
+ purge = "--purge" in sys.argv[2:]
685
+ uninstall_all(purge=purge)
686
+ elif cmd == "claude":
687
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
688
+ if subcmd == "install":
689
+ claude_install()
690
+ elif subcmd == "uninstall":
691
+ claude_uninstall()
692
+ else:
693
+ print("Usage: graphify claude [install|uninstall]", file=sys.stderr)
694
+ sys.exit(1)
695
+ elif cmd == "codex":
696
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
697
+ if subcmd == "install":
698
+ _agents_install(Path("."))
699
+ elif subcmd == "uninstall":
700
+ _agents_uninstall(Path("."))
701
+ _uninstall_codex_hook(Path("."))
702
+ else:
703
+ print("Usage: graphify codex [install|uninstall]", file=sys.stderr)
704
+ sys.exit(1)
705
+ elif cmd == "hook":
706
+ from graphify.hooks import install as hook_install, uninstall as hook_uninstall, status as hook_status
707
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
708
+ if subcmd == "install":
709
+ print(hook_install(Path(".")))
710
+ elif subcmd == "uninstall":
711
+ print(hook_uninstall(Path(".")))
712
+ elif subcmd == "status":
713
+ print(hook_status(Path(".")))
714
+ else:
715
+ print("Usage: graphify hook [install|uninstall|status]", file=sys.stderr)
716
+ sys.exit(1)
717
+ elif cmd == "query":
718
+ if len(sys.argv) < 3:
719
+ print("Usage: graphify query \"<question>\" [--dfs] [--context C] [--budget N] [--graph path]", file=sys.stderr)
720
+ sys.exit(1)
721
+ from graphify.serve import _query_graph_text
722
+ from graphify.security import sanitize_label
723
+ from networkx.readwrite import json_graph
724
+ question = sys.argv[2]
725
+ use_dfs = "--dfs" in sys.argv
726
+ budget = 2000
727
+ graph_path = _default_graph_path()
728
+ context_filters: list[str] = []
729
+ args = sys.argv[3:]
730
+ i = 0
731
+ while i < len(args):
732
+ if args[i] == "--budget" and i + 1 < len(args):
733
+ try:
734
+ budget = int(args[i + 1])
735
+ except ValueError:
736
+ print(f"error: --budget must be an integer", file=sys.stderr)
737
+ sys.exit(1)
738
+ i += 2
739
+ elif args[i].startswith("--budget="):
740
+ try:
741
+ budget = int(args[i].split("=", 1)[1])
742
+ except ValueError:
743
+ print(f"error: --budget must be an integer", file=sys.stderr)
744
+ sys.exit(1)
745
+ i += 1
746
+ elif args[i] == "--context" and i + 1 < len(args):
747
+ context_filters.append(args[i + 1])
748
+ i += 2
749
+ elif args[i].startswith("--context="):
750
+ context_filters.append(args[i].split("=", 1)[1])
751
+ i += 1
752
+ elif args[i] == "--graph" and i + 1 < len(args):
753
+ graph_path = args[i + 1]; i += 2
754
+ else:
755
+ i += 1
756
+ gp = Path(graph_path).resolve()
757
+ if not gp.exists():
758
+ print(f"error: graph file not found: {gp}", file=sys.stderr)
759
+ sys.exit(1)
760
+ if not gp.suffix == ".json":
761
+ print(f"error: graph file must be a .json file", file=sys.stderr)
762
+ sys.exit(1)
763
+ try:
764
+ import json as _json
765
+ import networkx as _nx
766
+ _raw = _json.loads(gp.read_text(encoding="utf-8"))
767
+ if "links" not in _raw and "edges" in _raw:
768
+ _raw = dict(_raw, links=_raw["edges"])
769
+ try:
770
+ G = json_graph.node_link_graph(_raw, edges="links")
771
+ except TypeError:
772
+ G = json_graph.node_link_graph(_raw)
773
+ except Exception as exc:
774
+ print(f"error: could not load graph: {exc}", file=sys.stderr)
775
+ sys.exit(1)
776
+ print(
777
+ _query_graph_text(
778
+ G,
779
+ question,
780
+ mode="dfs" if use_dfs else "bfs",
781
+ depth=2,
782
+ token_budget=budget,
783
+ context_filters=context_filters,
784
+ )
785
+ )
786
+ elif cmd == "save-result":
787
+ # graphify save-result --question Q --answer A --type T [--nodes N1 N2 ...]
788
+ import argparse as _ap
789
+ p = _ap.ArgumentParser(prog="graphify save-result")
790
+ p.add_argument("--question", required=True)
791
+ p.add_argument("--answer", required=True)
792
+ p.add_argument("--type", dest="query_type", default="query")
793
+ p.add_argument("--nodes", nargs="*", default=[])
794
+ p.add_argument("--memory-dir", default="graphify-out/memory")
795
+ opts = p.parse_args(sys.argv[2:])
796
+ from graphify.ingest import save_query_result as _sqr
797
+ out = _sqr(
798
+ question=opts.question,
799
+ answer=opts.answer,
800
+ memory_dir=Path(opts.memory_dir),
801
+ query_type=opts.query_type,
802
+ source_nodes=opts.nodes or None,
803
+ )
804
+ print(f"Saved to {out}")
805
+ elif cmd == "path":
806
+ if len(sys.argv) < 4:
807
+ print("Usage: graphify path \"<source>\" \"<target>\" [--graph path]", file=sys.stderr)
808
+ sys.exit(1)
809
+ from graphify.serve import _score_nodes
810
+ from networkx.readwrite import json_graph
811
+ import networkx as _nx
812
+ source_label = sys.argv[2]
813
+ target_label = sys.argv[3]
814
+ graph_path = _default_graph_path()
815
+ args = sys.argv[4:]
816
+ for i, a in enumerate(args):
817
+ if a == "--graph" and i + 1 < len(args):
818
+ graph_path = args[i + 1]
819
+ gp = Path(graph_path).resolve()
820
+ if not gp.exists():
821
+ print(f"error: graph file not found: {gp}", file=sys.stderr)
822
+ sys.exit(1)
823
+ _raw = json.loads(gp.read_text(encoding="utf-8"))
824
+ if "links" not in _raw and "edges" in _raw:
825
+ _raw = dict(_raw, links=_raw["edges"])
826
+ # Force directed so the renderer can recover stored caller→callee direction.
827
+ _raw = {**_raw, "directed": True}
828
+ try:
829
+ G = json_graph.node_link_graph(_raw, edges="links")
830
+ except TypeError:
831
+ G = json_graph.node_link_graph(_raw)
832
+ src_scored = _score_nodes(G, [t.lower() for t in source_label.split()])
833
+ tgt_scored = _score_nodes(G, [t.lower() for t in target_label.split()])
834
+ if not src_scored:
835
+ print(f"No node matching '{source_label}' found.", file=sys.stderr)
836
+ sys.exit(1)
837
+ if not tgt_scored:
838
+ print(f"No node matching '{target_label}' found.", file=sys.stderr)
839
+ sys.exit(1)
840
+ src_nid, tgt_nid = src_scored[0][1], tgt_scored[0][1]
841
+ # Ambiguity guard: when both queries resolve to the same node, the
842
+ # shortest path is trivially zero hops, which is almost never what the
843
+ # caller wanted (see bug #828).
844
+ if src_nid == tgt_nid:
845
+ print(
846
+ f"'{source_label}' and '{target_label}' both resolved to the same "
847
+ f"node '{src_nid}'. Use a more specific label or the exact node ID.",
848
+ file=sys.stderr,
849
+ )
850
+ sys.exit(1)
851
+ for _name, _scored in (("source", src_scored), ("target", tgt_scored)):
852
+ if len(_scored) >= 2:
853
+ _top, _runner = _scored[0][0], _scored[1][0]
854
+ if _top > 0 and (_top - _runner) / _top < 0.10:
855
+ print(
856
+ f"warning: {_name} match was ambiguous "
857
+ f"(top score {_top:g}, runner-up {_runner:g})",
858
+ file=sys.stderr,
859
+ )
860
+ try:
861
+ path_nodes = _nx.shortest_path(G.to_undirected(as_view=True), src_nid, tgt_nid)
862
+ except (_nx.NetworkXNoPath, _nx.NodeNotFound):
863
+ print(f"No path found between '{source_label}' and '{target_label}'.")
864
+ sys.exit(0)
865
+ hops = len(path_nodes) - 1
866
+ segments = []
867
+ from graphify.build import edge_data
868
+ for i in range(len(path_nodes) - 1):
869
+ u, v = path_nodes[i], path_nodes[i + 1]
870
+ # Check which direction the stored edge points.
871
+ if G.has_edge(u, v):
872
+ edata = edge_data(G, u, v)
873
+ forward = True
874
+ else:
875
+ edata = edge_data(G, v, u)
876
+ forward = False
877
+ rel = edata.get("relation", "")
878
+ conf = edata.get("confidence", "")
879
+ conf_str = f" [{conf}]" if conf else ""
880
+ if i == 0:
881
+ segments.append(G.nodes[u].get("label", u))
882
+ if forward:
883
+ segments.append(f"--{rel}{conf_str}--> {G.nodes[v].get('label', v)}")
884
+ else:
885
+ segments.append(f"<--{rel}{conf_str}-- {G.nodes[v].get('label', v)}")
886
+ print(f"Shortest path ({hops} hops):\n " + " ".join(segments))
887
+
888
+ elif cmd == "explain":
889
+ if len(sys.argv) < 3:
890
+ print("Usage: graphify explain \"<node>\" [--graph path]", file=sys.stderr)
891
+ sys.exit(1)
892
+ from graphify.serve import _find_node
893
+ from networkx.readwrite import json_graph
894
+ label = sys.argv[2]
895
+ graph_path = _default_graph_path()
896
+ args = sys.argv[3:]
897
+ for i, a in enumerate(args):
898
+ if a == "--graph" and i + 1 < len(args):
899
+ graph_path = args[i + 1]
900
+ gp = Path(graph_path).resolve()
901
+ if not gp.exists():
902
+ print(f"error: graph file not found: {gp}", file=sys.stderr)
903
+ sys.exit(1)
904
+ _raw = json.loads(gp.read_text(encoding="utf-8"))
905
+ if "links" not in _raw and "edges" in _raw:
906
+ _raw = dict(_raw, links=_raw["edges"])
907
+ # Force directed so the renderer can recover stored caller→callee direction.
908
+ _raw = {**_raw, "directed": True}
909
+ try:
910
+ G = json_graph.node_link_graph(_raw, edges="links")
911
+ except TypeError:
912
+ G = json_graph.node_link_graph(_raw)
913
+ matches = _find_node(G, label)
914
+ if not matches:
915
+ print(f"No node matching '{label}' found.")
916
+ sys.exit(0)
917
+ nid = matches[0]
918
+ d = G.nodes[nid]
919
+ print(f"Node: {d.get('label', nid)}")
920
+ print(f" ID: {nid}")
921
+ print(f" Source: {d.get('source_file', '')} {d.get('source_location', '')}".rstrip())
922
+ print(f" Type: {d.get('file_type', '')}")
923
+ print(f" Community: {d.get('community', '')}")
924
+ print(f" Degree: {G.degree(nid)}")
925
+ from graphify.build import edge_data
926
+ connections: list[tuple[str, str, dict]] = [] # (direction, neighbor_id, edge_data)
927
+ for nb in G.successors(nid):
928
+ connections.append(("out", nb, edge_data(G, nid, nb)))
929
+ for nb in G.predecessors(nid):
930
+ connections.append(("in", nb, edge_data(G, nb, nid)))
931
+ if connections:
932
+ print(f"\nConnections ({len(connections)}):")
933
+ connections.sort(key=lambda c: G.degree(c[1]), reverse=True)
934
+ for direction, nb, edata in connections[:20]:
935
+ rel = edata.get("relation", "")
936
+ conf = edata.get("confidence", "")
937
+ arrow = "-->" if direction == "out" else "<--"
938
+ print(f" {arrow} {G.nodes[nb].get('label', nb)} [{rel}] [{conf}]")
939
+ if len(connections) > 20:
940
+ print(f" ... and {len(connections) - 20} more")
941
+
942
+ elif cmd == "add":
943
+ if len(sys.argv) < 3:
944
+ print("Usage: graphify add <url> [--author Name] [--contributor Name] [--dir ./raw]", file=sys.stderr)
945
+ sys.exit(1)
946
+ from graphify.ingest import ingest as _ingest
947
+ url = sys.argv[2]
948
+ author: str | None = None
949
+ contributor: str | None = None
950
+ target_dir = Path("raw")
951
+ args = sys.argv[3:]
952
+ i = 0
953
+ while i < len(args):
954
+ if args[i] == "--author" and i + 1 < len(args):
955
+ author = args[i + 1]; i += 2
956
+ elif args[i] == "--contributor" and i + 1 < len(args):
957
+ contributor = args[i + 1]; i += 2
958
+ elif args[i] == "--dir" and i + 1 < len(args):
959
+ target_dir = Path(args[i + 1]); i += 2
960
+ else:
961
+ i += 1
962
+ try:
963
+ saved = _ingest(url, target_dir, author=author, contributor=contributor)
964
+ print(f"Saved to {saved}")
965
+ print("Run /cgraph --update in your AI assistant to update the graph.")
966
+ except Exception as exc:
967
+ print(f"error: {exc}", file=sys.stderr)
968
+ sys.exit(1)
969
+
970
+ elif cmd == "watch":
971
+ watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".")
972
+ if not watch_path.exists():
973
+ print(f"error: path not found: {watch_path}", file=sys.stderr)
974
+ sys.exit(1)
975
+ from graphify.watch import watch as _watch
976
+ try:
977
+ _watch(watch_path)
978
+ except ImportError as exc:
979
+ print(f"error: {exc}", file=sys.stderr)
980
+ sys.exit(1)
981
+
982
+ elif cmd == "cluster-only":
983
+ # Mirror the tree/export arg-parsing pattern: walk argv so flags and
984
+ # the optional positional path can appear in any order (#724).
985
+ no_viz = "--no-viz" in sys.argv
986
+ _min_cs_arg = next((a for a in sys.argv if a.startswith("--min-community-size=")), None)
987
+ min_community_size = int(_min_cs_arg.split("=")[1]) if _min_cs_arg else 3
988
+ args = sys.argv[2:]
989
+ watch_path: Path | None = None
990
+ graph_override: Path | None = None
991
+ i_arg = 0
992
+ while i_arg < len(args):
993
+ a = args[i_arg]
994
+ if a == "--graph" and i_arg + 1 < len(args):
995
+ graph_override = Path(args[i_arg + 1]); i_arg += 2
996
+ elif a == "--no-viz" or a.startswith("--min-community-size="):
997
+ i_arg += 1
998
+ elif a.startswith("--"):
999
+ i_arg += 1
1000
+ elif watch_path is None:
1001
+ watch_path = Path(a); i_arg += 1
1002
+ else:
1003
+ i_arg += 1
1004
+ if watch_path is None:
1005
+ watch_path = Path(".")
1006
+ graph_json = graph_override if graph_override is not None else watch_path / "graphify-out" / "graph.json"
1007
+ if not graph_json.exists():
1008
+ print(f"error: no graph found at {graph_json} — run /cgraph first", file=sys.stderr)
1009
+ sys.exit(1)
1010
+ from networkx.readwrite import json_graph as _jg
1011
+ from graphify.build import build_from_json
1012
+ from graphify.cluster import cluster, score_all
1013
+ from graphify.analyze import god_nodes, surprising_connections, suggest_questions
1014
+ from graphify.report import generate
1015
+ from graphify.export import to_json, to_html
1016
+ print("Loading existing graph...")
1017
+ _raw = json.loads(graph_json.read_text(encoding="utf-8"))
1018
+ _directed = bool(_raw.get("directed", False))
1019
+ G = build_from_json(_raw, directed=_directed)
1020
+ print(f"Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
1021
+ print("Re-clustering...")
1022
+ communities = cluster(G)
1023
+ cohesion = score_all(G, communities)
1024
+ gods = god_nodes(G)
1025
+ surprises = surprising_connections(G, communities)
1026
+ out = watch_path / "graphify-out"
1027
+ labels_path = out / ".graphify_labels.json"
1028
+ if labels_path.exists():
1029
+ try:
1030
+ labels = {int(k): v for k, v in json.loads(labels_path.read_text(encoding="utf-8")).items()}
1031
+ except Exception:
1032
+ labels = {cid: f"Community {cid}" for cid in communities}
1033
+ else:
1034
+ labels = {cid: f"Community {cid}" for cid in communities}
1035
+ questions = suggest_questions(G, communities, labels)
1036
+ tokens = {"input": 0, "output": 0}
1037
+ from graphify.export import _git_head as _gh
1038
+ _commit = _gh()
1039
+ report = generate(G, communities, cohesion, labels, gods, surprises,
1040
+ {"warning": "cluster-only mode — file stats not available"},
1041
+ tokens, str(watch_path), suggested_questions=questions,
1042
+ min_community_size=min_community_size, built_at_commit=_commit)
1043
+ (out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8")
1044
+ to_json(G, communities, str(out / "graph.json"))
1045
+ labels_path.write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding="utf-8")
1046
+
1047
+ # Mirror watch.py pattern: gate to_html so core outputs (graph.json +
1048
+ # GRAPH_REPORT.md) always land. Honor --no-viz explicitly; otherwise
1049
+ # fall back to ValueError handling so an oversized graph doesn't crash
1050
+ # the CLI mid-write and leave a stale graph.html on disk.
1051
+ html_target = out / "graph.html"
1052
+ if no_viz:
1053
+ if html_target.exists():
1054
+ html_target.unlink()
1055
+ print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated (--no-viz; graph.html removed).")
1056
+ else:
1057
+ try:
1058
+ to_html(G, communities, str(html_target), community_labels=labels or None)
1059
+ print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.")
1060
+ except ValueError as viz_err:
1061
+ if html_target.exists():
1062
+ html_target.unlink()
1063
+ print(f"Skipped graph.html: {viz_err}")
1064
+ print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated.")
1065
+
1066
+ elif cmd == "update":
1067
+ force = os.environ.get("GRAPHIFY_FORCE", "").lower() in ("1", "true", "yes")
1068
+ no_cluster = False
1069
+ args = sys.argv[2:]
1070
+ watch_arg: str | None = None
1071
+ for a in args:
1072
+ if a == "--force":
1073
+ force = True
1074
+ continue
1075
+ if a == "--no-cluster":
1076
+ no_cluster = True
1077
+ continue
1078
+ if a.startswith("-"):
1079
+ print(f"error: unknown update option: {a}", file=sys.stderr)
1080
+ sys.exit(2)
1081
+ if watch_arg is not None:
1082
+ print("error: update accepts at most one path argument", file=sys.stderr)
1083
+ sys.exit(2)
1084
+ watch_arg = a
1085
+
1086
+ if watch_arg is not None:
1087
+ watch_path = Path(watch_arg)
1088
+ else:
1089
+ # Try to recover the scan root saved by the last full build
1090
+ saved = Path(_GRAPHIFY_OUT) / ".graphify_root"
1091
+ if saved.exists():
1092
+ watch_path = Path(saved.read_text(encoding="utf-8").strip())
1093
+ else:
1094
+ watch_path = Path(".")
1095
+ if not watch_path.exists():
1096
+ print(f"error: path not found: {watch_path}", file=sys.stderr)
1097
+ sys.exit(1)
1098
+ from graphify.watch import _rebuild_code
1099
+ print(f"Re-extracting code files in {watch_path} (no LLM needed)...")
1100
+ # Interactive CLI: block on the per-repo lock rather than skip, so the
1101
+ # user sees their explicit `graphify update` complete instead of
1102
+ # exiting silently when a hook-driven rebuild happens to be running.
1103
+ ok = _rebuild_code(watch_path, force=force, no_cluster=no_cluster, block_on_lock=True)
1104
+ if ok:
1105
+ print("Code graph updated. For doc/paper/image changes run /cgraph --update in your AI assistant.")
1106
+ if not (
1107
+ os.environ.get("GEMINI_API_KEY")
1108
+ or os.environ.get("GOOGLE_API_KEY")
1109
+ or os.environ.get("MOONSHOT_API_KEY")
1110
+ or os.environ.get("GRAPHIFY_NO_TIPS")
1111
+ ):
1112
+ print("Tip: set GEMINI_API_KEY or GOOGLE_API_KEY to use Gemini for semantic extraction.")
1113
+ else:
1114
+ print("Nothing to update or rebuild failed — check output above.", file=sys.stderr)
1115
+ sys.exit(1)
1116
+
1117
+ elif cmd == "hook-check":
1118
+ # Codex Desktop rejects hookSpecificOutput.additionalContext on PreToolUse.
1119
+ # Keep this as a cross-platform no-op so installed hooks never break Bash
1120
+ # tool calls. Graph guidance reaches the agent via AGENTS.md / skill instead.
1121
+ sys.exit(0)
1122
+ elif cmd == "check-update":
1123
+ if len(sys.argv) < 3:
1124
+ print("Usage: graphify check-update <path>", file=sys.stderr)
1125
+ sys.exit(1)
1126
+ from graphify.watch import check_update
1127
+ check_update(Path(sys.argv[2]).resolve())
1128
+ sys.exit(0)
1129
+ elif cmd == "tree":
1130
+ # Emit a D3 v7 collapsible-tree HTML view of graph.json:
1131
+ # expand-all / collapse-all / reset-view buttons, multi-line
1132
+ # wrapText labels with separately-coloured name + count,
1133
+ # depth-based palette, click-to-toggle subtree, hover inspector
1134
+ # showing top-K outbound edges per symbol.
1135
+ from typing import Optional as _Opt
1136
+ from graphify.tree_html import write_tree_html, DEFAULT_MAX_CHILDREN
1137
+ graph_path = Path(_GRAPHIFY_OUT) / "graph.json"
1138
+ output_path: "_Opt[Path]" = None
1139
+ root: "_Opt[str]" = None
1140
+ max_children = DEFAULT_MAX_CHILDREN
1141
+ top_k_edges = 0
1142
+ project_label: "_Opt[str]" = None
1143
+ args = sys.argv[2:]
1144
+ i_arg = 0
1145
+ while i_arg < len(args):
1146
+ a = args[i_arg]
1147
+ if a == "--graph" and i_arg + 1 < len(args):
1148
+ graph_path = Path(args[i_arg + 1]); i_arg += 2
1149
+ elif a == "--output" and i_arg + 1 < len(args):
1150
+ output_path = Path(args[i_arg + 1]); i_arg += 2
1151
+ elif a == "--root" and i_arg + 1 < len(args):
1152
+ root = args[i_arg + 1]; i_arg += 2
1153
+ elif a == "--max-children" and i_arg + 1 < len(args):
1154
+ max_children = int(args[i_arg + 1]); i_arg += 2
1155
+ elif a == "--top-k-edges" and i_arg + 1 < len(args):
1156
+ top_k_edges = int(args[i_arg + 1]); i_arg += 2
1157
+ elif a == "--label" and i_arg + 1 < len(args):
1158
+ project_label = args[i_arg + 1]; i_arg += 2
1159
+ elif a in ("-h", "--help"):
1160
+ print("Usage: graphify tree [--graph PATH] [--output HTML]")
1161
+ print(" --graph PATH path to graph.json (default graphify-out/graph.json)")
1162
+ print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)")
1163
+ print(" --root PATH filesystem root (default: longest common dir of all source_files)")
1164
+ print(" --max-children N cap visible children per node (default 200)")
1165
+ print(" --top-k-edges N pre-compute top-K outbound edges per symbol (default 12)")
1166
+ print(" --label NAME project label shown in the page header")
1167
+ return
1168
+ else:
1169
+ i_arg += 1
1170
+ if not graph_path.is_file():
1171
+ print(f"error: graph.json not found at {graph_path}", file=sys.stderr)
1172
+ sys.exit(1)
1173
+ if output_path is None:
1174
+ output_path = graph_path.parent / "GRAPH_TREE.html"
1175
+ out = write_tree_html(
1176
+ graph_path=graph_path, output_path=output_path,
1177
+ root=root, max_children=max_children,
1178
+ top_k_edges=top_k_edges, project_label=project_label,
1179
+ )
1180
+ size_kb = out.stat().st_size / 1024
1181
+ print(f"wrote {out} ({size_kb:.1f} KB)")
1182
+ print(f"open with: xdg-open {out} (or file://{out.resolve()})")
1183
+ sys.exit(0)
1184
+
1185
+ elif cmd == "merge-driver":
1186
+ # git merge driver for graph.json — takes (base, current, other) and writes
1187
+ # the union of current+other nodes/edges back to current. Exits 1 on
1188
+ # corrupt input so git surfaces the conflict instead of silently
1189
+ # accepting a poisoned merge (see F-005).
1190
+ # Usage: graphify merge-driver %O %A %B (set in .git/config merge driver)
1191
+ if len(sys.argv) < 5:
1192
+ print("Usage: graphify merge-driver <base> <current> <other>", file=sys.stderr)
1193
+ sys.exit(1)
1194
+ _base_path, _current_path, _other_path = sys.argv[2], sys.argv[3], sys.argv[4]
1195
+ # Hard caps so a malicious or corrupted graph.json cannot exhaust memory
1196
+ # at parse time. 50 MB / 100k nodes are well above any realistic graph
1197
+ # (typical graphs are <5 MB / <50k nodes); anything larger should fail
1198
+ # the merge so a human can investigate.
1199
+ _MERGE_MAX_BYTES = 50 * 1024 * 1024
1200
+ _MERGE_MAX_NODES = 100_000
1201
+ import networkx as _nx
1202
+ from networkx.readwrite import json_graph as _jg
1203
+ def _load_graph(p: str):
1204
+ path_obj = Path(p)
1205
+ try:
1206
+ size = path_obj.stat().st_size
1207
+ except OSError as exc:
1208
+ raise RuntimeError(f"cannot stat {p}: {exc}") from exc
1209
+ if size > _MERGE_MAX_BYTES:
1210
+ raise RuntimeError(
1211
+ f"graph.json {p} is {size} bytes, exceeds {_MERGE_MAX_BYTES}-byte cap"
1212
+ )
1213
+ data = json.loads(path_obj.read_text(encoding="utf-8"))
1214
+ try:
1215
+ return _jg.node_link_graph(data, edges="links"), data
1216
+ except TypeError:
1217
+ return _jg.node_link_graph(data), data
1218
+ try:
1219
+ G_cur, _ = _load_graph(_current_path)
1220
+ G_oth, _ = _load_graph(_other_path)
1221
+ except Exception as exc:
1222
+ print(f"[graphify merge-driver] error loading graphs: {exc}", file=sys.stderr)
1223
+ sys.exit(1) # surface the conflict so git doesn't accept a corrupt merge
1224
+ merged = _nx.compose(G_cur, G_oth)
1225
+ if merged.number_of_nodes() > _MERGE_MAX_NODES:
1226
+ print(
1227
+ f"[graphify merge-driver] merged graph has {merged.number_of_nodes()} nodes, "
1228
+ f"exceeds {_MERGE_MAX_NODES}-node cap; aborting merge.",
1229
+ file=sys.stderr,
1230
+ )
1231
+ sys.exit(1)
1232
+ try:
1233
+ out_data = _jg.node_link_data(merged, edges="links")
1234
+ except TypeError:
1235
+ out_data = _jg.node_link_data(merged)
1236
+ Path(_current_path).write_text(json.dumps(out_data, indent=2), encoding="utf-8")
1237
+ sys.exit(0)
1238
+
1239
+ elif cmd == "merge-graphs":
1240
+ # graphify merge-graphs graph1.json graph2.json ... --out merged.json
1241
+ args = sys.argv[2:]
1242
+ graph_paths: list[Path] = []
1243
+ out_path = Path(_GRAPHIFY_OUT) / "merged-graph.json"
1244
+ i = 0
1245
+ while i < len(args):
1246
+ if args[i] == "--out" and i + 1 < len(args):
1247
+ out_path = Path(args[i + 1]); i += 2
1248
+ else:
1249
+ graph_paths.append(Path(args[i])); i += 1
1250
+ if len(graph_paths) < 2:
1251
+ print("Usage: graphify merge-graphs <graph1.json> <graph2.json> [...] [--out merged.json]", file=sys.stderr)
1252
+ sys.exit(1)
1253
+ import networkx as _nx
1254
+ from networkx.readwrite import json_graph as _jg
1255
+ from graphify.build import prefix_graph_for_global as _prefix
1256
+ graphs = []
1257
+ for gp in graph_paths:
1258
+ if not gp.exists():
1259
+ print(f"error: not found: {gp}", file=sys.stderr)
1260
+ sys.exit(1)
1261
+ data = json.loads(gp.read_text(encoding="utf-8"))
1262
+ # Normalize edges/links key before loading — graphify writes "links"
1263
+ # via node_link_data but older runs may have used "edges" (#738).
1264
+ if "links" not in data and "edges" in data:
1265
+ data = dict(data, links=data["edges"])
1266
+ try:
1267
+ G = _jg.node_link_graph(data, edges="links")
1268
+ except TypeError:
1269
+ G = _jg.node_link_graph(data)
1270
+ graphs.append(G)
1271
+ merged = _nx.Graph()
1272
+ for G, gp in zip(graphs, graph_paths):
1273
+ repo_tag = gp.parent.parent.name # graphify-out/../ → repo dir name
1274
+ prefixed = _prefix(G, repo_tag)
1275
+ merged = _nx.compose(merged, prefixed)
1276
+ try:
1277
+ out_data = _jg.node_link_data(merged, edges="links")
1278
+ except TypeError:
1279
+ out_data = _jg.node_link_data(merged)
1280
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1281
+ out_path.write_text(json.dumps(out_data, indent=2), encoding="utf-8")
1282
+ print(f"Merged {len(graphs)} graphs → {merged.number_of_nodes()} nodes, {merged.number_of_edges()} edges")
1283
+ print(f"Written to: {out_path}")
1284
+
1285
+ elif cmd == "merge-main-graphs":
1286
+ args = sys.argv[2:]
1287
+ graph_paths: list[Path] = []
1288
+ out_path = Path(_GRAPHIFY_OUT) / "graph-main-merged.json"
1289
+ report_path: Path | None = None
1290
+ i = 0
1291
+ while i < len(args):
1292
+ a = args[i]
1293
+ if a == "--out" and i + 1 < len(args):
1294
+ out_path = Path(args[i + 1]); i += 2
1295
+ elif a.startswith("--out="):
1296
+ out_path = Path(a.split("=", 1)[1]); i += 1
1297
+ elif a == "--report" and i + 1 < len(args):
1298
+ report_path = Path(args[i + 1]); i += 2
1299
+ elif a.startswith("--report="):
1300
+ report_path = Path(a.split("=", 1)[1]); i += 1
1301
+ elif a.startswith("-"):
1302
+ print(f"error: unknown merge-main-graphs option: {a}", file=sys.stderr)
1303
+ sys.exit(1)
1304
+ else:
1305
+ graph_paths.append(Path(a)); i += 1
1306
+ if len(graph_paths) < 2:
1307
+ print(
1308
+ "Usage: graphify merge-main-graphs <graph-main1.json> <graph-main2.json> [...] "
1309
+ "[--out graph-main-merged.json] [--report report.md]",
1310
+ file=sys.stderr,
1311
+ )
1312
+ sys.exit(1)
1313
+ if report_path is None:
1314
+ report_path = out_path.with_name(out_path.stem + "-report.md")
1315
+ try:
1316
+ from graphify.graph_main_merge import (
1317
+ merge_graph_main_files as _merge_graph_main_files,
1318
+ write_graph_main_merge_report as _write_graph_main_merge_report,
1319
+ )
1320
+ from graphify.graph_main_html import build_graph_main_html as _build_graph_main_html
1321
+
1322
+ stats = _merge_graph_main_files(graph_paths, out_path)
1323
+ _write_graph_main_merge_report(stats.report, report_path)
1324
+ html_path = out_path.with_suffix(".html")
1325
+ _build_graph_main_html(out_path, html_path, title=f"Graph Main Merged - {out_path.stem}")
1326
+ except Exception as exc:
1327
+ print(f"error: merge-main-graphs failed: {exc}", file=sys.stderr)
1328
+ sys.exit(1)
1329
+ print(
1330
+ "Merged graph-main: "
1331
+ f"repos={stats.repo_count}, nodes={stats.nodes}, edges={stats.edges}, "
1332
+ f"cross_edges={stats.cross_edges}, mtop={stats.mtop_links}, rest={stats.rest_links}, "
1333
+ f"hsf_method={stats.hsf_method_links}, hsf_api={stats.hsf_api_links}, metaq={stats.metaq_links}, "
1334
+ f"unresolved_dependencies={stats.unresolved_dependencies}"
1335
+ )
1336
+ print(f"Graph: {out_path}")
1337
+ print(f"HTML: {html_path}")
1338
+ print(f"Report: {report_path}")
1339
+
1340
+ elif cmd == "main-trace":
1341
+ args = sys.argv[2:]
1342
+ graph_path: Path | None = None
1343
+ from_query = ""
1344
+ api_query = ""
1345
+ max_depth = 8
1346
+ include_sources = False
1347
+ prefer_repos: list[str] = []
1348
+ exclude_repos: list[str] = []
1349
+ i = 0
1350
+ while i < len(args):
1351
+ a = args[i]
1352
+ if a == "--graph" and i + 1 < len(args):
1353
+ graph_path = Path(args[i + 1]); i += 2
1354
+ elif a.startswith("--graph="):
1355
+ graph_path = Path(a.split("=", 1)[1]); i += 1
1356
+ elif a == "--from" and i + 1 < len(args):
1357
+ from_query = args[i + 1]; i += 2
1358
+ elif a.startswith("--from="):
1359
+ from_query = a.split("=", 1)[1]; i += 1
1360
+ elif a == "--api" and i + 1 < len(args):
1361
+ api_query = args[i + 1]; i += 2
1362
+ elif a.startswith("--api="):
1363
+ api_query = a.split("=", 1)[1]; i += 1
1364
+ elif a == "--max-depth" and i + 1 < len(args):
1365
+ try:
1366
+ max_depth = int(args[i + 1])
1367
+ except ValueError:
1368
+ print("error: --max-depth must be an integer", file=sys.stderr)
1369
+ sys.exit(1)
1370
+ i += 2
1371
+ elif a.startswith("--max-depth="):
1372
+ try:
1373
+ max_depth = int(a.split("=", 1)[1])
1374
+ except ValueError:
1375
+ print("error: --max-depth must be an integer", file=sys.stderr)
1376
+ sys.exit(1)
1377
+ i += 1
1378
+ elif a == "--sources":
1379
+ include_sources = True; i += 1
1380
+ elif a == "--prefer-repo" and i + 1 < len(args):
1381
+ prefer_repos.append(args[i + 1]); i += 2
1382
+ elif a.startswith("--prefer-repo="):
1383
+ prefer_repos.append(a.split("=", 1)[1]); i += 1
1384
+ elif a == "--exclude-repo" and i + 1 < len(args):
1385
+ exclude_repos.append(args[i + 1]); i += 2
1386
+ elif a.startswith("--exclude-repo="):
1387
+ exclude_repos.append(a.split("=", 1)[1]); i += 1
1388
+ else:
1389
+ print(f"error: unknown main-trace option: {a}", file=sys.stderr)
1390
+ sys.exit(1)
1391
+ if graph_path is None or not from_query:
1392
+ print(
1393
+ "Usage: graphify main-trace --graph graph-main-merged.json --from <node> [--api <api>] [--max-depth N] [--sources] [--prefer-repo R] [--exclude-repo R]",
1394
+ file=sys.stderr,
1395
+ )
1396
+ sys.exit(1)
1397
+ if not graph_path.exists():
1398
+ print(f"error: graph file not found: {graph_path}", file=sys.stderr)
1399
+ sys.exit(1)
1400
+ try:
1401
+ from graphify.graph_main_trace import trace_graph_main as _trace_graph_main
1402
+
1403
+ print(
1404
+ _trace_graph_main(
1405
+ graph_path,
1406
+ from_query=from_query,
1407
+ api_query=api_query,
1408
+ max_depth=max_depth,
1409
+ include_sources=include_sources,
1410
+ prefer_repos=prefer_repos,
1411
+ exclude_repos=exclude_repos,
1412
+ )
1413
+ )
1414
+ except Exception as exc:
1415
+ print(f"error: main-trace failed: {exc}", file=sys.stderr)
1416
+ sys.exit(1)
1417
+
1418
+ elif cmd == "business-init":
1419
+ args = sys.argv[2:]
1420
+ concept = ""
1421
+ from graphify.business_map import default_business_map_path
1422
+
1423
+ out_path = default_business_map_path()
1424
+ force = False
1425
+ i = 0
1426
+ while i < len(args):
1427
+ a = args[i]
1428
+ if a == "--concept" and i + 1 < len(args):
1429
+ concept = args[i + 1]; i += 2
1430
+ elif a.startswith("--concept="):
1431
+ concept = a.split("=", 1)[1]; i += 1
1432
+ elif a == "--out" and i + 1 < len(args):
1433
+ out_path = Path(args[i + 1]); i += 2
1434
+ elif a.startswith("--out="):
1435
+ out_path = Path(a.split("=", 1)[1]); i += 1
1436
+ elif a == "--force":
1437
+ force = True; i += 1
1438
+ else:
1439
+ print(f"error: unknown business-init option: {a}", file=sys.stderr)
1440
+ sys.exit(1)
1441
+ if not concept:
1442
+ print("Usage: graphify business-init --concept <name> [--out graphify-out/business-map.json] [--force]", file=sys.stderr)
1443
+ sys.exit(1)
1444
+ try:
1445
+ from graphify.business_map import init_business_map
1446
+
1447
+ data = init_business_map(concept, out_path, force=force)
1448
+ print(f"Business map written: {out_path}")
1449
+ print(f"Concepts: {len(data.get('concepts', []))}")
1450
+ except Exception as exc:
1451
+ print(f"error: business-init failed: {exc}", file=sys.stderr)
1452
+ sys.exit(1)
1453
+
1454
+ elif cmd == "business-show":
1455
+ args = sys.argv[2:]
1456
+ from graphify.business_map import default_business_map_path
1457
+
1458
+ map_path = default_business_map_path()
1459
+ concept = ""
1460
+ scenario = ""
1461
+ i = 0
1462
+ while i < len(args):
1463
+ a = args[i]
1464
+ if a == "--map" and i + 1 < len(args):
1465
+ map_path = Path(args[i + 1]); i += 2
1466
+ elif a.startswith("--map="):
1467
+ map_path = Path(a.split("=", 1)[1]); i += 1
1468
+ elif a == "--concept" and i + 1 < len(args):
1469
+ concept = args[i + 1]; i += 2
1470
+ elif a.startswith("--concept="):
1471
+ concept = a.split("=", 1)[1]; i += 1
1472
+ elif a == "--scenario" and i + 1 < len(args):
1473
+ scenario = args[i + 1]; i += 2
1474
+ elif a.startswith("--scenario="):
1475
+ scenario = a.split("=", 1)[1]; i += 1
1476
+ else:
1477
+ print(f"error: unknown business-show option: {a}", file=sys.stderr)
1478
+ sys.exit(1)
1479
+ if not map_path.exists():
1480
+ print(f"error: business map not found: {map_path}", file=sys.stderr)
1481
+ sys.exit(1)
1482
+ try:
1483
+ from graphify.business_map import format_business_show, load_business_map
1484
+
1485
+ print(format_business_show(load_business_map(map_path), concept_query=concept, scenario_query=scenario))
1486
+ except Exception as exc:
1487
+ print(f"error: business-show failed: {exc}", file=sys.stderr)
1488
+ sys.exit(1)
1489
+
1490
+ elif cmd == "business-query":
1491
+ args = sys.argv[2:]
1492
+ from graphify.business_map import default_business_graph_path, default_business_map_path
1493
+
1494
+ map_path = default_business_map_path()
1495
+ graph_path: Path | None = None
1496
+ question = ""
1497
+ max_depth: int | None = None
1498
+ include_sources = False
1499
+ run_trace = False
1500
+ output_format = "text"
1501
+ top_k = 5
1502
+ i = 0
1503
+ while i < len(args):
1504
+ a = args[i]
1505
+ if a == "--map" and i + 1 < len(args):
1506
+ map_path = Path(args[i + 1]); i += 2
1507
+ elif a.startswith("--map="):
1508
+ map_path = Path(a.split("=", 1)[1]); i += 1
1509
+ elif a == "--graph" and i + 1 < len(args):
1510
+ graph_path = Path(args[i + 1]); i += 2
1511
+ elif a.startswith("--graph="):
1512
+ graph_path = Path(a.split("=", 1)[1]); i += 1
1513
+ elif a == "--max-depth" and i + 1 < len(args):
1514
+ try:
1515
+ max_depth = int(args[i + 1])
1516
+ except ValueError:
1517
+ print("error: --max-depth must be an integer", file=sys.stderr)
1518
+ sys.exit(1)
1519
+ i += 2
1520
+ elif a.startswith("--max-depth="):
1521
+ try:
1522
+ max_depth = int(a.split("=", 1)[1])
1523
+ except ValueError:
1524
+ print("error: --max-depth must be an integer", file=sys.stderr)
1525
+ sys.exit(1)
1526
+ i += 1
1527
+ elif a == "--top-k" and i + 1 < len(args):
1528
+ try:
1529
+ top_k = int(args[i + 1])
1530
+ except ValueError:
1531
+ print("error: --top-k must be an integer", file=sys.stderr)
1532
+ sys.exit(1)
1533
+ i += 2
1534
+ elif a.startswith("--top-k="):
1535
+ try:
1536
+ top_k = int(a.split("=", 1)[1])
1537
+ except ValueError:
1538
+ print("error: --top-k must be an integer", file=sys.stderr)
1539
+ sys.exit(1)
1540
+ i += 1
1541
+ elif a == "--format" and i + 1 < len(args):
1542
+ output_format = args[i + 1]; i += 2
1543
+ elif a.startswith("--format="):
1544
+ output_format = a.split("=", 1)[1]; i += 1
1545
+ elif a == "--sources":
1546
+ include_sources = True; i += 1
1547
+ elif a == "--trace":
1548
+ run_trace = True; i += 1
1549
+ elif a.startswith("-"):
1550
+ print(f"error: unknown business-query option: {a}", file=sys.stderr)
1551
+ sys.exit(1)
1552
+ else:
1553
+ question = a if not question else f"{question} {a}"
1554
+ i += 1
1555
+ if not question:
1556
+ print(
1557
+ "Usage: graphify business-query \"question\" [--map <business-map.json>] [--graph <graph-main.json>] [--trace] [--sources] [--format json|text]",
1558
+ file=sys.stderr,
1559
+ )
1560
+ sys.exit(1)
1561
+ if output_format not in {"json", "text"}:
1562
+ print("error: --format must be json or text", file=sys.stderr)
1563
+ sys.exit(1)
1564
+ if not map_path.exists():
1565
+ print(f"error: business map not found: {map_path}", file=sys.stderr)
1566
+ sys.exit(1)
1567
+ if graph_path is None:
1568
+ graph_path = default_business_graph_path(map_path)
1569
+ if graph_path is not None and not graph_path.exists():
1570
+ print(f"error: graph file not found: {graph_path}", file=sys.stderr)
1571
+ sys.exit(1)
1572
+ try:
1573
+ from graphify.business_map import query_business
1574
+
1575
+ result = query_business(
1576
+ map_path=map_path,
1577
+ graph_path=graph_path,
1578
+ query=question,
1579
+ top_k=top_k,
1580
+ max_depth=max_depth,
1581
+ include_sources=include_sources,
1582
+ run_trace=run_trace,
1583
+ )
1584
+ if output_format == "json":
1585
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1586
+ else:
1587
+ match = result.get("match", {})
1588
+ best = match.get("best")
1589
+ print(f"Business query: {result.get('query')}")
1590
+ print(f"Map: {result.get('map')}")
1591
+ print(f"Graph: {result.get('graph') or '-'}")
1592
+ print(f"Match: {match.get('status')} scope={match.get('scope') or '-'}")
1593
+ if best:
1594
+ print(
1595
+ "Best: "
1596
+ f"{best.get('concept')}"
1597
+ + (f" / {best.get('scenario')}" if best.get("scenario") else "")
1598
+ + (f" / {best.get('flow')}" if best.get("flow") else "")
1599
+ + f" score={best.get('score')}"
1600
+ )
1601
+ if best.get("summary"):
1602
+ print(f"Summary: {best.get('summary')}")
1603
+ if match.get("top_matches"):
1604
+ print("Top matches:")
1605
+ for cand in match.get("top_matches", []):
1606
+ print(
1607
+ "- "
1608
+ f"{cand.get('type')} {cand.get('concept')}"
1609
+ + (f" / {cand.get('scenario')}" if cand.get("scenario") else "")
1610
+ + (f" / {cand.get('flow')}" if cand.get("flow") else "")
1611
+ + f" score={cand.get('score')}"
1612
+ )
1613
+ if result.get("graph_matches"):
1614
+ print("Graph matches:")
1615
+ for cand in result.get("graph_matches", [])[:10]:
1616
+ print(f"- {cand.get('kind')} {cand.get('label')} repo={cand.get('repo') or '-'} score={cand.get('score')}")
1617
+ print(f"Next action: {result.get('next_action')}")
1618
+ if result.get("trace"):
1619
+ print("")
1620
+ print(result.get("trace"))
1621
+ except Exception as exc:
1622
+ print(f"error: business-query failed: {exc}", file=sys.stderr)
1623
+ sys.exit(1)
1624
+
1625
+ elif cmd == "business-lint":
1626
+ args = sys.argv[2:]
1627
+ from graphify.business_map import default_business_map_path
1628
+
1629
+ map_path = default_business_map_path()
1630
+ i = 0
1631
+ while i < len(args):
1632
+ a = args[i]
1633
+ if a == "--map" and i + 1 < len(args):
1634
+ map_path = Path(args[i + 1]); i += 2
1635
+ elif a.startswith("--map="):
1636
+ map_path = Path(a.split("=", 1)[1]); i += 1
1637
+ else:
1638
+ print(f"error: unknown business-lint option: {a}", file=sys.stderr)
1639
+ sys.exit(1)
1640
+ if not map_path.exists():
1641
+ print(f"error: business map not found: {map_path}", file=sys.stderr)
1642
+ sys.exit(1)
1643
+ try:
1644
+ from graphify.business_map import format_lint_result, lint_business_map
1645
+
1646
+ result = lint_business_map(map_path)
1647
+ print(format_lint_result(result))
1648
+ if not result.ok:
1649
+ sys.exit(2)
1650
+ except Exception as exc:
1651
+ print(f"error: business-lint failed: {exc}", file=sys.stderr)
1652
+ sys.exit(1)
1653
+
1654
+ elif cmd == "business-validate":
1655
+ args = sys.argv[2:]
1656
+ from graphify.business_map import default_business_graph_path, default_business_map_path
1657
+
1658
+ map_path = default_business_map_path()
1659
+ graph_path: Path | None = None
1660
+ i = 0
1661
+ while i < len(args):
1662
+ a = args[i]
1663
+ if a == "--map" and i + 1 < len(args):
1664
+ map_path = Path(args[i + 1]); i += 2
1665
+ elif a.startswith("--map="):
1666
+ map_path = Path(a.split("=", 1)[1]); i += 1
1667
+ elif a == "--graph" and i + 1 < len(args):
1668
+ graph_path = Path(args[i + 1]); i += 2
1669
+ elif a.startswith("--graph="):
1670
+ graph_path = Path(a.split("=", 1)[1]); i += 1
1671
+ else:
1672
+ print(f"error: unknown business-validate option: {a}", file=sys.stderr)
1673
+ sys.exit(1)
1674
+ if not map_path.exists():
1675
+ print(f"error: business map not found: {map_path}", file=sys.stderr)
1676
+ sys.exit(1)
1677
+ if graph_path is None:
1678
+ graph_path = default_business_graph_path(map_path)
1679
+ if graph_path is None:
1680
+ print("Usage: graphify business-validate [--map <business-map.json>] [--graph <graph-main.json>]", file=sys.stderr)
1681
+ sys.exit(1)
1682
+ if not graph_path.exists():
1683
+ print(f"error: graph file not found: {graph_path}", file=sys.stderr)
1684
+ sys.exit(1)
1685
+ try:
1686
+ from graphify.business_map import format_validation_result, validate_business_map
1687
+
1688
+ result = validate_business_map(map_path, graph_path)
1689
+ print(format_validation_result(result))
1690
+ if not result.ok:
1691
+ sys.exit(2)
1692
+ except Exception as exc:
1693
+ print(f"error: business-validate failed: {exc}", file=sys.stderr)
1694
+ sys.exit(1)
1695
+
1696
+ elif cmd == "business-trace":
1697
+ args = sys.argv[2:]
1698
+ from graphify.business_map import default_business_graph_path, default_business_map_path
1699
+
1700
+ map_path = default_business_map_path()
1701
+ graph_path: Path | None = None
1702
+ concept = ""
1703
+ scenario = ""
1704
+ flow = ""
1705
+ max_depth: int | None = None
1706
+ include_sources = False
1707
+ i = 0
1708
+ while i < len(args):
1709
+ a = args[i]
1710
+ if a == "--map" and i + 1 < len(args):
1711
+ map_path = Path(args[i + 1]); i += 2
1712
+ elif a.startswith("--map="):
1713
+ map_path = Path(a.split("=", 1)[1]); i += 1
1714
+ elif a == "--graph" and i + 1 < len(args):
1715
+ graph_path = Path(args[i + 1]); i += 2
1716
+ elif a.startswith("--graph="):
1717
+ graph_path = Path(a.split("=", 1)[1]); i += 1
1718
+ elif a == "--concept" and i + 1 < len(args):
1719
+ concept = args[i + 1]; i += 2
1720
+ elif a.startswith("--concept="):
1721
+ concept = a.split("=", 1)[1]; i += 1
1722
+ elif a == "--scenario" and i + 1 < len(args):
1723
+ scenario = args[i + 1]; i += 2
1724
+ elif a.startswith("--scenario="):
1725
+ scenario = a.split("=", 1)[1]; i += 1
1726
+ elif a == "--flow" and i + 1 < len(args):
1727
+ flow = args[i + 1]; i += 2
1728
+ elif a.startswith("--flow="):
1729
+ flow = a.split("=", 1)[1]; i += 1
1730
+ elif a == "--max-depth" and i + 1 < len(args):
1731
+ try:
1732
+ max_depth = int(args[i + 1])
1733
+ except ValueError:
1734
+ print("error: --max-depth must be an integer", file=sys.stderr)
1735
+ sys.exit(1)
1736
+ i += 2
1737
+ elif a.startswith("--max-depth="):
1738
+ try:
1739
+ max_depth = int(a.split("=", 1)[1])
1740
+ except ValueError:
1741
+ print("error: --max-depth must be an integer", file=sys.stderr)
1742
+ sys.exit(1)
1743
+ i += 1
1744
+ elif a == "--sources":
1745
+ include_sources = True; i += 1
1746
+ else:
1747
+ print(f"error: unknown business-trace option: {a}", file=sys.stderr)
1748
+ sys.exit(1)
1749
+ if not concept:
1750
+ print(
1751
+ "Usage: graphify business-trace [--map <business-map.json>] [--graph <graph-main.json>] --concept <name> [--scenario <name>] [--flow <name>] [--max-depth N] [--sources]",
1752
+ file=sys.stderr,
1753
+ )
1754
+ sys.exit(1)
1755
+ if not map_path.exists():
1756
+ print(f"error: business map not found: {map_path}", file=sys.stderr)
1757
+ sys.exit(1)
1758
+ if graph_path is None:
1759
+ graph_path = default_business_graph_path(map_path)
1760
+ if graph_path is None:
1761
+ print(
1762
+ "Usage: graphify business-trace [--map <business-map.json>] [--graph <graph-main.json>] --concept <name> [--scenario <name>] [--flow <name>] [--max-depth N] [--sources]",
1763
+ file=sys.stderr,
1764
+ )
1765
+ sys.exit(1)
1766
+ if not graph_path.exists():
1767
+ print(f"error: graph file not found: {graph_path}", file=sys.stderr)
1768
+ sys.exit(1)
1769
+ try:
1770
+ from graphify.business_map import trace_business
1771
+
1772
+ print(
1773
+ trace_business(
1774
+ map_path=map_path,
1775
+ graph_path=graph_path,
1776
+ concept_query=concept,
1777
+ scenario_query=scenario,
1778
+ flow_query=flow,
1779
+ max_depth=max_depth,
1780
+ include_sources=include_sources,
1781
+ )
1782
+ )
1783
+ except Exception as exc:
1784
+ print(f"error: business-trace failed: {exc}", file=sys.stderr)
1785
+ sys.exit(1)
1786
+
1787
+ elif cmd == "bridge-mtop":
1788
+ args = sys.argv[2:]
1789
+ repos_root: Path | None = None
1790
+ graph_in = Path(_GRAPHIFY_OUT) / "graph.json"
1791
+ graph_out = Path(_GRAPHIFY_OUT) / "graph-bridge-mtop.json"
1792
+ report_path = Path(_GRAPHIFY_OUT) / "bridge-mtop-report.json"
1793
+ max_file_size = 1_000_000
1794
+ repo_tags: list[str] | None = None
1795
+ thin_out: Path | None = None
1796
+ thin_hops = 1
1797
+ i = 0
1798
+ while i < len(args):
1799
+ a = args[i]
1800
+ if a == "--repos-root" and i + 1 < len(args):
1801
+ repos_root = Path(args[i + 1]); i += 2
1802
+ elif a.startswith("--repos-root="):
1803
+ repos_root = Path(a.split("=", 1)[1]); i += 1
1804
+ elif a == "--graph-in" and i + 1 < len(args):
1805
+ graph_in = Path(args[i + 1]); i += 2
1806
+ elif a.startswith("--graph-in="):
1807
+ graph_in = Path(a.split("=", 1)[1]); i += 1
1808
+ elif a == "--graph-out" and i + 1 < len(args):
1809
+ graph_out = Path(args[i + 1]); i += 2
1810
+ elif a.startswith("--graph-out="):
1811
+ graph_out = Path(a.split("=", 1)[1]); i += 1
1812
+ elif a == "--report" and i + 1 < len(args):
1813
+ report_path = Path(args[i + 1]); i += 2
1814
+ elif a.startswith("--report="):
1815
+ report_path = Path(a.split("=", 1)[1]); i += 1
1816
+ elif a == "--max-file-size" and i + 1 < len(args):
1817
+ try:
1818
+ max_file_size = int(args[i + 1])
1819
+ except ValueError:
1820
+ print("error: --max-file-size must be an integer", file=sys.stderr)
1821
+ sys.exit(1)
1822
+ i += 2
1823
+ elif a.startswith("--max-file-size="):
1824
+ try:
1825
+ max_file_size = int(a.split("=", 1)[1])
1826
+ except ValueError:
1827
+ print("error: --max-file-size must be an integer", file=sys.stderr)
1828
+ sys.exit(1)
1829
+ i += 1
1830
+ elif a == "--repos" and i + 1 < len(args):
1831
+ repo_tags = [t.strip() for t in args[i + 1].split(",") if t.strip()]
1832
+ i += 2
1833
+ elif a.startswith("--repos="):
1834
+ repo_tags = [t.strip() for t in a.split("=", 1)[1].split(",") if t.strip()]
1835
+ i += 1
1836
+ elif a == "--thin-out" and i + 1 < len(args):
1837
+ thin_out = Path(args[i + 1]); i += 2
1838
+ elif a.startswith("--thin-out="):
1839
+ thin_out = Path(a.split("=", 1)[1]); i += 1
1840
+ elif a == "--thin-hops" and i + 1 < len(args):
1841
+ try:
1842
+ thin_hops = int(args[i + 1])
1843
+ except ValueError:
1844
+ print("error: --thin-hops must be an integer", file=sys.stderr)
1845
+ sys.exit(1)
1846
+ i += 2
1847
+ elif a.startswith("--thin-hops="):
1848
+ try:
1849
+ thin_hops = int(a.split("=", 1)[1])
1850
+ except ValueError:
1851
+ print("error: --thin-hops must be an integer", file=sys.stderr)
1852
+ sys.exit(1)
1853
+ i += 1
1854
+ else:
1855
+ i += 1
1856
+ if repos_root is None:
1857
+ print(
1858
+ "Usage: graphify bridge-mtop --repos-root <path> [--graph-in in.json] "
1859
+ "[--graph-out out.json] [--report report.json] [--max-file-size N] [--repos A,B,C]",
1860
+ file=sys.stderr,
1861
+ )
1862
+ sys.exit(1)
1863
+ if max_file_size <= 0:
1864
+ print("error: --max-file-size must be > 0", file=sys.stderr)
1865
+ sys.exit(1)
1866
+ if thin_hops < 0:
1867
+ print("error: --thin-hops must be >= 0", file=sys.stderr)
1868
+ sys.exit(1)
1869
+ from graphify.bridge_mtop import bridge_mtop as _bridge_mtop
1870
+ try:
1871
+ stats = _bridge_mtop(
1872
+ graph_in=graph_in,
1873
+ graph_out=graph_out,
1874
+ repos_root=repos_root,
1875
+ report_path=report_path,
1876
+ max_file_size=max_file_size,
1877
+ repo_tags=repo_tags,
1878
+ thin_out_path=thin_out,
1879
+ thin_hops=thin_hops,
1880
+ )
1881
+ except Exception as exc:
1882
+ print(f"error: {exc}", file=sys.stderr)
1883
+ sys.exit(1)
1884
+ print(
1885
+ f"Bridge complete: repos={stats.repos_scanned}, files={stats.files_scanned}, "
1886
+ f"api_nodes+={stats.api_nodes_added}, calls_mtop+={stats.edge_calls_added}, "
1887
+ f"implemented_by+={stats.edge_impl_added}"
1888
+ )
1889
+ print(f"Graph: {graph_out}")
1890
+ print(f"Report: {report_path}")
1891
+ if thin_out is not None:
1892
+ print(f"Thin graph: {thin_out}")
1893
+
1894
+ elif cmd == "main-graph":
1895
+ args = sys.argv[2:]
1896
+ root = Path(".")
1897
+ out_path = Path(_GRAPHIFY_OUT) / "graph-main.json"
1898
+ i = 0
1899
+ while i < len(args):
1900
+ a = args[i]
1901
+ if a == "--out" and i + 1 < len(args):
1902
+ out_path = Path(args[i + 1]); i += 2
1903
+ elif a.startswith("--out="):
1904
+ out_path = Path(a.split("=", 1)[1]); i += 1
1905
+ elif a.startswith("-"):
1906
+ print(f"error: unknown main-graph option: {a}", file=sys.stderr)
1907
+ sys.exit(1)
1908
+ else:
1909
+ if root != Path("."):
1910
+ print("error: main-graph accepts at most one path argument", file=sys.stderr)
1911
+ sys.exit(1)
1912
+ root = Path(a)
1913
+ i += 1
1914
+ if not root.exists() or not root.is_dir():
1915
+ print(f"error: path not found or not a directory: {root}", file=sys.stderr)
1916
+ sys.exit(1)
1917
+
1918
+ root = root.resolve()
1919
+ java_files = list(root.rglob("*.java"))
1920
+ try:
1921
+ if java_files:
1922
+ from graphify.graph_main_backend import build_graph_main_backend as _build_graph_main_backend
1923
+ from graphify.graph_main_html import build_graph_main_html as _build_graph_main_html
1924
+ stats = _build_graph_main_backend(repo_root=root, out_path=out_path)
1925
+ html_out = out_path.with_suffix(".html")
1926
+ report_out = out_path.with_name("graph-main-report.md")
1927
+ _build_graph_main_html(out_path, html_out, title=f"Graph Main - {root.name}")
1928
+ rep = stats.report or {}
1929
+ m = rep.get("mtop_map_impl", {}) if isinstance(rep, dict) else {}
1930
+ unresolved = m.get("unresolved_apis", []) if isinstance(m, dict) else []
1931
+ lines = []
1932
+ lines.append("# Graph Main Report")
1933
+ lines.append("")
1934
+ lines.append(f"- repo_root: `{rep.get('repo_root', '')}`")
1935
+ lines.append(f"- generated_at: `{rep.get('generated_at', '')}`")
1936
+ lines.append(f"- mode: `{rep.get('mode', '')}`")
1937
+ lines.append(f"- files_scanned: `{rep.get('files_scanned', 0)}`")
1938
+ lines.append(f"- node_count: `{rep.get('node_count', 0)}`")
1939
+ lines.append(f"- edge_count: `{rep.get('edge_count', 0)}`")
1940
+ lines.append("")
1941
+ lines.append("## Node Kind Counts")
1942
+ nk = rep.get("node_kind_counts", {}) if isinstance(rep, dict) else {}
1943
+ if isinstance(nk, dict) and nk:
1944
+ for k in sorted(nk.keys()):
1945
+ lines.append(f"- {k}: {nk[k]}")
1946
+ else:
1947
+ lines.append("- (empty)")
1948
+ lines.append("")
1949
+ lines.append("## Relation Counts")
1950
+ rk = rep.get("relation_counts", {}) if isinstance(rep, dict) else {}
1951
+ if isinstance(rk, dict) and rk:
1952
+ for k in sorted(rk.keys()):
1953
+ lines.append(f"- {k}: {rk[k]}")
1954
+ else:
1955
+ lines.append("- (empty)")
1956
+ lines.append("")
1957
+ lines.append("## MTOP Map Coverage")
1958
+ lines.append(f"- map_source_file: `{m.get('map_source_file', '')}`")
1959
+ lines.append(f"- map_total: `{m.get('map_total', 0)}`")
1960
+ lines.append(f"- map_resolved: `{m.get('map_resolved', 0)}`")
1961
+ lines.append(f"- map_unresolved: `{m.get('map_unresolved', 0)}`")
1962
+ lines.append("")
1963
+ lines.append("## Unresolved MTOP APIs (No In-Repo Implementation)")
1964
+ if unresolved:
1965
+ lines.append("| api | hsf_interface | hsf_method | source_location | reason |")
1966
+ lines.append("| --- | --- | --- | --- | --- |")
1967
+ for x in unresolved:
1968
+ api = str(x.get("api", "")).replace("|", "\\|")
1969
+ iface = str(x.get("hsf_interface", "")).replace("|", "\\|")
1970
+ hm = str(x.get("hsf_method", "")).replace("|", "\\|")
1971
+ loc = str(x.get("source_location", "")).replace("|", "\\|")
1972
+ reason = str(x.get("reason", "")).replace("|", "\\|")
1973
+ lines.append(f"| {api} | {iface} | {hm} | {loc} | {reason} |")
1974
+ else:
1975
+ lines.append("- (none)")
1976
+ report_out.write_text("\n".join(lines) + "\n", encoding="utf-8")
1977
+ sdk = stats.sdk_detection
1978
+ mode = stats.mode
1979
+ print(
1980
+ "Main graph complete (backend): "
1981
+ f"files={stats.files_scanned}, nodes={stats.nodes}, edges={stats.edges}, "
1982
+ f"mode={mode}, is_sdk_repo={sdk.get('is_sdk_repo')}, "
1983
+ f"packaging={sdk.get('packaging')}, has_modules={sdk.get('has_modules')}, "
1984
+ f"entry_signal_score={sdk.get('entry_signal_score')}"
1985
+ )
1986
+ print(
1987
+ "Node counts: "
1988
+ f"hsf_method={stats.hsf_method_nodes}, hsf_api={stats.hsf_api_nodes}, "
1989
+ f"mtop_api={stats.mtop_api_nodes}, rest_api={stats.rest_api_nodes}, "
1990
+ f"dependency={stats.dependency_nodes}, metaq_producer={stats.metaq_producer_nodes}, "
1991
+ f"metaq_consumer={stats.metaq_consumer_nodes}"
1992
+ )
1993
+ print(f"HTML: {html_out}")
1994
+ print(f"Report: {report_out}")
1995
+ else:
1996
+ from graphify.graph_main_frontend import build_graph_main_frontend as _build_graph_main_frontend
1997
+ from graphify.graph_main_html import build_graph_main_html as _build_graph_main_html
1998
+ stats = _build_graph_main_frontend(repo_root=root, out_path=out_path)
1999
+ html_out = out_path.with_suffix(".html")
2000
+ report_out = out_path.with_name("graph-main-report.md")
2001
+ _build_graph_main_html(out_path, html_out, title=f"Graph Main - {root.name}")
2002
+ rep = stats.report or {}
2003
+ fd = rep.get("frontend_detection", {}) if isinstance(rep, dict) else {}
2004
+ unresolved = rep.get("unresolved_component_entries", []) if isinstance(rep, dict) else []
2005
+ lines = []
2006
+ lines.append("# Graph Main Report")
2007
+ lines.append("")
2008
+ lines.append(f"- repo_root: `{rep.get('repo_root', '')}`")
2009
+ lines.append(f"- generated_at: `{rep.get('generated_at', '')}`")
2010
+ lines.append(f"- mode: `{rep.get('mode', '')}`")
2011
+ lines.append(f"- files_scanned: `{rep.get('files_scanned', 0)}`")
2012
+ lines.append(f"- node_count: `{rep.get('node_count', 0)}`")
2013
+ lines.append(f"- edge_count: `{rep.get('edge_count', 0)}`")
2014
+ lines.append("")
2015
+ lines.append("## Frontend Detection")
2016
+ lines.append(f"- mode: `{fd.get('mode', '')}`")
2017
+ pmd = fd.get("page_mode_detection", {}) if isinstance(fd, dict) else {}
2018
+ lines.append(f"- page_mode: `{pmd.get('mode', fd.get('mode', ''))}`")
2019
+ lines.append(f"- page_count: `{pmd.get('page_count', 0)}`")
2020
+ lines.append(f"- slc_page_count: `{pmd.get('slc_page_count', 0)}`")
2021
+ lines.append(f"- normal_page_count: `{pmd.get('normal_page_count', 0)}`")
2022
+ slc_pages = pmd.get("slc_pages", []) if isinstance(pmd, dict) else []
2023
+ if slc_pages:
2024
+ lines.append(f"- slc_pages: `{', '.join(str(x) for x in slc_pages)}`")
2025
+ lines.append(f"- legacy_repo_mode: `{fd.get('legacy_repo_mode', '')}`")
2026
+ lines.append(f"- legacy_slc_signal_score: `{fd.get('slc_signal_score', 0)}`")
2027
+ lines.append(f"- has_manifest: `{fd.get('has_manifest', False)}`")
2028
+ lines.append(f"- manifest_count: `{fd.get('manifest_count', 0)}`")
2029
+ lines.append(f"- has_data_service: `{fd.get('has_data_service', False)}`")
2030
+ lines.append(f"- template_hits: `{fd.get('template_hits', 0)}`")
2031
+ lines.append(f"- component_registry_hits: `{fd.get('component_registry_hits', 0)}`")
2032
+ lines.append("")
2033
+ lines.append("## Node Kind Counts")
2034
+ nk = rep.get("node_kind_counts", {}) if isinstance(rep, dict) else {}
2035
+ if isinstance(nk, dict) and nk:
2036
+ for k in sorted(nk.keys()):
2037
+ lines.append(f"- {k}: {nk[k]}")
2038
+ else:
2039
+ lines.append("- (empty)")
2040
+ lines.append("")
2041
+ lines.append("## Relation Counts")
2042
+ rk = rep.get("relation_counts", {}) if isinstance(rep, dict) else {}
2043
+ if isinstance(rk, dict) and rk:
2044
+ for k in sorted(rk.keys()):
2045
+ lines.append(f"- {k}: {rk[k]}")
2046
+ else:
2047
+ lines.append("- (empty)")
2048
+ lines.append("")
2049
+ lines.append("## Unresolved Component Entries")
2050
+ if unresolved:
2051
+ lines.append("| page | component_id | reason |")
2052
+ lines.append("| --- | --- | --- |")
2053
+ for x in unresolved:
2054
+ page = str(x.get("page", "")).replace("|", "\\|")
2055
+ cid = str(x.get("component_id", "")).replace("|", "\\|")
2056
+ reason = str(x.get("reason", "")).replace("|", "\\|")
2057
+ lines.append(f"| {page} | {cid} | {reason} |")
2058
+ else:
2059
+ lines.append("- (none)")
2060
+ report_out.write_text("\n".join(lines) + "\n", encoding="utf-8")
2061
+ print(
2062
+ "Main graph complete (frontend): "
2063
+ f"files={stats.files_scanned}, nodes={stats.nodes}, edges={stats.edges}, mode={stats.mode}"
2064
+ )
2065
+ print(
2066
+ "Node counts: "
2067
+ f"page={stats.page_nodes}, component={stats.component_nodes}, "
2068
+ f"mtop_api={stats.mtop_api_nodes}, rest_api={stats.rest_api_nodes}"
2069
+ )
2070
+ print(f"HTML: {html_out}")
2071
+ print(f"Report: {report_out}")
2072
+ except Exception as exc:
2073
+ print(f"error: main-graph failed: {exc}", file=sys.stderr)
2074
+ sys.exit(1)
2075
+ print(f"Graph: {out_path}")
2076
+
2077
+ elif cmd == "clone":
2078
+ if len(sys.argv) < 3:
2079
+ print("Usage: graphify clone <github-url> [--branch <branch>] [--out <dir>]", file=sys.stderr)
2080
+ sys.exit(1)
2081
+ url = sys.argv[2]
2082
+ branch: str | None = None
2083
+ out_dir: Path | None = None
2084
+ args = sys.argv[3:]
2085
+ i = 0
2086
+ while i < len(args):
2087
+ if args[i] == "--branch" and i + 1 < len(args):
2088
+ branch = args[i + 1]; i += 2
2089
+ elif args[i] == "--out" and i + 1 < len(args):
2090
+ out_dir = Path(args[i + 1]); i += 2
2091
+ else:
2092
+ i += 1
2093
+ local_path = _clone_repo(url, branch=branch, out_dir=out_dir)
2094
+ print(local_path)
2095
+
2096
+ elif cmd == "export":
2097
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2098
+ if subcmd not in ("html", "callflow-html", "obsidian", "wiki", "svg", "graphml", "neo4j"):
2099
+ print("Usage: graphify export <format>", file=sys.stderr)
2100
+ print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz]", file=sys.stderr)
2101
+ print(" callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH] [--report PATH] [--sections PATH] [--output HTML]", file=sys.stderr)
2102
+ print(" [--lang auto|zh-CN|en] [--max-sections N] [--diagram-scale N]", file=sys.stderr)
2103
+ print(" obsidian [--graph PATH] [--labels PATH] [--dir PATH]", file=sys.stderr)
2104
+ print(" wiki [--graph PATH] [--labels PATH]", file=sys.stderr)
2105
+ print(" svg [--graph PATH] [--labels PATH]", file=sys.stderr)
2106
+ print(" graphml [--graph PATH]", file=sys.stderr)
2107
+ print(" neo4j [--graph PATH] [--push URI] [--user U] [--password P]", file=sys.stderr)
2108
+ print(" (or set NEO4J_PASSWORD instead of --password to keep it off argv)", file=sys.stderr)
2109
+ sys.exit(1)
2110
+
2111
+ # Parse shared args
2112
+ args = sys.argv[3:]
2113
+ graph_path = Path(_GRAPHIFY_OUT) / "graph.json"
2114
+ graph_path_explicit = False
2115
+ labels_path = Path(_GRAPHIFY_OUT) / ".graphify_labels.json"
2116
+ labels_path_explicit = False
2117
+ report_path = Path(_GRAPHIFY_OUT) / "GRAPH_REPORT.md"
2118
+ report_path_explicit = False
2119
+ sections_path: Path | None = None
2120
+ callflow_output: Path | None = None
2121
+ callflow_lang = "auto"
2122
+ callflow_max_sections = 15
2123
+ callflow_diagram_scale = 1.0
2124
+ callflow_max_diagram_nodes = 18
2125
+ callflow_max_diagram_edges = 24
2126
+ analysis_path = Path(_GRAPHIFY_OUT) / ".graphify_analysis.json"
2127
+ node_limit = 5000
2128
+ no_viz = False
2129
+ obsidian_dir = Path(_GRAPHIFY_OUT) / "obsidian"
2130
+ neo4j_uri: str | None = None
2131
+ neo4j_user = "neo4j"
2132
+ # F-031: prefer the NEO4J_PASSWORD env var so the password never
2133
+ # appears on argv (visible in `ps` output / shell history). The
2134
+ # explicit --password flag still overrides it for compatibility.
2135
+ neo4j_password: str | None = os.environ.get("NEO4J_PASSWORD") or None
2136
+ i = 0
2137
+ while i < len(args):
2138
+ a = args[i]
2139
+ if a == "--graph" and i + 1 < len(args):
2140
+ graph_path = Path(args[i + 1])
2141
+ graph_path_explicit = True
2142
+ i += 2
2143
+ elif a == "--labels" and i + 1 < len(args):
2144
+ labels_path = Path(args[i + 1])
2145
+ labels_path_explicit = True
2146
+ i += 2
2147
+ elif a == "--report" and i + 1 < len(args):
2148
+ report_path = Path(args[i + 1])
2149
+ report_path_explicit = True
2150
+ i += 2
2151
+ elif a == "--sections" and i + 1 < len(args):
2152
+ sections_path = Path(args[i + 1]); i += 2
2153
+ elif a == "--output" and i + 1 < len(args):
2154
+ callflow_output = Path(args[i + 1]).expanduser()
2155
+ if not callflow_output.is_absolute():
2156
+ callflow_output = Path.cwd() / callflow_output
2157
+ i += 2
2158
+ elif a == "--lang" and i + 1 < len(args):
2159
+ callflow_lang = args[i + 1]; i += 2
2160
+ elif a == "--max-sections" and i + 1 < len(args):
2161
+ callflow_max_sections = int(args[i + 1]); i += 2
2162
+ elif a == "--diagram-scale" and i + 1 < len(args):
2163
+ callflow_diagram_scale = float(args[i + 1]); i += 2
2164
+ elif a == "--max-diagram-nodes" and i + 1 < len(args):
2165
+ callflow_max_diagram_nodes = int(args[i + 1]); i += 2
2166
+ elif a == "--max-diagram-edges" and i + 1 < len(args):
2167
+ callflow_max_diagram_edges = int(args[i + 1]); i += 2
2168
+ elif a in ("-h", "--help") and subcmd == "callflow-html":
2169
+ print("Usage: graphify export callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH]")
2170
+ print(" --report PATH path to GRAPH_REPORT.md")
2171
+ print(" --sections PATH JSON section definitions")
2172
+ print(" --output HTML output path (default graphify-out/<project>-callflow.html)")
2173
+ print(" --lang LANG auto, zh-CN, en, etc. (default auto)")
2174
+ print(" --max-sections N maximum auto-derived sections (default 15)")
2175
+ print(" --diagram-scale N Mermaid diagram scale (default 1.0)")
2176
+ print(" --max-diagram-nodes N representative nodes per section (default 18)")
2177
+ print(" --max-diagram-edges N representative edges per section (default 24)")
2178
+ sys.exit(0)
2179
+ elif a == "--node-limit" and i + 1 < len(args):
2180
+ node_limit = int(args[i + 1]); i += 2
2181
+ elif a == "--no-viz":
2182
+ no_viz = True; i += 1
2183
+ elif a == "--dir" and i + 1 < len(args):
2184
+ obsidian_dir = Path(args[i + 1]); i += 2
2185
+ elif a == "--push" and i + 1 < len(args):
2186
+ neo4j_uri = args[i + 1]; i += 2
2187
+ elif a == "--user" and i + 1 < len(args):
2188
+ neo4j_user = args[i + 1]; i += 2
2189
+ elif a == "--password" and i + 1 < len(args):
2190
+ neo4j_password = args[i + 1]; i += 2
2191
+ elif subcmd == "callflow-html" and not a.startswith("-") and not graph_path_explicit:
2192
+ candidate = Path(a)
2193
+ if candidate.name == "graph.json" or candidate.suffix.lower() == ".json":
2194
+ graph_path = candidate
2195
+ elif (candidate / "graph.json").exists():
2196
+ graph_path = candidate / "graph.json"
2197
+ else:
2198
+ graph_path = candidate / _GRAPHIFY_OUT / "graph.json"
2199
+ graph_path_explicit = True
2200
+ i += 1
2201
+ else:
2202
+ i += 1
2203
+
2204
+ graph_path = graph_path.expanduser()
2205
+ if graph_path_explicit:
2206
+ graph_out_dir = graph_path.parent
2207
+ if not labels_path_explicit:
2208
+ labels_path = graph_out_dir / ".graphify_labels.json"
2209
+ if not report_path_explicit:
2210
+ report_path = graph_out_dir / "GRAPH_REPORT.md"
2211
+ labels_path = labels_path.expanduser()
2212
+ report_path = report_path.expanduser()
2213
+
2214
+ if not graph_path.exists():
2215
+ print(f"error: graph not found: {graph_path}. Run /cgraph <path> first.", file=sys.stderr)
2216
+ sys.exit(1)
2217
+
2218
+ if subcmd == "callflow-html":
2219
+ from graphify.callflow_html import write_callflow_html as _write_callflow_html
2220
+ out = _write_callflow_html(
2221
+ graph=graph_path,
2222
+ report=report_path,
2223
+ labels=labels_path,
2224
+ sections=sections_path,
2225
+ output=callflow_output,
2226
+ lang=callflow_lang,
2227
+ max_sections=callflow_max_sections,
2228
+ diagram_scale=callflow_diagram_scale,
2229
+ max_diagram_nodes=callflow_max_diagram_nodes,
2230
+ max_diagram_edges=callflow_max_diagram_edges,
2231
+ verbose=True,
2232
+ )
2233
+ print(f"callflow HTML written - open in any browser: {out}")
2234
+ sys.exit(0)
2235
+
2236
+ from networkx.readwrite import json_graph as _jg
2237
+ from graphify.build import build_from_json as _bfj
2238
+
2239
+ _raw = json.loads(graph_path.read_text(encoding="utf-8"))
2240
+ try:
2241
+ G = _jg.node_link_graph(_raw, edges="links")
2242
+ except TypeError:
2243
+ G = _jg.node_link_graph(_raw)
2244
+
2245
+ # Load optional analysis/labels
2246
+ communities: dict[int, list[str]] = {}
2247
+ if analysis_path.exists():
2248
+ _an = json.loads(analysis_path.read_text(encoding="utf-8"))
2249
+ communities = {int(k): v for k, v in _an.get("communities", {}).items()}
2250
+ cohesion: dict[int, float] = {int(k): v for k, v in _an.get("cohesion", {}).items()}
2251
+ gods_data = _an.get("gods", [])
2252
+ else:
2253
+ cohesion = {}
2254
+ gods_data = []
2255
+
2256
+ labels: dict[int, str] = {}
2257
+ if labels_path.exists():
2258
+ labels = {int(k): v for k, v in json.loads(labels_path.read_text(encoding="utf-8")).items()}
2259
+
2260
+ out_dir = graph_path.parent
2261
+
2262
+ if subcmd == "html":
2263
+ from graphify.export import to_html as _to_html
2264
+ if no_viz:
2265
+ html_target = out_dir / "graph.html"
2266
+ if html_target.exists():
2267
+ html_target.unlink()
2268
+ print("--no-viz: skipped graph.html")
2269
+ else:
2270
+ _to_html(G, communities, str(out_dir / "graph.html"),
2271
+ community_labels=labels or None, node_limit=node_limit)
2272
+ if G.number_of_nodes() <= node_limit:
2273
+ print(f"graph.html written - open in any browser, no server needed")
2274
+
2275
+ elif subcmd == "obsidian":
2276
+ from graphify.export import to_obsidian as _to_obsidian, to_canvas as _to_canvas
2277
+ n = _to_obsidian(G, communities, str(obsidian_dir),
2278
+ community_labels=labels or None, cohesion=cohesion or None)
2279
+ print(f"Obsidian vault: {n} notes in {obsidian_dir}/")
2280
+ _to_canvas(G, communities, str(obsidian_dir / "graph.canvas"),
2281
+ community_labels=labels or None)
2282
+ print(f"Canvas: {obsidian_dir}/graph.canvas")
2283
+ print(f"Open {obsidian_dir}/ as a vault in Obsidian.")
2284
+
2285
+ elif subcmd == "wiki":
2286
+ from graphify.wiki import to_wiki as _to_wiki
2287
+ from graphify.analyze import god_nodes as _god_nodes
2288
+ if not communities:
2289
+ print(
2290
+ "error: .graphify_analysis.json is missing or empty — refusing to export wiki to prevent data loss.\n"
2291
+ "Run `graphify extract .` (or `graphify cluster-only .`) to regenerate community data first.",
2292
+ file=sys.stderr,
2293
+ )
2294
+ sys.exit(1)
2295
+ if not gods_data:
2296
+ gods_data = _god_nodes(G)
2297
+ n = _to_wiki(G, communities, str(out_dir / "wiki"),
2298
+ community_labels=labels or None, cohesion=cohesion or None,
2299
+ god_nodes_data=gods_data)
2300
+ print(f"Wiki: {n} articles written to {out_dir}/wiki/")
2301
+ print(f" {out_dir}/wiki/index.md -> agent entry point")
2302
+
2303
+ elif subcmd == "svg":
2304
+ from graphify.export import to_svg as _to_svg
2305
+ _to_svg(G, communities, str(out_dir / "graph.svg"),
2306
+ community_labels=labels or None)
2307
+ print(f"graph.svg written - embeds in Obsidian, Notion, GitHub READMEs")
2308
+
2309
+ elif subcmd == "graphml":
2310
+ from graphify.export import to_graphml as _to_graphml
2311
+ _to_graphml(G, communities, str(out_dir / "graph.graphml"))
2312
+ print(f"graph.graphml written - open in Gephi, yEd, or any GraphML tool")
2313
+
2314
+ elif subcmd == "neo4j":
2315
+ if neo4j_uri:
2316
+ from graphify.export import push_to_neo4j as _push
2317
+ if neo4j_password is None:
2318
+ print("error: --password required for --push", file=sys.stderr)
2319
+ sys.exit(1)
2320
+ result = _push(G, uri=neo4j_uri, user=neo4j_user,
2321
+ password=neo4j_password, communities=communities)
2322
+ print(f"Pushed to Neo4j: {result['nodes']} nodes, {result['edges']} edges")
2323
+ else:
2324
+ from graphify.export import to_cypher as _to_cypher
2325
+ _to_cypher(G, str(out_dir / "cypher.txt"))
2326
+ print(f"cypher.txt written - import with: cypher-shell < {out_dir}/cypher.txt")
2327
+
2328
+ elif cmd == "benchmark":
2329
+ from graphify.benchmark import run_benchmark, print_benchmark
2330
+ graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json"
2331
+ # Try to load corpus_words from detect output
2332
+ corpus_words = None
2333
+ detect_path = Path(".graphify_detect.json")
2334
+ if detect_path.exists():
2335
+ try:
2336
+ detect_data = json.loads(detect_path.read_text(encoding="utf-8"))
2337
+ corpus_words = detect_data.get("total_words")
2338
+ except Exception:
2339
+ pass
2340
+ result = run_benchmark(graph_path, corpus_words=corpus_words)
2341
+ print_benchmark(result)
2342
+
2343
+ elif cmd == "global":
2344
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2345
+ from graphify.global_graph import (
2346
+ global_add as _global_add,
2347
+ global_remove as _global_remove,
2348
+ global_list as _global_list,
2349
+ global_path as _global_path,
2350
+ )
2351
+ if subcmd == "add":
2352
+ # graphify global add <graph.json> [--as <tag>]
2353
+ args = sys.argv[3:]
2354
+ source = None
2355
+ tag = None
2356
+ i = 0
2357
+ while i < len(args):
2358
+ if args[i] == "--as" and i + 1 < len(args):
2359
+ tag = args[i + 1]; i += 2
2360
+ elif not source:
2361
+ source = Path(args[i]); i += 1
2362
+ else:
2363
+ i += 1
2364
+ if not source:
2365
+ print("Usage: graphify global add <graph.json> [--as <repo-tag>]", file=sys.stderr)
2366
+ sys.exit(1)
2367
+ tag = tag or source.parent.parent.name
2368
+ try:
2369
+ result = _global_add(source, tag)
2370
+ if result["skipped"]:
2371
+ print(f"'{tag}' unchanged since last add — global graph not modified.")
2372
+ else:
2373
+ print(f"Added '{tag}' to global graph: +{result['nodes_added']} nodes, "
2374
+ f"-{result['nodes_removed']} pruned. Global: {_global_path()}")
2375
+ except Exception as exc:
2376
+ print(f"error: {exc}", file=sys.stderr); sys.exit(1)
2377
+ elif subcmd == "remove":
2378
+ tag = sys.argv[3] if len(sys.argv) > 3 else ""
2379
+ if not tag:
2380
+ print("Usage: graphify global remove <repo-tag>", file=sys.stderr); sys.exit(1)
2381
+ try:
2382
+ removed = _global_remove(tag)
2383
+ print(f"Removed '{tag}' from global graph ({removed} nodes pruned).")
2384
+ except KeyError as exc:
2385
+ print(f"error: {exc}", file=sys.stderr); sys.exit(1)
2386
+ elif subcmd == "list":
2387
+ repos = _global_list()
2388
+ if not repos:
2389
+ print("Global graph is empty. Use 'graphify global add' to add a project.")
2390
+ else:
2391
+ print(f"Global graph: {_global_path()}")
2392
+ for tag, info in repos.items():
2393
+ print(f" {tag}: {info.get('node_count', '?')} nodes, added {info.get('added_at', '?')[:10]}")
2394
+ elif subcmd == "path":
2395
+ print(_global_path())
2396
+ else:
2397
+ print("Usage: graphify global [add|remove|list|path]", file=sys.stderr); sys.exit(1)
2398
+
2399
+ elif cmd == "extract":
2400
+ # Headless full-pipeline extraction for CI / scripts (#698).
2401
+ # Runs detect -> AST extraction on code -> semantic LLM extraction on
2402
+ # docs/papers/images -> merge -> build -> cluster -> write outputs.
2403
+ # Unlike the skill.md path (which runs through Claude Code subagents),
2404
+ # this calls extract_corpus_parallel directly using whichever backend
2405
+ # has an API key set.
2406
+ if len(sys.argv) < 3:
2407
+ print(
2408
+ "Usage: graphify extract <path> [--backend gemini|kimi|claude|openai|ollama] "
2409
+ "[--model M] [--out DIR] [--google-workspace] [--no-cluster] "
2410
+ "[--max-workers N] [--token-budget N] [--max-concurrency N] "
2411
+ "[--api-timeout S]",
2412
+ file=sys.stderr,
2413
+ )
2414
+ sys.exit(1)
2415
+
2416
+ target = Path(sys.argv[2]).resolve()
2417
+ if not target.exists():
2418
+ print(f"error: path not found: {target}", file=sys.stderr)
2419
+ sys.exit(1)
2420
+
2421
+ backend: str | None = None
2422
+ model: str | None = None
2423
+ out_dir: Path | None = None
2424
+ no_cluster = False
2425
+ dedup_llm = False
2426
+ google_workspace = False
2427
+ global_merge = False
2428
+ global_repo_tag: str | None = None
2429
+ # Performance/tuning knobs (issue #792). None means "use library default".
2430
+ cli_max_workers: int | None = None
2431
+ cli_token_budget: int | None = None
2432
+ cli_max_concurrency: int | None = None
2433
+ cli_api_timeout: float | None = None
2434
+
2435
+ def _parse_int(name: str, raw: str) -> int:
2436
+ try:
2437
+ v = int(raw)
2438
+ except ValueError:
2439
+ print(f"error: {name} must be a positive integer (got {raw!r})", file=sys.stderr)
2440
+ sys.exit(2)
2441
+ if v <= 0:
2442
+ print(f"error: {name} must be > 0 (got {v})", file=sys.stderr)
2443
+ sys.exit(2)
2444
+ return v
2445
+
2446
+ def _parse_float(name: str, raw: str) -> float:
2447
+ try:
2448
+ v = float(raw)
2449
+ except ValueError:
2450
+ print(f"error: {name} must be a positive number (got {raw!r})", file=sys.stderr)
2451
+ sys.exit(2)
2452
+ if v <= 0:
2453
+ print(f"error: {name} must be > 0 (got {v})", file=sys.stderr)
2454
+ sys.exit(2)
2455
+ return v
2456
+
2457
+ args = sys.argv[3:]
2458
+ i = 0
2459
+ while i < len(args):
2460
+ a = args[i]
2461
+ if a == "--backend" and i + 1 < len(args):
2462
+ backend = args[i + 1]; i += 2
2463
+ elif a.startswith("--backend="):
2464
+ backend = a.split("=", 1)[1]; i += 1
2465
+ elif a == "--model" and i + 1 < len(args):
2466
+ model = args[i + 1]; i += 2
2467
+ elif a.startswith("--model="):
2468
+ model = a.split("=", 1)[1]; i += 1
2469
+ elif a == "--out" and i + 1 < len(args):
2470
+ out_dir = Path(args[i + 1]); i += 2
2471
+ elif a.startswith("--out="):
2472
+ out_dir = Path(a.split("=", 1)[1]); i += 1
2473
+ elif a == "--no-cluster":
2474
+ no_cluster = True; i += 1
2475
+ elif a == "--dedup-llm":
2476
+ dedup_llm = True; i += 1
2477
+ elif a == "--google-workspace":
2478
+ google_workspace = True; i += 1
2479
+ elif a == "--global":
2480
+ global_merge = True; i += 1
2481
+ elif a == "--as" and i + 1 < len(args):
2482
+ global_repo_tag = args[i + 1]; i += 2
2483
+ elif a == "--max-workers" and i + 1 < len(args):
2484
+ cli_max_workers = _parse_int("--max-workers", args[i + 1]); i += 2
2485
+ elif a.startswith("--max-workers="):
2486
+ cli_max_workers = _parse_int("--max-workers", a.split("=", 1)[1]); i += 1
2487
+ elif a == "--token-budget" and i + 1 < len(args):
2488
+ cli_token_budget = _parse_int("--token-budget", args[i + 1]); i += 2
2489
+ elif a.startswith("--token-budget="):
2490
+ cli_token_budget = _parse_int("--token-budget", a.split("=", 1)[1]); i += 1
2491
+ elif a == "--max-concurrency" and i + 1 < len(args):
2492
+ cli_max_concurrency = _parse_int("--max-concurrency", args[i + 1]); i += 2
2493
+ elif a.startswith("--max-concurrency="):
2494
+ cli_max_concurrency = _parse_int("--max-concurrency", a.split("=", 1)[1]); i += 1
2495
+ elif a == "--api-timeout" and i + 1 < len(args):
2496
+ cli_api_timeout = _parse_float("--api-timeout", args[i + 1]); i += 2
2497
+ elif a.startswith("--api-timeout="):
2498
+ cli_api_timeout = _parse_float("--api-timeout", a.split("=", 1)[1]); i += 1
2499
+ else:
2500
+ i += 1
2501
+
2502
+ # CLI flag wins over env var. Setting GRAPHIFY_API_TIMEOUT here so
2503
+ # _call_openai_compat picks it up without needing a new kwarg path.
2504
+ if cli_api_timeout is not None:
2505
+ os.environ["GRAPHIFY_API_TIMEOUT"] = str(cli_api_timeout)
2506
+ if cli_max_workers is not None:
2507
+ os.environ["GRAPHIFY_MAX_WORKERS"] = str(cli_max_workers)
2508
+
2509
+ # Backend resolution. If user did not pass --backend, sniff env.
2510
+ # If backend was explicitly requested, validate its key is present
2511
+ # and surface a clear error early — don't let extract_corpus_parallel
2512
+ # raise mid-run after we've spent time on AST extraction.
2513
+ from graphify.llm import (
2514
+ BACKENDS as _BACKENDS,
2515
+ detect_backend as _detect_backend,
2516
+ estimate_cost as _estimate_cost,
2517
+ extract_corpus_parallel as _extract_corpus_parallel,
2518
+ _format_backend_env_keys,
2519
+ _get_backend_api_key,
2520
+ )
2521
+ if backend is None:
2522
+ backend = _detect_backend()
2523
+ if backend is None:
2524
+ print(
2525
+ "error: no LLM API key found. Set GEMINI_API_KEY or GOOGLE_API_KEY "
2526
+ "(gemini), MOONSHOT_API_KEY (kimi), ANTHROPIC_API_KEY (claude), "
2527
+ "or OPENAI_API_KEY (openai), or pass --backend.",
2528
+ file=sys.stderr,
2529
+ )
2530
+ sys.exit(1)
2531
+ if backend not in _BACKENDS:
2532
+ print(
2533
+ f"error: unknown backend '{backend}'. "
2534
+ f"Available: {', '.join(sorted(_BACKENDS))}",
2535
+ file=sys.stderr,
2536
+ )
2537
+ sys.exit(1)
2538
+ if not _get_backend_api_key(backend):
2539
+ # Ollama on a loopback URL ignores auth entirely; don't block
2540
+ # the run just because OLLAMA_API_KEY is unset (issue #792).
2541
+ # extract_files_direct already prints a warning and substitutes
2542
+ # a placeholder key in that case.
2543
+ allow_no_key = False
2544
+ if backend == "ollama":
2545
+ from urllib.parse import urlparse
2546
+ ollama_url = os.environ.get(
2547
+ "OLLAMA_BASE_URL",
2548
+ _BACKENDS["ollama"].get("base_url", ""),
2549
+ )
2550
+ try:
2551
+ host = (urlparse(ollama_url).hostname or "").lower()
2552
+ except Exception:
2553
+ host = ""
2554
+ allow_no_key = (
2555
+ host in ("localhost", "127.0.0.1", "::1")
2556
+ or host.startswith("127.")
2557
+ )
2558
+ elif backend == "bedrock":
2559
+ allow_no_key = bool(
2560
+ os.environ.get("AWS_PROFILE")
2561
+ or os.environ.get("AWS_REGION")
2562
+ or os.environ.get("AWS_DEFAULT_REGION")
2563
+ or os.environ.get("AWS_ACCESS_KEY_ID")
2564
+ )
2565
+ elif backend == "claude-cli":
2566
+ import shutil as _shutil
2567
+ allow_no_key = _shutil.which("claude") is not None
2568
+ if not allow_no_key:
2569
+ print(
2570
+ "error: backend 'claude-cli' requires the `claude` CLI on $PATH "
2571
+ "(install Claude Code and run `claude` once to authenticate).",
2572
+ file=sys.stderr,
2573
+ )
2574
+ sys.exit(1)
2575
+ if not allow_no_key:
2576
+ print(
2577
+ f"error: backend '{backend}' requires {_format_backend_env_keys(backend)} to be set.",
2578
+ file=sys.stderr,
2579
+ )
2580
+ sys.exit(1)
2581
+
2582
+ # Resolve output dir. The user-facing contract is "<out>/graphify-out/"
2583
+ # so a fresh checkout writes graphify-out/ at the project root, matching
2584
+ # the skill.md pipeline.
2585
+ out_root = (out_dir.resolve() if out_dir else target)
2586
+ graphify_out = out_root / "graphify-out"
2587
+ graphify_out.mkdir(parents=True, exist_ok=True)
2588
+
2589
+ from graphify.detect import (
2590
+ detect as _detect,
2591
+ detect_incremental as _detect_incremental,
2592
+ save_manifest as _save_manifest,
2593
+ )
2594
+ manifest_path = graphify_out / "manifest.json"
2595
+ existing_graph_path = graphify_out / "graph.json"
2596
+ incremental_mode = manifest_path.exists() and existing_graph_path.exists()
2597
+
2598
+ if incremental_mode:
2599
+ print(f"[graphify extract] incremental scan of {target}")
2600
+ detection = _detect_incremental(
2601
+ target,
2602
+ manifest_path=str(manifest_path),
2603
+ google_workspace=google_workspace or None,
2604
+ )
2605
+ else:
2606
+ print(f"[graphify extract] scanning {target}")
2607
+ detection = _detect(target, google_workspace=google_workspace or None)
2608
+
2609
+ files_by_type = detection.get("files", {})
2610
+ if incremental_mode:
2611
+ new_by_type = detection.get("new_files", {})
2612
+ code_files = [Path(p) for p in new_by_type.get("code", [])]
2613
+ doc_files = [Path(p) for p in new_by_type.get("document", [])]
2614
+ paper_files = [Path(p) for p in new_by_type.get("paper", [])]
2615
+ image_files = [Path(p) for p in new_by_type.get("image", [])]
2616
+ deleted_files = list(detection.get("deleted_files", []))
2617
+ unchanged_total = sum(len(v) for v in detection.get("unchanged_files", {}).values())
2618
+ else:
2619
+ code_files = [Path(p) for p in files_by_type.get("code", [])]
2620
+ doc_files = [Path(p) for p in files_by_type.get("document", [])]
2621
+ paper_files = [Path(p) for p in files_by_type.get("paper", [])]
2622
+ image_files = [Path(p) for p in files_by_type.get("image", [])]
2623
+ deleted_files = []
2624
+ unchanged_total = 0
2625
+
2626
+ semantic_files = doc_files + paper_files + image_files
2627
+ if incremental_mode:
2628
+ print(
2629
+ f"[graphify extract] {len(code_files)} code, {len(doc_files)} docs, "
2630
+ f"{len(paper_files)} papers, {len(image_files)} images changed; "
2631
+ f"{unchanged_total} unchanged; {len(deleted_files)} deleted"
2632
+ )
2633
+ else:
2634
+ print(
2635
+ f"[graphify extract] found {len(code_files)} code, "
2636
+ f"{len(doc_files)} docs, {len(paper_files)} papers, "
2637
+ f"{len(image_files)} images"
2638
+ )
2639
+
2640
+ # AST extraction on code files. Empty code list (docs-only corpus) is
2641
+ # the issue #698 case — skip cleanly instead of crashing inside extract().
2642
+ ast_result: dict = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0}
2643
+ if code_files:
2644
+ from graphify.extract import extract as _ast_extract
2645
+ ast_kwargs: dict = {"cache_root": target}
2646
+ if cli_max_workers is not None:
2647
+ ast_kwargs["max_workers"] = cli_max_workers
2648
+ print(f"[graphify extract] AST extraction on {len(code_files)} code files...")
2649
+ try:
2650
+ ast_result = _ast_extract(code_files, **ast_kwargs)
2651
+ except Exception as exc:
2652
+ print(f"[graphify extract] AST extraction failed: {exc}", file=sys.stderr)
2653
+ ast_result = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0}
2654
+
2655
+ # Semantic extraction on docs/papers/images. Check cache first.
2656
+ from graphify.cache import (
2657
+ check_semantic_cache as _check_semantic_cache,
2658
+ save_semantic_cache as _save_semantic_cache,
2659
+ )
2660
+ sem_result: dict = {
2661
+ "nodes": [], "edges": [], "hyperedges": [],
2662
+ "input_tokens": 0, "output_tokens": 0,
2663
+ }
2664
+ sem_cache_hits = 0
2665
+ sem_cache_misses = 0
2666
+ if semantic_files:
2667
+ sem_paths_str = [str(p) for p in semantic_files]
2668
+ cached_nodes, cached_edges, cached_hyperedges, uncached_paths = (
2669
+ _check_semantic_cache(sem_paths_str, root=target)
2670
+ )
2671
+ sem_cache_hits = len(semantic_files) - len(uncached_paths)
2672
+ sem_cache_misses = len(uncached_paths)
2673
+ sem_result["nodes"].extend(cached_nodes)
2674
+ sem_result["edges"].extend(cached_edges)
2675
+ sem_result["hyperedges"].extend(cached_hyperedges)
2676
+ if sem_cache_hits:
2677
+ print(f"[graphify extract] semantic cache: {sem_cache_hits} hit / {sem_cache_misses} miss")
2678
+
2679
+ if uncached_paths:
2680
+ print(f"[graphify extract] semantic extraction on {len(uncached_paths)} files via {backend}...")
2681
+ corpus_kwargs: dict = {
2682
+ "backend": backend,
2683
+ "model": model,
2684
+ "root": target,
2685
+ }
2686
+ if cli_token_budget is not None:
2687
+ corpus_kwargs["token_budget"] = cli_token_budget
2688
+ if cli_max_concurrency is not None:
2689
+ corpus_kwargs["max_concurrency"] = cli_max_concurrency
2690
+
2691
+ # Minimal progress callback so the CLI is no longer silent
2692
+ # during long local-inference runs (issue #792 addendum).
2693
+ _total_chunks = {"n": 0}
2694
+ def _progress(idx: int, total: int, _result: dict) -> None:
2695
+ _total_chunks["n"] = total
2696
+ print(
2697
+ f"[graphify extract] chunk {idx + 1}/{total} done",
2698
+ flush=True,
2699
+ )
2700
+ corpus_kwargs["on_chunk_done"] = _progress
2701
+
2702
+ try:
2703
+ fresh = _extract_corpus_parallel(
2704
+ [Path(p) for p in uncached_paths],
2705
+ **corpus_kwargs,
2706
+ )
2707
+ except ImportError as exc:
2708
+ print(f"error: {exc}", file=sys.stderr)
2709
+ sys.exit(1)
2710
+ except Exception as exc:
2711
+ print(
2712
+ f"[graphify extract] semantic extraction failed: {exc}",
2713
+ file=sys.stderr,
2714
+ )
2715
+ fresh = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
2716
+ try:
2717
+ _save_semantic_cache(
2718
+ fresh.get("nodes", []),
2719
+ fresh.get("edges", []),
2720
+ fresh.get("hyperedges", []),
2721
+ root=target,
2722
+ )
2723
+ except Exception as exc:
2724
+ print(f"[graphify extract] warning: could not write semantic cache: {exc}", file=sys.stderr)
2725
+ sem_result["nodes"].extend(fresh.get("nodes", []))
2726
+ sem_result["edges"].extend(fresh.get("edges", []))
2727
+ sem_result["hyperedges"].extend(fresh.get("hyperedges", []))
2728
+ sem_result["input_tokens"] += fresh.get("input_tokens", 0)
2729
+ sem_result["output_tokens"] += fresh.get("output_tokens", 0)
2730
+
2731
+ # Merge AST + semantic. Order matters for deduplication: passing AST
2732
+ # first means semantic node attributes win on collision (richer labels
2733
+ # for symbols also referenced in docs). Hyperedges only come from the
2734
+ # semantic side.
2735
+ merged: dict = {
2736
+ "nodes": list(ast_result.get("nodes", [])) + list(sem_result.get("nodes", [])),
2737
+ "edges": list(ast_result.get("edges", [])) + list(sem_result.get("edges", [])),
2738
+ "hyperedges": list(sem_result.get("hyperedges", [])),
2739
+ "input_tokens": ast_result.get("input_tokens", 0) + sem_result.get("input_tokens", 0),
2740
+ "output_tokens": ast_result.get("output_tokens", 0) + sem_result.get("output_tokens", 0),
2741
+ }
2742
+
2743
+ graph_json_path = graphify_out / "graph.json"
2744
+ analysis_path = graphify_out / ".graphify_analysis.json"
2745
+
2746
+ if no_cluster:
2747
+ # --no-cluster: dump the raw merged extraction as graph.json.
2748
+ # No NetworkX, no community detection, no analysis sidecar.
2749
+ graph_json_path.write_text(
2750
+ json.dumps(merged, indent=2), encoding="utf-8"
2751
+ )
2752
+ cost = _estimate_cost(
2753
+ backend, merged["input_tokens"], merged["output_tokens"]
2754
+ )
2755
+ print(
2756
+ f"[graphify extract] wrote {graph_json_path} — "
2757
+ f"{len(merged['nodes'])} nodes, {len(merged['edges'])} edges "
2758
+ f"(no clustering)"
2759
+ )
2760
+ if merged["input_tokens"] or merged["output_tokens"]:
2761
+ print(
2762
+ f"[graphify extract] tokens: "
2763
+ f"{merged['input_tokens']:,} in / "
2764
+ f"{merged['output_tokens']:,} out, "
2765
+ f"est. cost: ${cost:.4f}"
2766
+ )
2767
+ try:
2768
+ _save_manifest(files_by_type, manifest_path=str(manifest_path))
2769
+ except Exception as exc:
2770
+ print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr)
2771
+ if global_merge:
2772
+ from graphify.global_graph import global_add as _global_add
2773
+ _tag = global_repo_tag or target.name
2774
+ try:
2775
+ result = _global_add(graphify_out / "graph.json", _tag)
2776
+ if result["skipped"]:
2777
+ print(f"[graphify global] '{_tag}' unchanged since last add — skipped.")
2778
+ else:
2779
+ print(f"[graphify global] '{_tag}' merged into global graph "
2780
+ f"(+{result['nodes_added']} nodes, -{result['nodes_removed']} pruned).")
2781
+ except Exception as exc:
2782
+ print(f"[graphify global] warning: failed to merge into global graph: {exc}", file=sys.stderr)
2783
+ sys.exit(0)
2784
+
2785
+ # Build graph + cluster + score + write.
2786
+ from graphify.build import (
2787
+ build as _build,
2788
+ build_from_json as _build_from_json,
2789
+ build_merge as _build_merge,
2790
+ )
2791
+ from graphify.cluster import cluster as _cluster, score_all as _score_all
2792
+ from graphify.export import to_json as _to_json
2793
+ from graphify.analyze import god_nodes as _god_nodes, surprising_connections as _surprising
2794
+ dedup_backend = backend if dedup_llm else None
2795
+ if incremental_mode:
2796
+ G = _build_merge(
2797
+ [merged],
2798
+ graph_path=existing_graph_path,
2799
+ prune_sources=deleted_files or None,
2800
+ dedup=True,
2801
+ dedup_llm_backend=dedup_backend,
2802
+ )
2803
+ else:
2804
+ G = _build([merged], dedup=True, dedup_llm_backend=dedup_backend)
2805
+ if G.number_of_nodes() == 0:
2806
+ print(
2807
+ "[graphify extract] graph is empty — extraction produced no nodes. "
2808
+ "Possible causes: all files skipped, binary-only corpus, or LLM "
2809
+ "returned no edges.",
2810
+ file=sys.stderr,
2811
+ )
2812
+ sys.exit(1)
2813
+
2814
+ communities = _cluster(G)
2815
+ cohesion = _score_all(G, communities)
2816
+ try:
2817
+ gods = _god_nodes(G)
2818
+ except Exception:
2819
+ gods = []
2820
+ try:
2821
+ surprises = _surprising(G, communities)
2822
+ except Exception:
2823
+ surprises = []
2824
+
2825
+ _to_json(G, communities, str(graph_json_path), force=True)
2826
+ if global_merge:
2827
+ from graphify.global_graph import global_add as _global_add
2828
+ _tag = global_repo_tag or target.name
2829
+ try:
2830
+ result = _global_add(graphify_out / "graph.json", _tag)
2831
+ if result["skipped"]:
2832
+ print(f"[graphify global] '{_tag}' unchanged since last add — skipped.")
2833
+ else:
2834
+ print(f"[graphify global] '{_tag}' merged into global graph "
2835
+ f"(+{result['nodes_added']} nodes, -{result['nodes_removed']} pruned).")
2836
+ except Exception as exc:
2837
+ print(f"[graphify global] warning: failed to merge into global graph: {exc}", file=sys.stderr)
2838
+ analysis = {
2839
+ "communities": {str(k): v for k, v in communities.items()},
2840
+ "cohesion": {str(k): v for k, v in cohesion.items()},
2841
+ "gods": gods,
2842
+ "surprises": surprises,
2843
+ "tokens": {
2844
+ "input": merged["input_tokens"],
2845
+ "output": merged["output_tokens"],
2846
+ },
2847
+ }
2848
+ analysis_path.write_text(json.dumps(analysis, indent=2), encoding="utf-8")
2849
+ try:
2850
+ _save_manifest(files_by_type, manifest_path=str(manifest_path))
2851
+ except Exception as exc:
2852
+ print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr)
2853
+
2854
+ cost = _estimate_cost(backend, merged["input_tokens"], merged["output_tokens"])
2855
+ print(
2856
+ f"[graphify extract] wrote {graph_json_path}: "
2857
+ f"{G.number_of_nodes()} nodes, {G.number_of_edges()} edges, "
2858
+ f"{len(communities)} communities"
2859
+ )
2860
+ print(f"[graphify extract] wrote {analysis_path}")
2861
+ if incremental_mode:
2862
+ print(
2863
+ f"[graphify extract] incremental summary: "
2864
+ f"{sem_cache_hits + unchanged_total} files cached/unchanged, "
2865
+ f"{len(code_files) + sem_cache_misses} re-extracted, "
2866
+ f"{len(deleted_files)} deleted"
2867
+ )
2868
+ elif sem_cache_hits:
2869
+ print(f"[graphify extract] semantic cache: {sem_cache_hits} cached, {sem_cache_misses} re-extracted")
2870
+ if merged["input_tokens"] or merged["output_tokens"]:
2871
+ print(
2872
+ f"[graphify extract] tokens: "
2873
+ f"{merged['input_tokens']:,} in / "
2874
+ f"{merged['output_tokens']:,} out, "
2875
+ f"est. cost (~{backend}): ${cost:.4f}"
2876
+ )
2877
+
2878
+ else:
2879
+ print(f"error: unknown command '{cmd}'", file=sys.stderr)
2880
+ print("Run 'graphify --help' for usage.", file=sys.stderr)
2881
+ sys.exit(1)
2882
+
2883
+
2884
+ if __name__ == "__main__":
2885
+ main()