attocode-code-intel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (345) hide show
  1. attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
  2. attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
  3. attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
  4. attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
  5. attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. attocode_intel/GUIDELINES.md +432 -0
  7. attocode_intel/__init__.py +52 -0
  8. attocode_intel/_internal/__init__.py +1 -0
  9. attocode_intel/_internal/errors/__init__.py +126 -0
  10. attocode_intel/_internal/integrations/__init__.py +1 -0
  11. attocode_intel/_internal/integrations/context/__init__.py +1 -0
  12. attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
  13. attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
  14. attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
  15. attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
  16. attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
  17. attocode_intel/_internal/integrations/context/cas.py +466 -0
  18. attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
  19. attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
  20. attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
  21. attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
  22. attocode_intel/_internal/integrations/context/cross_references.py +616 -0
  23. attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
  24. attocode_intel/_internal/integrations/context/embeddings.py +282 -0
  25. attocode_intel/_internal/integrations/context/frecency.py +427 -0
  26. attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
  27. attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
  28. attocode_intel/_internal/integrations/context/hydration.py +90 -0
  29. attocode_intel/_internal/integrations/context/index_repair.py +87 -0
  30. attocode_intel/_internal/integrations/context/index_store.py +586 -0
  31. attocode_intel/_internal/integrations/context/memory_store.py +602 -0
  32. attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
  33. attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
  34. attocode_intel/_internal/integrations/context/query_history.py +417 -0
  35. attocode_intel/_internal/integrations/context/reranker.py +351 -0
  36. attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
  37. attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
  38. attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
  39. attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
  40. attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
  41. attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
  42. attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
  43. attocode_intel/_internal/integrations/context/vector_store.py +795 -0
  44. attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
  45. attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
  46. attocode_intel/_internal/integrations/lsp/client.py +1140 -0
  47. attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
  48. attocode_intel/_internal/integrations/mcp/client.py +253 -0
  49. attocode_intel/_internal/integrations/security/__init__.py +1 -0
  50. attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
  51. attocode_intel/_internal/integrations/security/dataflow.py +522 -0
  52. attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
  53. attocode_intel/_internal/integrations/security/matcher.py +44 -0
  54. attocode_intel/_internal/integrations/security/patterns.py +724 -0
  55. attocode_intel/_internal/integrations/security/scanner.py +351 -0
  56. attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
  57. attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
  58. attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
  59. attocode_intel/_internal/tricks/__init__.py +1 -0
  60. attocode_intel/_internal/tricks/recursive_context.py +357 -0
  61. attocode_intel/_internal/types/__init__.py +1 -0
  62. attocode_intel/_internal/types/events.py +289 -0
  63. attocode_intel/_internal/types/severity.py +15 -0
  64. attocode_intel/_shared.py +341 -0
  65. attocode_intel/analysis_coverage.py +56 -0
  66. attocode_intel/api/__init__.py +1 -0
  67. attocode_intel/api/access.py +32 -0
  68. attocode_intel/api/app.py +247 -0
  69. attocode_intel/api/auth/__init__.py +128 -0
  70. attocode_intel/api/auth/api_keys.py +61 -0
  71. attocode_intel/api/auth/context.py +34 -0
  72. attocode_intel/api/auth/jwt.py +66 -0
  73. attocode_intel/api/auth/oauth.py +146 -0
  74. attocode_intel/api/auth/passwords.py +15 -0
  75. attocode_intel/api/deps.py +351 -0
  76. attocode_intel/api/knowledge_bridge.py +13 -0
  77. attocode_intel/api/mcp_transport.py +51 -0
  78. attocode_intel/api/middleware.py +411 -0
  79. attocode_intel/api/models.py +698 -0
  80. attocode_intel/api/providers/__init__.py +1 -0
  81. attocode_intel/api/providers/db_provider.py +1106 -0
  82. attocode_intel/api/providers/local_provider.py +476 -0
  83. attocode_intel/api/providers/remote_provider.py +740 -0
  84. attocode_intel/api/repository_source.py +21 -0
  85. attocode_intel/api/routes/__init__.py +1 -0
  86. attocode_intel/api/routes/_pin_helper.py +158 -0
  87. attocode_intel/api/routes/activity.py +148 -0
  88. attocode_intel/api/routes/adr.py +102 -0
  89. attocode_intel/api/routes/analysis.py +515 -0
  90. attocode_intel/api/routes/api_keys.py +170 -0
  91. attocode_intel/api/routes/auth.py +428 -0
  92. attocode_intel/api/routes/branches.py +262 -0
  93. attocode_intel/api/routes/cross_repo_search.py +198 -0
  94. attocode_intel/api/routes/embeddings.py +386 -0
  95. attocode_intel/api/routes/files.py +132 -0
  96. attocode_intel/api/routes/files_v2.py +388 -0
  97. attocode_intel/api/routes/gc.py +322 -0
  98. attocode_intel/api/routes/git_v2.py +433 -0
  99. attocode_intel/api/routes/graph.py +174 -0
  100. attocode_intel/api/routes/graph_viz.py +160 -0
  101. attocode_intel/api/routes/health.py +62 -0
  102. attocode_intel/api/routes/history.py +175 -0
  103. attocode_intel/api/routes/jobs.py +84 -0
  104. attocode_intel/api/routes/learning.py +205 -0
  105. attocode_intel/api/routes/lsp.py +272 -0
  106. attocode_intel/api/routes/notify.py +623 -0
  107. attocode_intel/api/routes/operations.py +27 -0
  108. attocode_intel/api/routes/orgs.py +924 -0
  109. attocode_intel/api/routes/preferences.py +100 -0
  110. attocode_intel/api/routes/presence.py +42 -0
  111. attocode_intel/api/routes/projects.py +116 -0
  112. attocode_intel/api/routes/repos.py +47 -0
  113. attocode_intel/api/routes/search.py +180 -0
  114. attocode_intel/api/routes/snapshots.py +614 -0
  115. attocode_intel/api/routes/webhooks.py +313 -0
  116. attocode_intel/api/routes/websocket.py +116 -0
  117. attocode_intel/api/static/graph.html +239 -0
  118. attocode_intel/api/utils.py +96 -0
  119. attocode_intel/artifacts/__init__.py +45 -0
  120. attocode_intel/artifacts/hashing.py +230 -0
  121. attocode_intel/artifacts/provenance.py +190 -0
  122. attocode_intel/artifacts/retrieval_pin.py +159 -0
  123. attocode_intel/audit.py +59 -0
  124. attocode_intel/bug_finder.py +206 -0
  125. attocode_intel/bundle.py +80 -0
  126. attocode_intel/catalog.py +242 -0
  127. attocode_intel/change_review.py +75 -0
  128. attocode_intel/cli.py +3062 -0
  129. attocode_intel/community.py +63 -0
  130. attocode_intel/confidence/__init__.py +144 -0
  131. attocode_intel/confidence/jev.py +234 -0
  132. attocode_intel/confidence/llm.py +330 -0
  133. attocode_intel/confidence/redact.py +44 -0
  134. attocode_intel/confidence/settings.py +74 -0
  135. attocode_intel/config.py +256 -0
  136. attocode_intel/crypto.py +31 -0
  137. attocode_intel/db/__init__.py +7 -0
  138. attocode_intel/db/base.py +33 -0
  139. attocode_intel/db/engine.py +61 -0
  140. attocode_intel/db/models.py +529 -0
  141. attocode_intel/entrypoint.py +185 -0
  142. attocode_intel/focused_evidence.py +130 -0
  143. attocode_intel/freshness.py +154 -0
  144. attocode_intel/gateway.py +734 -0
  145. attocode_intel/git/__init__.py +7 -0
  146. attocode_intel/git/credentials.py +75 -0
  147. attocode_intel/git/manager.py +493 -0
  148. attocode_intel/git/models.py +96 -0
  149. attocode_intel/git/storage.py +98 -0
  150. attocode_intel/graph_query_parser.py +761 -0
  151. attocode_intel/helpers.py +712 -0
  152. attocode_intel/indexing/__init__.py +29 -0
  153. attocode_intel/indexing/debouncer.py +136 -0
  154. attocode_intel/indexing/delta_indexer.py +151 -0
  155. attocode_intel/indexing/full_indexer.py +323 -0
  156. attocode_intel/indexing/incremental.py +240 -0
  157. attocode_intel/indexing/parser.py +403 -0
  158. attocode_intel/indexing/summarizer.py +264 -0
  159. attocode_intel/installer.py +1896 -0
  160. attocode_intel/knowledge.py +269 -0
  161. attocode_intel/local_knowledge.py +77 -0
  162. attocode_intel/migrations/alembic.ini +36 -0
  163. attocode_intel/migrations/env.py +71 -0
  164. attocode_intel/migrations/script.py.mako +27 -0
  165. attocode_intel/migrations/versions/001_initial_schema.py +219 -0
  166. attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
  167. attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
  168. attocode_intel/migrations/versions/004_audit_events.py +38 -0
  169. attocode_intel/migrations/versions/005_user_preferences.py +30 -0
  170. attocode_intel/migrations/versions/006_google_oauth.py +27 -0
  171. attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
  172. attocode_intel/migrations/versions/008_symbol_references.py +37 -0
  173. attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
  174. attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
  175. attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
  176. attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
  177. attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
  178. attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
  179. attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
  180. attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
  181. attocode_intel/migrations/versions/017_provenance_table.py +102 -0
  182. attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
  183. attocode_intel/migrations/versions/019_symref_caller.py +42 -0
  184. attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
  185. attocode_intel/onboarding.py +258 -0
  186. attocode_intel/output.py +178 -0
  187. attocode_intel/precision.py +133 -0
  188. attocode_intel/presence.py +160 -0
  189. attocode_intel/probe.py +110 -0
  190. attocode_intel/project_dir.py +97 -0
  191. attocode_intel/pubsub.py +113 -0
  192. attocode_intel/query_ranking.py +242 -0
  193. attocode_intel/readiness.py +972 -0
  194. attocode_intel/redis.py +51 -0
  195. attocode_intel/reference_pages.py +60 -0
  196. attocode_intel/remote.py +131 -0
  197. attocode_intel/repo_ranker.py +246 -0
  198. attocode_intel/request_context.py +82 -0
  199. attocode_intel/rules/__init__.py +28 -0
  200. attocode_intel/rules/ci.py +320 -0
  201. attocode_intel/rules/combinators.py +293 -0
  202. attocode_intel/rules/enricher.py +127 -0
  203. attocode_intel/rules/evolution.py +452 -0
  204. attocode_intel/rules/executor.py +531 -0
  205. attocode_intel/rules/filters/__init__.py +5 -0
  206. attocode_intel/rules/filters/pipeline.py +151 -0
  207. attocode_intel/rules/formatter.py +198 -0
  208. attocode_intel/rules/hygiene.py +242 -0
  209. attocode_intel/rules/importers/__init__.py +1 -0
  210. attocode_intel/rules/importers/semgrep.py +382 -0
  211. attocode_intel/rules/loader.py +292 -0
  212. attocode_intel/rules/marketplace.py +375 -0
  213. attocode_intel/rules/metavar.py +181 -0
  214. attocode_intel/rules/model.py +317 -0
  215. attocode_intel/rules/packs/__init__.py +1 -0
  216. attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
  217. attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
  218. attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
  219. attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
  220. attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
  221. attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
  222. attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
  223. attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
  224. attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
  225. attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
  226. attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
  227. attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
  228. attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
  229. attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
  230. attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
  231. attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
  232. attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
  233. attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
  234. attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
  235. attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
  236. attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
  237. attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
  238. attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
  239. attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
  240. attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
  241. attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
  242. attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
  243. attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
  244. attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
  245. attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
  246. attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
  247. attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
  248. attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
  249. attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
  250. attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
  251. attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
  252. attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
  253. attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
  254. attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
  255. attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
  256. attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
  257. attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
  258. attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
  259. attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
  260. attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
  261. attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
  262. attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
  263. attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
  264. attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
  265. attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
  266. attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
  267. attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
  268. attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
  269. attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
  270. attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
  271. attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
  272. attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
  273. attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
  274. attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
  275. attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
  276. attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
  277. attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
  278. attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
  279. attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
  280. attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
  281. attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
  282. attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
  283. attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
  284. attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
  285. attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
  286. attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
  287. attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
  288. attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
  289. attocode_intel/rules/packs/pack_loader.py +244 -0
  290. attocode_intel/rules/plugins/__init__.py +1 -0
  291. attocode_intel/rules/plugins/plugin_loader.py +132 -0
  292. attocode_intel/rules/profiling.py +332 -0
  293. attocode_intel/rules/registry.py +213 -0
  294. attocode_intel/rules/sarif.py +187 -0
  295. attocode_intel/rules/synthesis.py +420 -0
  296. attocode_intel/rules/testing.py +326 -0
  297. attocode_intel/server.py +734 -0
  298. attocode_intel/service.py +3416 -0
  299. attocode_intel/sharing.py +57 -0
  300. attocode_intel/storage/__init__.py +6 -0
  301. attocode_intel/storage/branch_overlay.py +352 -0
  302. attocode_intel/storage/content_store.py +220 -0
  303. attocode_intel/storage/dependency_store.py +143 -0
  304. attocode_intel/storage/diff_engine.py +187 -0
  305. attocode_intel/storage/embedding_store.py +594 -0
  306. attocode_intel/storage/provenance_store.py +137 -0
  307. attocode_intel/storage/security_scanner_db.py +164 -0
  308. attocode_intel/storage/symbol_store.py +160 -0
  309. attocode_intel/structural_evidence.py +144 -0
  310. attocode_intel/symbol_inspection.py +141 -0
  311. attocode_intel/symbol_links.py +167 -0
  312. attocode_intel/telemetry.py +42 -0
  313. attocode_intel/test_ranking.py +73 -0
  314. attocode_intel/testing/__init__.py +43 -0
  315. attocode_intel/testing/fixtures.py +122 -0
  316. attocode_intel/testing/helpers.py +75 -0
  317. attocode_intel/testing/mocks.py +99 -0
  318. attocode_intel/tools/__init__.py +2 -0
  319. attocode_intel/tools/adr_tools.py +628 -0
  320. attocode_intel/tools/analysis_tools.py +382 -0
  321. attocode_intel/tools/composite_tools.py +695 -0
  322. attocode_intel/tools/cross_mode_tools.py +173 -0
  323. attocode_intel/tools/dead_code_tools.py +582 -0
  324. attocode_intel/tools/distill_tools.py +353 -0
  325. attocode_intel/tools/frecency_tools.py +220 -0
  326. attocode_intel/tools/fuzzy_tools.py +255 -0
  327. attocode_intel/tools/history_tools.py +486 -0
  328. attocode_intel/tools/learning_tools.py +222 -0
  329. attocode_intel/tools/lsp_tools.py +218 -0
  330. attocode_intel/tools/maintenance_tools.py +1662 -0
  331. attocode_intel/tools/navigation_tools.py +220 -0
  332. attocode_intel/tools/overlay_tools.py +540 -0
  333. attocode_intel/tools/pin_store.py +432 -0
  334. attocode_intel/tools/pin_tools.py +266 -0
  335. attocode_intel/tools/query_constraints_tools.py +262 -0
  336. attocode_intel/tools/query_history_tools.py +158 -0
  337. attocode_intel/tools/readiness_tools.py +44 -0
  338. attocode_intel/tools/rule_tools.py +992 -0
  339. attocode_intel/tools/search_tools.py +770 -0
  340. attocode_intel/tools/snapshot_tools.py +624 -0
  341. attocode_intel/workers/__init__.py +1 -0
  342. attocode_intel/workers/job_utils.py +111 -0
  343. attocode_intel/workers/jobs.py +573 -0
  344. attocode_intel/workers/run.py +30 -0
  345. attocode_intel/workers/settings.py +61 -0
@@ -0,0 +1,1896 @@
1
+ """Install/uninstall attocode-code-intel MCP server into coding assistants.
2
+
3
+ Supports:
4
+ - Claude Code: via `claude mcp add` CLI
5
+ - Cursor: via `.cursor/mcp.json`
6
+ - Windsurf: via `.windsurf/mcp.json`
7
+ - VS Code: via `.vscode/mcp.json`
8
+ - Codex: via `.codex/config.toml`
9
+ - Claude Desktop: via platform-specific `claude_desktop_config.json`
10
+ - Cline: via VS Code globalStorage `cline_mcp_settings.json`
11
+ - Zed: via `.zed/settings.json` or `~/.config/zed/settings.json`
12
+ - OpenCode: via `~/.config/opencode/config.json`
13
+ - Gemini CLI: via `.gemini/settings.json` or `~/.gemini/settings.json`
14
+ - Roo Code: via `.roo/mcp.json`
15
+ - Amazon Q: via `~/.aws/amazonq/mcp.json`
16
+ - GitHub Copilot CLI: via `~/.copilot/mcp-config.json`
17
+ - Junie: via `.junie/mcp/mcp.json` or `~/.junie/mcp/mcp.json`
18
+ - Kiro: via `.kiro/settings/mcp.json`
19
+ - Trae: via `.trae/mcp.json`
20
+ - Firebase Studio: via `.idx/mcp.json`
21
+ - Amp: via `.amp/settings.json` or `~/.config/amp/settings.json`
22
+ - Continue.dev: via `.continue/mcp.json`
23
+ - Hermes Agent: via `~/.hermes/config.yaml`
24
+ - Goose: via `~/.config/goose/config.yaml`
25
+ - GSD: via `.gsd/mcp.json` or `~/.gsd/mcp.json`
26
+ - IntelliJ: manual instructions
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import os
33
+ import platform
34
+ import shutil
35
+ import subprocess
36
+ import sys
37
+ import tomllib
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ import tomli_w
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Target constants
46
+ # ---------------------------------------------------------------------------
47
+
48
+ #: Targets that can be auto-installed via config file manipulation or CLI.
49
+ AUTO_INSTALL_TARGETS: tuple[str, ...] = (
50
+ "claude", "cursor", "windsurf", "vscode", "codex",
51
+ "claude-desktop", "cline", "zed",
52
+ "opencode", "gemini-cli", "roo-code", "amazon-q",
53
+ "copilot-cli", "junie", "kiro", "trae", "firebase",
54
+ "amp", "continue", "hermes", "goose", "gsd",
55
+ )
56
+
57
+ #: Targets that only support manual setup instructions.
58
+ MANUAL_TARGETS: tuple[str, ...] = ("intellij",)
59
+
60
+ #: All recognised target names.
61
+ ALL_TARGETS: tuple[str, ...] = AUTO_INSTALL_TARGETS + MANUAL_TARGETS
62
+
63
+ #: Friendly string for error messages.
64
+ ALL_TARGETS_STR: str = ", ".join(ALL_TARGETS)
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ResolvedInstallSpec:
69
+ """Normalized read-only view of an installed MCP target config."""
70
+
71
+ target: str
72
+ scope: str
73
+ config_path: str | None
74
+ command: str
75
+ args: list[str]
76
+ env: dict[str, str]
77
+ source_kind: str
78
+ unsupported_reason: str | None = None
79
+
80
+ @property
81
+ def is_supported(self) -> bool:
82
+ return self.unsupported_reason is None
83
+
84
+
85
+ def _read_json_file(path: Path) -> dict[str, Any] | None:
86
+ try:
87
+ raw = json.loads(path.read_text(encoding="utf-8"))
88
+ except (json.JSONDecodeError, OSError):
89
+ return None
90
+ return raw if isinstance(raw, dict) else None
91
+
92
+
93
+ def _read_toml_file(path: Path) -> dict[str, Any] | None:
94
+ try:
95
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
96
+ except (tomllib.TOMLDecodeError, OSError):
97
+ return None
98
+ return raw if isinstance(raw, dict) else None
99
+
100
+
101
+ def _read_yaml_file(path: Path) -> dict[str, Any] | None:
102
+ import yaml
103
+
104
+ try:
105
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
106
+ except (yaml.YAMLError, OSError):
107
+ return None
108
+ return raw if isinstance(raw, dict) else None
109
+
110
+
111
+ def _normalize_standard_entry(
112
+ target: str,
113
+ scope: str,
114
+ config_path: Path,
115
+ entry: dict[str, Any],
116
+ *,
117
+ source_kind: str,
118
+ ) -> ResolvedInstallSpec | None:
119
+ command = entry.get("command")
120
+ args = entry.get("args", [])
121
+ env = entry.get("env", {})
122
+ if not isinstance(command, str):
123
+ return None
124
+ if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
125
+ return None
126
+ if not isinstance(env, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()):
127
+ return None
128
+ return ResolvedInstallSpec(
129
+ target=target,
130
+ scope=scope,
131
+ config_path=str(config_path),
132
+ command=command,
133
+ args=list(args),
134
+ env=dict(env),
135
+ source_kind=source_kind,
136
+ )
137
+
138
+
139
+ def _normalize_zed_entry(
140
+ target: str,
141
+ scope: str,
142
+ config_path: Path,
143
+ entry: dict[str, Any],
144
+ ) -> ResolvedInstallSpec | None:
145
+ command_block = entry.get("command")
146
+ if not isinstance(command_block, dict):
147
+ return None
148
+ command = command_block.get("path")
149
+ args = command_block.get("args", [])
150
+ if not isinstance(command, str):
151
+ return None
152
+ if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
153
+ return None
154
+ return ResolvedInstallSpec(
155
+ target=target,
156
+ scope=scope,
157
+ config_path=str(config_path),
158
+ command=command,
159
+ args=list(args),
160
+ env={},
161
+ source_kind="custom-json",
162
+ )
163
+
164
+
165
+ def _normalize_opencode_entry(
166
+ target: str,
167
+ scope: str,
168
+ config_path: Path,
169
+ entry: dict[str, Any],
170
+ ) -> ResolvedInstallSpec | None:
171
+ command_parts = entry.get("command")
172
+ if not isinstance(command_parts, list) or not command_parts or not all(isinstance(part, str) for part in command_parts):
173
+ return None
174
+ env = entry.get("env", {})
175
+ if not isinstance(env, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()):
176
+ env = {}
177
+ return ResolvedInstallSpec(
178
+ target=target,
179
+ scope=scope,
180
+ config_path=str(config_path),
181
+ command=command_parts[0],
182
+ args=list(command_parts[1:]),
183
+ env=dict(env),
184
+ source_kind="custom-json",
185
+ )
186
+
187
+
188
+ def _normalize_goose_entry(
189
+ target: str,
190
+ scope: str,
191
+ config_path: Path,
192
+ entry: dict[str, Any],
193
+ ) -> ResolvedInstallSpec | None:
194
+ import shlex
195
+
196
+ cmd = entry.get("cmd")
197
+ if not isinstance(cmd, str) or not cmd.strip():
198
+ return None
199
+ parts = shlex.split(cmd)
200
+ if not parts:
201
+ return None
202
+ envs = entry.get("envs", {})
203
+ if not isinstance(envs, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in envs.items()):
204
+ envs = {}
205
+ return ResolvedInstallSpec(
206
+ target=target,
207
+ scope=scope,
208
+ config_path=str(config_path),
209
+ command=parts[0],
210
+ args=parts[1:],
211
+ env=dict(envs),
212
+ source_kind="yaml",
213
+ )
214
+
215
+
216
+ def _unsupported_spec(target: str, scope: str, reason: str) -> ResolvedInstallSpec:
217
+ return ResolvedInstallSpec(
218
+ target=target,
219
+ scope=scope,
220
+ config_path=None,
221
+ command="",
222
+ args=[],
223
+ env={},
224
+ source_kind="unsupported",
225
+ unsupported_reason=reason,
226
+ )
227
+
228
+
229
+ def resolve_install_spec(target: str, project_dir: str = ".", scope: str = "local") -> ResolvedInstallSpec | None:
230
+ """Resolve the installed config for a target into a normalized spec.
231
+
232
+ Returns ``None`` when the target is supported but not currently installed.
233
+ Returns a ``ResolvedInstallSpec`` with ``unsupported_reason`` for targets
234
+ that are intentionally excluded from probing in the non-breaking phase.
235
+ """
236
+ project_root = Path(project_dir)
237
+
238
+ if target == "claude":
239
+ return _unsupported_spec(
240
+ target,
241
+ scope,
242
+ "claude uses CLI-managed installs; probe-install only supports file-based targets in v1.",
243
+ )
244
+ if target == "intellij":
245
+ return _unsupported_spec(
246
+ target,
247
+ scope,
248
+ "intellij is manual-only; probe-install only supports file-based targets in v1.",
249
+ )
250
+ if target not in ALL_TARGETS:
251
+ return _unsupported_spec(target, scope, f"Unknown target '{target}'.")
252
+
253
+ if target in _JSON_CONFIG_TARGETS:
254
+ config_dirs = {
255
+ "cursor": ".cursor",
256
+ "windsurf": ".windsurf",
257
+ "vscode": ".vscode",
258
+ "roo-code": ".roo",
259
+ "trae": ".trae",
260
+ "kiro": os.path.join(".kiro", "settings"),
261
+ "firebase": ".idx",
262
+ "continue": ".continue",
263
+ }
264
+ config_path = project_root / config_dirs[target] / "mcp.json"
265
+ data = _read_json_file(config_path)
266
+ entry = data.get("mcpServers", {}).get("attocode-code-intel") if data else None
267
+ if not isinstance(entry, dict):
268
+ return None
269
+ return _normalize_standard_entry(target, scope, config_path, entry, source_kind="json")
270
+
271
+ if target == "codex":
272
+ config_path = Path.home() / ".codex" / "config.toml" if scope == "user" else project_root / ".codex" / "config.toml"
273
+ data = _read_toml_file(config_path)
274
+ entry = data.get("mcp_servers", {}).get("attocode-code-intel") if data else None
275
+ if not isinstance(entry, dict):
276
+ return None
277
+ return _normalize_standard_entry(target, scope, config_path, entry, source_kind="toml")
278
+
279
+ if target == "claude-desktop":
280
+ config_dir = _get_user_config_dir("claude-desktop")
281
+ if config_dir is None:
282
+ return None
283
+ config_path = config_dir / "claude_desktop_config.json"
284
+ data = _read_json_file(config_path)
285
+ entry = data.get("mcpServers", {}).get("attocode-code-intel") if data else None
286
+ if not isinstance(entry, dict):
287
+ return None
288
+ return _normalize_standard_entry(target, scope, config_path, entry, source_kind="json")
289
+
290
+ if target == "cline":
291
+ config_dir = _get_user_config_dir("cline")
292
+ if config_dir is None:
293
+ return None
294
+ config_path = config_dir / "cline_mcp_settings.json"
295
+ data = _read_json_file(config_path)
296
+ entry = data.get("mcpServers", {}).get("attocode-code-intel") if data else None
297
+ if not isinstance(entry, dict):
298
+ return None
299
+ return _normalize_standard_entry(target, scope, config_path, entry, source_kind="json")
300
+
301
+ if target == "zed":
302
+ config_path = (
303
+ Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "zed" / "settings.json"
304
+ if scope == "user"
305
+ else project_root / ".zed" / "settings.json"
306
+ )
307
+ data = _read_json_file(config_path)
308
+ entry = data.get("context_servers", {}).get("attocode-code-intel") if data else None
309
+ if not isinstance(entry, dict):
310
+ return None
311
+ return _normalize_zed_entry(target, scope, config_path, entry)
312
+
313
+ if target == "opencode":
314
+ config_path = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "opencode" / "config.json"
315
+ data = _read_json_file(config_path)
316
+ entry = data.get("mcp", {}).get("attocode-code-intel") if data else None
317
+ if not isinstance(entry, dict):
318
+ return None
319
+ return _normalize_opencode_entry(target, scope, config_path, entry)
320
+
321
+ if target == "gemini-cli":
322
+ config_path = Path.home() / ".gemini" / "settings.json" if scope == "user" else project_root / ".gemini" / "settings.json"
323
+ data = _read_json_file(config_path)
324
+ entry = data.get("mcpServers", {}).get("attocode-code-intel") if data else None
325
+ if not isinstance(entry, dict):
326
+ return None
327
+ return _normalize_standard_entry(target, scope, config_path, entry, source_kind="json")
328
+
329
+ if target == "amazon-q":
330
+ config_path = Path.home() / ".aws" / "amazonq" / "mcp.json"
331
+ data = _read_json_file(config_path)
332
+ entry = data.get("mcpServers", {}).get("attocode-code-intel") if data else None
333
+ if not isinstance(entry, dict):
334
+ return None
335
+ return _normalize_standard_entry(target, scope, config_path, entry, source_kind="json")
336
+
337
+ if target == "copilot-cli":
338
+ config_path = Path.home() / ".copilot" / "mcp-config.json"
339
+ data = _read_json_file(config_path)
340
+ entry = data.get("mcpServers", {}).get("attocode-code-intel") if data else None
341
+ if not isinstance(entry, dict):
342
+ return None
343
+ return _normalize_standard_entry(target, scope, config_path, entry, source_kind="json")
344
+
345
+ if target == "junie":
346
+ config_path = Path.home() / ".junie" / "mcp" / "mcp.json" if scope == "user" else project_root / ".junie" / "mcp" / "mcp.json"
347
+ data = _read_json_file(config_path)
348
+ entry = data.get("mcpServers", {}).get("attocode-code-intel") if data else None
349
+ if not isinstance(entry, dict):
350
+ return None
351
+ return _normalize_standard_entry(target, scope, config_path, entry, source_kind="json")
352
+
353
+ if target == "amp":
354
+ config_path = (
355
+ Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "amp" / "settings.json"
356
+ if scope == "user"
357
+ else project_root / ".amp" / "settings.json"
358
+ )
359
+ data = _read_json_file(config_path)
360
+ entry = data.get("amp", {}).get("mcpServers", {}).get("attocode-code-intel") if data else None
361
+ if not isinstance(entry, dict):
362
+ return None
363
+ return _normalize_standard_entry(target, scope, config_path, entry, source_kind="custom-json")
364
+
365
+ if target == "hermes":
366
+ config_path = Path.home() / ".hermes" / "config.yaml"
367
+ data = _read_yaml_file(config_path)
368
+ entry = data.get("mcp_servers", {}).get("attocode-code-intel") if data else None
369
+ if not isinstance(entry, dict):
370
+ return None
371
+ return _normalize_standard_entry(target, scope, config_path, entry, source_kind="yaml")
372
+
373
+ if target == "goose":
374
+ config_path = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "goose" / "config.yaml"
375
+ data = _read_yaml_file(config_path)
376
+ extensions = data.get("extensions", []) if data else []
377
+ if not isinstance(extensions, list):
378
+ return None
379
+ entry = next(
380
+ (
381
+ ext for ext in extensions
382
+ if isinstance(ext, dict) and ext.get("name") == "attocode-code-intel"
383
+ ),
384
+ None,
385
+ )
386
+ if not isinstance(entry, dict):
387
+ return None
388
+ return _normalize_goose_entry(target, scope, config_path, entry)
389
+
390
+ if target == "gsd":
391
+ config_path = Path.home() / ".gsd" / "mcp.json" if scope == "user" else project_root / ".gsd" / "mcp.json"
392
+ data = _read_json_file(config_path)
393
+ entry = data.get("servers", {}).get("attocode-code-intel") if data else None
394
+ if not isinstance(entry, dict):
395
+ return None
396
+ return _normalize_standard_entry(target, scope, config_path, entry, source_kind="custom-json")
397
+
398
+ return None
399
+
400
+
401
+ def _find_command(project_dir: str | None = None) -> str:
402
+ """Determine the right command to invoke the MCP server.
403
+
404
+ Resolution order:
405
+ 1. ``uv run attocode-code-intel`` when ``uv`` is available and the target
406
+ project's ``pyproject.toml`` declares the script — MCP hosts often spawn
407
+ subprocesses with a minimal PATH, so this is preferred for dev clones.
408
+ 2. ``attocode-code-intel`` on PATH (pipx / global install).
409
+ 3. ``python -m attocode_intel.entrypoint`` using the current interpreter. The
410
+ older ``attocode_intel.server`` lists all tools and has no profiles.
411
+ """
412
+ base = Path(project_dir or ".").resolve()
413
+ pyproject = base / "pyproject.toml"
414
+ if shutil.which("uv") and pyproject.is_file():
415
+ try:
416
+ text = pyproject.read_text(encoding="utf-8")
417
+ except OSError:
418
+ text = ""
419
+ if "attocode-code-intel" in text:
420
+ return "uv run attocode-code-intel"
421
+
422
+ if shutil.which("attocode-code-intel"):
423
+ return "attocode-code-intel"
424
+
425
+ return f"{sys.executable} -m attocode_intel.entrypoint"
426
+
427
+
428
+ def _build_server_entry(
429
+ project_dir: str | None,
430
+ *,
431
+ local_only: bool = True,
432
+ vscode_cursor_stdio: bool = False,
433
+ ) -> dict:
434
+ """Build the MCP server config dict for JSON/TOML-based configs.
435
+
436
+ By default, ``--local-only`` is included so the stdio MCP process does not
437
+ auto-load ``.attocode/config.toml`` remote settings (which often point at
438
+ ``http://127.0.0.1:8080`` and break tools with connection refused when no
439
+ HTTP server is running). Pass ``local_only=False`` only if you intentionally
440
+ proxy MCP tools through a reachable remote code-intel API.
441
+
442
+ When ``vscode_cursor_stdio`` is True (Cursor, VS Code family, etc.), the
443
+ entry matches `Cursor MCP stdio requirements`_:
444
+
445
+ - ``"type": "stdio"`` (required for correct routing in Cursor)
446
+ - ``--project`` set to ``${workspaceFolder}`` so the subprocess always
447
+ indexes the folder that contains ``.cursor/mcp.json``, even when the
448
+ MCP host's cwd differs from the workspace root
449
+ - On macOS, ``PATH`` is prefixed with Homebrew locations so ``uv`` is
450
+ found when the host spawns the server with a minimal environment
451
+
452
+ .. _Cursor MCP stdio requirements: https://cursor.com/docs/mcp
453
+ """
454
+ resolve_dir = project_dir if project_dir is not None else "."
455
+ cmd = _find_command(resolve_dir)
456
+ parts = cmd.split()
457
+ command = parts[0]
458
+ args = parts[1:]
459
+ if local_only:
460
+ args.append("--local-only")
461
+ if project_dir is not None:
462
+ if vscode_cursor_stdio:
463
+ args += ["--project", "${workspaceFolder}"]
464
+ else:
465
+ args += ["--project", os.path.abspath(project_dir)]
466
+
467
+ entry: dict = {
468
+ "command": command,
469
+ "args": args,
470
+ }
471
+ if vscode_cursor_stdio:
472
+ entry["type"] = "stdio"
473
+ if platform.system() == "Darwin":
474
+ entry["env"] = {
475
+ "PATH": "/opt/homebrew/bin:/usr/local/bin:${env:PATH}",
476
+ }
477
+ elif platform.system() != "Windows":
478
+ entry["env"] = {
479
+ "PATH": "/usr/local/bin:/usr/bin:${env:PATH}",
480
+ }
481
+ return entry
482
+
483
+
484
+ def install_claude(project_dir: str = ".", scope: str = "local") -> bool:
485
+ """Install into Claude Code via `claude mcp add`.
486
+
487
+ Args:
488
+ project_dir: Path to the project to index.
489
+ scope: "local" (project) or "user" (global).
490
+
491
+ Returns:
492
+ True if installation succeeded.
493
+ """
494
+ if not shutil.which("claude"):
495
+ print("Error: `claude` CLI not found. Install Claude Code first.", file=sys.stderr)
496
+ return False
497
+
498
+ cmd = _find_command(project_dir)
499
+ parts = cmd.split()
500
+ command = parts[0]
501
+ server_args = parts[1:]
502
+ server_args.append("--local-only")
503
+
504
+ # For local installs (or explicit --project), bake in the absolute path.
505
+ # For global installs without explicit --project, omit it so the server
506
+ # dynamically uses whatever directory Claude Code runs it from.
507
+ if scope != "user" or project_dir != ".":
508
+ server_args += ["--project", os.path.abspath(project_dir)]
509
+
510
+ claude_cmd = [
511
+ "claude", "mcp", "add",
512
+ "--transport", "stdio",
513
+ "--scope", scope,
514
+ "attocode-code-intel",
515
+ "--",
516
+ command,
517
+ *server_args,
518
+ ]
519
+
520
+ try:
521
+ result = subprocess.run(claude_cmd, capture_output=True, text=True, timeout=30)
522
+ if result.returncode == 0:
523
+ print(f"Installed attocode-code-intel into Claude Code (scope={scope})")
524
+ print(f" command: {command}")
525
+ print(f" args: {server_args}")
526
+ return True
527
+ else:
528
+ print(f"Error installing: {result.stderr.strip()}", file=sys.stderr)
529
+ return False
530
+ except FileNotFoundError:
531
+ print("Error: `claude` CLI not found.", file=sys.stderr)
532
+ return False
533
+ except subprocess.TimeoutExpired:
534
+ print("Error: `claude mcp add` timed out.", file=sys.stderr)
535
+ return False
536
+
537
+
538
+ def install_json_config(
539
+ target: str,
540
+ project_dir: str = ".",
541
+ ) -> bool:
542
+ """Install into a target that uses a project-scoped ``mcp.json`` file.
543
+
544
+ Args:
545
+ target: One of the supported project-scoped JSON targets.
546
+ project_dir: Path to the project to index.
547
+
548
+ Returns:
549
+ True if installation succeeded.
550
+ """
551
+ config_dirs = {
552
+ "cursor": ".cursor",
553
+ "windsurf": ".windsurf",
554
+ "vscode": ".vscode",
555
+ "roo-code": ".roo",
556
+ "trae": ".trae",
557
+ "kiro": os.path.join(".kiro", "settings"),
558
+ "firebase": ".idx",
559
+ "continue": ".continue",
560
+ }
561
+
562
+ config_dir = config_dirs.get(target)
563
+ if not config_dir:
564
+ print(f"Error: Unknown target '{target}'", file=sys.stderr)
565
+ return False
566
+
567
+ config_path = Path(project_dir) / config_dir / "mcp.json"
568
+ config_path.parent.mkdir(parents=True, exist_ok=True)
569
+
570
+ # Load existing config or start fresh
571
+ existing: dict = {}
572
+ if config_path.exists():
573
+ import contextlib
574
+ with contextlib.suppress(json.JSONDecodeError, OSError):
575
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
576
+
577
+ servers = existing.setdefault("mcpServers", {})
578
+ servers["attocode-code-intel"] = _build_server_entry(
579
+ project_dir,
580
+ vscode_cursor_stdio=True,
581
+ )
582
+
583
+ config_path.write_text(
584
+ json.dumps(existing, indent=2) + "\n",
585
+ encoding="utf-8",
586
+ )
587
+ print(f"Installed attocode-code-intel into {config_path}")
588
+ return True
589
+
590
+
591
+ def uninstall_claude(scope: str = "local") -> bool:
592
+ """Uninstall from Claude Code."""
593
+ if not shutil.which("claude"):
594
+ print("Error: `claude` CLI not found.", file=sys.stderr)
595
+ return False
596
+
597
+ try:
598
+ result = subprocess.run(
599
+ ["claude", "mcp", "remove", "--scope", scope, "attocode-code-intel"],
600
+ capture_output=True, text=True, timeout=30,
601
+ )
602
+ if result.returncode == 0:
603
+ print(f"Removed attocode-code-intel from Claude Code (scope={scope})")
604
+ return True
605
+ else:
606
+ print(f"Error removing: {result.stderr.strip()}", file=sys.stderr)
607
+ return False
608
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
609
+ print(f"Error: {e}", file=sys.stderr)
610
+ return False
611
+
612
+
613
+ def uninstall_json_config(target: str, project_dir: str = ".") -> bool:
614
+ """Uninstall from a project-scoped ``mcp.json`` target."""
615
+ config_dirs = {
616
+ "cursor": ".cursor",
617
+ "windsurf": ".windsurf",
618
+ "vscode": ".vscode",
619
+ "roo-code": ".roo",
620
+ "trae": ".trae",
621
+ "kiro": os.path.join(".kiro", "settings"),
622
+ "firebase": ".idx",
623
+ "continue": ".continue",
624
+ }
625
+
626
+ config_dir = config_dirs.get(target)
627
+ if not config_dir:
628
+ print(f"Error: Unknown target '{target}'", file=sys.stderr)
629
+ return False
630
+
631
+ config_path = Path(project_dir) / config_dir / "mcp.json"
632
+ if not config_path.exists():
633
+ print(f"No config found at {config_path}")
634
+ return True
635
+
636
+ try:
637
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
638
+ except (json.JSONDecodeError, OSError):
639
+ return True
640
+
641
+ servers = existing.get("mcpServers", {})
642
+ if "attocode-code-intel" in servers:
643
+ del servers["attocode-code-intel"]
644
+ config_path.write_text(
645
+ json.dumps(existing, indent=2) + "\n",
646
+ encoding="utf-8",
647
+ )
648
+ print(f"Removed attocode-code-intel from {config_path}")
649
+
650
+ return True
651
+
652
+
653
+ def install_codex(project_dir: str = ".", scope: str = "local") -> bool:
654
+ """Install into OpenAI Codex CLI via `.codex/config.toml`.
655
+
656
+ Args:
657
+ project_dir: Path to the project to index.
658
+ scope: "local" (project) or "user" (global ~/.codex/).
659
+
660
+ Returns:
661
+ True if installation succeeded.
662
+ """
663
+ if scope == "user":
664
+ config_path = Path.home() / ".codex" / "config.toml"
665
+ else:
666
+ config_path = Path(project_dir) / ".codex" / "config.toml"
667
+
668
+ config_path.parent.mkdir(parents=True, exist_ok=True)
669
+
670
+ # Load existing config or start fresh
671
+ existing: dict = {}
672
+ if config_path.exists():
673
+ import contextlib
674
+ with contextlib.suppress(tomllib.TOMLDecodeError, OSError):
675
+ existing = tomllib.loads(config_path.read_text(encoding="utf-8"))
676
+
677
+ omit_project = scope == "user" and project_dir == "."
678
+ entry = _build_server_entry(None if omit_project else project_dir)
679
+ servers = existing.setdefault("mcp_servers", {})
680
+ servers["attocode-code-intel"] = {
681
+ "command": entry["command"],
682
+ "args": entry["args"],
683
+ }
684
+
685
+ config_path.write_text(
686
+ tomli_w.dumps(existing),
687
+ encoding="utf-8",
688
+ )
689
+ print(f"Installed attocode-code-intel into {config_path}")
690
+ return True
691
+
692
+
693
+ def uninstall_codex(project_dir: str = ".", scope: str = "local") -> bool:
694
+ """Uninstall from OpenAI Codex CLI."""
695
+ if scope == "user":
696
+ config_path = Path.home() / ".codex" / "config.toml"
697
+ else:
698
+ config_path = Path(project_dir) / ".codex" / "config.toml"
699
+
700
+ if not config_path.exists():
701
+ print(f"No config found at {config_path}")
702
+ return True
703
+
704
+ try:
705
+ existing = tomllib.loads(config_path.read_text(encoding="utf-8"))
706
+ except (tomllib.TOMLDecodeError, OSError):
707
+ return True
708
+
709
+ servers = existing.get("mcp_servers", {})
710
+ if "attocode-code-intel" in servers:
711
+ del servers["attocode-code-intel"]
712
+ config_path.write_text(
713
+ tomli_w.dumps(existing),
714
+ encoding="utf-8",
715
+ )
716
+ print(f"Removed attocode-code-intel from {config_path}")
717
+
718
+ return True
719
+
720
+
721
+ # ---------------------------------------------------------------------------
722
+ # Platform-specific config path resolver
723
+ # ---------------------------------------------------------------------------
724
+
725
+
726
+ def _get_user_config_dir(app: str) -> Path | None:
727
+ """Return the platform-specific user config directory for *app*.
728
+
729
+ Supported apps:
730
+ - ``"claude-desktop"`` — Claude Desktop's config dir
731
+ - ``"cline"`` — Cline extension globalStorage inside VS Code
732
+
733
+ Returns ``None`` for unsupported (app, platform) combinations.
734
+ """
735
+ system = platform.system() # "Darwin", "Linux", "Windows"
736
+
737
+ if app == "claude-desktop":
738
+ if system == "Darwin":
739
+ return Path.home() / "Library" / "Application Support" / "Claude"
740
+ if system == "Linux":
741
+ return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "Claude"
742
+ if system == "Windows":
743
+ appdata = os.environ.get("APPDATA", "")
744
+ if appdata:
745
+ return Path(appdata) / "Claude"
746
+ return None
747
+
748
+ if app == "cline":
749
+ if system == "Darwin":
750
+ return (
751
+ Path.home()
752
+ / "Library"
753
+ / "Application Support"
754
+ / "Code"
755
+ / "User"
756
+ / "globalStorage"
757
+ / "saoudrizwan.claude-dev"
758
+ )
759
+ if system == "Linux":
760
+ return (
761
+ Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
762
+ / "Code"
763
+ / "User"
764
+ / "globalStorage"
765
+ / "saoudrizwan.claude-dev"
766
+ )
767
+ if system == "Windows":
768
+ appdata = os.environ.get("APPDATA", "")
769
+ if appdata:
770
+ return (
771
+ Path(appdata)
772
+ / "Code"
773
+ / "User"
774
+ / "globalStorage"
775
+ / "saoudrizwan.claude-dev"
776
+ )
777
+ return None
778
+
779
+ return None
780
+
781
+
782
+ # ---------------------------------------------------------------------------
783
+ # Claude Desktop
784
+ # ---------------------------------------------------------------------------
785
+
786
+
787
+ def install_claude_desktop(project_dir: str = ".") -> bool:
788
+ """Install into Claude Desktop via ``claude_desktop_config.json``.
789
+
790
+ Returns True if installation succeeded.
791
+ """
792
+ config_dir = _get_user_config_dir("claude-desktop")
793
+ if config_dir is None:
794
+ print(
795
+ f"Error: Claude Desktop config path not supported on {platform.system()}",
796
+ file=sys.stderr,
797
+ )
798
+ return False
799
+
800
+ config_path = config_dir / "claude_desktop_config.json"
801
+ config_path.parent.mkdir(parents=True, exist_ok=True)
802
+
803
+ existing: dict = {}
804
+ if config_path.exists():
805
+ import contextlib
806
+ with contextlib.suppress(json.JSONDecodeError, OSError):
807
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
808
+
809
+ # Omit --project when no explicit project was given, so the server
810
+ # dynamically uses whatever directory the host runs it from.
811
+ effective_dir = None if project_dir == "." else project_dir
812
+ servers = existing.setdefault("mcpServers", {})
813
+ servers["attocode-code-intel"] = _build_server_entry(effective_dir)
814
+
815
+ config_path.write_text(
816
+ json.dumps(existing, indent=2) + "\n",
817
+ encoding="utf-8",
818
+ )
819
+ print(f"Installed attocode-code-intel into {config_path}")
820
+ return True
821
+
822
+
823
+ def uninstall_claude_desktop() -> bool:
824
+ """Uninstall from Claude Desktop."""
825
+ config_dir = _get_user_config_dir("claude-desktop")
826
+ if config_dir is None:
827
+ print(
828
+ f"Error: Claude Desktop config path not supported on {platform.system()}",
829
+ file=sys.stderr,
830
+ )
831
+ return False
832
+
833
+ config_path = config_dir / "claude_desktop_config.json"
834
+ if not config_path.exists():
835
+ print(f"No config found at {config_path}")
836
+ return True
837
+
838
+ try:
839
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
840
+ except (json.JSONDecodeError, OSError):
841
+ return True
842
+
843
+ servers = existing.get("mcpServers", {})
844
+ if "attocode-code-intel" in servers:
845
+ del servers["attocode-code-intel"]
846
+ config_path.write_text(
847
+ json.dumps(existing, indent=2) + "\n",
848
+ encoding="utf-8",
849
+ )
850
+ print(f"Removed attocode-code-intel from {config_path}")
851
+
852
+ return True
853
+
854
+
855
+ # ---------------------------------------------------------------------------
856
+ # Cline (VS Code extension)
857
+ # ---------------------------------------------------------------------------
858
+
859
+
860
+ def install_cline(project_dir: str = ".") -> bool:
861
+ """Install into Cline via ``cline_mcp_settings.json``.
862
+
863
+ Returns True if installation succeeded.
864
+ """
865
+ config_dir = _get_user_config_dir("cline")
866
+ if config_dir is None:
867
+ print(
868
+ f"Error: Cline config path not supported on {platform.system()}",
869
+ file=sys.stderr,
870
+ )
871
+ return False
872
+
873
+ config_path = config_dir / "cline_mcp_settings.json"
874
+ config_path.parent.mkdir(parents=True, exist_ok=True)
875
+
876
+ existing: dict = {}
877
+ if config_path.exists():
878
+ import contextlib
879
+ with contextlib.suppress(json.JSONDecodeError, OSError):
880
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
881
+
882
+ # Omit --project when no explicit project was given, so the server
883
+ # dynamically uses whatever directory the host runs it from.
884
+ effective_dir = None if project_dir == "." else project_dir
885
+ servers = existing.setdefault("mcpServers", {})
886
+ servers["attocode-code-intel"] = _build_server_entry(effective_dir)
887
+
888
+ config_path.write_text(
889
+ json.dumps(existing, indent=2) + "\n",
890
+ encoding="utf-8",
891
+ )
892
+ print(f"Installed attocode-code-intel into {config_path}")
893
+ return True
894
+
895
+
896
+ def uninstall_cline() -> bool:
897
+ """Uninstall from Cline."""
898
+ config_dir = _get_user_config_dir("cline")
899
+ if config_dir is None:
900
+ print(
901
+ f"Error: Cline config path not supported on {platform.system()}",
902
+ file=sys.stderr,
903
+ )
904
+ return False
905
+
906
+ config_path = config_dir / "cline_mcp_settings.json"
907
+ if not config_path.exists():
908
+ print(f"No config found at {config_path}")
909
+ return True
910
+
911
+ try:
912
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
913
+ except (json.JSONDecodeError, OSError):
914
+ return True
915
+
916
+ servers = existing.get("mcpServers", {})
917
+ if "attocode-code-intel" in servers:
918
+ del servers["attocode-code-intel"]
919
+ config_path.write_text(
920
+ json.dumps(existing, indent=2) + "\n",
921
+ encoding="utf-8",
922
+ )
923
+ print(f"Removed attocode-code-intel from {config_path}")
924
+
925
+ return True
926
+
927
+
928
+ # ---------------------------------------------------------------------------
929
+ # Zed
930
+ # ---------------------------------------------------------------------------
931
+
932
+
933
+ def _build_zed_server_entry(project_dir: str) -> dict:
934
+ """Build the MCP server config dict for Zed's ``context_servers`` format.
935
+
936
+ Zed uses a nested ``{"command": {"path": ..., "args": [...]}}`` schema.
937
+ """
938
+ cmd = _find_command(project_dir)
939
+ parts = cmd.split()
940
+ command = parts[0]
941
+ args = parts[1:] + [
942
+ "--local-only",
943
+ "--project",
944
+ os.path.abspath(project_dir),
945
+ ]
946
+ return {
947
+ "command": {
948
+ "path": command,
949
+ "args": args,
950
+ },
951
+ }
952
+
953
+
954
+ def install_zed(project_dir: str = ".", scope: str = "local") -> bool:
955
+ """Install into Zed via ``settings.json``.
956
+
957
+ Args:
958
+ project_dir: Path to the project to index.
959
+ scope: "local" writes to ``.zed/settings.json`` (project),
960
+ "user" writes to ``~/.config/zed/settings.json``.
961
+
962
+ Returns True if installation succeeded.
963
+ """
964
+ if scope == "user":
965
+ config_path = Path(
966
+ os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
967
+ ) / "zed" / "settings.json"
968
+ else:
969
+ config_path = Path(project_dir) / ".zed" / "settings.json"
970
+
971
+ config_path.parent.mkdir(parents=True, exist_ok=True)
972
+
973
+ existing: dict = {}
974
+ if config_path.exists():
975
+ import contextlib
976
+ with contextlib.suppress(json.JSONDecodeError, OSError):
977
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
978
+
979
+ servers = existing.setdefault("context_servers", {})
980
+ servers["attocode-code-intel"] = _build_zed_server_entry(project_dir)
981
+
982
+ config_path.write_text(
983
+ json.dumps(existing, indent=2) + "\n",
984
+ encoding="utf-8",
985
+ )
986
+ print(f"Installed attocode-code-intel into {config_path}")
987
+ return True
988
+
989
+
990
+ def uninstall_zed(project_dir: str = ".", scope: str = "local") -> bool:
991
+ """Uninstall from Zed."""
992
+ if scope == "user":
993
+ config_path = Path(
994
+ os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
995
+ ) / "zed" / "settings.json"
996
+ else:
997
+ config_path = Path(project_dir) / ".zed" / "settings.json"
998
+
999
+ if not config_path.exists():
1000
+ print(f"No config found at {config_path}")
1001
+ return True
1002
+
1003
+ try:
1004
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1005
+ except (json.JSONDecodeError, OSError):
1006
+ return True
1007
+
1008
+ servers = existing.get("context_servers", {})
1009
+ if "attocode-code-intel" in servers:
1010
+ del servers["attocode-code-intel"]
1011
+ config_path.write_text(
1012
+ json.dumps(existing, indent=2) + "\n",
1013
+ encoding="utf-8",
1014
+ )
1015
+ print(f"Removed attocode-code-intel from {config_path}")
1016
+
1017
+ return True
1018
+
1019
+
1020
+ # ---------------------------------------------------------------------------
1021
+ # OpenCode — ~/.config/opencode/config.json
1022
+ # ---------------------------------------------------------------------------
1023
+
1024
+
1025
+ def install_opencode(project_dir: str = ".", scope: str = "local") -> bool:
1026
+ """Install into OpenCode via ``~/.config/opencode/config.json``.
1027
+
1028
+ OpenCode uses ``"mcp"`` as the top-level key (not ``"mcpServers"``),
1029
+ entries require ``"type": "local"`` and ``"command"`` as a single array.
1030
+ """
1031
+ config_path = Path(
1032
+ os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
1033
+ ) / "opencode" / "config.json"
1034
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1035
+
1036
+ existing: dict = {}
1037
+ if config_path.exists():
1038
+ import contextlib
1039
+ with contextlib.suppress(json.JSONDecodeError, OSError):
1040
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1041
+
1042
+ # Omit --project when no explicit project was given, so the server
1043
+ # dynamically uses whatever directory the host runs it from.
1044
+ effective_dir = None if project_dir == "." else project_dir
1045
+ entry = _build_server_entry(effective_dir)
1046
+
1047
+ # OpenCode format: "mcp" key, "type": "local", "command" as single array.
1048
+ servers = existing.setdefault("mcp", {})
1049
+ servers["attocode-code-intel"] = {
1050
+ "type": "local",
1051
+ "command": [entry["command"]] + entry["args"],
1052
+ }
1053
+
1054
+ config_path.write_text(
1055
+ json.dumps(existing, indent=2) + "\n",
1056
+ encoding="utf-8",
1057
+ )
1058
+ print(f"Installed attocode-code-intel into {config_path}")
1059
+ return True
1060
+
1061
+
1062
+ def uninstall_opencode() -> bool:
1063
+ """Uninstall from OpenCode."""
1064
+ config_path = Path(
1065
+ os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
1066
+ ) / "opencode" / "config.json"
1067
+
1068
+ if not config_path.exists():
1069
+ print(f"No config found at {config_path}")
1070
+ return True
1071
+
1072
+ try:
1073
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1074
+ except (json.JSONDecodeError, OSError):
1075
+ return True
1076
+
1077
+ servers = existing.get("mcp", {})
1078
+ if "attocode-code-intel" in servers:
1079
+ del servers["attocode-code-intel"]
1080
+ config_path.write_text(
1081
+ json.dumps(existing, indent=2) + "\n",
1082
+ encoding="utf-8",
1083
+ )
1084
+ print(f"Removed attocode-code-intel from {config_path}")
1085
+
1086
+ return True
1087
+
1088
+
1089
+ # ---------------------------------------------------------------------------
1090
+ # Gemini CLI — .gemini/settings.json (project) or ~/.gemini/settings.json
1091
+ # ---------------------------------------------------------------------------
1092
+
1093
+
1094
+ def install_gemini(project_dir: str = ".", scope: str = "local") -> bool:
1095
+ """Install into Gemini CLI via ``settings.json``.
1096
+
1097
+ Args:
1098
+ project_dir: Path to the project to index.
1099
+ scope: "local" (project) or "user" (global).
1100
+ """
1101
+ if scope == "user":
1102
+ config_path = Path.home() / ".gemini" / "settings.json"
1103
+ else:
1104
+ config_path = Path(project_dir) / ".gemini" / "settings.json"
1105
+
1106
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1107
+
1108
+ existing: dict = {}
1109
+ if config_path.exists():
1110
+ import contextlib
1111
+ with contextlib.suppress(json.JSONDecodeError, OSError):
1112
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1113
+
1114
+ servers = existing.setdefault("mcpServers", {})
1115
+ servers["attocode-code-intel"] = _build_server_entry(project_dir)
1116
+
1117
+ config_path.write_text(
1118
+ json.dumps(existing, indent=2) + "\n",
1119
+ encoding="utf-8",
1120
+ )
1121
+ print(f"Installed attocode-code-intel into {config_path}")
1122
+ return True
1123
+
1124
+
1125
+ def uninstall_gemini(project_dir: str = ".", scope: str = "local") -> bool:
1126
+ """Uninstall from Gemini CLI."""
1127
+ if scope == "user":
1128
+ config_path = Path.home() / ".gemini" / "settings.json"
1129
+ else:
1130
+ config_path = Path(project_dir) / ".gemini" / "settings.json"
1131
+
1132
+ if not config_path.exists():
1133
+ print(f"No config found at {config_path}")
1134
+ return True
1135
+
1136
+ try:
1137
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1138
+ except (json.JSONDecodeError, OSError):
1139
+ return True
1140
+
1141
+ servers = existing.get("mcpServers", {})
1142
+ if "attocode-code-intel" in servers:
1143
+ del servers["attocode-code-intel"]
1144
+ config_path.write_text(
1145
+ json.dumps(existing, indent=2) + "\n",
1146
+ encoding="utf-8",
1147
+ )
1148
+ print(f"Removed attocode-code-intel from {config_path}")
1149
+
1150
+ return True
1151
+
1152
+
1153
+ # ---------------------------------------------------------------------------
1154
+ # Amazon Q Developer — ~/.aws/amazonq/mcp.json
1155
+ # ---------------------------------------------------------------------------
1156
+
1157
+
1158
+ def install_amazonq(project_dir: str = ".") -> bool:
1159
+ """Install into Amazon Q Developer via ``~/.aws/amazonq/mcp.json``."""
1160
+ config_path = Path.home() / ".aws" / "amazonq" / "mcp.json"
1161
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1162
+
1163
+ existing: dict = {}
1164
+ if config_path.exists():
1165
+ import contextlib
1166
+ with contextlib.suppress(json.JSONDecodeError, OSError):
1167
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1168
+
1169
+ # Omit --project when no explicit project was given, so the server
1170
+ # dynamically uses whatever directory the host runs it from.
1171
+ effective_dir = None if project_dir == "." else project_dir
1172
+ servers = existing.setdefault("mcpServers", {})
1173
+ servers["attocode-code-intel"] = _build_server_entry(effective_dir)
1174
+
1175
+ config_path.write_text(
1176
+ json.dumps(existing, indent=2) + "\n",
1177
+ encoding="utf-8",
1178
+ )
1179
+ print(f"Installed attocode-code-intel into {config_path}")
1180
+ return True
1181
+
1182
+
1183
+ def uninstall_amazonq() -> bool:
1184
+ """Uninstall from Amazon Q Developer."""
1185
+ config_path = Path.home() / ".aws" / "amazonq" / "mcp.json"
1186
+
1187
+ if not config_path.exists():
1188
+ print(f"No config found at {config_path}")
1189
+ return True
1190
+
1191
+ try:
1192
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1193
+ except (json.JSONDecodeError, OSError):
1194
+ return True
1195
+
1196
+ servers = existing.get("mcpServers", {})
1197
+ if "attocode-code-intel" in servers:
1198
+ del servers["attocode-code-intel"]
1199
+ config_path.write_text(
1200
+ json.dumps(existing, indent=2) + "\n",
1201
+ encoding="utf-8",
1202
+ )
1203
+ print(f"Removed attocode-code-intel from {config_path}")
1204
+
1205
+ return True
1206
+
1207
+
1208
+ # ---------------------------------------------------------------------------
1209
+ # GitHub Copilot CLI — ~/.copilot/mcp-config.json
1210
+ # ---------------------------------------------------------------------------
1211
+
1212
+
1213
+ def install_copilot_cli(project_dir: str = ".") -> bool:
1214
+ """Install into GitHub Copilot CLI via ``~/.copilot/mcp-config.json``."""
1215
+ config_path = Path.home() / ".copilot" / "mcp-config.json"
1216
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1217
+
1218
+ existing: dict = {}
1219
+ if config_path.exists():
1220
+ import contextlib
1221
+ with contextlib.suppress(json.JSONDecodeError, OSError):
1222
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1223
+
1224
+ # Omit --project when no explicit project was given, so the server
1225
+ # dynamically uses whatever directory the host runs it from.
1226
+ effective_dir = None if project_dir == "." else project_dir
1227
+ servers = existing.setdefault("mcpServers", {})
1228
+ servers["attocode-code-intel"] = _build_server_entry(effective_dir)
1229
+
1230
+ config_path.write_text(
1231
+ json.dumps(existing, indent=2) + "\n",
1232
+ encoding="utf-8",
1233
+ )
1234
+ print(f"Installed attocode-code-intel into {config_path}")
1235
+ return True
1236
+
1237
+
1238
+ def uninstall_copilot_cli() -> bool:
1239
+ """Uninstall from GitHub Copilot CLI."""
1240
+ config_path = Path.home() / ".copilot" / "mcp-config.json"
1241
+
1242
+ if not config_path.exists():
1243
+ print(f"No config found at {config_path}")
1244
+ return True
1245
+
1246
+ try:
1247
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1248
+ except (json.JSONDecodeError, OSError):
1249
+ return True
1250
+
1251
+ servers = existing.get("mcpServers", {})
1252
+ if "attocode-code-intel" in servers:
1253
+ del servers["attocode-code-intel"]
1254
+ config_path.write_text(
1255
+ json.dumps(existing, indent=2) + "\n",
1256
+ encoding="utf-8",
1257
+ )
1258
+ print(f"Removed attocode-code-intel from {config_path}")
1259
+
1260
+ return True
1261
+
1262
+
1263
+ # ---------------------------------------------------------------------------
1264
+ # Junie (JetBrains) — .junie/mcp/mcp.json or ~/.junie/mcp/mcp.json
1265
+ # ---------------------------------------------------------------------------
1266
+
1267
+
1268
+ def install_junie(project_dir: str = ".", scope: str = "local") -> bool:
1269
+ """Install into Junie via ``mcp.json``.
1270
+
1271
+ Args:
1272
+ project_dir: Path to the project to index.
1273
+ scope: "local" (project) or "user" (global).
1274
+ """
1275
+ if scope == "user":
1276
+ config_path = Path.home() / ".junie" / "mcp" / "mcp.json"
1277
+ else:
1278
+ config_path = Path(project_dir) / ".junie" / "mcp" / "mcp.json"
1279
+
1280
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1281
+
1282
+ existing: dict = {}
1283
+ if config_path.exists():
1284
+ import contextlib
1285
+ with contextlib.suppress(json.JSONDecodeError, OSError):
1286
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1287
+
1288
+ servers = existing.setdefault("mcpServers", {})
1289
+ servers["attocode-code-intel"] = _build_server_entry(project_dir)
1290
+
1291
+ config_path.write_text(
1292
+ json.dumps(existing, indent=2) + "\n",
1293
+ encoding="utf-8",
1294
+ )
1295
+ print(f"Installed attocode-code-intel into {config_path}")
1296
+ return True
1297
+
1298
+
1299
+ def uninstall_junie(project_dir: str = ".", scope: str = "local") -> bool:
1300
+ """Uninstall from Junie."""
1301
+ if scope == "user":
1302
+ config_path = Path.home() / ".junie" / "mcp" / "mcp.json"
1303
+ else:
1304
+ config_path = Path(project_dir) / ".junie" / "mcp" / "mcp.json"
1305
+
1306
+ if not config_path.exists():
1307
+ print(f"No config found at {config_path}")
1308
+ return True
1309
+
1310
+ try:
1311
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1312
+ except (json.JSONDecodeError, OSError):
1313
+ return True
1314
+
1315
+ servers = existing.get("mcpServers", {})
1316
+ if "attocode-code-intel" in servers:
1317
+ del servers["attocode-code-intel"]
1318
+ config_path.write_text(
1319
+ json.dumps(existing, indent=2) + "\n",
1320
+ encoding="utf-8",
1321
+ )
1322
+ print(f"Removed attocode-code-intel from {config_path}")
1323
+
1324
+ return True
1325
+
1326
+
1327
+ # ---------------------------------------------------------------------------
1328
+ # Amp (Sourcegraph) — .amp/settings.json or ~/.config/amp/settings.json
1329
+ # Key is nested under "amp" → "mcpServers"
1330
+ # ---------------------------------------------------------------------------
1331
+
1332
+
1333
+ def install_amp(project_dir: str = ".", scope: str = "local") -> bool:
1334
+ """Install into Amp (Sourcegraph) via ``settings.json``.
1335
+
1336
+ Amp uses a nested ``amp.mcpServers`` key, not a top-level ``mcpServers``.
1337
+
1338
+ Args:
1339
+ project_dir: Path to the project to index.
1340
+ scope: "local" (project) or "user" (global).
1341
+ """
1342
+ if scope == "user":
1343
+ config_path = Path(
1344
+ os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
1345
+ ) / "amp" / "settings.json"
1346
+ else:
1347
+ config_path = Path(project_dir) / ".amp" / "settings.json"
1348
+
1349
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1350
+
1351
+ existing: dict = {}
1352
+ if config_path.exists():
1353
+ import contextlib
1354
+ with contextlib.suppress(json.JSONDecodeError, OSError):
1355
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1356
+
1357
+ amp_section = existing.setdefault("amp", {})
1358
+ servers = amp_section.setdefault("mcpServers", {})
1359
+ servers["attocode-code-intel"] = _build_server_entry(project_dir)
1360
+
1361
+ config_path.write_text(
1362
+ json.dumps(existing, indent=2) + "\n",
1363
+ encoding="utf-8",
1364
+ )
1365
+ print(f"Installed attocode-code-intel into {config_path}")
1366
+ return True
1367
+
1368
+
1369
+ def uninstall_amp(project_dir: str = ".", scope: str = "local") -> bool:
1370
+ """Uninstall from Amp."""
1371
+ if scope == "user":
1372
+ config_path = Path(
1373
+ os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
1374
+ ) / "amp" / "settings.json"
1375
+ else:
1376
+ config_path = Path(project_dir) / ".amp" / "settings.json"
1377
+
1378
+ if not config_path.exists():
1379
+ print(f"No config found at {config_path}")
1380
+ return True
1381
+
1382
+ try:
1383
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1384
+ except (json.JSONDecodeError, OSError):
1385
+ return True
1386
+
1387
+ servers = existing.get("amp", {}).get("mcpServers", {})
1388
+ if "attocode-code-intel" in servers:
1389
+ del servers["attocode-code-intel"]
1390
+ config_path.write_text(
1391
+ json.dumps(existing, indent=2) + "\n",
1392
+ encoding="utf-8",
1393
+ )
1394
+ print(f"Removed attocode-code-intel from {config_path}")
1395
+
1396
+ return True
1397
+
1398
+
1399
+ # ---------------------------------------------------------------------------
1400
+ # Hermes Agent (NousResearch) — ~/.hermes/config.yaml, key "mcp_servers"
1401
+ # ---------------------------------------------------------------------------
1402
+
1403
+
1404
+ def install_hermes(project_dir: str = ".") -> bool:
1405
+ """Install into Hermes Agent via ``~/.hermes/config.yaml``."""
1406
+ import yaml
1407
+
1408
+ config_path = Path.home() / ".hermes" / "config.yaml"
1409
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1410
+
1411
+ existing: dict = {}
1412
+ if config_path.exists():
1413
+ import contextlib
1414
+ with contextlib.suppress(yaml.YAMLError, OSError):
1415
+ raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
1416
+ if isinstance(raw, dict):
1417
+ existing = raw
1418
+
1419
+ # Omit --project when no explicit project was given, so the server
1420
+ # dynamically uses whatever directory the host runs it from.
1421
+ effective_dir = None if project_dir == "." else project_dir
1422
+ servers = existing.setdefault("mcp_servers", {})
1423
+ entry = _build_server_entry(effective_dir)
1424
+ servers["attocode-code-intel"] = {
1425
+ "command": entry["command"],
1426
+ "args": entry["args"],
1427
+ }
1428
+
1429
+ config_path.write_text(
1430
+ yaml.safe_dump(existing, default_flow_style=False, sort_keys=False),
1431
+ encoding="utf-8",
1432
+ )
1433
+ print(f"Installed attocode-code-intel into {config_path}")
1434
+ return True
1435
+
1436
+
1437
+ def uninstall_hermes() -> bool:
1438
+ """Uninstall from Hermes Agent."""
1439
+ import yaml
1440
+
1441
+ config_path = Path.home() / ".hermes" / "config.yaml"
1442
+
1443
+ if not config_path.exists():
1444
+ print(f"No config found at {config_path}")
1445
+ return True
1446
+
1447
+ try:
1448
+ raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
1449
+ if not isinstance(raw, dict):
1450
+ return True
1451
+ except (yaml.YAMLError, OSError):
1452
+ return True
1453
+
1454
+ servers = raw.get("mcp_servers", {})
1455
+ if "attocode-code-intel" in servers:
1456
+ del servers["attocode-code-intel"]
1457
+ config_path.write_text(
1458
+ yaml.safe_dump(raw, default_flow_style=False, sort_keys=False),
1459
+ encoding="utf-8",
1460
+ )
1461
+ print(f"Removed attocode-code-intel from {config_path}")
1462
+
1463
+ return True
1464
+
1465
+
1466
+ # ---------------------------------------------------------------------------
1467
+ # Goose (Block) — ~/.config/goose/config.yaml, key "extensions"
1468
+ # Uses different schema: cmd, envs, type, name, timeout
1469
+ # ---------------------------------------------------------------------------
1470
+
1471
+
1472
+ def _build_goose_extension_entry(project_dir: str | None) -> dict:
1473
+ """Build a Goose extension entry (different schema from standard MCP)."""
1474
+ entry = _build_server_entry(project_dir)
1475
+ cmd_str = entry["command"]
1476
+ if entry["args"]:
1477
+ cmd_str += " " + " ".join(entry["args"])
1478
+ return {
1479
+ "name": "attocode-code-intel",
1480
+ "type": "stdio",
1481
+ "cmd": cmd_str,
1482
+ "envs": {},
1483
+ "timeout": 300,
1484
+ }
1485
+
1486
+
1487
+ def install_goose(project_dir: str = ".") -> bool:
1488
+ """Install into Goose (Block) via ``~/.config/goose/config.yaml``."""
1489
+ import yaml
1490
+
1491
+ config_path = Path(
1492
+ os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
1493
+ ) / "goose" / "config.yaml"
1494
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1495
+
1496
+ existing: dict = {}
1497
+ if config_path.exists():
1498
+ import contextlib
1499
+ with contextlib.suppress(yaml.YAMLError, OSError):
1500
+ raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
1501
+ if isinstance(raw, dict):
1502
+ existing = raw
1503
+
1504
+ extensions = existing.setdefault("extensions", [])
1505
+
1506
+ # Remove existing entry if present
1507
+ extensions[:] = [
1508
+ ext for ext in extensions
1509
+ if not (isinstance(ext, dict) and ext.get("name") == "attocode-code-intel")
1510
+ ]
1511
+ # Omit --project when no explicit project was given, so the server
1512
+ # dynamically uses whatever directory the host runs it from.
1513
+ effective_dir = None if project_dir == "." else project_dir
1514
+ extensions.append(_build_goose_extension_entry(effective_dir))
1515
+
1516
+ config_path.write_text(
1517
+ yaml.safe_dump(existing, default_flow_style=False, sort_keys=False),
1518
+ encoding="utf-8",
1519
+ )
1520
+ print(f"Installed attocode-code-intel into {config_path}")
1521
+ return True
1522
+
1523
+
1524
+ def uninstall_goose() -> bool:
1525
+ """Uninstall from Goose."""
1526
+ import yaml
1527
+
1528
+ config_path = Path(
1529
+ os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
1530
+ ) / "goose" / "config.yaml"
1531
+
1532
+ if not config_path.exists():
1533
+ print(f"No config found at {config_path}")
1534
+ return True
1535
+
1536
+ try:
1537
+ raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
1538
+ if not isinstance(raw, dict):
1539
+ return True
1540
+ except (yaml.YAMLError, OSError):
1541
+ return True
1542
+
1543
+ extensions = raw.get("extensions", [])
1544
+ filtered = [
1545
+ ext for ext in extensions
1546
+ if not (isinstance(ext, dict) and ext.get("name") == "attocode-code-intel")
1547
+ ]
1548
+ if len(filtered) < len(extensions):
1549
+ raw["extensions"] = filtered
1550
+ config_path.write_text(
1551
+ yaml.safe_dump(raw, default_flow_style=False, sort_keys=False),
1552
+ encoding="utf-8",
1553
+ )
1554
+ print(f"Removed attocode-code-intel from {config_path}")
1555
+
1556
+ return True
1557
+
1558
+
1559
+ # ---------------------------------------------------------------------------
1560
+ # GSD — .gsd/mcp.json (project) or ~/.gsd/mcp.json (global)
1561
+ # ---------------------------------------------------------------------------
1562
+
1563
+
1564
+ def install_gsd(project_dir: str = ".", scope: str = "local") -> bool:
1565
+ """Install into GSD via ``mcp.json``.
1566
+
1567
+ Args:
1568
+ project_dir: Path to the project to index.
1569
+ scope: "local" (project) or "user" (global ~/.gsd/).
1570
+ """
1571
+ if scope == "user":
1572
+ config_path = Path.home() / ".gsd" / "mcp.json"
1573
+ else:
1574
+ config_path = Path(project_dir) / ".gsd" / "mcp.json"
1575
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1576
+
1577
+ existing: dict = {}
1578
+ if config_path.exists():
1579
+ import contextlib
1580
+ with contextlib.suppress(json.JSONDecodeError, OSError):
1581
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1582
+
1583
+ effective_dir = None if project_dir == "." and scope == "user" else project_dir
1584
+ entry = _build_server_entry(effective_dir)
1585
+
1586
+ servers = existing.setdefault("servers", {})
1587
+ servers["attocode-code-intel"] = entry
1588
+
1589
+ config_path.write_text(
1590
+ json.dumps(existing, indent=2) + "\n",
1591
+ encoding="utf-8",
1592
+ )
1593
+ print(f"Installed attocode-code-intel into {config_path}")
1594
+ return True
1595
+
1596
+
1597
+ def uninstall_gsd(project_dir: str = ".", scope: str = "local") -> bool:
1598
+ """Uninstall from GSD."""
1599
+ if scope == "user":
1600
+ config_path = Path.home() / ".gsd" / "mcp.json"
1601
+ else:
1602
+ config_path = Path(project_dir) / ".gsd" / "mcp.json"
1603
+
1604
+ if not config_path.exists():
1605
+ print(f"No config found at {config_path}")
1606
+ return True
1607
+
1608
+ try:
1609
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
1610
+ except (json.JSONDecodeError, OSError):
1611
+ return True
1612
+
1613
+ servers = existing.get("servers", {})
1614
+ if "attocode-code-intel" in servers:
1615
+ del servers["attocode-code-intel"]
1616
+ config_path.write_text(
1617
+ json.dumps(existing, indent=2) + "\n",
1618
+ encoding="utf-8",
1619
+ )
1620
+ print(f"Removed attocode-code-intel from {config_path}")
1621
+
1622
+ return True
1623
+
1624
+
1625
+ # ---------------------------------------------------------------------------
1626
+ # Manual instruction targets (IntelliJ only)
1627
+ # ---------------------------------------------------------------------------
1628
+
1629
+ _MANUAL_INSTRUCTIONS: dict[str, str] = {
1630
+ "intellij": (
1631
+ "IntelliJ IDEA does not support file-based MCP configuration.\n"
1632
+ "\n"
1633
+ "To set up attocode-code-intel in IntelliJ:\n"
1634
+ " 1. Open Settings > Tools > AI Assistant > MCP Servers\n"
1635
+ " 2. Click '+' to add a new server\n"
1636
+ " 3. Set transport to 'stdio'\n"
1637
+ " 4. Set command to: {command}\n"
1638
+ " 5. Set arguments to: {args}\n"
1639
+ " 6. Click OK and restart the AI Assistant\n"
1640
+ ),
1641
+ }
1642
+
1643
+
1644
+ def print_manual_instructions(target: str, project_dir: str = ".") -> bool:
1645
+ """Print manual setup instructions for targets without file-based config.
1646
+
1647
+ Returns True (always succeeds — just prints instructions).
1648
+ """
1649
+ template = _MANUAL_INSTRUCTIONS.get(target)
1650
+ if template is None:
1651
+ print(f"Error: No manual instructions for '{target}'", file=sys.stderr)
1652
+ return False
1653
+
1654
+ entry = _build_server_entry(project_dir)
1655
+ print(
1656
+ template.format(
1657
+ command=entry["command"],
1658
+ args=" ".join(entry["args"]),
1659
+ args_json=json.dumps(entry["args"]),
1660
+ )
1661
+ )
1662
+ return True
1663
+
1664
+
1665
+ # ---------------------------------------------------------------------------
1666
+ # Dispatch
1667
+ # ---------------------------------------------------------------------------
1668
+
1669
+
1670
+ #: Targets handled by the generic ``install_json_config()`` function.
1671
+ _JSON_CONFIG_TARGETS = ("cursor", "windsurf", "vscode", "roo-code", "trae", "kiro", "firebase", "continue")
1672
+
1673
+
1674
+ def install(target: str, project_dir: str = ".", scope: str = "local") -> bool:
1675
+ """Install into the given target.
1676
+
1677
+ Args:
1678
+ target: One of :data:`ALL_TARGETS`.
1679
+ project_dir: Path to the project to index.
1680
+ scope: For Claude/Codex/Zed/Gemini/Junie/Amp/OpenCode: "local" or "user".
1681
+ Ignored for others.
1682
+ """
1683
+ if target == "claude":
1684
+ return install_claude(project_dir, scope=scope)
1685
+ elif target in _JSON_CONFIG_TARGETS:
1686
+ return install_json_config(target, project_dir)
1687
+ elif target == "codex":
1688
+ return install_codex(project_dir, scope=scope)
1689
+ elif target == "claude-desktop":
1690
+ return install_claude_desktop(project_dir)
1691
+ elif target == "cline":
1692
+ return install_cline(project_dir)
1693
+ elif target == "zed":
1694
+ return install_zed(project_dir, scope=scope)
1695
+ elif target == "opencode":
1696
+ return install_opencode(project_dir, scope=scope)
1697
+ elif target == "gemini-cli":
1698
+ return install_gemini(project_dir, scope=scope)
1699
+ elif target == "amazon-q":
1700
+ return install_amazonq(project_dir)
1701
+ elif target == "copilot-cli":
1702
+ return install_copilot_cli(project_dir)
1703
+ elif target == "junie":
1704
+ return install_junie(project_dir, scope=scope)
1705
+ elif target == "amp":
1706
+ return install_amp(project_dir, scope=scope)
1707
+ elif target == "hermes":
1708
+ return install_hermes(project_dir)
1709
+ elif target == "goose":
1710
+ return install_goose(project_dir)
1711
+ elif target == "gsd":
1712
+ return install_gsd(project_dir, scope=scope)
1713
+ elif target in MANUAL_TARGETS:
1714
+ return print_manual_instructions(target, project_dir)
1715
+ else:
1716
+ print(
1717
+ f"Error: Unknown target '{target}'. Use: {ALL_TARGETS_STR}",
1718
+ file=sys.stderr,
1719
+ )
1720
+ return False
1721
+
1722
+
1723
+ def uninstall(target: str, project_dir: str = ".", scope: str = "local") -> bool:
1724
+ """Uninstall from the given target."""
1725
+ if target == "claude":
1726
+ return uninstall_claude(scope=scope)
1727
+ elif target in _JSON_CONFIG_TARGETS:
1728
+ return uninstall_json_config(target, project_dir)
1729
+ elif target == "codex":
1730
+ return uninstall_codex(project_dir, scope=scope)
1731
+ elif target == "claude-desktop":
1732
+ return uninstall_claude_desktop()
1733
+ elif target == "cline":
1734
+ return uninstall_cline()
1735
+ elif target == "zed":
1736
+ return uninstall_zed(project_dir, scope=scope)
1737
+ elif target == "opencode":
1738
+ return uninstall_opencode()
1739
+ elif target == "gemini-cli":
1740
+ return uninstall_gemini(project_dir, scope=scope)
1741
+ elif target == "amazon-q":
1742
+ return uninstall_amazonq()
1743
+ elif target == "copilot-cli":
1744
+ return uninstall_copilot_cli()
1745
+ elif target == "junie":
1746
+ return uninstall_junie(project_dir, scope=scope)
1747
+ elif target == "amp":
1748
+ return uninstall_amp(project_dir, scope=scope)
1749
+ elif target == "hermes":
1750
+ return uninstall_hermes()
1751
+ elif target == "goose":
1752
+ return uninstall_goose()
1753
+ elif target == "gsd":
1754
+ return uninstall_gsd(project_dir, scope=scope)
1755
+ elif target in MANUAL_TARGETS:
1756
+ print(f"Nothing to uninstall — {target} uses manual configuration.")
1757
+ return True
1758
+ else:
1759
+ print(
1760
+ f"Error: Unknown target '{target}'. Use: {ALL_TARGETS_STR}",
1761
+ file=sys.stderr,
1762
+ )
1763
+ return False
1764
+
1765
+
1766
+ # ---------------------------------------------------------------------------
1767
+ # Hooks installation (Claude Code PostToolUse)
1768
+ # ---------------------------------------------------------------------------
1769
+
1770
+ #: The hook matcher pattern — matches file-editing tools.
1771
+ _HOOK_MATCHER = "Edit|Write|NotebookEdit"
1772
+
1773
+ #: Tag used to identify our hooks in settings.local.json.
1774
+ _HOOK_TAG = "attocode-code-intel"
1775
+
1776
+
1777
+ def _build_hook_config() -> dict:
1778
+ """Build the Claude Code PostToolUse hook configuration."""
1779
+ return {
1780
+ "matcher": _HOOK_MATCHER,
1781
+ "hooks": [
1782
+ {
1783
+ "type": "command",
1784
+ "command": "attocode-code-intel notify --stdin",
1785
+ },
1786
+ ],
1787
+ }
1788
+
1789
+
1790
+ def install_hooks(target: str, project_dir: str = ".") -> bool:
1791
+ """Install PostToolUse hooks for automatic index updates.
1792
+
1793
+ Currently only supported for Claude Code (``claude`` target).
1794
+ Writes hooks to ``.claude/settings.local.json``.
1795
+
1796
+ Args:
1797
+ target: Installation target (only "claude" is supported).
1798
+ project_dir: Project directory.
1799
+
1800
+ Returns:
1801
+ True if hooks were installed.
1802
+ """
1803
+ if target != "claude":
1804
+ print(
1805
+ f"Hooks not supported for {target}. "
1806
+ "The file watcher handles automatic updates. "
1807
+ "Alternatively, have your agent call the `notify_file_changed` "
1808
+ "MCP tool after edits.",
1809
+ )
1810
+ return False
1811
+
1812
+ settings_path = Path(project_dir) / ".claude" / "settings.local.json"
1813
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
1814
+
1815
+ existing: dict = {}
1816
+ if settings_path.exists():
1817
+ import contextlib
1818
+ with contextlib.suppress(json.JSONDecodeError, OSError):
1819
+ existing = json.loads(settings_path.read_text(encoding="utf-8"))
1820
+
1821
+ hooks = existing.setdefault("hooks", {})
1822
+ post_tool_use = hooks.setdefault("PostToolUse", [])
1823
+
1824
+ # Remove any existing attocode hooks (handles old formats too)
1825
+ filtered = [
1826
+ entry for entry in post_tool_use
1827
+ if not any(_HOOK_TAG in h.get("command", "") for h in entry.get("hooks", []))
1828
+ ]
1829
+
1830
+ expected = _build_hook_config()
1831
+
1832
+ # Already up-to-date?
1833
+ if len(filtered) < len(post_tool_use) and expected in post_tool_use:
1834
+ print("Hooks already installed in .claude/settings.local.json")
1835
+ return True
1836
+
1837
+ hooks["PostToolUse"] = filtered + [expected]
1838
+
1839
+ settings_path.write_text(
1840
+ json.dumps(existing, indent=2) + "\n",
1841
+ encoding="utf-8",
1842
+ )
1843
+ print(f"Installed PostToolUse hooks in {settings_path}")
1844
+ return True
1845
+
1846
+
1847
+ def uninstall_hooks(target: str, project_dir: str = ".") -> bool:
1848
+ """Remove attocode PostToolUse hooks.
1849
+
1850
+ Args:
1851
+ target: Installation target (only "claude" is supported).
1852
+ project_dir: Project directory.
1853
+
1854
+ Returns:
1855
+ True if hooks were removed (or didn't exist).
1856
+ """
1857
+ if target != "claude":
1858
+ return True
1859
+
1860
+ settings_path = Path(project_dir) / ".claude" / "settings.local.json"
1861
+ if not settings_path.exists():
1862
+ return True
1863
+
1864
+ try:
1865
+ existing = json.loads(settings_path.read_text(encoding="utf-8"))
1866
+ except (json.JSONDecodeError, OSError):
1867
+ return True
1868
+
1869
+ hooks = existing.get("hooks", {})
1870
+ post_tool_use = hooks.get("PostToolUse", [])
1871
+ if not post_tool_use:
1872
+ return True
1873
+
1874
+ # Remove entries that reference attocode-code-intel
1875
+ filtered = []
1876
+ removed = False
1877
+ for entry in post_tool_use:
1878
+ is_ours = False
1879
+ for h in entry.get("hooks", []):
1880
+ if _HOOK_TAG in h.get("command", ""):
1881
+ is_ours = True
1882
+ break
1883
+ if is_ours:
1884
+ removed = True
1885
+ else:
1886
+ filtered.append(entry)
1887
+
1888
+ if removed:
1889
+ hooks["PostToolUse"] = filtered
1890
+ settings_path.write_text(
1891
+ json.dumps(existing, indent=2) + "\n",
1892
+ encoding="utf-8",
1893
+ )
1894
+ print(f"Removed attocode hooks from {settings_path}")
1895
+
1896
+ return True