graphitect 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (336) hide show
  1. graphify/__init__.py +30 -0
  2. graphify/__main__.py +757 -0
  3. graphify/_minhash.py +107 -0
  4. graphify/affected.py +318 -0
  5. graphify/always_on/agents-md.md +12 -0
  6. graphify/always_on/antigravity-rules.md +14 -0
  7. graphify/always_on/claude-md.md +9 -0
  8. graphify/always_on/gemini-md.md +9 -0
  9. graphify/always_on/kiro-steering.md +5 -0
  10. graphify/always_on/vscode-instructions.md +17 -0
  11. graphify/analyze.py +769 -0
  12. graphify/benchmark.py +152 -0
  13. graphify/build.py +2300 -0
  14. graphify/cache.py +1746 -0
  15. graphify/callflow_html.py +2051 -0
  16. graphify/cargo_introspect.py +109 -0
  17. graphify/cli.py +4745 -0
  18. graphify/cluster.py +409 -0
  19. graphify/command-kilo.md +15 -0
  20. graphify/cross_repo_calls.py +216 -0
  21. graphify/cross_repo_types.py +75 -0
  22. graphify/csharp_dispatch.py +154 -0
  23. graphify/dedup.py +1213 -0
  24. graphify/detect.py +2566 -0
  25. graphify/diagnostics.py +406 -0
  26. graphify/export.py +1349 -0
  27. graphify/exporters/__init__.py +1 -0
  28. graphify/exporters/base.py +14 -0
  29. graphify/exporters/graphdb.py +173 -0
  30. graphify/exporters/html.py +637 -0
  31. graphify/extract.py +7856 -0
  32. graphify/extractors/MIGRATION.md +107 -0
  33. graphify/extractors/__init__.py +66 -0
  34. graphify/extractors/apex.py +215 -0
  35. graphify/extractors/base.py +85 -0
  36. graphify/extractors/bash.py +579 -0
  37. graphify/extractors/blade.py +53 -0
  38. graphify/extractors/commonlisp.py +540 -0
  39. graphify/extractors/csharp.py +448 -0
  40. graphify/extractors/dart.py +564 -0
  41. graphify/extractors/dm.py +494 -0
  42. graphify/extractors/elixir.py +241 -0
  43. graphify/extractors/engine.py +6509 -0
  44. graphify/extractors/fortran.py +311 -0
  45. graphify/extractors/go.py +527 -0
  46. graphify/extractors/json_config.py +240 -0
  47. graphify/extractors/julia.py +289 -0
  48. graphify/extractors/markdown.py +408 -0
  49. graphify/extractors/models.py +131 -0
  50. graphify/extractors/objc.py +566 -0
  51. graphify/extractors/ocaml.py +289 -0
  52. graphify/extractors/pascal.py +688 -0
  53. graphify/extractors/pascal_forms.py +196 -0
  54. graphify/extractors/powershell.py +522 -0
  55. graphify/extractors/razor.py +192 -0
  56. graphify/extractors/resolution.py +3584 -0
  57. graphify/extractors/robot.py +296 -0
  58. graphify/extractors/rust.py +470 -0
  59. graphify/extractors/sln.py +92 -0
  60. graphify/extractors/sql.py +720 -0
  61. graphify/extractors/terraform.py +181 -0
  62. graphify/extractors/verilog.py +329 -0
  63. graphify/extractors/zig.py +181 -0
  64. graphify/file_slice.py +246 -0
  65. graphify/global_graph.py +194 -0
  66. graphify/google_workspace.py +237 -0
  67. graphify/hooks.py +933 -0
  68. graphify/ids.py +93 -0
  69. graphify/ingest.py +358 -0
  70. graphify/install.py +2366 -0
  71. graphify/llm.py +3544 -0
  72. graphify/manifest.py +4 -0
  73. graphify/manifest_ingest.py +311 -0
  74. graphify/mcp_ingest.py +386 -0
  75. graphify/multigraph_compat.py +212 -0
  76. graphify/pascal_resolution.py +129 -0
  77. graphify/paths.py +436 -0
  78. graphify/pg_introspect.py +165 -0
  79. graphify/prs.py +770 -0
  80. graphify/querylog.py +80 -0
  81. graphify/reflect.py +882 -0
  82. graphify/report.py +346 -0
  83. graphify/resolver_registry.py +85 -0
  84. graphify/ruby_resolution.py +242 -0
  85. graphify/scip_ingest.py +363 -0
  86. graphify/security.py +460 -0
  87. graphify/semantic_cleanup.py +336 -0
  88. graphify/serve.py +2608 -0
  89. graphify/skill-agents.md +710 -0
  90. graphify/skill-aider.md +1283 -0
  91. graphify/skill-amp.md +710 -0
  92. graphify/skill-claw.md +713 -0
  93. graphify/skill-codex.md +710 -0
  94. graphify/skill-copilot.md +713 -0
  95. graphify/skill-devin.md +1410 -0
  96. graphify/skill-droid.md +710 -0
  97. graphify/skill-kilo.md +722 -0
  98. graphify/skill-kiro.md +713 -0
  99. graphify/skill-opencode.md +705 -0
  100. graphify/skill-pi.md +713 -0
  101. graphify/skill-trae.md +711 -0
  102. graphify/skill-vscode.md +709 -0
  103. graphify/skill-windows.md +755 -0
  104. graphify/skill.md +713 -0
  105. graphify/skills/agents/references/add-watch.md +56 -0
  106. graphify/skills/agents/references/exports.md +87 -0
  107. graphify/skills/agents/references/extraction-spec.md +70 -0
  108. graphify/skills/agents/references/github-and-merge.md +46 -0
  109. graphify/skills/agents/references/hooks.md +33 -0
  110. graphify/skills/agents/references/query.md +311 -0
  111. graphify/skills/agents/references/transcribe.md +52 -0
  112. graphify/skills/agents/references/update.md +210 -0
  113. graphify/skills/amp/references/add-watch.md +56 -0
  114. graphify/skills/amp/references/exports.md +87 -0
  115. graphify/skills/amp/references/extraction-spec.md +70 -0
  116. graphify/skills/amp/references/github-and-merge.md +46 -0
  117. graphify/skills/amp/references/hooks.md +33 -0
  118. graphify/skills/amp/references/query.md +311 -0
  119. graphify/skills/amp/references/transcribe.md +52 -0
  120. graphify/skills/amp/references/update.md +210 -0
  121. graphify/skills/claude/references/add-watch.md +56 -0
  122. graphify/skills/claude/references/exports.md +87 -0
  123. graphify/skills/claude/references/extraction-spec.md +70 -0
  124. graphify/skills/claude/references/github-and-merge.md +46 -0
  125. graphify/skills/claude/references/hooks.md +33 -0
  126. graphify/skills/claude/references/query.md +311 -0
  127. graphify/skills/claude/references/transcribe.md +52 -0
  128. graphify/skills/claude/references/update.md +210 -0
  129. graphify/skills/claw/references/add-watch.md +56 -0
  130. graphify/skills/claw/references/exports.md +87 -0
  131. graphify/skills/claw/references/extraction-spec.md +31 -0
  132. graphify/skills/claw/references/github-and-merge.md +46 -0
  133. graphify/skills/claw/references/hooks.md +33 -0
  134. graphify/skills/claw/references/query.md +311 -0
  135. graphify/skills/claw/references/transcribe.md +52 -0
  136. graphify/skills/claw/references/update.md +210 -0
  137. graphify/skills/codex/references/add-watch.md +56 -0
  138. graphify/skills/codex/references/exports.md +87 -0
  139. graphify/skills/codex/references/extraction-spec.md +31 -0
  140. graphify/skills/codex/references/github-and-merge.md +46 -0
  141. graphify/skills/codex/references/hooks.md +33 -0
  142. graphify/skills/codex/references/query.md +311 -0
  143. graphify/skills/codex/references/transcribe.md +52 -0
  144. graphify/skills/codex/references/update.md +210 -0
  145. graphify/skills/copilot/references/add-watch.md +56 -0
  146. graphify/skills/copilot/references/exports.md +87 -0
  147. graphify/skills/copilot/references/extraction-spec.md +70 -0
  148. graphify/skills/copilot/references/github-and-merge.md +46 -0
  149. graphify/skills/copilot/references/hooks.md +33 -0
  150. graphify/skills/copilot/references/query.md +311 -0
  151. graphify/skills/copilot/references/transcribe.md +52 -0
  152. graphify/skills/copilot/references/update.md +210 -0
  153. graphify/skills/droid/references/add-watch.md +56 -0
  154. graphify/skills/droid/references/exports.md +87 -0
  155. graphify/skills/droid/references/extraction-spec.md +70 -0
  156. graphify/skills/droid/references/github-and-merge.md +46 -0
  157. graphify/skills/droid/references/hooks.md +33 -0
  158. graphify/skills/droid/references/query.md +311 -0
  159. graphify/skills/droid/references/transcribe.md +52 -0
  160. graphify/skills/droid/references/update.md +210 -0
  161. graphify/skills/kilo/references/add-watch.md +56 -0
  162. graphify/skills/kilo/references/exports.md +87 -0
  163. graphify/skills/kilo/references/extraction-spec.md +70 -0
  164. graphify/skills/kilo/references/github-and-merge.md +46 -0
  165. graphify/skills/kilo/references/hooks.md +33 -0
  166. graphify/skills/kilo/references/query.md +311 -0
  167. graphify/skills/kilo/references/transcribe.md +52 -0
  168. graphify/skills/kilo/references/update.md +210 -0
  169. graphify/skills/kiro/references/add-watch.md +56 -0
  170. graphify/skills/kiro/references/exports.md +87 -0
  171. graphify/skills/kiro/references/extraction-spec.md +31 -0
  172. graphify/skills/kiro/references/github-and-merge.md +46 -0
  173. graphify/skills/kiro/references/hooks.md +33 -0
  174. graphify/skills/kiro/references/query.md +311 -0
  175. graphify/skills/kiro/references/transcribe.md +52 -0
  176. graphify/skills/kiro/references/update.md +210 -0
  177. graphify/skills/opencode/references/add-watch.md +56 -0
  178. graphify/skills/opencode/references/exports.md +87 -0
  179. graphify/skills/opencode/references/extraction-spec.md +70 -0
  180. graphify/skills/opencode/references/github-and-merge.md +46 -0
  181. graphify/skills/opencode/references/hooks.md +33 -0
  182. graphify/skills/opencode/references/query.md +311 -0
  183. graphify/skills/opencode/references/transcribe.md +52 -0
  184. graphify/skills/opencode/references/update.md +210 -0
  185. graphify/skills/pi/references/add-watch.md +56 -0
  186. graphify/skills/pi/references/exports.md +87 -0
  187. graphify/skills/pi/references/extraction-spec.md +31 -0
  188. graphify/skills/pi/references/github-and-merge.md +46 -0
  189. graphify/skills/pi/references/hooks.md +33 -0
  190. graphify/skills/pi/references/query.md +311 -0
  191. graphify/skills/pi/references/transcribe.md +52 -0
  192. graphify/skills/pi/references/update.md +210 -0
  193. graphify/skills/trae/references/add-watch.md +56 -0
  194. graphify/skills/trae/references/exports.md +87 -0
  195. graphify/skills/trae/references/extraction-spec.md +70 -0
  196. graphify/skills/trae/references/github-and-merge.md +46 -0
  197. graphify/skills/trae/references/hooks.md +35 -0
  198. graphify/skills/trae/references/query.md +311 -0
  199. graphify/skills/trae/references/transcribe.md +52 -0
  200. graphify/skills/trae/references/update.md +210 -0
  201. graphify/skills/vscode/references/add-watch.md +56 -0
  202. graphify/skills/vscode/references/exports.md +87 -0
  203. graphify/skills/vscode/references/extraction-spec.md +70 -0
  204. graphify/skills/vscode/references/github-and-merge.md +46 -0
  205. graphify/skills/vscode/references/hooks.md +33 -0
  206. graphify/skills/vscode/references/query.md +311 -0
  207. graphify/skills/vscode/references/transcribe.md +52 -0
  208. graphify/skills/vscode/references/update.md +210 -0
  209. graphify/skills/windows/references/add-watch.md +56 -0
  210. graphify/skills/windows/references/exports.md +87 -0
  211. graphify/skills/windows/references/extraction-spec.md +70 -0
  212. graphify/skills/windows/references/github-and-merge.md +46 -0
  213. graphify/skills/windows/references/hooks.md +33 -0
  214. graphify/skills/windows/references/query.md +311 -0
  215. graphify/skills/windows/references/transcribe.md +52 -0
  216. graphify/skills/windows/references/update.md +210 -0
  217. graphify/symbol_resolution.py +556 -0
  218. graphify/transcribe.py +186 -0
  219. graphify/tree_html.py +603 -0
  220. graphify/validate.py +95 -0
  221. graphify/watch.py +2280 -0
  222. graphify/wiki.py +405 -0
  223. graphitect/__init__.py +28 -0
  224. graphitect/__main__.py +4 -0
  225. graphitect/_vendor/__init__.py +2 -0
  226. graphitect/_vendor/archify/LICENSE +22 -0
  227. graphitect/_vendor/archify/SKILL.md +137 -0
  228. graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  229. graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  230. graphitect/_vendor/archify/assets/template.html +14935 -0
  231. graphitect/_vendor/archify/bin/archify.mjs +2091 -0
  232. graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
  233. graphitect/_vendor/archify/bin/preview.mjs +653 -0
  234. graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
  235. graphitect/_vendor/archify/brand-marks/README.md +31 -0
  236. graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
  237. graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
  238. graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
  239. graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  240. graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  241. graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  242. graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  243. graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  244. graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  245. graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  246. graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  247. graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
  248. graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
  249. graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  250. graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
  251. graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
  252. graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
  253. graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  254. graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
  255. graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
  256. graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  257. graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
  258. graphitect/_vendor/archify/package-lock.json +149 -0
  259. graphitect/_vendor/archify/package.json +39 -0
  260. graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
  261. graphitect/_vendor/archify/references/authoring-contract.md +243 -0
  262. graphitect/_vendor/archify/references/brand-marks.md +65 -0
  263. graphitect/_vendor/archify/references/delivery-contract.md +120 -0
  264. graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
  265. graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
  266. graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  267. graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
  268. graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  269. graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
  270. graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  271. graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
  272. graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  273. graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  274. graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
  275. graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  276. graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  277. graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  278. graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  279. graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  280. graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
  281. graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
  282. graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
  283. graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
  284. graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
  285. graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  286. graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
  287. graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
  288. graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
  289. graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
  290. graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
  291. graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  292. graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  293. graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  294. graphitect/_vendor/archify/schemas/README.md +211 -0
  295. graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
  296. graphitect/_vendor/archify/schemas/common.schema.json +115 -0
  297. graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
  298. graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
  299. graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
  300. graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
  301. graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
  302. graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
  303. graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
  304. graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
  305. graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
  306. graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
  307. graphitect/_vendor/archify/skill-release.json +10 -0
  308. graphitect/cli.py +981 -0
  309. graphitect/deliver/__init__.py +5 -0
  310. graphitect/deliver/archify_adapter.py +1877 -0
  311. graphitect/deliver/archify_ir.py +160 -0
  312. graphitect/deliver/archify_repair.py +135 -0
  313. graphitect/deliver/doc_compiler.py +916 -0
  314. graphitect/ground/__init__.py +5 -0
  315. graphitect/ground/describe_source.py +27 -0
  316. graphitect/ground/fullread_source.py +56 -0
  317. graphitect/ground/graphify_source.py +107 -0
  318. graphitect/models.py +118 -0
  319. graphitect/skill/SKILL.md +80 -0
  320. graphitect/skill/agents/openai.yaml +4 -0
  321. graphitect/synthesize/__init__.py +5 -0
  322. graphitect/synthesize/engine.py +281 -0
  323. graphitect/synthesize/llm_backend.py +331 -0
  324. graphitect/synthesize/questions.py +139 -0
  325. graphitect/synthesize/rubric.py +104 -0
  326. graphitect-0.2.0.dist-info/METADATA +284 -0
  327. graphitect-0.2.0.dist-info/RECORD +336 -0
  328. graphitect-0.2.0.dist-info/WHEEL +5 -0
  329. graphitect-0.2.0.dist-info/entry_points.txt +2 -0
  330. graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
  331. graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
  332. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
  333. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
  334. graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
  335. graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
  336. graphitect-0.2.0.dist-info/top_level.txt +2 -0
graphify/install.py ADDED
@@ -0,0 +1,2366 @@
1
+ """graphify install/uninstall subsystem.
2
+
3
+ The per-platform skill/hook installers and uninstallers, extracted verbatim from
4
+ graphify/__main__.py so the CLI dispatcher (`main`) stays readable. Import path is
5
+ unchanged: `graphify.__main__` re-exports every name defined here, so
6
+ `from graphify.__main__ import claude_install` (etc.) keeps working.
7
+
8
+ Lives at graphify/install.py (same package dir as __main__) on purpose: the
9
+ installers resolve packaged assets via `Path(__file__).parent / "always_on"` and
10
+ `/ "skills"`, which only works from within the graphify/ directory.
11
+ """
12
+
13
+ from __future__ import annotations
14
+ import functools
15
+ import json
16
+ import os
17
+ import platform
18
+ import re
19
+ import shutil
20
+ import stat
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import NoReturn
24
+
25
+ try:
26
+ from importlib.metadata import version as _pkg_version
27
+
28
+ __version__ = _pkg_version("graphifyy")
29
+ except Exception:
30
+ __version__ = "unknown"
31
+
32
+ from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT
33
+
34
+
35
+ def _write_version_stamp(skill_dst: Path, version: str) -> None:
36
+ """Atomically write ``.graphify_version`` beside ``skill_dst``.
37
+
38
+ Matches the SKILL.md install path: temp file in the same directory, then
39
+ ``os.replace``. Unlike ``Path.write_text`` (and unlike
40
+ ``paths.write_text_atomic``, which resolves through symlinks), ``os.replace``
41
+ replaces a managed symlink in place — consistent with SKILL.md /
42
+ ``references/`` and crash-safe against a half-written stamp (#3286).
43
+ """
44
+ version_file = skill_dst.parent / ".graphify_version"
45
+ tmp = version_file.with_name(".graphify_version.tmp")
46
+ try:
47
+ tmp.write_text(version, encoding="utf-8")
48
+ os.replace(tmp, version_file)
49
+ except Exception:
50
+ try:
51
+ tmp.unlink(missing_ok=True)
52
+ except OSError:
53
+ pass
54
+ raise
55
+
56
+
57
+ @functools.lru_cache(maxsize=None)
58
+ def _always_on(basename: str) -> str:
59
+ """Read a packaged always-on instruction block from graphify/always_on/.
60
+
61
+ The six always-on blocks (CLAUDE.md / AGENTS.md / GEMINI.md / VS Code
62
+ Copilot instructions / Antigravity rules / Kiro steering) live as committed
63
+ markdown next to this module, generated by tools/skillgen from a single
64
+ human-edited fragment and guarded against drift by ``skillgen --check``. The
65
+ installer injects them verbatim via ``_replace_or_append_section``, so the
66
+ bytes here must match the former triple-quoted constant exactly — the
67
+ always-on-roundtrip validator proves that.
68
+ """
69
+ path = Path(__file__).parent / "always_on" / f"{basename}.md"
70
+ try:
71
+ return path.read_text(encoding="utf-8")
72
+ except OSError as exc:
73
+ # Defer to use-time so a missing/corrupt packaged block can't crash module
74
+ # import (which would brick every CLI command, not just install). Reached
75
+ # only by an install/integration path that actually needs this block.
76
+ raise RuntimeError(
77
+ f"graphify install is incomplete: missing always-on block '{basename}' "
78
+ f"at {path}. Reinstall graphifyy (e.g. `uv tool install --reinstall graphifyy`)."
79
+ ) from exc
80
+ def _platform_skill_destination(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path:
81
+ """Return the skill destination for a platform and scope."""
82
+ if platform_name == "gemini":
83
+ if project:
84
+ return (project_dir or Path(".")) / ".gemini" / "skills" / "graphify" / "SKILL.md"
85
+ if platform.system() == "Windows":
86
+ return Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md"
87
+ return Path.home() / ".gemini" / "skills" / "graphify" / "SKILL.md"
88
+
89
+ if platform_name == "opencode":
90
+ if project:
91
+ return (project_dir or Path(".")) / ".opencode" / "skills" / "graphify" / "SKILL.md"
92
+ return Path.home() / ".config" / "opencode" / "skills" / "graphify" / "SKILL.md"
93
+
94
+ if platform_name == "hermes":
95
+ if project:
96
+ return (project_dir or Path(".")) / ".hermes" / "skills" / "graphify" / "SKILL.md"
97
+ # On Windows, Hermes scans %LOCALAPPDATA%\hermes\skills, not ~/.hermes (#1403).
98
+ if platform.system() == "Windows":
99
+ local_appdata = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local"))
100
+ return local_appdata / "hermes" / "skills" / "graphify" / "SKILL.md"
101
+ return Path.home() / ".hermes" / "skills" / "graphify" / "SKILL.md"
102
+
103
+ if platform_name == "devin":
104
+ if project:
105
+ return (project_dir or Path(".")) / ".devin" / "skills" / "graphify" / "SKILL.md"
106
+ return Path.home() / ".config" / "devin" / "skills" / "graphify" / "SKILL.md"
107
+
108
+ if platform_name == "amp":
109
+ if project:
110
+ return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
111
+ return Path.home() / ".config" / "agents" / "skills" / "graphify" / "SKILL.md"
112
+
113
+ if platform_name == "agents":
114
+ # The generic Agent-Skills target: project ./.agents/skills, global the
115
+ # spec's user-global ~/.agents/skills (read by `npx skills` and compliant
116
+ # frameworks), NOT amp's ~/.config/agents/skills.
117
+ if project:
118
+ return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
119
+ return Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md"
120
+
121
+ if platform_name in ("antigravity", "antigravity-windows"):
122
+ if project:
123
+ return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
124
+ # Global Antigravity skill dir (all workspaces): ~/.gemini/config/skills/
125
+ return Path.home() / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md"
126
+
127
+ cfg = _PLATFORM_CONFIG[platform_name]
128
+ if project:
129
+ return (project_dir or Path(".")) / cfg["skill_dst"]
130
+
131
+ if platform_name in ("claude", "windows") and os.environ.get("CLAUDE_CONFIG_DIR"):
132
+ return Path(os.environ["CLAUDE_CONFIG_DIR"]) / "skills" / "graphify" / "SKILL.md"
133
+ return Path.home() / cfg["skill_dst"]
134
+ def _packaged_skill_refs_dir(platform_name: str) -> Path | None:
135
+ """Return the packaged references source dir for a progressive platform, else None.
136
+
137
+ A platform opts into progressive disclosure by setting ``skill_refs`` in its
138
+ ``_PLATFORM_CONFIG`` entry. The value names a bundle under
139
+ ``graphify/skills/<bundle>/references/``. Reuse keys (e.g. trae-cn) point at
140
+ their twin's bundle.
141
+
142
+ ``gemini`` has no ``_PLATFORM_CONFIG`` entry: it installs claude's
143
+ ``skill.md`` body verbatim (see ``_copy_skill_file``). Since that body is the
144
+ lean progressive core that links to ``references/``, gemini needs claude's
145
+ references/ sidecar too, or its SKILL.md ships with dead pointers. So gemini
146
+ resolves to the claude bundle rather than opting out.
147
+
148
+ Bundles ship one platform-group at a time. A host whose bundle directory
149
+ ``graphify/skills/<bundle>/`` is not in this build has not gone progressive
150
+ yet, so this returns None and the host installs today's monolithic SKILL.md
151
+ with no references/ sidecar. Only when the bundle directory IS present does
152
+ this return the references path; if that directory then lacks its
153
+ ``references/`` subdir, ``_copy_skill_file`` hard-fails (a malformed bundle,
154
+ the empty-sidecar regression the wheel-content test also guards).
155
+ """
156
+ if platform_name == "gemini":
157
+ bundle = "claude"
158
+ else:
159
+ bundle = _PLATFORM_CONFIG[platform_name].get("skill_refs")
160
+ if not bundle:
161
+ return None
162
+ bundle_dir = Path(__file__).parent / "skills" / bundle
163
+ if not bundle_dir.is_dir():
164
+ return None
165
+ return bundle_dir / "references"
166
+ def _install_skill_references(skill_dst: Path, refs_src: Path) -> None:
167
+ """Atomically install a packaged references/ sidecar next to SKILL.md.
168
+
169
+ Stages the packaged dir into ``references.tmp`` (copytree), drops any stale
170
+ ``references/`` already on disk, then ``os.replace``-renames the staged dir
171
+ into place. The rename is atomic on the same filesystem, so an interrupted
172
+ install never leaves a half-written references/ visible to the agent.
173
+ """
174
+ refs_dst = skill_dst.parent / "references"
175
+ refs_staged = skill_dst.parent / "references.tmp"
176
+ if refs_staged.exists():
177
+ shutil.rmtree(refs_staged)
178
+ try:
179
+ shutil.copytree(refs_src, refs_staged)
180
+ # copytree preserves the source's mode bits, and a packaged bundle can
181
+ # be read-only: a Nix store path, a root-owned site-packages, a
182
+ # container image layer. Renaming a directory needs write permission on
183
+ # the directory itself, to update its ".." entry, so the os.replace
184
+ # below would fail with EACCES. Restore owner-write on the staged copy.
185
+ for path in (refs_staged, *refs_staged.rglob("*")):
186
+ path.chmod(path.stat().st_mode | stat.S_IWUSR)
187
+ if refs_dst.exists():
188
+ shutil.rmtree(refs_dst)
189
+ os.replace(refs_staged, refs_dst)
190
+ except Exception:
191
+ if refs_staged.exists():
192
+ shutil.rmtree(refs_staged, ignore_errors=True)
193
+ raise
194
+ def _copy_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path:
195
+ """Copy a packaged skill file and write its version stamp.
196
+
197
+ For progressive platforms (those with ``skill_refs`` set), the packaged
198
+ ``references/`` sidecar is installed alongside SKILL.md and the single
199
+ ``.graphify_version`` stamp covers both. For monolith platforms (no
200
+ ``skill_refs``), any orphan ``references/`` left by a prior progressive
201
+ install is removed so the on-disk layout matches the package.
202
+ """
203
+ skill_file = "skill.md" if platform_name == "gemini" else _PLATFORM_CONFIG[platform_name]["skill_file"]
204
+ skill_src = Path(__file__).parent / skill_file
205
+ if not skill_src.exists():
206
+ print(f"error: {skill_file} not found in package - reinstall graphify", file=sys.stderr)
207
+ sys.exit(1)
208
+
209
+ refs_src = _packaged_skill_refs_dir(platform_name)
210
+ if refs_src is not None and not refs_src.exists():
211
+ # Progressive platform declared a references bundle that is missing from
212
+ # the package. Fail loud rather than silently shipping an empty sidecar.
213
+ print(
214
+ f"error: references for '{platform_name}' not found in package "
215
+ f"({refs_src}) - reinstall graphify",
216
+ file=sys.stderr,
217
+ )
218
+ sys.exit(1)
219
+
220
+ skill_dst = _platform_skill_destination(platform_name, project=project, project_dir=project_dir)
221
+ skill_dst.parent.mkdir(parents=True, exist_ok=True)
222
+
223
+ # Install the references/ sidecar (or clear an orphan one) BEFORE writing
224
+ # SKILL.md, so SKILL.md is the last artifact laid down. An install that is
225
+ # interrupted partway then leaves no SKILL.md rather than a SKILL.md that
226
+ # points at an absent references/ dir.
227
+ if refs_src is not None:
228
+ _install_skill_references(skill_dst, refs_src)
229
+ print(f" references -> {skill_dst.parent / 'references'}")
230
+ else:
231
+ # Monolith (or progressive-with-no-refs): clear any orphan references/.
232
+ orphan_refs = skill_dst.parent / "references"
233
+ if orphan_refs.exists():
234
+ shutil.rmtree(orphan_refs)
235
+
236
+ # A SKILL.md that differs from what is about to be written may carry the
237
+ # user's local edits (a tuned description:, extra guidance); replacing it
238
+ # wholesale with only "skill installed ->" for output read like a no-op
239
+ # while the edits were gone (#3144). Keep one .bak beside it and say so.
240
+ # Every upgrade differs too - the .bak is overwritten each install, so it
241
+ # always holds exactly the previous copy.
242
+ try:
243
+ if skill_dst.exists() and skill_dst.read_bytes() != skill_src.read_bytes():
244
+ backup = skill_dst.with_suffix(skill_dst.suffix + ".bak")
245
+ shutil.copy2(skill_dst, backup)
246
+ print(f" previous copy -> {backup} (differed from the packaged skill)")
247
+ except OSError:
248
+ pass # a failed backup must not block the install
249
+ # SKILL.md last (crash-safety), via an atomic temp + rename.
250
+ tmp_dst = skill_dst.with_suffix(skill_dst.suffix + ".tmp")
251
+ try:
252
+ shutil.copy(skill_src, tmp_dst)
253
+ os.replace(tmp_dst, skill_dst)
254
+ except Exception:
255
+ try:
256
+ tmp_dst.unlink(missing_ok=True)
257
+ except OSError:
258
+ pass
259
+ raise
260
+
261
+ _write_version_stamp(skill_dst, __version__)
262
+ print(f" skill installed -> {skill_dst}")
263
+ return skill_dst
264
+ def _remove_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> bool:
265
+ """Remove a platform skill file and its version stamp without touching other scopes."""
266
+ skill_dst = _platform_skill_destination(platform_name, project=project, project_dir=project_dir)
267
+ removed = False
268
+ if skill_dst.exists():
269
+ skill_dst.unlink()
270
+ print(f" skill removed -> {skill_dst}")
271
+ removed = True
272
+ version_file = skill_dst.parent / ".graphify_version"
273
+ if version_file.exists():
274
+ version_file.unlink()
275
+ removed = True
276
+ refs_dir = skill_dst.parent / "references"
277
+ if refs_dir.exists():
278
+ shutil.rmtree(refs_dir)
279
+ removed = True
280
+ for d in (skill_dst.parent, skill_dst.parent.parent, skill_dst.parent.parent.parent):
281
+ try:
282
+ d.rmdir()
283
+ except OSError:
284
+ break
285
+ return removed
286
+ def _project_scope_root(path: Path, project_dir: Path) -> Path:
287
+ """Return the top-level project artifact for a project-scoped skill path."""
288
+ try:
289
+ rel = path.relative_to(project_dir)
290
+ except ValueError:
291
+ return path
292
+ return project_dir / rel.parts[0] if rel.parts else path
293
+ def _remove_claude_skill_registration(project_dir: Path) -> None:
294
+ """Remove the project-scoped Claude skill registration file/section."""
295
+ claude_md = project_dir / ".claude" / "CLAUDE.md"
296
+ if not claude_md.exists():
297
+ return
298
+ content = claude_md.read_text(encoding="utf-8")
299
+ # Match the exact H1 `# graphify` registration heading, never a substring of a
300
+ # user's `## graphify`/`### graphify` (#2062). Section runs to the next H1.
301
+ cleaned = _remove_marker_section(content, "# graphify", boundary_prefix="# ")
302
+ if cleaned is None:
303
+ return
304
+ if cleaned:
305
+ claude_md.write_text(cleaned + "\n", encoding="utf-8")
306
+ print(f" CLAUDE.md -> graphify skill registration removed from {claude_md}")
307
+ else:
308
+ claude_md.unlink()
309
+ print(f" CLAUDE.md -> deleted {claude_md}")
310
+ def _print_project_git_add_hint(paths: list[Path]) -> None:
311
+ unique: list[str] = []
312
+ for path in paths:
313
+ text = path.as_posix().rstrip("/")
314
+ if path.exists() and path.is_dir():
315
+ text += "/"
316
+ if text not in unique:
317
+ unique.append(text)
318
+ if not unique:
319
+ return
320
+ print()
321
+ print("Project-scoped install. Add to version control:")
322
+ print(f" git add {' '.join(unique)}")
323
+ def _claude_pretooluse_hooks(strict: bool = False, project: bool = False) -> "list[dict]":
324
+ """graphify's Claude/Codebuddy PreToolUse hooks, resolved at install time.
325
+
326
+ The command invokes `graphify hook-guard <search|read>` via the absolute exe
327
+ path (`_resolve_graphify_exe`) — or, for a project-scoped install, via the
328
+ bare `graphify` command, since that config gets committed (#3129). Either
329
+ form parses under sh, cmd.exe and PowerShell alike — this is the #522 fix,
330
+ and mirrors the codex hook. Matchers are
331
+ "Bash|Grep" and "Read|Glob" and the command always contains "graphify", so the
332
+ existing install/uninstall filters find and replace both old bash hooks and
333
+ these. "Grep" is in the search matcher because current Claude Code routes
334
+ content search through its dedicated Grep tool, not Bash (#1986) — a
335
+ Bash-only matcher never fired on the agent's primary search path.
336
+
337
+ When ``strict`` is set, the read hook carries ``--strict`` so it blocks the
338
+ first raw read per session (Claude Code only). The ``GRAPHIFY_HOOK_STRICT`` env
339
+ var can force it on or off at runtime without a reinstall.
340
+ """
341
+ exe = _resolve_graphify_exe(project=project)
342
+ if " " in exe and not exe.startswith('"'):
343
+ exe = f'"{exe}"'
344
+ read_cmd = f"{exe} hook-guard read" + (" --strict" if strict else "")
345
+ return [
346
+ {"matcher": "Bash|Grep",
347
+ "hooks": [{"type": "command", "command": f"{exe} hook-guard search"}]},
348
+ {"matcher": "Read|Glob",
349
+ "hooks": [{"type": "command", "command": read_cmd}]},
350
+ ]
351
+ def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") -> str:
352
+ return (
353
+ "\n# graphify\n"
354
+ f"- **graphify** (`{skill_path}`) "
355
+ "- any input to knowledge graph. Trigger: `/graphify`\n"
356
+ "When the user types `/graphify`, use the installed graphify skill "
357
+ "or instructions before doing anything else.\n"
358
+ )
359
+ def _register_always_on_block(target: Path, prefix: str, registration: str) -> None:
360
+ """Append an always-on registration to *target*, degrading instead of raising.
361
+
362
+ The skill files are copied before this runs, so a *target* that cannot be
363
+ read or written must not abort an otherwise-complete install (#3474). That
364
+ happens whenever the dotfile is managed declaratively -- nix/home-manager
365
+ symlinks ``~/.claude/CLAUDE.md`` into a read-only /nix/store, and chezmoi or
366
+ stow with read-only sources leave the same shape.
367
+ """
368
+ try:
369
+ if target.exists():
370
+ content = target.read_text(encoding="utf-8")
371
+ if "graphify" in content:
372
+ print(f"{prefix}already registered (no change)")
373
+ else:
374
+ target.write_text(content.rstrip() + registration, encoding="utf-8")
375
+ print(f"{prefix}skill registered in {target}")
376
+ else:
377
+ target.parent.mkdir(parents=True, exist_ok=True)
378
+ target.write_text(registration.lstrip(), encoding="utf-8")
379
+ print(f"{prefix}created at {target}")
380
+ except OSError as exc:
381
+ print(f"{prefix}skipped: {exc.__class__.__name__}: {exc}", file=sys.stderr)
382
+ print(
383
+ f" hint: the skill files were installed; add the graphify block to "
384
+ f"{target} manually to finish always-on registration",
385
+ file=sys.stderr,
386
+ )
387
+ _PLATFORM_CONFIG: dict[str, dict] = {
388
+ "claude": {
389
+ "skill_file": "skill.md",
390
+ "skill_dst": Path(".claude") / "skills" / "graphify" / "SKILL.md",
391
+ "claude_md": True,
392
+ "skill_refs": "claude",
393
+ },
394
+ "codex": {
395
+ "skill_file": "skill-codex.md",
396
+ "skill_dst": Path(".codex") / "skills" / "graphify" / "SKILL.md",
397
+ "claude_md": False,
398
+ "skill_refs": "codex",
399
+ },
400
+ "opencode": {
401
+ "skill_file": "skill-opencode.md",
402
+ "skill_dst": Path(".config") / "opencode" / "skills" / "graphify" / "SKILL.md",
403
+ "claude_md": False,
404
+ "skill_refs": "opencode",
405
+ },
406
+ "kilo": {
407
+ "skill_file": "skill-kilo.md",
408
+ "skill_dst": Path(".config") / "kilo" / "skills" / "graphify" / "SKILL.md",
409
+ "claude_md": False,
410
+ "skill_refs": "kilo",
411
+ },
412
+ "aider": {
413
+ # Monolith: aider ships the full SKILL.md inline, no references/ sidecar.
414
+ "skill_file": "skill-aider.md",
415
+ "skill_dst": Path(".aider") / "graphify" / "SKILL.md",
416
+ "claude_md": False,
417
+ },
418
+ "copilot": {
419
+ "skill_file": "skill-copilot.md",
420
+ "skill_dst": Path(".copilot") / "skills" / "graphify" / "SKILL.md",
421
+ "claude_md": False,
422
+ "skill_refs": "copilot",
423
+ },
424
+ "claw": {
425
+ "skill_file": "skill-claw.md",
426
+ "skill_dst": Path(".openclaw") / "skills" / "graphify" / "SKILL.md",
427
+ "claude_md": False,
428
+ "skill_refs": "claw",
429
+ },
430
+ "droid": {
431
+ "skill_file": "skill-droid.md",
432
+ "skill_dst": Path(".factory") / "skills" / "graphify" / "SKILL.md",
433
+ "claude_md": False,
434
+ "skill_refs": "droid",
435
+ },
436
+ "trae": {
437
+ "skill_file": "skill-trae.md",
438
+ "skill_dst": Path(".trae") / "skills" / "graphify" / "SKILL.md",
439
+ "claude_md": False,
440
+ "skill_refs": "trae",
441
+ },
442
+ "trae-cn": {
443
+ # Reuses trae's split bundle (same skill body + references).
444
+ "skill_file": "skill-trae.md",
445
+ "skill_dst": Path(".trae-cn") / "skills" / "graphify" / "SKILL.md",
446
+ "claude_md": False,
447
+ "skill_refs": "trae",
448
+ },
449
+ "hermes": {
450
+ # Reuses claw's split bundle.
451
+ "skill_file": "skill-claw.md",
452
+ "skill_dst": Path(".hermes") / "skills" / "graphify" / "SKILL.md",
453
+ "claude_md": False,
454
+ "skill_refs": "claw",
455
+ },
456
+ "kiro": {
457
+ "skill_file": "skill-kiro.md",
458
+ "skill_dst": Path(".kiro") / "skills" / "graphify" / "SKILL.md",
459
+ "claude_md": False,
460
+ "skill_refs": "kiro",
461
+ },
462
+ "pi": {
463
+ "skill_file": "skill-pi.md",
464
+ "skill_dst": Path(".pi") / "agent" / "skills" / "graphify" / "SKILL.md",
465
+ "claude_md": False,
466
+ "skill_refs": "pi",
467
+ },
468
+ "codebuddy": {
469
+ # Reuses claude's split bundle (shares skill.md).
470
+ "skill_file": "skill.md",
471
+ "skill_dst": Path(".codebuddy") / "skills" / "graphify" / "SKILL.md",
472
+ "claude_md": False,
473
+ "skill_refs": "claude",
474
+ },
475
+ "antigravity": {
476
+ # Rides claude's split bundle (shares skill.md).
477
+ "skill_file": "skill.md",
478
+ "skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
479
+ "claude_md": False,
480
+ "skill_refs": "claude",
481
+ },
482
+ "antigravity-windows": {
483
+ # Rides windows' split bundle.
484
+ "skill_file": "skill-windows.md",
485
+ "skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
486
+ "claude_md": False,
487
+ "skill_refs": "windows",
488
+ },
489
+ "windows": {
490
+ "skill_file": "skill-windows.md",
491
+ "skill_dst": Path(".claude") / "skills" / "graphify" / "SKILL.md",
492
+ "claude_md": True,
493
+ "skill_refs": "windows",
494
+ },
495
+ "kimi": {
496
+ # Reuses claude's split bundle (shares skill.md).
497
+ "skill_file": "skill.md",
498
+ "skill_dst": Path(".kimi") / "skills" / "graphify" / "SKILL.md",
499
+ "claude_md": False,
500
+ "skill_refs": "claude",
501
+ },
502
+ "amp": {
503
+ # Amp searches .agents/skills (project) and ~/.config/agents/skills (user),
504
+ # not .amp/skills. The user-scope path is set in _platform_skill_destination.
505
+ "skill_file": "skill-amp.md",
506
+ "skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
507
+ "claude_md": False,
508
+ "skill_refs": "amp",
509
+ },
510
+ "agents": {
511
+ # The generic cross-framework Agent-Skills target. Global: ~/.agents/skills
512
+ # (the spec's user-global location, read by `npx skills` and compliant
513
+ # frameworks); project: ./.agents/skills. The CLI accepts `skills` as an
514
+ # alias (see _canonical_platform). Ships its own rendered bundle.
515
+ "skill_file": "skill-agents.md",
516
+ "skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
517
+ "claude_md": False,
518
+ "skill_refs": "agents",
519
+ },
520
+ "devin": {
521
+ # Monolith: devin ships the full SKILL.md inline, no references/ sidecar.
522
+ "skill_file": "skill-devin.md",
523
+ # User scope: ~/.config/devin/skills/graphify/SKILL.md
524
+ # Project scope: .devin/skills/graphify/SKILL.md (overridden in _platform_skill_destination)
525
+ "skill_dst": Path(".config") / "devin" / "skills" / "graphify" / "SKILL.md",
526
+ "claude_md": False,
527
+ },
528
+ }
529
+ # CLI-only platform aliases, resolved to a real _PLATFORM_CONFIG key before
530
+ # dispatch. `skills` is the friendly alias for the generic `agents` platform
531
+ # (the Agent-Skills ecosystem calls them "skills").
532
+ _PLATFORM_ALIASES: dict[str, str] = {"skills": "agents"}
533
+ def _canonical_platform(platform_name: str) -> str:
534
+ """Resolve a CLI platform alias to its real _PLATFORM_CONFIG key."""
535
+ return _PLATFORM_ALIASES.get(platform_name, platform_name)
536
+ def _replace_or_append_section(content: str, marker: str, new_section: str) -> str:
537
+ """Idempotently update or append a graphify-owned section in shared files.
538
+
539
+ If no line is exactly ``marker`` (the heading, at column 0), append
540
+ ``new_section`` to the end (with a blank-line separator if there's existing
541
+ content).
542
+
543
+ If a real ``marker`` heading exists, replace the existing section in place.
544
+ The section runs from that heading to the line before the next H2 heading
545
+ (``## `` at line start), or to EOF if no later H2 exists. This lets older
546
+ installs receive the updated copy without users having to uninstall and
547
+ reinstall (issue #580).
548
+
549
+ The heading is matched only when a line *is* exactly ``marker`` (after
550
+ stripping surrounding whitespace), never as a substring. Matching ``##
551
+ graphify`` inside a bullet or an inline reference used to anchor the replace
552
+ on that mention and delete every line from there to the next heading,
553
+ silently destroying hand-curated content (#1688). When several exact
554
+ headings exist, the last one is used, since graphify's section is appended.
555
+ """
556
+ lines = content.split("\n")
557
+ starts = [i for i, line in enumerate(lines) if line.strip() == marker]
558
+ if not starts:
559
+ if content.strip():
560
+ return content.rstrip() + "\n\n" + new_section.lstrip()
561
+ return new_section.lstrip()
562
+
563
+ start = starts[-1]
564
+ end = len(lines)
565
+ for j in range(start + 1, len(lines)):
566
+ if lines[j].startswith("## "):
567
+ end = j
568
+ break
569
+
570
+ head = "\n".join(lines[:start]).rstrip()
571
+ tail = "\n".join(lines[end:]).lstrip()
572
+ section = new_section.strip()
573
+
574
+ parts: list[str] = []
575
+ if head:
576
+ parts.append(head)
577
+ parts.append(section)
578
+ if tail:
579
+ parts.append(tail)
580
+ out = "\n\n".join(parts)
581
+ if not out.endswith("\n"):
582
+ out += "\n"
583
+ return out
584
+
585
+
586
+ def _remove_marker_section(content: str, marker: str, boundary_prefix: str = "## ") -> "str | None":
587
+ """Remove every section whose heading line is exactly ``marker``.
588
+
589
+ The heading is matched only when a line *is* exactly ``marker`` (after
590
+ stripping surrounding whitespace), never as a substring. The old uninstall
591
+ regex ``## graphify`` was unanchored, so it matched inside a user's
592
+ ``### graphify`` heading and deleted hand-written content (#2062) — the same
593
+ class of bug the install side hardened against in #1688. Each section runs to
594
+ the line before the next ``boundary_prefix`` heading (default the next H2) or
595
+ EOF, mirroring ``_replace_or_append_section``. All exact-heading sections are
596
+ removed (pre-#1688 installs could leave duplicates).
597
+
598
+ Returns None when no exact ``marker`` line exists — the caller must then leave
599
+ the file untouched. This doubles as the guard: a substring mention (a bullet,
600
+ an inline reference, a deeper ``###`` heading) never triggers a strip.
601
+ """
602
+ lines = content.split("\n")
603
+ removed = False
604
+ while True:
605
+ starts = [i for i, line in enumerate(lines) if line.strip() == marker]
606
+ if not starts:
607
+ break
608
+ start = starts[-1]
609
+ end = len(lines)
610
+ for j in range(start + 1, len(lines)):
611
+ if lines[j].startswith(boundary_prefix):
612
+ end = j
613
+ break
614
+ head = "\n".join(lines[:start]).rstrip()
615
+ tail = "\n".join(lines[end:]).lstrip()
616
+ merged = head + "\n\n" + tail if head and tail else (head or tail)
617
+ lines = merged.split("\n")
618
+ removed = True
619
+ if not removed:
620
+ return None
621
+ return "\n".join(lines).rstrip()
622
+
623
+
624
+ def _print_banner() -> None:
625
+ """Amber brain banner on graphify install. TTY-only, never raises."""
626
+ if not sys.stdout.isatty():
627
+ return
628
+ try:
629
+ if sys.platform == "win32":
630
+ import ctypes
631
+ ctypes.windll.kernel32.SetConsoleMode(
632
+ ctypes.windll.kernel32.GetStdHandle(-11), 7
633
+ )
634
+ A = "\033[38;5;214m"
635
+ D = "\033[38;5;130m"
636
+ R = "\033[0m"
637
+ print(f"""{A}
638
+ ╭──◉──╮ ╭──◉──╮
639
+ ╱ ◉ ◉ ╲ ╱ ◉ ◉ ╲
640
+ │ ◉─◉─◉ ◉ ◉─◉─◉ │
641
+ │ ◉ ◉ │ ◉ ◉ │
642
+ │ ◉─◉─◉ ◉ ◉─◉─◉ │
643
+ ╲ ◉ ◉ ╱ ╲ ◉ ◉ ╱
644
+ ╰──◉──╯ ╰──◉──╯
645
+ ◉
646
+
647
+ █▀▀ █▀█ ▄▀█ █▀█ █ █ █ █▀▀ █▄█
648
+ █▄█ █▀▄ █▀█ █▀▀ █▀█ █ █▀ █{D} {__version__}{R}
649
+ """)
650
+ except Exception:
651
+ pass
652
+ def install(platform: str = "claude", *, project: bool = False, project_dir: Path | None = None) -> None:
653
+ _print_banner()
654
+ platform = _canonical_platform(platform)
655
+ if platform == "gemini":
656
+ gemini_install(project_dir=project_dir, project=project)
657
+ return
658
+ if platform == "cursor":
659
+ _cursor_install(Path("."))
660
+ return
661
+ # On Windows, antigravity needs the PowerShell skill, not the bash one
662
+ if platform == "antigravity" and sys.platform == "win32":
663
+ platform = "antigravity-windows"
664
+ if platform not in _PLATFORM_CONFIG:
665
+ print(
666
+ f"error: unknown platform '{platform}'. Choose from: {', '.join(_PLATFORM_CONFIG)}, gemini, cursor",
667
+ file=sys.stderr,
668
+ )
669
+ sys.exit(1)
670
+
671
+ cfg = _PLATFORM_CONFIG[platform]
672
+ project_dir = project_dir or Path(".")
673
+ skill_dst = _copy_skill_file(platform, project=project, project_dir=project_dir)
674
+
675
+ if platform == "kilo":
676
+ # Kilo Code also supports a native /graphify command file.
677
+ command_src = Path(__file__).parent / "command-kilo.md"
678
+ if not command_src.exists():
679
+ print(
680
+ f"error: command-kilo.md not found in package - reinstall graphify",
681
+ file=sys.stderr,
682
+ )
683
+ sys.exit(1)
684
+ command_dst = Path.home() / ".config" / "kilo" / "command" / "graphify.md"
685
+ command_dst.parent.mkdir(parents=True, exist_ok=True)
686
+ shutil.copy(command_src, command_dst)
687
+ print(f" command installed -> {command_dst}")
688
+
689
+ if cfg["claude_md"]:
690
+ # Register in the matching Claude Code scope. Honor CLAUDE_CONFIG_DIR
691
+ # for the global (non-project) case, same as _platform_skill_destination
692
+ # does for the skill copy path (#527) -- this always-on registration
693
+ # path was missed by that fix (#2694).
694
+ if project:
695
+ claude_md = project_dir / ".claude" / "CLAUDE.md"
696
+ skill_ref = ".claude/skills/graphify/SKILL.md"
697
+ elif os.environ.get("CLAUDE_CONFIG_DIR"):
698
+ config_dir = Path(os.environ["CLAUDE_CONFIG_DIR"])
699
+ claude_md = config_dir / "CLAUDE.md"
700
+ skill_ref = str(config_dir / "skills" / "graphify" / "SKILL.md")
701
+ else:
702
+ claude_md = Path.home() / ".claude" / "CLAUDE.md"
703
+ skill_ref = "~/.claude/skills/graphify/SKILL.md"
704
+ _register_always_on_block(
705
+ claude_md, " CLAUDE.md -> ", _skill_registration(skill_ref)
706
+ )
707
+
708
+ if platform == "codebuddy":
709
+ # Register in ~/.codebuddy/CODEBUDDY.md (CodeBuddy only)
710
+ _register_always_on_block(
711
+ Path.home() / ".codebuddy" / "CODEBUDDY.md",
712
+ " CODEBUDDY.md -> ",
713
+ _skill_registration("~/.codebuddy/skills/graphify/SKILL.md"),
714
+ )
715
+
716
+ if platform == "opencode":
717
+ _install_opencode_plugin(project_dir if project else Path("."))
718
+
719
+ if project:
720
+ _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir)])
721
+
722
+ print()
723
+ print("Done. Open your AI coding assistant and type:")
724
+ print()
725
+ print(" /graphify .")
726
+ print()
727
+ print("Prefer a hosted version? Early access to the graphify platform is")
728
+ print("open free before the public v1 launch: https://app.graphify.com")
729
+ print()
730
+ def _print_install_usage() -> None:
731
+ platforms = ", ".join([*_PLATFORM_CONFIG, "gemini", "cursor"])
732
+ print("Usage: graphify install [--project] [--strict] [--platform P|P]")
733
+ print(f"Platforms: {platforms}")
734
+ print(" --strict block the first raw file read per session until one "
735
+ "`graphify query` runs (Claude Code project hook only; needs --project)")
736
+ _CLAUDE_MD_MARKER = "## graphify"
737
+ _CODEBUDDY_MD_MARKER = "## graphify"
738
+ _AGENTS_MD_MARKER = "## graphify"
739
+ _GEMINI_MD_MARKER = "## graphify"
740
+ def _gemini_hook(project: bool = False) -> dict:
741
+ """Gemini CLI BeforeTool hook, resolved to a shell-agnostic `graphify` call.
742
+
743
+ A project-scoped install emits the bare command, since .gemini/settings.json
744
+ is then committed and an installing machine's path is wrong there (#3129).
745
+ """
746
+ exe = _resolve_graphify_exe(project=project)
747
+ if " " in exe and not exe.startswith('"'):
748
+ exe = f'"{exe}"'
749
+ return {
750
+ "matcher": "read_file|list_directory",
751
+ "hooks": [{"type": "command", "command": f"{exe} hook-guard gemini"}],
752
+ }
753
+ def gemini_install(project_dir: Path | None = None, *, project: bool = False) -> None:
754
+ """Copy skill file, write GEMINI.md section, and install BeforeTool hook."""
755
+ project_dir = project_dir or Path(".")
756
+ skill_dst = _copy_skill_file("gemini", project=project, project_dir=project_dir)
757
+
758
+ target = project_dir / "GEMINI.md"
759
+
760
+ if target.exists():
761
+ content = target.read_text(encoding="utf-8")
762
+ new_content = _replace_or_append_section(
763
+ content, _GEMINI_MD_MARKER, _always_on("gemini-md")
764
+ )
765
+ else:
766
+ new_content = _always_on("gemini-md")
767
+
768
+ if target.exists() and new_content == target.read_text(encoding="utf-8"):
769
+ print(f"graphify already configured in {target.resolve()} (no change)")
770
+ else:
771
+ target.write_text(new_content, encoding="utf-8")
772
+ print(f"graphify section written to {target.resolve()}")
773
+
774
+ # Always re-install the Gemini hook so an older payload (e.g. pre-issue-#580
775
+ # wording) is replaced on upgrade.
776
+ _install_gemini_hook(project_dir, project=project)
777
+ if project:
778
+ _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / "GEMINI.md", project_dir / ".gemini"])
779
+ print()
780
+ print("Gemini CLI will now check the knowledge graph before answering")
781
+ print("codebase questions and rebuild it after code changes.")
782
+ def _refuse_to_modify(settings_path: Path) -> "NoReturn":
783
+ """Abort a hook install rather than clobber a config file we can't parse (#2167)."""
784
+ print(
785
+ f"[graphify] refusing to modify {settings_path}: not valid JSON "
786
+ "(fix or move it and re-run)",
787
+ file=sys.stderr,
788
+ )
789
+ sys.exit(1)
790
+ def _read_settings_for_merge(settings_path: Path) -> dict:
791
+ """Load an existing settings/hooks JSON file for a read-modify-write merge.
792
+
793
+ A missing file yields a fresh ``{}`` (first install). An existing file that
794
+ cannot be parsed as a JSON object aborts via ``_refuse_to_modify`` instead of
795
+ silently falling back to ``{}`` — the old fallback rewrote the whole file and
796
+ destroyed every setting the user had (#2167). Reads with ``utf-8-sig`` so a
797
+ UTF-8 BOM (the most likely parse-error trigger, same class as #2163) is
798
+ tolerated rather than fatal.
799
+ """
800
+ if not settings_path.exists():
801
+ return {}
802
+ try:
803
+ settings = json.loads(settings_path.read_text(encoding="utf-8-sig"))
804
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError):
805
+ settings = None
806
+ if not isinstance(settings, dict):
807
+ _refuse_to_modify(settings_path)
808
+ return settings
809
+ def _write_settings_with_backup(settings_path: Path, settings: dict) -> None:
810
+ """Serialize ``settings`` to ``settings_path``, backing up the previous file.
811
+
812
+ Skips the write entirely when the output is identical to what is on disk
813
+ (idempotent re-install: no backup churn, no mtime churn). Otherwise copies
814
+ the existing file to ``<name>.graphify-bak`` (single rolling backup) before
815
+ overwriting, so one bad merge can never destroy the user's config (#2167).
816
+ """
817
+ output = json.dumps(settings, indent=2)
818
+ if settings_path.exists():
819
+ if settings_path.read_text(encoding="utf-8") == output:
820
+ return
821
+ backup = settings_path.with_name(settings_path.name + ".graphify-bak")
822
+ shutil.copy2(settings_path, backup)
823
+ settings_path.write_text(output, encoding="utf-8")
824
+ def _install_gemini_hook(project_dir: Path, project: bool = False) -> None:
825
+ settings_path = project_dir / ".gemini" / "settings.json"
826
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
827
+ settings = _read_settings_for_merge(settings_path)
828
+ hooks = settings.setdefault("hooks", {})
829
+ if not isinstance(hooks, dict):
830
+ _refuse_to_modify(settings_path)
831
+ before_tool = hooks.setdefault("BeforeTool", [])
832
+ if not isinstance(before_tool, list):
833
+ _refuse_to_modify(settings_path)
834
+ hooks["BeforeTool"] = [
835
+ h for h in before_tool if "graphify" not in str(h)
836
+ ]
837
+ hooks["BeforeTool"].append(_gemini_hook(project=project))
838
+ _write_settings_with_backup(settings_path, settings)
839
+ print(" .gemini/settings.json -> BeforeTool hook registered")
840
+ def _uninstall_gemini_hook(project_dir: Path) -> None:
841
+ settings_path = project_dir / ".gemini" / "settings.json"
842
+ if not settings_path.exists():
843
+ return
844
+ try:
845
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
846
+ except json.JSONDecodeError:
847
+ return
848
+ before_tool = settings.get("hooks", {}).get("BeforeTool", [])
849
+ filtered = [h for h in before_tool if "graphify" not in str(h)]
850
+ if len(filtered) == len(before_tool):
851
+ return
852
+ settings["hooks"]["BeforeTool"] = filtered
853
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
854
+ print(" .gemini/settings.json -> BeforeTool hook removed")
855
+ def gemini_uninstall(project_dir: Path | None = None, *, project: bool = False, remove_user_skill: bool | None = None) -> None:
856
+ """Remove the graphify section from GEMINI.md, uninstall hook, and remove skill file.
857
+
858
+ Scope rules (#2215): a bare call removes the user-global skill; passing
859
+ ``project_dir`` (or ``project=True``) scopes skill removal to that project
860
+ and leaves the global tree untouched, unless ``remove_user_skill=True``
861
+ explicitly opts back into the global delete (as ``uninstall_all`` does).
862
+ """
863
+ explicit_dir = project_dir is not None
864
+ project_dir = project_dir or Path(".")
865
+ if remove_user_skill is None:
866
+ remove_user_skill = not project and not explicit_dir
867
+ if project or (explicit_dir and not remove_user_skill):
868
+ _remove_skill_file("gemini", project=True, project_dir=project_dir)
869
+ if remove_user_skill:
870
+ _remove_skill_file("gemini", project=False)
871
+
872
+ target = project_dir / "GEMINI.md"
873
+ if not target.exists():
874
+ print("No GEMINI.md found in current directory - nothing to do")
875
+ return
876
+ content = target.read_text(encoding="utf-8")
877
+ cleaned = _remove_marker_section(content, _GEMINI_MD_MARKER)
878
+ if cleaned is None:
879
+ print("graphify section not found in GEMINI.md - nothing to do")
880
+ return
881
+ if cleaned:
882
+ target.write_text(cleaned + "\n", encoding="utf-8")
883
+ print(f"graphify section removed from {target.resolve()}")
884
+ else:
885
+ target.unlink()
886
+ print(f"GEMINI.md was empty after removal - deleted {target.resolve()}")
887
+ _uninstall_gemini_hook(project_dir)
888
+ _VSCODE_INSTRUCTIONS_MARKER = "## graphify"
889
+ def vscode_install(project_dir: Path | None = None) -> None:
890
+ """Install graphify skill for VS Code Copilot Chat + write .github/copilot-instructions.md."""
891
+ skill_src = Path(__file__).parent / "skill-vscode.md"
892
+ refs_bundle = "vscode"
893
+ if not skill_src.exists():
894
+ skill_src = Path(__file__).parent / "skill-copilot.md"
895
+ refs_bundle = "copilot"
896
+ skill_dst = Path.home() / ".copilot" / "skills" / "graphify" / "SKILL.md"
897
+ skill_dst.parent.mkdir(parents=True, exist_ok=True)
898
+ tmp_dst = skill_dst.with_suffix(skill_dst.suffix + ".tmp")
899
+ try:
900
+ shutil.copy(skill_src, tmp_dst)
901
+ os.replace(tmp_dst, skill_dst)
902
+ except Exception:
903
+ try:
904
+ tmp_dst.unlink(missing_ok=True)
905
+ except OSError:
906
+ pass
907
+ raise
908
+ # Progressive-capable: install the packaged references/ sidecar when present.
909
+ refs_src = Path(__file__).parent / "skills" / refs_bundle / "references"
910
+ if refs_src.exists():
911
+ _install_skill_references(skill_dst, refs_src)
912
+ print(f" references -> {skill_dst.parent / 'references'}")
913
+ else:
914
+ orphan_refs = skill_dst.parent / "references"
915
+ if orphan_refs.exists():
916
+ shutil.rmtree(orphan_refs)
917
+ _write_version_stamp(skill_dst, __version__)
918
+ print(f" skill installed -> {skill_dst}")
919
+
920
+ instructions = (project_dir or Path(".")) / ".github" / "copilot-instructions.md"
921
+ instructions.parent.mkdir(parents=True, exist_ok=True)
922
+ if instructions.exists():
923
+ content = instructions.read_text(encoding="utf-8")
924
+ new_content = _replace_or_append_section(
925
+ content, _VSCODE_INSTRUCTIONS_MARKER, _always_on("vscode-instructions")
926
+ )
927
+ if new_content == content:
928
+ print(f" {instructions} -> already configured (no change)")
929
+ else:
930
+ instructions.write_text(new_content, encoding="utf-8")
931
+ print(f" {instructions} -> graphify section {'updated' if _VSCODE_INSTRUCTIONS_MARKER in content else 'added'}")
932
+ else:
933
+ instructions.write_text(_always_on("vscode-instructions"), encoding="utf-8")
934
+ print(f" {instructions} -> created")
935
+
936
+ print()
937
+ print(
938
+ "VS Code Copilot Chat configured. Type /graphify in the chat panel to build the graph."
939
+ )
940
+ print("Note: for GitHub Copilot CLI (terminal), use: graphify copilot install")
941
+ def vscode_uninstall(project_dir: Path | None = None) -> None:
942
+ """Remove graphify VS Code Copilot Chat skill and .github/copilot-instructions.md section."""
943
+ skill_dst = Path.home() / ".copilot" / "skills" / "graphify" / "SKILL.md"
944
+ if skill_dst.exists():
945
+ skill_dst.unlink()
946
+ print(f" skill removed -> {skill_dst}")
947
+ version_file = skill_dst.parent / ".graphify_version"
948
+ if version_file.exists():
949
+ version_file.unlink()
950
+ refs_dir = skill_dst.parent / "references"
951
+ if refs_dir.exists():
952
+ shutil.rmtree(refs_dir)
953
+ for d in (
954
+ skill_dst.parent,
955
+ skill_dst.parent.parent,
956
+ skill_dst.parent.parent.parent,
957
+ ):
958
+ try:
959
+ d.rmdir()
960
+ except OSError:
961
+ break
962
+
963
+ instructions = (project_dir or Path(".")) / ".github" / "copilot-instructions.md"
964
+ if not instructions.exists():
965
+ return
966
+ content = instructions.read_text(encoding="utf-8")
967
+ cleaned = _remove_marker_section(content, _VSCODE_INSTRUCTIONS_MARKER)
968
+ if cleaned is None:
969
+ return
970
+ if cleaned:
971
+ instructions.write_text(cleaned + "\n", encoding="utf-8")
972
+ print(f" graphify section removed from {instructions}")
973
+ else:
974
+ instructions.unlink()
975
+ print(f" {instructions} -> deleted (was empty after removal)")
976
+ _ANTIGRAVITY_RULES_PATH = Path(".agents") / "rules" / "graphify.md"
977
+ _ANTIGRAVITY_WORKFLOW_PATH = Path(".agents") / "workflows" / "graphify.md"
978
+ # Names no SKILL.md location on purpose: this constant is shared by the global and
979
+ # project-scoped installs, which put the skill in different places, so any hardcoded
980
+ # path dangles for the other scope. Antigravity resolves the skill by frontmatter name.
981
+ _ANTIGRAVITY_WORKFLOW = """\
982
+ ---
983
+ name: graphify
984
+ description: Turn any folder of files into a navigable knowledge graph
985
+ ---
986
+
987
+ # Workflow: graphify
988
+
989
+ Follow the graphify skill to run the full pipeline.
990
+
991
+ If no path argument is given, use `.` (current directory).
992
+ """
993
+ def _kiro_install(project_dir: Path) -> None:
994
+ """Write graphify skill + steering file for Kiro IDE/CLI."""
995
+ project_dir = project_dir or Path(".")
996
+
997
+ # Skill file + references/ sidecar + .graphify_version stamp via the shared
998
+ # progressive-disclosure helper. Previously this used a bare write_text that
999
+ # bypassed _copy_skill_file, so the references/ dir and version stamp were
1000
+ # never written even though kiro declares skill_refs: "kiro" (#1142).
1001
+ _copy_skill_file("kiro", project=True, project_dir=project_dir)
1002
+
1003
+ # Steering file → .kiro/steering/graphify.md (always-on)
1004
+ steering_dir = project_dir / ".kiro" / "steering"
1005
+ steering_dir.mkdir(parents=True, exist_ok=True)
1006
+ steering_dst = steering_dir / "graphify.md"
1007
+ if steering_dst.exists() and steering_dst.read_text(encoding="utf-8") == _always_on("kiro-steering"):
1008
+ print(f" .kiro/steering/graphify.md -> already configured (no change)")
1009
+ else:
1010
+ # File is wholly graphify-owned. Overwrite on upgrade so older
1011
+ # report-first wording does not silently linger (issue #580).
1012
+ action = "updated" if steering_dst.exists() else "written"
1013
+ steering_dst.write_text(_always_on("kiro-steering"), encoding="utf-8")
1014
+ print(f" .kiro/steering/graphify.md -> always-on steering {action}")
1015
+
1016
+ print()
1017
+ print("Kiro will now read the knowledge graph before every conversation.")
1018
+ print("Use /graphify to build or update the graph.")
1019
+ def _kiro_uninstall(project_dir: Path) -> None:
1020
+ """Remove graphify skill + steering file for Kiro."""
1021
+ project_dir = project_dir or Path(".")
1022
+ removed = []
1023
+
1024
+ # Skill + .graphify_version + references/ sidecar + empty-dir walk.
1025
+ skill_dst = _platform_skill_destination("kiro", project=True, project_dir=project_dir)
1026
+ if _remove_skill_file("kiro", project=True, project_dir=project_dir):
1027
+ removed.append(str(skill_dst.relative_to(project_dir)))
1028
+
1029
+ steering_dst = project_dir / ".kiro" / "steering" / "graphify.md"
1030
+ if steering_dst.exists():
1031
+ steering_dst.unlink()
1032
+ removed.append(str(steering_dst.relative_to(project_dir)))
1033
+
1034
+ print("Removed: " + (", ".join(removed) if removed else "nothing to remove"))
1035
+ def _antigravity_finalize(skill_dst: Path, project_dir: Path) -> None:
1036
+ """Write Antigravity's always-on layer next to an installed skill.
1037
+
1038
+ Injects the native tool-discovery YAML frontmatter into *skill_dst*, then
1039
+ writes ``.agents/rules/graphify.md`` and ``.agents/workflows/graphify.md``
1040
+ under *project_dir*. Shared by the global ``antigravity install`` and the
1041
+ project-scoped ``install --project --platform antigravity`` paths, so both lay
1042
+ down the rules/workflows that the uninstall path already expects to remove.
1043
+ """
1044
+ # Inject YAML frontmatter for native Antigravity tool discovery.
1045
+ if skill_dst.exists():
1046
+ content = skill_dst.read_text(encoding="utf-8")
1047
+ if not content.startswith("---\n"):
1048
+ frontmatter = "---\nname: graphify-manager\ndescription: Rebuild the code graph or perform manual CLI queries when MCP server is offline.\n---\n\n"
1049
+ skill_dst.write_text(frontmatter + content, encoding="utf-8")
1050
+
1051
+ # .agents/rules/graphify.md
1052
+ rules_path = project_dir / _ANTIGRAVITY_RULES_PATH
1053
+ rules_path.parent.mkdir(parents=True, exist_ok=True)
1054
+ if rules_path.exists():
1055
+ existing = rules_path.read_text(encoding="utf-8")
1056
+ if _always_on("antigravity-rules").strip() != existing.strip():
1057
+ rules_path.write_text(_always_on("antigravity-rules"), encoding="utf-8")
1058
+ print(f"graphify rule updated at {rules_path.resolve()}")
1059
+ else:
1060
+ print(f"graphify rule already configured at {rules_path.resolve()} (no change)")
1061
+ else:
1062
+ rules_path.write_text(_always_on("antigravity-rules"), encoding="utf-8")
1063
+ print(f"graphify rule written to {rules_path.resolve()}")
1064
+
1065
+ # .agents/workflows/graphify.md
1066
+ wf_path = project_dir / _ANTIGRAVITY_WORKFLOW_PATH
1067
+ wf_path.parent.mkdir(parents=True, exist_ok=True)
1068
+ if wf_path.exists():
1069
+ existing = wf_path.read_text(encoding="utf-8")
1070
+ if _ANTIGRAVITY_WORKFLOW.strip() != existing.strip():
1071
+ wf_path.write_text(_ANTIGRAVITY_WORKFLOW, encoding="utf-8")
1072
+ print(f"graphify workflow updated at {wf_path.resolve()}")
1073
+ else:
1074
+ print(f"graphify workflow already configured at {wf_path.resolve()} (no change)")
1075
+ else:
1076
+ wf_path.write_text(_ANTIGRAVITY_WORKFLOW, encoding="utf-8")
1077
+ print(f"graphify workflow written to {wf_path.resolve()}")
1078
+ def _antigravity_install(project_dir: Path) -> None:
1079
+ """Install graphify for Google Antigravity (global skill + .agents/rules + .agents/workflows)."""
1080
+ # Copy the skill to ~/.gemini/config/skills/graphify/SKILL.md (global), then
1081
+ # lay down the always-on rules/workflows under the project dir.
1082
+ install(platform="antigravity")
1083
+ _antigravity_finalize(_platform_skill_destination("antigravity"), project_dir)
1084
+
1085
+ print()
1086
+ print("Antigravity will now check the knowledge graph before answering")
1087
+ print("codebase questions. Run /graphify first to build the graph.")
1088
+ print()
1089
+ print(
1090
+ "To enable full MCP architecture navigation, add this to ~/.gemini/antigravity/mcp_config.json:"
1091
+ )
1092
+ print(' "graphify": {')
1093
+ print(' "command": "uv",')
1094
+ print(
1095
+ ' "args": ["run", "--with", "graphifyy", "--with", "mcp", "-m", "graphify.serve", "${workspace.path}/graphify-out/graph.json"]'
1096
+ )
1097
+ print(" }")
1098
+ def _antigravity_uninstall(project_dir: Path, *, project: bool = False) -> None:
1099
+ """Remove graphify Antigravity rules, workflow, and skill files."""
1100
+ # Remove rules file
1101
+ rules_path = project_dir / _ANTIGRAVITY_RULES_PATH
1102
+ if rules_path.exists():
1103
+ rules_path.unlink()
1104
+ print(f"graphify rule removed from {rules_path.resolve()}")
1105
+ else:
1106
+ print("No graphify Antigravity rule found - nothing to do")
1107
+
1108
+ # Remove workflow file
1109
+ wf_path = project_dir / _ANTIGRAVITY_WORKFLOW_PATH
1110
+ if wf_path.exists():
1111
+ wf_path.unlink()
1112
+ print(f"graphify workflow removed from {wf_path.resolve()}")
1113
+
1114
+ # Remove skill file
1115
+ skill_dst = _platform_skill_destination("antigravity", project=project, project_dir=project_dir)
1116
+ if skill_dst.exists():
1117
+ skill_dst.unlink()
1118
+ print(f"graphify skill removed from {skill_dst}")
1119
+ version_file = skill_dst.parent / ".graphify_version"
1120
+ if version_file.exists():
1121
+ version_file.unlink()
1122
+ refs_dir = skill_dst.parent / "references"
1123
+ if refs_dir.exists():
1124
+ shutil.rmtree(refs_dir)
1125
+ for d in (
1126
+ skill_dst.parent,
1127
+ skill_dst.parent.parent,
1128
+ skill_dst.parent.parent.parent,
1129
+ ):
1130
+ try:
1131
+ d.rmdir()
1132
+ except OSError:
1133
+ break
1134
+ _CURSOR_RULE_PATH = Path(".cursor") / "rules" / "graphify.mdc"
1135
+ _CURSOR_RULE = """\
1136
+ ---
1137
+ description: graphify knowledge graph context
1138
+ alwaysApply: true
1139
+ ---
1140
+
1141
+ This project has a graphify knowledge graph at graphify-out/.
1142
+
1143
+ **MANDATORY: Before using Read, Grep, Glob, or Bash to explore the codebase, you MUST run graphify first:**
1144
+ - `graphify query "<question>"` — scoped subgraph for any codebase or architecture question
1145
+ - `graphify path "<A>" "<B>"` — dependency path between two symbols
1146
+ - `graphify explain "<concept>"` — all nodes related to a concept
1147
+
1148
+ This applies to YOU and to every subagent you spawn. Include this rule explicitly in every subagent prompt that involves code exploration. Do not skip graphify because files are "already known" or because you are executing a plan — the graph surfaces cross-file dependencies and INFERRED edges that grep and Read cannot find.
1149
+
1150
+ Only use Read/Grep/Glob directly when:
1151
+ 1. graphify has already oriented you and you need to modify or debug specific lines
1152
+ 2. `graphify-out/graph.json` does not exist yet
1153
+
1154
+ - If `graphify-out/wiki/index.md` exists, navigate it instead of reading raw files
1155
+ - Read `graphify-out/GRAPH_REPORT.md` only for broad architecture review when query/path/explain do not surface enough context
1156
+ - After modifying code files, run `graphify update .` to keep the graph current (AST-only, no API cost)
1157
+ """
1158
+ def _cursor_install(project_dir: Path) -> None:
1159
+ """Write .cursor/rules/graphify.mdc with alwaysApply: true."""
1160
+ rule_path = (project_dir or Path(".")) / _CURSOR_RULE_PATH
1161
+ rule_path.parent.mkdir(parents=True, exist_ok=True)
1162
+ if rule_path.exists() and rule_path.read_text(encoding="utf-8") == _CURSOR_RULE:
1163
+ print(f"graphify rule at {rule_path} already configured (no change)")
1164
+ return
1165
+ # File is wholly graphify-owned. Overwrite on upgrade so older
1166
+ # report-first wording does not silently linger (issue #580).
1167
+ action = "updated" if rule_path.exists() else "written"
1168
+ rule_path.write_text(_CURSOR_RULE, encoding="utf-8")
1169
+ print(f"graphify rule {action} at {rule_path.resolve()}")
1170
+ print()
1171
+ print("Cursor will now always include the knowledge graph context.")
1172
+ print("Run /graphify . first to build the graph if you haven't already.")
1173
+ def _cursor_uninstall(project_dir: Path) -> None:
1174
+ """Remove .cursor/rules/graphify.mdc."""
1175
+ rule_path = (project_dir or Path(".")) / _CURSOR_RULE_PATH
1176
+ if not rule_path.exists():
1177
+ print("No graphify Cursor rule found - nothing to do")
1178
+ return
1179
+ rule_path.unlink()
1180
+ print(f"graphify Cursor rule removed from {rule_path.resolve()}")
1181
+ # Devin CLI — .windsurf/rules/graphify.md (always-on context)
1182
+ # Devin reads .windsurf/rules/*.md files the same way Windsurf IDE does.
1183
+ _DEVIN_RULES_PATH = Path(".windsurf") / "rules" / "graphify.md"
1184
+ _DEVIN_RULES = """\
1185
+ ## graphify
1186
+
1187
+ This project has a graphify knowledge graph at graphify-out/.
1188
+
1189
+ Rules:
1190
+ - 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.
1191
+ - If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
1192
+ - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context
1193
+ - After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)
1194
+ """
1195
+ def _devin_rules_install(project_dir: Path) -> None:
1196
+ """Write .windsurf/rules/graphify.md for always-on Devin context."""
1197
+ rules_path = (project_dir or Path(".")) / _DEVIN_RULES_PATH
1198
+ rules_path.parent.mkdir(parents=True, exist_ok=True)
1199
+ if rules_path.exists() and rules_path.read_text(encoding="utf-8") == _DEVIN_RULES:
1200
+ print(f" {rules_path} -> already configured (no change)")
1201
+ return
1202
+ action = "updated" if rules_path.exists() else "written"
1203
+ rules_path.write_text(_DEVIN_RULES, encoding="utf-8")
1204
+ print(f" rules {action} -> {rules_path}")
1205
+ def _devin_rules_uninstall(project_dir: Path) -> None:
1206
+ """Remove .windsurf/rules/graphify.md."""
1207
+ rules_path = (project_dir or Path(".")) / _DEVIN_RULES_PATH
1208
+ if not rules_path.exists():
1209
+ return
1210
+ rules_path.unlink()
1211
+ print(f" rules removed -> {rules_path}")
1212
+ _KILO_PLUGIN_JS = """\
1213
+ // graphify Kilo plugin
1214
+ // Injects a knowledge graph reminder before bash tool calls when the graph exists.
1215
+ import { existsSync } from "fs";
1216
+ import { join } from "path";
1217
+
1218
+ export const GraphifyPlugin = async ({ directory }) => {
1219
+ let reminded = false;
1220
+
1221
+ return {
1222
+ "tool.execute.before": async (input, output) => {
1223
+ if (reminded) return;
1224
+ if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
1225
+
1226
+ if (input.tool === "bash") {
1227
+ // Separate with ';' not '&&' — Windows PowerShell 5.1 rejects '&&' as a
1228
+ // statement separator ("not a valid statement separator"), which broke
1229
+ // the first bash command in every OpenCode session on Windows (#1646).
1230
+ // ';' works in PowerShell 5.1, Bash, and POSIX shells alike.
1231
+ output.args.command =
1232
+ 'echo "[graphify] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for god nodes and architecture context before searching files." ; ' +
1233
+ output.args.command;
1234
+ reminded = true;
1235
+ }
1236
+ },
1237
+ };
1238
+ };
1239
+ """
1240
+ _KILO_PLUGIN_PATH = Path(".kilo") / "plugins" / "graphify.js"
1241
+ _KILO_CONFIG_JSON_PATH = Path(".kilo") / "kilo.json"
1242
+ _KILO_CONFIG_JSONC_PATH = Path(".kilo") / "kilo.jsonc"
1243
+ def _strip_json_comments(raw: str) -> str:
1244
+ """Remove JSONC-style comments while leaving string content intact."""
1245
+ result: list[str] = []
1246
+ in_string = False
1247
+ escaped = False
1248
+ line_comment = False
1249
+ block_comment = False
1250
+ i = 0
1251
+
1252
+ while i < len(raw):
1253
+ ch = raw[i]
1254
+ nxt = raw[i + 1] if i + 1 < len(raw) else ""
1255
+
1256
+ if line_comment:
1257
+ if ch == "\n":
1258
+ line_comment = False
1259
+ result.append(ch)
1260
+ i += 1
1261
+ continue
1262
+
1263
+ if block_comment:
1264
+ if ch == "*" and nxt == "/":
1265
+ block_comment = False
1266
+ i += 2
1267
+ else:
1268
+ i += 1
1269
+ continue
1270
+
1271
+ if in_string:
1272
+ result.append(ch)
1273
+ if escaped:
1274
+ escaped = False
1275
+ elif ch == "\\":
1276
+ escaped = True
1277
+ elif ch == '"':
1278
+ in_string = False
1279
+ i += 1
1280
+ continue
1281
+
1282
+ if ch == "/" and nxt == "/":
1283
+ line_comment = True
1284
+ i += 2
1285
+ continue
1286
+ if ch == "/" and nxt == "*":
1287
+ block_comment = True
1288
+ i += 2
1289
+ continue
1290
+
1291
+ result.append(ch)
1292
+ if ch == '"':
1293
+ in_string = True
1294
+ i += 1
1295
+
1296
+ return re.sub(r",(\s*[}\]])", r"\1", "".join(result))
1297
+ def _load_json_like(config_file: Path) -> dict:
1298
+ if not config_file.exists():
1299
+ return {}
1300
+ try:
1301
+ raw = config_file.read_text(encoding="utf-8")
1302
+ if config_file.suffix == ".jsonc":
1303
+ raw = _strip_json_comments(raw)
1304
+ loaded = json.loads(raw)
1305
+ except (OSError, json.JSONDecodeError):
1306
+ return {}
1307
+ return loaded if isinstance(loaded, dict) else {}
1308
+ def _kilo_config_path(project_dir: Path) -> Path:
1309
+ kilo_dir = (project_dir or Path(".")) / ".kilo"
1310
+ json_path = kilo_dir / _KILO_CONFIG_JSON_PATH.name
1311
+ if json_path.exists():
1312
+ return json_path
1313
+ jsonc_path = kilo_dir / _KILO_CONFIG_JSONC_PATH.name
1314
+ if jsonc_path.exists():
1315
+ return jsonc_path
1316
+ return json_path
1317
+ def _kilo_config_write_path(project_dir: Path) -> Path:
1318
+ """Write automated Kilo edits to kilo.json so existing JSONC stays untouched."""
1319
+ kilo_dir = (project_dir or Path(".")) / ".kilo"
1320
+ return kilo_dir / _KILO_CONFIG_JSON_PATH.name
1321
+ def _install_kilo_plugin(project_dir: Path) -> None:
1322
+ """Write graphify.js plugin and register it without rewriting user JSONC."""
1323
+ plugin_file = project_dir / _KILO_PLUGIN_PATH
1324
+ plugin_file.parent.mkdir(parents=True, exist_ok=True)
1325
+ plugin_file.write_text(_KILO_PLUGIN_JS, encoding="utf-8")
1326
+ print(f" {_KILO_PLUGIN_PATH} -> tool.execute.before hook written")
1327
+
1328
+ config_file = _kilo_config_path(project_dir)
1329
+ write_config_file = _kilo_config_write_path(project_dir)
1330
+ write_config_file.parent.mkdir(parents=True, exist_ok=True)
1331
+ config = _load_json_like(config_file)
1332
+ plugins = config.get("plugin")
1333
+ if not isinstance(plugins, list):
1334
+ plugins = []
1335
+ config["plugin"] = plugins
1336
+ entry = plugin_file.resolve().as_uri()
1337
+ if entry not in plugins:
1338
+ plugins.append(entry)
1339
+ write_config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
1340
+ print(f" {write_config_file.relative_to(project_dir)} -> plugin registered")
1341
+ else:
1342
+ print(
1343
+ f" {config_file.relative_to(project_dir)} -> plugin already registered (no change)"
1344
+ )
1345
+ def _uninstall_kilo_plugin(project_dir: Path) -> None:
1346
+ """Remove graphify.js plugin and deregister it without rewriting user JSONC."""
1347
+ plugin_file = project_dir / _KILO_PLUGIN_PATH
1348
+ if plugin_file.exists():
1349
+ plugin_file.unlink()
1350
+ print(f" {_KILO_PLUGIN_PATH} -> removed")
1351
+
1352
+ config_file = _kilo_config_path(project_dir)
1353
+ if not config_file.exists():
1354
+ return
1355
+ write_config_file = _kilo_config_write_path(project_dir)
1356
+ config = _load_json_like(config_file)
1357
+ plugins = config.get("plugin", [])
1358
+ if not isinstance(plugins, list):
1359
+ plugins = []
1360
+ entry = plugin_file.resolve().as_uri()
1361
+ if entry in plugins:
1362
+ config["plugin"] = [plugin for plugin in plugins if plugin != entry]
1363
+ if not config["plugin"]:
1364
+ config.pop("plugin")
1365
+ write_config_file.parent.mkdir(parents=True, exist_ok=True)
1366
+ write_config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
1367
+ print(
1368
+ f" {write_config_file.relative_to(project_dir)} -> plugin deregistered"
1369
+ )
1370
+ # OpenCode tool.execute.before plugin — fires before every tool call.
1371
+ # Injects a graph reminder into bash command output when graph.json exists.
1372
+ _OPENCODE_PLUGIN_JS = """\
1373
+ // graphify OpenCode plugin
1374
+ // Injects a knowledge graph reminder before bash tool calls when the graph exists.
1375
+ //
1376
+ // IMPORTANT: keep the reminder string free of backticks and $(...) constructs.
1377
+ // The hook prepends `echo "<reminder>" && <cmd>` to the user's bash command;
1378
+ // backticks inside the double-quoted echo trigger bash command substitution,
1379
+ // which both corrupts tool output and silently executes the very graphify
1380
+ // command we are only suggesting. Plain words render fine in opencode's TUI.
1381
+ import { existsSync } from "fs";
1382
+ import { join } from "path";
1383
+
1384
+ export const GraphifyPlugin = async ({ directory }) => {
1385
+ let reminded = false;
1386
+
1387
+ return {
1388
+ "tool.execute.before": async (input, output) => {
1389
+ if (reminded) return;
1390
+ if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
1391
+
1392
+ if (input.tool === "bash") {
1393
+ // ';' not '&&' — Windows PowerShell 5.1 rejects '&&' as a statement
1394
+ // separator, breaking the first bash command of the session (#1646).
1395
+ output.args.command =
1396
+ 'echo "[graphify] knowledge graph at graphify-out/. For focused questions, run graphify query with your question (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context." ; ' +
1397
+ output.args.command;
1398
+ reminded = true;
1399
+ }
1400
+ },
1401
+ };
1402
+ };
1403
+ """
1404
+ _OPENCODE_PLUGIN_PATH = Path(".opencode") / "plugins" / "graphify.js"
1405
+ _OPENCODE_CONFIG_PATH = Path(".opencode") / "opencode.json"
1406
+ def _install_opencode_plugin(project_dir: Path) -> None:
1407
+ """Write graphify.js plugin and register it in opencode.json."""
1408
+ plugin_file = project_dir / _OPENCODE_PLUGIN_PATH
1409
+ plugin_file.parent.mkdir(parents=True, exist_ok=True)
1410
+ plugin_file.write_text(_OPENCODE_PLUGIN_JS, encoding="utf-8")
1411
+ print(f" {_OPENCODE_PLUGIN_PATH} -> tool.execute.before hook written")
1412
+
1413
+ config_file = project_dir / _OPENCODE_CONFIG_PATH
1414
+ if config_file.exists():
1415
+ try:
1416
+ config = json.loads(config_file.read_text(encoding="utf-8"))
1417
+ except json.JSONDecodeError:
1418
+ config = {}
1419
+ else:
1420
+ config = {}
1421
+
1422
+ plugins = config.setdefault("plugin", [])
1423
+ entry = _OPENCODE_PLUGIN_PATH.as_posix()
1424
+ if entry not in plugins:
1425
+ plugins.append(entry)
1426
+ config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
1427
+ print(f" {_OPENCODE_CONFIG_PATH} -> plugin registered")
1428
+ else:
1429
+ print(f" {_OPENCODE_CONFIG_PATH} -> plugin already registered (no change)")
1430
+ def _uninstall_opencode_plugin(project_dir: Path) -> None:
1431
+ """Remove graphify.js plugin and deregister from opencode.json."""
1432
+ plugin_file = project_dir / _OPENCODE_PLUGIN_PATH
1433
+ if plugin_file.exists():
1434
+ plugin_file.unlink()
1435
+ print(f" {_OPENCODE_PLUGIN_PATH} -> removed")
1436
+
1437
+ config_file = project_dir / _OPENCODE_CONFIG_PATH
1438
+ if not config_file.exists():
1439
+ return
1440
+ try:
1441
+ config = json.loads(config_file.read_text(encoding="utf-8"))
1442
+ except json.JSONDecodeError:
1443
+ return
1444
+ plugins = config.get("plugin", [])
1445
+ entry = _OPENCODE_PLUGIN_PATH.as_posix()
1446
+ if entry in plugins:
1447
+ plugins.remove(entry)
1448
+ if not plugins:
1449
+ config.pop("plugin")
1450
+ config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
1451
+ print(f" {_OPENCODE_CONFIG_PATH} -> plugin deregistered")
1452
+ def _resolve_graphify_exe(project: bool = False) -> str:
1453
+ """Return the absolute path to the graphify executable, with forward slashes.
1454
+
1455
+ With *project* set, return the bare ``graphify`` command instead. A
1456
+ project-scoped install writes hook config the installer then tells the user
1457
+ to commit, so an absolute path resolved from the installing machine is wrong
1458
+ for every other clone: it names a directory that does not exist there, and
1459
+ the drive letter and ``.EXE`` casing do not even survive between two Windows
1460
+ checkouts. A committed hook refers to ``graphify`` the way it would refer to
1461
+ ``git`` or ``node``, and PATH resolves it per machine (#3129).
1462
+
1463
+ Falls back to bare 'graphify' if resolution fails. Using an absolute path
1464
+ ensures the hook works in environments where the venv Scripts/ directory is
1465
+ not on PATH (e.g. VS Code Codex extension on Windows).
1466
+
1467
+ The path is normalized to forward slashes so it survives every shell that
1468
+ runs the hook command. On Windows, Claude Code runs command-type hooks
1469
+ through Git Bash by default, where an unquoted backslash is an escape
1470
+ character: a raw ``C:\\Users\\me\\graphify.EXE`` collapses to
1471
+ ``C:Usersmegraphify.EXE: command not found`` and the guard silently fails.
1472
+ Forward slashes are accepted by Git Bash, cmd.exe, and PowerShell alike, and
1473
+ ``.replace`` is a no-op on POSIX where paths already use forward slashes.
1474
+ """
1475
+ import shutil
1476
+ if project:
1477
+ return "graphify"
1478
+ found = shutil.which("graphify")
1479
+ if not found:
1480
+ # Derive from sys.executable: same Scripts/ (Windows) or bin/ (Unix) dir
1481
+ scripts_dir = Path(sys.executable).parent
1482
+ for name in ("graphify.exe", "graphify"):
1483
+ candidate = scripts_dir / name
1484
+ if candidate.exists():
1485
+ found = str(candidate)
1486
+ break
1487
+ return (found or "graphify").replace("\\", "/")
1488
+ def _install_codex_hook(project_dir: Path, project: bool = False) -> None:
1489
+ """Add graphify PreToolUse hook to .codex/hooks.json.
1490
+
1491
+ A project-scoped install emits the bare command, since .codex/hooks.json is
1492
+ then committed and an installing machine's path is wrong there (#3129).
1493
+ """
1494
+ hooks_path = project_dir / ".codex" / "hooks.json"
1495
+ hooks_path.parent.mkdir(parents=True, exist_ok=True)
1496
+
1497
+ existing = _read_settings_for_merge(hooks_path)
1498
+
1499
+ graphify_exe = _resolve_graphify_exe(project=project)
1500
+ hook_entry = {
1501
+ "hooks": {
1502
+ "PreToolUse": [
1503
+ {
1504
+ "matcher": "Bash",
1505
+ "hooks": [{"type": "command", "command": f"{graphify_exe} hook-check"}],
1506
+ }
1507
+ ]
1508
+ }
1509
+ }
1510
+
1511
+ hooks = existing.setdefault("hooks", {})
1512
+ if not isinstance(hooks, dict):
1513
+ _refuse_to_modify(hooks_path)
1514
+ pre_tool = hooks.setdefault("PreToolUse", [])
1515
+ if not isinstance(pre_tool, list):
1516
+ _refuse_to_modify(hooks_path)
1517
+ hooks["PreToolUse"] = [h for h in pre_tool if "graphify" not in str(h)]
1518
+ hooks["PreToolUse"].extend(hook_entry["hooks"]["PreToolUse"])
1519
+ _write_settings_with_backup(hooks_path, existing)
1520
+ print(
1521
+ f" .codex/hooks.json -> PreToolUse hook registered ({graphify_exe} hook-check"
1522
+ " - intentional no-op; Codex Desktop rejects additionalContext on PreToolUse,"
1523
+ " so graph guidance comes from AGENTS.md)"
1524
+ )
1525
+
1526
+
1527
+ def _uninstall_codex_hook(project_dir: Path) -> None:
1528
+ """Remove graphify PreToolUse hook from .codex/hooks.json."""
1529
+ hooks_path = project_dir / ".codex" / "hooks.json"
1530
+ if not hooks_path.exists():
1531
+ return
1532
+ try:
1533
+ existing = json.loads(hooks_path.read_text(encoding="utf-8"))
1534
+ except json.JSONDecodeError:
1535
+ return
1536
+ pre_tool = existing.get("hooks", {}).get("PreToolUse", [])
1537
+ filtered = [h for h in pre_tool if "graphify" not in str(h)]
1538
+ existing["hooks"]["PreToolUse"] = filtered
1539
+ hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
1540
+ print(f" .codex/hooks.json -> PreToolUse hook removed")
1541
+ def _agents_install(project_dir: Path, platform: str, project: bool = False) -> None:
1542
+ """Write the graphify section to the local AGENTS.md for always-on platforms."""
1543
+ target = (project_dir or Path(".")) / "AGENTS.md"
1544
+
1545
+ if target.exists():
1546
+ content = target.read_text(encoding="utf-8")
1547
+ new_content = _replace_or_append_section(
1548
+ content, _AGENTS_MD_MARKER, _always_on("agents-md")
1549
+ )
1550
+ else:
1551
+ new_content = _always_on("agents-md")
1552
+
1553
+ if target.exists() and new_content == target.read_text(encoding="utf-8"):
1554
+ print(f"graphify already configured in {target.resolve()} (no change)")
1555
+ else:
1556
+ target.write_text(new_content, encoding="utf-8")
1557
+ print(f"graphify section written to {target.resolve()}")
1558
+
1559
+ if platform == "codex":
1560
+ _install_codex_hook(project_dir or Path("."), project=project)
1561
+ elif platform == "opencode":
1562
+ _install_opencode_plugin(project_dir or Path("."))
1563
+ elif platform == "kilo":
1564
+ _install_kilo_plugin(project_dir or Path("."))
1565
+
1566
+ print()
1567
+ print(
1568
+ f"{platform.capitalize()} will now check the knowledge graph before answering"
1569
+ )
1570
+ print("codebase questions and rebuild it after code changes.")
1571
+ if platform not in ("codex", "opencode", "kilo"):
1572
+ print()
1573
+ print("Note: unlike Claude Code, there is no PreToolUse hook equivalent for")
1574
+ print(
1575
+ f"{platform.capitalize()} — the AGENTS.md rules are the always-on mechanism."
1576
+ )
1577
+ def _amp_legacy_cleanup() -> None:
1578
+ """Best-effort removal of the pre-fix ~/.amp/skills/graphify install dir.
1579
+
1580
+ Older graphify versions wrote the Amp skill to ~/.amp/skills, which Amp does
1581
+ not search. Clean it up on install so a stale, never-loaded copy does not
1582
+ linger. Failures are ignored (the new path is what matters).
1583
+ """
1584
+ legacy = Path.home() / ".amp" / "skills" / "graphify"
1585
+ if legacy.exists():
1586
+ shutil.rmtree(legacy, ignore_errors=True)
1587
+ if not legacy.exists():
1588
+ print(f" legacy removed -> {legacy}")
1589
+ def _amp_install(project_dir: Path | None = None) -> None:
1590
+ """User-scope Amp install: skill into ~/.config/agents/skills + AGENTS.md."""
1591
+ _amp_legacy_cleanup()
1592
+ _copy_skill_file("amp")
1593
+ _agents_install(project_dir or Path("."), "amp")
1594
+ def _amp_uninstall(project_dir: Path | None = None) -> None:
1595
+ """User-scope Amp uninstall: remove the skill and the AGENTS.md section."""
1596
+ removed = _remove_skill_file("amp")
1597
+ if removed:
1598
+ print("skill removed")
1599
+ _agents_uninstall(project_dir or Path("."), platform="amp")
1600
+ def _agents_platform_install(project_dir: Path | None = None) -> None:
1601
+ """`graphify agents install`: skill into ~/.agents/skills + AGENTS.md.
1602
+
1603
+ The amp-twin of the generic Agent-Skills target. Mirrors _amp_install but
1604
+ lands the skill at the spec's user-global ~/.agents/skills (set in
1605
+ _platform_skill_destination). Wiring AGENTS.md keeps it honest with the
1606
+ rendered hooks reference, which points at `graphify agents install`. The bare
1607
+ `graphify install --platform agents` path stays skill-only (via install()),
1608
+ exactly as amp's `--platform amp` does.
1609
+ """
1610
+ _copy_skill_file("agents")
1611
+ _agents_install(project_dir or Path("."), "agents")
1612
+ def _agents_platform_uninstall(project_dir: Path | None = None) -> None:
1613
+ """`graphify agents uninstall`: remove the skill and the AGENTS.md section."""
1614
+ removed = _remove_skill_file("agents")
1615
+ if removed:
1616
+ print("skill removed")
1617
+ _agents_uninstall(project_dir or Path("."), platform="agents")
1618
+ def _project_install(platform_name: str, project_dir: Path | None = None, strict: bool = False) -> None:
1619
+ """Install platform skill/config files in the current project."""
1620
+ project_dir = project_dir or Path(".")
1621
+ platform_name = _canonical_platform(platform_name)
1622
+ if platform_name in ("claude", "windows"):
1623
+ install(platform=platform_name, project=True, project_dir=project_dir)
1624
+ claude_install(project_dir, strict=strict, project=True)
1625
+ _print_project_git_add_hint([project_dir / ".claude", project_dir / "CLAUDE.md"])
1626
+ elif platform_name == "gemini":
1627
+ gemini_install(project_dir, project=True)
1628
+ elif platform_name == "cursor":
1629
+ _cursor_install(project_dir)
1630
+ _print_project_git_add_hint([project_dir / ".cursor"])
1631
+ elif platform_name == "kiro":
1632
+ _kiro_install(project_dir)
1633
+ _print_project_git_add_hint([project_dir / ".kiro"])
1634
+ elif platform_name in ("aider", "amp", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
1635
+ skill_dst = _copy_skill_file(platform_name, project=True, project_dir=project_dir)
1636
+ _agents_install(project_dir, platform_name, project=True)
1637
+ hint_paths = [_project_scope_root(skill_dst, project_dir), project_dir / "AGENTS.md"]
1638
+ if platform_name == "opencode":
1639
+ hint_paths.append(project_dir / ".opencode")
1640
+ elif platform_name == "codex":
1641
+ hint_paths.append(project_dir / ".codex")
1642
+ _print_project_git_add_hint(hint_paths)
1643
+ elif platform_name == "devin":
1644
+ skill_dst = _copy_skill_file("devin", project=True, project_dir=project_dir)
1645
+ _devin_rules_install(project_dir)
1646
+ _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / ".windsurf"])
1647
+ elif platform_name == "antigravity":
1648
+ # Project-scoped: skill in .agents/skills/ PLUS the .agents/rules +
1649
+ # .agents/workflows always-on layer (previously this path wrote only the
1650
+ # skill, leaving the rules/workflows the uninstall path removes unset).
1651
+ skill_dst = _copy_skill_file("antigravity", project=True, project_dir=project_dir)
1652
+ _antigravity_finalize(skill_dst, project_dir)
1653
+ _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / ".agents"])
1654
+ elif platform_name in ("copilot", "pi", "kimi", "agents"):
1655
+ # Skill-only project install: drop SKILL.md (+ references) at the scope
1656
+ # root. `agents` -> ./.agents/skills/graphify/SKILL.md.
1657
+ skill_dst = _copy_skill_file(platform_name, project=True, project_dir=project_dir)
1658
+ _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir)])
1659
+ else:
1660
+ install(platform=platform_name, project=True, project_dir=project_dir)
1661
+ def _project_uninstall(platform_name: str, project_dir: Path | None = None) -> None:
1662
+ """Remove project-scoped platform skill/config files only."""
1663
+ project_dir = project_dir or Path(".")
1664
+ platform_name = _canonical_platform(platform_name)
1665
+ if platform_name in ("claude", "windows"):
1666
+ _remove_skill_file(platform_name, project=True, project_dir=project_dir)
1667
+ _remove_claude_skill_registration(project_dir)
1668
+ claude_uninstall(project_dir, project=True)
1669
+ elif platform_name == "gemini":
1670
+ gemini_uninstall(project_dir, project=True)
1671
+ elif platform_name == "cursor":
1672
+ _cursor_uninstall(project_dir)
1673
+ elif platform_name == "kiro":
1674
+ _kiro_uninstall(project_dir)
1675
+ elif platform_name in ("aider", "amp", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
1676
+ _remove_skill_file(platform_name, project=True, project_dir=project_dir)
1677
+ _agents_uninstall(project_dir, platform=platform_name)
1678
+ if platform_name == "codex":
1679
+ _uninstall_codex_hook(project_dir)
1680
+ elif platform_name == "antigravity":
1681
+ _antigravity_uninstall(project_dir, project=True)
1682
+ elif platform_name == "devin":
1683
+ removed = _remove_skill_file("devin", project=True, project_dir=project_dir)
1684
+ _devin_rules_uninstall(project_dir)
1685
+ if not removed:
1686
+ print("nothing to remove")
1687
+ elif platform_name in ("copilot", "pi", "kimi", "agents"):
1688
+ removed = _remove_skill_file(platform_name, project=True, project_dir=project_dir)
1689
+ if not removed:
1690
+ print("nothing to remove")
1691
+ elif platform_name == "codebuddy":
1692
+ # project=True keeps `uninstall --project` project-scoped; previously
1693
+ # this deleted the user-global codebuddy skill (#2215).
1694
+ codebuddy_uninstall(project_dir, project=True)
1695
+ else:
1696
+ _remove_skill_file(platform_name, project=True, project_dir=project_dir)
1697
+ def _project_uninstall_all(project_dir: Path | None = None) -> None:
1698
+ """Remove project-scoped install files without touching user-scope installs."""
1699
+ project_dir = project_dir or Path(".")
1700
+ print("Uninstalling project-scoped graphify files...\n")
1701
+ for platform_name in _PLATFORM_CONFIG:
1702
+ _project_uninstall(platform_name, project_dir)
1703
+ for platform_name in ("gemini", "cursor"):
1704
+ _project_uninstall(platform_name, project_dir)
1705
+ print("\nDone.")
1706
+ def _agents_uninstall(project_dir: Path, platform: str = "") -> None:
1707
+ """Remove the graphify section from the local AGENTS.md."""
1708
+ target = (project_dir or Path(".")) / "AGENTS.md"
1709
+
1710
+ if not target.exists():
1711
+ print("No AGENTS.md found in current directory - nothing to do")
1712
+ if platform == "opencode":
1713
+ _uninstall_opencode_plugin(project_dir or Path("."))
1714
+ elif platform == "kilo":
1715
+ _uninstall_kilo_plugin(project_dir or Path("."))
1716
+ return
1717
+
1718
+ content = target.read_text(encoding="utf-8")
1719
+ cleaned = _remove_marker_section(content, _AGENTS_MD_MARKER)
1720
+ if cleaned is None:
1721
+ print("graphify section not found in AGENTS.md - nothing to do")
1722
+ if platform == "opencode":
1723
+ _uninstall_opencode_plugin(project_dir or Path("."))
1724
+ elif platform == "kilo":
1725
+ _uninstall_kilo_plugin(project_dir or Path("."))
1726
+ return
1727
+
1728
+ if cleaned:
1729
+ target.write_text(cleaned + "\n", encoding="utf-8")
1730
+ print(f"graphify section removed from {target.resolve()}")
1731
+ else:
1732
+ target.unlink()
1733
+ print(f"AGENTS.md was empty after removal - deleted {target.resolve()}")
1734
+
1735
+ if platform == "opencode":
1736
+ _uninstall_opencode_plugin(project_dir or Path("."))
1737
+ elif platform == "kilo":
1738
+ _uninstall_kilo_plugin(project_dir or Path("."))
1739
+ def _kilo_uninstall_global() -> list[str]:
1740
+ removed = []
1741
+ command_dst = Path.home() / ".config" / "kilo" / "command" / "graphify.md"
1742
+ if command_dst.exists():
1743
+ command_dst.unlink()
1744
+ removed.append(f"command removed: {command_dst}")
1745
+ try:
1746
+ command_dst.parent.rmdir()
1747
+ except OSError:
1748
+ pass
1749
+
1750
+ skill_dst = Path.home() / _PLATFORM_CONFIG["kilo"]["skill_dst"]
1751
+ if skill_dst.exists():
1752
+ skill_dst.unlink()
1753
+ removed.append(f"skill removed: {skill_dst}")
1754
+ version_file = skill_dst.parent / ".graphify_version"
1755
+ if version_file.exists():
1756
+ version_file.unlink()
1757
+ for d in (
1758
+ skill_dst.parent,
1759
+ skill_dst.parent.parent,
1760
+ skill_dst.parent.parent.parent,
1761
+ ):
1762
+ try:
1763
+ d.rmdir()
1764
+ except OSError:
1765
+ break
1766
+
1767
+ return removed
1768
+ def _kilo_install(project_dir: Path) -> None:
1769
+ """Install native Kilo skill + command globally and always-on project wiring locally."""
1770
+ install(platform="kilo")
1771
+ _agents_install(project_dir or Path("."), "kilo")
1772
+ def _kilo_uninstall(project_dir: Path) -> None:
1773
+ """Remove Kilo always-on project wiring and global skill/command files."""
1774
+ _agents_uninstall(project_dir or Path("."), platform="kilo")
1775
+ removed = _kilo_uninstall_global()
1776
+ print("; ".join(removed) if removed else "nothing to remove")
1777
+ def claude_install(project_dir: Path | None = None, strict: bool = False, project: bool = False) -> None:
1778
+ """Write the graphify section to the local CLAUDE.md."""
1779
+ target = (project_dir or Path(".")) / "CLAUDE.md"
1780
+
1781
+ if target.exists():
1782
+ content = target.read_text(encoding="utf-8")
1783
+ new_content = _replace_or_append_section(
1784
+ content, _CLAUDE_MD_MARKER, _always_on("claude-md")
1785
+ )
1786
+ else:
1787
+ new_content = _always_on("claude-md")
1788
+
1789
+ if target.exists() and new_content == target.read_text(encoding="utf-8"):
1790
+ print(f"graphify already configured in {target.resolve()} (no change)")
1791
+ else:
1792
+ target.write_text(new_content, encoding="utf-8")
1793
+ print(f"graphify section written to {target.resolve()}")
1794
+
1795
+ # Always re-install the Claude Code PreToolUse hook so an old hook
1796
+ # payload (e.g. pre-issue-#580 wording) is replaced on upgrade.
1797
+ _install_claude_hook(project_dir or Path("."), strict=strict, project=project)
1798
+
1799
+ print()
1800
+ print("Claude Code will now check the knowledge graph before answering")
1801
+ print("codebase questions and rebuild it after code changes.")
1802
+ if strict:
1803
+ print("Strict mode: the first raw file read per session is blocked until")
1804
+ print("one `graphify query` runs (toggle with GRAPHIFY_HOOK_STRICT=0).")
1805
+ def _install_claude_hook(project_dir: Path, strict: bool = False, project: bool = False) -> None:
1806
+ """Add graphify PreToolUse hook to .claude/settings.json.
1807
+
1808
+ A project-scoped install emits the bare command, since .claude/settings.json
1809
+ is then committed and an installing machine's path is wrong there (#3129).
1810
+ """
1811
+ settings_path = project_dir / ".claude" / "settings.json"
1812
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
1813
+
1814
+ settings = _read_settings_for_merge(settings_path)
1815
+
1816
+ hooks = settings.setdefault("hooks", {})
1817
+ if not isinstance(hooks, dict):
1818
+ _refuse_to_modify(settings_path)
1819
+ pre_tool = hooks.setdefault("PreToolUse", [])
1820
+ if not isinstance(pre_tool, list):
1821
+ _refuse_to_modify(settings_path)
1822
+
1823
+ hooks["PreToolUse"] = [h for h in pre_tool if not (isinstance(h, dict) and h.get("matcher") in ("Glob|Grep", "Bash", "Bash|Grep", "Read|Glob") and "graphify" in str(h))]
1824
+ hooks["PreToolUse"].extend(_claude_pretooluse_hooks(strict=strict, project=project))
1825
+ _write_settings_with_backup(settings_path, settings)
1826
+ _mode = " (strict)" if strict else ""
1827
+ print(f" .claude/settings.json -> PreToolUse hooks registered (Bash|Grep search + Read/Glob){_mode}")
1828
+ def _uninstall_claude_hook(project_dir: Path) -> None:
1829
+ """Remove the graphify PreToolUse hook from .claude/settings.json and its
1830
+ local-only sibling .claude/settings.local.json.
1831
+
1832
+ A user may relocate the hook into settings.local.json so it is not committed
1833
+ to a shared repo, so uninstall has to clean whichever file holds it (#1731).
1834
+ """
1835
+ claude_dir = project_dir / ".claude"
1836
+ for name in ("settings.json", "settings.local.json"):
1837
+ _strip_graphify_hook(claude_dir / name)
1838
+ def _strip_graphify_hook(settings_path: Path) -> None:
1839
+ """Drop graphify PreToolUse hooks from a single Claude settings file, if present."""
1840
+ if not settings_path.exists():
1841
+ return
1842
+ try:
1843
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
1844
+ except json.JSONDecodeError:
1845
+ return
1846
+ pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
1847
+ filtered = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Bash|Grep", "Read|Glob") and "graphify" in str(h))]
1848
+ if len(filtered) == len(pre_tool):
1849
+ return
1850
+ settings["hooks"]["PreToolUse"] = filtered
1851
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
1852
+ print(f" .claude/{settings_path.name} -> PreToolUse hook removed")
1853
+ def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None:
1854
+ """Remove graphify from every platform detected in the current project."""
1855
+ pd = project_dir or Path(".")
1856
+ print("Uninstalling graphify from all detected platforms...\n")
1857
+
1858
+ # Skill-file / config-section uninstallers. remove_user_skill=True keeps the
1859
+ # historical `graphify uninstall` behavior: global skill delete plus md/hook
1860
+ # cleanup at the project dir (#2215).
1861
+ claude_uninstall(pd, remove_user_skill=True)
1862
+ codebuddy_uninstall(pd, remove_user_skill=True)
1863
+ gemini_uninstall(pd, remove_user_skill=True)
1864
+ vscode_uninstall(pd)
1865
+ _cursor_uninstall(pd)
1866
+ _kiro_uninstall(pd)
1867
+ _antigravity_uninstall(pd)
1868
+ # AGENTS.md covers: codex, aider, opencode, claw, droid, trae, trae-cn, hermes, copilot
1869
+ _agents_uninstall(pd)
1870
+ # Amp also drops a user-scope skill at ~/.config/agents/skills, which the
1871
+ # AGENTS.md cleanup above does not touch.
1872
+ _remove_skill_file("amp")
1873
+ # The generic agents platform's user-scope skill lives at ~/.agents/skills,
1874
+ # which neither the AGENTS.md cleanup nor amp's removal reaches.
1875
+ _remove_skill_file("agents")
1876
+ _uninstall_opencode_plugin(pd)
1877
+ _uninstall_codex_hook(pd)
1878
+
1879
+ # Git hook
1880
+ try:
1881
+ from graphify.hooks import uninstall as hook_uninstall
1882
+ result = hook_uninstall(pd)
1883
+ if result:
1884
+ print(result)
1885
+ except Exception:
1886
+ pass
1887
+
1888
+ if purge:
1889
+ import shutil as _shutil
1890
+ out = pd / _GRAPHIFY_OUT
1891
+ if out.exists():
1892
+ _shutil.rmtree(out)
1893
+ print(f"\n {_GRAPHIFY_OUT}/ -> deleted (--purge)")
1894
+ else:
1895
+ print(f"\n {_GRAPHIFY_OUT}/ -> not found (nothing to purge)")
1896
+
1897
+ print("\nDone. Run 'pip uninstall graphifyy' to remove the package itself.")
1898
+ def claude_uninstall(project_dir: Path | None = None, *, project: bool = False, remove_user_skill: bool | None = None) -> None:
1899
+ """Remove the graphify skill tree (SKILL.md + references/) and the graphify
1900
+ section from CLAUDE.md and its local-only variants, plus the PreToolUse hook.
1901
+
1902
+ Mirrors gemini_uninstall: the bare `graphify uninstall` and `graphify claude
1903
+ uninstall` must remove the installed skill, not just strip CLAUDE.md, or the
1904
+ progressive-disclosure tree (SKILL.md + references/) is orphaned (#1121).
1905
+
1906
+ A user may relocate the section/hook into the local-only files Claude Code
1907
+ supports so they are not committed to a shared repo, so uninstall also cleans
1908
+ CLAUDE.local.md, .claude/CLAUDE.local.md and .claude/settings.local.json (#1731).
1909
+
1910
+ Scope rules (#2215): a bare call removes the user-global skill; passing
1911
+ ``project_dir`` (or ``project=True``) scopes skill removal to that project
1912
+ and leaves the global tree untouched, unless ``remove_user_skill=True``
1913
+ explicitly opts back into the global delete (as ``uninstall_all`` does).
1914
+ """
1915
+ explicit_dir = project_dir is not None
1916
+ project_dir = project_dir or Path(".")
1917
+ if remove_user_skill is None:
1918
+ remove_user_skill = not project and not explicit_dir
1919
+ if project or (explicit_dir and not remove_user_skill):
1920
+ _remove_skill_file("claude", project=True, project_dir=project_dir)
1921
+ if remove_user_skill:
1922
+ _remove_skill_file("claude", project=False)
1923
+
1924
+ md_targets = [
1925
+ project_dir / "CLAUDE.md",
1926
+ project_dir / "CLAUDE.local.md",
1927
+ project_dir / ".claude" / "CLAUDE.local.md",
1928
+ ]
1929
+ existing = [t for t in md_targets if t.exists()]
1930
+ removed_any = False
1931
+ for target in existing:
1932
+ # Not short-circuited: every present file must be cleaned, not just the first.
1933
+ if _strip_graphify_md_section(target):
1934
+ removed_any = True
1935
+
1936
+ if not existing:
1937
+ print("No CLAUDE.md found in current directory - nothing to do")
1938
+ elif not removed_any:
1939
+ print("graphify section not found in CLAUDE.md - nothing to do")
1940
+
1941
+ _uninstall_claude_hook(project_dir)
1942
+ def _strip_graphify_md_section(target: Path) -> bool:
1943
+ """Strip the ## graphify section from one CLAUDE.md-style file.
1944
+
1945
+ Returns True if a section was removed. Deletes the file if nothing else
1946
+ remains after removal.
1947
+ """
1948
+ try:
1949
+ content = target.read_text(encoding="utf-8")
1950
+ except (OSError, UnicodeDecodeError):
1951
+ # An unreadable/undecodable CLAUDE.md-style file (e.g. non-UTF-8, or a
1952
+ # directory of that name) must not abort uninstall - nothing to strip.
1953
+ return False
1954
+ # Remove graphify's ## graphify section (heading matched exactly, never as a
1955
+ # substring of a user's ### graphify) from the marker to the next H2 or EOF.
1956
+ cleaned = _remove_marker_section(content, _CLAUDE_MD_MARKER)
1957
+ if cleaned is None:
1958
+ return False
1959
+ if cleaned:
1960
+ target.write_text(cleaned + "\n", encoding="utf-8")
1961
+ print(f"graphify section removed from {target.resolve()}")
1962
+ else:
1963
+ target.unlink()
1964
+ print(f"{target.name} was empty after removal - deleted {target.resolve()}")
1965
+ return True
1966
+ def codebuddy_install(project_dir: Path | None = None) -> None:
1967
+ """Install the graphify skill and CODEBUDDY.md section for CodeBuddy."""
1968
+ _copy_skill_file("codebuddy", project=bool(project_dir), project_dir=project_dir)
1969
+ target = (project_dir or Path(".")) / "CODEBUDDY.md"
1970
+
1971
+ if target.exists():
1972
+ content = target.read_text(encoding="utf-8")
1973
+ new_content = _replace_or_append_section(
1974
+ content, _CODEBUDDY_MD_MARKER, _always_on("claude-md")
1975
+ )
1976
+ else:
1977
+ new_content = _always_on("claude-md")
1978
+
1979
+ if target.exists() and new_content == target.read_text(encoding="utf-8"):
1980
+ print(f"graphify already configured in {target.resolve()} (no change)")
1981
+ else:
1982
+ target.write_text(new_content, encoding="utf-8")
1983
+ print(f"graphify section written to {target.resolve()}")
1984
+
1985
+ # Also write CodeBuddy PreToolUse hook to .codebuddy/settings.json
1986
+ _install_codebuddy_hook(project_dir or Path("."))
1987
+
1988
+ print()
1989
+ print("CodeBuddy will now check the knowledge graph before answering")
1990
+ print("codebase questions and rebuild it after code changes.")
1991
+ def _install_codebuddy_hook(project_dir: Path) -> None:
1992
+ """Add graphify PreToolUse hook to .codebuddy/settings.json."""
1993
+ settings_path = project_dir / ".codebuddy" / "settings.json"
1994
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
1995
+
1996
+ settings = _read_settings_for_merge(settings_path)
1997
+
1998
+ hooks = settings.setdefault("hooks", {})
1999
+ if not isinstance(hooks, dict):
2000
+ _refuse_to_modify(settings_path)
2001
+ pre_tool = hooks.setdefault("PreToolUse", [])
2002
+ if not isinstance(pre_tool, list):
2003
+ _refuse_to_modify(settings_path)
2004
+
2005
+ hooks["PreToolUse"] = [h for h in pre_tool if not (isinstance(h, dict) and h.get("matcher") in ("Glob|Grep", "Bash", "Bash|Grep", "Read|Glob") and "graphify" in str(h))]
2006
+ hooks["PreToolUse"].extend(_claude_pretooluse_hooks())
2007
+ _write_settings_with_backup(settings_path, settings)
2008
+ print(f" .codebuddy/settings.json -> PreToolUse hooks registered")
2009
+ def _uninstall_codebuddy_hook(project_dir: Path) -> None:
2010
+ """Remove graphify PreToolUse hook from .codebuddy/settings.json."""
2011
+ settings_path = project_dir / ".codebuddy" / "settings.json"
2012
+ if not settings_path.exists():
2013
+ return
2014
+ try:
2015
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
2016
+ except json.JSONDecodeError:
2017
+ return
2018
+ pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
2019
+ filtered = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Bash|Grep", "Read|Glob") and "graphify" in str(h))]
2020
+ if len(filtered) == len(pre_tool):
2021
+ return
2022
+ settings["hooks"]["PreToolUse"] = filtered
2023
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
2024
+ print(f" .codebuddy/settings.json -> PreToolUse hook removed")
2025
+ def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = False, remove_user_skill: bool | None = None) -> None:
2026
+ """Remove the graphify skill tree (SKILL.md + references/) and the CODEBUDDY.md section.
2027
+
2028
+ Scope rules (#2215): a bare call removes the user-global skill; passing
2029
+ ``project_dir`` (or ``project=True``) scopes skill removal to that project
2030
+ and leaves the global tree untouched, unless ``remove_user_skill=True``
2031
+ explicitly opts back into the global delete (as ``uninstall_all`` does).
2032
+ """
2033
+ explicit_dir = project_dir is not None
2034
+ project_dir = project_dir or Path(".")
2035
+ if remove_user_skill is None:
2036
+ remove_user_skill = not project and not explicit_dir
2037
+ if project or (explicit_dir and not remove_user_skill):
2038
+ _remove_skill_file("codebuddy", project=True, project_dir=project_dir)
2039
+ if remove_user_skill:
2040
+ _remove_skill_file("codebuddy", project=False)
2041
+ target = project_dir / "CODEBUDDY.md"
2042
+
2043
+ if not target.exists():
2044
+ print("No CODEBUDDY.md found in current directory - nothing to do")
2045
+ return
2046
+
2047
+ content = target.read_text(encoding="utf-8")
2048
+ cleaned = _remove_marker_section(content, _CODEBUDDY_MD_MARKER)
2049
+ if cleaned is None:
2050
+ print("graphify section not found in CODEBUDDY.md - nothing to do")
2051
+ return
2052
+
2053
+ if cleaned:
2054
+ target.write_text(cleaned + "\n", encoding="utf-8")
2055
+ print(f"graphify section removed from {target.resolve()}")
2056
+ else:
2057
+ target.unlink()
2058
+ print(f"CODEBUDDY.md was empty after removal - deleted {target.resolve()}")
2059
+
2060
+ _uninstall_codebuddy_hook(project_dir or Path("."))
2061
+
2062
+
2063
+ _CLI_INSTALL_COMMANDS = frozenset({
2064
+ "agents",
2065
+ "aider",
2066
+ "amp",
2067
+ "antigravity",
2068
+ "claude",
2069
+ "claw",
2070
+ "codebuddy",
2071
+ "codex",
2072
+ "copilot",
2073
+ "cursor",
2074
+ "devin",
2075
+ "droid",
2076
+ "gemini",
2077
+ "hermes",
2078
+ "install",
2079
+ "kilo",
2080
+ "kiro",
2081
+ "opencode",
2082
+ "pi",
2083
+ "skills",
2084
+ "trae",
2085
+ "trae-cn",
2086
+ "uninstall",
2087
+ "vscode",
2088
+ })
2089
+
2090
+
2091
+ def dispatch_install_cli(cmd: str) -> bool:
2092
+ """Handle `graphify <install-command>` dispatch (install/uninstall + every
2093
+ per-platform install target). Returns True if cmd was an install command and
2094
+ was handled, False otherwise so the caller can continue its own dispatch.
2095
+ Moved verbatim from __main__.main().
2096
+ """
2097
+ if cmd not in _CLI_INSTALL_COMMANDS:
2098
+ return False
2099
+ if cmd == "install":
2100
+ # Default to windows platform on Windows, claude elsewhere
2101
+ default_platform = "windows" if platform.system() == "Windows" else "claude"
2102
+ selected_platform: str | None = None
2103
+ project_scope = False
2104
+ strict = False
2105
+ args = sys.argv[2:]
2106
+ i = 0
2107
+ while i < len(args):
2108
+ arg = args[i]
2109
+ if arg in ("-h", "--help"):
2110
+ _print_install_usage()
2111
+ return True
2112
+ if arg == "--project":
2113
+ project_scope = True
2114
+ i += 1
2115
+ elif arg == "--strict":
2116
+ strict = True
2117
+ i += 1
2118
+ elif arg.startswith("--platform="):
2119
+ candidate = arg.split("=", 1)[1]
2120
+ if selected_platform and selected_platform != candidate:
2121
+ print("error: specify install platform only once", file=sys.stderr)
2122
+ sys.exit(1)
2123
+ selected_platform = candidate
2124
+ i += 1
2125
+ elif arg == "--platform":
2126
+ if i + 1 >= len(args):
2127
+ print("error: --platform requires a value", file=sys.stderr)
2128
+ sys.exit(1)
2129
+ candidate = args[i + 1]
2130
+ if selected_platform and selected_platform != candidate:
2131
+ print("error: specify install platform only once", file=sys.stderr)
2132
+ sys.exit(1)
2133
+ selected_platform = candidate
2134
+ i += 2
2135
+ elif arg.startswith("-"):
2136
+ print(f"error: unknown install option '{arg}'", file=sys.stderr)
2137
+ sys.exit(1)
2138
+ else:
2139
+ if selected_platform and selected_platform != arg:
2140
+ print("error: specify install platform only once", file=sys.stderr)
2141
+ sys.exit(1)
2142
+ selected_platform = arg
2143
+ i += 1
2144
+ chosen_platform = selected_platform or default_platform
2145
+ if project_scope:
2146
+ _project_install(chosen_platform, Path("."), strict=strict)
2147
+ else:
2148
+ if strict:
2149
+ print(
2150
+ "note: --strict applies to the project PreToolUse hook; run "
2151
+ "`graphify install --project --strict` or `graphify claude install --strict`.",
2152
+ file=sys.stderr,
2153
+ )
2154
+ install(platform=chosen_platform)
2155
+ elif cmd == "uninstall":
2156
+ args = sys.argv[2:]
2157
+ purge = "--purge" in args
2158
+ project_scope = "--project" in args
2159
+ selected_platform = None
2160
+ i = 0
2161
+ while i < len(args):
2162
+ arg = args[i]
2163
+ if arg in ("--purge", "--project"):
2164
+ i += 1
2165
+ elif arg.startswith("--platform="):
2166
+ selected_platform = arg.split("=", 1)[1]
2167
+ i += 1
2168
+ elif arg == "--platform":
2169
+ if i + 1 >= len(args):
2170
+ print("error: --platform requires a value", file=sys.stderr)
2171
+ sys.exit(1)
2172
+ selected_platform = args[i + 1]
2173
+ i += 2
2174
+ elif arg.startswith("-"):
2175
+ print(f"error: unknown uninstall option '{arg}'", file=sys.stderr)
2176
+ sys.exit(1)
2177
+ else:
2178
+ selected_platform = arg
2179
+ i += 1
2180
+ if project_scope:
2181
+ if selected_platform:
2182
+ _project_uninstall(selected_platform, Path("."))
2183
+ else:
2184
+ _project_uninstall_all(Path("."))
2185
+ else:
2186
+ uninstall_all(purge=purge)
2187
+ elif cmd == "claude":
2188
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2189
+ if subcmd == "install":
2190
+ _strict = "--strict" in sys.argv[3:]
2191
+ if "--project" in sys.argv[3:]:
2192
+ _project_install("claude", Path("."), strict=_strict)
2193
+ else:
2194
+ claude_install(strict=_strict)
2195
+ elif subcmd == "uninstall":
2196
+ if "--project" in sys.argv[3:]:
2197
+ _project_uninstall("claude", Path("."))
2198
+ else:
2199
+ claude_uninstall()
2200
+ else:
2201
+ print("Usage: graphify claude [install|uninstall]", file=sys.stderr)
2202
+ sys.exit(1)
2203
+ elif cmd == "codebuddy":
2204
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2205
+ if subcmd == "install":
2206
+ codebuddy_install()
2207
+ elif subcmd == "uninstall":
2208
+ codebuddy_uninstall()
2209
+ else:
2210
+ print("Usage: graphify codebuddy [install|uninstall]", file=sys.stderr)
2211
+ sys.exit(1)
2212
+ elif cmd == "gemini":
2213
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2214
+ if subcmd == "install":
2215
+ gemini_install(project=("--project" in sys.argv[3:]))
2216
+ elif subcmd == "uninstall":
2217
+ gemini_uninstall(project=("--project" in sys.argv[3:]))
2218
+ else:
2219
+ print("Usage: graphify gemini [install|uninstall]", file=sys.stderr)
2220
+ sys.exit(1)
2221
+ elif cmd == "cursor":
2222
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2223
+ if subcmd == "install":
2224
+ _cursor_install(Path("."))
2225
+ elif subcmd == "uninstall":
2226
+ _cursor_uninstall(Path("."))
2227
+ else:
2228
+ print("Usage: graphify cursor [install|uninstall]", file=sys.stderr)
2229
+ sys.exit(1)
2230
+ elif cmd == "vscode":
2231
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2232
+ if subcmd == "install":
2233
+ vscode_install()
2234
+ elif subcmd == "uninstall":
2235
+ vscode_uninstall()
2236
+ else:
2237
+ print("Usage: graphify vscode [install|uninstall]", file=sys.stderr)
2238
+ sys.exit(1)
2239
+ elif cmd == "copilot":
2240
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2241
+ if subcmd == "install":
2242
+ if "--project" in sys.argv[3:]:
2243
+ _project_install("copilot", Path("."))
2244
+ else:
2245
+ install(platform="copilot")
2246
+ elif subcmd == "uninstall":
2247
+ if "--project" in sys.argv[3:]:
2248
+ _project_uninstall("copilot", Path("."))
2249
+ else:
2250
+ removed = _remove_skill_file("copilot")
2251
+ print("skill removed" if removed else "nothing to remove")
2252
+ else:
2253
+ print("Usage: graphify copilot [install|uninstall]", file=sys.stderr)
2254
+ sys.exit(1)
2255
+ elif cmd == "kilo":
2256
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2257
+ if subcmd == "install":
2258
+ _kilo_install(Path("."))
2259
+ elif subcmd == "uninstall":
2260
+ _kilo_uninstall(Path("."))
2261
+ else:
2262
+ print("Usage: graphify kilo [install|uninstall]", file=sys.stderr)
2263
+ sys.exit(1)
2264
+ elif cmd == "kiro":
2265
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2266
+ if subcmd == "install":
2267
+ _kiro_install(Path("."))
2268
+ elif subcmd == "uninstall":
2269
+ _kiro_uninstall(Path("."))
2270
+ else:
2271
+ print("Usage: graphify kiro [install|uninstall]", file=sys.stderr)
2272
+ sys.exit(1)
2273
+ elif cmd == "devin":
2274
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2275
+ if subcmd == "install":
2276
+ if "--project" in sys.argv[3:]:
2277
+ _project_install("devin", Path("."))
2278
+ else:
2279
+ install(platform="devin")
2280
+ elif subcmd == "uninstall":
2281
+ if "--project" in sys.argv[3:]:
2282
+ _project_uninstall("devin", Path("."))
2283
+ else:
2284
+ removed = _remove_skill_file("devin")
2285
+ print("skill removed" if removed else "nothing to remove")
2286
+ else:
2287
+ print("Usage: graphify devin [install|uninstall]", file=sys.stderr)
2288
+ sys.exit(1)
2289
+ elif cmd == "pi":
2290
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2291
+ if subcmd == "install":
2292
+ if "--project" in sys.argv[3:]:
2293
+ _project_install("pi", Path("."))
2294
+ else:
2295
+ install("pi")
2296
+ elif subcmd == "uninstall":
2297
+ if "--project" in sys.argv[3:]:
2298
+ _project_uninstall("pi", Path("."))
2299
+ else:
2300
+ _remove_skill_file("pi")
2301
+ else:
2302
+ print("Usage: graphify pi [install|uninstall]", file=sys.stderr)
2303
+ sys.exit(1)
2304
+ elif cmd == "amp":
2305
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2306
+ if subcmd == "install":
2307
+ if "--project" in sys.argv[3:]:
2308
+ _project_install("amp", Path("."))
2309
+ else:
2310
+ _amp_install(Path("."))
2311
+ elif subcmd == "uninstall":
2312
+ if "--project" in sys.argv[3:]:
2313
+ _project_uninstall("amp", Path("."))
2314
+ else:
2315
+ _amp_uninstall(Path("."))
2316
+ else:
2317
+ print("Usage: graphify amp [install|uninstall]", file=sys.stderr)
2318
+ sys.exit(1)
2319
+ elif cmd in ("agents", "skills"):
2320
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2321
+ if subcmd == "install":
2322
+ if "--project" in sys.argv[3:]:
2323
+ _project_install("agents", Path("."))
2324
+ else:
2325
+ _agents_platform_install(Path("."))
2326
+ elif subcmd == "uninstall":
2327
+ if "--project" in sys.argv[3:]:
2328
+ _project_uninstall("agents", Path("."))
2329
+ else:
2330
+ _agents_platform_uninstall(Path("."))
2331
+ else:
2332
+ print(f"Usage: graphify {cmd} [install|uninstall]", file=sys.stderr)
2333
+ sys.exit(1)
2334
+ elif cmd in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
2335
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2336
+ if subcmd == "install":
2337
+ if "--project" in sys.argv[3:]:
2338
+ _project_install(cmd, Path("."))
2339
+ else:
2340
+ _agents_install(Path("."), cmd)
2341
+ elif subcmd == "uninstall":
2342
+ if "--project" in sys.argv[3:]:
2343
+ _project_uninstall(cmd, Path("."))
2344
+ else:
2345
+ _agents_uninstall(Path("."), platform=cmd)
2346
+ if cmd == "codex":
2347
+ _uninstall_codex_hook(Path("."))
2348
+ else:
2349
+ print(f"Usage: graphify {cmd} [install|uninstall]", file=sys.stderr)
2350
+ sys.exit(1)
2351
+ elif cmd == "antigravity":
2352
+ subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
2353
+ if subcmd == "install":
2354
+ if "--project" in sys.argv[3:]:
2355
+ _project_install("antigravity", Path("."))
2356
+ else:
2357
+ _antigravity_install(Path("."))
2358
+ elif subcmd == "uninstall":
2359
+ if "--project" in sys.argv[3:]:
2360
+ _project_uninstall("antigravity", Path("."))
2361
+ else:
2362
+ _antigravity_uninstall(Path("."))
2363
+ else:
2364
+ print("Usage: graphify antigravity [install|uninstall]", file=sys.stderr)
2365
+ sys.exit(1)
2366
+ return True