@oriro/orirocli 0.1.7 → 0.1.9

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.
Files changed (351) hide show
  1. package/ATTRIBUTION.md +8 -0
  2. package/LICENSE +21 -0
  3. package/dist/cli.js +35 -5
  4. package/package.json +1 -1
  5. package/skills/21stdev/SKILL.md +64 -0
  6. package/skills/graphify/SKILL.md +619 -0
  7. package/skills/graphify/__init__.py +28 -0
  8. package/skills/graphify/__main__.py +4582 -0
  9. package/skills/graphify/affected.py +154 -0
  10. package/skills/graphify/always_on/agents-md.md +12 -0
  11. package/skills/graphify/always_on/antigravity-rules.md +14 -0
  12. package/skills/graphify/always_on/claude-md.md +9 -0
  13. package/skills/graphify/always_on/gemini-md.md +9 -0
  14. package/skills/graphify/always_on/kiro-steering.md +5 -0
  15. package/skills/graphify/always_on/vscode-instructions.md +17 -0
  16. package/skills/graphify/analyze.py +724 -0
  17. package/skills/graphify/benchmark.py +155 -0
  18. package/skills/graphify/build.py +487 -0
  19. package/skills/graphify/cache.py +417 -0
  20. package/skills/graphify/callflow_html.py +2020 -0
  21. package/skills/graphify/cluster.py +272 -0
  22. package/skills/graphify/command-kilo.md +15 -0
  23. package/skills/graphify/dedup.py +429 -0
  24. package/skills/graphify/detect.py +1379 -0
  25. package/skills/graphify/diagnostics.py +390 -0
  26. package/skills/graphify/export.py +1408 -0
  27. package/skills/graphify/extract.py +11570 -0
  28. package/skills/graphify/global_graph.py +159 -0
  29. package/skills/graphify/google_workspace.py +223 -0
  30. package/skills/graphify/hooks.py +457 -0
  31. package/skills/graphify/ingest.py +331 -0
  32. package/skills/graphify/llm.py +1896 -0
  33. package/skills/graphify/manifest.py +4 -0
  34. package/skills/graphify/mcp_ingest.py +392 -0
  35. package/skills/graphify/multigraph_compat.py +212 -0
  36. package/skills/graphify/pg_introspect.py +142 -0
  37. package/skills/graphify/prs.py +748 -0
  38. package/skills/graphify/querylog.py +70 -0
  39. package/skills/graphify/report.py +218 -0
  40. package/skills/graphify/scip_ingest.py +363 -0
  41. package/skills/graphify/security.py +336 -0
  42. package/skills/graphify/semantic_cleanup.py +319 -0
  43. package/skills/graphify/serve.py +1309 -0
  44. package/skills/graphify/skill-aider.md +1246 -0
  45. package/skills/graphify/skill-amp.md +613 -0
  46. package/skills/graphify/skill-claw.md +616 -0
  47. package/skills/graphify/skill-codex.md +613 -0
  48. package/skills/graphify/skill-copilot.md +616 -0
  49. package/skills/graphify/skill-devin.md +1372 -0
  50. package/skills/graphify/skill-droid.md +613 -0
  51. package/skills/graphify/skill-kilo.md +625 -0
  52. package/skills/graphify/skill-kiro.md +615 -0
  53. package/skills/graphify/skill-opencode.md +608 -0
  54. package/skills/graphify/skill-pi.md +615 -0
  55. package/skills/graphify/skill-trae.md +614 -0
  56. package/skills/graphify/skill-vscode.md +612 -0
  57. package/skills/graphify/skill-windows.md +651 -0
  58. package/skills/graphify/skills/amp/references/add-watch.md +56 -0
  59. package/skills/graphify/skills/amp/references/exports.md +71 -0
  60. package/skills/graphify/skills/amp/references/extraction-spec.md +68 -0
  61. package/skills/graphify/skills/amp/references/github-and-merge.md +46 -0
  62. package/skills/graphify/skills/amp/references/hooks.md +33 -0
  63. package/skills/graphify/skills/amp/references/query.md +249 -0
  64. package/skills/graphify/skills/amp/references/transcribe.md +48 -0
  65. package/skills/graphify/skills/amp/references/update.md +179 -0
  66. package/skills/graphify/skills/claude/references/add-watch.md +56 -0
  67. package/skills/graphify/skills/claude/references/exports.md +71 -0
  68. package/skills/graphify/skills/claude/references/extraction-spec.md +68 -0
  69. package/skills/graphify/skills/claude/references/github-and-merge.md +46 -0
  70. package/skills/graphify/skills/claude/references/hooks.md +33 -0
  71. package/skills/graphify/skills/claude/references/query.md +103 -0
  72. package/skills/graphify/skills/claude/references/transcribe.md +48 -0
  73. package/skills/graphify/skills/claude/references/update.md +179 -0
  74. package/skills/graphify/skills/claw/references/add-watch.md +56 -0
  75. package/skills/graphify/skills/claw/references/exports.md +71 -0
  76. package/skills/graphify/skills/claw/references/extraction-spec.md +29 -0
  77. package/skills/graphify/skills/claw/references/github-and-merge.md +46 -0
  78. package/skills/graphify/skills/claw/references/hooks.md +33 -0
  79. package/skills/graphify/skills/claw/references/query.md +249 -0
  80. package/skills/graphify/skills/claw/references/transcribe.md +48 -0
  81. package/skills/graphify/skills/claw/references/update.md +179 -0
  82. package/skills/graphify/skills/codex/references/add-watch.md +56 -0
  83. package/skills/graphify/skills/codex/references/exports.md +71 -0
  84. package/skills/graphify/skills/codex/references/extraction-spec.md +29 -0
  85. package/skills/graphify/skills/codex/references/github-and-merge.md +46 -0
  86. package/skills/graphify/skills/codex/references/hooks.md +33 -0
  87. package/skills/graphify/skills/codex/references/query.md +249 -0
  88. package/skills/graphify/skills/codex/references/transcribe.md +48 -0
  89. package/skills/graphify/skills/codex/references/update.md +179 -0
  90. package/skills/graphify/skills/copilot/references/add-watch.md +56 -0
  91. package/skills/graphify/skills/copilot/references/exports.md +71 -0
  92. package/skills/graphify/skills/copilot/references/extraction-spec.md +68 -0
  93. package/skills/graphify/skills/copilot/references/github-and-merge.md +46 -0
  94. package/skills/graphify/skills/copilot/references/hooks.md +33 -0
  95. package/skills/graphify/skills/copilot/references/query.md +249 -0
  96. package/skills/graphify/skills/copilot/references/transcribe.md +48 -0
  97. package/skills/graphify/skills/copilot/references/update.md +179 -0
  98. package/skills/graphify/skills/droid/references/add-watch.md +56 -0
  99. package/skills/graphify/skills/droid/references/exports.md +71 -0
  100. package/skills/graphify/skills/droid/references/extraction-spec.md +68 -0
  101. package/skills/graphify/skills/droid/references/github-and-merge.md +46 -0
  102. package/skills/graphify/skills/droid/references/hooks.md +33 -0
  103. package/skills/graphify/skills/droid/references/query.md +249 -0
  104. package/skills/graphify/skills/droid/references/transcribe.md +48 -0
  105. package/skills/graphify/skills/droid/references/update.md +179 -0
  106. package/skills/graphify/skills/kilo/references/add-watch.md +56 -0
  107. package/skills/graphify/skills/kilo/references/exports.md +71 -0
  108. package/skills/graphify/skills/kilo/references/extraction-spec.md +68 -0
  109. package/skills/graphify/skills/kilo/references/github-and-merge.md +46 -0
  110. package/skills/graphify/skills/kilo/references/hooks.md +33 -0
  111. package/skills/graphify/skills/kilo/references/query.md +249 -0
  112. package/skills/graphify/skills/kilo/references/transcribe.md +48 -0
  113. package/skills/graphify/skills/kilo/references/update.md +179 -0
  114. package/skills/graphify/skills/kiro/references/add-watch.md +56 -0
  115. package/skills/graphify/skills/kiro/references/exports.md +71 -0
  116. package/skills/graphify/skills/kiro/references/extraction-spec.md +29 -0
  117. package/skills/graphify/skills/kiro/references/github-and-merge.md +46 -0
  118. package/skills/graphify/skills/kiro/references/hooks.md +33 -0
  119. package/skills/graphify/skills/kiro/references/query.md +249 -0
  120. package/skills/graphify/skills/kiro/references/transcribe.md +48 -0
  121. package/skills/graphify/skills/kiro/references/update.md +179 -0
  122. package/skills/graphify/skills/opencode/references/add-watch.md +56 -0
  123. package/skills/graphify/skills/opencode/references/exports.md +71 -0
  124. package/skills/graphify/skills/opencode/references/extraction-spec.md +68 -0
  125. package/skills/graphify/skills/opencode/references/github-and-merge.md +46 -0
  126. package/skills/graphify/skills/opencode/references/hooks.md +33 -0
  127. package/skills/graphify/skills/opencode/references/query.md +249 -0
  128. package/skills/graphify/skills/opencode/references/transcribe.md +48 -0
  129. package/skills/graphify/skills/opencode/references/update.md +179 -0
  130. package/skills/graphify/skills/pi/references/add-watch.md +56 -0
  131. package/skills/graphify/skills/pi/references/exports.md +71 -0
  132. package/skills/graphify/skills/pi/references/extraction-spec.md +29 -0
  133. package/skills/graphify/skills/pi/references/github-and-merge.md +46 -0
  134. package/skills/graphify/skills/pi/references/hooks.md +33 -0
  135. package/skills/graphify/skills/pi/references/query.md +249 -0
  136. package/skills/graphify/skills/pi/references/transcribe.md +48 -0
  137. package/skills/graphify/skills/pi/references/update.md +179 -0
  138. package/skills/graphify/skills/trae/references/add-watch.md +56 -0
  139. package/skills/graphify/skills/trae/references/exports.md +71 -0
  140. package/skills/graphify/skills/trae/references/extraction-spec.md +68 -0
  141. package/skills/graphify/skills/trae/references/github-and-merge.md +46 -0
  142. package/skills/graphify/skills/trae/references/hooks.md +35 -0
  143. package/skills/graphify/skills/trae/references/query.md +249 -0
  144. package/skills/graphify/skills/trae/references/transcribe.md +48 -0
  145. package/skills/graphify/skills/trae/references/update.md +179 -0
  146. package/skills/graphify/skills/vscode/references/add-watch.md +56 -0
  147. package/skills/graphify/skills/vscode/references/exports.md +71 -0
  148. package/skills/graphify/skills/vscode/references/extraction-spec.md +68 -0
  149. package/skills/graphify/skills/vscode/references/github-and-merge.md +46 -0
  150. package/skills/graphify/skills/vscode/references/hooks.md +33 -0
  151. package/skills/graphify/skills/vscode/references/query.md +249 -0
  152. package/skills/graphify/skills/vscode/references/transcribe.md +48 -0
  153. package/skills/graphify/skills/vscode/references/update.md +179 -0
  154. package/skills/graphify/skills/windows/references/add-watch.md +56 -0
  155. package/skills/graphify/skills/windows/references/exports.md +71 -0
  156. package/skills/graphify/skills/windows/references/extraction-spec.md +68 -0
  157. package/skills/graphify/skills/windows/references/github-and-merge.md +46 -0
  158. package/skills/graphify/skills/windows/references/hooks.md +33 -0
  159. package/skills/graphify/skills/windows/references/query.md +249 -0
  160. package/skills/graphify/skills/windows/references/transcribe.md +48 -0
  161. package/skills/graphify/skills/windows/references/update.md +179 -0
  162. package/skills/graphify/symbol_resolution.py +538 -0
  163. package/skills/graphify/transcribe.py +184 -0
  164. package/skills/graphify/tree_html.py +582 -0
  165. package/skills/graphify/validate.py +72 -0
  166. package/skills/graphify/watch.py +898 -0
  167. package/skills/graphify/wiki.py +282 -0
  168. package/skills/impeccable/SKILL.md +186 -0
  169. package/skills/impeccable/agents/impeccable_asset_producer.toml +92 -0
  170. package/skills/impeccable/agents/impeccable_manual_edit_applier.toml +95 -0
  171. package/skills/impeccable/agents/openai.yaml +4 -0
  172. package/skills/impeccable/reference/adapt.md +311 -0
  173. package/skills/impeccable/reference/animate.md +201 -0
  174. package/skills/impeccable/reference/audit.md +133 -0
  175. package/skills/impeccable/reference/bolder.md +113 -0
  176. package/skills/impeccable/reference/brand.md +108 -0
  177. package/skills/impeccable/reference/clarify.md +288 -0
  178. package/skills/impeccable/reference/codex.md +105 -0
  179. package/skills/impeccable/reference/colorize.md +257 -0
  180. package/skills/impeccable/reference/craft.md +123 -0
  181. package/skills/impeccable/reference/critique.md +790 -0
  182. package/skills/impeccable/reference/delight.md +302 -0
  183. package/skills/impeccable/reference/distill.md +111 -0
  184. package/skills/impeccable/reference/document.md +429 -0
  185. package/skills/impeccable/reference/extract.md +69 -0
  186. package/skills/impeccable/reference/harden.md +347 -0
  187. package/skills/impeccable/reference/init.md +172 -0
  188. package/skills/impeccable/reference/interaction-design.md +189 -0
  189. package/skills/impeccable/reference/layout.md +161 -0
  190. package/skills/impeccable/reference/live.md +720 -0
  191. package/skills/impeccable/reference/onboard.md +234 -0
  192. package/skills/impeccable/reference/optimize.md +258 -0
  193. package/skills/impeccable/reference/overdrive.md +130 -0
  194. package/skills/impeccable/reference/polish.md +241 -0
  195. package/skills/impeccable/reference/product.md +60 -0
  196. package/skills/impeccable/reference/quieter.md +99 -0
  197. package/skills/impeccable/reference/shape.md +165 -0
  198. package/skills/impeccable/reference/typeset.md +279 -0
  199. package/skills/impeccable/scripts/cleanup-deprecated.mjs +284 -0
  200. package/skills/impeccable/scripts/command-metadata.json +94 -0
  201. package/skills/impeccable/scripts/context-signals.mjs +225 -0
  202. package/skills/impeccable/scripts/context.mjs +266 -0
  203. package/skills/impeccable/scripts/critique-storage.mjs +242 -0
  204. package/skills/impeccable/scripts/design-parser.mjs +835 -0
  205. package/skills/impeccable/scripts/detect-csp.mjs +198 -0
  206. package/skills/impeccable/scripts/detect.mjs +21 -0
  207. package/skills/impeccable/scripts/detector/browser/injected/index.mjs +1733 -0
  208. package/skills/impeccable/scripts/detector/cli/main.mjs +244 -0
  209. package/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +4618 -0
  210. package/skills/impeccable/scripts/detector/detect-antipatterns.mjs +43 -0
  211. package/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +252 -0
  212. package/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +535 -0
  213. package/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +986 -0
  214. package/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +208 -0
  215. package/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +189 -0
  216. package/skills/impeccable/scripts/detector/findings.mjs +12 -0
  217. package/skills/impeccable/scripts/detector/node/file-system.mjs +198 -0
  218. package/skills/impeccable/scripts/detector/profile/profiler.mjs +166 -0
  219. package/skills/impeccable/scripts/detector/registry/antipatterns.mjs +419 -0
  220. package/skills/impeccable/scripts/detector/rules/checks.mjs +2384 -0
  221. package/skills/impeccable/scripts/detector/shared/color.mjs +124 -0
  222. package/skills/impeccable/scripts/detector/shared/constants.mjs +101 -0
  223. package/skills/impeccable/scripts/detector/shared/page.mjs +7 -0
  224. package/skills/impeccable/scripts/impeccable-paths.mjs +126 -0
  225. package/skills/impeccable/scripts/is-generated.mjs +69 -0
  226. package/skills/impeccable/scripts/live-accept.mjs +812 -0
  227. package/skills/impeccable/scripts/live-browser-session.js +123 -0
  228. package/skills/impeccable/scripts/live-browser.js +10295 -0
  229. package/skills/impeccable/scripts/live-commit-manual-edits.mjs +1241 -0
  230. package/skills/impeccable/scripts/live-complete.mjs +75 -0
  231. package/skills/impeccable/scripts/live-completion.mjs +19 -0
  232. package/skills/impeccable/scripts/live-copy-edit-agent.mjs +683 -0
  233. package/skills/impeccable/scripts/live-discard-manual-edits.mjs +51 -0
  234. package/skills/impeccable/scripts/live-event-validation.mjs +137 -0
  235. package/skills/impeccable/scripts/live-inject.mjs +557 -0
  236. package/skills/impeccable/scripts/live-insert-ui.mjs +458 -0
  237. package/skills/impeccable/scripts/live-insert.mjs +272 -0
  238. package/skills/impeccable/scripts/live-manual-edit-evidence.mjs +363 -0
  239. package/skills/impeccable/scripts/live-manual-edits-buffer.mjs +152 -0
  240. package/skills/impeccable/scripts/live-poll.mjs +379 -0
  241. package/skills/impeccable/scripts/live-resume.mjs +94 -0
  242. package/skills/impeccable/scripts/live-server.mjs +2326 -0
  243. package/skills/impeccable/scripts/live-session-store.mjs +289 -0
  244. package/skills/impeccable/scripts/live-status.mjs +61 -0
  245. package/skills/impeccable/scripts/live-svelte-component.mjs +826 -0
  246. package/skills/impeccable/scripts/live-sveltekit-adapter.mjs +274 -0
  247. package/skills/impeccable/scripts/live-ui-core.mjs +179 -0
  248. package/skills/impeccable/scripts/live-vocabulary.mjs +36 -0
  249. package/skills/impeccable/scripts/live-wrap.mjs +894 -0
  250. package/skills/impeccable/scripts/live.mjs +246 -0
  251. package/skills/impeccable/scripts/modern-screenshot.umd.js +14 -0
  252. package/skills/impeccable/scripts/palette.mjs +633 -0
  253. package/skills/impeccable/scripts/pin.mjs +214 -0
  254. package/skills/uipm-ui-styling/LICENSE.txt +202 -0
  255. package/skills/uipm-ui-styling/SKILL.md +328 -0
  256. package/skills/uipm-ui-styling/canvas-fonts/ArsenalSC-OFL.txt +93 -0
  257. package/skills/uipm-ui-styling/canvas-fonts/ArsenalSC-Regular.ttf +0 -0
  258. package/skills/uipm-ui-styling/canvas-fonts/BigShoulders-Bold.ttf +0 -0
  259. package/skills/uipm-ui-styling/canvas-fonts/BigShoulders-OFL.txt +93 -0
  260. package/skills/uipm-ui-styling/canvas-fonts/BigShoulders-Regular.ttf +0 -0
  261. package/skills/uipm-ui-styling/canvas-fonts/Boldonse-OFL.txt +93 -0
  262. package/skills/uipm-ui-styling/canvas-fonts/Boldonse-Regular.ttf +0 -0
  263. package/skills/uipm-ui-styling/canvas-fonts/BricolageGrotesque-Bold.ttf +0 -0
  264. package/skills/uipm-ui-styling/canvas-fonts/BricolageGrotesque-OFL.txt +93 -0
  265. package/skills/uipm-ui-styling/canvas-fonts/BricolageGrotesque-Regular.ttf +0 -0
  266. package/skills/uipm-ui-styling/canvas-fonts/CrimsonPro-Bold.ttf +0 -0
  267. package/skills/uipm-ui-styling/canvas-fonts/CrimsonPro-Italic.ttf +0 -0
  268. package/skills/uipm-ui-styling/canvas-fonts/CrimsonPro-OFL.txt +93 -0
  269. package/skills/uipm-ui-styling/canvas-fonts/CrimsonPro-Regular.ttf +0 -0
  270. package/skills/uipm-ui-styling/canvas-fonts/DMMono-OFL.txt +93 -0
  271. package/skills/uipm-ui-styling/canvas-fonts/DMMono-Regular.ttf +0 -0
  272. package/skills/uipm-ui-styling/canvas-fonts/EricaOne-OFL.txt +94 -0
  273. package/skills/uipm-ui-styling/canvas-fonts/EricaOne-Regular.ttf +0 -0
  274. package/skills/uipm-ui-styling/canvas-fonts/GeistMono-Bold.ttf +0 -0
  275. package/skills/uipm-ui-styling/canvas-fonts/GeistMono-OFL.txt +93 -0
  276. package/skills/uipm-ui-styling/canvas-fonts/GeistMono-Regular.ttf +0 -0
  277. package/skills/uipm-ui-styling/canvas-fonts/Gloock-OFL.txt +93 -0
  278. package/skills/uipm-ui-styling/canvas-fonts/Gloock-Regular.ttf +0 -0
  279. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexMono-Bold.ttf +0 -0
  280. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexMono-OFL.txt +93 -0
  281. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexMono-Regular.ttf +0 -0
  282. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexSerif-Bold.ttf +0 -0
  283. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexSerif-BoldItalic.ttf +0 -0
  284. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexSerif-Italic.ttf +0 -0
  285. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexSerif-Regular.ttf +0 -0
  286. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSans-Bold.ttf +0 -0
  287. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSans-BoldItalic.ttf +0 -0
  288. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSans-Italic.ttf +0 -0
  289. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSans-OFL.txt +93 -0
  290. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSans-Regular.ttf +0 -0
  291. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSerif-Italic.ttf +0 -0
  292. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSerif-Regular.ttf +0 -0
  293. package/skills/uipm-ui-styling/canvas-fonts/Italiana-OFL.txt +93 -0
  294. package/skills/uipm-ui-styling/canvas-fonts/Italiana-Regular.ttf +0 -0
  295. package/skills/uipm-ui-styling/canvas-fonts/JetBrainsMono-Bold.ttf +0 -0
  296. package/skills/uipm-ui-styling/canvas-fonts/JetBrainsMono-OFL.txt +93 -0
  297. package/skills/uipm-ui-styling/canvas-fonts/JetBrainsMono-Regular.ttf +0 -0
  298. package/skills/uipm-ui-styling/canvas-fonts/Jura-Light.ttf +0 -0
  299. package/skills/uipm-ui-styling/canvas-fonts/Jura-Medium.ttf +0 -0
  300. package/skills/uipm-ui-styling/canvas-fonts/Jura-OFL.txt +93 -0
  301. package/skills/uipm-ui-styling/canvas-fonts/LibreBaskerville-OFL.txt +93 -0
  302. package/skills/uipm-ui-styling/canvas-fonts/LibreBaskerville-Regular.ttf +0 -0
  303. package/skills/uipm-ui-styling/canvas-fonts/Lora-Bold.ttf +0 -0
  304. package/skills/uipm-ui-styling/canvas-fonts/Lora-BoldItalic.ttf +0 -0
  305. package/skills/uipm-ui-styling/canvas-fonts/Lora-Italic.ttf +0 -0
  306. package/skills/uipm-ui-styling/canvas-fonts/Lora-OFL.txt +93 -0
  307. package/skills/uipm-ui-styling/canvas-fonts/Lora-Regular.ttf +0 -0
  308. package/skills/uipm-ui-styling/canvas-fonts/NationalPark-Bold.ttf +0 -0
  309. package/skills/uipm-ui-styling/canvas-fonts/NationalPark-OFL.txt +93 -0
  310. package/skills/uipm-ui-styling/canvas-fonts/NationalPark-Regular.ttf +0 -0
  311. package/skills/uipm-ui-styling/canvas-fonts/NothingYouCouldDo-OFL.txt +93 -0
  312. package/skills/uipm-ui-styling/canvas-fonts/NothingYouCouldDo-Regular.ttf +0 -0
  313. package/skills/uipm-ui-styling/canvas-fonts/Outfit-Bold.ttf +0 -0
  314. package/skills/uipm-ui-styling/canvas-fonts/Outfit-OFL.txt +93 -0
  315. package/skills/uipm-ui-styling/canvas-fonts/Outfit-Regular.ttf +0 -0
  316. package/skills/uipm-ui-styling/canvas-fonts/PixelifySans-Medium.ttf +0 -0
  317. package/skills/uipm-ui-styling/canvas-fonts/PixelifySans-OFL.txt +93 -0
  318. package/skills/uipm-ui-styling/canvas-fonts/PoiretOne-OFL.txt +93 -0
  319. package/skills/uipm-ui-styling/canvas-fonts/PoiretOne-Regular.ttf +0 -0
  320. package/skills/uipm-ui-styling/canvas-fonts/RedHatMono-Bold.ttf +0 -0
  321. package/skills/uipm-ui-styling/canvas-fonts/RedHatMono-OFL.txt +93 -0
  322. package/skills/uipm-ui-styling/canvas-fonts/RedHatMono-Regular.ttf +0 -0
  323. package/skills/uipm-ui-styling/canvas-fonts/Silkscreen-OFL.txt +93 -0
  324. package/skills/uipm-ui-styling/canvas-fonts/Silkscreen-Regular.ttf +0 -0
  325. package/skills/uipm-ui-styling/canvas-fonts/SmoochSans-Medium.ttf +0 -0
  326. package/skills/uipm-ui-styling/canvas-fonts/SmoochSans-OFL.txt +93 -0
  327. package/skills/uipm-ui-styling/canvas-fonts/Tektur-Medium.ttf +0 -0
  328. package/skills/uipm-ui-styling/canvas-fonts/Tektur-OFL.txt +93 -0
  329. package/skills/uipm-ui-styling/canvas-fonts/Tektur-Regular.ttf +0 -0
  330. package/skills/uipm-ui-styling/canvas-fonts/WorkSans-Bold.ttf +0 -0
  331. package/skills/uipm-ui-styling/canvas-fonts/WorkSans-BoldItalic.ttf +0 -0
  332. package/skills/uipm-ui-styling/canvas-fonts/WorkSans-Italic.ttf +0 -0
  333. package/skills/uipm-ui-styling/canvas-fonts/WorkSans-OFL.txt +93 -0
  334. package/skills/uipm-ui-styling/canvas-fonts/WorkSans-Regular.ttf +0 -0
  335. package/skills/uipm-ui-styling/canvas-fonts/YoungSerif-OFL.txt +93 -0
  336. package/skills/uipm-ui-styling/canvas-fonts/YoungSerif-Regular.ttf +0 -0
  337. package/skills/uipm-ui-styling/references/canvas-design-system.md +320 -0
  338. package/skills/uipm-ui-styling/references/shadcn-accessibility.md +471 -0
  339. package/skills/uipm-ui-styling/references/shadcn-components.md +424 -0
  340. package/skills/uipm-ui-styling/references/shadcn-theming.md +373 -0
  341. package/skills/uipm-ui-styling/references/tailwind-customization.md +483 -0
  342. package/skills/uipm-ui-styling/references/tailwind-responsive.md +382 -0
  343. package/skills/uipm-ui-styling/references/tailwind-utilities.md +455 -0
  344. package/skills/uipm-ui-styling/scripts/.coverage +0 -0
  345. package/skills/uipm-ui-styling/scripts/requirements.txt +17 -0
  346. package/skills/uipm-ui-styling/scripts/shadcn_add.py +292 -0
  347. package/skills/uipm-ui-styling/scripts/tailwind_config_gen.py +456 -0
  348. package/skills/uipm-ui-styling/scripts/tests/coverage-ui.json +1 -0
  349. package/skills/uipm-ui-styling/scripts/tests/requirements.txt +3 -0
  350. package/skills/uipm-ui-styling/scripts/tests/test_shadcn_add.py +266 -0
  351. package/skills/uipm-ui-styling/scripts/tests/test_tailwind_config_gen.py +336 -0
@@ -0,0 +1,4582 @@
1
+ """graphify CLI - `graphify install` sets up the Claude Code skill."""
2
+
3
+ from __future__ import annotations
4
+ import functools
5
+ import json
6
+ import os
7
+ import platform
8
+ import re
9
+ import shutil
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ try:
14
+ from importlib.metadata import version as _pkg_version
15
+
16
+ __version__ = _pkg_version("graphifyy")
17
+ except Exception:
18
+ __version__ = "unknown"
19
+
20
+ # Output directory — override with GRAPHIFY_OUT env var for worktrees or shared-output setups.
21
+ # Accepts a relative name ("graphify-out-feature") or an absolute path ("/shared/graphify-out").
22
+ _GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")
23
+
24
+
25
+ @functools.lru_cache(maxsize=None)
26
+ def _always_on(basename: str) -> str:
27
+ """Read a packaged always-on instruction block from graphify/always_on/.
28
+
29
+ The six always-on blocks (CLAUDE.md / AGENTS.md / GEMINI.md / VS Code
30
+ Copilot instructions / Antigravity rules / Kiro steering) live as committed
31
+ markdown next to this module, generated by tools/skillgen from a single
32
+ human-edited fragment and guarded against drift by ``skillgen --check``. The
33
+ installer injects them verbatim via ``_replace_or_append_section``, so the
34
+ bytes here must match the former triple-quoted constant exactly — the
35
+ always-on-roundtrip validator proves that.
36
+ """
37
+ path = Path(__file__).parent / "always_on" / f"{basename}.md"
38
+ try:
39
+ return path.read_text(encoding="utf-8")
40
+ except OSError as exc:
41
+ # Defer to use-time so a missing/corrupt packaged block can't crash module
42
+ # import (which would brick every CLI command, not just install). Reached
43
+ # only by an install/integration path that actually needs this block.
44
+ raise RuntimeError(
45
+ f"graphify install is incomplete: missing always-on block '{basename}' "
46
+ f"at {path}. Reinstall graphifyy (e.g. `uv tool install --reinstall graphifyy`)."
47
+ ) from exc
48
+
49
+
50
+ _ALWAYS_ON_ALIASES = {
51
+ "_CLAUDE_MD_SECTION": "claude-md",
52
+ "_AGENTS_MD_SECTION": "agents-md",
53
+ "_GEMINI_MD_SECTION": "gemini-md",
54
+ "_VSCODE_INSTRUCTIONS_SECTION": "vscode-instructions",
55
+ "_ANTIGRAVITY_RULES": "antigravity-rules",
56
+ "_KIRO_STEERING": "kiro-steering",
57
+ }
58
+
59
+
60
+ def __getattr__(name: str) -> str:
61
+ # PEP 562: lazily resolve the legacy always-on section constants for external
62
+ # importers (e.g. the install-string tests). In-module code calls _always_on()
63
+ # directly; nothing is read at import time, so a missing block can no longer
64
+ # brick the CLI on `import graphify.__main__` (#1121 follow-up).
65
+ base = _ALWAYS_ON_ALIASES.get(name)
66
+ if base is not None:
67
+ return _always_on(base)
68
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
69
+
70
+
71
+ def _default_graph_path() -> str:
72
+ return str(Path(_GRAPHIFY_OUT) / "graph.json")
73
+
74
+
75
+ def _enforce_graph_size_cap_or_exit(gp: Path) -> None:
76
+ """Reject oversized graph files before parsing (CLI exit-on-fail flavor).
77
+
78
+ Delegates to ``graphify.security.check_graph_file_size_cap`` and turns the
79
+ raised ``ValueError`` into a CLI-style ``error: ...`` message + exit 1.
80
+ Use this from ``__main__.py`` subcommands that already use the ``print +
81
+ sys.exit(1)`` idiom. Library/MCP/loader callers (``serve._load_graph``,
82
+ ``build``, ``benchmark``, ``tree_html``, ``callflow_html``, ``prs``,
83
+ ``global_graph``, ``watch``, ``export``) call the security helper directly
84
+ and let the ``ValueError`` propagate.
85
+ """
86
+ from graphify.security import check_graph_file_size_cap
87
+ try:
88
+ check_graph_file_size_cap(gp)
89
+ except ValueError as exc:
90
+ print(f"error: {exc}", file=sys.stderr)
91
+ sys.exit(1)
92
+
93
+
94
+ def _check_skill_version(skill_dst: Path) -> None:
95
+ """Warn if the installed skill is from an older graphify version."""
96
+ version_file = skill_dst.parent / ".graphify_version"
97
+ if not version_file.exists():
98
+ return
99
+ if not skill_dst.exists():
100
+ print(" warning: skill dir exists but SKILL.md is missing. Run 'graphify install' to repair.")
101
+ return
102
+ # A progressive SKILL.md links to its references/ sidecar. If the body points
103
+ # at references/ but the dir is gone (manual delete, partial upgrade), the
104
+ # on-demand fragments won't load — flag it for repair.
105
+ try:
106
+ body = skill_dst.read_text(encoding="utf-8")
107
+ except OSError:
108
+ body = ""
109
+ if "references/" in body and not (skill_dst.parent / "references").exists():
110
+ print(" warning: skill references/ sidecar is missing. Run 'graphify install' to repair.", file=sys.stderr)
111
+ installed = version_file.read_text(encoding="utf-8").strip()
112
+ if installed != __version__:
113
+ print(f" warning: skill is from graphify {installed}, package is {__version__}. Run 'graphify install' to update.", file=sys.stderr)
114
+
115
+
116
+ def _refresh_all_version_stamps() -> None:
117
+ """After a successful install, update .graphify_version in all other known skill dirs.
118
+
119
+ Prevents stale-version warnings from platforms that were installed previously
120
+ but not explicitly re-installed during this upgrade.
121
+ """
122
+ for name in _PLATFORM_CONFIG:
123
+ skill_dst = _platform_skill_destination(name)
124
+ vf = skill_dst.parent / ".graphify_version"
125
+ if skill_dst.exists():
126
+ vf.write_text(__version__, encoding="utf-8")
127
+
128
+
129
+ def _platform_skill_destination(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path:
130
+ """Return the skill destination for a platform and scope."""
131
+ if platform_name == "gemini":
132
+ if project:
133
+ return (project_dir or Path(".")) / ".gemini" / "skills" / "graphify" / "SKILL.md"
134
+ if platform.system() == "Windows":
135
+ return Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md"
136
+ return Path.home() / ".gemini" / "skills" / "graphify" / "SKILL.md"
137
+
138
+ if platform_name == "opencode":
139
+ if project:
140
+ return (project_dir or Path(".")) / ".opencode" / "skills" / "graphify" / "SKILL.md"
141
+ return Path.home() / ".config" / "opencode" / "skills" / "graphify" / "SKILL.md"
142
+
143
+ if platform_name == "devin":
144
+ if project:
145
+ return (project_dir or Path(".")) / ".devin" / "skills" / "graphify" / "SKILL.md"
146
+ return Path.home() / ".config" / "devin" / "skills" / "graphify" / "SKILL.md"
147
+
148
+ if platform_name == "amp":
149
+ if project:
150
+ return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
151
+ return Path.home() / ".config" / "agents" / "skills" / "graphify" / "SKILL.md"
152
+
153
+ if platform_name in ("antigravity", "antigravity-windows"):
154
+ if project:
155
+ return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
156
+ # Global Antigravity skill dir (all workspaces): ~/.gemini/config/skills/
157
+ return Path.home() / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md"
158
+
159
+ cfg = _PLATFORM_CONFIG[platform_name]
160
+ if project:
161
+ return (project_dir or Path(".")) / cfg["skill_dst"]
162
+
163
+ if platform_name in ("claude", "windows") and os.environ.get("CLAUDE_CONFIG_DIR"):
164
+ return Path(os.environ["CLAUDE_CONFIG_DIR"]) / "skills" / "graphify" / "SKILL.md"
165
+ return Path.home() / cfg["skill_dst"]
166
+
167
+
168
+ def _packaged_skill_refs_dir(platform_name: str) -> Path | None:
169
+ """Return the packaged references source dir for a progressive platform, else None.
170
+
171
+ A platform opts into progressive disclosure by setting ``skill_refs`` in its
172
+ ``_PLATFORM_CONFIG`` entry. The value names a bundle under
173
+ ``graphify/skills/<bundle>/references/``. Reuse keys (e.g. trae-cn) point at
174
+ their twin's bundle.
175
+
176
+ ``gemini`` has no ``_PLATFORM_CONFIG`` entry: it installs claude's
177
+ ``skill.md`` body verbatim (see ``_copy_skill_file``). Since that body is the
178
+ lean progressive core that links to ``references/``, gemini needs claude's
179
+ references/ sidecar too, or its SKILL.md ships with dead pointers. So gemini
180
+ resolves to the claude bundle rather than opting out.
181
+
182
+ Bundles ship one platform-group at a time. A host whose bundle directory
183
+ ``graphify/skills/<bundle>/`` is not in this build has not gone progressive
184
+ yet, so this returns None and the host installs today's monolithic SKILL.md
185
+ with no references/ sidecar. Only when the bundle directory IS present does
186
+ this return the references path; if that directory then lacks its
187
+ ``references/`` subdir, ``_copy_skill_file`` hard-fails (a malformed bundle,
188
+ the empty-sidecar regression the wheel-content test also guards).
189
+ """
190
+ if platform_name == "gemini":
191
+ bundle = "claude"
192
+ else:
193
+ bundle = _PLATFORM_CONFIG[platform_name].get("skill_refs")
194
+ if not bundle:
195
+ return None
196
+ bundle_dir = Path(__file__).parent / "skills" / bundle
197
+ if not bundle_dir.is_dir():
198
+ return None
199
+ return bundle_dir / "references"
200
+
201
+
202
+ def _install_skill_references(skill_dst: Path, refs_src: Path) -> None:
203
+ """Atomically install a packaged references/ sidecar next to SKILL.md.
204
+
205
+ Stages the packaged dir into ``references.tmp`` (copytree), drops any stale
206
+ ``references/`` already on disk, then ``os.replace``-renames the staged dir
207
+ into place. The rename is atomic on the same filesystem, so an interrupted
208
+ install never leaves a half-written references/ visible to the agent.
209
+ """
210
+ refs_dst = skill_dst.parent / "references"
211
+ refs_staged = skill_dst.parent / "references.tmp"
212
+ if refs_staged.exists():
213
+ shutil.rmtree(refs_staged)
214
+ try:
215
+ shutil.copytree(refs_src, refs_staged)
216
+ if refs_dst.exists():
217
+ shutil.rmtree(refs_dst)
218
+ os.replace(refs_staged, refs_dst)
219
+ except Exception:
220
+ if refs_staged.exists():
221
+ shutil.rmtree(refs_staged, ignore_errors=True)
222
+ raise
223
+
224
+
225
+ def _copy_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path:
226
+ """Copy a packaged skill file and write its version stamp.
227
+
228
+ For progressive platforms (those with ``skill_refs`` set), the packaged
229
+ ``references/`` sidecar is installed alongside SKILL.md and the single
230
+ ``.graphify_version`` stamp covers both. For monolith platforms (no
231
+ ``skill_refs``), any orphan ``references/`` left by a prior progressive
232
+ install is removed so the on-disk layout matches the package.
233
+ """
234
+ skill_file = "skill.md" if platform_name == "gemini" else _PLATFORM_CONFIG[platform_name]["skill_file"]
235
+ skill_src = Path(__file__).parent / skill_file
236
+ if not skill_src.exists():
237
+ print(f"error: {skill_file} not found in package - reinstall graphify", file=sys.stderr)
238
+ sys.exit(1)
239
+
240
+ refs_src = _packaged_skill_refs_dir(platform_name)
241
+ if refs_src is not None and not refs_src.exists():
242
+ # Progressive platform declared a references bundle that is missing from
243
+ # the package. Fail loud rather than silently shipping an empty sidecar.
244
+ print(
245
+ f"error: references for '{platform_name}' not found in package "
246
+ f"({refs_src}) - reinstall graphify",
247
+ file=sys.stderr,
248
+ )
249
+ sys.exit(1)
250
+
251
+ skill_dst = _platform_skill_destination(platform_name, project=project, project_dir=project_dir)
252
+ skill_dst.parent.mkdir(parents=True, exist_ok=True)
253
+
254
+ # Install the references/ sidecar (or clear an orphan one) BEFORE writing
255
+ # SKILL.md, so SKILL.md is the last artifact laid down. An install that is
256
+ # interrupted partway then leaves no SKILL.md rather than a SKILL.md that
257
+ # points at an absent references/ dir.
258
+ if refs_src is not None:
259
+ _install_skill_references(skill_dst, refs_src)
260
+ print(f" references -> {skill_dst.parent / 'references'}")
261
+ else:
262
+ # Monolith (or progressive-with-no-refs): clear any orphan references/.
263
+ orphan_refs = skill_dst.parent / "references"
264
+ if orphan_refs.exists():
265
+ shutil.rmtree(orphan_refs)
266
+
267
+ # SKILL.md last (crash-safety), via an atomic temp + rename.
268
+ tmp_dst = skill_dst.with_suffix(skill_dst.suffix + ".tmp")
269
+ try:
270
+ shutil.copy(skill_src, tmp_dst)
271
+ os.replace(tmp_dst, skill_dst)
272
+ except Exception:
273
+ try:
274
+ tmp_dst.unlink(missing_ok=True)
275
+ except OSError:
276
+ pass
277
+ raise
278
+
279
+ (skill_dst.parent / ".graphify_version").write_text(__version__, encoding="utf-8")
280
+ print(f" skill installed -> {skill_dst}")
281
+ return skill_dst
282
+
283
+
284
+ def _remove_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> bool:
285
+ """Remove a platform skill file and its version stamp without touching other scopes."""
286
+ skill_dst = _platform_skill_destination(platform_name, project=project, project_dir=project_dir)
287
+ removed = False
288
+ if skill_dst.exists():
289
+ skill_dst.unlink()
290
+ print(f" skill removed -> {skill_dst}")
291
+ removed = True
292
+ version_file = skill_dst.parent / ".graphify_version"
293
+ if version_file.exists():
294
+ version_file.unlink()
295
+ removed = True
296
+ refs_dir = skill_dst.parent / "references"
297
+ if refs_dir.exists():
298
+ shutil.rmtree(refs_dir)
299
+ removed = True
300
+ for d in (skill_dst.parent, skill_dst.parent.parent, skill_dst.parent.parent.parent):
301
+ try:
302
+ d.rmdir()
303
+ except OSError:
304
+ break
305
+ return removed
306
+
307
+
308
+ def _project_scope_root(path: Path, project_dir: Path) -> Path:
309
+ """Return the top-level project artifact for a project-scoped skill path."""
310
+ try:
311
+ rel = path.relative_to(project_dir)
312
+ except ValueError:
313
+ return path
314
+ return project_dir / rel.parts[0] if rel.parts else path
315
+
316
+
317
+ def _remove_claude_skill_registration(project_dir: Path) -> None:
318
+ """Remove the project-scoped Claude skill registration file/section."""
319
+ claude_md = project_dir / ".claude" / "CLAUDE.md"
320
+ if not claude_md.exists():
321
+ return
322
+ content = claude_md.read_text(encoding="utf-8")
323
+ if "# graphify" not in content:
324
+ return
325
+ cleaned = re.sub(r"\n*# graphify\n.*?(?=\n# |\Z)", "", content, flags=re.DOTALL).rstrip()
326
+ if cleaned:
327
+ claude_md.write_text(cleaned + "\n", encoding="utf-8")
328
+ print(f" CLAUDE.md -> graphify skill registration removed from {claude_md}")
329
+ else:
330
+ claude_md.unlink()
331
+ print(f" CLAUDE.md -> deleted {claude_md}")
332
+
333
+
334
+ def _print_project_git_add_hint(paths: list[Path]) -> None:
335
+ unique: list[str] = []
336
+ for path in paths:
337
+ text = path.as_posix().rstrip("/")
338
+ if path.exists() and path.is_dir():
339
+ text += "/"
340
+ if text not in unique:
341
+ unique.append(text)
342
+ if not unique:
343
+ return
344
+ print()
345
+ print("Project-scoped install. Add to version control:")
346
+ print(f" git add {' '.join(unique)}")
347
+
348
+ _SETTINGS_HOOK = {
349
+ # Claude Code v2.1.117+ removed dedicated Grep/Glob tools; searches now go through Bash.
350
+ # We match on Bash and inspect the command string to avoid firing on every shell call.
351
+ "matcher": "Bash",
352
+ "hooks": [
353
+ {
354
+ "type": "command",
355
+ "command": (
356
+ "CMD=$(python3 -c \""
357
+ "import json,sys; d=json.load(sys.stdin); "
358
+ "print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); "
359
+ "case \"$CMD\" in "
360
+ r"*grep*|*rg\ *|*ripgrep*|*find\ *|*fd\ *|*ack\ *|*ag\ *) "
361
+ " [ -f graphify-out/graph.json ] && "
362
+ r""" echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: knowledge graph at graphify-out/. For focused questions, run `graphify query \"<question>\"` (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context."}}' """
363
+ " || true ;; "
364
+ "esac"
365
+ ),
366
+ }
367
+ ],
368
+ }
369
+
370
+ _READ_SETTINGS_HOOK = {
371
+ # The Bash hook above never sees a file read through the native Read tool or a
372
+ # Glob, which is the most common way an agent skips the graph: answering a
373
+ # codebase question by Read-ing many source files one by one (issue #1114).
374
+ # Match Read|Glob, inspect the target path, and nudge (never block) only for a
375
+ # source/doc file outside graphify-out/ when a graph exists. The parser is
376
+ # python3 (already a graphify dependency), the shell is POSIX, and every branch
377
+ # fails open, so a legitimate read always goes through. Reading the graph's own
378
+ # report under graphify-out/ is suppressed so it never starts a feedback loop.
379
+ "matcher": "Read|Glob",
380
+ "hooks": [
381
+ {
382
+ "type": "command",
383
+ "command": (
384
+ "HIT=$(python3 -c \""
385
+ "import json,sys;"
386
+ "d=json.load(sys.stdin);"
387
+ "t=d.get('tool_input',d);"
388
+ "s=(str(t.get('file_path') or '')+' '+str(t.get('pattern') or '')+' '+str(t.get('path') or '')).lower().replace(chr(92),'/');"
389
+ "exts=('.py','.js','.ts','.tsx','.jsx','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');"
390
+ "sys.stdout.write('1' if 'graphify-out/' not in s and any(e in s for e in exts) else '')\" 2>/dev/null || true); "
391
+ "if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then "
392
+ r"""echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: knowledge graph at graphify-out/. For codebase questions, run `graphify query \"<question>\"` (scoped subgraph, usually much smaller than reading files one by one), `graphify explain \"<concept>\"`, or `graphify path \"<A>\" \"<B>\"`, instead of reading source files to answer. Read raw files to modify or debug specific code, or when the graph lacks the detail."}}'; """
393
+ "fi || true"
394
+ ),
395
+ }
396
+ ],
397
+ }
398
+
399
+ def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") -> str:
400
+ return (
401
+ "\n# graphify\n"
402
+ f"- **graphify** (`{skill_path}`) "
403
+ "- any input to knowledge graph. Trigger: `/graphify`\n"
404
+ "When the user types `/graphify`, invoke the Skill tool "
405
+ "with `skill: \"graphify\"` before doing anything else.\n"
406
+ )
407
+
408
+
409
+ _PLATFORM_CONFIG: dict[str, dict] = {
410
+ "claude": {
411
+ "skill_file": "skill.md",
412
+ "skill_dst": Path(".claude") / "skills" / "graphify" / "SKILL.md",
413
+ "claude_md": True,
414
+ "skill_refs": "claude",
415
+ },
416
+ "codex": {
417
+ "skill_file": "skill-codex.md",
418
+ "skill_dst": Path(".codex") / "skills" / "graphify" / "SKILL.md",
419
+ "claude_md": False,
420
+ "skill_refs": "codex",
421
+ },
422
+ "opencode": {
423
+ "skill_file": "skill-opencode.md",
424
+ "skill_dst": Path(".config") / "opencode" / "skills" / "graphify" / "SKILL.md",
425
+ "claude_md": False,
426
+ "skill_refs": "opencode",
427
+ },
428
+ "kilo": {
429
+ "skill_file": "skill-kilo.md",
430
+ "skill_dst": Path(".config") / "kilo" / "skills" / "graphify" / "SKILL.md",
431
+ "claude_md": False,
432
+ "skill_refs": "kilo",
433
+ },
434
+ "aider": {
435
+ # Monolith: aider ships the full SKILL.md inline, no references/ sidecar.
436
+ "skill_file": "skill-aider.md",
437
+ "skill_dst": Path(".aider") / "graphify" / "SKILL.md",
438
+ "claude_md": False,
439
+ },
440
+ "copilot": {
441
+ "skill_file": "skill-copilot.md",
442
+ "skill_dst": Path(".copilot") / "skills" / "graphify" / "SKILL.md",
443
+ "claude_md": False,
444
+ "skill_refs": "copilot",
445
+ },
446
+ "claw": {
447
+ "skill_file": "skill-claw.md",
448
+ "skill_dst": Path(".openclaw") / "skills" / "graphify" / "SKILL.md",
449
+ "claude_md": False,
450
+ "skill_refs": "claw",
451
+ },
452
+ "droid": {
453
+ "skill_file": "skill-droid.md",
454
+ "skill_dst": Path(".factory") / "skills" / "graphify" / "SKILL.md",
455
+ "claude_md": False,
456
+ "skill_refs": "droid",
457
+ },
458
+ "trae": {
459
+ "skill_file": "skill-trae.md",
460
+ "skill_dst": Path(".trae") / "skills" / "graphify" / "SKILL.md",
461
+ "claude_md": False,
462
+ "skill_refs": "trae",
463
+ },
464
+ "trae-cn": {
465
+ # Reuses trae's split bundle (same skill body + references).
466
+ "skill_file": "skill-trae.md",
467
+ "skill_dst": Path(".trae-cn") / "skills" / "graphify" / "SKILL.md",
468
+ "claude_md": False,
469
+ "skill_refs": "trae",
470
+ },
471
+ "hermes": {
472
+ # Reuses claw's split bundle.
473
+ "skill_file": "skill-claw.md",
474
+ "skill_dst": Path(".hermes") / "skills" / "graphify" / "SKILL.md",
475
+ "claude_md": False,
476
+ "skill_refs": "claw",
477
+ },
478
+ "kiro": {
479
+ "skill_file": "skill-kiro.md",
480
+ "skill_dst": Path(".kiro") / "skills" / "graphify" / "SKILL.md",
481
+ "claude_md": False,
482
+ "skill_refs": "kiro",
483
+ },
484
+ "pi": {
485
+ "skill_file": "skill-pi.md",
486
+ "skill_dst": Path(".pi") / "agent" / "skills" / "graphify" / "SKILL.md",
487
+ "claude_md": False,
488
+ "skill_refs": "pi",
489
+ },
490
+ "codebuddy": {
491
+ # Reuses claude's split bundle (shares skill.md).
492
+ "skill_file": "skill.md",
493
+ "skill_dst": Path(".codebuddy") / "skills" / "graphify" / "SKILL.md",
494
+ "claude_md": False,
495
+ "skill_refs": "claude",
496
+ },
497
+ "antigravity": {
498
+ # Rides claude's split bundle (shares skill.md).
499
+ "skill_file": "skill.md",
500
+ "skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
501
+ "claude_md": False,
502
+ "skill_refs": "claude",
503
+ },
504
+ "antigravity-windows": {
505
+ # Rides windows' split bundle.
506
+ "skill_file": "skill-windows.md",
507
+ "skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
508
+ "claude_md": False,
509
+ "skill_refs": "windows",
510
+ },
511
+ "windows": {
512
+ "skill_file": "skill-windows.md",
513
+ "skill_dst": Path(".claude") / "skills" / "graphify" / "SKILL.md",
514
+ "claude_md": True,
515
+ "skill_refs": "windows",
516
+ },
517
+ "kimi": {
518
+ # Reuses claude's split bundle (shares skill.md).
519
+ "skill_file": "skill.md",
520
+ "skill_dst": Path(".kimi") / "skills" / "graphify" / "SKILL.md",
521
+ "claude_md": False,
522
+ "skill_refs": "claude",
523
+ },
524
+ "amp": {
525
+ # Amp searches .agents/skills (project) and ~/.config/agents/skills (user),
526
+ # not .amp/skills. The user-scope path is set in _platform_skill_destination.
527
+ "skill_file": "skill-amp.md",
528
+ "skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
529
+ "claude_md": False,
530
+ "skill_refs": "amp",
531
+ },
532
+ "devin": {
533
+ # Monolith: devin ships the full SKILL.md inline, no references/ sidecar.
534
+ "skill_file": "skill-devin.md",
535
+ # User scope: ~/.config/devin/skills/graphify/SKILL.md
536
+ # Project scope: .devin/skills/graphify/SKILL.md (overridden in _platform_skill_destination)
537
+ "skill_dst": Path(".config") / "devin" / "skills" / "graphify" / "SKILL.md",
538
+ "claude_md": False,
539
+ },
540
+ }
541
+
542
+
543
+ def _replace_or_append_section(content: str, marker: str, new_section: str) -> str:
544
+ """Idempotently update or append a graphify-owned section in shared files.
545
+
546
+ If ``marker`` is not in ``content``, append ``new_section`` to the end
547
+ (with a blank-line separator if there's existing content).
548
+
549
+ If ``marker`` IS in ``content``, replace the existing section in place.
550
+ The section runs from the first line containing ``marker`` to the line
551
+ before the next H2 heading (``## `` at line start), or to EOF if no later
552
+ H2 exists. This lets older installs receive the updated copy without
553
+ users having to uninstall and reinstall — important for the issue #580
554
+ fix where existing report-first text would otherwise silently linger.
555
+ """
556
+ if marker not in content:
557
+ if content.strip():
558
+ return content.rstrip() + "\n\n" + new_section.lstrip()
559
+ return new_section.lstrip()
560
+
561
+ lines = content.split("\n")
562
+ start = next((i for i, line in enumerate(lines) if marker in line), None)
563
+ if start is None:
564
+ return content.rstrip() + "\n\n" + new_section.lstrip()
565
+
566
+ end = len(lines)
567
+ for j in range(start + 1, len(lines)):
568
+ if lines[j].startswith("## "):
569
+ end = j
570
+ break
571
+
572
+ head = "\n".join(lines[:start]).rstrip()
573
+ tail = "\n".join(lines[end:]).lstrip()
574
+ section = new_section.strip()
575
+
576
+ parts: list[str] = []
577
+ if head:
578
+ parts.append(head)
579
+ parts.append(section)
580
+ if tail:
581
+ parts.append(tail)
582
+ out = "\n\n".join(parts)
583
+ if not out.endswith("\n"):
584
+ out += "\n"
585
+ return out
586
+
587
+
588
+ def _print_banner() -> None:
589
+ """Amber brain banner on graphify install. TTY-only, never raises."""
590
+ if not sys.stdout.isatty():
591
+ return
592
+ try:
593
+ if sys.platform == "win32":
594
+ import ctypes
595
+ ctypes.windll.kernel32.SetConsoleMode(
596
+ ctypes.windll.kernel32.GetStdHandle(-11), 7
597
+ )
598
+ A = "\033[38;5;214m"
599
+ D = "\033[38;5;130m"
600
+ R = "\033[0m"
601
+ print(f"""{A}
602
+ ╭──◉──╮ ╭──◉──╮
603
+ ╱ ◉ ◉ ╲ ╱ ◉ ◉ ╲
604
+ │ ◉─◉─◉ ◉ ◉─◉─◉ │
605
+ │ ◉ ◉ │ ◉ ◉ │
606
+ │ ◉─◉─◉ ◉ ◉─◉─◉ │
607
+ ╲ ◉ ◉ ╱ ╲ ◉ ◉ ╱
608
+ ╰──◉──╯ ╰──◉──╯
609
+
610
+
611
+ █▀▀ █▀█ ▄▀█ █▀█ █ █ █ █▀▀ █▄█
612
+ █▄█ █▀▄ █▀█ █▀▀ █▀█ █ █▀ █{D} {__version__}{R}
613
+ """)
614
+ except Exception:
615
+ pass
616
+
617
+
618
+ def install(platform: str = "claude", *, project: bool = False, project_dir: Path | None = None) -> None:
619
+ _print_banner()
620
+ if platform == "gemini":
621
+ gemini_install(project_dir=project_dir, project=project)
622
+ return
623
+ if platform == "cursor":
624
+ _cursor_install(Path("."))
625
+ return
626
+ # On Windows, antigravity needs the PowerShell skill, not the bash one
627
+ if platform == "antigravity" and sys.platform == "win32":
628
+ platform = "antigravity-windows"
629
+ if platform not in _PLATFORM_CONFIG:
630
+ print(
631
+ f"error: unknown platform '{platform}'. Choose from: {', '.join(_PLATFORM_CONFIG)}, gemini, cursor",
632
+ file=sys.stderr,
633
+ )
634
+ sys.exit(1)
635
+
636
+ cfg = _PLATFORM_CONFIG[platform]
637
+ project_dir = project_dir or Path(".")
638
+ skill_dst = _copy_skill_file(platform, project=project, project_dir=project_dir)
639
+
640
+ if platform == "kilo":
641
+ # Kilo Code also supports a native /graphify command file.
642
+ command_src = Path(__file__).parent / "command-kilo.md"
643
+ if not command_src.exists():
644
+ print(
645
+ f"error: command-kilo.md not found in package - reinstall graphify",
646
+ file=sys.stderr,
647
+ )
648
+ sys.exit(1)
649
+ command_dst = Path.home() / ".config" / "kilo" / "command" / "graphify.md"
650
+ command_dst.parent.mkdir(parents=True, exist_ok=True)
651
+ shutil.copy(command_src, command_dst)
652
+ print(f" command installed -> {command_dst}")
653
+
654
+ if cfg["claude_md"]:
655
+ # Register in the matching Claude Code scope.
656
+ claude_md = (project_dir / ".claude" / "CLAUDE.md") if project else Path.home() / ".claude" / "CLAUDE.md"
657
+ registration = _skill_registration(".claude/skills/graphify/SKILL.md" if project else "~/.claude/skills/graphify/SKILL.md")
658
+ if claude_md.exists():
659
+ content = claude_md.read_text(encoding="utf-8")
660
+ if "graphify" in content:
661
+ print(f" CLAUDE.md -> already registered (no change)")
662
+ else:
663
+ claude_md.write_text(content.rstrip() + registration, encoding="utf-8")
664
+ print(f" CLAUDE.md -> skill registered in {claude_md}")
665
+ else:
666
+ claude_md.parent.mkdir(parents=True, exist_ok=True)
667
+ claude_md.write_text(registration.lstrip(), encoding="utf-8")
668
+ print(f" CLAUDE.md -> created at {claude_md}")
669
+
670
+ if platform == "codebuddy":
671
+ # Register in ~/.codebuddy/CODEBUDDY.md (CodeBuddy only)
672
+ codebuddy_md = Path.home() / ".codebuddy" / "CODEBUDDY.md"
673
+ registration = _skill_registration("~/.codebuddy/skills/graphify/SKILL.md")
674
+ if codebuddy_md.exists():
675
+ content = codebuddy_md.read_text(encoding="utf-8")
676
+ if "graphify" in content:
677
+ print(f" CODEBUDDY.md -> already registered (no change)")
678
+ else:
679
+ codebuddy_md.write_text(content.rstrip() + registration, encoding="utf-8")
680
+ print(f" CODEBUDDY.md -> skill registered in {codebuddy_md}")
681
+ else:
682
+ codebuddy_md.parent.mkdir(parents=True, exist_ok=True)
683
+ codebuddy_md.write_text(registration.lstrip(), encoding="utf-8")
684
+ print(f" CODEBUDDY.md -> created at {codebuddy_md}")
685
+
686
+ if platform == "opencode":
687
+ _install_opencode_plugin(project_dir if project else Path("."))
688
+
689
+ # Refresh version stamps in all other previously-installed skill dirs so
690
+ # stale-version warnings don't fire for platforms not explicitly re-installed.
691
+ if project:
692
+ _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir)])
693
+ else:
694
+ _refresh_all_version_stamps()
695
+
696
+ print()
697
+ print("Done. Open your AI coding assistant and type:")
698
+ print()
699
+ print(" /graphify .")
700
+ print()
701
+
702
+
703
+ def _print_install_usage() -> None:
704
+ platforms = ", ".join([*_PLATFORM_CONFIG, "gemini", "cursor"])
705
+ print("Usage: graphify install [--project] [--platform P|P]")
706
+ print(f"Platforms: {platforms}")
707
+
708
+
709
+ # The always-on instruction blocks are packaged markdown under graphify/always_on/,
710
+ # generated by tools/skillgen and guarded by `skillgen --check`. Reading them at
711
+ # load keeps the install-string / issue-#580 contract byte-for-byte while letting
712
+ # a human edit one fragment instead of a triple-quoted literal here.
713
+
714
+ _CLAUDE_MD_MARKER = "## graphify"
715
+
716
+ _CODEBUDDY_MD_MARKER = "## graphify"
717
+
718
+ # AGENTS.md section for Codex, OpenCode, and OpenClaw.
719
+ # All three platforms read AGENTS.md in the project root for persistent instructions.
720
+
721
+ _AGENTS_MD_MARKER = "## graphify"
722
+
723
+
724
+ _GEMINI_MD_MARKER = "## graphify"
725
+
726
+ _GEMINI_HOOK = {
727
+ "matcher": "read_file|list_directory",
728
+ "hooks": [
729
+ {
730
+ "type": "command",
731
+ "command": (
732
+ 'python -c "'
733
+ "import sys,pathlib,json;"
734
+ "e=pathlib.Path('graphify-out/graph.json').exists();"
735
+ "d={'decision':'allow'};"
736
+ "e and d.update({'additionalContext':'graphify: knowledge graph at graphify-out/. For focused questions, run `graphify query \"<question>\"` (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context.'});"
737
+ "sys.stdout.write(json.dumps(d))"
738
+ '"'
739
+ ),
740
+ }
741
+ ],
742
+ }
743
+
744
+
745
+ def gemini_install(project_dir: Path | None = None, *, project: bool = False) -> None:
746
+ """Copy skill file, write GEMINI.md section, and install BeforeTool hook."""
747
+ project_dir = project_dir or Path(".")
748
+ skill_dst = _copy_skill_file("gemini", project=project, project_dir=project_dir)
749
+
750
+ target = project_dir / "GEMINI.md"
751
+
752
+ if target.exists():
753
+ content = target.read_text(encoding="utf-8")
754
+ new_content = _replace_or_append_section(
755
+ content, _GEMINI_MD_MARKER, _always_on("gemini-md")
756
+ )
757
+ else:
758
+ new_content = _always_on("gemini-md")
759
+
760
+ if target.exists() and new_content == target.read_text(encoding="utf-8"):
761
+ print(f"graphify already configured in {target.resolve()} (no change)")
762
+ else:
763
+ target.write_text(new_content, encoding="utf-8")
764
+ print(f"graphify section written to {target.resolve()}")
765
+
766
+ # Always re-install the Gemini hook so an older payload (e.g. pre-issue-#580
767
+ # wording) is replaced on upgrade.
768
+ _install_gemini_hook(project_dir)
769
+ if project:
770
+ _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / "GEMINI.md", project_dir / ".gemini"])
771
+ print()
772
+ print("Gemini CLI will now check the knowledge graph before answering")
773
+ print("codebase questions and rebuild it after code changes.")
774
+
775
+
776
+ def _install_gemini_hook(project_dir: Path) -> None:
777
+ settings_path = project_dir / ".gemini" / "settings.json"
778
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
779
+ try:
780
+ settings = (
781
+ json.loads(settings_path.read_text(encoding="utf-8"))
782
+ if settings_path.exists()
783
+ else {}
784
+ )
785
+ except json.JSONDecodeError:
786
+ settings = {}
787
+ before_tool = settings.setdefault("hooks", {}).setdefault("BeforeTool", [])
788
+ settings["hooks"]["BeforeTool"] = [
789
+ h for h in before_tool if "graphify" not in str(h)
790
+ ]
791
+ settings["hooks"]["BeforeTool"].append(_GEMINI_HOOK)
792
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
793
+ print(" .gemini/settings.json -> BeforeTool hook registered")
794
+
795
+
796
+ def _uninstall_gemini_hook(project_dir: Path) -> None:
797
+ settings_path = project_dir / ".gemini" / "settings.json"
798
+ if not settings_path.exists():
799
+ return
800
+ try:
801
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
802
+ except json.JSONDecodeError:
803
+ return
804
+ before_tool = settings.get("hooks", {}).get("BeforeTool", [])
805
+ filtered = [h for h in before_tool if "graphify" not in str(h)]
806
+ if len(filtered) == len(before_tool):
807
+ return
808
+ settings["hooks"]["BeforeTool"] = filtered
809
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
810
+ print(" .gemini/settings.json -> BeforeTool hook removed")
811
+
812
+
813
+ def gemini_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None:
814
+ """Remove the graphify section from GEMINI.md, uninstall hook, and remove skill file."""
815
+ project_dir = project_dir or Path(".")
816
+ _remove_skill_file("gemini", project=project, project_dir=project_dir)
817
+
818
+ target = project_dir / "GEMINI.md"
819
+ if not target.exists():
820
+ print("No GEMINI.md found in current directory - nothing to do")
821
+ return
822
+ content = target.read_text(encoding="utf-8")
823
+ if _GEMINI_MD_MARKER not in content:
824
+ print("graphify section not found in GEMINI.md - nothing to do")
825
+ return
826
+ cleaned = re.sub(
827
+ r"\n*## graphify\n.*?(?=\n## |\Z)", "", content, flags=re.DOTALL
828
+ ).rstrip()
829
+ if cleaned:
830
+ target.write_text(cleaned + "\n", encoding="utf-8")
831
+ print(f"graphify section removed from {target.resolve()}")
832
+ else:
833
+ target.unlink()
834
+ print(f"GEMINI.md was empty after removal - deleted {target.resolve()}")
835
+ _uninstall_gemini_hook(project_dir)
836
+
837
+
838
+ _VSCODE_INSTRUCTIONS_MARKER = "## graphify"
839
+
840
+
841
+ def vscode_install(project_dir: Path | None = None) -> None:
842
+ """Install graphify skill for VS Code Copilot Chat + write .github/copilot-instructions.md."""
843
+ skill_src = Path(__file__).parent / "skill-vscode.md"
844
+ refs_bundle = "vscode"
845
+ if not skill_src.exists():
846
+ skill_src = Path(__file__).parent / "skill-copilot.md"
847
+ refs_bundle = "copilot"
848
+ skill_dst = Path.home() / ".copilot" / "skills" / "graphify" / "SKILL.md"
849
+ skill_dst.parent.mkdir(parents=True, exist_ok=True)
850
+ tmp_dst = skill_dst.with_suffix(skill_dst.suffix + ".tmp")
851
+ try:
852
+ shutil.copy(skill_src, tmp_dst)
853
+ os.replace(tmp_dst, skill_dst)
854
+ except Exception:
855
+ try:
856
+ tmp_dst.unlink(missing_ok=True)
857
+ except OSError:
858
+ pass
859
+ raise
860
+ # Progressive-capable: install the packaged references/ sidecar when present.
861
+ refs_src = Path(__file__).parent / "skills" / refs_bundle / "references"
862
+ if refs_src.exists():
863
+ _install_skill_references(skill_dst, refs_src)
864
+ print(f" references -> {skill_dst.parent / 'references'}")
865
+ else:
866
+ orphan_refs = skill_dst.parent / "references"
867
+ if orphan_refs.exists():
868
+ shutil.rmtree(orphan_refs)
869
+ (skill_dst.parent / ".graphify_version").write_text(__version__, encoding="utf-8")
870
+ print(f" skill installed -> {skill_dst}")
871
+
872
+ instructions = (project_dir or Path(".")) / ".github" / "copilot-instructions.md"
873
+ instructions.parent.mkdir(parents=True, exist_ok=True)
874
+ if instructions.exists():
875
+ content = instructions.read_text(encoding="utf-8")
876
+ new_content = _replace_or_append_section(
877
+ content, _VSCODE_INSTRUCTIONS_MARKER, _always_on("vscode-instructions")
878
+ )
879
+ if new_content == content:
880
+ print(f" {instructions} -> already configured (no change)")
881
+ else:
882
+ instructions.write_text(new_content, encoding="utf-8")
883
+ print(f" {instructions} -> graphify section {'updated' if _VSCODE_INSTRUCTIONS_MARKER in content else 'added'}")
884
+ else:
885
+ instructions.write_text(_always_on("vscode-instructions"), encoding="utf-8")
886
+ print(f" {instructions} -> created")
887
+
888
+ print()
889
+ print(
890
+ "VS Code Copilot Chat configured. Type /graphify in the chat panel to build the graph."
891
+ )
892
+ print("Note: for GitHub Copilot CLI (terminal), use: graphify copilot install")
893
+
894
+
895
+ def vscode_uninstall(project_dir: Path | None = None) -> None:
896
+ """Remove graphify VS Code Copilot Chat skill and .github/copilot-instructions.md section."""
897
+ skill_dst = Path.home() / ".copilot" / "skills" / "graphify" / "SKILL.md"
898
+ if skill_dst.exists():
899
+ skill_dst.unlink()
900
+ print(f" skill removed -> {skill_dst}")
901
+ version_file = skill_dst.parent / ".graphify_version"
902
+ if version_file.exists():
903
+ version_file.unlink()
904
+ refs_dir = skill_dst.parent / "references"
905
+ if refs_dir.exists():
906
+ shutil.rmtree(refs_dir)
907
+ for d in (
908
+ skill_dst.parent,
909
+ skill_dst.parent.parent,
910
+ skill_dst.parent.parent.parent,
911
+ ):
912
+ try:
913
+ d.rmdir()
914
+ except OSError:
915
+ break
916
+
917
+ instructions = (project_dir or Path(".")) / ".github" / "copilot-instructions.md"
918
+ if not instructions.exists():
919
+ return
920
+ content = instructions.read_text(encoding="utf-8")
921
+ if _VSCODE_INSTRUCTIONS_MARKER not in content:
922
+ return
923
+ cleaned = re.sub(
924
+ r"\n*## graphify\n.*?(?=\n## |\Z)", "", content, flags=re.DOTALL
925
+ ).rstrip()
926
+ if cleaned:
927
+ instructions.write_text(cleaned + "\n", encoding="utf-8")
928
+ print(f" graphify section removed from {instructions}")
929
+ else:
930
+ instructions.unlink()
931
+ print(f" {instructions} -> deleted (was empty after removal)")
932
+
933
+
934
+ _ANTIGRAVITY_RULES_PATH = Path(".agents") / "rules" / "graphify.md"
935
+ _ANTIGRAVITY_WORKFLOW_PATH = Path(".agents") / "workflows" / "graphify.md"
936
+
937
+
938
+ _ANTIGRAVITY_WORKFLOW = """\
939
+ ---
940
+ name: graphify
941
+ description: Turn any folder of files into a navigable knowledge graph
942
+ ---
943
+
944
+ # Workflow: graphify
945
+
946
+ Follow the graphify skill installed at ~/.gemini/config/skills/graphify/SKILL.md to run the full pipeline.
947
+
948
+ If no path argument is given, use `.` (current directory).
949
+ """
950
+
951
+
952
+
953
+ _KIRO_STEERING_MARKER = "graphify: A knowledge graph of this project"
954
+
955
+
956
+ def _kiro_install(project_dir: Path) -> None:
957
+ """Write graphify skill + steering file for Kiro IDE/CLI."""
958
+ project_dir = project_dir or Path(".")
959
+
960
+ # Skill file + references/ sidecar + .graphify_version stamp via the shared
961
+ # progressive-disclosure helper. Previously this used a bare write_text that
962
+ # bypassed _copy_skill_file, so the references/ dir and version stamp were
963
+ # never written even though kiro declares skill_refs: "kiro" (#1142).
964
+ _copy_skill_file("kiro", project=True, project_dir=project_dir)
965
+
966
+ # Steering file → .kiro/steering/graphify.md (always-on)
967
+ steering_dir = project_dir / ".kiro" / "steering"
968
+ steering_dir.mkdir(parents=True, exist_ok=True)
969
+ steering_dst = steering_dir / "graphify.md"
970
+ if steering_dst.exists() and steering_dst.read_text(encoding="utf-8") == _always_on("kiro-steering"):
971
+ print(f" .kiro/steering/graphify.md -> already configured (no change)")
972
+ else:
973
+ # File is wholly graphify-owned. Overwrite on upgrade so older
974
+ # report-first wording does not silently linger (issue #580).
975
+ action = "updated" if steering_dst.exists() else "written"
976
+ steering_dst.write_text(_always_on("kiro-steering"), encoding="utf-8")
977
+ print(f" .kiro/steering/graphify.md -> always-on steering {action}")
978
+
979
+ print()
980
+ print("Kiro will now read the knowledge graph before every conversation.")
981
+ print("Use /graphify to build or update the graph.")
982
+
983
+
984
+ def _kiro_uninstall(project_dir: Path) -> None:
985
+ """Remove graphify skill + steering file for Kiro."""
986
+ project_dir = project_dir or Path(".")
987
+ removed = []
988
+
989
+ # Skill + .graphify_version + references/ sidecar + empty-dir walk.
990
+ skill_dst = _platform_skill_destination("kiro", project=True, project_dir=project_dir)
991
+ if _remove_skill_file("kiro", project=True, project_dir=project_dir):
992
+ removed.append(str(skill_dst.relative_to(project_dir)))
993
+
994
+ steering_dst = project_dir / ".kiro" / "steering" / "graphify.md"
995
+ if steering_dst.exists():
996
+ steering_dst.unlink()
997
+ removed.append(str(steering_dst.relative_to(project_dir)))
998
+
999
+ print("Removed: " + (", ".join(removed) if removed else "nothing to remove"))
1000
+
1001
+
1002
+ def _antigravity_finalize(skill_dst: Path, project_dir: Path) -> None:
1003
+ """Write Antigravity's always-on layer next to an installed skill.
1004
+
1005
+ Injects the native tool-discovery YAML frontmatter into *skill_dst*, then
1006
+ writes ``.agents/rules/graphify.md`` and ``.agents/workflows/graphify.md``
1007
+ under *project_dir*. Shared by the global ``antigravity install`` and the
1008
+ project-scoped ``install --project --platform antigravity`` paths, so both lay
1009
+ down the rules/workflows that the uninstall path already expects to remove.
1010
+ """
1011
+ # Inject YAML frontmatter for native Antigravity tool discovery.
1012
+ if skill_dst.exists():
1013
+ content = skill_dst.read_text(encoding="utf-8")
1014
+ if not content.startswith("---\n"):
1015
+ frontmatter = "---\nname: graphify-manager\ndescription: Rebuild the code graph or perform manual CLI queries when MCP server is offline.\n---\n\n"
1016
+ skill_dst.write_text(frontmatter + content, encoding="utf-8")
1017
+
1018
+ # .agents/rules/graphify.md
1019
+ rules_path = project_dir / _ANTIGRAVITY_RULES_PATH
1020
+ rules_path.parent.mkdir(parents=True, exist_ok=True)
1021
+ if rules_path.exists():
1022
+ existing = rules_path.read_text(encoding="utf-8")
1023
+ if _always_on("antigravity-rules").strip() != existing.strip():
1024
+ rules_path.write_text(_always_on("antigravity-rules"), encoding="utf-8")
1025
+ print(f"graphify rule updated at {rules_path.resolve()}")
1026
+ else:
1027
+ print(f"graphify rule already configured at {rules_path.resolve()} (no change)")
1028
+ else:
1029
+ rules_path.write_text(_always_on("antigravity-rules"), encoding="utf-8")
1030
+ print(f"graphify rule written to {rules_path.resolve()}")
1031
+
1032
+ # .agents/workflows/graphify.md
1033
+ wf_path = project_dir / _ANTIGRAVITY_WORKFLOW_PATH
1034
+ wf_path.parent.mkdir(parents=True, exist_ok=True)
1035
+ if wf_path.exists():
1036
+ existing = wf_path.read_text(encoding="utf-8")
1037
+ if _ANTIGRAVITY_WORKFLOW.strip() != existing.strip():
1038
+ wf_path.write_text(_ANTIGRAVITY_WORKFLOW, encoding="utf-8")
1039
+ print(f"graphify workflow updated at {wf_path.resolve()}")
1040
+ else:
1041
+ print(f"graphify workflow already configured at {wf_path.resolve()} (no change)")
1042
+ else:
1043
+ wf_path.write_text(_ANTIGRAVITY_WORKFLOW, encoding="utf-8")
1044
+ print(f"graphify workflow written to {wf_path.resolve()}")
1045
+
1046
+
1047
+ def _antigravity_install(project_dir: Path) -> None:
1048
+ """Install graphify for Google Antigravity (global skill + .agents/rules + .agents/workflows)."""
1049
+ # Copy the skill to ~/.gemini/config/skills/graphify/SKILL.md (global), then
1050
+ # lay down the always-on rules/workflows under the project dir.
1051
+ install(platform="antigravity")
1052
+ _antigravity_finalize(_platform_skill_destination("antigravity"), project_dir)
1053
+
1054
+ print()
1055
+ print("Antigravity will now check the knowledge graph before answering")
1056
+ print("codebase questions. Run /graphify first to build the graph.")
1057
+ print()
1058
+ print(
1059
+ "To enable full MCP architecture navigation, add this to ~/.gemini/antigravity/mcp_config.json:"
1060
+ )
1061
+ print(' "graphify": {')
1062
+ print(' "command": "uv",')
1063
+ print(
1064
+ ' "args": ["run", "--with", "graphifyy", "--with", "mcp", "-m", "graphify.serve", "${workspace.path}/graphify-out/graph.json"]'
1065
+ )
1066
+ print(" }")
1067
+
1068
+
1069
+ def _antigravity_uninstall(project_dir: Path, *, project: bool = False) -> None:
1070
+ """Remove graphify Antigravity rules, workflow, and skill files."""
1071
+ # Remove rules file
1072
+ rules_path = project_dir / _ANTIGRAVITY_RULES_PATH
1073
+ if rules_path.exists():
1074
+ rules_path.unlink()
1075
+ print(f"graphify rule removed from {rules_path.resolve()}")
1076
+ else:
1077
+ print("No graphify Antigravity rule found - nothing to do")
1078
+
1079
+ # Remove workflow file
1080
+ wf_path = project_dir / _ANTIGRAVITY_WORKFLOW_PATH
1081
+ if wf_path.exists():
1082
+ wf_path.unlink()
1083
+ print(f"graphify workflow removed from {wf_path.resolve()}")
1084
+
1085
+ # Remove skill file
1086
+ skill_dst = _platform_skill_destination("antigravity", project=project, project_dir=project_dir)
1087
+ if skill_dst.exists():
1088
+ skill_dst.unlink()
1089
+ print(f"graphify skill removed from {skill_dst}")
1090
+ version_file = skill_dst.parent / ".graphify_version"
1091
+ if version_file.exists():
1092
+ version_file.unlink()
1093
+ refs_dir = skill_dst.parent / "references"
1094
+ if refs_dir.exists():
1095
+ shutil.rmtree(refs_dir)
1096
+ for d in (
1097
+ skill_dst.parent,
1098
+ skill_dst.parent.parent,
1099
+ skill_dst.parent.parent.parent,
1100
+ ):
1101
+ try:
1102
+ d.rmdir()
1103
+ except OSError:
1104
+ break
1105
+
1106
+
1107
+ _CURSOR_RULE_PATH = Path(".cursor") / "rules" / "graphify.mdc"
1108
+ _CURSOR_RULE = """\
1109
+ ---
1110
+ description: graphify knowledge graph context
1111
+ alwaysApply: true
1112
+ ---
1113
+
1114
+ This project has a graphify knowledge graph at graphify-out/.
1115
+
1116
+ - For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query "<question>"` (or `graphify path "<A>" "<B>"` / `graphify explain "<concept>"`). These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output.
1117
+ - If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
1118
+ - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context
1119
+ - After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)
1120
+ """
1121
+
1122
+
1123
+ def _cursor_install(project_dir: Path) -> None:
1124
+ """Write .cursor/rules/graphify.mdc with alwaysApply: true."""
1125
+ rule_path = (project_dir or Path(".")) / _CURSOR_RULE_PATH
1126
+ rule_path.parent.mkdir(parents=True, exist_ok=True)
1127
+ if rule_path.exists() and rule_path.read_text(encoding="utf-8") == _CURSOR_RULE:
1128
+ print(f"graphify rule at {rule_path} already configured (no change)")
1129
+ return
1130
+ # File is wholly graphify-owned. Overwrite on upgrade so older
1131
+ # report-first wording does not silently linger (issue #580).
1132
+ action = "updated" if rule_path.exists() else "written"
1133
+ rule_path.write_text(_CURSOR_RULE, encoding="utf-8")
1134
+ print(f"graphify rule {action} at {rule_path.resolve()}")
1135
+ print()
1136
+ print("Cursor will now always include the knowledge graph context.")
1137
+ print("Run /graphify . first to build the graph if you haven't already.")
1138
+
1139
+
1140
+ def _cursor_uninstall(project_dir: Path) -> None:
1141
+ """Remove .cursor/rules/graphify.mdc."""
1142
+ rule_path = (project_dir or Path(".")) / _CURSOR_RULE_PATH
1143
+ if not rule_path.exists():
1144
+ print("No graphify Cursor rule found - nothing to do")
1145
+ return
1146
+ rule_path.unlink()
1147
+ print(f"graphify Cursor rule removed from {rule_path.resolve()}")
1148
+
1149
+
1150
+ # Devin CLI — .windsurf/rules/graphify.md (always-on context)
1151
+ # Devin reads .windsurf/rules/*.md files the same way Windsurf IDE does.
1152
+ _DEVIN_RULES_PATH = Path(".windsurf") / "rules" / "graphify.md"
1153
+ _DEVIN_RULES = """\
1154
+ ## graphify
1155
+
1156
+ This project has a graphify knowledge graph at graphify-out/.
1157
+
1158
+ Rules:
1159
+ - For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query "<question>"` (or `graphify path "<A>" "<B>"` / `graphify explain "<concept>"`). These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output.
1160
+ - If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
1161
+ - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context
1162
+ - After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)
1163
+ """
1164
+
1165
+
1166
+ def _devin_rules_install(project_dir: Path) -> None:
1167
+ """Write .windsurf/rules/graphify.md for always-on Devin context."""
1168
+ rules_path = (project_dir or Path(".")) / _DEVIN_RULES_PATH
1169
+ rules_path.parent.mkdir(parents=True, exist_ok=True)
1170
+ if rules_path.exists() and rules_path.read_text(encoding="utf-8") == _DEVIN_RULES:
1171
+ print(f" {rules_path} -> already configured (no change)")
1172
+ return
1173
+ action = "updated" if rules_path.exists() else "written"
1174
+ rules_path.write_text(_DEVIN_RULES, encoding="utf-8")
1175
+ print(f" rules {action} -> {rules_path}")
1176
+
1177
+
1178
+ def _devin_rules_uninstall(project_dir: Path) -> None:
1179
+ """Remove .windsurf/rules/graphify.md."""
1180
+ rules_path = (project_dir or Path(".")) / _DEVIN_RULES_PATH
1181
+ if not rules_path.exists():
1182
+ return
1183
+ rules_path.unlink()
1184
+ print(f" rules removed -> {rules_path}")
1185
+
1186
+
1187
+ _KILO_PLUGIN_JS = """\
1188
+ // graphify Kilo plugin
1189
+ // Injects a knowledge graph reminder before bash tool calls when the graph exists.
1190
+ import { existsSync } from "fs";
1191
+ import { join } from "path";
1192
+
1193
+ export const GraphifyPlugin = async ({ directory }) => {
1194
+ let reminded = false;
1195
+
1196
+ return {
1197
+ "tool.execute.before": async (input, output) => {
1198
+ if (reminded) return;
1199
+ if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
1200
+
1201
+ if (input.tool === "bash") {
1202
+ output.args.command =
1203
+ 'echo "[graphify] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for god nodes and architecture context before searching files." && ' +
1204
+ output.args.command;
1205
+ reminded = true;
1206
+ }
1207
+ },
1208
+ };
1209
+ };
1210
+ """
1211
+
1212
+ _KILO_PLUGIN_PATH = Path(".kilo") / "plugins" / "graphify.js"
1213
+ _KILO_CONFIG_JSON_PATH = Path(".kilo") / "kilo.json"
1214
+ _KILO_CONFIG_JSONC_PATH = Path(".kilo") / "kilo.jsonc"
1215
+
1216
+
1217
+ def _strip_json_comments(raw: str) -> str:
1218
+ """Remove JSONC-style comments while leaving string content intact."""
1219
+ result: list[str] = []
1220
+ in_string = False
1221
+ escaped = False
1222
+ line_comment = False
1223
+ block_comment = False
1224
+ i = 0
1225
+
1226
+ while i < len(raw):
1227
+ ch = raw[i]
1228
+ nxt = raw[i + 1] if i + 1 < len(raw) else ""
1229
+
1230
+ if line_comment:
1231
+ if ch == "\n":
1232
+ line_comment = False
1233
+ result.append(ch)
1234
+ i += 1
1235
+ continue
1236
+
1237
+ if block_comment:
1238
+ if ch == "*" and nxt == "/":
1239
+ block_comment = False
1240
+ i += 2
1241
+ else:
1242
+ i += 1
1243
+ continue
1244
+
1245
+ if in_string:
1246
+ result.append(ch)
1247
+ if escaped:
1248
+ escaped = False
1249
+ elif ch == "\\":
1250
+ escaped = True
1251
+ elif ch == '"':
1252
+ in_string = False
1253
+ i += 1
1254
+ continue
1255
+
1256
+ if ch == "/" and nxt == "/":
1257
+ line_comment = True
1258
+ i += 2
1259
+ continue
1260
+ if ch == "/" and nxt == "*":
1261
+ block_comment = True
1262
+ i += 2
1263
+ continue
1264
+
1265
+ result.append(ch)
1266
+ if ch == '"':
1267
+ in_string = True
1268
+ i += 1
1269
+
1270
+ return re.sub(r",(\s*[}\]])", r"\1", "".join(result))
1271
+
1272
+
1273
+ def _load_json_like(config_file: Path) -> dict:
1274
+ if not config_file.exists():
1275
+ return {}
1276
+ try:
1277
+ raw = config_file.read_text(encoding="utf-8")
1278
+ if config_file.suffix == ".jsonc":
1279
+ raw = _strip_json_comments(raw)
1280
+ loaded = json.loads(raw)
1281
+ except (OSError, json.JSONDecodeError):
1282
+ return {}
1283
+ return loaded if isinstance(loaded, dict) else {}
1284
+
1285
+
1286
+ def _kilo_config_path(project_dir: Path) -> Path:
1287
+ kilo_dir = (project_dir or Path(".")) / ".kilo"
1288
+ json_path = kilo_dir / _KILO_CONFIG_JSON_PATH.name
1289
+ if json_path.exists():
1290
+ return json_path
1291
+ jsonc_path = kilo_dir / _KILO_CONFIG_JSONC_PATH.name
1292
+ if jsonc_path.exists():
1293
+ return jsonc_path
1294
+ return json_path
1295
+
1296
+
1297
+ def _kilo_config_write_path(project_dir: Path) -> Path:
1298
+ """Write automated Kilo edits to kilo.json so existing JSONC stays untouched."""
1299
+ kilo_dir = (project_dir or Path(".")) / ".kilo"
1300
+ return kilo_dir / _KILO_CONFIG_JSON_PATH.name
1301
+
1302
+
1303
+ def _install_kilo_plugin(project_dir: Path) -> None:
1304
+ """Write graphify.js plugin and register it without rewriting user JSONC."""
1305
+ plugin_file = project_dir / _KILO_PLUGIN_PATH
1306
+ plugin_file.parent.mkdir(parents=True, exist_ok=True)
1307
+ plugin_file.write_text(_KILO_PLUGIN_JS, encoding="utf-8")
1308
+ print(f" {_KILO_PLUGIN_PATH} -> tool.execute.before hook written")
1309
+
1310
+ config_file = _kilo_config_path(project_dir)
1311
+ write_config_file = _kilo_config_write_path(project_dir)
1312
+ write_config_file.parent.mkdir(parents=True, exist_ok=True)
1313
+ config = _load_json_like(config_file)
1314
+ plugins = config.get("plugin")
1315
+ if not isinstance(plugins, list):
1316
+ plugins = []
1317
+ config["plugin"] = plugins
1318
+ entry = plugin_file.resolve().as_uri()
1319
+ if entry not in plugins:
1320
+ plugins.append(entry)
1321
+ write_config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
1322
+ print(f" {write_config_file.relative_to(project_dir)} -> plugin registered")
1323
+ else:
1324
+ print(
1325
+ f" {config_file.relative_to(project_dir)} -> plugin already registered (no change)"
1326
+ )
1327
+
1328
+
1329
+ def _uninstall_kilo_plugin(project_dir: Path) -> None:
1330
+ """Remove graphify.js plugin and deregister it without rewriting user JSONC."""
1331
+ plugin_file = project_dir / _KILO_PLUGIN_PATH
1332
+ if plugin_file.exists():
1333
+ plugin_file.unlink()
1334
+ print(f" {_KILO_PLUGIN_PATH} -> removed")
1335
+
1336
+ config_file = _kilo_config_path(project_dir)
1337
+ if not config_file.exists():
1338
+ return
1339
+ write_config_file = _kilo_config_write_path(project_dir)
1340
+ config = _load_json_like(config_file)
1341
+ plugins = config.get("plugin", [])
1342
+ if not isinstance(plugins, list):
1343
+ plugins = []
1344
+ entry = plugin_file.resolve().as_uri()
1345
+ if entry in plugins:
1346
+ config["plugin"] = [plugin for plugin in plugins if plugin != entry]
1347
+ if not config["plugin"]:
1348
+ config.pop("plugin")
1349
+ write_config_file.parent.mkdir(parents=True, exist_ok=True)
1350
+ write_config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
1351
+ print(
1352
+ f" {write_config_file.relative_to(project_dir)} -> plugin deregistered"
1353
+ )
1354
+
1355
+
1356
+ # OpenCode tool.execute.before plugin — fires before every tool call.
1357
+ # Injects a graph reminder into bash command output when graph.json exists.
1358
+ _OPENCODE_PLUGIN_JS = """\
1359
+ // graphify OpenCode plugin
1360
+ // Injects a knowledge graph reminder before bash tool calls when the graph exists.
1361
+ import { existsSync } from "fs";
1362
+ import { join } from "path";
1363
+
1364
+ export const GraphifyPlugin = async ({ directory }) => {
1365
+ let reminded = false;
1366
+
1367
+ return {
1368
+ "tool.execute.before": async (input, output) => {
1369
+ if (reminded) return;
1370
+ if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
1371
+
1372
+ if (input.tool === "bash") {
1373
+ output.args.command =
1374
+ 'echo "[graphify] knowledge graph at graphify-out/. For focused questions, run \\`graphify query \\"<question>\\"\\` (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context." && ' +
1375
+ output.args.command;
1376
+ reminded = true;
1377
+ }
1378
+ },
1379
+ };
1380
+ };
1381
+ """
1382
+
1383
+ _OPENCODE_PLUGIN_PATH = Path(".opencode") / "plugins" / "graphify.js"
1384
+ _OPENCODE_CONFIG_PATH = Path(".opencode") / "opencode.json"
1385
+
1386
+
1387
+ def _install_opencode_plugin(project_dir: Path) -> None:
1388
+ """Write graphify.js plugin and register it in opencode.json."""
1389
+ plugin_file = project_dir / _OPENCODE_PLUGIN_PATH
1390
+ plugin_file.parent.mkdir(parents=True, exist_ok=True)
1391
+ plugin_file.write_text(_OPENCODE_PLUGIN_JS, encoding="utf-8")
1392
+ print(f" {_OPENCODE_PLUGIN_PATH} -> tool.execute.before hook written")
1393
+
1394
+ config_file = project_dir / _OPENCODE_CONFIG_PATH
1395
+ if config_file.exists():
1396
+ try:
1397
+ config = json.loads(config_file.read_text(encoding="utf-8"))
1398
+ except json.JSONDecodeError:
1399
+ config = {}
1400
+ else:
1401
+ config = {}
1402
+
1403
+ plugins = config.setdefault("plugin", [])
1404
+ entry = _OPENCODE_PLUGIN_PATH.as_posix()
1405
+ if entry not in plugins:
1406
+ plugins.append(entry)
1407
+ config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
1408
+ print(f" {_OPENCODE_CONFIG_PATH} -> plugin registered")
1409
+ else:
1410
+ print(f" {_OPENCODE_CONFIG_PATH} -> plugin already registered (no change)")
1411
+
1412
+
1413
+ def _uninstall_opencode_plugin(project_dir: Path) -> None:
1414
+ """Remove graphify.js plugin and deregister from opencode.json."""
1415
+ plugin_file = project_dir / _OPENCODE_PLUGIN_PATH
1416
+ if plugin_file.exists():
1417
+ plugin_file.unlink()
1418
+ print(f" {_OPENCODE_PLUGIN_PATH} -> removed")
1419
+
1420
+ config_file = project_dir / _OPENCODE_CONFIG_PATH
1421
+ if not config_file.exists():
1422
+ return
1423
+ try:
1424
+ config = json.loads(config_file.read_text(encoding="utf-8"))
1425
+ except json.JSONDecodeError:
1426
+ return
1427
+ plugins = config.get("plugin", [])
1428
+ entry = _OPENCODE_PLUGIN_PATH.as_posix()
1429
+ if entry in plugins:
1430
+ plugins.remove(entry)
1431
+ if not plugins:
1432
+ config.pop("plugin")
1433
+ config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
1434
+ print(f" {_OPENCODE_CONFIG_PATH} -> plugin deregistered")
1435
+
1436
+
1437
+ _CODEX_HOOK = {
1438
+ "hooks": {
1439
+ "PreToolUse": [
1440
+ {
1441
+ "matcher": "Bash",
1442
+ "hooks": [
1443
+ {
1444
+ "type": "command",
1445
+ # Use the graphify CLI itself so the hook is shell-agnostic:
1446
+ # no [ -f ] bash syntax, no python3 vs python Conda issue,
1447
+ # no JSON escaping inside PowerShell strings. Works on
1448
+ # Windows (PowerShell/cmd.exe), macOS, and Linux.
1449
+ "command": "graphify hook-check",
1450
+ }
1451
+ ],
1452
+ }
1453
+ ]
1454
+ }
1455
+ }
1456
+
1457
+
1458
+ def _resolve_graphify_exe() -> str:
1459
+ """Return the absolute path to the graphify executable.
1460
+
1461
+ Falls back to bare 'graphify' if resolution fails. Using an absolute path
1462
+ ensures the hook works in environments where the venv Scripts/ directory is
1463
+ not on PATH (e.g. VS Code Codex extension on Windows).
1464
+ """
1465
+ import shutil
1466
+ found = shutil.which("graphify")
1467
+ if found:
1468
+ return found
1469
+ # Derive from sys.executable: same Scripts/ (Windows) or bin/ (Unix) dir
1470
+ scripts_dir = Path(sys.executable).parent
1471
+ for name in ("graphify.exe", "graphify"):
1472
+ candidate = scripts_dir / name
1473
+ if candidate.exists():
1474
+ return str(candidate)
1475
+ return "graphify"
1476
+
1477
+
1478
+ def _install_codex_hook(project_dir: Path) -> None:
1479
+ """Add graphify PreToolUse hook to .codex/hooks.json."""
1480
+ hooks_path = project_dir / ".codex" / "hooks.json"
1481
+ hooks_path.parent.mkdir(parents=True, exist_ok=True)
1482
+
1483
+ if hooks_path.exists():
1484
+ try:
1485
+ existing = json.loads(hooks_path.read_text(encoding="utf-8"))
1486
+ except json.JSONDecodeError:
1487
+ existing = {}
1488
+ else:
1489
+ existing = {}
1490
+
1491
+ graphify_exe = _resolve_graphify_exe()
1492
+ hook_entry = {
1493
+ "hooks": {
1494
+ "PreToolUse": [
1495
+ {
1496
+ "matcher": "Bash",
1497
+ "hooks": [{"type": "command", "command": f"{graphify_exe} hook-check"}],
1498
+ }
1499
+ ]
1500
+ }
1501
+ }
1502
+
1503
+ pre_tool = existing.setdefault("hooks", {}).setdefault("PreToolUse", [])
1504
+ existing["hooks"]["PreToolUse"] = [h for h in pre_tool if "graphify" not in str(h)]
1505
+ existing["hooks"]["PreToolUse"].extend(hook_entry["hooks"]["PreToolUse"])
1506
+ hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
1507
+ print(f" .codex/hooks.json -> PreToolUse hook registered ({graphify_exe} hook-check)")
1508
+
1509
+
1510
+ def _uninstall_codex_hook(project_dir: Path) -> None:
1511
+ """Remove graphify PreToolUse hook from .codex/hooks.json."""
1512
+ hooks_path = project_dir / ".codex" / "hooks.json"
1513
+ if not hooks_path.exists():
1514
+ return
1515
+ try:
1516
+ existing = json.loads(hooks_path.read_text(encoding="utf-8"))
1517
+ except json.JSONDecodeError:
1518
+ return
1519
+ pre_tool = existing.get("hooks", {}).get("PreToolUse", [])
1520
+ filtered = [h for h in pre_tool if "graphify" not in str(h)]
1521
+ existing["hooks"]["PreToolUse"] = filtered
1522
+ hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
1523
+ print(f" .codex/hooks.json -> PreToolUse hook removed")
1524
+
1525
+
1526
+ def _agents_install(project_dir: Path, platform: str) -> None:
1527
+ """Write the graphify section to the local AGENTS.md for always-on platforms."""
1528
+ target = (project_dir or Path(".")) / "AGENTS.md"
1529
+
1530
+ if target.exists():
1531
+ content = target.read_text(encoding="utf-8")
1532
+ new_content = _replace_or_append_section(
1533
+ content, _AGENTS_MD_MARKER, _always_on("agents-md")
1534
+ )
1535
+ else:
1536
+ new_content = _always_on("agents-md")
1537
+
1538
+ if target.exists() and new_content == target.read_text(encoding="utf-8"):
1539
+ print(f"graphify already configured in {target.resolve()} (no change)")
1540
+ else:
1541
+ target.write_text(new_content, encoding="utf-8")
1542
+ print(f"graphify section written to {target.resolve()}")
1543
+
1544
+ if platform == "codex":
1545
+ _install_codex_hook(project_dir or Path("."))
1546
+ elif platform == "opencode":
1547
+ _install_opencode_plugin(project_dir or Path("."))
1548
+ elif platform == "kilo":
1549
+ _install_kilo_plugin(project_dir or Path("."))
1550
+
1551
+ print()
1552
+ print(
1553
+ f"{platform.capitalize()} will now check the knowledge graph before answering"
1554
+ )
1555
+ print("codebase questions and rebuild it after code changes.")
1556
+ if platform not in ("codex", "opencode", "kilo"):
1557
+ print()
1558
+ print("Note: unlike Claude Code, there is no PreToolUse hook equivalent for")
1559
+ print(
1560
+ f"{platform.capitalize()} — the AGENTS.md rules are the always-on mechanism."
1561
+ )
1562
+
1563
+
1564
+ def _amp_legacy_cleanup() -> None:
1565
+ """Best-effort removal of the pre-fix ~/.amp/skills/graphify install dir.
1566
+
1567
+ Older graphify versions wrote the Amp skill to ~/.amp/skills, which Amp does
1568
+ not search. Clean it up on install so a stale, never-loaded copy does not
1569
+ linger. Failures are ignored (the new path is what matters).
1570
+ """
1571
+ legacy = Path.home() / ".amp" / "skills" / "graphify"
1572
+ if legacy.exists():
1573
+ shutil.rmtree(legacy, ignore_errors=True)
1574
+ if not legacy.exists():
1575
+ print(f" legacy removed -> {legacy}")
1576
+
1577
+
1578
+ def _amp_install(project_dir: Path | None = None) -> None:
1579
+ """User-scope Amp install: skill into ~/.config/agents/skills + AGENTS.md."""
1580
+ _amp_legacy_cleanup()
1581
+ _copy_skill_file("amp")
1582
+ _agents_install(project_dir or Path("."), "amp")
1583
+
1584
+
1585
+ def _amp_uninstall(project_dir: Path | None = None) -> None:
1586
+ """User-scope Amp uninstall: remove the skill and the AGENTS.md section."""
1587
+ removed = _remove_skill_file("amp")
1588
+ if removed:
1589
+ print("skill removed")
1590
+ _agents_uninstall(project_dir or Path("."), platform="amp")
1591
+
1592
+
1593
+ def _project_install(platform_name: str, project_dir: Path | None = None) -> None:
1594
+ """Install platform skill/config files in the current project."""
1595
+ project_dir = project_dir or Path(".")
1596
+ if platform_name in ("claude", "windows"):
1597
+ install(platform=platform_name, project=True, project_dir=project_dir)
1598
+ claude_install(project_dir)
1599
+ _print_project_git_add_hint([project_dir / ".claude", project_dir / "CLAUDE.md"])
1600
+ elif platform_name == "gemini":
1601
+ gemini_install(project_dir, project=True)
1602
+ elif platform_name == "cursor":
1603
+ _cursor_install(project_dir)
1604
+ _print_project_git_add_hint([project_dir / ".cursor"])
1605
+ elif platform_name == "kiro":
1606
+ _kiro_install(project_dir)
1607
+ _print_project_git_add_hint([project_dir / ".kiro"])
1608
+ elif platform_name in ("aider", "amp", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
1609
+ skill_dst = _copy_skill_file(platform_name, project=True, project_dir=project_dir)
1610
+ _agents_install(project_dir, platform_name)
1611
+ hint_paths = [_project_scope_root(skill_dst, project_dir), project_dir / "AGENTS.md"]
1612
+ if platform_name == "opencode":
1613
+ hint_paths.append(project_dir / ".opencode")
1614
+ elif platform_name == "codex":
1615
+ hint_paths.append(project_dir / ".codex")
1616
+ _print_project_git_add_hint(hint_paths)
1617
+ elif platform_name == "devin":
1618
+ skill_dst = _copy_skill_file("devin", project=True, project_dir=project_dir)
1619
+ _devin_rules_install(project_dir)
1620
+ _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / ".windsurf"])
1621
+ elif platform_name == "antigravity":
1622
+ # Project-scoped: skill in .agents/skills/ PLUS the .agents/rules +
1623
+ # .agents/workflows always-on layer (previously this path wrote only the
1624
+ # skill, leaving the rules/workflows the uninstall path removes unset).
1625
+ skill_dst = _copy_skill_file("antigravity", project=True, project_dir=project_dir)
1626
+ _antigravity_finalize(skill_dst, project_dir)
1627
+ _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / ".agents"])
1628
+ elif platform_name in ("copilot", "pi", "kimi"):
1629
+ skill_dst = _copy_skill_file(platform_name, project=True, project_dir=project_dir)
1630
+ _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir)])
1631
+ else:
1632
+ install(platform=platform_name, project=True, project_dir=project_dir)
1633
+
1634
+
1635
+ def _project_uninstall(platform_name: str, project_dir: Path | None = None) -> None:
1636
+ """Remove project-scoped platform skill/config files only."""
1637
+ project_dir = project_dir or Path(".")
1638
+ if platform_name in ("claude", "windows"):
1639
+ _remove_skill_file(platform_name, project=True, project_dir=project_dir)
1640
+ _remove_claude_skill_registration(project_dir)
1641
+ claude_uninstall(project_dir, project=True)
1642
+ elif platform_name == "gemini":
1643
+ gemini_uninstall(project_dir, project=True)
1644
+ elif platform_name == "cursor":
1645
+ _cursor_uninstall(project_dir)
1646
+ elif platform_name == "kiro":
1647
+ _kiro_uninstall(project_dir)
1648
+ elif platform_name in ("aider", "amp", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
1649
+ _remove_skill_file(platform_name, project=True, project_dir=project_dir)
1650
+ _agents_uninstall(project_dir, platform=platform_name)
1651
+ if platform_name == "codex":
1652
+ _uninstall_codex_hook(project_dir)
1653
+ elif platform_name == "antigravity":
1654
+ _antigravity_uninstall(project_dir, project=True)
1655
+ elif platform_name == "devin":
1656
+ removed = _remove_skill_file("devin", project=True, project_dir=project_dir)
1657
+ _devin_rules_uninstall(project_dir)
1658
+ if not removed:
1659
+ print("nothing to remove")
1660
+ elif platform_name in ("copilot", "pi", "kimi"):
1661
+ removed = _remove_skill_file(platform_name, project=True, project_dir=project_dir)
1662
+ if not removed:
1663
+ print("nothing to remove")
1664
+ elif platform_name == "codebuddy":
1665
+ codebuddy_uninstall(project_dir)
1666
+ else:
1667
+ _remove_skill_file(platform_name, project=True, project_dir=project_dir)
1668
+
1669
+
1670
+ def _project_uninstall_all(project_dir: Path | None = None) -> None:
1671
+ """Remove project-scoped install files without touching user-scope installs."""
1672
+ project_dir = project_dir or Path(".")
1673
+ print("Uninstalling project-scoped graphify files...\n")
1674
+ for platform_name in _PLATFORM_CONFIG:
1675
+ _project_uninstall(platform_name, project_dir)
1676
+ for platform_name in ("gemini", "cursor"):
1677
+ _project_uninstall(platform_name, project_dir)
1678
+ print("\nDone.")
1679
+
1680
+
1681
+ def _agents_uninstall(project_dir: Path, platform: str = "") -> None:
1682
+ """Remove the graphify section from the local AGENTS.md."""
1683
+ target = (project_dir or Path(".")) / "AGENTS.md"
1684
+
1685
+ if not target.exists():
1686
+ print("No AGENTS.md found in current directory - nothing to do")
1687
+ if platform == "opencode":
1688
+ _uninstall_opencode_plugin(project_dir or Path("."))
1689
+ elif platform == "kilo":
1690
+ _uninstall_kilo_plugin(project_dir or Path("."))
1691
+ return
1692
+
1693
+ content = target.read_text(encoding="utf-8")
1694
+ if _AGENTS_MD_MARKER not in content:
1695
+ print("graphify section not found in AGENTS.md - nothing to do")
1696
+ if platform == "opencode":
1697
+ _uninstall_opencode_plugin(project_dir or Path("."))
1698
+ elif platform == "kilo":
1699
+ _uninstall_kilo_plugin(project_dir or Path("."))
1700
+ return
1701
+
1702
+ cleaned = re.sub(
1703
+ r"\n*## graphify\n.*?(?=\n## |\Z)",
1704
+ "",
1705
+ content,
1706
+ flags=re.DOTALL,
1707
+ ).rstrip()
1708
+ if cleaned:
1709
+ target.write_text(cleaned + "\n", encoding="utf-8")
1710
+ print(f"graphify section removed from {target.resolve()}")
1711
+ else:
1712
+ target.unlink()
1713
+ print(f"AGENTS.md was empty after removal - deleted {target.resolve()}")
1714
+
1715
+ if platform == "opencode":
1716
+ _uninstall_opencode_plugin(project_dir or Path("."))
1717
+ elif platform == "kilo":
1718
+ _uninstall_kilo_plugin(project_dir or Path("."))
1719
+
1720
+
1721
+ def _kilo_uninstall_global() -> list[str]:
1722
+ removed = []
1723
+ command_dst = Path.home() / ".config" / "kilo" / "command" / "graphify.md"
1724
+ if command_dst.exists():
1725
+ command_dst.unlink()
1726
+ removed.append(f"command removed: {command_dst}")
1727
+ try:
1728
+ command_dst.parent.rmdir()
1729
+ except OSError:
1730
+ pass
1731
+
1732
+ skill_dst = Path.home() / _PLATFORM_CONFIG["kilo"]["skill_dst"]
1733
+ if skill_dst.exists():
1734
+ skill_dst.unlink()
1735
+ removed.append(f"skill removed: {skill_dst}")
1736
+ version_file = skill_dst.parent / ".graphify_version"
1737
+ if version_file.exists():
1738
+ version_file.unlink()
1739
+ for d in (
1740
+ skill_dst.parent,
1741
+ skill_dst.parent.parent,
1742
+ skill_dst.parent.parent.parent,
1743
+ ):
1744
+ try:
1745
+ d.rmdir()
1746
+ except OSError:
1747
+ break
1748
+
1749
+ return removed
1750
+
1751
+
1752
+ def _kilo_install(project_dir: Path) -> None:
1753
+ """Install native Kilo skill + command globally and always-on project wiring locally."""
1754
+ install(platform="kilo")
1755
+ _agents_install(project_dir or Path("."), "kilo")
1756
+
1757
+
1758
+ def _kilo_uninstall(project_dir: Path) -> None:
1759
+ """Remove Kilo always-on project wiring and global skill/command files."""
1760
+ _agents_uninstall(project_dir or Path("."), platform="kilo")
1761
+ removed = _kilo_uninstall_global()
1762
+ print("; ".join(removed) if removed else "nothing to remove")
1763
+
1764
+
1765
+ def claude_install(project_dir: Path | None = None) -> None:
1766
+ """Write the graphify section to the local CLAUDE.md."""
1767
+ target = (project_dir or Path(".")) / "CLAUDE.md"
1768
+
1769
+ if target.exists():
1770
+ content = target.read_text(encoding="utf-8")
1771
+ new_content = _replace_or_append_section(
1772
+ content, _CLAUDE_MD_MARKER, _always_on("claude-md")
1773
+ )
1774
+ else:
1775
+ new_content = _always_on("claude-md")
1776
+
1777
+ if target.exists() and new_content == target.read_text(encoding="utf-8"):
1778
+ print(f"graphify already configured in {target.resolve()} (no change)")
1779
+ else:
1780
+ target.write_text(new_content, encoding="utf-8")
1781
+ print(f"graphify section written to {target.resolve()}")
1782
+
1783
+ # Always re-install the Claude Code PreToolUse hook so an old hook
1784
+ # payload (e.g. pre-issue-#580 wording) is replaced on upgrade.
1785
+ _install_claude_hook(project_dir or Path("."))
1786
+
1787
+ print()
1788
+ print("Claude Code will now check the knowledge graph before answering")
1789
+ print("codebase questions and rebuild it after code changes.")
1790
+
1791
+
1792
+ def _install_claude_hook(project_dir: Path) -> None:
1793
+ """Add graphify PreToolUse hook to .claude/settings.json."""
1794
+ settings_path = project_dir / ".claude" / "settings.json"
1795
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
1796
+
1797
+ if settings_path.exists():
1798
+ try:
1799
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
1800
+ except json.JSONDecodeError:
1801
+ settings = {}
1802
+ else:
1803
+ settings = {}
1804
+
1805
+ hooks = settings.setdefault("hooks", {})
1806
+ pre_tool = hooks.setdefault("PreToolUse", [])
1807
+
1808
+ hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))]
1809
+ hooks["PreToolUse"].append(_SETTINGS_HOOK)
1810
+ hooks["PreToolUse"].append(_READ_SETTINGS_HOOK)
1811
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
1812
+ print(f" .claude/settings.json -> PreToolUse hooks registered (Bash search + Read/Glob)")
1813
+
1814
+
1815
+ def _uninstall_claude_hook(project_dir: Path) -> None:
1816
+ """Remove graphify PreToolUse hook from .claude/settings.json."""
1817
+ settings_path = project_dir / ".claude" / "settings.json"
1818
+ if not settings_path.exists():
1819
+ return
1820
+ try:
1821
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
1822
+ except json.JSONDecodeError:
1823
+ return
1824
+ pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
1825
+ filtered = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))]
1826
+ if len(filtered) == len(pre_tool):
1827
+ return
1828
+ settings["hooks"]["PreToolUse"] = filtered
1829
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
1830
+ print(f" .claude/settings.json -> PreToolUse hook removed")
1831
+
1832
+
1833
+ def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None:
1834
+ """Remove graphify from every platform detected in the current project."""
1835
+ pd = project_dir or Path(".")
1836
+ print("Uninstalling graphify from all detected platforms...\n")
1837
+
1838
+ # Skill-file / config-section uninstallers
1839
+ claude_uninstall(pd)
1840
+ codebuddy_uninstall(pd)
1841
+ gemini_uninstall(pd)
1842
+ vscode_uninstall(pd)
1843
+ _cursor_uninstall(pd)
1844
+ _kiro_uninstall(pd)
1845
+ _antigravity_uninstall(pd)
1846
+ # AGENTS.md covers: codex, aider, opencode, claw, droid, trae, trae-cn, hermes, copilot
1847
+ _agents_uninstall(pd)
1848
+ # Amp also drops a user-scope skill at ~/.config/agents/skills, which the
1849
+ # AGENTS.md cleanup above does not touch.
1850
+ _remove_skill_file("amp")
1851
+ _uninstall_opencode_plugin(pd)
1852
+ _uninstall_codex_hook(pd)
1853
+
1854
+ # Git hook
1855
+ try:
1856
+ from graphify.hooks import uninstall as hook_uninstall
1857
+ result = hook_uninstall(pd)
1858
+ if result:
1859
+ print(result)
1860
+ except Exception:
1861
+ pass
1862
+
1863
+ if purge:
1864
+ import shutil as _shutil
1865
+ out = pd / "graphify-out"
1866
+ if out.exists():
1867
+ _shutil.rmtree(out)
1868
+ print(f"\n graphify-out/ -> deleted (--purge)")
1869
+ else:
1870
+ print("\n graphify-out/ -> not found (nothing to purge)")
1871
+
1872
+ print("\nDone. Run 'pip uninstall graphifyy' to remove the package itself.")
1873
+
1874
+
1875
+ def claude_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None:
1876
+ """Remove the graphify skill tree (SKILL.md + references/) and the CLAUDE.md section.
1877
+
1878
+ Mirrors gemini_uninstall: the bare `graphify uninstall` and `graphify claude
1879
+ uninstall` must remove the installed skill, not just strip CLAUDE.md, or the
1880
+ progressive-disclosure tree (SKILL.md + references/) is orphaned (#1121).
1881
+ """
1882
+ project_dir = project_dir or Path(".")
1883
+ _remove_skill_file("claude", project=project, project_dir=project_dir)
1884
+ target = project_dir / "CLAUDE.md"
1885
+
1886
+ if not target.exists():
1887
+ print("No CLAUDE.md found in current directory - nothing to do")
1888
+ return
1889
+
1890
+ content = target.read_text(encoding="utf-8")
1891
+ if _CLAUDE_MD_MARKER not in content:
1892
+ print("graphify section not found in CLAUDE.md - nothing to do")
1893
+ return
1894
+
1895
+ # Remove the ## graphify section: from the marker to the next ## heading or EOF
1896
+ cleaned = re.sub(
1897
+ r"\n*## graphify\n.*?(?=\n## |\Z)",
1898
+ "",
1899
+ content,
1900
+ flags=re.DOTALL,
1901
+ ).rstrip()
1902
+ if cleaned:
1903
+ target.write_text(cleaned + "\n", encoding="utf-8")
1904
+ print(f"graphify section removed from {target.resolve()}")
1905
+ else:
1906
+ target.unlink()
1907
+ print(f"CLAUDE.md was empty after removal - deleted {target.resolve()}")
1908
+
1909
+ _uninstall_claude_hook(project_dir or Path("."))
1910
+
1911
+
1912
+ def codebuddy_install(project_dir: Path | None = None) -> None:
1913
+ """Install the graphify skill and CODEBUDDY.md section for CodeBuddy."""
1914
+ _copy_skill_file("codebuddy", project=bool(project_dir), project_dir=project_dir)
1915
+ target = (project_dir or Path(".")) / "CODEBUDDY.md"
1916
+
1917
+ if target.exists():
1918
+ content = target.read_text(encoding="utf-8")
1919
+ new_content = _replace_or_append_section(
1920
+ content, _CODEBUDDY_MD_MARKER, _always_on("claude-md")
1921
+ )
1922
+ else:
1923
+ new_content = _always_on("claude-md")
1924
+
1925
+ if target.exists() and new_content == target.read_text(encoding="utf-8"):
1926
+ print(f"graphify already configured in {target.resolve()} (no change)")
1927
+ else:
1928
+ target.write_text(new_content, encoding="utf-8")
1929
+ print(f"graphify section written to {target.resolve()}")
1930
+
1931
+ # Also write CodeBuddy PreToolUse hook to .codebuddy/settings.json
1932
+ _install_codebuddy_hook(project_dir or Path("."))
1933
+
1934
+ print()
1935
+ print("CodeBuddy will now check the knowledge graph before answering")
1936
+ print("codebase questions and rebuild it after code changes.")
1937
+
1938
+
1939
+ def _install_codebuddy_hook(project_dir: Path) -> None:
1940
+ """Add graphify PreToolUse hook to .codebuddy/settings.json."""
1941
+ settings_path = project_dir / ".codebuddy" / "settings.json"
1942
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
1943
+
1944
+ if settings_path.exists():
1945
+ try:
1946
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
1947
+ except json.JSONDecodeError:
1948
+ settings = {}
1949
+ else:
1950
+ settings = {}
1951
+
1952
+ hooks = settings.setdefault("hooks", {})
1953
+ pre_tool = hooks.setdefault("PreToolUse", [])
1954
+
1955
+ hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))]
1956
+ hooks["PreToolUse"].append(_SETTINGS_HOOK)
1957
+ hooks["PreToolUse"].append(_READ_SETTINGS_HOOK)
1958
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
1959
+ print(f" .codebuddy/settings.json -> PreToolUse hooks registered")
1960
+
1961
+
1962
+ def _uninstall_codebuddy_hook(project_dir: Path) -> None:
1963
+ """Remove graphify PreToolUse hook from .codebuddy/settings.json."""
1964
+ settings_path = project_dir / ".codebuddy" / "settings.json"
1965
+ if not settings_path.exists():
1966
+ return
1967
+ try:
1968
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
1969
+ except json.JSONDecodeError:
1970
+ return
1971
+ pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
1972
+ filtered = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))]
1973
+ if len(filtered) == len(pre_tool):
1974
+ return
1975
+ settings["hooks"]["PreToolUse"] = filtered
1976
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
1977
+ print(f" .codebuddy/settings.json -> PreToolUse hook removed")
1978
+
1979
+
1980
+ def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None:
1981
+ """Remove the graphify skill tree (SKILL.md + references/) and the CODEBUDDY.md section."""
1982
+ project_dir = project_dir or Path(".")
1983
+ _remove_skill_file("codebuddy", project=project, project_dir=project_dir)
1984
+ target = project_dir / "CODEBUDDY.md"
1985
+
1986
+ if not target.exists():
1987
+ print("No CODEBUDDY.md found in current directory - nothing to do")
1988
+ return
1989
+
1990
+ content = target.read_text(encoding="utf-8")
1991
+ if _CODEBUDDY_MD_MARKER not in content:
1992
+ print("graphify section not found in CODEBUDDY.md - nothing to do")
1993
+ return
1994
+
1995
+ # Remove the ## graphify section: from the marker to the next ## heading or EOF
1996
+ cleaned = re.sub(
1997
+ r"\n*## graphify\n.*?(?=\n## |\Z)",
1998
+ "",
1999
+ content,
2000
+ flags=re.DOTALL,
2001
+ ).rstrip()
2002
+ if cleaned:
2003
+ target.write_text(cleaned + "\n", encoding="utf-8")
2004
+ print(f"graphify section removed from {target.resolve()}")
2005
+ else:
2006
+ target.unlink()
2007
+ print(f"CODEBUDDY.md was empty after removal - deleted {target.resolve()}")
2008
+
2009
+ _uninstall_codebuddy_hook(project_dir or Path("."))
2010
+
2011
+ def _clone_repo(
2012
+ url: str, branch: str | None = None, out_dir: Path | None = None
2013
+ ) -> Path:
2014
+ """Clone a GitHub repo to a local cache dir and return the path.
2015
+
2016
+ Clones into ~/.graphify/repos/<owner>/<repo> by default so repeated
2017
+ runs on the same URL reuse the existing clone (git pull instead of clone).
2018
+ """
2019
+ import subprocess as _sp
2020
+ import re as _re
2021
+
2022
+ # Normalise URL — strip trailing .git if present
2023
+ url = url.rstrip("/")
2024
+ if not url.endswith(".git"):
2025
+ git_url = url + ".git"
2026
+ else:
2027
+ git_url = url
2028
+ url = url[:-4]
2029
+
2030
+ # Extract owner/repo from URL
2031
+ m = _re.search(r"github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$", url)
2032
+ if not m:
2033
+ print(f"error: not a recognised GitHub URL: {url}", file=sys.stderr)
2034
+ sys.exit(1)
2035
+ owner, repo = m.group(1), m.group(2)
2036
+
2037
+ if out_dir:
2038
+ dest = out_dir
2039
+ else:
2040
+ dest = Path.home() / ".graphify" / "repos" / owner / repo
2041
+
2042
+ if branch and branch.startswith("-"):
2043
+ print(f"error: invalid branch name: {branch!r}", file=sys.stderr)
2044
+ sys.exit(1)
2045
+
2046
+ if dest.exists():
2047
+ print(f"Repo already cloned at {dest} - pulling latest...", flush=True)
2048
+ cmd = ["git", "-C", str(dest), "pull"]
2049
+ if branch:
2050
+ cmd += ["origin", "--", branch]
2051
+ result = _sp.run(cmd, capture_output=True, text=True)
2052
+ if result.returncode != 0:
2053
+ print(f"warning: git pull failed:\n{result.stderr}", file=sys.stderr)
2054
+ else:
2055
+ dest.parent.mkdir(parents=True, exist_ok=True)
2056
+ print(f"Cloning {url} -> {dest} ...", flush=True)
2057
+ cmd = ["git", "clone", "--depth", "1"]
2058
+ if branch:
2059
+ cmd += ["--branch", branch]
2060
+ cmd += ["--", git_url, str(dest)]
2061
+ result = _sp.run(cmd, capture_output=True, text=True)
2062
+ if result.returncode != 0:
2063
+ print(f"error: git clone failed:\n{result.stderr}", file=sys.stderr)
2064
+ sys.exit(1)
2065
+
2066
+ print(f"Ready at: {dest}", flush=True)
2067
+ return dest
2068
+
2069
+
2070
+ def main() -> None:
2071
+ for _stream in (sys.stdout, sys.stderr):
2072
+ if _stream is not None and hasattr(_stream, "reconfigure"):
2073
+ try:
2074
+ _stream.reconfigure(encoding="utf-8", errors="replace")
2075
+ except Exception:
2076
+ pass
2077
+ # Check all known skill install locations for a stale version stamp.
2078
+ # Skip during install/uninstall (hook writes trigger a fresh check anyway).
2079
+ # Skip during hook-check — it runs on every editor tool use and must be silent.
2080
+ # Deduplicate paths so platforms sharing the same install dir don't warn twice.
2081
+ _silent_cmds = {"install", "uninstall", "hook-check"}
2082
+ if not any(arg in _silent_cmds for arg in sys.argv):
2083
+ # Resolve each platform's real user-scope destination so per-platform
2084
+ # overrides (gemini, opencode, devin, antigravity, amp) check the dir
2085
+ # they actually install into, not the bare cfg['skill_dst'].
2086
+ for skill_dst in {_platform_skill_destination(name) for name in _PLATFORM_CONFIG}:
2087
+ _check_skill_version(skill_dst)
2088
+
2089
+ if len(sys.argv) >= 2 and sys.argv[1] in ("-v", "--version", "version"):
2090
+ print(f"graphify {__version__}")
2091
+ return
2092
+
2093
+ if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "-?"):
2094
+ print("Usage: graphify <command>")
2095
+ print()
2096
+ print("Commands:")
2097
+ print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)")
2098
+ print(" uninstall remove graphify from all detected platforms in one shot")
2099
+ print(" --purge also delete graphify-out/ directory")
2100
+ print(" path \"A\" \"B\" shortest path between two nodes in graph.json")
2101
+ print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
2102
+ print(" explain \"X\" plain-language explanation of a node and its neighbors")
2103
+ print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
2104
+ print(" diagnose multigraph report same-endpoint edge collapse risk in graph.json")
2105
+ print(" --graph <path> path to graph/extraction JSON")
2106
+ print(" (default graphify-out/graph.json)")
2107
+ print(" --json emit machine-readable JSON")
2108
+ print(" --max-examples N max same-endpoint examples to print (default 5)")
2109
+ print(" --directed force directed post-build simulation")
2110
+ print(" --undirected force undirected post-build simulation")
2111
+ print(" (default follows JSON directed flag;")
2112
+ print(" raw extraction with no flag defaults directed)")
2113
+ print(" --extract-path PATH extractor source for suppression scan")
2114
+ print(" clone <github-url> clone a GitHub repo locally and print its path for /graphify")
2115
+ print(" merge-driver <base> <current> <other> git merge driver: union-merge two graph.json files (set up via hook install)")
2116
+ print(" merge-graphs <g1> <g2> merge two or more graph.json files into one cross-repo graph")
2117
+ print(" --out <path> output path (default: graphify-out/merged-graph.json)")
2118
+ print(" --branch <branch> checkout a specific branch (default: repo default)")
2119
+ print(" --out <dir> clone to a custom directory (default: ~/.graphify/repos/<owner>/<repo>)")
2120
+ print(" add <url> fetch a URL and save it to ./raw, then update the graph")
2121
+ print(" --author \"Name\" tag the author of the content")
2122
+ print(" --contributor \"Name\" tag who added it to the corpus")
2123
+ print(" --dir <path> target directory (default: ./raw)")
2124
+ print(" watch <path> watch a folder and rebuild the graph on code changes")
2125
+ print(" update <path> re-extract code files and update the graph (no LLM needed)")
2126
+ print(" --force overwrite graph.json even if the rebuild has fewer nodes")
2127
+ print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)")
2128
+ print(" --no-cluster skip clustering, write raw extraction only")
2129
+ print(" cluster-only <path> rerun clustering on an existing graph.json and regenerate report")
2130
+ print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)")
2131
+ print(" --graph <path> path to graph.json (default <path>/graphify-out/graph.json)")
2132
+ print(" --no-label keep 'Community N' placeholders (skip LLM community naming)")
2133
+ print(" --backend=<name> backend to use for community naming (default: auto-detect)")
2134
+ print(" label <path> (re)name communities with the configured LLM backend, regenerate report")
2135
+ print(" --backend=<name> backend to use (default: auto-detect from API keys)")
2136
+ print(" query \"<question>\" BFS traversal of graph.json for a question")
2137
+ print(" --dfs use depth-first instead of breadth-first")
2138
+ print(" --context C explicit edge-context filter (repeatable)")
2139
+ print(" --budget N cap output at N tokens (default 2000)")
2140
+ print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
2141
+ print(" affected \"X\" reverse traversal to find nodes impacted by X")
2142
+ print(" --relation R edge relation to traverse in reverse (repeatable)")
2143
+ print(" --depth N reverse traversal depth (default 2)")
2144
+ print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
2145
+ print(" save-result save a Q&A result to graphify-out/memory/ for graph feedback loop")
2146
+ print(" --question Q the question asked")
2147
+ print(" --answer A the answer to save")
2148
+ print(
2149
+ " --type T query type: query|path_query|explain (default: query)"
2150
+ )
2151
+ print(" --nodes N1 N2 ... source node labels cited in the answer")
2152
+ print(" --memory-dir DIR memory directory (default: graphify-out/memory)")
2153
+ print(" check-update <path> check needs_update flag and notify if semantic re-extraction is pending (cron-safe)")
2154
+ print(" tree emit a D3 v7 collapsible-tree HTML for graph.json")
2155
+ print(" --graph PATH path to graph.json (default graphify-out/graph.json)")
2156
+ print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)")
2157
+ print(" --root PATH filesystem root for the hierarchy")
2158
+ print(" --max-children N cap children per node (default 200)")
2159
+ print(" --top-k-edges N per-symbol outbound edges in inspector (default 12)")
2160
+ print(" --label NAME project label in header")
2161
+ print(" extract <path> headless full extraction (AST + semantic LLM) for CI/scripts")
2162
+ print(" --backend B gemini|kimi|claude|openai|deepseek|ollama (default: whichever API key is set)")
2163
+ print(" --model M override backend default model")
2164
+ print(" --mode deep aggressive INFERRED-edge semantic extraction")
2165
+ print(" --max-workers N AST extraction subprocess count (default: cpu_count)")
2166
+ print(" --token-budget N per-chunk token cap for semantic extraction (default: 60000)")
2167
+ print(" --max-concurrency N parallel semantic chunks in flight (default: 4; set 1 for local LLMs)")
2168
+ print(" --api-timeout S per-request timeout in seconds for the LLM client (default: 600)")
2169
+ print(" --out DIR output dir (default: <path>); writes <DIR>/graphify-out/")
2170
+ print(" --google-workspace export .gdoc/.gsheet/.gslides shortcuts via gws before extraction")
2171
+ print(" --no-cluster skip clustering, write raw extraction only")
2172
+ print(" --postgres DSN extract schema from a live PostgreSQL database")
2173
+ print(" maps tables, views, functions + FK relationships;")
2174
+ print(" column-level detail is not represented in the graph")
2175
+ print(" --global also merge the resulting graph into the global graph")
2176
+ print(" --as <tag> repo tag for --global (default: target directory name)")
2177
+ print(" global add <graph.json> add/update a project graph in the global graph (~/.graphify/global-graph.json)")
2178
+ print(" --as <tag> repo tag (default: parent directory name)")
2179
+ print(" global remove <tag> remove a repo's nodes from the global graph")
2180
+ print(" global list list repos in the global graph")
2181
+ print(" global path print path to the global graph file")
2182
+ print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach")
2183
+ print(" export callflow-html emit Mermaid-based architecture/call-flow HTML")
2184
+ print(" hook install install post-commit/post-checkout git hooks (all platforms)")
2185
+ print(" hook uninstall remove git hooks")
2186
+ print(" hook status check if git hooks are installed")
2187
+ print(
2188
+ " gemini install write GEMINI.md section + BeforeTool hook (Gemini CLI)"
2189
+ )
2190
+ print(" gemini uninstall remove GEMINI.md section + BeforeTool hook")
2191
+ print(" cursor install write .cursor/rules/graphify.mdc (Cursor)")
2192
+ print(" cursor uninstall remove .cursor/rules/graphify.mdc")
2193
+ print(" claude install write graphify section to CLAUDE.md + PreToolUse hook (Claude Code)")
2194
+ print(" claude uninstall remove graphify section from CLAUDE.md + PreToolUse hook")
2195
+ print(" codebuddy install write graphify section to CODEBUDDY.md + PreToolUse hook (CodeBuddy)")
2196
+ print(" codebuddy uninstall remove graphify section from CODEBUDDY.md + PreToolUse hook")
2197
+ print(" codex install write graphify section to AGENTS.md (Codex)")
2198
+ print(" codex uninstall remove graphify section from AGENTS.md")
2199
+ print(
2200
+ " opencode install write graphify section to AGENTS.md + tool.execute.before plugin (OpenCode)"
2201
+ )
2202
+ print(
2203
+ " opencode uninstall remove graphify section from AGENTS.md + plugin"
2204
+ )
2205
+ print(
2206
+ " kilo install install native Kilo skill + command + AGENTS.md + .kilo plugin"
2207
+ )
2208
+ print(
2209
+ " kilo uninstall remove native Kilo skill + command + AGENTS.md + .kilo plugin"
2210
+ )
2211
+ print(" aider install write graphify section to AGENTS.md (Aider)")
2212
+ print(" aider uninstall remove graphify section from AGENTS.md")
2213
+ print(
2214
+ " copilot install copy graphify skill to ~/.copilot/skills (GitHub Copilot CLI)"
2215
+ )
2216
+ print(" copilot uninstall remove graphify skill from ~/.copilot/skills")
2217
+ print(
2218
+ " vscode install configure VS Code Copilot Chat (skill + .github/copilot-instructions.md)"
2219
+ )
2220
+ print(" vscode uninstall remove VS Code Copilot Chat configuration")
2221
+ print(
2222
+ " claw install write graphify section to AGENTS.md (OpenClaw)"
2223
+ )
2224
+ print(" claw uninstall remove graphify section from AGENTS.md")
2225
+ print(
2226
+ " droid install write graphify section to AGENTS.md (Factory Droid)"
2227
+ )
2228
+ print(" droid uninstall remove graphify section from AGENTS.md")
2229
+ print(" trae install write graphify section to AGENTS.md (Trae)")
2230
+ print(" trae uninstall remove graphify section from AGENTS.md")
2231
+ print(" trae-cn install write graphify section to AGENTS.md (Trae CN)")
2232
+ print(" trae-cn uninstall remove graphify section from AGENTS.md")
2233
+ print(
2234
+ " antigravity install write .agents/rules + .agents/workflows + skill (Google Antigravity)"
2235
+ )
2236
+ print(
2237
+ " antigravity uninstall remove .agents/rules, .agents/workflows, and skill"
2238
+ )
2239
+ print(
2240
+ " hermes install write skill to ~/.hermes/skills/graphify/ (Hermes)"
2241
+ )
2242
+ print(" hermes uninstall remove skill from ~/.hermes/skills/graphify/")
2243
+ print(
2244
+ " kiro install write skill to .kiro/skills/graphify/ + steering file (Kiro IDE/CLI)"
2245
+ )
2246
+ print(" kiro uninstall remove skill + steering file")
2247
+ print(" pi install write skill to ~/.pi/agent/skills/graphify/ (Pi coding agent)")
2248
+ print(" pi uninstall remove skill from ~/.pi/agent/skills/graphify/")
2249
+ print(" devin install write skill to ~/.config/devin/skills/graphify/ (Devin CLI)")
2250
+ print(" devin uninstall remove skill from ~/.config/devin/skills/graphify/")
2251
+ print()
2252
+ return
2253
+
2254
+ cmd = sys.argv[1]
2255
+
2256
+ # Universal help guard: -h/--help/-? anywhere after the command shows help
2257
+ # and stops — prevents flags from silently triggering destructive subcommands
2258
+ # (e.g. "cursor install --help" was silently installing into Cursor, #821).
2259
+ # Exempt: free-text commands (user string may contain these tokens), and
2260
+ # "install"/"uninstall" which have their own per-subcommand help handlers.
2261
+ _FREE_TEXT_CMDS = {"query", "explain", "path", "save-result", "install", "uninstall"}
2262
+ if cmd not in _FREE_TEXT_CMDS and any(a in {"-h", "--help", "-?"} for a in sys.argv[2:]):
2263
+ print(f"Run 'graphify --help' for full usage.")
2264
+ return
2265
+
2266
+ if cmd == "install":
2267
+ # Default to windows platform on Windows, claude elsewhere
2268
+ default_platform = "windows" if platform.system() == "Windows" else "claude"
2269
+ selected_platform: str | None = None
2270
+ project_scope = False
2271
+ args = sys.argv[2:]
2272
+ i = 0
2273
+ while i < len(args):
2274
+ arg = args[i]
2275
+ if arg in ("-h", "--help"):
2276
+ _print_install_usage()
2277
+ return
2278
+ if arg == "--project":
2279
+ project_scope = True
2280
+ i += 1
2281
+ elif arg.startswith("--platform="):
2282
+ candidate = arg.split("=", 1)[1]
2283
+ if selected_platform and selected_platform != candidate:
2284
+ print("error: specify install platform only once", file=sys.stderr)
2285
+ sys.exit(1)
2286
+ selected_platform = candidate
2287
+ i += 1
2288
+ elif arg == "--platform":
2289
+ if i + 1 >= len(args):
2290
+ print("error: --platform requires a value", file=sys.stderr)
2291
+ sys.exit(1)
2292
+ candidate = args[i + 1]
2293
+ if selected_platform and selected_platform != candidate:
2294
+ print("error: specify install platform only once", file=sys.stderr)
2295
+ sys.exit(1)
2296
+ selected_platform = candidate
2297
+ i += 2
2298
+ elif arg.startswith("-"):
2299
+ print(f"error: unknown install option '{arg}'", file=sys.stderr)
2300
+ sys.exit(1)
2301
+ else:
2302
+ if selected_platform and selected_platform != arg:
2303
+ print("error: specify install platform only once", file=sys.stderr)
2304
+ sys.exit(1)
2305
+ selected_platform = arg
2306
+ i += 1
2307
+ chosen_platform = selected_platform or default_platform
2308
+ if project_scope:
2309
+ _project_install(chosen_platform, Path("."))
2310
+ else:
2311
+ install(platform=chosen_platform)
2312
+ elif cmd == "uninstall":
2313
+ args = sys.argv[2:]
2314
+ purge = "--purge" in args
2315
+ project_scope = "--project" in args
2316
+ selected_platform = None
2317
+ i = 0
2318
+ while i < len(args):
2319
+ arg = args[i]
2320
+ if arg in ("--purge", "--project"):
2321
+ i += 1
2322
+ elif arg.startswith("--platform="):
2323
+ selected_platform = arg.split("=", 1)[1]
2324
+ i += 1
2325
+ elif arg == "--platform":
2326
+ if i + 1 >= len(args):
2327
+ print("error: --platform requires a value", file=sys.stderr)
2328
+ sys.exit(1)
2329
+ selected_platform = args[i + 1]
2330
+ i += 2
2331
+ elif arg.startswith("-"):
2332
+ print(f"error: unknown uninstall option '{arg}'", file=sys.stderr)
2333
+ sys.exit(1)
2334
+ else:
2335
+ selected_platform = arg
2336
+ i += 1
2337
+ if project_scope:
2338
+ if selected_platform:
2339
+ _project_uninstall(selected_platform, Path("."))
2340
+ else:
2341
+ _project_uninstall_all(Path("."))
2342
+ else:
2343
+ uninstall_all(purge=purge)
2344
+ elif cmd == "claude":
2345
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2346
+ if subcmd == "install":
2347
+ if "--project" in sys.argv[3:]:
2348
+ _project_install("claude", Path("."))
2349
+ else:
2350
+ claude_install()
2351
+ elif subcmd == "uninstall":
2352
+ if "--project" in sys.argv[3:]:
2353
+ _project_uninstall("claude", Path("."))
2354
+ else:
2355
+ claude_uninstall()
2356
+ else:
2357
+ print("Usage: graphify claude [install|uninstall]", file=sys.stderr)
2358
+ sys.exit(1)
2359
+ elif cmd == "codebuddy":
2360
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2361
+ if subcmd == "install":
2362
+ codebuddy_install()
2363
+ elif subcmd == "uninstall":
2364
+ codebuddy_uninstall()
2365
+ else:
2366
+ print("Usage: graphify codebuddy [install|uninstall]", file=sys.stderr)
2367
+ sys.exit(1)
2368
+ elif cmd == "gemini":
2369
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2370
+ if subcmd == "install":
2371
+ gemini_install(project=("--project" in sys.argv[3:]))
2372
+ elif subcmd == "uninstall":
2373
+ gemini_uninstall(project=("--project" in sys.argv[3:]))
2374
+ else:
2375
+ print("Usage: graphify gemini [install|uninstall]", file=sys.stderr)
2376
+ sys.exit(1)
2377
+ elif cmd == "cursor":
2378
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2379
+ if subcmd == "install":
2380
+ _cursor_install(Path("."))
2381
+ elif subcmd == "uninstall":
2382
+ _cursor_uninstall(Path("."))
2383
+ else:
2384
+ print("Usage: graphify cursor [install|uninstall]", file=sys.stderr)
2385
+ sys.exit(1)
2386
+ elif cmd == "vscode":
2387
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2388
+ if subcmd == "install":
2389
+ vscode_install()
2390
+ elif subcmd == "uninstall":
2391
+ vscode_uninstall()
2392
+ else:
2393
+ print("Usage: graphify vscode [install|uninstall]", file=sys.stderr)
2394
+ sys.exit(1)
2395
+ elif cmd == "copilot":
2396
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2397
+ if subcmd == "install":
2398
+ if "--project" in sys.argv[3:]:
2399
+ _project_install("copilot", Path("."))
2400
+ else:
2401
+ install(platform="copilot")
2402
+ elif subcmd == "uninstall":
2403
+ if "--project" in sys.argv[3:]:
2404
+ _project_uninstall("copilot", Path("."))
2405
+ else:
2406
+ removed = _remove_skill_file("copilot")
2407
+ print("skill removed" if removed else "nothing to remove")
2408
+ else:
2409
+ print("Usage: graphify copilot [install|uninstall]", file=sys.stderr)
2410
+ sys.exit(1)
2411
+ elif cmd == "kilo":
2412
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2413
+ if subcmd == "install":
2414
+ _kilo_install(Path("."))
2415
+ elif subcmd == "uninstall":
2416
+ _kilo_uninstall(Path("."))
2417
+ else:
2418
+ print("Usage: graphify kilo [install|uninstall]", file=sys.stderr)
2419
+ sys.exit(1)
2420
+ elif cmd == "kiro":
2421
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2422
+ if subcmd == "install":
2423
+ _kiro_install(Path("."))
2424
+ elif subcmd == "uninstall":
2425
+ _kiro_uninstall(Path("."))
2426
+ else:
2427
+ print("Usage: graphify kiro [install|uninstall]", file=sys.stderr)
2428
+ sys.exit(1)
2429
+ elif cmd == "devin":
2430
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2431
+ if subcmd == "install":
2432
+ if "--project" in sys.argv[3:]:
2433
+ _project_install("devin", Path("."))
2434
+ else:
2435
+ install(platform="devin")
2436
+ elif subcmd == "uninstall":
2437
+ if "--project" in sys.argv[3:]:
2438
+ _project_uninstall("devin", Path("."))
2439
+ else:
2440
+ removed = _remove_skill_file("devin")
2441
+ print("skill removed" if removed else "nothing to remove")
2442
+ else:
2443
+ print("Usage: graphify devin [install|uninstall]", file=sys.stderr)
2444
+ sys.exit(1)
2445
+ elif cmd == "pi":
2446
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2447
+ if subcmd == "install":
2448
+ if "--project" in sys.argv[3:]:
2449
+ _project_install("pi", Path("."))
2450
+ else:
2451
+ install("pi")
2452
+ elif subcmd == "uninstall":
2453
+ if "--project" in sys.argv[3:]:
2454
+ _project_uninstall("pi", Path("."))
2455
+ else:
2456
+ _remove_skill_file("pi")
2457
+ else:
2458
+ print("Usage: graphify pi [install|uninstall]", file=sys.stderr)
2459
+ sys.exit(1)
2460
+ elif cmd == "amp":
2461
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2462
+ if subcmd == "install":
2463
+ if "--project" in sys.argv[3:]:
2464
+ _project_install("amp", Path("."))
2465
+ else:
2466
+ _amp_install(Path("."))
2467
+ elif subcmd == "uninstall":
2468
+ if "--project" in sys.argv[3:]:
2469
+ _project_uninstall("amp", Path("."))
2470
+ else:
2471
+ _amp_uninstall(Path("."))
2472
+ else:
2473
+ print("Usage: graphify amp [install|uninstall]", file=sys.stderr)
2474
+ sys.exit(1)
2475
+ elif cmd in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
2476
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2477
+ if subcmd == "install":
2478
+ if "--project" in sys.argv[3:]:
2479
+ _project_install(cmd, Path("."))
2480
+ else:
2481
+ _agents_install(Path("."), cmd)
2482
+ elif subcmd == "uninstall":
2483
+ if "--project" in sys.argv[3:]:
2484
+ _project_uninstall(cmd, Path("."))
2485
+ else:
2486
+ _agents_uninstall(Path("."), platform=cmd)
2487
+ if cmd == "codex":
2488
+ _uninstall_codex_hook(Path("."))
2489
+ else:
2490
+ print(f"Usage: graphify {cmd} [install|uninstall]", file=sys.stderr)
2491
+ sys.exit(1)
2492
+ elif cmd == "antigravity":
2493
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2494
+ if subcmd == "install":
2495
+ if "--project" in sys.argv[3:]:
2496
+ _project_install("antigravity", Path("."))
2497
+ else:
2498
+ _antigravity_install(Path("."))
2499
+ elif subcmd == "uninstall":
2500
+ if "--project" in sys.argv[3:]:
2501
+ _project_uninstall("antigravity", Path("."))
2502
+ else:
2503
+ _antigravity_uninstall(Path("."))
2504
+ else:
2505
+ print("Usage: graphify antigravity [install|uninstall]", file=sys.stderr)
2506
+ sys.exit(1)
2507
+ elif cmd == "provider":
2508
+ from graphify.llm import _custom_providers_path, BACKENDS
2509
+ import json as _json
2510
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2511
+ global_path = _custom_providers_path(global_=True)
2512
+
2513
+ if subcmd == "list":
2514
+ global_path.parent.mkdir(parents=True, exist_ok=True)
2515
+ existing: dict = {}
2516
+ if global_path.is_file():
2517
+ try:
2518
+ existing = _json.loads(global_path.read_text(encoding="utf-8"))
2519
+ except Exception:
2520
+ pass
2521
+ if not existing:
2522
+ print("No custom providers registered.")
2523
+ else:
2524
+ for name in existing:
2525
+ print(f" {name} ({existing[name].get('base_url', '')})")
2526
+
2527
+ elif subcmd == "show":
2528
+ name = sys.argv[3] if len(sys.argv) > 3 else ""
2529
+ if not name:
2530
+ print("Usage: graphify provider show <name>", file=sys.stderr)
2531
+ sys.exit(1)
2532
+ existing = {}
2533
+ if global_path.is_file():
2534
+ try:
2535
+ existing = _json.loads(global_path.read_text(encoding="utf-8"))
2536
+ except Exception:
2537
+ pass
2538
+ if name not in existing:
2539
+ print(f"Provider '{name}' not found.", file=sys.stderr)
2540
+ sys.exit(1)
2541
+ print(_json.dumps({name: existing[name]}, indent=2))
2542
+
2543
+ elif subcmd == "add":
2544
+ args = sys.argv[3:]
2545
+ name = args[0] if args and not args[0].startswith("-") else ""
2546
+ if not name:
2547
+ print("Usage: graphify provider add <name> --base-url URL --default-model MODEL --env-key KEY", file=sys.stderr)
2548
+ sys.exit(1)
2549
+ if name in BACKENDS:
2550
+ print(f"Error: '{name}' is a built-in provider and cannot be overridden.", file=sys.stderr)
2551
+ sys.exit(1)
2552
+ base_url = ""
2553
+ default_model = ""
2554
+ env_key = ""
2555
+ pricing_input = 0.0
2556
+ pricing_output = 0.0
2557
+ i = 1
2558
+ while i < len(args):
2559
+ a = args[i]
2560
+ if a == "--base-url" and i + 1 < len(args):
2561
+ base_url = args[i + 1]; i += 2
2562
+ elif a.startswith("--base-url="):
2563
+ base_url = a.split("=", 1)[1]; i += 1
2564
+ elif a == "--default-model" and i + 1 < len(args):
2565
+ default_model = args[i + 1]; i += 2
2566
+ elif a.startswith("--default-model="):
2567
+ default_model = a.split("=", 1)[1]; i += 1
2568
+ elif a == "--env-key" and i + 1 < len(args):
2569
+ env_key = args[i + 1]; i += 2
2570
+ elif a.startswith("--env-key="):
2571
+ env_key = a.split("=", 1)[1]; i += 1
2572
+ elif a == "--pricing-input" and i + 1 < len(args):
2573
+ pricing_input = float(args[i + 1]); i += 2
2574
+ elif a == "--pricing-output" and i + 1 < len(args):
2575
+ pricing_output = float(args[i + 1]); i += 2
2576
+ else:
2577
+ i += 1
2578
+ if not base_url or not default_model or not env_key:
2579
+ print("Error: --base-url, --default-model, and --env-key are required.", file=sys.stderr)
2580
+ sys.exit(1)
2581
+ from graphify.llm import provider_base_url_ok
2582
+ if not provider_base_url_ok(base_url, name):
2583
+ print(f"Error: refusing to add provider with unsafe base_url {base_url!r}.", file=sys.stderr)
2584
+ sys.exit(1)
2585
+ global_path.parent.mkdir(parents=True, exist_ok=True)
2586
+ existing = {}
2587
+ if global_path.is_file():
2588
+ try:
2589
+ existing = _json.loads(global_path.read_text(encoding="utf-8"))
2590
+ except Exception:
2591
+ pass
2592
+ existing[name] = {
2593
+ "base_url": base_url,
2594
+ "default_model": default_model,
2595
+ "env_key": env_key,
2596
+ "pricing": {"input": pricing_input, "output": pricing_output},
2597
+ "temperature": 0,
2598
+ }
2599
+ global_path.write_text(_json.dumps(existing, indent=2) + "\n", encoding="utf-8")
2600
+ print(f"Provider '{name}' added. Use with: graphify extract . --backend {name}")
2601
+
2602
+ elif subcmd == "remove":
2603
+ name = sys.argv[3] if len(sys.argv) > 3 else ""
2604
+ if not name:
2605
+ print("Usage: graphify provider remove <name>", file=sys.stderr)
2606
+ sys.exit(1)
2607
+ existing = {}
2608
+ if global_path.is_file():
2609
+ try:
2610
+ existing = _json.loads(global_path.read_text(encoding="utf-8"))
2611
+ except Exception:
2612
+ pass
2613
+ if name not in existing:
2614
+ print(f"Provider '{name}' not found.", file=sys.stderr)
2615
+ sys.exit(1)
2616
+ del existing[name]
2617
+ global_path.write_text(_json.dumps(existing, indent=2) + "\n", encoding="utf-8")
2618
+ print(f"Provider '{name}' removed.")
2619
+
2620
+ else:
2621
+ print("Usage: graphify provider [add|list|show|remove]", file=sys.stderr)
2622
+ if subcmd:
2623
+ sys.exit(1)
2624
+ elif cmd == "prs":
2625
+ from graphify.prs import cmd_prs
2626
+ cmd_prs(sys.argv[2:])
2627
+ elif cmd == "hook":
2628
+ from graphify.hooks import (
2629
+ install as hook_install,
2630
+ uninstall as hook_uninstall,
2631
+ status as hook_status,
2632
+ )
2633
+
2634
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2635
+ if subcmd == "install":
2636
+ print(hook_install(Path(".")))
2637
+ elif subcmd == "uninstall":
2638
+ print(hook_uninstall(Path(".")))
2639
+ elif subcmd == "status":
2640
+ print(hook_status(Path(".")))
2641
+ else:
2642
+ print("Usage: graphify hook [install|uninstall|status]", file=sys.stderr)
2643
+ sys.exit(1)
2644
+ elif cmd == "query":
2645
+ if len(sys.argv) < 3:
2646
+ print("Usage: graphify query \"<question>\" [--dfs] [--context C] [--budget N] [--graph path]", file=sys.stderr)
2647
+ sys.exit(1)
2648
+ from graphify.serve import _query_graph_text
2649
+ from graphify.security import sanitize_label
2650
+ from networkx.readwrite import json_graph
2651
+ from graphify import querylog
2652
+
2653
+ question = sys.argv[2]
2654
+ use_dfs = "--dfs" in sys.argv
2655
+ budget = 2000
2656
+ graph_path = _default_graph_path()
2657
+ context_filters: list[str] = []
2658
+ args = sys.argv[3:]
2659
+ i = 0
2660
+ while i < len(args):
2661
+ if args[i] == "--budget" and i + 1 < len(args):
2662
+ try:
2663
+ budget = int(args[i + 1])
2664
+ except ValueError:
2665
+ print(f"error: --budget must be an integer", file=sys.stderr)
2666
+ sys.exit(1)
2667
+ i += 2
2668
+ elif args[i].startswith("--budget="):
2669
+ try:
2670
+ budget = int(args[i].split("=", 1)[1])
2671
+ except ValueError:
2672
+ print(f"error: --budget must be an integer", file=sys.stderr)
2673
+ sys.exit(1)
2674
+ i += 1
2675
+ elif args[i] == "--context" and i + 1 < len(args):
2676
+ context_filters.append(args[i + 1])
2677
+ i += 2
2678
+ elif args[i].startswith("--context="):
2679
+ context_filters.append(args[i].split("=", 1)[1])
2680
+ i += 1
2681
+ elif args[i] == "--graph" and i + 1 < len(args):
2682
+ graph_path = args[i + 1]
2683
+ i += 2
2684
+ else:
2685
+ i += 1
2686
+ gp = Path(graph_path).resolve()
2687
+ if not gp.exists():
2688
+ print(f"error: graph file not found: {gp}", file=sys.stderr)
2689
+ sys.exit(1)
2690
+ if not gp.suffix == ".json":
2691
+ print(f"error: graph file must be a .json file", file=sys.stderr)
2692
+ sys.exit(1)
2693
+ _enforce_graph_size_cap_or_exit(gp)
2694
+ try:
2695
+ import json as _json
2696
+ import networkx as _nx
2697
+
2698
+ _raw = _json.loads(gp.read_text(encoding="utf-8"))
2699
+ if "links" not in _raw and "edges" in _raw:
2700
+ _raw = dict(_raw, links=_raw["edges"])
2701
+ try:
2702
+ G = json_graph.node_link_graph(_raw, edges="links")
2703
+ except TypeError:
2704
+ G = json_graph.node_link_graph(_raw)
2705
+ except Exception as exc:
2706
+ print(f"error: could not load graph: {exc}", file=sys.stderr)
2707
+ sys.exit(1)
2708
+ import time as _time
2709
+ _t0 = _time.perf_counter()
2710
+ _mode = "dfs" if use_dfs else "bfs"
2711
+ _result = _query_graph_text(
2712
+ G,
2713
+ question,
2714
+ mode=_mode,
2715
+ depth=2,
2716
+ token_budget=budget,
2717
+ context_filters=context_filters,
2718
+ )
2719
+ querylog.log_query(
2720
+ kind="query",
2721
+ question=question,
2722
+ corpus=str(gp),
2723
+ result=_result,
2724
+ mode=_mode,
2725
+ depth=2,
2726
+ token_budget=budget,
2727
+ duration_ms=(_time.perf_counter() - _t0) * 1000,
2728
+ )
2729
+ print(_result)
2730
+ elif cmd == "affected":
2731
+ if len(sys.argv) < 3:
2732
+ print("Usage: graphify affected \"<node-or-label>\" [--relation R] [--depth N] [--graph path]", file=sys.stderr)
2733
+ sys.exit(1)
2734
+ from graphify.affected import DEFAULT_AFFECTED_RELATIONS, format_affected, load_graph
2735
+ query = sys.argv[2]
2736
+ graph_path = "graphify-out/graph.json"
2737
+ depth = 2
2738
+ relations: list[str] = []
2739
+ args = sys.argv[3:]
2740
+ i = 0
2741
+ while i < len(args):
2742
+ if args[i] == "--graph" and i + 1 < len(args):
2743
+ graph_path = args[i + 1]
2744
+ i += 2
2745
+ elif args[i].startswith("--graph="):
2746
+ graph_path = args[i].split("=", 1)[1]
2747
+ i += 1
2748
+ elif args[i] == "--depth" and i + 1 < len(args):
2749
+ try:
2750
+ depth = int(args[i + 1])
2751
+ except ValueError:
2752
+ print("error: --depth must be an integer", file=sys.stderr)
2753
+ sys.exit(1)
2754
+ i += 2
2755
+ elif args[i].startswith("--depth="):
2756
+ try:
2757
+ depth = int(args[i].split("=", 1)[1])
2758
+ except ValueError:
2759
+ print("error: --depth must be an integer", file=sys.stderr)
2760
+ sys.exit(1)
2761
+ i += 1
2762
+ elif args[i] == "--relation" and i + 1 < len(args):
2763
+ relations.append(args[i + 1])
2764
+ i += 2
2765
+ elif args[i].startswith("--relation="):
2766
+ relations.append(args[i].split("=", 1)[1])
2767
+ i += 1
2768
+ else:
2769
+ i += 1
2770
+ gp = Path(graph_path).resolve()
2771
+ if not gp.exists():
2772
+ print(f"error: graph file not found: {gp}", file=sys.stderr)
2773
+ sys.exit(1)
2774
+ if not gp.suffix == ".json":
2775
+ print("error: graph file must be a .json file", file=sys.stderr)
2776
+ sys.exit(1)
2777
+ try:
2778
+ graph = load_graph(gp)
2779
+ except Exception as exc:
2780
+ print(f"error: could not load graph: {exc}", file=sys.stderr)
2781
+ sys.exit(1)
2782
+ print(
2783
+ format_affected(
2784
+ graph,
2785
+ query,
2786
+ relations=relations or DEFAULT_AFFECTED_RELATIONS,
2787
+ depth=depth,
2788
+ )
2789
+ )
2790
+ elif cmd == "save-result":
2791
+ # graphify save-result --question Q --answer A --type T [--nodes N1 N2 ...]
2792
+ import argparse as _ap
2793
+
2794
+ p = _ap.ArgumentParser(prog="graphify save-result")
2795
+ p.add_argument("--question", required=True)
2796
+ p.add_argument("--answer", required=True)
2797
+ p.add_argument("--type", dest="query_type", default="query")
2798
+ p.add_argument("--nodes", nargs="*", default=[])
2799
+ p.add_argument("--memory-dir", default="graphify-out/memory")
2800
+ opts = p.parse_args(sys.argv[2:])
2801
+ from graphify.ingest import save_query_result as _sqr
2802
+
2803
+ out = _sqr(
2804
+ question=opts.question,
2805
+ answer=opts.answer,
2806
+ memory_dir=Path(opts.memory_dir),
2807
+ query_type=opts.query_type,
2808
+ source_nodes=opts.nodes or None,
2809
+ )
2810
+ print(f"Saved to {out}")
2811
+ elif cmd == "path":
2812
+ if len(sys.argv) < 4:
2813
+ print(
2814
+ 'Usage: graphify path "<source>" "<target>" [--graph path]',
2815
+ file=sys.stderr,
2816
+ )
2817
+ sys.exit(1)
2818
+ from graphify.serve import _score_nodes
2819
+ from networkx.readwrite import json_graph
2820
+ import networkx as _nx
2821
+
2822
+ source_label = sys.argv[2]
2823
+ target_label = sys.argv[3]
2824
+ graph_path = _default_graph_path()
2825
+ args = sys.argv[4:]
2826
+ for i, a in enumerate(args):
2827
+ if a == "--graph" and i + 1 < len(args):
2828
+ graph_path = args[i + 1]
2829
+ gp = Path(graph_path).resolve()
2830
+ if not gp.exists():
2831
+ print(f"error: graph file not found: {gp}", file=sys.stderr)
2832
+ sys.exit(1)
2833
+ _enforce_graph_size_cap_or_exit(gp)
2834
+ _raw = json.loads(gp.read_text(encoding="utf-8"))
2835
+ if "links" not in _raw and "edges" in _raw:
2836
+ _raw = dict(_raw, links=_raw["edges"])
2837
+ # Force directed so the renderer can recover stored caller→callee direction.
2838
+ _raw = {**_raw, "directed": True}
2839
+ try:
2840
+ G = json_graph.node_link_graph(_raw, edges="links")
2841
+ except TypeError:
2842
+ G = json_graph.node_link_graph(_raw)
2843
+ src_scored = _score_nodes(G, [t.lower() for t in source_label.split()])
2844
+ tgt_scored = _score_nodes(G, [t.lower() for t in target_label.split()])
2845
+ if not src_scored:
2846
+ print(f"No node matching '{source_label}' found.", file=sys.stderr)
2847
+ sys.exit(1)
2848
+ if not tgt_scored:
2849
+ print(f"No node matching '{target_label}' found.", file=sys.stderr)
2850
+ sys.exit(1)
2851
+ src_nid, tgt_nid = src_scored[0][1], tgt_scored[0][1]
2852
+ # Ambiguity guard: when both queries resolve to the same node, the
2853
+ # shortest path is trivially zero hops, which is almost never what the
2854
+ # caller wanted (see bug #828).
2855
+ if src_nid == tgt_nid:
2856
+ print(
2857
+ f"'{source_label}' and '{target_label}' both resolved to the same "
2858
+ f"node '{src_nid}'. Use a more specific label or the exact node ID.",
2859
+ file=sys.stderr,
2860
+ )
2861
+ sys.exit(1)
2862
+ for _name, _scored in (("source", src_scored), ("target", tgt_scored)):
2863
+ if len(_scored) >= 2:
2864
+ _top, _runner = _scored[0][0], _scored[1][0]
2865
+ if _top > 0 and (_top - _runner) / _top < 0.10:
2866
+ print(
2867
+ f"warning: {_name} match was ambiguous "
2868
+ f"(top score {_top:g}, runner-up {_runner:g})",
2869
+ file=sys.stderr,
2870
+ )
2871
+ try:
2872
+ path_nodes = _nx.shortest_path(G.to_undirected(as_view=True), src_nid, tgt_nid)
2873
+ except (_nx.NetworkXNoPath, _nx.NodeNotFound):
2874
+ print(f"No path found between '{source_label}' and '{target_label}'.")
2875
+ sys.exit(0)
2876
+ hops = len(path_nodes) - 1
2877
+ segments = []
2878
+ from graphify.build import edge_data
2879
+ for i in range(len(path_nodes) - 1):
2880
+ u, v = path_nodes[i], path_nodes[i + 1]
2881
+ # Check which direction the stored edge points.
2882
+ if G.has_edge(u, v):
2883
+ edata = edge_data(G, u, v)
2884
+ forward = True
2885
+ else:
2886
+ edata = edge_data(G, v, u)
2887
+ forward = False
2888
+ rel = edata.get("relation", "")
2889
+ conf = edata.get("confidence", "")
2890
+ conf_str = f" [{conf}]" if conf else ""
2891
+ if i == 0:
2892
+ segments.append(G.nodes[u].get("label", u))
2893
+ if forward:
2894
+ segments.append(f"--{rel}{conf_str}--> {G.nodes[v].get('label', v)}")
2895
+ else:
2896
+ segments.append(f"<--{rel}{conf_str}-- {G.nodes[v].get('label', v)}")
2897
+ print(f"Shortest path ({hops} hops):\n " + " ".join(segments))
2898
+ from graphify import querylog
2899
+ querylog.log_query(
2900
+ kind="path",
2901
+ question=f"{sys.argv[2]} -> {sys.argv[3]}",
2902
+ corpus=str(gp),
2903
+ nodes_returned=hops,
2904
+ )
2905
+
2906
+ elif cmd == "explain":
2907
+ if len(sys.argv) < 3:
2908
+ print('Usage: graphify explain "<node>" [--graph path]', file=sys.stderr)
2909
+ sys.exit(1)
2910
+ from graphify.serve import _find_node
2911
+ from networkx.readwrite import json_graph
2912
+
2913
+ label = sys.argv[2]
2914
+ graph_path = _default_graph_path()
2915
+ args = sys.argv[3:]
2916
+ for i, a in enumerate(args):
2917
+ if a == "--graph" and i + 1 < len(args):
2918
+ graph_path = args[i + 1]
2919
+ gp = Path(graph_path).resolve()
2920
+ if not gp.exists():
2921
+ print(f"error: graph file not found: {gp}", file=sys.stderr)
2922
+ sys.exit(1)
2923
+ _enforce_graph_size_cap_or_exit(gp)
2924
+ _raw = json.loads(gp.read_text(encoding="utf-8"))
2925
+ if "links" not in _raw and "edges" in _raw:
2926
+ _raw = dict(_raw, links=_raw["edges"])
2927
+ # Force directed so the renderer can recover stored caller→callee direction.
2928
+ _raw = {**_raw, "directed": True}
2929
+ try:
2930
+ G = json_graph.node_link_graph(_raw, edges="links")
2931
+ except TypeError:
2932
+ G = json_graph.node_link_graph(_raw)
2933
+ matches = _find_node(G, label)
2934
+ if not matches:
2935
+ print(f"No node matching '{label}' found.")
2936
+ sys.exit(0)
2937
+ nid = matches[0]
2938
+ d = G.nodes[nid]
2939
+ print(f"Node: {d.get('label', nid)}")
2940
+ print(f" ID: {nid}")
2941
+ print(
2942
+ f" Source: {d.get('source_file', '')} {d.get('source_location', '')}".rstrip()
2943
+ )
2944
+ print(f" Type: {d.get('file_type', '')}")
2945
+ print(f" Community: {d.get('community', '')}")
2946
+ print(f" Degree: {G.degree(nid)}")
2947
+ from graphify.build import edge_data
2948
+ connections: list[tuple[str, str, dict]] = [] # (direction, neighbor_id, edge_data)
2949
+ for nb in G.successors(nid):
2950
+ connections.append(("out", nb, edge_data(G, nid, nb)))
2951
+ for nb in G.predecessors(nid):
2952
+ connections.append(("in", nb, edge_data(G, nb, nid)))
2953
+ if connections:
2954
+ print(f"\nConnections ({len(connections)}):")
2955
+ connections.sort(key=lambda c: G.degree(c[1]), reverse=True)
2956
+ for direction, nb, edata in connections[:20]:
2957
+ rel = edata.get("relation", "")
2958
+ conf = edata.get("confidence", "")
2959
+ arrow = "-->" if direction == "out" else "<--"
2960
+ print(f" {arrow} {G.nodes[nb].get('label', nb)} [{rel}] [{conf}]")
2961
+ if len(connections) > 20:
2962
+ print(f" ... and {len(connections) - 20} more")
2963
+ from graphify import querylog
2964
+ querylog.log_query(
2965
+ kind="explain",
2966
+ question=sys.argv[2],
2967
+ corpus=str(gp),
2968
+ nodes_returned=len(connections),
2969
+ )
2970
+
2971
+ elif cmd == "diagnose":
2972
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2973
+ if subcmd != "multigraph":
2974
+ print(
2975
+ "Usage: graphify diagnose multigraph "
2976
+ "[--graph path] [--json] [--max-examples N] "
2977
+ "[--directed] [--undirected] [--extract-path path]",
2978
+ file=sys.stderr,
2979
+ )
2980
+ sys.exit(1)
2981
+
2982
+ graph_path = Path(_default_graph_path())
2983
+ max_examples = 5
2984
+ directed: bool | None = None
2985
+ direction_flag: str | None = None
2986
+ json_output = False
2987
+ extract_path: Path | None = None
2988
+
2989
+ i = 3
2990
+ while i < len(sys.argv):
2991
+ arg = sys.argv[i]
2992
+ if arg == "--graph":
2993
+ i += 1
2994
+ if i >= len(sys.argv):
2995
+ print("error: --graph requires a path", file=sys.stderr)
2996
+ sys.exit(1)
2997
+ graph_path = Path(sys.argv[i])
2998
+ elif arg == "--json":
2999
+ json_output = True
3000
+ elif arg == "--max-examples":
3001
+ i += 1
3002
+ if i >= len(sys.argv):
3003
+ print("error: --max-examples requires an integer", file=sys.stderr)
3004
+ sys.exit(1)
3005
+ try:
3006
+ max_examples = int(sys.argv[i])
3007
+ except ValueError:
3008
+ print("error: --max-examples requires an integer", file=sys.stderr)
3009
+ sys.exit(1)
3010
+ if max_examples < 0:
3011
+ print("error: --max-examples must be >= 0", file=sys.stderr)
3012
+ sys.exit(1)
3013
+ elif arg == "--directed":
3014
+ if direction_flag == "undirected":
3015
+ print(
3016
+ "error: --directed and --undirected are mutually exclusive",
3017
+ file=sys.stderr,
3018
+ )
3019
+ sys.exit(1)
3020
+ direction_flag = "directed"
3021
+ directed = True
3022
+ elif arg == "--undirected":
3023
+ if direction_flag == "directed":
3024
+ print(
3025
+ "error: --directed and --undirected are mutually exclusive",
3026
+ file=sys.stderr,
3027
+ )
3028
+ sys.exit(1)
3029
+ direction_flag = "undirected"
3030
+ directed = False
3031
+ elif arg == "--extract-path":
3032
+ i += 1
3033
+ if i >= len(sys.argv):
3034
+ print("error: --extract-path requires a path", file=sys.stderr)
3035
+ sys.exit(1)
3036
+ extract_path = Path(sys.argv[i])
3037
+ else:
3038
+ print(f"error: unknown diagnose option {arg}", file=sys.stderr)
3039
+ sys.exit(1)
3040
+ i += 1
3041
+
3042
+ from graphify.diagnostics import (
3043
+ diagnose_file,
3044
+ format_diagnostic_json,
3045
+ format_diagnostic_report,
3046
+ )
3047
+
3048
+ try:
3049
+ summary = diagnose_file(
3050
+ graph_path,
3051
+ directed=directed,
3052
+ root=Path(".").resolve(),
3053
+ max_examples=max_examples,
3054
+ extract_path=extract_path,
3055
+ )
3056
+ except Exception as exc:
3057
+ print(f"error: {exc}", file=sys.stderr)
3058
+ sys.exit(1)
3059
+
3060
+ if json_output:
3061
+ print(json.dumps(format_diagnostic_json(summary), indent=2))
3062
+ else:
3063
+ print(format_diagnostic_report(summary))
3064
+
3065
+ elif cmd == "add":
3066
+ if len(sys.argv) < 3:
3067
+ print(
3068
+ "Usage: graphify add <url> [--author Name] [--contributor Name] [--dir ./raw]",
3069
+ file=sys.stderr,
3070
+ )
3071
+ sys.exit(1)
3072
+ from graphify.ingest import ingest as _ingest
3073
+
3074
+ url = sys.argv[2]
3075
+ author: str | None = None
3076
+ contributor: str | None = None
3077
+ target_dir = Path("raw")
3078
+ args = sys.argv[3:]
3079
+ i = 0
3080
+ while i < len(args):
3081
+ if args[i] == "--author" and i + 1 < len(args):
3082
+ author = args[i + 1]
3083
+ i += 2
3084
+ elif args[i] == "--contributor" and i + 1 < len(args):
3085
+ contributor = args[i + 1]
3086
+ i += 2
3087
+ elif args[i] == "--dir" and i + 1 < len(args):
3088
+ target_dir = Path(args[i + 1])
3089
+ i += 2
3090
+ else:
3091
+ i += 1
3092
+ try:
3093
+ saved = _ingest(url, target_dir, author=author, contributor=contributor)
3094
+ print(f"Saved to {saved}")
3095
+ print("Run /graphify --update in your AI assistant to update the graph.")
3096
+ except Exception as exc:
3097
+ print(f"error: {exc}", file=sys.stderr)
3098
+ sys.exit(1)
3099
+
3100
+ elif cmd == "watch":
3101
+ watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".")
3102
+ if not watch_path.exists():
3103
+ print(f"error: path not found: {watch_path}", file=sys.stderr)
3104
+ sys.exit(1)
3105
+ from graphify.watch import watch as _watch
3106
+
3107
+ try:
3108
+ _watch(watch_path)
3109
+ except ImportError as exc:
3110
+ print(f"error: {exc}", file=sys.stderr)
3111
+ sys.exit(1)
3112
+
3113
+ elif cmd in ("cluster-only", "label"):
3114
+ # `label` is `cluster-only` that always (re)generates community names with
3115
+ # the configured backend, even when a .graphify_labels.json already exists.
3116
+ force_relabel = cmd == "label"
3117
+ # Mirror the tree/export arg-parsing pattern: walk argv so flags and
3118
+ # the optional positional path can appear in any order (#724).
3119
+ no_viz = "--no-viz" in sys.argv
3120
+ no_label = "--no-label" in sys.argv
3121
+ _backend_arg = next((a for a in sys.argv if a.startswith("--backend=")), None)
3122
+ label_backend = _backend_arg.split("=", 1)[1] if _backend_arg else None
3123
+ _min_cs_arg = next((a for a in sys.argv if a.startswith("--min-community-size=")), None)
3124
+ min_community_size = int(_min_cs_arg.split("=")[1]) if _min_cs_arg else 3
3125
+ args = sys.argv[2:]
3126
+ watch_path: Path | None = None
3127
+ graph_override: Path | None = None
3128
+ co_resolution: float = 1.0
3129
+ co_exclude_hubs: float | None = None
3130
+ i_arg = 0
3131
+ while i_arg < len(args):
3132
+ a = args[i_arg]
3133
+ if a == "--graph" and i_arg + 1 < len(args):
3134
+ graph_override = Path(args[i_arg + 1]); i_arg += 2
3135
+ elif a == "--resolution" and i_arg + 1 < len(args):
3136
+ co_resolution = float(args[i_arg + 1]); i_arg += 2
3137
+ elif a.startswith("--resolution="):
3138
+ co_resolution = float(a.split("=", 1)[1]); i_arg += 1
3139
+ elif a == "--exclude-hubs" and i_arg + 1 < len(args):
3140
+ co_exclude_hubs = float(args[i_arg + 1]); i_arg += 2
3141
+ elif a.startswith("--exclude-hubs="):
3142
+ co_exclude_hubs = float(a.split("=", 1)[1]); i_arg += 1
3143
+ elif a == "--no-viz" or a.startswith("--min-community-size="):
3144
+ i_arg += 1
3145
+ elif a.startswith("--"):
3146
+ i_arg += 1
3147
+ elif watch_path is None:
3148
+ watch_path = Path(a); i_arg += 1
3149
+ else:
3150
+ i_arg += 1
3151
+ if watch_path is None:
3152
+ watch_path = Path(".")
3153
+ graph_json = graph_override if graph_override is not None else watch_path / "graphify-out" / "graph.json"
3154
+ if not graph_json.exists():
3155
+ print(
3156
+ f"error: no graph found at {graph_json} — run /graphify first",
3157
+ file=sys.stderr,
3158
+ )
3159
+ sys.exit(1)
3160
+ from networkx.readwrite import json_graph as _jg
3161
+ from graphify.build import build_from_json
3162
+ from graphify.cluster import cluster, score_all, remap_communities_to_previous
3163
+ from graphify.analyze import (
3164
+ god_nodes,
3165
+ surprising_connections,
3166
+ suggest_questions,
3167
+ )
3168
+ from graphify.report import generate
3169
+ from graphify.export import to_json, to_html
3170
+
3171
+ print("Loading existing graph...")
3172
+ _enforce_graph_size_cap_or_exit(graph_json)
3173
+ _raw = json.loads(graph_json.read_text(encoding="utf-8"))
3174
+ _directed = bool(_raw.get("directed", False))
3175
+ G = build_from_json(_raw, directed=_directed)
3176
+ print(f"Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
3177
+ print("Re-clustering...")
3178
+ communities = cluster(G, resolution=co_resolution, exclude_hubs_percentile=co_exclude_hubs)
3179
+ # Mirror the watch/update path (#822): map new cids to prior ones by
3180
+ # node-overlap so the existing .graphify_labels.json keeps attaching
3181
+ # to the same conceptual community after re-clustering. Without this,
3182
+ # labels follow raw cid index and become misaligned whenever the
3183
+ # graph has changed between labeling and cluster-only (#1027).
3184
+ previous_node_community = {
3185
+ n["id"]: n["community"]
3186
+ for n in _raw.get("nodes", [])
3187
+ if n.get("community") is not None and n.get("id") is not None
3188
+ }
3189
+ if previous_node_community:
3190
+ communities = remap_communities_to_previous(communities, previous_node_community)
3191
+ cohesion = score_all(G, communities)
3192
+ gods = god_nodes(G)
3193
+ surprises = surprising_connections(G, communities)
3194
+ out = watch_path / "graphify-out"
3195
+ out.mkdir(parents=True, exist_ok=True)
3196
+ labels_path = out / ".graphify_labels.json"
3197
+ if labels_path.exists() and not force_relabel:
3198
+ try:
3199
+ labels = {int(k): v for k, v in json.loads(labels_path.read_text(encoding="utf-8")).items()}
3200
+ except Exception:
3201
+ labels = {cid: f"Community {cid}" for cid in communities}
3202
+ elif no_label and not force_relabel:
3203
+ labels = {cid: f"Community {cid}" for cid in communities}
3204
+ else:
3205
+ # No labels file yet (or `graphify label` forced a refresh). When run
3206
+ # standalone there is no orchestrating agent to do skill.md Step 5, so
3207
+ # auto-name communities with the configured backend rather than leave
3208
+ # "Community N" (#1097). Degrades to placeholders if no backend/on error.
3209
+ from graphify.llm import generate_community_labels
3210
+ print("Labeling communities...")
3211
+ # The final labels (LLM or placeholder fallback) are persisted to
3212
+ # .graphify_labels.json by the unconditional write below.
3213
+ labels, _ = generate_community_labels(
3214
+ G, communities, backend=label_backend, gods=gods
3215
+ )
3216
+ questions = suggest_questions(G, communities, labels)
3217
+ tokens = {"input": 0, "output": 0}
3218
+ from graphify.export import _git_head as _gh
3219
+ _commit = _gh()
3220
+ report = generate(G, communities, cohesion, labels, gods, surprises,
3221
+ {"warning": "cluster-only mode — file stats not available"},
3222
+ tokens, str(watch_path), suggested_questions=questions,
3223
+ min_community_size=min_community_size, built_at_commit=_commit)
3224
+ (out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8")
3225
+ from graphify.export import backup_if_protected as _backup
3226
+ _backup(out)
3227
+ to_json(G, communities, str(out / "graph.json"))
3228
+ labels_path.write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding="utf-8")
3229
+
3230
+ # Mirror watch.py pattern: gate to_html so core outputs (graph.json +
3231
+ # GRAPH_REPORT.md) always land. Honor --no-viz explicitly; otherwise
3232
+ # fall back to ValueError handling so an oversized graph doesn't crash
3233
+ # the CLI mid-write and leave a stale graph.html on disk.
3234
+ html_target = out / "graph.html"
3235
+ if no_viz:
3236
+ if html_target.exists():
3237
+ html_target.unlink()
3238
+ print(f"Done - {len(communities)} communities. GRAPH_REPORT.md and graph.json updated (--no-viz; graph.html removed).")
3239
+ else:
3240
+ try:
3241
+ to_html(G, communities, str(html_target), community_labels=labels or None)
3242
+ print(f"Done - {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.")
3243
+ except ValueError as viz_err:
3244
+ if html_target.exists():
3245
+ html_target.unlink()
3246
+ print(f"Skipped graph.html: {viz_err}")
3247
+ print(f"Done - {len(communities)} communities. GRAPH_REPORT.md and graph.json updated.")
3248
+
3249
+ elif cmd == "update":
3250
+ force = os.environ.get("GRAPHIFY_FORCE", "").lower() in ("1", "true", "yes")
3251
+ no_cluster = False
3252
+ args = sys.argv[2:]
3253
+ watch_arg: str | None = None
3254
+ for a in args:
3255
+ if a == "--force":
3256
+ force = True
3257
+ continue
3258
+ if a == "--no-cluster":
3259
+ no_cluster = True
3260
+ continue
3261
+ if a.startswith("-"):
3262
+ print(f"error: unknown update option: {a}", file=sys.stderr)
3263
+ sys.exit(2)
3264
+ if watch_arg is not None:
3265
+ print("error: update accepts at most one path argument", file=sys.stderr)
3266
+ sys.exit(2)
3267
+ watch_arg = a
3268
+
3269
+ if watch_arg is not None:
3270
+ watch_path = Path(watch_arg)
3271
+ else:
3272
+ # Try to recover the scan root saved by the last full build
3273
+ saved = Path(_GRAPHIFY_OUT) / ".graphify_root"
3274
+ if saved.exists():
3275
+ watch_path = Path(saved.read_text(encoding="utf-8").strip())
3276
+ else:
3277
+ watch_path = Path(".")
3278
+ if not watch_path.exists():
3279
+ print(f"error: path not found: {watch_path}", file=sys.stderr)
3280
+ sys.exit(1)
3281
+ from graphify.watch import _rebuild_code
3282
+
3283
+ print(f"Re-extracting code files in {watch_path} (no LLM needed)...")
3284
+ # Interactive CLI: block on the per-repo lock rather than skip, so the
3285
+ # user sees their explicit `graphify update` complete instead of
3286
+ # exiting silently when a hook-driven rebuild happens to be running.
3287
+ ok = _rebuild_code(watch_path, force=force, no_cluster=no_cluster, block_on_lock=True)
3288
+ if ok:
3289
+ print("Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.")
3290
+ if not (
3291
+ os.environ.get("GEMINI_API_KEY")
3292
+ or os.environ.get("GOOGLE_API_KEY")
3293
+ or os.environ.get("MOONSHOT_API_KEY")
3294
+ or os.environ.get("DEEPSEEK_API_KEY")
3295
+ or os.environ.get("GRAPHIFY_NO_TIPS")
3296
+ ):
3297
+ print("Tip: set GEMINI_API_KEY or GOOGLE_API_KEY to use Gemini for semantic extraction.")
3298
+ else:
3299
+ print(
3300
+ "Nothing to update or rebuild failed — check output above.",
3301
+ file=sys.stderr,
3302
+ )
3303
+ sys.exit(1)
3304
+
3305
+ elif cmd == "hook-check":
3306
+ # Codex Desktop rejects hookSpecificOutput.additionalContext on PreToolUse.
3307
+ # Keep this as a cross-platform no-op so installed hooks never break Bash
3308
+ # tool calls. Graph guidance reaches the agent via AGENTS.md / skill instead.
3309
+ sys.exit(0)
3310
+ elif cmd == "check-update":
3311
+ if len(sys.argv) < 3:
3312
+ print("Usage: graphify check-update <path>", file=sys.stderr)
3313
+ sys.exit(1)
3314
+ from graphify.watch import check_update
3315
+
3316
+ check_update(Path(sys.argv[2]).resolve())
3317
+ sys.exit(0)
3318
+ elif cmd == "tree":
3319
+ # Emit a D3 v7 collapsible-tree HTML view of graph.json:
3320
+ # expand-all / collapse-all / reset-view buttons, multi-line
3321
+ # wrapText labels with separately-coloured name + count,
3322
+ # depth-based palette, click-to-toggle subtree, hover inspector
3323
+ # showing top-K outbound edges per symbol.
3324
+ from typing import Optional as _Opt
3325
+ from graphify.tree_html import write_tree_html, DEFAULT_MAX_CHILDREN
3326
+ graph_path = Path(_GRAPHIFY_OUT) / "graph.json"
3327
+ output_path: "_Opt[Path]" = None
3328
+ root: "_Opt[str]" = None
3329
+ max_children = DEFAULT_MAX_CHILDREN
3330
+ top_k_edges = 0
3331
+ project_label: "_Opt[str]" = None
3332
+ args = sys.argv[2:]
3333
+ i_arg = 0
3334
+ while i_arg < len(args):
3335
+ a = args[i_arg]
3336
+ if a == "--graph" and i_arg + 1 < len(args):
3337
+ graph_path = Path(args[i_arg + 1]); i_arg += 2
3338
+ elif a == "--output" and i_arg + 1 < len(args):
3339
+ output_path = Path(args[i_arg + 1]); i_arg += 2
3340
+ elif a == "--root" and i_arg + 1 < len(args):
3341
+ root = args[i_arg + 1]; i_arg += 2
3342
+ elif a == "--max-children" and i_arg + 1 < len(args):
3343
+ max_children = int(args[i_arg + 1]); i_arg += 2
3344
+ elif a == "--top-k-edges" and i_arg + 1 < len(args):
3345
+ top_k_edges = int(args[i_arg + 1]); i_arg += 2
3346
+ elif a == "--label" and i_arg + 1 < len(args):
3347
+ project_label = args[i_arg + 1]; i_arg += 2
3348
+ elif a in ("-h", "--help"):
3349
+ print("Usage: graphify tree [--graph PATH] [--output HTML]")
3350
+ print(" --graph PATH path to graph.json (default graphify-out/graph.json)")
3351
+ print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)")
3352
+ print(" --root PATH filesystem root (default: longest common dir of all source_files)")
3353
+ print(" --max-children N cap visible children per node (default 200)")
3354
+ print(" --top-k-edges N pre-compute top-K outbound edges per symbol (default 12)")
3355
+ print(" --label NAME project label shown in the page header")
3356
+ return
3357
+ else:
3358
+ i_arg += 1
3359
+ if not graph_path.is_file():
3360
+ print(f"error: graph.json not found at {graph_path}", file=sys.stderr)
3361
+ sys.exit(1)
3362
+ _enforce_graph_size_cap_or_exit(graph_path)
3363
+ if output_path is None:
3364
+ output_path = graph_path.parent / "GRAPH_TREE.html"
3365
+ out = write_tree_html(
3366
+ graph_path=graph_path, output_path=output_path,
3367
+ root=root, max_children=max_children,
3368
+ top_k_edges=top_k_edges, project_label=project_label,
3369
+ )
3370
+ size_kb = out.stat().st_size / 1024
3371
+ print(f"wrote {out} ({size_kb:.1f} KB)")
3372
+ print(f"open with: xdg-open {out} (or file://{out.resolve()})")
3373
+ sys.exit(0)
3374
+
3375
+ elif cmd == "merge-driver":
3376
+ # git merge driver for graph.json — takes (base, current, other) and writes
3377
+ # the union of current+other nodes/edges back to current. Exits 1 on
3378
+ # corrupt input so git surfaces the conflict instead of silently
3379
+ # accepting a poisoned merge (see F-005).
3380
+ # Usage: graphify merge-driver %O %A %B (set in .git/config merge driver)
3381
+ if len(sys.argv) < 5:
3382
+ print("Usage: graphify merge-driver <base> <current> <other>", file=sys.stderr)
3383
+ sys.exit(1)
3384
+ _base_path, _current_path, _other_path = sys.argv[2], sys.argv[3], sys.argv[4]
3385
+ # Hard caps so a malicious or corrupted graph.json cannot exhaust memory
3386
+ # at parse time. 50 MB / 100k nodes are well above any realistic graph
3387
+ # (typical graphs are <5 MB / <50k nodes); anything larger should fail
3388
+ # the merge so a human can investigate.
3389
+ _MERGE_MAX_BYTES = 50 * 1024 * 1024
3390
+ _MERGE_MAX_NODES = 100_000
3391
+ import networkx as _nx
3392
+ from networkx.readwrite import json_graph as _jg
3393
+ def _load_graph(p: str):
3394
+ path_obj = Path(p)
3395
+ try:
3396
+ size = path_obj.stat().st_size
3397
+ except OSError as exc:
3398
+ raise RuntimeError(f"cannot stat {p}: {exc}") from exc
3399
+ if size > _MERGE_MAX_BYTES:
3400
+ raise RuntimeError(
3401
+ f"graph.json {p} is {size} bytes, exceeds {_MERGE_MAX_BYTES}-byte cap"
3402
+ )
3403
+ data = json.loads(path_obj.read_text(encoding="utf-8"))
3404
+ try:
3405
+ return _jg.node_link_graph(data, edges="links"), data
3406
+ except TypeError:
3407
+ return _jg.node_link_graph(data), data
3408
+ try:
3409
+ G_cur, _ = _load_graph(_current_path)
3410
+ G_oth, _ = _load_graph(_other_path)
3411
+ except Exception as exc:
3412
+ print(f"[graphify merge-driver] error loading graphs: {exc}", file=sys.stderr)
3413
+ sys.exit(1) # surface the conflict so git doesn't accept a corrupt merge
3414
+ merged = _nx.compose(G_cur, G_oth)
3415
+ if merged.number_of_nodes() > _MERGE_MAX_NODES:
3416
+ print(
3417
+ f"[graphify merge-driver] merged graph has {merged.number_of_nodes()} nodes, "
3418
+ f"exceeds {_MERGE_MAX_NODES}-node cap; aborting merge.",
3419
+ file=sys.stderr,
3420
+ )
3421
+ sys.exit(1)
3422
+ try:
3423
+ out_data = _jg.node_link_data(merged, edges="links")
3424
+ except TypeError:
3425
+ out_data = _jg.node_link_data(merged)
3426
+ Path(_current_path).write_text(json.dumps(out_data, indent=2), encoding="utf-8")
3427
+ sys.exit(0)
3428
+
3429
+ elif cmd == "merge-graphs":
3430
+ # graphify merge-graphs graph1.json graph2.json ... --out merged.json
3431
+ args = sys.argv[2:]
3432
+ graph_paths: list[Path] = []
3433
+ out_path = Path(_GRAPHIFY_OUT) / "merged-graph.json"
3434
+ i = 0
3435
+ while i < len(args):
3436
+ if args[i] == "--out" and i + 1 < len(args):
3437
+ out_path = Path(args[i + 1])
3438
+ i += 2
3439
+ else:
3440
+ graph_paths.append(Path(args[i]))
3441
+ i += 1
3442
+ if len(graph_paths) < 2:
3443
+ print(
3444
+ "Usage: graphify merge-graphs <graph1.json> <graph2.json> [...] [--out merged.json]",
3445
+ file=sys.stderr,
3446
+ )
3447
+ sys.exit(1)
3448
+ import networkx as _nx
3449
+ from networkx.readwrite import json_graph as _jg
3450
+ from graphify.build import prefix_graph_for_global as _prefix
3451
+ graphs = []
3452
+ for gp in graph_paths:
3453
+ if not gp.exists():
3454
+ print(f"error: not found: {gp}", file=sys.stderr)
3455
+ sys.exit(1)
3456
+ _enforce_graph_size_cap_or_exit(gp)
3457
+ data = json.loads(gp.read_text(encoding="utf-8"))
3458
+ # Normalize edges/links key before loading — graphify writes "links"
3459
+ # via node_link_data but older runs may have used "edges" (#738).
3460
+ if "links" not in data and "edges" in data:
3461
+ data = dict(data, links=data["edges"])
3462
+ try:
3463
+ G = _jg.node_link_graph(data, edges="links")
3464
+ except TypeError:
3465
+ G = _jg.node_link_graph(data)
3466
+ graphs.append(G)
3467
+ merged = _nx.Graph()
3468
+ for G, gp in zip(graphs, graph_paths):
3469
+ repo_tag = gp.parent.parent.name # graphify-out/../ → repo dir name
3470
+ prefixed = _prefix(G, repo_tag)
3471
+ merged = _nx.compose(merged, prefixed)
3472
+ try:
3473
+ out_data = _jg.node_link_data(merged, edges="links")
3474
+ except TypeError:
3475
+ out_data = _jg.node_link_data(merged)
3476
+ out_path.parent.mkdir(parents=True, exist_ok=True)
3477
+ out_path.write_text(json.dumps(out_data, indent=2), encoding="utf-8")
3478
+ print(f"Merged {len(graphs)} graphs -> {merged.number_of_nodes()} nodes, {merged.number_of_edges()} edges")
3479
+ print(f"Written to: {out_path}")
3480
+
3481
+ elif cmd == "clone":
3482
+ if len(sys.argv) < 3:
3483
+ print(
3484
+ "Usage: graphify clone <github-url> [--branch <branch>] [--out <dir>]",
3485
+ file=sys.stderr,
3486
+ )
3487
+ sys.exit(1)
3488
+ url = sys.argv[2]
3489
+ branch: str | None = None
3490
+ out_dir: Path | None = None
3491
+ args = sys.argv[3:]
3492
+ i = 0
3493
+ while i < len(args):
3494
+ if args[i] == "--branch" and i + 1 < len(args):
3495
+ branch = args[i + 1]
3496
+ i += 2
3497
+ elif args[i] == "--out" and i + 1 < len(args):
3498
+ out_dir = Path(args[i + 1])
3499
+ i += 2
3500
+ else:
3501
+ i += 1
3502
+ local_path = _clone_repo(url, branch=branch, out_dir=out_dir)
3503
+ print(local_path)
3504
+
3505
+ elif cmd == "export":
3506
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
3507
+ if subcmd not in ("html", "callflow-html", "obsidian", "wiki", "svg", "graphml", "neo4j"):
3508
+ print("Usage: graphify export <format>", file=sys.stderr)
3509
+ print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz]", file=sys.stderr)
3510
+ print(" callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH] [--report PATH] [--sections PATH] [--output HTML]", file=sys.stderr)
3511
+ print(" [--lang auto|zh-CN|en] [--max-sections N] [--diagram-scale N]", file=sys.stderr)
3512
+ print(" obsidian [--graph PATH] [--labels PATH] [--dir PATH]", file=sys.stderr)
3513
+ print(" wiki [--graph PATH] [--labels PATH]", file=sys.stderr)
3514
+ print(" svg [--graph PATH] [--labels PATH]", file=sys.stderr)
3515
+ print(" graphml [--graph PATH]", file=sys.stderr)
3516
+ print(" neo4j [--graph PATH] [--push URI] [--user U] [--password P]", file=sys.stderr)
3517
+ print(" (or set NEO4J_PASSWORD instead of --password to keep it off argv)", file=sys.stderr)
3518
+ sys.exit(1)
3519
+
3520
+ # Parse shared args
3521
+ args = sys.argv[3:]
3522
+ graph_path = Path(_GRAPHIFY_OUT) / "graph.json"
3523
+ graph_path_explicit = False
3524
+ labels_path = Path(_GRAPHIFY_OUT) / ".graphify_labels.json"
3525
+ labels_path_explicit = False
3526
+ report_path = Path(_GRAPHIFY_OUT) / "GRAPH_REPORT.md"
3527
+ report_path_explicit = False
3528
+ sections_path: Path | None = None
3529
+ callflow_output: Path | None = None
3530
+ callflow_lang = "auto"
3531
+ callflow_max_sections = 15
3532
+ callflow_diagram_scale = 1.0
3533
+ callflow_max_diagram_nodes = 18
3534
+ callflow_max_diagram_edges = 24
3535
+ analysis_path = Path(_GRAPHIFY_OUT) / ".graphify_analysis.json"
3536
+ node_limit = 5000
3537
+ no_viz = False
3538
+ obsidian_dir = Path(_GRAPHIFY_OUT) / "obsidian"
3539
+ neo4j_uri: str | None = None
3540
+ neo4j_user = "neo4j"
3541
+ # F-031: prefer the NEO4J_PASSWORD env var so the password never
3542
+ # appears on argv (visible in `ps` output / shell history). The
3543
+ # explicit --password flag still overrides it for compatibility.
3544
+ neo4j_password: str | None = os.environ.get("NEO4J_PASSWORD") or None
3545
+ i = 0
3546
+ while i < len(args):
3547
+ a = args[i]
3548
+ if a == "--graph" and i + 1 < len(args):
3549
+ graph_path = Path(args[i + 1])
3550
+ graph_path_explicit = True
3551
+ i += 2
3552
+ elif a == "--labels" and i + 1 < len(args):
3553
+ labels_path = Path(args[i + 1])
3554
+ labels_path_explicit = True
3555
+ i += 2
3556
+ elif a == "--report" and i + 1 < len(args):
3557
+ report_path = Path(args[i + 1])
3558
+ report_path_explicit = True
3559
+ i += 2
3560
+ elif a == "--sections" and i + 1 < len(args):
3561
+ sections_path = Path(args[i + 1]); i += 2
3562
+ elif a == "--output" and i + 1 < len(args):
3563
+ callflow_output = Path(args[i + 1]).expanduser()
3564
+ if not callflow_output.is_absolute():
3565
+ callflow_output = Path.cwd() / callflow_output
3566
+ i += 2
3567
+ elif a == "--lang" and i + 1 < len(args):
3568
+ callflow_lang = args[i + 1]; i += 2
3569
+ elif a == "--max-sections" and i + 1 < len(args):
3570
+ callflow_max_sections = int(args[i + 1]); i += 2
3571
+ elif a == "--diagram-scale" and i + 1 < len(args):
3572
+ callflow_diagram_scale = float(args[i + 1]); i += 2
3573
+ elif a == "--max-diagram-nodes" and i + 1 < len(args):
3574
+ callflow_max_diagram_nodes = int(args[i + 1]); i += 2
3575
+ elif a == "--max-diagram-edges" and i + 1 < len(args):
3576
+ callflow_max_diagram_edges = int(args[i + 1]); i += 2
3577
+ elif a in ("-h", "--help") and subcmd == "callflow-html":
3578
+ print("Usage: graphify export callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH]")
3579
+ print(" --report PATH path to GRAPH_REPORT.md")
3580
+ print(" --sections PATH JSON section definitions")
3581
+ print(" --output HTML output path (default graphify-out/<project>-callflow.html)")
3582
+ print(" --lang LANG auto, zh-CN, en, etc. (default auto)")
3583
+ print(" --max-sections N maximum auto-derived sections (default 15)")
3584
+ print(" --diagram-scale N Mermaid diagram scale (default 1.0)")
3585
+ print(" --max-diagram-nodes N representative nodes per section (default 18)")
3586
+ print(" --max-diagram-edges N representative edges per section (default 24)")
3587
+ sys.exit(0)
3588
+ elif a == "--node-limit" and i + 1 < len(args):
3589
+ node_limit = int(args[i + 1]); i += 2
3590
+ elif a == "--no-viz":
3591
+ no_viz = True; i += 1
3592
+ elif a == "--dir" and i + 1 < len(args):
3593
+ obsidian_dir = Path(args[i + 1]); i += 2
3594
+ elif a == "--push" and i + 1 < len(args):
3595
+ neo4j_uri = args[i + 1]; i += 2
3596
+ elif a == "--user" and i + 1 < len(args):
3597
+ neo4j_user = args[i + 1]; i += 2
3598
+ elif a == "--password" and i + 1 < len(args):
3599
+ neo4j_password = args[i + 1]; i += 2
3600
+ elif subcmd == "callflow-html" and not a.startswith("-") and not graph_path_explicit:
3601
+ candidate = Path(a)
3602
+ if candidate.name == "graph.json" or candidate.suffix.lower() == ".json":
3603
+ graph_path = candidate
3604
+ elif (candidate / "graph.json").exists():
3605
+ graph_path = candidate / "graph.json"
3606
+ else:
3607
+ graph_path = candidate / _GRAPHIFY_OUT / "graph.json"
3608
+ graph_path_explicit = True
3609
+ i += 1
3610
+ else:
3611
+ i += 1
3612
+
3613
+ graph_path = graph_path.expanduser()
3614
+ if graph_path_explicit:
3615
+ graph_out_dir = graph_path.parent
3616
+ if not labels_path_explicit:
3617
+ labels_path = graph_out_dir / ".graphify_labels.json"
3618
+ if not report_path_explicit:
3619
+ report_path = graph_out_dir / "GRAPH_REPORT.md"
3620
+ labels_path = labels_path.expanduser()
3621
+ report_path = report_path.expanduser()
3622
+
3623
+ if not graph_path.exists():
3624
+ print(f"error: graph not found: {graph_path}. Run /graphify <path> first.", file=sys.stderr)
3625
+ sys.exit(1)
3626
+
3627
+ if subcmd == "callflow-html":
3628
+ from graphify.callflow_html import write_callflow_html as _write_callflow_html
3629
+ out = _write_callflow_html(
3630
+ graph=graph_path,
3631
+ report=report_path,
3632
+ labels=labels_path,
3633
+ sections=sections_path,
3634
+ output=callflow_output,
3635
+ lang=callflow_lang,
3636
+ max_sections=callflow_max_sections,
3637
+ diagram_scale=callflow_diagram_scale,
3638
+ max_diagram_nodes=callflow_max_diagram_nodes,
3639
+ max_diagram_edges=callflow_max_diagram_edges,
3640
+ verbose=True,
3641
+ )
3642
+ print(f"callflow HTML written - open in any browser: {out}")
3643
+ sys.exit(0)
3644
+
3645
+ from networkx.readwrite import json_graph as _jg
3646
+ from graphify.build import build_from_json as _bfj
3647
+
3648
+ _enforce_graph_size_cap_or_exit(graph_path)
3649
+ _raw = json.loads(graph_path.read_text(encoding="utf-8"))
3650
+ if "links" not in _raw and "edges" in _raw:
3651
+ _raw = dict(_raw, links=_raw["edges"])
3652
+ try:
3653
+ G = _jg.node_link_graph(_raw, edges="links")
3654
+ except TypeError:
3655
+ G = _jg.node_link_graph(_raw)
3656
+
3657
+ # Load optional analysis/labels
3658
+ communities: dict[int, list[str]] = {}
3659
+ if analysis_path.exists():
3660
+ _an = json.loads(analysis_path.read_text(encoding="utf-8"))
3661
+ communities = {int(k): v for k, v in _an.get("communities", {}).items()}
3662
+ cohesion: dict[int, float] = {int(k): v for k, v in _an.get("cohesion", {}).items()}
3663
+ gods_data = _an.get("gods", [])
3664
+ else:
3665
+ cohesion = {}
3666
+ gods_data = []
3667
+
3668
+ # Fallback: graph.json carries the per-node community as a node attribute
3669
+ # (`to_json` writes it on every node). The analysis sidecar is the
3670
+ # canonical source — but the post-commit / watch rebuild path doesn't
3671
+ # regenerate it, and `extract` may have its temp files cleaned up. When
3672
+ # that happens, `graphify export html` previously bailed with
3673
+ # "Single community - aggregated view not useful." even though the
3674
+ # per-node attribute had the right data all along. Reconstruct from
3675
+ # the graph itself so downstream subcommands (html, obsidian, wiki,
3676
+ # svg, graphml, neo4j) don't silently produce a degraded artifact.
3677
+ if not communities:
3678
+ reconstructed: dict[int, list[str]] = {}
3679
+ for node_id, data in G.nodes(data=True):
3680
+ cid_raw = data.get("community")
3681
+ if cid_raw is None:
3682
+ continue
3683
+ try:
3684
+ cid = int(cid_raw)
3685
+ except (TypeError, ValueError):
3686
+ continue
3687
+ reconstructed.setdefault(cid, []).append(str(node_id))
3688
+ if reconstructed:
3689
+ communities = reconstructed
3690
+
3691
+ labels: dict[int, str] = {}
3692
+ if labels_path.exists():
3693
+ labels = {int(k): v for k, v in json.loads(labels_path.read_text(encoding="utf-8")).items()}
3694
+
3695
+ out_dir = graph_path.parent
3696
+
3697
+ if subcmd == "html":
3698
+ from graphify.export import to_html as _to_html
3699
+ if no_viz:
3700
+ html_target = out_dir / "graph.html"
3701
+ if html_target.exists():
3702
+ html_target.unlink()
3703
+ print("--no-viz: skipped graph.html")
3704
+ else:
3705
+ _to_html(G, communities, str(out_dir / "graph.html"),
3706
+ community_labels=labels or None, node_limit=node_limit)
3707
+ if G.number_of_nodes() <= node_limit:
3708
+ print(f"graph.html written - open in any browser, no server needed")
3709
+
3710
+ elif subcmd == "obsidian":
3711
+ from graphify.export import to_obsidian as _to_obsidian, to_canvas as _to_canvas
3712
+ n = _to_obsidian(G, communities, str(obsidian_dir),
3713
+ community_labels=labels or None, cohesion=cohesion or None)
3714
+ print(f"Obsidian vault: {n} notes in {obsidian_dir}/")
3715
+ _to_canvas(G, communities, str(obsidian_dir / "graph.canvas"),
3716
+ community_labels=labels or None)
3717
+ print(f"Canvas: {obsidian_dir}/graph.canvas")
3718
+ print(f"Open {obsidian_dir}/ as a vault in Obsidian.")
3719
+
3720
+ elif subcmd == "wiki":
3721
+ from graphify.wiki import to_wiki as _to_wiki
3722
+ from graphify.analyze import god_nodes as _god_nodes
3723
+ if not communities:
3724
+ print(
3725
+ "error: .graphify_analysis.json is missing or empty — refusing to export wiki to prevent data loss.\n"
3726
+ "Run `graphify extract .` (or `graphify cluster-only .`) to regenerate community data first.",
3727
+ file=sys.stderr,
3728
+ )
3729
+ sys.exit(1)
3730
+ if not gods_data:
3731
+ gods_data = _god_nodes(G)
3732
+ n = _to_wiki(G, communities, str(out_dir / "wiki"),
3733
+ community_labels=labels or None, cohesion=cohesion or None,
3734
+ god_nodes_data=gods_data)
3735
+ print(f"Wiki: {n} articles written to {out_dir}/wiki/")
3736
+ print(f" {out_dir}/wiki/index.md -> agent entry point")
3737
+
3738
+ elif subcmd == "svg":
3739
+ from graphify.export import to_svg as _to_svg
3740
+ _to_svg(G, communities, str(out_dir / "graph.svg"),
3741
+ community_labels=labels or None)
3742
+ print(f"graph.svg written - embeds in Obsidian, Notion, GitHub READMEs")
3743
+
3744
+ elif subcmd == "graphml":
3745
+ from graphify.export import to_graphml as _to_graphml
3746
+ _to_graphml(G, communities, str(out_dir / "graph.graphml"))
3747
+ print(f"graph.graphml written - open in Gephi, yEd, or any GraphML tool")
3748
+
3749
+ elif subcmd == "neo4j":
3750
+ if neo4j_uri:
3751
+ from graphify.export import push_to_neo4j as _push
3752
+ if neo4j_password is None:
3753
+ print("error: --password required for --push", file=sys.stderr)
3754
+ sys.exit(1)
3755
+ result = _push(G, uri=neo4j_uri, user=neo4j_user,
3756
+ password=neo4j_password, communities=communities)
3757
+ print(f"Pushed to Neo4j: {result['nodes']} nodes, {result['edges']} edges")
3758
+ else:
3759
+ from graphify.export import to_cypher as _to_cypher
3760
+ _to_cypher(G, str(out_dir / "cypher.txt"))
3761
+ print(f"cypher.txt written - import with: cypher-shell < {out_dir}/cypher.txt")
3762
+
3763
+ elif cmd == "benchmark":
3764
+ from graphify.benchmark import run_benchmark, print_benchmark
3765
+
3766
+ graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json"
3767
+ _enforce_graph_size_cap_or_exit(Path(graph_path))
3768
+ # Try to load corpus_words from detect output
3769
+ corpus_words = None
3770
+ detect_path = Path(".graphify_detect.json")
3771
+ if detect_path.exists():
3772
+ try:
3773
+ detect_data = json.loads(detect_path.read_text(encoding="utf-8"))
3774
+ corpus_words = detect_data.get("total_words")
3775
+ except Exception:
3776
+ pass
3777
+ result = run_benchmark(graph_path, corpus_words=corpus_words)
3778
+ print_benchmark(result)
3779
+
3780
+ elif cmd == "global":
3781
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
3782
+ from graphify.global_graph import (
3783
+ global_add as _global_add,
3784
+ global_remove as _global_remove,
3785
+ global_list as _global_list,
3786
+ global_path as _global_path,
3787
+ )
3788
+ if subcmd == "add":
3789
+ # graphify global add <graph.json> [--as <tag>]
3790
+ args = sys.argv[3:]
3791
+ source = None
3792
+ tag = None
3793
+ i = 0
3794
+ while i < len(args):
3795
+ if args[i] == "--as" and i + 1 < len(args):
3796
+ tag = args[i + 1]; i += 2
3797
+ elif not source:
3798
+ source = Path(args[i]); i += 1
3799
+ else:
3800
+ i += 1
3801
+ if not source:
3802
+ print("Usage: graphify global add <graph.json> [--as <repo-tag>]", file=sys.stderr)
3803
+ sys.exit(1)
3804
+ tag = tag or source.parent.parent.name
3805
+ try:
3806
+ result = _global_add(source, tag)
3807
+ if result["skipped"]:
3808
+ print(f"'{tag}' unchanged since last add - global graph not modified.")
3809
+ else:
3810
+ print(f"Added '{tag}' to global graph: +{result['nodes_added']} nodes, "
3811
+ f"-{result['nodes_removed']} pruned. Global: {_global_path()}")
3812
+ except Exception as exc:
3813
+ print(f"error: {exc}", file=sys.stderr); sys.exit(1)
3814
+ elif subcmd == "remove":
3815
+ tag = sys.argv[3] if len(sys.argv) > 3 else ""
3816
+ if not tag:
3817
+ print("Usage: graphify global remove <repo-tag>", file=sys.stderr); sys.exit(1)
3818
+ try:
3819
+ removed = _global_remove(tag)
3820
+ print(f"Removed '{tag}' from global graph ({removed} nodes pruned).")
3821
+ except KeyError as exc:
3822
+ print(f"error: {exc}", file=sys.stderr); sys.exit(1)
3823
+ elif subcmd == "list":
3824
+ repos = _global_list()
3825
+ if not repos:
3826
+ print("Global graph is empty. Use 'graphify global add' to add a project.")
3827
+ else:
3828
+ print(f"Global graph: {_global_path()}")
3829
+ for tag, info in repos.items():
3830
+ print(f" {tag}: {info.get('node_count', '?')} nodes, added {info.get('added_at', '?')[:10]}")
3831
+ elif subcmd == "path":
3832
+ print(_global_path())
3833
+ else:
3834
+ print("Usage: graphify global [add|remove|list|path]", file=sys.stderr); sys.exit(1)
3835
+
3836
+ elif cmd == "extract":
3837
+ # Headless full-pipeline extraction for CI / scripts (#698).
3838
+ # Runs detect -> AST extraction on code -> semantic LLM extraction on
3839
+ # docs/papers/images -> merge -> build -> cluster -> write outputs.
3840
+ # Unlike the skill.md path (which runs through Claude Code subagents),
3841
+ # this calls extract_corpus_parallel directly using whichever backend
3842
+ # has an API key set.
3843
+ if len(sys.argv) < 3:
3844
+ print(
3845
+ "Usage: graphify extract <path> [--backend gemini|kimi|claude|openai|deepseek|ollama] "
3846
+ "[--model M] [--mode deep] [--out DIR] [--google-workspace] [--no-cluster] "
3847
+ "[--max-workers N] [--token-budget N] [--max-concurrency N] "
3848
+ "[--api-timeout S] [--postgres DSN]",
3849
+ file=sys.stderr,
3850
+ )
3851
+ sys.exit(1)
3852
+
3853
+ has_path = True
3854
+ if sys.argv[2].startswith("-"):
3855
+ has_path = False
3856
+ target = Path(".").resolve()
3857
+ else:
3858
+ target = Path(sys.argv[2]).resolve()
3859
+ if not target.exists():
3860
+ print(f"error: path not found: {target}", file=sys.stderr)
3861
+ sys.exit(1)
3862
+
3863
+ backend: str | None = None
3864
+ model: str | None = None
3865
+ extract_mode: str | None = None
3866
+ out_dir: Path | None = None
3867
+ cli_postgres_dsn: str | None = None
3868
+ no_cluster = False
3869
+ dedup_llm = False
3870
+ google_workspace = False
3871
+ global_merge = False
3872
+ global_repo_tag: str | None = None
3873
+ # Performance/tuning knobs (issue #792). None means "use library default".
3874
+ cli_max_workers: int | None = None
3875
+ cli_token_budget: int | None = None
3876
+ cli_max_concurrency: int | None = None
3877
+ cli_api_timeout: float | None = None
3878
+ # Clustering tuning knobs
3879
+ cli_resolution: float = 1.0
3880
+ cli_exclude_hubs: float | None = None
3881
+ cli_excludes: list[str] = []
3882
+
3883
+ def _parse_int(name: str, raw: str) -> int:
3884
+ try:
3885
+ v = int(raw)
3886
+ except ValueError:
3887
+ print(f"error: {name} must be a positive integer (got {raw!r})", file=sys.stderr)
3888
+ sys.exit(2)
3889
+ if v <= 0:
3890
+ print(f"error: {name} must be > 0 (got {v})", file=sys.stderr)
3891
+ sys.exit(2)
3892
+ return v
3893
+
3894
+ def _parse_float(name: str, raw: str) -> float:
3895
+ try:
3896
+ v = float(raw)
3897
+ except ValueError:
3898
+ print(f"error: {name} must be a positive number (got {raw!r})", file=sys.stderr)
3899
+ sys.exit(2)
3900
+ if v <= 0:
3901
+ print(f"error: {name} must be > 0 (got {v})", file=sys.stderr)
3902
+ sys.exit(2)
3903
+ return v
3904
+
3905
+ args = sys.argv[3:] if has_path else sys.argv[2:]
3906
+ i = 0
3907
+ while i < len(args):
3908
+ a = args[i]
3909
+ if a == "--backend" and i + 1 < len(args):
3910
+ backend = args[i + 1]; i += 2
3911
+ elif a.startswith("--backend="):
3912
+ backend = a.split("=", 1)[1]; i += 1
3913
+ elif a == "--model" and i + 1 < len(args):
3914
+ model = args[i + 1]; i += 2
3915
+ elif a.startswith("--model="):
3916
+ model = a.split("=", 1)[1]; i += 1
3917
+ elif a == "--mode" and i + 1 < len(args):
3918
+ extract_mode = args[i + 1]; i += 2
3919
+ elif a.startswith("--mode="):
3920
+ extract_mode = a.split("=", 1)[1]; i += 1
3921
+ elif a == "--out" and i + 1 < len(args):
3922
+ out_dir = Path(args[i + 1]); i += 2
3923
+ elif a.startswith("--out="):
3924
+ out_dir = Path(a.split("=", 1)[1]); i += 1
3925
+ elif a == "--no-cluster":
3926
+ no_cluster = True; i += 1
3927
+ elif a == "--dedup-llm":
3928
+ dedup_llm = True; i += 1
3929
+ elif a == "--google-workspace":
3930
+ google_workspace = True; i += 1
3931
+ elif a == "--global":
3932
+ global_merge = True; i += 1
3933
+ elif a == "--as" and i + 1 < len(args):
3934
+ global_repo_tag = args[i + 1]; i += 2
3935
+ elif a == "--max-workers" and i + 1 < len(args):
3936
+ cli_max_workers = _parse_int("--max-workers", args[i + 1]); i += 2
3937
+ elif a.startswith("--max-workers="):
3938
+ cli_max_workers = _parse_int("--max-workers", a.split("=", 1)[1]); i += 1
3939
+ elif a == "--token-budget" and i + 1 < len(args):
3940
+ cli_token_budget = _parse_int("--token-budget", args[i + 1]); i += 2
3941
+ elif a.startswith("--token-budget="):
3942
+ cli_token_budget = _parse_int("--token-budget", a.split("=", 1)[1]); i += 1
3943
+ elif a == "--max-concurrency" and i + 1 < len(args):
3944
+ cli_max_concurrency = _parse_int("--max-concurrency", args[i + 1]); i += 2
3945
+ elif a.startswith("--max-concurrency="):
3946
+ cli_max_concurrency = _parse_int("--max-concurrency", a.split("=", 1)[1]); i += 1
3947
+ elif a == "--api-timeout" and i + 1 < len(args):
3948
+ cli_api_timeout = _parse_float("--api-timeout", args[i + 1]); i += 2
3949
+ elif a.startswith("--api-timeout="):
3950
+ cli_api_timeout = _parse_float("--api-timeout", a.split("=", 1)[1]); i += 1
3951
+ elif a == "--resolution" and i + 1 < len(args):
3952
+ cli_resolution = _parse_float("--resolution", args[i + 1]); i += 2
3953
+ elif a.startswith("--resolution="):
3954
+ cli_resolution = _parse_float("--resolution", a.split("=", 1)[1]); i += 1
3955
+ elif a == "--exclude-hubs" and i + 1 < len(args):
3956
+ cli_exclude_hubs = float(args[i + 1]); i += 2
3957
+ elif a.startswith("--exclude-hubs="):
3958
+ cli_exclude_hubs = float(a.split("=", 1)[1]); i += 1
3959
+ elif a == "--exclude" and i + 1 < len(args):
3960
+ cli_excludes.append(args[i + 1]); i += 2
3961
+ elif a.startswith("--exclude="):
3962
+ cli_excludes.append(a.split("=", 1)[1]); i += 1
3963
+ elif a == "--postgres" and i + 1 < len(args):
3964
+ cli_postgres_dsn = args[i + 1]; i += 2
3965
+ elif a.startswith("--postgres="):
3966
+ cli_postgres_dsn = a.split("=", 1)[1]; i += 1
3967
+ else:
3968
+ i += 1
3969
+
3970
+ if not has_path and cli_postgres_dsn is None:
3971
+ print("error: must specify a path to scan or a --postgres DSN", file=sys.stderr)
3972
+ sys.exit(1)
3973
+
3974
+ _VALID_MODES = {"deep"}
3975
+ if extract_mode is not None and extract_mode not in _VALID_MODES:
3976
+ print(
3977
+ f"error: unknown --mode '{extract_mode}'. "
3978
+ f"Available: {', '.join(sorted(_VALID_MODES))}",
3979
+ file=sys.stderr,
3980
+ )
3981
+ sys.exit(2)
3982
+ deep_mode = extract_mode == "deep"
3983
+ if deep_mode:
3984
+ print("[graphify extract] deep mode enabled: richer semantic extraction")
3985
+
3986
+ # CLI flag wins over env var. Setting GRAPHIFY_API_TIMEOUT here so
3987
+ # _call_openai_compat picks it up without needing a new kwarg path.
3988
+ if cli_api_timeout is not None:
3989
+ os.environ["GRAPHIFY_API_TIMEOUT"] = str(cli_api_timeout)
3990
+ if cli_max_workers is not None:
3991
+ os.environ["GRAPHIFY_MAX_WORKERS"] = str(cli_max_workers)
3992
+
3993
+ # Resolve output dir. The user-facing contract is "<out>/graphify-out/"
3994
+ # so a fresh checkout writes graphify-out/ at the project root, matching
3995
+ # the skill.md pipeline.
3996
+ out_root = (out_dir.resolve() if out_dir else target)
3997
+ graphify_out = out_root / "graphify-out"
3998
+ graphify_out.mkdir(parents=True, exist_ok=True)
3999
+
4000
+ from graphify.detect import (
4001
+ detect as _detect,
4002
+ detect_incremental as _detect_incremental,
4003
+ save_manifest as _save_manifest,
4004
+ )
4005
+ manifest_path = graphify_out / "manifest.json"
4006
+ existing_graph_path = graphify_out / "graph.json"
4007
+ incremental_mode = manifest_path.exists() and existing_graph_path.exists() if has_path else False
4008
+
4009
+ if not has_path:
4010
+ code_files = []
4011
+ doc_files = []
4012
+ paper_files = []
4013
+ image_files = []
4014
+ deleted_files = []
4015
+ unchanged_total = 0
4016
+ files_by_type = {}
4017
+ elif incremental_mode:
4018
+ print(f"[graphify extract] incremental scan of {target}")
4019
+ detection = _detect_incremental(
4020
+ target,
4021
+ manifest_path=str(manifest_path),
4022
+ google_workspace=google_workspace or None,
4023
+ extra_excludes=cli_excludes or None,
4024
+ )
4025
+ files_by_type = detection.get("files", {})
4026
+ new_by_type = detection.get("new_files", {})
4027
+ code_files = [Path(p) for p in new_by_type.get("code", [])]
4028
+ doc_files = [Path(p) for p in new_by_type.get("document", [])]
4029
+ paper_files = [Path(p) for p in new_by_type.get("paper", [])]
4030
+ image_files = [Path(p) for p in new_by_type.get("image", [])]
4031
+ deleted_files = list(detection.get("deleted_files", []))
4032
+ unchanged_total = sum(len(v) for v in detection.get("unchanged_files", {}).values())
4033
+ else:
4034
+ print(f"[graphify extract] scanning {target}")
4035
+ detection = _detect(target, google_workspace=google_workspace or None, extra_excludes=cli_excludes or None)
4036
+ files_by_type = detection.get("files", {})
4037
+ code_files = [Path(p) for p in files_by_type.get("code", [])]
4038
+ doc_files = [Path(p) for p in files_by_type.get("document", [])]
4039
+ paper_files = [Path(p) for p in files_by_type.get("paper", [])]
4040
+ image_files = [Path(p) for p in files_by_type.get("image", [])]
4041
+ deleted_files = []
4042
+ unchanged_total = 0
4043
+
4044
+ semantic_files = doc_files + paper_files + image_files
4045
+ if incremental_mode:
4046
+ print(
4047
+ f"[graphify extract] {len(code_files)} code, {len(doc_files)} docs, "
4048
+ f"{len(paper_files)} papers, {len(image_files)} images changed; "
4049
+ f"{unchanged_total} unchanged; {len(deleted_files)} deleted"
4050
+ )
4051
+ else:
4052
+ print(
4053
+ f"[graphify extract] found {len(code_files)} code, "
4054
+ f"{len(doc_files)} docs, {len(paper_files)} papers, "
4055
+ f"{len(image_files)} images"
4056
+ )
4057
+
4058
+ # Resolve the LLM backend only now that we know whether the corpus
4059
+ # needs one. A code-only corpus is pure local AST and must not require
4060
+ # an API key; the key is enforced below only when there's LLM work.
4061
+ from graphify.llm import (
4062
+ BACKENDS as _BACKENDS,
4063
+ detect_backend as _detect_backend,
4064
+ estimate_cost as _estimate_cost,
4065
+ extract_corpus_parallel as _extract_corpus_parallel,
4066
+ _format_backend_env_keys,
4067
+ _get_backend_api_key,
4068
+ )
4069
+ needs_llm = bool(semantic_files) or dedup_llm
4070
+ if backend is None and needs_llm:
4071
+ backend = _detect_backend()
4072
+ if backend is not None and backend not in _BACKENDS:
4073
+ print(
4074
+ f"error: unknown backend '{backend}'. "
4075
+ f"Available: {', '.join(sorted(_BACKENDS))}",
4076
+ file=sys.stderr,
4077
+ )
4078
+ sys.exit(1)
4079
+ if needs_llm:
4080
+ if backend is None:
4081
+ reasons = []
4082
+ if semantic_files:
4083
+ reasons.append(
4084
+ f"{len(semantic_files)} doc/paper/image file(s) need semantic extraction"
4085
+ )
4086
+ if dedup_llm:
4087
+ reasons.append("--dedup-llm was passed")
4088
+ print(
4089
+ "error: no LLM API key found (" + "; ".join(reasons) + "). "
4090
+ "Set GEMINI_API_KEY or GOOGLE_API_KEY (gemini), MOONSHOT_API_KEY "
4091
+ "(kimi), ANTHROPIC_API_KEY (claude), OPENAI_API_KEY (openai), "
4092
+ "DEEPSEEK_API_KEY (deepseek), or pass --backend. A code-only "
4093
+ "corpus needs no key.",
4094
+ file=sys.stderr,
4095
+ )
4096
+ sys.exit(1)
4097
+ if backend == "ollama":
4098
+ from graphify.llm import _validate_ollama_base_url
4099
+ _oll_url = os.environ.get("OLLAMA_BASE_URL", _BACKENDS["ollama"].get("base_url", ""))
4100
+ try:
4101
+ _validate_ollama_base_url(_oll_url, warn=False)
4102
+ except ValueError as exc:
4103
+ print(f"error: {exc}", file=sys.stderr)
4104
+ sys.exit(2)
4105
+ if not _get_backend_api_key(backend):
4106
+ allow_no_key = False
4107
+ if backend == "ollama":
4108
+ from urllib.parse import urlparse
4109
+ ollama_url = os.environ.get(
4110
+ "OLLAMA_BASE_URL",
4111
+ _BACKENDS["ollama"].get("base_url", ""),
4112
+ )
4113
+ try:
4114
+ host = (urlparse(ollama_url).hostname or "").lower()
4115
+ except Exception:
4116
+ host = ""
4117
+ allow_no_key = (
4118
+ host in ("localhost", "127.0.0.1", "::1")
4119
+ or host.startswith("127.")
4120
+ )
4121
+ elif backend == "bedrock":
4122
+ allow_no_key = bool(
4123
+ os.environ.get("AWS_PROFILE")
4124
+ or os.environ.get("AWS_REGION")
4125
+ or os.environ.get("AWS_DEFAULT_REGION")
4126
+ or os.environ.get("AWS_ACCESS_KEY_ID")
4127
+ )
4128
+ elif backend == "claude-cli":
4129
+ import shutil as _shutil
4130
+ allow_no_key = _shutil.which("claude") is not None
4131
+ if not allow_no_key:
4132
+ print(
4133
+ "error: backend 'claude-cli' requires the `claude` CLI on $PATH "
4134
+ "(install Claude Code and run `claude` once to authenticate).",
4135
+ file=sys.stderr,
4136
+ )
4137
+ sys.exit(1)
4138
+ if not allow_no_key:
4139
+ print(
4140
+ f"error: backend '{backend}' requires {_format_backend_env_keys(backend)} to be set.",
4141
+ file=sys.stderr,
4142
+ )
4143
+ sys.exit(1)
4144
+
4145
+ # AST extraction on code files. Empty code list (docs-only corpus) is
4146
+ # the issue #698 case — skip cleanly instead of crashing inside extract().
4147
+ ast_result: dict = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0}
4148
+ if code_files:
4149
+ from graphify.extract import extract as _ast_extract
4150
+ ast_kwargs: dict = {"cache_root": target}
4151
+ if cli_max_workers is not None:
4152
+ ast_kwargs["max_workers"] = cli_max_workers
4153
+ print(f"[graphify extract] AST extraction on {len(code_files)} code files...")
4154
+ try:
4155
+ ast_result = _ast_extract(code_files, **ast_kwargs)
4156
+ except Exception as exc:
4157
+ print(f"[graphify extract] AST extraction failed: {exc}", file=sys.stderr)
4158
+ ast_result = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0}
4159
+
4160
+ # Semantic extraction on docs/papers/images. Check cache first.
4161
+ from graphify.cache import (
4162
+ check_semantic_cache as _check_semantic_cache,
4163
+ save_semantic_cache as _save_semantic_cache,
4164
+ )
4165
+ sem_result: dict = {
4166
+ "nodes": [], "edges": [], "hyperedges": [],
4167
+ "input_tokens": 0, "output_tokens": 0,
4168
+ }
4169
+ sem_cache_hits = 0
4170
+ sem_cache_misses = 0
4171
+ if semantic_files:
4172
+ sem_paths_str = [str(p) for p in semantic_files]
4173
+ cached_nodes, cached_edges, cached_hyperedges, uncached_paths = (
4174
+ _check_semantic_cache(sem_paths_str, root=target)
4175
+ )
4176
+ sem_cache_hits = len(semantic_files) - len(uncached_paths)
4177
+ sem_cache_misses = len(uncached_paths)
4178
+ sem_result["nodes"].extend(cached_nodes)
4179
+ sem_result["edges"].extend(cached_edges)
4180
+ sem_result["hyperedges"].extend(cached_hyperedges)
4181
+ if sem_cache_hits:
4182
+ print(f"[graphify extract] semantic cache: {sem_cache_hits} hit / {sem_cache_misses} miss")
4183
+
4184
+ if uncached_paths:
4185
+ print(f"[graphify extract] semantic extraction on {len(uncached_paths)} files via {backend}...")
4186
+ corpus_kwargs: dict = {
4187
+ "backend": backend,
4188
+ "model": model,
4189
+ "root": target,
4190
+ }
4191
+ if deep_mode:
4192
+ corpus_kwargs["deep_mode"] = True
4193
+ if cli_token_budget is not None:
4194
+ corpus_kwargs["token_budget"] = cli_token_budget
4195
+ if cli_max_concurrency is not None:
4196
+ corpus_kwargs["max_concurrency"] = cli_max_concurrency
4197
+
4198
+ # Minimal progress callback so the CLI is no longer silent
4199
+ # during long local-inference runs (issue #792 addendum).
4200
+ # Also track per-chunk success so we can fail loudly when
4201
+ # every chunk errors (e.g. missing backend SDK package).
4202
+ _chunk_stats = {"total": 0, "succeeded": 0}
4203
+ def _progress(idx: int, total: int, _result: dict) -> None:
4204
+ _chunk_stats["total"] = total
4205
+ _chunk_stats["succeeded"] += 1
4206
+ print(
4207
+ f"[graphify extract] chunk {idx + 1}/{total} done",
4208
+ flush=True,
4209
+ )
4210
+ corpus_kwargs["on_chunk_done"] = _progress
4211
+
4212
+ try:
4213
+ fresh = _extract_corpus_parallel(
4214
+ [Path(p) for p in uncached_paths],
4215
+ **corpus_kwargs,
4216
+ )
4217
+ except ImportError as exc:
4218
+ print(f"error: {exc}", file=sys.stderr)
4219
+ sys.exit(1)
4220
+ except Exception as exc:
4221
+ print(
4222
+ f"[graphify extract] semantic extraction failed: {exc}",
4223
+ file=sys.stderr,
4224
+ )
4225
+ fresh = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
4226
+
4227
+ # on_chunk_done only fires after a chunk succeeds. If fresh
4228
+ # semantic extraction was requested and no chunks completed,
4229
+ # fail instead of writing an AST-only graph with exit 0.
4230
+ if uncached_paths and _chunk_stats["succeeded"] == 0:
4231
+ print(
4232
+ f"[graphify extract] error: all semantic chunks failed "
4233
+ f"for backend '{backend}' ({len(uncached_paths)} uncached files) - "
4234
+ f"see per-chunk errors above. If you see 'requires the X package', "
4235
+ f"run `pip install X` and retry.",
4236
+ file=sys.stderr,
4237
+ )
4238
+ sys.exit(1)
4239
+ try:
4240
+ _save_semantic_cache(
4241
+ fresh.get("nodes", []),
4242
+ fresh.get("edges", []),
4243
+ fresh.get("hyperedges", []),
4244
+ root=target,
4245
+ )
4246
+ except Exception as exc:
4247
+ print(f"[graphify extract] warning: could not write semantic cache: {exc}", file=sys.stderr)
4248
+ sem_result["nodes"].extend(fresh.get("nodes", []))
4249
+ sem_result["edges"].extend(fresh.get("edges", []))
4250
+ sem_result["hyperedges"].extend(fresh.get("hyperedges", []))
4251
+ sem_result["input_tokens"] += fresh.get("input_tokens", 0)
4252
+ sem_result["output_tokens"] += fresh.get("output_tokens", 0)
4253
+
4254
+ pg_result: dict = {"nodes": [], "edges": []}
4255
+ if cli_postgres_dsn is not None:
4256
+ from graphify.pg_introspect import introspect_postgres
4257
+ print(f"[graphify extract] introspecting PostgreSQL schema...")
4258
+ try:
4259
+ pg_result = introspect_postgres(cli_postgres_dsn)
4260
+ except (ConnectionError, ImportError) as exc:
4261
+ print(f"error: {exc}", file=sys.stderr)
4262
+ sys.exit(1)
4263
+ print(f"[graphify extract] PostgreSQL: {len(pg_result['nodes'])} nodes, "
4264
+ f"{len(pg_result['edges'])} edges")
4265
+
4266
+ # Merge AST + semantic + pg_result. Order matters for deduplication: passing AST
4267
+ # first means semantic node attributes win on collision (richer labels
4268
+ # for symbols also referenced in docs). Hyperedges only come from the
4269
+ # semantic side.
4270
+ merged: dict = {
4271
+ "nodes": list(ast_result.get("nodes", [])) + list(sem_result.get("nodes", [])) + list(pg_result.get("nodes", [])),
4272
+ "edges": list(ast_result.get("edges", [])) + list(sem_result.get("edges", [])) + list(pg_result.get("edges", [])),
4273
+ "hyperedges": list(sem_result.get("hyperedges", [])),
4274
+ "input_tokens": ast_result.get("input_tokens", 0) + sem_result.get("input_tokens", 0),
4275
+ "output_tokens": ast_result.get("output_tokens", 0) + sem_result.get("output_tokens", 0),
4276
+ }
4277
+
4278
+ graph_json_path = graphify_out / "graph.json"
4279
+ analysis_path = graphify_out / ".graphify_analysis.json"
4280
+
4281
+ # Build a manifest-safe files dict: only stamp semantic_hash for files
4282
+ # that actually produced output (cache hit or fresh extraction). Files
4283
+ # whose chunk failed have no source_file entry in sem_result — leaving
4284
+ # their semantic_hash empty so detect_incremental re-queues them (#933).
4285
+ _sem_extracted: set[str] = {
4286
+ n.get("source_file", "") for n in sem_result.get("nodes", [])
4287
+ } | {
4288
+ e.get("source_file", "") for e in sem_result.get("edges", [])
4289
+ }
4290
+ _sem_extracted.discard("")
4291
+ _sem_types = {"document", "paper", "image"}
4292
+ _manifest_files = {
4293
+ ftype: [f for f in flist if ftype not in _sem_types or f in _sem_extracted]
4294
+ for ftype, flist in files_by_type.items()
4295
+ }
4296
+
4297
+ if no_cluster:
4298
+ # --no-cluster: dump the raw merged extraction as graph.json.
4299
+ # No NetworkX, no community detection, no analysis sidecar.
4300
+ from graphify.export import backup_if_protected as _backup
4301
+ _backup(graphify_out)
4302
+ graph_json_path.write_text(
4303
+ json.dumps(merged, indent=2), encoding="utf-8"
4304
+ )
4305
+ cost = _estimate_cost(
4306
+ backend, merged["input_tokens"], merged["output_tokens"]
4307
+ )
4308
+ print(
4309
+ f"[graphify extract] wrote {graph_json_path} — "
4310
+ f"{len(merged['nodes'])} nodes, {len(merged['edges'])} edges "
4311
+ f"(no clustering)"
4312
+ )
4313
+ if merged["input_tokens"] or merged["output_tokens"]:
4314
+ print(
4315
+ f"[graphify extract] tokens: "
4316
+ f"{merged['input_tokens']:,} in / "
4317
+ f"{merged['output_tokens']:,} out, "
4318
+ f"est. cost: ${cost:.4f}"
4319
+ )
4320
+ try:
4321
+ _save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target)
4322
+ except Exception as exc:
4323
+ print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr)
4324
+ if global_merge:
4325
+ from graphify.global_graph import global_add as _global_add
4326
+ _tag = global_repo_tag or target.name
4327
+ try:
4328
+ result = _global_add(graphify_out / "graph.json", _tag)
4329
+ if result["skipped"]:
4330
+ print(f"[graphify global] '{_tag}' unchanged since last add - skipped.")
4331
+ else:
4332
+ print(f"[graphify global] '{_tag}' merged into global graph "
4333
+ f"(+{result['nodes_added']} nodes, -{result['nodes_removed']} pruned).")
4334
+ except Exception as exc:
4335
+ print(f"[graphify global] warning: failed to merge into global graph: {exc}", file=sys.stderr)
4336
+ sys.exit(0)
4337
+
4338
+ # Build graph + cluster + score + write.
4339
+ from graphify.build import (
4340
+ build as _build,
4341
+ build_from_json as _build_from_json,
4342
+ build_merge as _build_merge,
4343
+ )
4344
+ from graphify.cluster import cluster as _cluster, score_all as _score_all
4345
+ from graphify.export import to_json as _to_json
4346
+ from graphify.analyze import god_nodes as _god_nodes, surprising_connections as _surprising
4347
+ dedup_backend = backend if dedup_llm else None
4348
+ if incremental_mode:
4349
+ G = _build_merge(
4350
+ [merged],
4351
+ graph_path=existing_graph_path,
4352
+ prune_sources=deleted_files or None,
4353
+ dedup=True,
4354
+ dedup_llm_backend=dedup_backend,
4355
+ root=target,
4356
+ )
4357
+ else:
4358
+ G = _build([merged], dedup=True, dedup_llm_backend=dedup_backend, root=target)
4359
+ if G.number_of_nodes() == 0:
4360
+ print(
4361
+ "[graphify extract] graph is empty — extraction produced no nodes. "
4362
+ "Possible causes: all files skipped, binary-only corpus, or LLM "
4363
+ "returned no edges.",
4364
+ file=sys.stderr,
4365
+ )
4366
+ sys.exit(1)
4367
+
4368
+ communities = _cluster(G, resolution=cli_resolution, exclude_hubs_percentile=cli_exclude_hubs)
4369
+ cohesion = _score_all(G, communities)
4370
+ try:
4371
+ gods = _god_nodes(G)
4372
+ except Exception:
4373
+ gods = []
4374
+ try:
4375
+ surprises = _surprising(G, communities)
4376
+ except Exception:
4377
+ surprises = []
4378
+
4379
+ from graphify.export import backup_if_protected as _backup
4380
+ _backup(graphify_out)
4381
+ _to_json(G, communities, str(graph_json_path), force=True)
4382
+ if merged.get("output_tokens", 0) > 0:
4383
+ (graphify_out / ".graphify_semantic_marker").write_text(
4384
+ json.dumps({"output_tokens": merged["output_tokens"]}), encoding="utf-8"
4385
+ )
4386
+ if global_merge:
4387
+ from graphify.global_graph import global_add as _global_add
4388
+ _tag = global_repo_tag or target.name
4389
+ try:
4390
+ result = _global_add(graphify_out / "graph.json", _tag)
4391
+ if result["skipped"]:
4392
+ print(f"[graphify global] '{_tag}' unchanged since last add - skipped.")
4393
+ else:
4394
+ print(f"[graphify global] '{_tag}' merged into global graph "
4395
+ f"(+{result['nodes_added']} nodes, -{result['nodes_removed']} pruned).")
4396
+ except Exception as exc:
4397
+ print(f"[graphify global] warning: failed to merge into global graph: {exc}", file=sys.stderr)
4398
+ analysis = {
4399
+ "communities": {str(k): v for k, v in communities.items()},
4400
+ "cohesion": {str(k): v for k, v in cohesion.items()},
4401
+ "gods": gods,
4402
+ "surprises": surprises,
4403
+ "tokens": {
4404
+ "input": merged["input_tokens"],
4405
+ "output": merged["output_tokens"],
4406
+ },
4407
+ }
4408
+ analysis_path.write_text(json.dumps(analysis, indent=2), encoding="utf-8")
4409
+ try:
4410
+ _save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target)
4411
+ except Exception as exc:
4412
+ print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr)
4413
+
4414
+ cost = _estimate_cost(backend, merged["input_tokens"], merged["output_tokens"])
4415
+ print(
4416
+ f"[graphify extract] wrote {graph_json_path}: "
4417
+ f"{G.number_of_nodes()} nodes, {G.number_of_edges()} edges, "
4418
+ f"{len(communities)} communities"
4419
+ )
4420
+ print(f"[graphify extract] wrote {analysis_path}")
4421
+ if incremental_mode:
4422
+ print(
4423
+ f"[graphify extract] incremental summary: "
4424
+ f"{sem_cache_hits + unchanged_total} files cached/unchanged, "
4425
+ f"{len(code_files) + sem_cache_misses} re-extracted, "
4426
+ f"{len(deleted_files)} deleted"
4427
+ )
4428
+ elif sem_cache_hits:
4429
+ print(f"[graphify extract] semantic cache: {sem_cache_hits} cached, {sem_cache_misses} re-extracted")
4430
+ if merged["input_tokens"] or merged["output_tokens"]:
4431
+ print(
4432
+ f"[graphify extract] tokens: "
4433
+ f"{merged['input_tokens']:,} in / "
4434
+ f"{merged['output_tokens']:,} out, "
4435
+ f"est. cost (~{backend}): ${cost:.4f}"
4436
+ )
4437
+ # extract intentionally stops at graph.json + analysis; the report and
4438
+ # community labels are produced by `cluster-only` (or an agent's Step 5).
4439
+ # Point standalone users at it so communities get named (#1097).
4440
+ print(
4441
+ "[graphify extract] next: run "
4442
+ f"`graphify cluster-only {graphify_out.parent}` "
4443
+ "to generate GRAPH_REPORT.md and name communities"
4444
+ )
4445
+
4446
+ elif cmd == "cache-check":
4447
+ # graphify cache-check <files_from> [--root <dir>]
4448
+ # Reads file paths (one per line) from <files_from>, checks semantic cache.
4449
+ # Writes:
4450
+ # graphify-out/.graphify_cached.json — already-cached nodes/edges/hyperedges
4451
+ # graphify-out/.graphify_uncached.txt — paths that need extraction
4452
+ # Stdout: "Cache: N hit, M miss"
4453
+ from graphify.cache import check_semantic_cache
4454
+ if len(sys.argv) < 3:
4455
+ print("Usage: graphify cache-check <files_from> [--root <dir>]", file=sys.stderr)
4456
+ sys.exit(1)
4457
+ files_from = Path(sys.argv[2])
4458
+ root = Path(".")
4459
+ i = 3
4460
+ while i < len(sys.argv):
4461
+ if sys.argv[i] == "--root" and i + 1 < len(sys.argv):
4462
+ root = Path(sys.argv[i + 1])
4463
+ i += 2
4464
+ else:
4465
+ i += 1
4466
+ files = [f for f in files_from.read_text(encoding="utf-8").splitlines() if f.strip()]
4467
+ cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(files, root)
4468
+ out = root / "graphify-out"
4469
+ out.mkdir(parents=True, exist_ok=True)
4470
+ if cached_nodes or cached_edges or cached_hyperedges:
4471
+ (out / ".graphify_cached.json").write_text(
4472
+ json.dumps({"nodes": cached_nodes, "edges": cached_edges, "hyperedges": cached_hyperedges},
4473
+ ensure_ascii=False),
4474
+ encoding="utf-8",
4475
+ )
4476
+ (out / ".graphify_uncached.txt").write_text("\n".join(uncached), encoding="utf-8")
4477
+ print(f"Cache: {len(files) - len(uncached)} hit, {len(uncached)} miss")
4478
+
4479
+ elif cmd == "merge-chunks":
4480
+ # graphify merge-chunks <chunk_glob_or_files...> --out <path>
4481
+ # Concatenates .graphify_chunk_*.json files written by semantic subagents.
4482
+ # Deduplicates nodes by id (first writer wins). Sums token counts.
4483
+ import glob as _glob
4484
+ if len(sys.argv) < 3:
4485
+ print("Usage: graphify merge-chunks <chunk_files...> --out <path>", file=sys.stderr)
4486
+ sys.exit(1)
4487
+ out_path: Path | None = None
4488
+ chunk_args: list[str] = []
4489
+ i = 2
4490
+ while i < len(sys.argv):
4491
+ if sys.argv[i] == "--out" and i + 1 < len(sys.argv):
4492
+ out_path = Path(sys.argv[i + 1])
4493
+ i += 2
4494
+ else:
4495
+ chunk_args.append(sys.argv[i])
4496
+ i += 1
4497
+ if not out_path:
4498
+ print("error: --out <path> required", file=sys.stderr)
4499
+ sys.exit(1)
4500
+ chunk_files: list[str] = []
4501
+ for arg in chunk_args:
4502
+ expanded = _glob.glob(arg)
4503
+ chunk_files.extend(sorted(expanded) if expanded else [arg])
4504
+ merged: dict = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
4505
+ seen_ids: set[str] = set()
4506
+ for cf in chunk_files:
4507
+ try:
4508
+ chunk = json.loads(Path(cf).read_text(encoding="utf-8"))
4509
+ except (json.JSONDecodeError, OSError) as exc:
4510
+ print(f"[graphify merge-chunks] warning: skipping {cf}: {exc}", file=sys.stderr)
4511
+ continue
4512
+ for n in chunk.get("nodes", []):
4513
+ if n.get("id") not in seen_ids:
4514
+ seen_ids.add(n["id"])
4515
+ merged["nodes"].append(n)
4516
+ merged["edges"].extend(chunk.get("edges", []))
4517
+ merged["hyperedges"].extend(chunk.get("hyperedges", []))
4518
+ merged["input_tokens"] += chunk.get("input_tokens", 0)
4519
+ merged["output_tokens"] += chunk.get("output_tokens", 0)
4520
+ out_path.parent.mkdir(parents=True, exist_ok=True)
4521
+ out_path.write_text(json.dumps(merged, ensure_ascii=False), encoding="utf-8")
4522
+ print(
4523
+ f"Merged {len(chunk_files)} chunks: {merged['nodes']} nodes, {len(merged['edges'])} edges, "
4524
+ f"{merged['input_tokens']:,} in / {merged['output_tokens']:,} out tokens"
4525
+ )
4526
+
4527
+ elif cmd == "merge-semantic":
4528
+ # graphify merge-semantic --cached <path> --new <path> --out <path>
4529
+ # Merges cached semantic results with freshly-extracted chunk results.
4530
+ # Deduplicates nodes by id (cached entries take priority over new ones).
4531
+ if len(sys.argv) < 3:
4532
+ print("Usage: graphify merge-semantic --cached <path> --new <path> --out <path>", file=sys.stderr)
4533
+ sys.exit(1)
4534
+ cached_path: Path | None = None
4535
+ new_path: Path | None = None
4536
+ out_path2: Path | None = None
4537
+ i = 2
4538
+ while i < len(sys.argv):
4539
+ if sys.argv[i] == "--cached" and i + 1 < len(sys.argv):
4540
+ cached_path = Path(sys.argv[i + 1]); i += 2
4541
+ elif sys.argv[i] == "--new" and i + 1 < len(sys.argv):
4542
+ new_path = Path(sys.argv[i + 1]); i += 2
4543
+ elif sys.argv[i] == "--out" and i + 1 < len(sys.argv):
4544
+ out_path2 = Path(sys.argv[i + 1]); i += 2
4545
+ else:
4546
+ i += 1
4547
+ if not out_path2:
4548
+ print("error: --out <path> required", file=sys.stderr)
4549
+ sys.exit(1)
4550
+ empty: dict = {"nodes": [], "edges": [], "hyperedges": []}
4551
+ cached_data = json.loads(cached_path.read_text(encoding="utf-8")) if cached_path and cached_path.exists() else empty
4552
+ new_data = json.loads(new_path.read_text(encoding="utf-8")) if new_path and new_path.exists() else empty
4553
+ seen_ids2: set[str] = set()
4554
+ all_nodes: list[dict] = []
4555
+ for n in cached_data.get("nodes", []) + new_data.get("nodes", []):
4556
+ if n.get("id") not in seen_ids2:
4557
+ seen_ids2.add(n["id"])
4558
+ all_nodes.append(n)
4559
+ merged2 = {
4560
+ "nodes": all_nodes,
4561
+ "edges": cached_data.get("edges", []) + new_data.get("edges", []),
4562
+ "hyperedges": cached_data.get("hyperedges", []) + new_data.get("hyperedges", []),
4563
+ }
4564
+ out_path2.parent.mkdir(parents=True, exist_ok=True)
4565
+ out_path2.write_text(json.dumps(merged2, ensure_ascii=False), encoding="utf-8")
4566
+ print(f"Merged: {len(merged2['nodes'])} nodes, {len(merged2['edges'])} edges")
4567
+
4568
+ elif Path(cmd).exists() or cmd in (".", "..") or cmd.startswith(("./", "../", "/", "~")):
4569
+ # User ran `graphify <path>` directly — treat as `graphify extract <path>`.
4570
+ # Common when following the PowerShell note in README (`graphify .`) or
4571
+ # copy-pasting skill invocations without the leading slash.
4572
+ sys.argv.insert(2, sys.argv[1])
4573
+ sys.argv[1] = "extract"
4574
+ main()
4575
+ else:
4576
+ print(f"error: unknown command '{cmd}'", file=sys.stderr)
4577
+ print("Run 'graphify --help' for usage.", file=sys.stderr)
4578
+ sys.exit(1)
4579
+
4580
+
4581
+ if __name__ == "__main__":
4582
+ main()