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
attocode_intel/cli.py ADDED
@@ -0,0 +1,3062 @@
1
+ """CLI subcommands for attocode code-intel.
2
+
3
+ Dispatched from the main CLI when the first positional argument is "code-intel".
4
+
5
+ Usage::
6
+
7
+ attocode code-intel install claude [--global] [--project .]
8
+ attocode code-intel install codex [--global] [--project .]
9
+ attocode code-intel uninstall claude
10
+ attocode code-intel serve [--project .]
11
+ attocode code-intel status
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import sys
19
+
20
+
21
+ def dispatch_code_intel(parts: tuple[str, ...] | list[str], *, debug: bool = False) -> None:
22
+ """Dispatch code-intel subcommands.
23
+
24
+ Args:
25
+ parts: Remaining CLI arguments after "code-intel".
26
+ debug: Whether debug mode is enabled.
27
+ """
28
+ args = list(parts)
29
+
30
+ if not args or args[0] in ("--help", "-h", "help", "commands"):
31
+ _print_help()
32
+ return
33
+
34
+ cmd = args[0]
35
+
36
+ if cmd == "install":
37
+ _cmd_install(args[1:])
38
+ elif cmd == "uninstall":
39
+ _cmd_uninstall(args[1:])
40
+ elif cmd == "serve":
41
+ _cmd_serve(args[1:], debug=debug)
42
+ elif cmd == "status":
43
+ _cmd_status()
44
+ elif cmd == "probe-install":
45
+ _cmd_probe_install(args[1:])
46
+ elif cmd == "notify":
47
+ _cmd_notify(args[1:])
48
+ elif cmd == "index":
49
+ _cmd_index(args[1:])
50
+ elif cmd == "connect":
51
+ _cmd_connect(args[1:])
52
+ elif cmd == "test-connection":
53
+ _cmd_test_connection(args[1:])
54
+ elif cmd == "watch":
55
+ _cmd_watch(args[1:])
56
+ elif cmd == "setup":
57
+ _cmd_setup(args[1:])
58
+ elif cmd == "query":
59
+ _cmd_query(args[1:])
60
+ elif cmd == "symbols":
61
+ _cmd_symbols(args[1:])
62
+ elif cmd == "impact":
63
+ _cmd_impact(args[1:])
64
+ elif cmd == "hotspots":
65
+ _cmd_hotspots(args[1:])
66
+ elif cmd == "deps":
67
+ _cmd_deps(args[1:])
68
+ elif cmd == "gc":
69
+ _cmd_gc(args[1:])
70
+ elif cmd == "verify":
71
+ _cmd_verify(args[1:])
72
+ elif cmd == "reindex":
73
+ _cmd_reindex(args[1:])
74
+ elif cmd == "bundle":
75
+ _cmd_bundle(args[1:])
76
+ else:
77
+ print(f"Unknown code-intel command: {cmd}", file=sys.stderr)
78
+ _print_help()
79
+ sys.exit(1)
80
+
81
+
82
+ def _print_help() -> None:
83
+ print(
84
+ "Usage: attocode-code-intel <command>\n"
85
+ "\n"
86
+ "Commands:\n"
87
+ " init Add the server to Codex, Claude Code or Cursor\n"
88
+ " doctor Start the configured server and check that it answers\n"
89
+ " install <target> Install MCP server into a coding assistant\n"
90
+ " uninstall <target> Remove MCP server from a coding assistant\n"
91
+ " connect Connect project to a running code-intel server\n"
92
+ " serve Run MCP server directly (stdio or SSE)\n"
93
+ " index Build or check embedding index for semantic search\n"
94
+ " status Check installation status across all targets\n"
95
+ " probe-install Run a runtime MCP probe for an installed target\n"
96
+ " notify Notify server about changed files (for hooks)\n"
97
+ " test-connection Verify connectivity to the remote server\n"
98
+ " watch Watch filesystem for changes and notify remote server\n"
99
+ " setup [Dev] Bootstrap local infrastructure for development\n"
100
+ "\n"
101
+ "Query commands:\n"
102
+ " query <text> Semantic search across the codebase\n"
103
+ " symbols <file> List symbols in a file (or --search <name>)\n"
104
+ " impact <file> ... Show blast radius of file changes\n"
105
+ " hotspots Show risk/complexity hotspots\n"
106
+ " deps <file> Show file dependencies and dependents\n"
107
+ "\n"
108
+ "Maintenance commands:\n"
109
+ " bundle export Export local code-intel artifacts into a bundle\n"
110
+ " bundle inspect Inspect bundle metadata and artifacts\n"
111
+ " gc Run garbage collection (orphaned embeddings + content)\n"
112
+ " verify Run integrity checks on the index\n"
113
+ " reindex Force a full reindex of the project\n"
114
+ "\n"
115
+ "Targets (auto-install):\n"
116
+ " claude Claude Code (via `claude mcp add` CLI)\n"
117
+ " cursor Cursor (.cursor/mcp.json)\n"
118
+ " windsurf Windsurf (.windsurf/mcp.json)\n"
119
+ " vscode VS Code / GitHub Copilot (.vscode/mcp.json)\n"
120
+ " codex OpenAI Codex CLI (.codex/config.toml)\n"
121
+ " claude-desktop Claude Desktop (platform-specific config)\n"
122
+ " cline Cline VS Code extension (globalStorage)\n"
123
+ " zed Zed (.zed/settings.json)\n"
124
+ " opencode OpenCode (~/.config/opencode/config.json)\n"
125
+ " gemini-cli Gemini CLI (.gemini/settings.json)\n"
126
+ " roo-code Roo Code (.roo/mcp.json)\n"
127
+ " amazon-q Amazon Q Developer (~/.aws/amazonq/mcp.json)\n"
128
+ " copilot-cli GitHub Copilot CLI (~/.copilot/mcp-config.json)\n"
129
+ " junie Junie / JetBrains (.junie/mcp/mcp.json)\n"
130
+ " kiro Kiro (.kiro/settings/mcp.json)\n"
131
+ " trae Trae (.trae/mcp.json)\n"
132
+ " firebase Firebase Studio (.idx/mcp.json)\n"
133
+ " amp Amp / Sourcegraph (.amp/settings.json)\n"
134
+ " continue Continue.dev (.continue/mcp.json)\n"
135
+ " hermes Hermes Agent (~/.hermes/config.yaml)\n"
136
+ " goose Goose (~/.config/goose/config.yaml)\n"
137
+ "\n"
138
+ "Targets (manual instructions):\n"
139
+ " intellij IntelliJ IDEA (prints setup steps)\n"
140
+ "\n"
141
+ "Options:\n"
142
+ " --project <path> Project directory to index (default: .)\n"
143
+ " --global Install globally (Claude, Codex, Zed, Gemini, Junie, Amp)\n"
144
+ " --hooks Also install PostToolUse hooks (Claude Code)\n"
145
+ "\n"
146
+ "Probe options:\n"
147
+ " probe-install <t> Probe an installed file-based target by launching MCP stdio\n"
148
+ " --project <path> Project directory to substitute for ${workspaceFolder}\n"
149
+ " --global Read the user/global config when the target supports it\n"
150
+ "\n"
151
+ "Serve options:\n"
152
+ " --transport <type> Transport protocol: stdio (default), sse, or http\n"
153
+ " --host <addr> Server host address (default: 127.0.0.1)\n"
154
+ " --port <num> Server port number (default: 8080)\n"
155
+ " --no-watch Disable filesystem watcher (watcher is on by default)\n"
156
+ " --watch-debounce N File-change debounce in milliseconds (default: 500)\n"
157
+ " --local-only Disable remote auto-load from .attocode/config.toml\n"
158
+ "\n"
159
+ "Notify options:\n"
160
+ " --file <path> File that changed (repeatable)\n"
161
+ " --stdin Read changed file paths from stdin (JSON or lines)\n"
162
+ "\n"
163
+ "Connect options:\n"
164
+ " --server <url> Remote server URL (e.g. https://code.example.com)\n"
165
+ " --token <token> JWT or API key (skip interactive login)\n"
166
+ " --email <email> Email for register/login (skip prompt)\n"
167
+ " --password <pass> Password for register/login (skip prompt)\n"
168
+ " --org <slug-or-id> Organization slug or ID (skip selection)\n"
169
+ " --repo <repo_id> Repository UUID on the remote server\n"
170
+ " --name <repo-name> Override auto-detected repository name\n"
171
+ "\n"
172
+ "Watch options:\n"
173
+ " --debounce <ms> Debounce interval in milliseconds (default: 500)\n"
174
+ "\n"
175
+ "Setup options (dev):\n"
176
+ " --reset Wipe Docker volumes and dev state, then re-bootstrap\n"
177
+ " --skip-deps Skip uv sync (use if deps already installed)\n"
178
+ " --project <path> Project directory (default: .)\n"
179
+ "\n"
180
+ "Query options:\n"
181
+ " --top <N> Number of results (query: default 10, hotspots: default 15)\n"
182
+ " --filter <glob> File filter glob for semantic search (e.g. '*.py')\n"
183
+ " --search <name> Search for symbol by name (symbols command)\n"
184
+ "\n"
185
+ "Bundle options:\n"
186
+ " bundle export --output <bundle.tar.gz> [--project <path>]\n"
187
+ " bundle inspect <bundle.tar.gz>\n"
188
+ )
189
+
190
+
191
+ def _parse_opts(args: list[str]) -> tuple[str | None, str, str, bool]:
192
+ """Parse target, --project, --global, and --hooks from args.
193
+
194
+ Returns:
195
+ (target, project_dir, scope, hooks)
196
+ """
197
+ target = None
198
+ project_dir = "."
199
+ scope = "local"
200
+ hooks = False
201
+
202
+ i = 0
203
+ while i < len(args):
204
+ arg = args[i]
205
+ if arg == "--project" and i + 1 < len(args):
206
+ project_dir = args[i + 1]
207
+ i += 2
208
+ elif arg.startswith("--project="):
209
+ project_dir = arg.split("=", 1)[1]
210
+ i += 1
211
+ elif arg == "--global":
212
+ scope = "user"
213
+ i += 1
214
+ elif arg == "--hooks":
215
+ hooks = True
216
+ i += 1
217
+ elif not arg.startswith("-") and target is None:
218
+ target = arg
219
+ i += 1
220
+ else:
221
+ i += 1
222
+
223
+ return target, project_dir, scope, hooks
224
+
225
+
226
+ def _args_include_project_flag(args: list[str]) -> bool:
227
+ return any(arg == "--project" or arg.startswith("--project=") for arg in args)
228
+
229
+
230
+ def _cmd_install(args: list[str]) -> None:
231
+ from attocode_intel.installer import ALL_TARGETS_STR, install, install_hooks
232
+
233
+ target, project_dir, scope, hooks = _parse_opts(args)
234
+ if not target:
235
+ print(f"Error: specify a target ({ALL_TARGETS_STR})", file=sys.stderr)
236
+ sys.exit(1)
237
+
238
+ success = install(target, project_dir=project_dir, scope=scope)
239
+ if not success:
240
+ sys.exit(1)
241
+
242
+ if hooks:
243
+ install_hooks(target, project_dir=project_dir)
244
+
245
+
246
+ def _cmd_uninstall(args: list[str]) -> None:
247
+ from attocode_intel.installer import ALL_TARGETS_STR, uninstall, uninstall_hooks
248
+
249
+ target, project_dir, scope, _hooks = _parse_opts(args)
250
+ if not target:
251
+ print(f"Error: specify a target ({ALL_TARGETS_STR})", file=sys.stderr)
252
+ sys.exit(1)
253
+
254
+ # Always attempt to remove hooks on uninstall
255
+ uninstall_hooks(target, project_dir=project_dir)
256
+
257
+ success = uninstall(target, project_dir=project_dir, scope=scope)
258
+ if not success:
259
+ sys.exit(1)
260
+
261
+
262
+ def _cmd_serve(args: list[str], *, debug: bool = False) -> None:
263
+ _, project_dir, _, _ = _parse_opts(args)
264
+
265
+ # Parse transport flags and watcher options
266
+ transport = "stdio"
267
+ host = "127.0.0.1"
268
+ port = 8080
269
+ no_watch = False
270
+ watch_debounce = 500
271
+ local_only = False
272
+ i = 0
273
+ while i < len(args):
274
+ arg = args[i]
275
+ if arg == "--transport" and i + 1 < len(args):
276
+ transport = args[i + 1]
277
+ i += 2
278
+ elif arg.startswith("--transport="):
279
+ transport = arg.split("=", 1)[1]
280
+ i += 1
281
+ elif arg == "--host" and i + 1 < len(args):
282
+ host = args[i + 1]
283
+ i += 2
284
+ elif arg.startswith("--host="):
285
+ host = arg.split("=", 1)[1]
286
+ i += 1
287
+ elif arg == "--port" and i + 1 < len(args):
288
+ port = int(args[i + 1])
289
+ i += 2
290
+ elif arg.startswith("--port="):
291
+ port = int(arg.split("=", 1)[1])
292
+ i += 1
293
+ elif arg == "--no-watch":
294
+ no_watch = True
295
+ i += 1
296
+ elif arg == "--watch-debounce" and i + 1 < len(args):
297
+ watch_debounce = int(args[i + 1])
298
+ i += 2
299
+ elif arg.startswith("--watch-debounce="):
300
+ watch_debounce = int(arg.split("=", 1)[1])
301
+ i += 1
302
+ elif arg == "--local-only":
303
+ local_only = True
304
+ i += 1
305
+ else:
306
+ i += 1
307
+
308
+ abs_dir = os.path.abspath(project_dir)
309
+ os.environ["ATTOCODE_PROJECT_DIR"] = abs_dir
310
+
311
+ # Remote config handling — shared with server.py main() so both startup
312
+ # paths behave identically (config + env, health/expiry-gated fallback).
313
+ from attocode_intel._shared import enable_remote_if_configured
314
+ if local_only:
315
+ os.environ["ATTOCODE_LOCAL_ONLY"] = "1"
316
+ enable_remote_if_configured(abs_dir, local_only=local_only)
317
+
318
+ if debug:
319
+ import logging
320
+ logging.basicConfig(level=logging.DEBUG)
321
+
322
+ from attocode_intel.server import (
323
+ _start_file_watcher,
324
+ _start_queue_poller,
325
+ _stop_file_watcher,
326
+ mcp,
327
+ )
328
+
329
+ print(f"Starting attocode-code-intel server for {abs_dir} (transport={transport})", file=sys.stderr)
330
+
331
+ # Start file watcher and notification queue poller for stdio/sse transports.
332
+ # (HTTP transport manages its own lifecycle via the FastAPI app.)
333
+ if transport != "http" and not no_watch:
334
+ _start_file_watcher(abs_dir, debounce_ms=watch_debounce)
335
+ _start_queue_poller(abs_dir)
336
+
337
+ try:
338
+ if transport == "http":
339
+ _serve_http(abs_dir, host=host, port=port, debug=debug)
340
+ elif transport == "sse":
341
+ mcp.run(transport="sse", host=host, port=port)
342
+ else:
343
+ mcp.run(transport="stdio")
344
+ finally:
345
+ if transport != "http" and not no_watch:
346
+ _stop_file_watcher()
347
+
348
+
349
+ def _serve_http(project_dir: str, *, host: str, port: int, debug: bool) -> None:
350
+ """Start the FastAPI HTTP server."""
351
+ from attocode_intel.config import CodeIntelConfig, is_loopback
352
+
353
+ config = CodeIntelConfig.from_env()
354
+ # Service mode signs in its users. Local mode checks only ATTOCODE_API_KEY, and without it
355
+ # any computer that reaches the port can read the code index.
356
+ if not (is_loopback(host) or config.api_key or config.database_url):
357
+ print(
358
+ f"Error: --host {host} lets other computers read this code index. "
359
+ "Set ATTOCODE_API_KEY, or use --host 127.0.0.1.",
360
+ file=sys.stderr,
361
+ )
362
+ sys.exit(1)
363
+ try:
364
+ import uvicorn
365
+ except ImportError:
366
+ print(
367
+ "Error: the HTTP server needs the http extra. "
368
+ "Install it with: uv tool install 'attocode-code-intel[http]'",
369
+ file=sys.stderr,
370
+ )
371
+ sys.exit(1)
372
+
373
+ from attocode_intel.api.app import create_app
374
+
375
+ config.project_dir = project_dir
376
+ config.host = host
377
+ config.port = port
378
+ if debug:
379
+ config.log_level = "debug"
380
+ app = create_app(config)
381
+ uvicorn.run(app, host=host, port=port, log_level=config.log_level)
382
+
383
+
384
+ def _cmd_status() -> None:
385
+ """Check if the MCP server is installed in known targets."""
386
+ import json
387
+ from pathlib import Path
388
+
389
+ from attocode_intel.installer import _get_user_config_dir
390
+
391
+ print("attocode-code-intel status:\n")
392
+
393
+ # Check Claude Code
394
+ has_claude = shutil.which("claude") is not None
395
+ if has_claude:
396
+ try:
397
+ result = __import__("subprocess").run(
398
+ ["claude", "mcp", "list"],
399
+ capture_output=True, text=True, timeout=10,
400
+ )
401
+ if "attocode-code-intel" in result.stdout:
402
+ print(" Claude Code: installed")
403
+ else:
404
+ print(" Claude Code: not installed")
405
+ except Exception:
406
+ print(" Claude Code: unable to check")
407
+ else:
408
+ print(" Claude Code: CLI not found")
409
+
410
+ # Check JSON-config targets (Cursor, Windsurf, VS Code)
411
+ for name, config_dir in [("Cursor", ".cursor"), ("Windsurf", ".windsurf"), ("VS Code", ".vscode")]:
412
+ cfg = Path(config_dir) / "mcp.json"
413
+ if cfg.exists():
414
+ try:
415
+ data = json.loads(cfg.read_text())
416
+ if "attocode-code-intel" in data.get("mcpServers", {}):
417
+ print(f" {name}: installed")
418
+ else:
419
+ print(f" {name}: not installed")
420
+ except Exception:
421
+ print(f" {name}: unable to check")
422
+ else:
423
+ print(f" {name}: not installed")
424
+
425
+ # GSD — project `.gsd/mcp.json` and user `~/.gsd/mcp.json` use ``servers`` key
426
+ gsd_project = Path(".gsd") / "mcp.json"
427
+ gsd_user = Path.home() / ".gsd" / "mcp.json"
428
+ gsd_found = False
429
+ for gsd_path, label in [(gsd_project, "installed"), (gsd_user, "installed (user)")]:
430
+ if gsd_path.exists():
431
+ try:
432
+ data = json.loads(gsd_path.read_text())
433
+ if "attocode-code-intel" in data.get("servers", {}):
434
+ print(f" GSD: {label}")
435
+ gsd_found = True
436
+ break
437
+ except Exception:
438
+ print(" GSD: unable to check")
439
+ gsd_found = True
440
+ break
441
+ if not gsd_found:
442
+ print(" GSD: not installed")
443
+
444
+ # Check Codex (project-level)
445
+ codex_cfg = Path(".codex/config.toml")
446
+ if codex_cfg.exists():
447
+ try:
448
+ import tomllib
449
+ data = tomllib.loads(codex_cfg.read_text())
450
+ if "attocode-code-intel" in data.get("mcp_servers", {}):
451
+ print(" Codex: installed")
452
+ else:
453
+ print(" Codex: not installed")
454
+ except Exception:
455
+ print(" Codex: unable to check")
456
+ else:
457
+ # Also check user-level
458
+ codex_user_cfg = Path.home() / ".codex" / "config.toml"
459
+ if codex_user_cfg.exists():
460
+ try:
461
+ import tomllib
462
+ data = tomllib.loads(codex_user_cfg.read_text())
463
+ if "attocode-code-intel" in data.get("mcp_servers", {}):
464
+ print(" Codex: installed (user)")
465
+ else:
466
+ print(" Codex: not installed")
467
+ except Exception:
468
+ print(" Codex: unable to check")
469
+ else:
470
+ print(" Codex: not installed")
471
+
472
+ # Check Claude Desktop
473
+ claude_desktop_dir = _get_user_config_dir("claude-desktop")
474
+ if claude_desktop_dir is not None:
475
+ cfg = claude_desktop_dir / "claude_desktop_config.json"
476
+ if cfg.exists():
477
+ try:
478
+ data = json.loads(cfg.read_text())
479
+ if "attocode-code-intel" in data.get("mcpServers", {}):
480
+ print(" Claude Desktop: installed")
481
+ else:
482
+ print(" Claude Desktop: not installed")
483
+ except Exception:
484
+ print(" Claude Desktop: unable to check")
485
+ else:
486
+ print(" Claude Desktop: not installed")
487
+ else:
488
+ print(" Claude Desktop: not supported on this platform")
489
+
490
+ # Check Cline
491
+ cline_dir = _get_user_config_dir("cline")
492
+ if cline_dir is not None:
493
+ cfg = cline_dir / "cline_mcp_settings.json"
494
+ if cfg.exists():
495
+ try:
496
+ data = json.loads(cfg.read_text())
497
+ if "attocode-code-intel" in data.get("mcpServers", {}):
498
+ print(" Cline: installed")
499
+ else:
500
+ print(" Cline: not installed")
501
+ except Exception:
502
+ print(" Cline: unable to check")
503
+ else:
504
+ print(" Cline: not installed")
505
+ else:
506
+ print(" Cline: not supported on this platform")
507
+
508
+ # Check Zed (project-level, then user-level)
509
+ zed_local = Path(".zed/settings.json")
510
+ zed_user = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "zed" / "settings.json"
511
+ zed_found = False
512
+ for zed_path, label in [(zed_local, "installed"), (zed_user, "installed (user)")]:
513
+ if zed_path.exists():
514
+ try:
515
+ data = json.loads(zed_path.read_text())
516
+ if "attocode-code-intel" in data.get("context_servers", {}):
517
+ print(f" Zed: {label}")
518
+ zed_found = True
519
+ break
520
+ except Exception:
521
+ pass
522
+ if not zed_found:
523
+ print(" Zed: not installed")
524
+
525
+ # Check if entry point is available
526
+ from attocode_intel.installer import _find_command
527
+
528
+ print()
529
+ resolved = _find_command(".")
530
+ if resolved == "attocode-code-intel":
531
+ print(" Entry point: attocode-code-intel (on PATH)")
532
+ else:
533
+ print(f" Entry point: {resolved}")
534
+
535
+
536
+ def _cmd_probe_install(args: list[str]) -> None:
537
+ """Run a runtime MCP probe for a file-based installed target."""
538
+ from attocode_intel.installer import ALL_TARGETS_STR
539
+ from attocode_intel.probe import probe_install
540
+
541
+ target, project_dir, scope, _hooks = _parse_opts(args)
542
+ if not target:
543
+ print(f"Error: specify a target ({ALL_TARGETS_STR})", file=sys.stderr)
544
+ sys.exit(1)
545
+
546
+ exit_code = probe_install(
547
+ target,
548
+ project_dir=project_dir,
549
+ scope=scope,
550
+ force_project_probe=_args_include_project_flag(args),
551
+ )
552
+ if exit_code:
553
+ sys.exit(exit_code)
554
+
555
+
556
+ def _cmd_bundle(args: list[str]) -> None:
557
+ """Export or inspect local code-intel bundles."""
558
+ if not args or args[0] in {"-h", "--help", "help"}:
559
+ print(
560
+ "Usage:\n"
561
+ " attocode code-intel bundle export [--project <path>] [--output <bundle.tar.gz>]\n"
562
+ " attocode code-intel bundle inspect <bundle.tar.gz>\n"
563
+ )
564
+ return
565
+
566
+ subcmd = args[0]
567
+ if subcmd == "export":
568
+ from attocode_intel.bundle import export_bundle
569
+
570
+ _, project_dir, _, _ = _parse_opts(args[1:])
571
+ output_path = ""
572
+ tail = args[1:]
573
+ i = 0
574
+ while i < len(tail):
575
+ arg = tail[i]
576
+ if arg == "--output" and i + 1 < len(tail):
577
+ output_path = tail[i + 1]
578
+ i += 2
579
+ elif arg.startswith("--output="):
580
+ output_path = arg.split("=", 1)[1]
581
+ i += 1
582
+ else:
583
+ i += 1
584
+
585
+ if not output_path:
586
+ bundle_name = f"attocode-bundle-{os.path.basename(os.path.abspath(project_dir))}.tar.gz"
587
+ output_path = os.path.join(os.getcwd(), bundle_name)
588
+
589
+ destination = export_bundle(project_dir, output_path)
590
+ print(f"Bundle exported to {destination}")
591
+ return
592
+
593
+ if subcmd == "inspect":
594
+ import json as json_mod
595
+ import tarfile
596
+
597
+ from attocode_intel.bundle import inspect_bundle
598
+
599
+ bundle_path = ""
600
+ for arg in args[1:]:
601
+ if not arg.startswith("-"):
602
+ bundle_path = arg
603
+ break
604
+ if not bundle_path:
605
+ print("Error: specify a bundle path.", file=sys.stderr)
606
+ sys.exit(1)
607
+
608
+ try:
609
+ metadata = inspect_bundle(bundle_path)
610
+ except (FileNotFoundError, OSError, tarfile.TarError, KeyError, json_mod.JSONDecodeError) as exc:
611
+ print(f"Error: could not inspect bundle: {exc}", file=sys.stderr)
612
+ sys.exit(1)
613
+ print(f"Bundle: {os.path.abspath(bundle_path)}")
614
+ print(f" Schema version: {metadata.get('schema_version')}")
615
+ print(f" Created at: {metadata.get('created_at')}")
616
+ print(f" Project: {metadata.get('project_name')}")
617
+ print(f" Attocode version: {metadata.get('attocode_version')}")
618
+ print(" Artifacts:")
619
+ for artifact in metadata.get("artifacts", []):
620
+ status = "present" if artifact.get("present") else "missing"
621
+ print(
622
+ " "
623
+ f"{artifact.get('path')}: {status}, "
624
+ f"size={artifact.get('size_bytes')}, sha256={artifact.get('sha256')}"
625
+ )
626
+ return
627
+
628
+ print(f"Error: unknown bundle command '{subcmd}'", file=sys.stderr)
629
+ sys.exit(1)
630
+
631
+
632
+ def _cmd_notify(args: list[str]) -> None:
633
+ """Notify the server about changed files via the notification queue.
634
+
635
+ Writes file paths to .attocode/cache/file_changes for the server to pick up.
636
+ """
637
+ import json as json_mod
638
+ from pathlib import Path
639
+
640
+ _, project_dir, _, _ = _parse_opts(args)
641
+ project_dir = os.path.abspath(project_dir)
642
+
643
+ files: list[str] = []
644
+
645
+ # Parse --file flags
646
+ i = 0
647
+ while i < len(args):
648
+ if args[i] == "--file" and i + 1 < len(args):
649
+ files.append(args[i + 1])
650
+ i += 2
651
+ elif args[i].startswith("--file="):
652
+ files.append(args[i].split("=", 1)[1])
653
+ i += 1
654
+ elif args[i] == "--stdin":
655
+ # Read from stdin: support both raw lines and JSON with tool_input
656
+ for line in sys.stdin:
657
+ line = line.strip()
658
+ if not line:
659
+ continue
660
+ if line.startswith("{"):
661
+ try:
662
+ data = json_mod.loads(line)
663
+ fp = (
664
+ data.get("tool_input", {}).get("file_path")
665
+ or data.get("file_path")
666
+ )
667
+ if fp:
668
+ files.append(fp)
669
+ except json_mod.JSONDecodeError:
670
+ pass
671
+ else:
672
+ files.append(line)
673
+ i += 1
674
+ else:
675
+ i += 1
676
+
677
+ if not files:
678
+ print("No files specified. Use --file <path> or --stdin.", file=sys.stderr)
679
+ sys.exit(1)
680
+
681
+ # Resolve to relative paths and write to queue
682
+ queue_path = Path(project_dir) / ".attocode" / "cache" / "file_changes"
683
+ queue_path.parent.mkdir(parents=True, exist_ok=True)
684
+
685
+ rel_paths: list[str] = []
686
+ for f in files:
687
+ p = Path(f)
688
+ if p.is_absolute():
689
+ try:
690
+ rel = os.path.relpath(str(p), project_dir)
691
+ except ValueError:
692
+ rel = str(p)
693
+ else:
694
+ rel = str(p)
695
+ rel_paths.append(rel)
696
+
697
+ # Check if remote server is configured — POST instead of local queue
698
+ from attocode_intel.config import load_remote_config
699
+
700
+ remote_cfg = load_remote_config(project_dir)
701
+ if remote_cfg.is_configured:
702
+ _notify_remote(remote_cfg, rel_paths, project_dir)
703
+ return
704
+
705
+ # Fallback: local queue file for MCP server to pick up
706
+ try:
707
+ import fcntl
708
+ with queue_path.open("a", encoding="utf-8") as fh:
709
+ fcntl.flock(fh, fcntl.LOCK_EX)
710
+ for rp in rel_paths:
711
+ fh.write(rp + "\n")
712
+ except ImportError:
713
+ # Windows: fall back to non-locked append
714
+ with queue_path.open("a", encoding="utf-8") as fh:
715
+ for rp in rel_paths:
716
+ fh.write(rp + "\n")
717
+
718
+ print(f"Queued {len(rel_paths)} file(s) for index update.")
719
+
720
+
721
+ def _notify_remote(remote_cfg, rel_paths: list[str], project_dir: str) -> None:
722
+ """POST file changes to the remote server, including file contents."""
723
+ import base64
724
+ import subprocess
725
+
726
+ try:
727
+ import httpx
728
+ except ImportError:
729
+ print(
730
+ "Error: httpx not installed. Install with: pip install httpx",
731
+ file=sys.stderr,
732
+ )
733
+ sys.exit(1)
734
+
735
+ # Auto-detect branch from git
736
+ branch = "main"
737
+ try:
738
+ result = subprocess.run(
739
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
740
+ cwd=project_dir,
741
+ capture_output=True,
742
+ text=True,
743
+ timeout=5,
744
+ )
745
+ if result.returncode == 0:
746
+ branch = result.stdout.strip()
747
+ except Exception:
748
+ pass
749
+
750
+ # Read file contents and base64-encode them
751
+ files: dict[str, str] = {}
752
+ for rel_path in rel_paths:
753
+ full = os.path.join(project_dir, rel_path)
754
+ if os.path.isfile(full):
755
+ try:
756
+ with open(full, "rb") as f:
757
+ files[rel_path] = base64.b64encode(f.read()).decode("ascii")
758
+ except (OSError, PermissionError):
759
+ pass # deleted or unreadable — omit, server treats as deleted
760
+
761
+ server = remote_cfg.server.rstrip("/")
762
+ try:
763
+ resp = httpx.post(
764
+ f"{server}/api/v1/notify/file-changed",
765
+ json={
766
+ "paths": rel_paths,
767
+ "files": files,
768
+ "project": remote_cfg.repo_id,
769
+ "branch": branch,
770
+ },
771
+ headers={"Authorization": f"Bearer {remote_cfg.token}"},
772
+ timeout=30,
773
+ )
774
+ if resp.status_code in (200, 202):
775
+ data = resp.json()
776
+ print(f"Notified remote server: {data.get('accepted', len(rel_paths))} file(s) accepted.")
777
+ else:
778
+ print(
779
+ f"Remote server returned {resp.status_code}: {resp.text}",
780
+ file=sys.stderr,
781
+ )
782
+ sys.exit(1)
783
+ except httpx.ConnectError:
784
+ print(f"Error: cannot connect to {server}", file=sys.stderr)
785
+ sys.exit(1)
786
+ except httpx.TimeoutException:
787
+ print(f"Error: request to {server} timed out", file=sys.stderr)
788
+ sys.exit(1)
789
+
790
+
791
+ def _prompt(label: str, *, default: str = "", secret: bool = False) -> str:
792
+ """Prompt user for input. Uses getpass for secrets."""
793
+ if secret:
794
+ import getpass
795
+
796
+ suffix = f" [{default}]" if default else ""
797
+ val = getpass.getpass(f"{label}{suffix}: ")
798
+ return val or default
799
+ suffix = f" [{default}]" if default else ""
800
+ val = input(f"{label}{suffix}: ").strip()
801
+ return val or default
802
+
803
+
804
+ def _bootstrap_user_and_project(
805
+ api_base: str,
806
+ project_dir: str,
807
+ state_path: str,
808
+ *,
809
+ email: str = "",
810
+ password: str = "",
811
+ org_slug: str = "",
812
+ repo_name: str = "",
813
+ interactive: bool = True,
814
+ ) -> dict:
815
+ """Register/login, select org, add repo. Returns state dict.
816
+
817
+ Shared logic used by both ``connect`` (user-provided credentials) and
818
+ ``setup`` (hardcoded dev credentials).
819
+ """
820
+ import json
821
+ from pathlib import Path
822
+
823
+ try:
824
+ import httpx
825
+ except ImportError:
826
+ print("Error: httpx not installed. Install with: uv sync --extra service", file=sys.stderr)
827
+ sys.exit(1)
828
+
829
+ # Load existing state for idempotency
830
+ state: dict = {}
831
+ if os.path.isfile(state_path):
832
+ try:
833
+ state = json.loads(Path(state_path).read_text(encoding="utf-8"))
834
+ except Exception:
835
+ pass
836
+
837
+ # --- Auth: register or login ---
838
+ if not state.get("token"):
839
+ if not email:
840
+ if not interactive:
841
+ print("Error: --email required in non-interactive mode", file=sys.stderr)
842
+ sys.exit(1)
843
+ email = _prompt("Email")
844
+ if not password:
845
+ if not interactive:
846
+ print("Error: --password required in non-interactive mode", file=sys.stderr)
847
+ sys.exit(1)
848
+ password = _prompt("Password", secret=True)
849
+ if not email or not password:
850
+ print("Error: email and password are required", file=sys.stderr)
851
+ sys.exit(1)
852
+
853
+ # Try register first, fall back to login
854
+ resp = httpx.post(
855
+ f"{api_base}/api/v1/auth/register",
856
+ json={"email": email, "password": password, "name": email.split("@")[0]},
857
+ timeout=10,
858
+ )
859
+ if resp.status_code in (200, 201):
860
+ data = resp.json()
861
+ state["token"] = data["access_token"]
862
+ state["user_id"] = data.get("user_id", data.get("id", ""))
863
+ print("Registered new account.", file=sys.stderr)
864
+ elif resp.status_code == 409:
865
+ resp2 = httpx.post(
866
+ f"{api_base}/api/v1/auth/login",
867
+ json={"email": email, "password": password},
868
+ timeout=10,
869
+ )
870
+ if resp2.status_code == 200:
871
+ data = resp2.json()
872
+ state["token"] = data["access_token"]
873
+ state["user_id"] = data.get("user_id", data.get("id", ""))
874
+ print("Logged in.", file=sys.stderr)
875
+ else:
876
+ print(f"Error logging in: {resp2.status_code} {resp2.text}", file=sys.stderr)
877
+ sys.exit(1)
878
+ else:
879
+ print(f"Error registering: {resp.status_code} {resp.text}", file=sys.stderr)
880
+ sys.exit(1)
881
+
882
+ headers = {"Authorization": f"Bearer {state['token']}"}
883
+
884
+ # --- Org selection ---
885
+ if not state.get("org_id"):
886
+ resp = httpx.get(f"{api_base}/api/v1/orgs", headers=headers, timeout=10)
887
+ if resp.status_code != 200:
888
+ print(f"Error listing orgs: {resp.status_code}", file=sys.stderr)
889
+ sys.exit(1)
890
+
891
+ orgs_raw = resp.json()
892
+ orgs = orgs_raw if isinstance(orgs_raw, list) else (orgs_raw.get("organizations") or orgs_raw.get("items", []))
893
+
894
+ # Filter by slug if provided
895
+ if org_slug:
896
+ match = [o for o in orgs if o.get("slug") == org_slug or o.get("id") == org_slug]
897
+ if match:
898
+ state["org_id"] = match[0]["id"]
899
+ print(f"Using org: {match[0].get('name', org_slug)}", file=sys.stderr)
900
+ else:
901
+ # Create it
902
+ resp2 = httpx.post(
903
+ f"{api_base}/api/v1/orgs",
904
+ json={"name": org_slug, "slug": org_slug},
905
+ headers=headers,
906
+ timeout=10,
907
+ )
908
+ if resp2.status_code in (200, 201):
909
+ state["org_id"] = resp2.json()["id"]
910
+ print(f"Created org: {org_slug}", file=sys.stderr)
911
+ else:
912
+ print(f"Error creating org: {resp2.status_code} {resp2.text}", file=sys.stderr)
913
+ sys.exit(1)
914
+ elif len(orgs) == 1:
915
+ state["org_id"] = orgs[0]["id"]
916
+ print(f"Using org: {orgs[0].get('name', orgs[0]['id'])}", file=sys.stderr)
917
+ elif len(orgs) > 1:
918
+ if not interactive:
919
+ print("Error: multiple orgs found, specify --org", file=sys.stderr)
920
+ sys.exit(1)
921
+ print("\nAvailable organizations:", file=sys.stderr)
922
+ for idx, org in enumerate(orgs, 1):
923
+ print(f" {idx}. {org.get('name', '?')} ({org.get('slug', '')})", file=sys.stderr)
924
+ choice = _prompt("Select org number", default="1")
925
+ try:
926
+ state["org_id"] = orgs[int(choice) - 1]["id"]
927
+ except (ValueError, IndexError):
928
+ print("Invalid selection.", file=sys.stderr)
929
+ sys.exit(1)
930
+ else:
931
+ # No orgs — create one
932
+ if interactive:
933
+ slug = _prompt("No orgs found. Create one — slug", default="my-org")
934
+ else:
935
+ slug = org_slug or "default"
936
+ resp2 = httpx.post(
937
+ f"{api_base}/api/v1/orgs",
938
+ json={"name": slug, "slug": slug},
939
+ headers=headers,
940
+ timeout=10,
941
+ )
942
+ if resp2.status_code in (200, 201):
943
+ state["org_id"] = resp2.json()["id"]
944
+ print(f"Created org: {slug}", file=sys.stderr)
945
+ elif resp2.status_code == 409:
946
+ # Org exists but user isn't a member — look it up by slug
947
+ resp3 = httpx.get(
948
+ f"{api_base}/api/v1/orgs",
949
+ headers=headers,
950
+ params={"slug": slug},
951
+ timeout=10,
952
+ )
953
+ found = False
954
+ if resp3.status_code == 200:
955
+ orgs3 = resp3.json() if isinstance(resp3.json(), list) else resp3.json().get("items", [])
956
+ for o in orgs3:
957
+ if o.get("slug") == slug:
958
+ state["org_id"] = o["id"]
959
+ print(f"Joined existing org: {slug}", file=sys.stderr)
960
+ found = True
961
+ break
962
+ if not found:
963
+ # Last resort: parse the 409 response for org id, or just re-list all
964
+ resp4 = httpx.get(f"{api_base}/api/v1/orgs", headers=headers, timeout=10)
965
+ if resp4.status_code == 200:
966
+ orgs4 = resp4.json() if isinstance(resp4.json(), list) else resp4.json().get("items", [])
967
+ for o in orgs4:
968
+ if o.get("slug") == slug or o.get("name") == slug:
969
+ state["org_id"] = o["id"]
970
+ print(f"Using existing org: {slug}", file=sys.stderr)
971
+ found = True
972
+ break
973
+ if not found:
974
+ print(f"Error: org '{slug}' exists but could not resolve its ID.", file=sys.stderr)
975
+ sys.exit(1)
976
+ else:
977
+ print(f"Error creating org: {resp2.status_code} {resp2.text}", file=sys.stderr)
978
+ sys.exit(1)
979
+
980
+ # --- Repo selection ---
981
+ if not state.get("repo_id"):
982
+ resp = httpx.get(
983
+ f"{api_base}/api/v1/orgs/{state['org_id']}/repos",
984
+ headers=headers,
985
+ timeout=10,
986
+ )
987
+ if resp.status_code != 200:
988
+ print(f"Error listing repos: {resp.status_code}", file=sys.stderr)
989
+ sys.exit(1)
990
+
991
+ repos_raw = resp.json()
992
+ repos = repos_raw if isinstance(repos_raw, list) else (repos_raw.get("repositories") or repos_raw.get("items", []))
993
+
994
+ # Detect local git remote origin for matching and repo creation
995
+ local_clone_url = None
996
+ try:
997
+ import subprocess
998
+ origin = subprocess.run(
999
+ ["git", "remote", "get-url", "origin"],
1000
+ cwd=project_dir, capture_output=True, text=True, timeout=5,
1001
+ )
1002
+ if origin.returncode == 0 and origin.stdout.strip():
1003
+ local_clone_url = origin.stdout.strip()
1004
+ except Exception:
1005
+ pass
1006
+
1007
+ # Try auto-match by local_path
1008
+ for repo in repos:
1009
+ if repo.get("local_path") == project_dir:
1010
+ state["repo_id"] = repo["id"]
1011
+ print(f"Matched existing repo: {repo.get('name', repo['id'])}", file=sys.stderr)
1012
+ break
1013
+
1014
+ # Try auto-match by clone_url
1015
+ if not state.get("repo_id") and local_clone_url:
1016
+ for repo in repos:
1017
+ repo_url = repo.get("clone_url", "")
1018
+ if repo_url and _urls_match(local_clone_url, repo_url):
1019
+ state["repo_id"] = repo["id"]
1020
+ print(f"Matched existing repo by remote URL: {repo.get('name', repo['id'])}", file=sys.stderr)
1021
+ break
1022
+
1023
+ if not state.get("repo_id"):
1024
+ # Add current project as a new repo
1025
+ if not repo_name:
1026
+ repo_name = os.path.basename(project_dir)
1027
+ default_branch = _detect_default_branch(project_dir)
1028
+
1029
+ clone_url = local_clone_url
1030
+
1031
+ repo_payload: dict = {
1032
+ "name": repo_name,
1033
+ "local_path": project_dir,
1034
+ "default_branch": default_branch,
1035
+ }
1036
+ if clone_url:
1037
+ repo_payload["clone_url"] = clone_url
1038
+
1039
+ resp2 = httpx.post(
1040
+ f"{api_base}/api/v1/orgs/{state['org_id']}/repos",
1041
+ json=repo_payload,
1042
+ headers=headers,
1043
+ timeout=10,
1044
+ )
1045
+ if resp2.status_code in (200, 201):
1046
+ state["repo_id"] = resp2.json()["id"]
1047
+ print(f"Added repo: {repo_name} ({state['repo_id']})", file=sys.stderr)
1048
+ elif resp2.status_code == 409:
1049
+ # Already exists — search by name
1050
+ for repo in repos:
1051
+ if repo.get("name") == repo_name:
1052
+ state["repo_id"] = repo["id"]
1053
+ break
1054
+ if state.get("repo_id"):
1055
+ print(f"Using existing repo: {state['repo_id']}", file=sys.stderr)
1056
+ else:
1057
+ print("Error: repo conflict but could not find existing.", file=sys.stderr)
1058
+ sys.exit(1)
1059
+ else:
1060
+ print(f"Error adding repo: {resp2.status_code} {resp2.text}", file=sys.stderr)
1061
+ sys.exit(1)
1062
+
1063
+ # --- Save state ---
1064
+ Path(state_path).parent.mkdir(parents=True, exist_ok=True)
1065
+ Path(state_path).write_text(json.dumps(state, indent=2), encoding="utf-8")
1066
+
1067
+ return state
1068
+
1069
+
1070
+ def _urls_match(url_a: str, url_b: str) -> bool:
1071
+ """Compare git URLs, normalizing protocol and .git suffix."""
1072
+
1073
+ def _normalize(u: str) -> str:
1074
+ u = u.strip().rstrip("/")
1075
+ if u.endswith(".git"):
1076
+ u = u[:-4]
1077
+ if u.startswith("git@"):
1078
+ u = u[4:].replace(":", "/", 1)
1079
+ for prefix in ("https://", "http://", "ssh://"):
1080
+ if u.startswith(prefix):
1081
+ u = u[len(prefix):]
1082
+ break
1083
+ return u.lower()
1084
+
1085
+ return _normalize(url_a) == _normalize(url_b)
1086
+
1087
+
1088
+ _SYNC_CODE_EXTENSIONS = {
1089
+ ".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".rs", ".java",
1090
+ ".rb", ".c", ".cpp", ".h", ".hpp", ".cs", ".swift", ".kt",
1091
+ ".toml", ".yaml", ".yml", ".json", ".md", ".txt", ".cfg",
1092
+ ".sh", ".bash", ".zsh", ".fish", ".sql", ".graphql",
1093
+ }
1094
+
1095
+
1096
+ def _initial_sync(remote_cfg, project_dir: str, batch_size: int = 50) -> None:
1097
+ """Push all git-tracked code files to the server after connect.
1098
+
1099
+ Uses ``git ls-files`` to enumerate tracked files, filters by extension,
1100
+ and POSTs them in batches via the notify endpoint (same path as watch/notify).
1101
+ """
1102
+ import base64
1103
+ import subprocess
1104
+
1105
+ try:
1106
+ import httpx
1107
+ except ImportError:
1108
+ return
1109
+
1110
+ # Get git-tracked files
1111
+ try:
1112
+ result = subprocess.run(
1113
+ ["git", "ls-files"],
1114
+ cwd=project_dir,
1115
+ capture_output=True, text=True, timeout=15,
1116
+ )
1117
+ if result.returncode != 0:
1118
+ print(" [skip] Could not list git files for initial sync.", file=sys.stderr)
1119
+ return
1120
+ except Exception:
1121
+ return
1122
+
1123
+ all_files = [f for f in result.stdout.strip().splitlines() if f]
1124
+ # Filter to code files
1125
+ code_files = [
1126
+ f for f in all_files
1127
+ if os.path.splitext(f)[1].lower() in _SYNC_CODE_EXTENSIONS
1128
+ ]
1129
+ if not code_files:
1130
+ print(" [skip] No code files to sync.", file=sys.stderr)
1131
+ return
1132
+
1133
+ # Detect branch
1134
+ branch = "main"
1135
+ try:
1136
+ br = subprocess.run(
1137
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1138
+ cwd=project_dir,
1139
+ capture_output=True, text=True, timeout=5,
1140
+ )
1141
+ if br.returncode == 0:
1142
+ branch = br.stdout.strip()
1143
+ except Exception:
1144
+ pass
1145
+
1146
+ server = remote_cfg.server.rstrip("/")
1147
+ headers = {"Authorization": f"Bearer {remote_cfg.token}"}
1148
+ client = httpx.Client(timeout=60)
1149
+
1150
+ total = len(code_files)
1151
+ synced = 0
1152
+ bulk_batch_size = 500 # Bulk sync supports up to 500 files
1153
+ print(f"\n Initial sync: pushing {total} tracked files...", file=sys.stderr)
1154
+
1155
+ # Try bulk-sync endpoint first (faster, no debounce)
1156
+ use_bulk = True
1157
+ try:
1158
+ for i in range(0, total, bulk_batch_size):
1159
+ batch = code_files[i : i + bulk_batch_size]
1160
+ files: dict[str, str] = {}
1161
+ for rel_path in batch:
1162
+ full = os.path.join(project_dir, rel_path)
1163
+ if os.path.isfile(full):
1164
+ try:
1165
+ with open(full, "rb") as f:
1166
+ files[rel_path] = base64.b64encode(f.read()).decode("ascii")
1167
+ except (OSError, PermissionError):
1168
+ pass
1169
+
1170
+ if not files:
1171
+ continue
1172
+
1173
+ if use_bulk:
1174
+ try:
1175
+ resp = client.post(
1176
+ f"{server}/api/v1/notify/bulk-sync",
1177
+ json={
1178
+ "files": files,
1179
+ "project": remote_cfg.repo_id,
1180
+ "branch": branch,
1181
+ },
1182
+ headers=headers,
1183
+ )
1184
+ if resp.status_code == 200:
1185
+ synced += len(files)
1186
+ print(f" [{synced}/{total}] synced (bulk)", file=sys.stderr)
1187
+ continue
1188
+ elif resp.status_code == 404:
1189
+ # Server doesn't support bulk-sync, fall back
1190
+ use_bulk = False
1191
+ else:
1192
+ print(f" Bulk sync error: {resp.status_code}", file=sys.stderr)
1193
+ use_bulk = False
1194
+ except Exception:
1195
+ use_bulk = False
1196
+
1197
+ # Fallback: regular notify in smaller batches
1198
+ for j in range(0, len(files), batch_size):
1199
+ sub_files = dict(list(files.items())[j : j + batch_size])
1200
+ try:
1201
+ resp = client.post(
1202
+ f"{server}/api/v1/notify/file-changed",
1203
+ json={
1204
+ "paths": list(sub_files.keys()),
1205
+ "files": sub_files,
1206
+ "project": remote_cfg.repo_id,
1207
+ "branch": branch,
1208
+ },
1209
+ headers=headers,
1210
+ )
1211
+ if resp.status_code in (200, 202):
1212
+ synced += len(sub_files)
1213
+ print(f" [{synced}/{total}] synced", file=sys.stderr)
1214
+ else:
1215
+ print(f" Batch error: server returned {resp.status_code}", file=sys.stderr)
1216
+ except Exception as e:
1217
+ print(f" Batch error: {e}", file=sys.stderr)
1218
+ finally:
1219
+ # Flush debouncer if we used regular notify
1220
+ if synced > 0 and not use_bulk:
1221
+ try:
1222
+ client.post(
1223
+ f"{server}/api/v1/notify/flush",
1224
+ json={"project": remote_cfg.repo_id, "branch": branch},
1225
+ headers=headers,
1226
+ )
1227
+ except Exception:
1228
+ pass # best-effort flush
1229
+ client.close()
1230
+
1231
+ print(f" Initial sync complete: {synced}/{total} files pushed.", file=sys.stderr)
1232
+
1233
+ # Push recent commit history for DB-backed commits page
1234
+ _push_commit_history(remote_cfg, project_dir, branch)
1235
+
1236
+ # Push blame data for recently changed files
1237
+ _push_blame_data(remote_cfg, project_dir, branch)
1238
+
1239
+
1240
+ def _push_commit_history(remote_cfg, project_dir: str, branch: str, limit: int = 100) -> None:
1241
+ """Push recent commit metadata to the server for DB-backed commits page.
1242
+
1243
+ Includes --name-status to capture changed files per commit.
1244
+ """
1245
+ import subprocess
1246
+
1247
+ try:
1248
+ import httpx
1249
+ except ImportError:
1250
+ return
1251
+
1252
+ # Use a separator to split commit header from name-status output
1253
+ separator = "---COMMIT_END---"
1254
+ try:
1255
+ result = subprocess.run(
1256
+ [
1257
+ "git", "log",
1258
+ f"--format=%H%n%s%n%an%n%ae%n%at%n%P%n{separator}",
1259
+ "--name-status",
1260
+ "-n", str(limit),
1261
+ ],
1262
+ cwd=project_dir, capture_output=True, text=True, timeout=30,
1263
+ )
1264
+ if result.returncode != 0 or not result.stdout.strip():
1265
+ return
1266
+ except Exception:
1267
+ return
1268
+
1269
+ # Parse commits with changed files
1270
+ commits = []
1271
+ blocks = result.stdout.split(separator)
1272
+
1273
+ for block in blocks:
1274
+ block = block.strip()
1275
+ if not block:
1276
+ continue
1277
+
1278
+ lines = block.split("\n")
1279
+ if len(lines) < 6:
1280
+ continue
1281
+
1282
+ oid = lines[0].strip()
1283
+ message = lines[1].strip()
1284
+ author_name = lines[2].strip()
1285
+ author_email = lines[3].strip()
1286
+ timestamp_str = lines[4].strip()
1287
+ parent_line = lines[5].strip()
1288
+
1289
+ if not oid:
1290
+ continue
1291
+ try:
1292
+ ts = int(timestamp_str)
1293
+ except ValueError:
1294
+ ts = 0
1295
+ parent_oids = parent_line.split() if parent_line else []
1296
+
1297
+ # Parse name-status lines (after the 6 header lines)
1298
+ changed_files = []
1299
+ for ns_line in lines[6:]:
1300
+ ns_line = ns_line.strip()
1301
+ if not ns_line:
1302
+ continue
1303
+ parts = ns_line.split("\t", 1)
1304
+ if len(parts) == 2:
1305
+ status_code = parts[0].strip()
1306
+ file_path = parts[1].strip()
1307
+ # Map git status codes to our format
1308
+ status_map = {"A": "A", "M": "M", "D": "D", "R": "R", "C": "C"}
1309
+ status = status_map.get(status_code[0], status_code[0]) if status_code else "M"
1310
+ changed_files.append({"path": file_path, "status": status})
1311
+
1312
+ commits.append({
1313
+ "oid": oid,
1314
+ "message": message,
1315
+ "author_name": author_name,
1316
+ "author_email": author_email,
1317
+ "timestamp": ts,
1318
+ "parent_oids": parent_oids,
1319
+ "changed_files": changed_files if changed_files else None,
1320
+ })
1321
+
1322
+ if not commits:
1323
+ return
1324
+
1325
+ server = remote_cfg.server.rstrip("/")
1326
+ headers = {"Authorization": f"Bearer {remote_cfg.token}"}
1327
+
1328
+ try:
1329
+ resp = httpx.post(
1330
+ f"{server}/api/v1/notify/commits",
1331
+ json={
1332
+ "commits": commits,
1333
+ "project": remote_cfg.repo_id,
1334
+ "branch": branch,
1335
+ },
1336
+ headers=headers,
1337
+ timeout=30,
1338
+ )
1339
+ if resp.status_code == 200:
1340
+ data = resp.json()
1341
+ print(f" Pushed {data.get('stored', 0)} commits to server.", file=sys.stderr)
1342
+ else:
1343
+ print(f" [warn] Commit push returned {resp.status_code}", file=sys.stderr)
1344
+ except Exception as e:
1345
+ print(f" [warn] Commit push failed: {e}", file=sys.stderr)
1346
+
1347
+
1348
+ def _push_blame_data(remote_cfg, project_dir: str, branch: str, paths: list[str] | None = None) -> None:
1349
+ """Push blame data for specified files (or top files) to the server."""
1350
+ import subprocess
1351
+
1352
+ try:
1353
+ import httpx
1354
+ except ImportError:
1355
+ return
1356
+
1357
+ if paths is None:
1358
+ # Default: blame top 20 most-recently-changed files
1359
+ try:
1360
+ result = subprocess.run(
1361
+ ["git", "diff", "--name-only", "HEAD~10", "HEAD"],
1362
+ cwd=project_dir, capture_output=True, text=True, timeout=15,
1363
+ )
1364
+ if result.returncode == 0 and result.stdout.strip():
1365
+ paths = result.stdout.strip().splitlines()[:20]
1366
+ else:
1367
+ return
1368
+ except Exception:
1369
+ return
1370
+
1371
+ if not paths:
1372
+ return
1373
+
1374
+ server = remote_cfg.server.rstrip("/")
1375
+ headers = {"Authorization": f"Bearer {remote_cfg.token}"}
1376
+ client = httpx.Client(timeout=30)
1377
+ pushed = 0
1378
+
1379
+ try:
1380
+ for file_path in paths:
1381
+ try:
1382
+ result = subprocess.run(
1383
+ ["git", "blame", "--porcelain", file_path],
1384
+ cwd=project_dir, capture_output=True, text=True, timeout=30,
1385
+ )
1386
+ if result.returncode != 0:
1387
+ continue
1388
+ except Exception:
1389
+ continue
1390
+
1391
+ hunks = _parse_git_blame_porcelain(result.stdout)
1392
+ if not hunks:
1393
+ continue
1394
+
1395
+ try:
1396
+ resp = client.post(
1397
+ f"{server}/api/v1/notify/blame",
1398
+ json={
1399
+ "hunks": hunks,
1400
+ "project": remote_cfg.repo_id,
1401
+ "branch": branch,
1402
+ "path": file_path,
1403
+ },
1404
+ headers=headers,
1405
+ )
1406
+ if resp.status_code == 200:
1407
+ pushed += 1
1408
+ except Exception:
1409
+ pass
1410
+ finally:
1411
+ client.close()
1412
+
1413
+ if pushed:
1414
+ print(f" Pushed blame data for {pushed} file(s).", file=sys.stderr)
1415
+
1416
+
1417
+ def _parse_git_blame_porcelain(output: str) -> list[dict]:
1418
+ """Parse git blame --porcelain output into structured hunks."""
1419
+ hunks: list[dict] = []
1420
+ lines = output.splitlines()
1421
+ i = 0
1422
+ current: dict = {}
1423
+
1424
+ while i < len(lines):
1425
+ line = lines[i]
1426
+ # Skip content lines (porcelain format prefixes them with \t)
1427
+ if line.startswith("\t"):
1428
+ i += 1
1429
+ continue
1430
+ # Header line: <sha> <orig_line> <final_line> [<num_lines>]
1431
+ parts = line.split()
1432
+ if (
1433
+ len(parts) >= 3
1434
+ and len(parts[0]) == 40
1435
+ and all(c in "0123456789abcdef" for c in parts[0])
1436
+ and parts[1].isdigit()
1437
+ and parts[2].isdigit()
1438
+ ):
1439
+ start_line = int(parts[2])
1440
+ num_lines = int(parts[3]) if len(parts) >= 4 and parts[3].isdigit() else 1
1441
+ if current:
1442
+ hunks.append(current)
1443
+ commit_oid = parts[0]
1444
+ current = {
1445
+ "commit_oid": commit_oid,
1446
+ "start_line": start_line,
1447
+ "end_line": start_line + num_lines - 1,
1448
+ "author_name": "",
1449
+ "author_email": "",
1450
+ "timestamp": 0,
1451
+ }
1452
+ elif line.startswith("author "):
1453
+ current["author_name"] = line[7:]
1454
+ elif line.startswith("author-mail "):
1455
+ email = line[12:].strip("<>")
1456
+ current["author_email"] = email
1457
+ elif line.startswith("author-time "):
1458
+ try:
1459
+ current["timestamp"] = int(line[12:])
1460
+ except ValueError:
1461
+ pass
1462
+ i += 1
1463
+
1464
+ if current:
1465
+ hunks.append(current)
1466
+
1467
+ # Merge consecutive hunks from the same commit
1468
+ merged: list[dict] = []
1469
+ for h in hunks:
1470
+ if merged and merged[-1]["commit_oid"] == h["commit_oid"] and merged[-1]["end_line"] + 1 == h["start_line"]:
1471
+ merged[-1]["end_line"] = h["end_line"]
1472
+ else:
1473
+ merged.append(h)
1474
+
1475
+ return merged
1476
+
1477
+
1478
+ def _cmd_connect(args: list[str]) -> None:
1479
+ """Connect a local project to a running code-intel server.
1480
+
1481
+ Primary onboarding flow for service mode. Handles authentication
1482
+ (interactive register/login or explicit --token), org/repo selection,
1483
+ and writes .attocode/config.toml for subsequent notify/watch commands.
1484
+ """
1485
+ from attocode_intel.config import RemoteConfig, save_remote_config
1486
+
1487
+ _, project_dir, _, _ = _parse_opts(args)
1488
+ project_dir = os.path.abspath(project_dir)
1489
+
1490
+ server = ""
1491
+ token = ""
1492
+ repo_id = ""
1493
+ email = ""
1494
+ password = ""
1495
+ org_slug = ""
1496
+ repo_name_override = ""
1497
+ ci_mode = False
1498
+ skip_sync = False
1499
+ state_file_override = ""
1500
+
1501
+ i = 0
1502
+ while i < len(args):
1503
+ arg = args[i]
1504
+ if arg == "--ci":
1505
+ ci_mode = True
1506
+ i += 1
1507
+ continue
1508
+ elif arg == "--skip-sync":
1509
+ skip_sync = True
1510
+ i += 1
1511
+ continue
1512
+ elif arg == "--state-file" and i + 1 < len(args):
1513
+ state_file_override = args[i + 1]
1514
+ i += 2
1515
+ continue
1516
+ elif arg.startswith("--state-file="):
1517
+ state_file_override = arg.split("=", 1)[1]
1518
+ i += 1
1519
+ continue
1520
+ elif arg == "--server" and i + 1 < len(args):
1521
+ server = args[i + 1]
1522
+ i += 2
1523
+ elif arg.startswith("--server="):
1524
+ server = arg.split("=", 1)[1]
1525
+ i += 1
1526
+ elif arg == "--token" and i + 1 < len(args):
1527
+ token = args[i + 1]
1528
+ i += 2
1529
+ elif arg.startswith("--token="):
1530
+ token = arg.split("=", 1)[1]
1531
+ i += 1
1532
+ elif arg == "--repo" and i + 1 < len(args):
1533
+ repo_id = args[i + 1]
1534
+ i += 2
1535
+ elif arg.startswith("--repo="):
1536
+ repo_id = arg.split("=", 1)[1]
1537
+ i += 1
1538
+ elif arg == "--email" and i + 1 < len(args):
1539
+ email = args[i + 1]
1540
+ i += 2
1541
+ elif arg.startswith("--email="):
1542
+ email = arg.split("=", 1)[1]
1543
+ i += 1
1544
+ elif arg == "--password" and i + 1 < len(args):
1545
+ password = args[i + 1]
1546
+ i += 2
1547
+ elif arg.startswith("--password="):
1548
+ password = arg.split("=", 1)[1]
1549
+ i += 1
1550
+ elif arg == "--org" and i + 1 < len(args):
1551
+ org_slug = args[i + 1]
1552
+ i += 2
1553
+ elif arg.startswith("--org="):
1554
+ org_slug = arg.split("=", 1)[1]
1555
+ i += 1
1556
+ elif arg == "--name" and i + 1 < len(args):
1557
+ repo_name_override = args[i + 1]
1558
+ i += 2
1559
+ elif arg.startswith("--name="):
1560
+ repo_name_override = arg.split("=", 1)[1]
1561
+ i += 1
1562
+ elif arg == "--force":
1563
+ # Will clear cached state below
1564
+ i += 1
1565
+ else:
1566
+ i += 1
1567
+
1568
+ force = "--force" in args
1569
+
1570
+ if not server:
1571
+ print("Error: --server <url> is required", file=sys.stderr)
1572
+ sys.exit(1)
1573
+
1574
+ server = server.rstrip("/")
1575
+ interactive = sys.stdin.isatty() and not ci_mode
1576
+
1577
+ # --- Health check ---
1578
+ try:
1579
+ import httpx
1580
+ except ImportError:
1581
+ print("Error: httpx not installed. Install with: pip install httpx", file=sys.stderr)
1582
+ sys.exit(1)
1583
+
1584
+ try:
1585
+ resp = httpx.get(f"{server}/health", timeout=10)
1586
+ if resp.status_code != 200:
1587
+ print(f"Error: server at {server} returned status {resp.status_code}", file=sys.stderr)
1588
+ sys.exit(1)
1589
+ print(f"Server reachable at {server}", file=sys.stderr)
1590
+ except Exception as e:
1591
+ print(f"Error: cannot reach server at {server}: {e}", file=sys.stderr)
1592
+ sys.exit(1)
1593
+
1594
+ state_path = state_file_override or os.path.join(project_dir, ".attocode", "dev-state.json")
1595
+
1596
+ # --force: nuke cached state so everything re-runs
1597
+ # --name without --force: just clear repo_id to add a new repo
1598
+ if force and os.path.isfile(state_path):
1599
+ os.remove(state_path)
1600
+ print("Cleared cached state (--force).", file=sys.stderr)
1601
+ elif repo_name_override and os.path.isfile(state_path):
1602
+ import json as _json
1603
+ try:
1604
+ _state = _json.loads(open(state_path, encoding="utf-8").read())
1605
+ _state.pop("repo_id", None)
1606
+ open(state_path, "w", encoding="utf-8").write(_json.dumps(_state, indent=2))
1607
+ print("Cleared cached repo_id (--name forces new repo).", file=sys.stderr)
1608
+ except Exception:
1609
+ pass
1610
+
1611
+ if token:
1612
+ # --- Non-interactive token path (original behavior + optional repo) ---
1613
+ rc = RemoteConfig(
1614
+ server=server,
1615
+ token=token,
1616
+ repo_id=repo_id,
1617
+ branch_auto_detect=True,
1618
+ )
1619
+ else:
1620
+ # --- Interactive (or scripted via --email/--password) auth path ---
1621
+ state = _bootstrap_user_and_project(
1622
+ api_base=server,
1623
+ project_dir=project_dir,
1624
+ state_path=state_path,
1625
+ email=email,
1626
+ password=password,
1627
+ org_slug=org_slug,
1628
+ repo_name=repo_name_override,
1629
+ interactive=interactive,
1630
+ )
1631
+ rc = RemoteConfig(
1632
+ server=server,
1633
+ token=state["token"],
1634
+ repo_id=repo_id or state.get("repo_id", ""),
1635
+ branch_auto_detect=True,
1636
+ )
1637
+
1638
+ config_path = save_remote_config(project_dir, rc)
1639
+
1640
+ # --- Auto-verify connection ---
1641
+ print(file=sys.stderr)
1642
+ errors = 0
1643
+ headers = {"Authorization": f"Bearer {rc.token}"}
1644
+ try:
1645
+ resp = httpx.get(f"{server}/api/v1/auth/me", headers=headers, timeout=10)
1646
+ if resp.status_code == 200:
1647
+ me = resp.json()
1648
+ print(f" [ok] Authenticated as {me.get('email', '?')}", file=sys.stderr)
1649
+ else:
1650
+ print(f" [FAIL] Auth check returned {resp.status_code}", file=sys.stderr)
1651
+ errors += 1
1652
+ except Exception as e:
1653
+ print(f" [FAIL] Auth check: {e}", file=sys.stderr)
1654
+ errors += 1
1655
+
1656
+ if rc.repo_id:
1657
+ try:
1658
+ resp = httpx.get(
1659
+ f"{server}/api/v2/repos/{rc.repo_id}/files",
1660
+ headers=headers,
1661
+ params={"limit": "1"},
1662
+ timeout=10,
1663
+ )
1664
+ if resp.status_code == 200:
1665
+ print(f" [ok] Repo {rc.repo_id} accessible", file=sys.stderr)
1666
+ else:
1667
+ print(f" [FAIL] Repo check returned {resp.status_code}", file=sys.stderr)
1668
+ errors += 1
1669
+ except Exception as e:
1670
+ print(f" [FAIL] Repo check: {e}", file=sys.stderr)
1671
+ errors += 1
1672
+
1673
+ # --- Initial sync: push all tracked files so the server has content ---
1674
+ if rc.repo_id and not errors and not skip_sync:
1675
+ _initial_sync(rc, project_dir)
1676
+
1677
+ # --- Summary ---
1678
+ print(
1679
+ f"\nConnection saved to {config_path}\n"
1680
+ f" Server: {rc.server}\n"
1681
+ f" Repo ID: {rc.repo_id or '(auto-detect)'}\n",
1682
+ file=sys.stderr,
1683
+ )
1684
+ if errors:
1685
+ print(
1686
+ "Some checks failed. Run 'attocode code-intel test-connection' for details.",
1687
+ file=sys.stderr,
1688
+ )
1689
+ else:
1690
+ print(
1691
+ "Next steps:\n"
1692
+ " attocode code-intel watch # auto-notify on file save\n"
1693
+ " attocode code-intel install claude # install MCP server\n",
1694
+ file=sys.stderr,
1695
+ )
1696
+
1697
+
1698
+ def _cmd_test_connection(args: list[str]) -> None:
1699
+ """Verify connectivity to the remote code-intel server.
1700
+
1701
+ Checks: server reachable, auth valid, repo exists, notify endpoint works,
1702
+ WebSocket connection, index stats.
1703
+ """
1704
+ import subprocess
1705
+
1706
+ try:
1707
+ import httpx
1708
+ except ImportError:
1709
+ print("Error: httpx not installed. Install with: pip install httpx", file=sys.stderr)
1710
+ sys.exit(1)
1711
+
1712
+ from attocode_intel.config import load_remote_config
1713
+
1714
+ _, project_dir, _, _ = _parse_opts(args)
1715
+ project_dir = os.path.abspath(project_dir)
1716
+
1717
+ rc = load_remote_config(project_dir)
1718
+ if not rc.is_configured:
1719
+ print("Error: no remote connection configured.", file=sys.stderr)
1720
+ print("Run 'attocode code-intel connect --server <url>' first.", file=sys.stderr)
1721
+ sys.exit(1)
1722
+
1723
+ server = rc.server.rstrip("/")
1724
+ headers = {"Authorization": f"Bearer {rc.token}"}
1725
+ errors = 0
1726
+
1727
+ # 1. Server reachable
1728
+ try:
1729
+ resp = httpx.get(f"{server}/health", timeout=10)
1730
+ if resp.status_code == 200:
1731
+ print(f" [ok] Server reachable at {server}")
1732
+ else:
1733
+ print(f" [FAIL] Server returned {resp.status_code}")
1734
+ errors += 1
1735
+ except Exception as e:
1736
+ print(f" [FAIL] Cannot connect to {server}: {e}")
1737
+ errors += 1
1738
+ # Can't continue without connectivity
1739
+ print(f"\n{errors} check(s) failed.")
1740
+ sys.exit(1)
1741
+
1742
+ # 2. Authentication valid
1743
+ try:
1744
+ resp = httpx.get(f"{server}/api/v1/auth/me", headers=headers, timeout=10)
1745
+ if resp.status_code == 200:
1746
+ data = resp.json()
1747
+ email = data.get("email", data.get("username", "unknown"))
1748
+ print(f" [ok] Authentication valid (user: {email})")
1749
+ else:
1750
+ print(f" [FAIL] Authentication failed ({resp.status_code})")
1751
+ errors += 1
1752
+ except Exception as e:
1753
+ print(f" [FAIL] Auth check error: {e}")
1754
+ errors += 1
1755
+
1756
+ # 3. Repository registered
1757
+ if rc.repo_id:
1758
+ try:
1759
+ resp = httpx.get(
1760
+ f"{server}/api/v1/repos/{rc.repo_id}",
1761
+ headers=headers, timeout=10,
1762
+ )
1763
+ if resp.status_code == 200:
1764
+ data = resp.json()
1765
+ repo_name = data.get("name", rc.repo_id)
1766
+ print(f" [ok] Repository registered ({repo_name}, repo_id: {rc.repo_id})")
1767
+ else:
1768
+ print(f" [FAIL] Repository not found ({resp.status_code})")
1769
+ errors += 1
1770
+ except Exception as e:
1771
+ print(f" [FAIL] Repo check error: {e}")
1772
+ errors += 1
1773
+ else:
1774
+ print(" [skip] No repo_id configured — use --repo to set")
1775
+
1776
+ # 4. Branch tracked
1777
+ branch = "main"
1778
+ try:
1779
+ result = subprocess.run(
1780
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1781
+ cwd=project_dir,
1782
+ capture_output=True, text=True, timeout=5,
1783
+ )
1784
+ if result.returncode == 0:
1785
+ branch = result.stdout.strip()
1786
+ except Exception:
1787
+ pass
1788
+
1789
+ if rc.repo_id:
1790
+ try:
1791
+ resp = httpx.get(
1792
+ f"{server}/api/v1/repos/{rc.repo_id}/branches",
1793
+ headers=headers, timeout=10,
1794
+ )
1795
+ if resp.status_code == 200:
1796
+ branches = resp.json()
1797
+ names = [b.get("name") for b in branches] if isinstance(branches, list) else []
1798
+ if branch in names:
1799
+ print(f" [ok] Branch tracked ({branch})")
1800
+ else:
1801
+ print(f" [warn] Branch '{branch}' not yet tracked (will be auto-created on first notify)")
1802
+ else:
1803
+ print(f" [skip] Could not check branches ({resp.status_code})")
1804
+ except Exception:
1805
+ print(" [skip] Branch check skipped")
1806
+
1807
+ # 5. Notify endpoint accepts changes
1808
+ try:
1809
+ resp = httpx.post(
1810
+ f"{server}/api/v1/notify/file-changed",
1811
+ json={"paths": [], "project": rc.repo_id, "branch": branch},
1812
+ headers=headers, timeout=10,
1813
+ )
1814
+ if resp.status_code in (200, 202, 422):
1815
+ # 422 is expected with empty paths — endpoint is reachable
1816
+ print(" [ok] Notify endpoint accepts changes")
1817
+ else:
1818
+ print(f" [FAIL] Notify endpoint returned {resp.status_code}")
1819
+ errors += 1
1820
+ except Exception as e:
1821
+ print(f" [FAIL] Notify endpoint error: {e}")
1822
+ errors += 1
1823
+
1824
+ # 6. WebSocket connection
1825
+ try:
1826
+ import websockets.sync.client # type: ignore[import-untyped]
1827
+
1828
+ ws_url = server.replace("https://", "wss://").replace("http://", "ws://")
1829
+ if rc.repo_id:
1830
+ ws_url = f"{ws_url}/ws/repos/{rc.repo_id}/events?token={rc.token}"
1831
+ ws = websockets.sync.client.connect(ws_url, close_timeout=3, open_timeout=5)
1832
+ ws.close()
1833
+ print(" [ok] WebSocket connection established")
1834
+ else:
1835
+ print(" [skip] WebSocket check requires repo_id")
1836
+ except ImportError:
1837
+ print(" [skip] WebSocket check requires 'websockets' package")
1838
+ except Exception as e:
1839
+ print(f" [warn] WebSocket connection failed: {e}")
1840
+
1841
+ # 7. Index stats
1842
+ if rc.repo_id:
1843
+ try:
1844
+ resp = httpx.get(
1845
+ f"{server}/api/v2/repos/{rc.repo_id}/stats",
1846
+ headers=headers, timeout=10,
1847
+ )
1848
+ if resp.status_code == 200:
1849
+ data = resp.json()
1850
+ files = data.get("total_files", "?")
1851
+ embedded = data.get("embedded_files", "?")
1852
+ print(f" [ok] {files} files indexed, {embedded} embedded")
1853
+ else:
1854
+ print(" [skip] Could not retrieve index stats")
1855
+ except Exception:
1856
+ print(" [skip] Index stats check skipped")
1857
+
1858
+ print()
1859
+ if errors == 0:
1860
+ print("All checks passed!")
1861
+ else:
1862
+ print(f"{errors} check(s) failed.")
1863
+ sys.exit(1)
1864
+
1865
+
1866
+ def _cmd_watch(args: list[str]) -> None:
1867
+ """Watch filesystem for changes and notify the remote server.
1868
+
1869
+ Uses watchfiles (Rust-based, fast) to detect changes and batch-POSTs
1870
+ them to the remote server's notify endpoint.
1871
+ """
1872
+ import signal
1873
+ import subprocess
1874
+
1875
+ try:
1876
+ import httpx
1877
+ except ImportError:
1878
+ print("Error: httpx not installed. Install with: pip install httpx", file=sys.stderr)
1879
+ sys.exit(1)
1880
+
1881
+ try:
1882
+ from watchfiles import Change, watch
1883
+ except ImportError:
1884
+ print(
1885
+ "Error: watchfiles not installed. Install with: pip install watchfiles",
1886
+ file=sys.stderr,
1887
+ )
1888
+ sys.exit(1)
1889
+
1890
+ from pathlib import Path
1891
+
1892
+ from attocode_intel.config import load_remote_config
1893
+
1894
+ _, project_dir, _, _ = _parse_opts(args)
1895
+ project_dir = os.path.abspath(project_dir)
1896
+
1897
+ # Parse debounce
1898
+ debounce_ms = 500
1899
+ for i, arg in enumerate(args):
1900
+ if arg == "--debounce" and i + 1 < len(args):
1901
+ debounce_ms = int(args[i + 1])
1902
+ elif arg.startswith("--debounce="):
1903
+ debounce_ms = int(arg.split("=", 1)[1])
1904
+
1905
+ rc = load_remote_config(project_dir)
1906
+ if not rc.is_configured:
1907
+ print("Error: no remote connection configured.", file=sys.stderr)
1908
+ print("Run 'attocode code-intel connect --server <url>' first.", file=sys.stderr)
1909
+ sys.exit(1)
1910
+
1911
+ # Detect branch
1912
+ branch = "main"
1913
+ try:
1914
+ result = subprocess.run(
1915
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1916
+ cwd=project_dir,
1917
+ capture_output=True, text=True, timeout=5,
1918
+ )
1919
+ if result.returncode == 0:
1920
+ branch = result.stdout.strip()
1921
+ except Exception:
1922
+ pass
1923
+
1924
+ code_extensions = {
1925
+ ".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".rs", ".java",
1926
+ ".rb", ".c", ".cpp", ".h", ".hpp", ".cs", ".swift", ".kt",
1927
+ }
1928
+
1929
+ import base64
1930
+
1931
+ server = rc.server.rstrip("/")
1932
+ headers = {"Authorization": f"Bearer {rc.token}"}
1933
+ client = httpx.Client(timeout=30)
1934
+
1935
+ # Run initial sync to catch up any files not yet pushed
1936
+ _initial_sync(rc, project_dir)
1937
+
1938
+ stop_event = __import__("threading").Event()
1939
+ signal.signal(signal.SIGINT, lambda *_: stop_event.set())
1940
+ signal.signal(signal.SIGTERM, lambda *_: stop_event.set())
1941
+
1942
+ print(f"Watching {project_dir} for changes (branch: {branch}, debounce: {debounce_ms}ms)")
1943
+ print(f"Server: {server}")
1944
+ print("Press Ctrl+C to stop.\n")
1945
+
1946
+ try:
1947
+ for changes in watch(
1948
+ project_dir,
1949
+ stop_event=stop_event,
1950
+ recursive=True,
1951
+ debounce=debounce_ms,
1952
+ watch_filter=lambda _, path: (
1953
+ not any(
1954
+ part.startswith(".") or part in ("node_modules", "__pycache__", ".git")
1955
+ for part in Path(path).parts
1956
+ )
1957
+ and Path(path).suffix.lower() in code_extensions
1958
+ ),
1959
+ ):
1960
+ if stop_event.is_set():
1961
+ break
1962
+
1963
+ # Re-detect branch (may have changed)
1964
+ try:
1965
+ result = subprocess.run(
1966
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1967
+ cwd=project_dir,
1968
+ capture_output=True, text=True, timeout=5,
1969
+ )
1970
+ if result.returncode == 0:
1971
+ branch = result.stdout.strip()
1972
+ except Exception:
1973
+ pass
1974
+
1975
+ rel_paths = []
1976
+ for change_type, path_str in changes:
1977
+ if change_type in (Change.modified, Change.added, Change.deleted):
1978
+ try:
1979
+ rel = os.path.relpath(path_str, project_dir)
1980
+ rel_paths.append(rel)
1981
+ except ValueError:
1982
+ pass
1983
+
1984
+ if not rel_paths:
1985
+ continue
1986
+
1987
+ # Read file contents and base64-encode them
1988
+ files: dict[str, str] = {}
1989
+ for rel_path in rel_paths:
1990
+ full = os.path.join(project_dir, rel_path)
1991
+ if os.path.isfile(full):
1992
+ try:
1993
+ with open(full, "rb") as f:
1994
+ files[rel_path] = base64.b64encode(f.read()).decode("ascii")
1995
+ except (OSError, PermissionError):
1996
+ pass # deleted or unreadable — omit, server treats as deleted
1997
+
1998
+ try:
1999
+ resp = client.post(
2000
+ f"{server}/api/v1/notify/file-changed",
2001
+ json={
2002
+ "paths": rel_paths,
2003
+ "files": files,
2004
+ "project": rc.repo_id,
2005
+ "branch": branch,
2006
+ },
2007
+ headers=headers,
2008
+ )
2009
+ if resp.status_code in (200, 202):
2010
+ print(f" Notified: {len(rel_paths)} file(s) on {branch}")
2011
+ else:
2012
+ print(f" Error: server returned {resp.status_code}", file=sys.stderr)
2013
+ except Exception as e:
2014
+ print(f" Error: {e}", file=sys.stderr)
2015
+
2016
+ except KeyboardInterrupt:
2017
+ pass
2018
+ finally:
2019
+ client.close()
2020
+ print("\nStopped watching.")
2021
+
2022
+
2023
+ def _cmd_index(args: list[str]) -> None:
2024
+ """Build or check the semantic search embedding index.
2025
+
2026
+ Modes:
2027
+ --status Print current index status and exit
2028
+ --foreground Blocking full index (default if no --background)
2029
+ --background Start daemon, poll progress, print status
2030
+ """
2031
+ import time
2032
+
2033
+ _, project_dir, _, _ = _parse_opts(args)
2034
+ project_dir = os.path.abspath(project_dir)
2035
+
2036
+ mode = "foreground"
2037
+ for arg in args:
2038
+ if arg == "--status":
2039
+ mode = "status"
2040
+ elif arg == "--background":
2041
+ mode = "background"
2042
+ elif arg == "--foreground":
2043
+ mode = "foreground"
2044
+
2045
+ os.environ.setdefault("ATTOCODE_PROJECT_DIR", project_dir)
2046
+
2047
+ from attocode_intel._internal.integrations.context.semantic_search import SemanticSearchManager
2048
+
2049
+ mgr = SemanticSearchManager(root_dir=project_dir)
2050
+
2051
+ if mode == "status":
2052
+ progress = mgr.get_index_progress()
2053
+ print(f"Provider: {mgr.provider_name}")
2054
+ print(f"Status: {progress.status}")
2055
+ print(f"Coverage: {progress.coverage:.0%} ({progress.indexed_files}/{progress.total_files} files)")
2056
+ if progress.elapsed_seconds > 0:
2057
+ print(f"Elapsed: {progress.elapsed_seconds:.1f}s")
2058
+ print(f"Vector search active: {mgr.is_index_ready()}")
2059
+ mgr.close()
2060
+ return
2061
+
2062
+ if not mgr.is_available:
2063
+ print(
2064
+ "No embedding provider available. Install the semantic extra:\n"
2065
+ " uv tool install 'attocode-code-intel[semantic]'",
2066
+ file=sys.stderr,
2067
+ )
2068
+ mgr.close()
2069
+ sys.exit(1)
2070
+
2071
+ if mode == "foreground":
2072
+ print(f"Indexing {project_dir} (foreground)...", file=sys.stderr)
2073
+ count = mgr.index()
2074
+ print(f"Indexed {count} chunks.", file=sys.stderr)
2075
+ mgr.close()
2076
+ return
2077
+
2078
+ # Parse --timeout flag (default: 30 minutes)
2079
+ timeout_seconds = 1800
2080
+ for i, arg in enumerate(args):
2081
+ if arg == "--timeout" and i + 1 < len(args):
2082
+ timeout_seconds = int(args[i + 1])
2083
+ elif arg.startswith("--timeout="):
2084
+ timeout_seconds = int(arg.split("=", 1)[1])
2085
+
2086
+ # Background mode
2087
+ print(f"Starting background indexing for {project_dir}...", file=sys.stderr)
2088
+ progress = mgr.start_background_indexing()
2089
+ deadline = time.time() + timeout_seconds
2090
+
2091
+ try:
2092
+ while progress.status == "running":
2093
+ if time.time() > deadline:
2094
+ print(
2095
+ f"\nTimeout: indexing did not complete within {timeout_seconds}s. "
2096
+ "Use --timeout to increase.",
2097
+ file=sys.stderr,
2098
+ )
2099
+ mgr.stop_background_indexing()
2100
+ break
2101
+ progress = mgr.get_index_progress()
2102
+ print(
2103
+ f"\r {progress.indexed_files}/{progress.total_files} files "
2104
+ f"({progress.coverage:.0%}) — {progress.elapsed_seconds:.0f}s",
2105
+ end="", file=sys.stderr, flush=True,
2106
+ )
2107
+ time.sleep(1.0)
2108
+ except KeyboardInterrupt:
2109
+ print("\nStopping...", file=sys.stderr)
2110
+ mgr.stop_background_indexing()
2111
+
2112
+ progress = mgr.get_index_progress()
2113
+ print(f"\nDone: {progress.status} ({progress.indexed_files}/{progress.total_files} files)", file=sys.stderr)
2114
+ mgr.close()
2115
+
2116
+
2117
+ # ---------------------------------------------------------------------------
2118
+ # setup command helpers
2119
+ # ---------------------------------------------------------------------------
2120
+
2121
+
2122
+ def _load_env_file(path: str) -> dict[str, str]:
2123
+ """Parse a .env file into os.environ. Returns parsed key-value pairs."""
2124
+ import re
2125
+
2126
+ pairs: dict[str, str] = {}
2127
+ with open(path, encoding="utf-8") as fh:
2128
+ for line in fh:
2129
+ line = line.strip()
2130
+ if not line or line.startswith("#"):
2131
+ continue
2132
+ m = re.match(r"([A-Za-z_][A-Za-z0-9_]*)=(.*)", line)
2133
+ if not m:
2134
+ continue
2135
+ key, val = m.group(1), m.group(2)
2136
+ # Strip surrounding quotes
2137
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
2138
+ val = val[1:-1]
2139
+ pairs[key] = val
2140
+ os.environ[key] = val
2141
+ return pairs
2142
+
2143
+
2144
+ def _run(
2145
+ cmd: list[str], *, check: bool = True, capture: bool = False,
2146
+ ) -> "subprocess.CompletedProcess[str]":
2147
+ """Run a subprocess with visible logging."""
2148
+ import subprocess
2149
+
2150
+ print(f"$ {' '.join(cmd)}", file=sys.stderr)
2151
+ return subprocess.run(
2152
+ cmd,
2153
+ check=check,
2154
+ capture_output=capture,
2155
+ text=True,
2156
+ )
2157
+
2158
+
2159
+ def _detect_default_branch(project_dir: str) -> str:
2160
+ """Detect the git default branch via symbolic-ref, fallback 'main'."""
2161
+ import subprocess
2162
+
2163
+ try:
2164
+ result = subprocess.run(
2165
+ ["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
2166
+ cwd=project_dir,
2167
+ capture_output=True, text=True, timeout=5,
2168
+ )
2169
+ if result.returncode == 0:
2170
+ # refs/remotes/origin/main -> main
2171
+ return result.stdout.strip().rsplit("/", 1)[-1]
2172
+ except Exception:
2173
+ pass
2174
+ return "main"
2175
+
2176
+
2177
+ def _cmd_setup(args: list[str]) -> None:
2178
+ """[Dev] Bootstrap local infrastructure for contributing to attocode.
2179
+
2180
+ Phase 1 (infra): Docker, deps, migrations — no API needed.
2181
+ Phase 2 (bootstrap): Register dev user, create org, add repo — requires running API.
2182
+
2183
+ To connect to an existing server, use ``attocode code-intel connect`` instead.
2184
+ """
2185
+
2186
+ try:
2187
+ import httpx
2188
+ except ImportError:
2189
+ print(
2190
+ "Error: httpx not installed. Install with: uv sync --extra service",
2191
+ file=sys.stderr,
2192
+ )
2193
+ sys.exit(1)
2194
+
2195
+ # --- parse flags ---
2196
+ reset = False
2197
+ skip_deps = False
2198
+ project_dir = "."
2199
+
2200
+ i = 0
2201
+ while i < len(args):
2202
+ arg = args[i]
2203
+ if arg == "--reset":
2204
+ reset = True
2205
+ i += 1
2206
+ elif arg == "--skip-deps":
2207
+ skip_deps = True
2208
+ i += 1
2209
+ elif arg == "--project" and i + 1 < len(args):
2210
+ project_dir = args[i + 1]
2211
+ i += 2
2212
+ elif arg.startswith("--project="):
2213
+ project_dir = arg.split("=", 1)[1]
2214
+ i += 1
2215
+ else:
2216
+ i += 1
2217
+
2218
+ project_dir = os.path.abspath(project_dir)
2219
+ compose_file = os.path.join(project_dir, "docker", "code-intel", "docker-compose.dev.yml")
2220
+ if not os.path.isfile(compose_file):
2221
+ print(
2222
+ "Error: setup works only in a clone of the attocode repository "
2223
+ f"({compose_file} not found). Use --project to point at the clone.",
2224
+ file=sys.stderr,
2225
+ )
2226
+ sys.exit(1)
2227
+ env_dev = os.path.join(project_dir, ".env.dev")
2228
+ state_path = os.path.join(project_dir, ".attocode", "dev-state.json")
2229
+ alembic_ini = os.path.join(
2230
+ project_dir, "src", "attocode", "code_intel", "migrations", "alembic.ini",
2231
+ )
2232
+
2233
+ # =========================================================
2234
+ # Phase 1 — Infrastructure (no API needed)
2235
+ # =========================================================
2236
+ print("\n=== Phase 1: Infrastructure ===\n", file=sys.stderr)
2237
+
2238
+ # 1. Reset if requested
2239
+ if reset:
2240
+ print("Resetting...", file=sys.stderr)
2241
+ _run(
2242
+ ["docker", "compose", "-f", compose_file, "down", "-v"],
2243
+ check=False,
2244
+ )
2245
+ if os.path.exists(state_path):
2246
+ os.remove(state_path)
2247
+ print(f"Removed {state_path}", file=sys.stderr)
2248
+
2249
+ # 2. Check prerequisites
2250
+ for tool in ("docker", "uv"):
2251
+ if not shutil.which(tool):
2252
+ print(f"Error: '{tool}' not found on PATH.", file=sys.stderr)
2253
+ sys.exit(1)
2254
+
2255
+ for required in (compose_file, env_dev):
2256
+ if not os.path.isfile(required):
2257
+ print(f"Error: required file not found: {required}", file=sys.stderr)
2258
+ sys.exit(1)
2259
+
2260
+ # 3. Start Postgres + Redis
2261
+ _run(["docker", "compose", "-f", compose_file, "up", "-d", "--wait"])
2262
+
2263
+ # 4. Install deps
2264
+ if not skip_deps:
2265
+ _run(["uv", "sync", "--extra", "service", "--extra", "semantic"])
2266
+
2267
+ # 5. Load .env.dev
2268
+ env_vars = _load_env_file(env_dev)
2269
+ print(f"Loaded {len(env_vars)} env vars from {env_dev}", file=sys.stderr)
2270
+
2271
+ # 6. Run migrations
2272
+ _run(["uv", "run", "alembic", "-c", alembic_ini, "upgrade", "head"])
2273
+
2274
+ print("\nPhase 1 complete.", file=sys.stderr)
2275
+
2276
+ # =========================================================
2277
+ # Phase 2 — Bootstrap (requires running API)
2278
+ # =========================================================
2279
+ print("\n=== Phase 2: Bootstrap ===\n", file=sys.stderr)
2280
+
2281
+ api_base = f"http://127.0.0.1:{env_vars.get('ATTOCODE_PORT', '8080')}"
2282
+
2283
+ # 7. Health check
2284
+ try:
2285
+ resp = httpx.get(f"{api_base}/health", timeout=5)
2286
+ if resp.status_code != 200:
2287
+ raise httpx.ConnectError("unhealthy")
2288
+ except Exception:
2289
+ print(
2290
+ f"API server not reachable at {api_base}.\n"
2291
+ "Start it in another terminal:\n\n"
2292
+ f" source {env_dev} && uvicorn attocode_intel.api.app:create_app "
2293
+ "--factory --reload --port 8080\n\n"
2294
+ "Then re-run: attocode code-intel setup\n",
2295
+ file=sys.stderr,
2296
+ )
2297
+ sys.exit(0)
2298
+
2299
+ print(f"API reachable at {api_base}", file=sys.stderr)
2300
+
2301
+ # 8. Register dev user, create org, add repo (shared helper)
2302
+ state = _bootstrap_user_and_project(
2303
+ api_base=api_base,
2304
+ project_dir=project_dir,
2305
+ state_path=state_path,
2306
+ email="dev@localhost",
2307
+ password="dev-password-123",
2308
+ org_slug="dev-org",
2309
+ interactive=False,
2310
+ )
2311
+
2312
+ # 9. Write .attocode/config.toml for notify/watch
2313
+ from attocode_intel.config import RemoteConfig, save_remote_config
2314
+
2315
+ rc = RemoteConfig(
2316
+ server=api_base,
2317
+ token=state["token"],
2318
+ repo_id=state["repo_id"],
2319
+ branch_auto_detect=True,
2320
+ )
2321
+ config_path = save_remote_config(project_dir, rc)
2322
+
2323
+ # 10. Summary
2324
+ print(
2325
+ f"\n=== Setup complete ===\n\n"
2326
+ f" API: {api_base}\n"
2327
+ f" Org: {state['org_id']}\n"
2328
+ f" Repo: {state['repo_id']}\n"
2329
+ f" Config: {config_path}\n\n"
2330
+ "Next steps:\n"
2331
+ f" # Worker (processes indexing jobs)\n"
2332
+ f" source {env_dev} && python -m attocode_intel.workers.run\n\n"
2333
+ f" # Frontend (optional)\n"
2334
+ f" cd frontend && npm run dev\n\n"
2335
+ f" # Auto-notify on file save\n"
2336
+ f" attocode code-intel watch\n",
2337
+ file=sys.stderr,
2338
+ )
2339
+
2340
+
2341
+ # ---------------------------------------------------------------------------
2342
+ # CLI Query Commands
2343
+ # ---------------------------------------------------------------------------
2344
+
2345
+
2346
+ def _cmd_query(args: list[str]) -> None:
2347
+ """Semantic search across the codebase."""
2348
+ _, project_dir, _, _ = _parse_opts(args)
2349
+ project_dir = os.path.abspath(project_dir)
2350
+
2351
+ # Parse positional query text and flags
2352
+ query_parts: list[str] = []
2353
+ top_k = 10
2354
+ file_filter = ""
2355
+
2356
+ i = 0
2357
+ while i < len(args):
2358
+ arg = args[i]
2359
+ if arg == "--top" and i + 1 < len(args):
2360
+ top_k = int(args[i + 1])
2361
+ i += 2
2362
+ elif arg.startswith("--top="):
2363
+ top_k = int(arg.split("=", 1)[1])
2364
+ i += 1
2365
+ elif arg == "--filter" and i + 1 < len(args):
2366
+ file_filter = args[i + 1]
2367
+ i += 2
2368
+ elif arg.startswith("--filter="):
2369
+ file_filter = arg.split("=", 1)[1]
2370
+ i += 1
2371
+ elif arg == "--project" and i + 1 < len(args):
2372
+ i += 2 # already parsed by _parse_opts
2373
+ elif arg.startswith("--project="):
2374
+ i += 1
2375
+ elif not arg.startswith("-"):
2376
+ query_parts.append(arg)
2377
+ i += 1
2378
+ else:
2379
+ i += 1
2380
+
2381
+ query = " ".join(query_parts)
2382
+ if not query:
2383
+ print("Usage: attocode code-intel query <text> [--top N] [--filter '*.py'] [--project <path>]", file=sys.stderr)
2384
+ sys.exit(1)
2385
+
2386
+ from attocode_intel.service import CodeIntelService
2387
+
2388
+ svc = CodeIntelService(project_dir)
2389
+ data = svc.semantic_search_data(query, top_k=top_k, file_filter=file_filter)
2390
+ _print_search_results(data)
2391
+
2392
+
2393
+ def _cmd_symbols(args: list[str]) -> None:
2394
+ """List symbols in a file or search symbols by name."""
2395
+ _, project_dir, _, _ = _parse_opts(args)
2396
+ project_dir = os.path.abspath(project_dir)
2397
+
2398
+ # Parse positional file and --search flag
2399
+ target_file: str | None = None
2400
+ search_name: str | None = None
2401
+
2402
+ i = 0
2403
+ while i < len(args):
2404
+ arg = args[i]
2405
+ if arg == "--search" and i + 1 < len(args):
2406
+ search_name = args[i + 1]
2407
+ i += 2
2408
+ elif arg.startswith("--search="):
2409
+ search_name = arg.split("=", 1)[1]
2410
+ i += 1
2411
+ elif arg == "--project" and i + 1 < len(args):
2412
+ i += 2
2413
+ elif arg.startswith("--project="):
2414
+ i += 1
2415
+ elif not arg.startswith("-") and target_file is None:
2416
+ target_file = arg
2417
+ i += 1
2418
+ else:
2419
+ i += 1
2420
+
2421
+ if not target_file and not search_name:
2422
+ print("Usage: attocode code-intel symbols <file> [--search <name>] [--project <path>]", file=sys.stderr)
2423
+ sys.exit(1)
2424
+
2425
+ from attocode_intel.service import CodeIntelService
2426
+
2427
+ svc = CodeIntelService(project_dir)
2428
+
2429
+ if search_name:
2430
+ data = svc.search_symbols_data(search_name)
2431
+ _print_symbols_table(data, title=f"Search results for '{search_name}'")
2432
+ else:
2433
+ assert target_file is not None
2434
+ data = svc.symbols_data(target_file)
2435
+ _print_symbols_table(data, title=f"Symbols in {target_file}")
2436
+
2437
+
2438
+ def _cmd_impact(args: list[str]) -> None:
2439
+ """Show blast radius of file changes."""
2440
+ _, project_dir, _, _ = _parse_opts(args)
2441
+ project_dir = os.path.abspath(project_dir)
2442
+
2443
+ # Collect positional file arguments
2444
+ files: list[str] = []
2445
+
2446
+ i = 0
2447
+ while i < len(args):
2448
+ arg = args[i]
2449
+ if arg == "--project" and i + 1 < len(args):
2450
+ i += 2
2451
+ elif arg.startswith("--project="):
2452
+ i += 1
2453
+ elif not arg.startswith("-"):
2454
+ files.append(arg)
2455
+ i += 1
2456
+ else:
2457
+ i += 1
2458
+
2459
+ if not files:
2460
+ print("Usage: attocode code-intel impact <file> [file2 ...] [--project <path>]", file=sys.stderr)
2461
+ sys.exit(1)
2462
+
2463
+ from attocode_intel.service import CodeIntelService
2464
+
2465
+ svc = CodeIntelService(project_dir)
2466
+ data = svc.impact_analysis_data(files)
2467
+ _print_impact_analysis(data)
2468
+
2469
+
2470
+ def _cmd_hotspots(args: list[str]) -> None:
2471
+ """Show risk/complexity hotspots."""
2472
+ _, project_dir, _, _ = _parse_opts(args)
2473
+ project_dir = os.path.abspath(project_dir)
2474
+
2475
+ top_n = 15
2476
+
2477
+ i = 0
2478
+ while i < len(args):
2479
+ arg = args[i]
2480
+ if arg == "--top" and i + 1 < len(args):
2481
+ top_n = int(args[i + 1])
2482
+ i += 2
2483
+ elif arg.startswith("--top="):
2484
+ top_n = int(arg.split("=", 1)[1])
2485
+ i += 1
2486
+ elif arg == "--project" and i + 1 < len(args):
2487
+ i += 2
2488
+ elif arg.startswith("--project="):
2489
+ i += 1
2490
+ else:
2491
+ i += 1
2492
+
2493
+ from attocode_intel.service import CodeIntelService
2494
+
2495
+ svc = CodeIntelService(project_dir)
2496
+ data = svc.hotspots_data(top_n=top_n)
2497
+ _print_hotspots(data)
2498
+
2499
+
2500
+ def _cmd_deps(args: list[str]) -> None:
2501
+ """Show file dependencies and dependents."""
2502
+ _, project_dir, _, _ = _parse_opts(args)
2503
+ project_dir = os.path.abspath(project_dir)
2504
+
2505
+ target_file: str | None = None
2506
+
2507
+ i = 0
2508
+ while i < len(args):
2509
+ arg = args[i]
2510
+ if arg == "--project" and i + 1 < len(args):
2511
+ i += 2
2512
+ elif arg.startswith("--project="):
2513
+ i += 1
2514
+ elif not arg.startswith("-") and target_file is None:
2515
+ target_file = arg
2516
+ i += 1
2517
+ else:
2518
+ i += 1
2519
+
2520
+ if not target_file:
2521
+ print("Usage: attocode code-intel deps <file> [--project <path>]", file=sys.stderr)
2522
+ sys.exit(1)
2523
+
2524
+ from attocode_intel.service import CodeIntelService
2525
+
2526
+ svc = CodeIntelService(project_dir)
2527
+ data = svc.dependencies_data(target_file)
2528
+ _print_dependencies(data)
2529
+
2530
+
2531
+ def _cmd_gc(args: list[str]) -> None:
2532
+ """Run garbage collection on orphaned embeddings and unreferenced content."""
2533
+ import asyncio
2534
+
2535
+ _, project_dir, _, _ = _parse_opts(args)
2536
+ project_dir = os.path.abspath(project_dir)
2537
+
2538
+ from attocode_intel.config import CodeIntelConfig, load_remote_config
2539
+
2540
+ remote_cfg = load_remote_config(project_dir)
2541
+
2542
+ if remote_cfg.is_configured:
2543
+ # Remote mode: trigger GC jobs via API
2544
+ try:
2545
+ import httpx
2546
+ except ImportError:
2547
+ print("Error: httpx not installed. Install with: pip install httpx", file=sys.stderr)
2548
+ sys.exit(1)
2549
+
2550
+ base_url = remote_cfg.server.rstrip("/")
2551
+ headers = {"Authorization": f"Bearer {remote_cfg.token}"}
2552
+ print(f"Triggering GC on remote server {base_url}...", file=sys.stderr)
2553
+
2554
+ try:
2555
+ with httpx.Client(timeout=60) as client:
2556
+ # Trigger gc_orphaned_embeddings job
2557
+ resp = client.post(
2558
+ f"{base_url}/api/v1/jobs/enqueue",
2559
+ json={"function": "gc_orphaned_embeddings"},
2560
+ headers=headers,
2561
+ )
2562
+ if resp.status_code == 200:
2563
+ print(" Enqueued gc_orphaned_embeddings job")
2564
+ else:
2565
+ print(f" Warning: could not enqueue gc_orphaned_embeddings: {resp.status_code}", file=sys.stderr)
2566
+
2567
+ # Trigger gc_unreferenced_content job
2568
+ resp = client.post(
2569
+ f"{base_url}/api/v1/jobs/enqueue",
2570
+ json={"function": "gc_unreferenced_content"},
2571
+ headers=headers,
2572
+ )
2573
+ if resp.status_code == 200:
2574
+ print(" Enqueued gc_unreferenced_content job")
2575
+ else:
2576
+ print(f" Warning: could not enqueue gc_unreferenced_content: {resp.status_code}", file=sys.stderr)
2577
+ except httpx.HTTPError as exc:
2578
+ print(f"Error contacting remote server: {exc}", file=sys.stderr)
2579
+ sys.exit(1)
2580
+
2581
+ print("GC jobs enqueued on remote server.")
2582
+ return
2583
+
2584
+ # Local mode: run GC directly against the database
2585
+ config = CodeIntelConfig.from_env()
2586
+ if not config.database_url:
2587
+ # Pure local mode (SQLite / no DB) — clear AST cache
2588
+ print("Local mode: clearing AST cache...", file=sys.stderr)
2589
+ from pathlib import Path
2590
+
2591
+ cache_dir = Path(project_dir) / ".attocode" / "cache"
2592
+ if cache_dir.exists():
2593
+ removed = 0
2594
+ for f in cache_dir.iterdir():
2595
+ if f.is_file():
2596
+ f.unlink()
2597
+ removed += 1
2598
+ print(f" Removed {removed} cached file(s).")
2599
+ else:
2600
+ print(" No cache directory found.")
2601
+ print("GC complete.")
2602
+ return
2603
+
2604
+ # Service mode with database: run GC operations
2605
+ async def _run_gc() -> None:
2606
+ from attocode_intel.db.engine import get_session
2607
+ from attocode_intel.storage.content_store import ContentStore
2608
+ from attocode_intel.storage.embedding_store import EmbeddingStore
2609
+
2610
+ print("Running garbage collection...", file=sys.stderr)
2611
+
2612
+ async for session in get_session():
2613
+ # 1. GC orphaned embeddings
2614
+ emb_store = EmbeddingStore(session)
2615
+ emb_count = await emb_store.gc_orphaned(
2616
+ min_age_minutes=config.gc_content_min_age_minutes,
2617
+ )
2618
+ print(f" Orphaned embeddings removed: {emb_count}")
2619
+
2620
+ # 2. GC unreferenced content
2621
+ content_store = ContentStore(session)
2622
+ content_count = await content_store.gc_unreferenced(
2623
+ min_age_minutes=config.gc_content_min_age_minutes,
2624
+ )
2625
+ print(f" Unreferenced content removed: {content_count}")
2626
+
2627
+ await session.commit()
2628
+ print("GC complete.")
2629
+ return
2630
+
2631
+ asyncio.run(_run_gc())
2632
+
2633
+
2634
+ def _cmd_verify(args: list[str]) -> None:
2635
+ """Run integrity checks on the code-intel index."""
2636
+ import asyncio
2637
+
2638
+ _, project_dir, _, _ = _parse_opts(args)
2639
+ project_dir = os.path.abspath(project_dir)
2640
+
2641
+ from attocode_intel.config import CodeIntelConfig
2642
+
2643
+ config = CodeIntelConfig.from_env()
2644
+
2645
+ def _run_local_verify() -> None:
2646
+ # Pure local mode — verify against the real DB-backed local index layout.
2647
+ from pathlib import Path
2648
+
2649
+ from attocode_intel.service import CodeIntelService
2650
+ from attocode_intel._internal.integrations.context.ast_service import ASTService
2651
+
2652
+ print("Running local integrity checks...", file=sys.stderr)
2653
+
2654
+ os.environ.setdefault("ATTOCODE_PROJECT_DIR", project_dir)
2655
+
2656
+ cache_dir = Path(project_dir) / ".attocode" / "cache"
2657
+ index_dir = Path(project_dir) / ".attocode" / "index"
2658
+ symbols_db = index_dir / "symbols.db"
2659
+ vectors_dir = Path(project_dir) / ".attocode" / "vectors"
2660
+
2661
+ issues: list[str] = []
2662
+ health: dict[str, object] = {}
2663
+ hydration: dict[str, object] = {}
2664
+
2665
+ CodeIntelService._reset_instances()
2666
+ ASTService.clear_instances()
2667
+ try:
2668
+ svc = CodeIntelService.get_instance(project_dir)
2669
+ health = svc._health_snapshot()
2670
+ hydration = svc.hydration_status()
2671
+ except Exception as exc:
2672
+ issues.append(f"Health check failed ({type(exc).__name__}: {exc})")
2673
+
2674
+ if not cache_dir.exists():
2675
+ issues.append("Cache directory missing (.attocode/cache)")
2676
+ if not index_dir.exists():
2677
+ issues.append("Index directory missing (.attocode/index)")
2678
+ if index_dir.exists() and not symbols_db.exists():
2679
+ issues.append("Index database missing (.attocode/index/symbols.db)")
2680
+
2681
+ discovery_count = int(health.get("discovery_count", 0) or 0)
2682
+ indexed_files = int(health.get("ast_indexed_files", 0) or 0)
2683
+ if health and discovery_count == 0:
2684
+ issues.append("No files discovered by CodeIntelService")
2685
+ if health and indexed_files == 0:
2686
+ issues.append("No files indexed by ASTService")
2687
+
2688
+ if issues:
2689
+ print(f"\nFound {len(issues)} issue(s):")
2690
+ for issue in issues:
2691
+ print(f" [!] {issue}")
2692
+ print("\nRun 'attocode code-intel reindex' to rebuild the index.")
2693
+ else:
2694
+ cache_files = list(cache_dir.iterdir()) if cache_dir.exists() else []
2695
+ print(f"\nLocal index OK:")
2696
+ print(f" Health status: {health.get('health_status', 'unknown')}")
2697
+ print(f" Discovery count: {discovery_count}")
2698
+ print(f" Indexed files: {indexed_files}")
2699
+ print(f" Cache files: {len(cache_files)}")
2700
+ print(f" Index DB: {symbols_db}")
2701
+ print(f" Vectors dir: {vectors_dir} ({'present' if vectors_dir.exists() else 'missing'})")
2702
+ if hydration:
2703
+ print(
2704
+ " Hydration: "
2705
+ f"{hydration.get('tier', 'unknown')} / {hydration.get('phase', 'unknown')}"
2706
+ )
2707
+ return
2708
+
2709
+ if not config.database_url:
2710
+ _run_local_verify()
2711
+ return
2712
+
2713
+ # Service mode: run integrity checks against the database
2714
+ async def _run_verify() -> None:
2715
+ from sqlalchemy import text
2716
+
2717
+ from attocode_intel.db.engine import get_session
2718
+
2719
+ print("Running integrity checks...\n", file=sys.stderr)
2720
+ issues: list[str] = []
2721
+ stats: dict[str, int] = {}
2722
+
2723
+ async for session in get_session():
2724
+ # 1. Orphaned BranchFile entries (content_sha not in file_contents)
2725
+ result = await session.execute(text("""
2726
+ SELECT COUNT(*) FROM branch_files
2727
+ WHERE content_sha IS NOT NULL
2728
+ AND content_sha NOT IN (SELECT sha256 FROM file_contents)
2729
+ """))
2730
+ orphaned_bf = result.scalar() or 0
2731
+ stats["orphaned_branch_files"] = orphaned_bf
2732
+ if orphaned_bf > 0:
2733
+ issues.append(f"{orphaned_bf} branch_files reference missing file_contents")
2734
+
2735
+ # 2. Orphaned embeddings (content_sha not in any branch manifest)
2736
+ result = await session.execute(text("""
2737
+ SELECT COUNT(*) FROM embeddings
2738
+ WHERE content_sha NOT IN (
2739
+ SELECT DISTINCT content_sha FROM branch_files WHERE content_sha IS NOT NULL
2740
+ )
2741
+ """))
2742
+ orphaned_emb = result.scalar() or 0
2743
+ stats["orphaned_embeddings"] = orphaned_emb
2744
+ if orphaned_emb > 0:
2745
+ issues.append(f"{orphaned_emb} embeddings reference content not in any branch manifest")
2746
+
2747
+ # 3. Missing embeddings (content_sha in branch manifest but no embedding)
2748
+ result = await session.execute(text("""
2749
+ SELECT COUNT(DISTINCT bf.content_sha) FROM branch_files bf
2750
+ WHERE bf.content_sha IS NOT NULL
2751
+ AND bf.content_sha NOT IN (SELECT DISTINCT content_sha FROM embeddings)
2752
+ """))
2753
+ missing_emb = result.scalar() or 0
2754
+ stats["missing_embeddings"] = missing_emb
2755
+ if missing_emb > 0:
2756
+ issues.append(f"{missing_emb} content SHAs in branch manifests have no embeddings")
2757
+
2758
+ # 4. Broken parent_branch_id references
2759
+ result = await session.execute(text("""
2760
+ SELECT COUNT(*) FROM branches
2761
+ WHERE parent_branch_id IS NOT NULL
2762
+ AND parent_branch_id NOT IN (SELECT id FROM branches)
2763
+ """))
2764
+ broken_parents = result.scalar() or 0
2765
+ stats["broken_parent_branch_refs"] = broken_parents
2766
+ if broken_parents > 0:
2767
+ issues.append(f"{broken_parents} branches reference non-existent parent branches")
2768
+
2769
+ # 5. Orphaned symbols (content_sha not in file_contents)
2770
+ result = await session.execute(text("""
2771
+ SELECT COUNT(*) FROM symbols
2772
+ WHERE content_sha NOT IN (SELECT sha256 FROM file_contents)
2773
+ """))
2774
+ orphaned_sym = result.scalar() or 0
2775
+ stats["orphaned_symbols"] = orphaned_sym
2776
+ if orphaned_sym > 0:
2777
+ issues.append(f"{orphaned_sym} symbols reference missing file_contents")
2778
+
2779
+ # Print results
2780
+ print("Integrity check results:")
2781
+ print(f" Orphaned branch_files: {stats['orphaned_branch_files']}")
2782
+ print(f" Orphaned embeddings: {stats['orphaned_embeddings']}")
2783
+ print(f" Missing embeddings: {stats['missing_embeddings']}")
2784
+ print(f" Broken parent_branch refs: {stats['broken_parent_branch_refs']}")
2785
+ print(f" Orphaned symbols: {stats['orphaned_symbols']}")
2786
+
2787
+ if issues:
2788
+ print(f"\nFound {len(issues)} issue(s):")
2789
+ for issue in issues:
2790
+ print(f" [!] {issue}")
2791
+ print("\nRun 'attocode code-intel gc' to clean up orphaned data.")
2792
+ print("Run 'attocode code-intel reindex' to rebuild missing embeddings.")
2793
+ else:
2794
+ print("\nAll checks passed.")
2795
+
2796
+ return
2797
+
2798
+ try:
2799
+ asyncio.run(_run_verify())
2800
+ except RuntimeError as exc:
2801
+ if "Database not initialized" not in str(exc):
2802
+ raise
2803
+ print(
2804
+ "Database engine is not initialized; falling back to local integrity checks.",
2805
+ file=sys.stderr,
2806
+ )
2807
+ _run_local_verify()
2808
+
2809
+
2810
+ def _cmd_reindex(args: list[str]) -> None:
2811
+ """Force a full reindex of the project."""
2812
+ _, project_dir, _, _ = _parse_opts(args)
2813
+ project_dir = os.path.abspath(project_dir)
2814
+
2815
+ from attocode_intel.config import load_remote_config
2816
+
2817
+ remote_cfg = load_remote_config(project_dir)
2818
+
2819
+ if remote_cfg.is_configured:
2820
+ # Remote mode: trigger index_repository job via API
2821
+ try:
2822
+ import httpx
2823
+ except ImportError:
2824
+ print("Error: httpx not installed. Install with: pip install httpx", file=sys.stderr)
2825
+ sys.exit(1)
2826
+
2827
+ base_url = remote_cfg.server.rstrip("/")
2828
+ headers = {"Authorization": f"Bearer {remote_cfg.token}"}
2829
+ repo_id = remote_cfg.repo_id
2830
+ if not repo_id:
2831
+ print("Error: no repo_id configured. Run 'attocode code-intel connect' first.", file=sys.stderr)
2832
+ sys.exit(1)
2833
+
2834
+ print(f"Triggering full reindex on remote server {base_url}...", file=sys.stderr)
2835
+
2836
+ try:
2837
+ with httpx.Client(timeout=60) as client:
2838
+ resp = client.post(
2839
+ f"{base_url}/api/v1/repos/{repo_id}/index",
2840
+ headers=headers,
2841
+ )
2842
+ if resp.status_code in (200, 201, 202):
2843
+ data = resp.json()
2844
+ print(f" Reindex job enqueued: {data}")
2845
+ else:
2846
+ print(f" Error: server returned {resp.status_code}: {resp.text}", file=sys.stderr)
2847
+ sys.exit(1)
2848
+ except httpx.HTTPError as exc:
2849
+ print(f"Error contacting remote server: {exc}", file=sys.stderr)
2850
+ sys.exit(1)
2851
+
2852
+ print("Reindex triggered on remote server.")
2853
+ return
2854
+
2855
+ # Local mode: clear cache and reinitialize
2856
+ from pathlib import Path
2857
+
2858
+ print(f"Reindexing {project_dir}...", file=sys.stderr)
2859
+
2860
+ # 1. Clear AST cache
2861
+ cache_dir = Path(project_dir) / ".attocode" / "cache"
2862
+ if cache_dir.exists():
2863
+ removed = 0
2864
+ for f in cache_dir.iterdir():
2865
+ if f.is_file():
2866
+ f.unlink()
2867
+ removed += 1
2868
+ print(f" Cleared {removed} cached file(s).")
2869
+
2870
+ # 2. Remove stale index file to force full rebuild
2871
+ index_file = Path(project_dir) / ".attocode" / "index.json"
2872
+ if index_file.exists():
2873
+ index_file.unlink()
2874
+ print(" Removed stale index file.")
2875
+
2876
+ # 3. Run full index
2877
+ os.environ.setdefault("ATTOCODE_PROJECT_DIR", project_dir)
2878
+
2879
+ from attocode_intel._internal.integrations.context.semantic_search import SemanticSearchManager
2880
+
2881
+ mgr = SemanticSearchManager(root_dir=project_dir)
2882
+
2883
+ if not mgr.is_available:
2884
+ print(
2885
+ "No embedding provider available. Install the semantic extra:\n"
2886
+ " uv tool install 'attocode-code-intel[semantic]'",
2887
+ file=sys.stderr,
2888
+ )
2889
+ mgr.close()
2890
+ sys.exit(1)
2891
+
2892
+ count = mgr.index()
2893
+ print(f" Indexed {count} chunks.")
2894
+ mgr.close()
2895
+ print("Reindex complete.")
2896
+
2897
+
2898
+ # ---------------------------------------------------------------------------
2899
+ # Terminal formatters for query commands
2900
+ # ---------------------------------------------------------------------------
2901
+
2902
+
2903
+ def _print_search_results(data: dict) -> None:
2904
+ """Print semantic search results as a numbered list with file, score, snippet."""
2905
+ results = data.get("results", [])
2906
+ total = data.get("total", len(results))
2907
+ query = data.get("query", "")
2908
+
2909
+ print(f"Semantic search: \"{query}\" ({total} result(s))\n")
2910
+
2911
+ if not results:
2912
+ print(" No results found.")
2913
+ return
2914
+
2915
+ for idx, r in enumerate(results, 1):
2916
+ file_path = r.get("file_path", "?")
2917
+ score = r.get("score", 0.0)
2918
+ snippet = r.get("snippet", "")
2919
+ line = r.get("line")
2920
+
2921
+ loc = f"{file_path}:{line}" if line else file_path
2922
+ print(f" {idx:>3}. {loc} (score: {score:.4f})")
2923
+
2924
+ if snippet:
2925
+ # Indent snippet lines, truncate long ones
2926
+ for sline in snippet.strip().splitlines()[:3]:
2927
+ truncated = sline[:120] + "..." if len(sline) > 120 else sline
2928
+ print(f" {truncated}")
2929
+ print()
2930
+
2931
+
2932
+ def _print_symbols_table(data: list[dict], title: str = "Symbols") -> None:
2933
+ """Print symbols as a fixed-width table with name, kind, file, line."""
2934
+ print(f"{title} ({len(data)} symbol(s))\n")
2935
+
2936
+ if not data:
2937
+ print(" No symbols found.")
2938
+ return
2939
+
2940
+ # Compute column widths
2941
+ name_w = max(len(s.get("name", "")) for s in data)
2942
+ name_w = max(name_w, 4) # minimum "Name"
2943
+ name_w = min(name_w, 40) # cap width
2944
+
2945
+ kind_w = max(len(s.get("kind", "")) for s in data)
2946
+ kind_w = max(kind_w, 4)
2947
+ kind_w = min(kind_w, 15)
2948
+
2949
+ # Header
2950
+ header = f" {'Name':<{name_w}} {'Kind':<{kind_w}} {'File':<50} {'Line':>5}"
2951
+ print(header)
2952
+ print(f" {'-' * name_w} {'-' * kind_w} {'-' * 50} {'-' * 5}")
2953
+
2954
+ for s in data:
2955
+ name = s.get("name", "")[:name_w]
2956
+ kind = s.get("kind", "")[:kind_w]
2957
+ file_path = s.get("file_path", "")
2958
+ if len(file_path) > 50:
2959
+ file_path = "..." + file_path[-47:]
2960
+ start_line = s.get("start_line", "")
2961
+
2962
+ print(f" {name:<{name_w}} {kind:<{kind_w}} {file_path:<50} {start_line:>5}")
2963
+
2964
+
2965
+ def _print_impact_analysis(data: dict) -> None:
2966
+ """Print impact analysis as a tree of affected files."""
2967
+ changed = data.get("changed_files", [])
2968
+ impacted = data.get("impacted_files", [])
2969
+ total = data.get("total_impacted", len(impacted))
2970
+ layers = data.get("layers", [])
2971
+
2972
+ print(f"Impact analysis: {len(changed)} changed file(s), {total} impacted file(s)\n")
2973
+
2974
+ print(" Changed files:")
2975
+ for f in changed:
2976
+ print(f" * {f}")
2977
+
2978
+ if not layers:
2979
+ print("\n No downstream impact detected.")
2980
+ return
2981
+
2982
+ print()
2983
+ for layer in layers:
2984
+ depth = layer.get("depth", "?")
2985
+ files = layer.get("files", [])
2986
+ print(f" Depth {depth} ({len(files)} file(s)):")
2987
+ for f in files:
2988
+ print(f" {'|' * depth} {f}")
2989
+
2990
+
2991
+ def _print_hotspots(data: dict) -> None:
2992
+ """Print hotspots as a ranked table with file, categories, score."""
2993
+ file_hotspots = data.get("file_hotspots", [])
2994
+ fn_hotspots = data.get("function_hotspots", [])
2995
+ orphans = data.get("orphan_files", [])
2996
+
2997
+ # File hotspots
2998
+ print(f"File hotspots ({len(file_hotspots)} file(s))\n")
2999
+
3000
+ if file_hotspots:
3001
+ header = f" {'#':>3} {'File':<55} {'Lines':>5} {'Score':>6} Categories"
3002
+ print(header)
3003
+ print(f" {'-' * 3} {'-' * 55} {'-' * 5} {'-' * 6} {'-' * 25}")
3004
+
3005
+ for idx, h in enumerate(file_hotspots, 1):
3006
+ path = h.get("path", "")
3007
+ if len(path) > 55:
3008
+ path = "..." + path[-52:]
3009
+ lines = h.get("line_count", 0)
3010
+ score = h.get("composite", 0.0)
3011
+ cats = ", ".join(h.get("categories", []))
3012
+
3013
+ print(f" {idx:>3} {path:<55} {lines:>5} {score:>6.2f} {cats}")
3014
+ else:
3015
+ print(" No file hotspots found.")
3016
+
3017
+ # Function hotspots
3018
+ if fn_hotspots:
3019
+ print(f"\nFunction hotspots ({len(fn_hotspots)} function(s))\n")
3020
+ header = f" {'Name':<35} {'File':<40} {'Lines':>5} {'Params':>6}"
3021
+ print(header)
3022
+ print(f" {'-' * 35} {'-' * 40} {'-' * 5} {'-' * 6}")
3023
+
3024
+ for fh in fn_hotspots:
3025
+ name = fh.get("name", "")[:35]
3026
+ fp = fh.get("file_path", "")
3027
+ if len(fp) > 40:
3028
+ fp = "..." + fp[-37:]
3029
+ lc = fh.get("line_count", 0)
3030
+ pc = fh.get("param_count", 0)
3031
+ print(f" {name:<35} {fp:<40} {lc:>5} {pc:>6}")
3032
+
3033
+ # Orphan files
3034
+ if orphans:
3035
+ print(f"\nOrphan files ({len(orphans)} file(s) — no imports/importers)\n")
3036
+ for o in orphans:
3037
+ path = o.get("path", "")
3038
+ lines = o.get("line_count", 0)
3039
+ print(f" {path} ({lines} lines)")
3040
+
3041
+
3042
+ def _print_dependencies(data: dict) -> None:
3043
+ """Print dependency information: imports and imported-by lists."""
3044
+ path = data.get("path", "?")
3045
+ imports = data.get("imports", [])
3046
+ imported_by = data.get("imported_by", [])
3047
+
3048
+ print(f"Dependencies for {path}\n")
3049
+
3050
+ print(f" Imports ({len(imports)}):")
3051
+ if imports:
3052
+ for dep in imports:
3053
+ print(f" -> {dep}")
3054
+ else:
3055
+ print(" (none)")
3056
+
3057
+ print(f"\n Imported by ({len(imported_by)}):")
3058
+ if imported_by:
3059
+ for dep in imported_by:
3060
+ print(f" <- {dep}")
3061
+ else:
3062
+ print(" (none)")