alysis-code 0.13.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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,1436 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import stat
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+ from typing import Any, Literal
17
+ from urllib.parse import urlsplit, urlunsplit
18
+
19
+ from packaging.specifiers import SpecifierSet
20
+ from packaging.version import Version
21
+ from pydantic import BaseModel, ConfigDict
22
+
23
+ from alysis_code import __version__
24
+
25
+ from ..atomic_io import atomic_write_json, atomic_write_text
26
+ from ..custom_tools.discovery import global_custom_tools_root, project_custom_tools_root
27
+ from ..custom_tools.trust import (
28
+ ProjectToolTrustKey,
29
+ ProjectToolTrustState,
30
+ )
31
+ from ..custom_tools.trust import (
32
+ load_trust_state as load_tool_trust_state,
33
+ )
34
+ from ..custom_tools.trust import (
35
+ save_trust_state as save_tool_trust_state,
36
+ )
37
+ from ..hooks.config import project_hooks_config_path, user_hooks_config_path
38
+ from ..hooks.models import HookConfigFile
39
+ from ..hooks.trust import (
40
+ ProjectHooksTrustKey,
41
+ ProjectHooksTrustState,
42
+ )
43
+ from ..hooks.trust import (
44
+ load_trust_state as load_hooks_trust_state,
45
+ )
46
+ from ..hooks.trust import (
47
+ save_trust_state as save_hooks_trust_state,
48
+ )
49
+ from ..mcp.config import _load_user_mcp_config, user_mcp_config_path
50
+ from ..mcp.models import UserMcpConfigFile
51
+ from ..skills.install import install_skill_bundle, remove_managed_skill
52
+ from .manifest import (
53
+ PluginManifest,
54
+ PluginManifestError,
55
+ Security,
56
+ load_manifest,
57
+ resolve_manifest_path,
58
+ )
59
+ from .models import (
60
+ ExtensionState,
61
+ InstalledExtensionState,
62
+ ProjectExtensionOverrides,
63
+ normalize_extension_id,
64
+ plugin_slug_from_id,
65
+ )
66
+ from .paths import installed_plugin_root, project_extensions_path
67
+ from .registry import find_by_id, load_registry
68
+ from .state import load_global_state, load_project_state, save_global_state, save_project_state
69
+
70
+ Scope = Literal["user", "project"]
71
+ RollbackFn = Callable[[], None]
72
+ TrustPromptFn = Callable[["TrustPromptRequest"], bool]
73
+
74
+ _SOURCE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_-]*$")
75
+ _SHA40_RE = re.compile(r"^[0-9a-fA-F]{40}$")
76
+ _UNSAFE_PATH_CHARS_RE = re.compile(r"[^a-z0-9._-]+")
77
+ _PLUGIN_MANAGED_TOOLS_SCHEMA_VERSION = 1
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class ComponentInstallSummary:
82
+ skill_ids: tuple[str, ...]
83
+ tool_ids: tuple[str, ...]
84
+ mcp_server_ids: tuple[str, ...]
85
+ hook_ids: tuple[str, ...]
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class PluginInstallResult:
90
+ plugin_id: str
91
+ version: str
92
+ commit: str
93
+ manifest_sha256: str
94
+ scope: Scope
95
+ components_installed: ComponentInstallSummary
96
+ trust_was_prompted: bool
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class PluginUninstallResult:
101
+ plugin_id: str
102
+ scope: Scope
103
+ components_removed: ComponentInstallSummary
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class EnableResult:
108
+ plugin_id: str
109
+ scope: Scope
110
+ previous_state: Literal["enabled", "disabled", "absent"]
111
+ new_state: Literal["enabled", "disabled"]
112
+ no_op: bool
113
+
114
+
115
+ class PluginInstallError(RuntimeError):
116
+ """Raised when install fails and rollback was performed."""
117
+
118
+ def __init__(self, message: str, *, rollback_errors: tuple[str, ...] = ()) -> None:
119
+ super().__init__(message)
120
+ self.rollback_errors = rollback_errors
121
+
122
+
123
+ class PermissionsSummary(BaseModel):
124
+ model_config = ConfigDict(frozen=True)
125
+
126
+ network: bool
127
+ filesystem_write: bool
128
+ required_env: tuple[str, ...]
129
+ mcp_scopes: tuple[str, ...]
130
+ hook_events: tuple[str, ...]
131
+
132
+
133
+ class TrustPromptRequest(BaseModel):
134
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
135
+
136
+ plugin_id: str
137
+ plugin_name: str
138
+ version: str
139
+ description: str
140
+ source_url: str
141
+ commit: str
142
+ manifest_sha256: str
143
+ components: ComponentInstallSummary
144
+ permissions_summary: PermissionsSummary
145
+ security: Security | None
146
+ is_reinstall_with_new_commit: bool
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class _ResolvedSource:
151
+ display_url: str
152
+ git_url: str
153
+ commit: str
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class _ToolRecord:
158
+ plugin_id: str
159
+ tool_id: str
160
+ scope: Scope
161
+ relative_path: str
162
+ file_hash: str
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class _PluginManagedToolsState:
167
+ tools: tuple[_ToolRecord, ...] = ()
168
+
169
+
170
+ def install_plugin(
171
+ *,
172
+ source: str,
173
+ repo_root: Path,
174
+ project: bool = False,
175
+ trust_prompt: TrustPromptFn,
176
+ user_config_dir: Path | None = None,
177
+ ) -> PluginInstallResult:
178
+ scope: Scope = "project" if project else "user"
179
+ resolved_repo_root = repo_root.expanduser().resolve()
180
+ source_ref = _resolve_source(source)
181
+
182
+ with tempfile.TemporaryDirectory(prefix="alysis-plugin-") as temp_dir:
183
+ staging_root = Path(temp_dir)
184
+ _clone_pinned_git_repo(
185
+ git_url=source_ref.git_url,
186
+ commit=source_ref.commit,
187
+ destination=staging_root,
188
+ )
189
+ try:
190
+ manifest = load_manifest(staging_root)
191
+ except PluginManifestError as exc:
192
+ raise PluginInstallError(f"manifest validation failed: {exc}") from exc
193
+
194
+ _check_compatibility(manifest)
195
+ # Hash whichever manifest was actually loaded — a legacy-named one still
196
+ # has to produce a stable integrity hash for the install record.
197
+ manifest_sha256 = _sha256_file(resolve_manifest_path(staging_root))
198
+ component_summary = _component_summary(manifest)
199
+ current_state = _load_extension_state(scope=scope, repo_root=resolved_repo_root)
200
+ existing_record = current_state.installed.get(manifest.plugin.id)
201
+ if (
202
+ existing_record is not None
203
+ and existing_record.commit == source_ref.commit
204
+ and existing_record.manifest_sha256 == manifest_sha256
205
+ ):
206
+ return PluginInstallResult(
207
+ plugin_id=manifest.plugin.id,
208
+ version=existing_record.version or manifest.plugin.version,
209
+ commit=source_ref.commit,
210
+ manifest_sha256=manifest_sha256,
211
+ scope=scope,
212
+ components_installed=_summary_from_record(existing_record),
213
+ trust_was_prompted=False,
214
+ )
215
+
216
+ request = TrustPromptRequest(
217
+ plugin_id=manifest.plugin.id,
218
+ plugin_name=manifest.plugin.name,
219
+ version=manifest.plugin.version,
220
+ description=manifest.plugin.description,
221
+ source_url=source_ref.display_url,
222
+ commit=source_ref.commit,
223
+ manifest_sha256=manifest_sha256,
224
+ components=component_summary,
225
+ permissions_summary=_permissions_summary(manifest),
226
+ security=manifest.security,
227
+ is_reinstall_with_new_commit=(
228
+ existing_record is not None and existing_record.commit != source_ref.commit
229
+ ),
230
+ )
231
+ if not trust_prompt(request):
232
+ raise PluginInstallError("install rejected by user")
233
+
234
+ rollback: list[RollbackFn] = []
235
+ cleanup_after_success: list[RollbackFn] = []
236
+ try:
237
+ plugin_root = installed_plugin_root(
238
+ manifest.plugin.id,
239
+ project=project,
240
+ repo_root=resolved_repo_root,
241
+ )
242
+ _install_plugin_tree(
243
+ source_root=staging_root,
244
+ target_root=plugin_root,
245
+ rollback=rollback,
246
+ cleanup_after_success=cleanup_after_success,
247
+ )
248
+ installed_skills = _install_skills(
249
+ manifest=manifest,
250
+ staging_root=staging_root,
251
+ repo_root=resolved_repo_root,
252
+ project=project,
253
+ user_config_dir=user_config_dir,
254
+ force=existing_record is not None,
255
+ rollback=rollback,
256
+ )
257
+ installed_tools = _install_tools(
258
+ manifest=manifest,
259
+ staging_root=staging_root,
260
+ repo_root=resolved_repo_root,
261
+ project=project,
262
+ user_config_dir=user_config_dir,
263
+ rollback=rollback,
264
+ )
265
+ installed_mcp_servers = _install_mcp_servers(
266
+ manifest=manifest,
267
+ rollback=rollback,
268
+ user_config_dir=user_config_dir,
269
+ )
270
+ installed_hooks = _install_hooks(
271
+ manifest=manifest,
272
+ staging_root=staging_root,
273
+ repo_root=resolved_repo_root,
274
+ project=project,
275
+ user_config_dir=user_config_dir,
276
+ rollback=rollback,
277
+ )
278
+ installed_summary = ComponentInstallSummary(
279
+ skill_ids=tuple(installed_skills),
280
+ tool_ids=tuple(installed_tools),
281
+ mcp_server_ids=tuple(installed_mcp_servers),
282
+ hook_ids=tuple(installed_hooks),
283
+ )
284
+ updated_state = _state_with_installed_record(
285
+ current_state,
286
+ plugin_id=manifest.plugin.id,
287
+ version=manifest.plugin.version,
288
+ commit=source_ref.commit,
289
+ source_url=source_ref.display_url,
290
+ manifest_sha256=manifest_sha256,
291
+ scope=scope,
292
+ summary=installed_summary,
293
+ )
294
+ _save_extension_state(scope=scope, repo_root=resolved_repo_root, state=updated_state)
295
+ except Exception as exc: # noqa: BLE001
296
+ rollback_errors = _run_rollback(rollback)
297
+ if isinstance(exc, PluginInstallError):
298
+ raise PluginInstallError(str(exc), rollback_errors=rollback_errors) from exc
299
+ raise PluginInstallError(str(exc), rollback_errors=rollback_errors) from exc
300
+
301
+ for cleanup in reversed(cleanup_after_success):
302
+ cleanup()
303
+
304
+ return PluginInstallResult(
305
+ plugin_id=manifest.plugin.id,
306
+ version=manifest.plugin.version,
307
+ commit=source_ref.commit,
308
+ manifest_sha256=manifest_sha256,
309
+ scope=scope,
310
+ components_installed=installed_summary,
311
+ trust_was_prompted=True,
312
+ )
313
+
314
+
315
+ def uninstall_plugin(
316
+ *,
317
+ plugin_id: str,
318
+ repo_root: Path,
319
+ project: bool = False,
320
+ user_config_dir: Path | None = None,
321
+ ) -> PluginUninstallResult:
322
+ scope: Scope = "project" if project else "user"
323
+ resolved_repo_root = repo_root.expanduser().resolve()
324
+ state = _load_extension_state(scope=scope, repo_root=resolved_repo_root)
325
+ record = state.installed.get(plugin_id)
326
+ if record is None:
327
+ raise PluginInstallError(f"plugin not installed in {scope}: {plugin_id}")
328
+
329
+ summary = _summary_from_record(record)
330
+ errors: list[str] = []
331
+ for hook_id in reversed(summary.hook_ids):
332
+ _collect_remove_error(
333
+ errors,
334
+ "hook",
335
+ hook_id,
336
+ lambda item=hook_id: _remove_hook_component(
337
+ plugin_id=plugin_id,
338
+ hook_id=item,
339
+ repo_root=resolved_repo_root,
340
+ project=project,
341
+ user_config_dir=user_config_dir,
342
+ ),
343
+ )
344
+ for server_id in reversed(summary.mcp_server_ids):
345
+ _collect_remove_error(
346
+ errors,
347
+ "mcp server",
348
+ server_id,
349
+ lambda item=server_id: _remove_mcp_server_component(
350
+ plugin_id=plugin_id,
351
+ server_id=item,
352
+ user_config_dir=user_config_dir,
353
+ ),
354
+ )
355
+ for tool_id in reversed(summary.tool_ids):
356
+ _collect_remove_error(
357
+ errors,
358
+ "tool",
359
+ tool_id,
360
+ lambda item=tool_id: _remove_tool_component(
361
+ plugin_id=plugin_id,
362
+ tool_id=item,
363
+ repo_root=resolved_repo_root,
364
+ project=project,
365
+ user_config_dir=user_config_dir,
366
+ ),
367
+ )
368
+ for skill_id in reversed(summary.skill_ids):
369
+ _collect_remove_error(
370
+ errors,
371
+ "skill",
372
+ skill_id,
373
+ lambda item=skill_id: remove_managed_skill(
374
+ name=item,
375
+ workspace_root=resolved_repo_root,
376
+ project=project,
377
+ user_config_dir=user_config_dir,
378
+ ),
379
+ )
380
+
381
+ plugin_root = installed_plugin_root(plugin_id, project=project, repo_root=resolved_repo_root)
382
+ try:
383
+ _remove_path(plugin_root)
384
+ except Exception as exc: # noqa: BLE001
385
+ errors.append(f"plugin root {plugin_root}: {exc}")
386
+
387
+ updated_state = state.model_copy(deep=True)
388
+ updated_state.installed.pop(plugin_id, None)
389
+ updated_state.enabled = _without_normalized_id(updated_state.enabled, plugin_id)
390
+ _save_extension_state(scope=scope, repo_root=resolved_repo_root, state=updated_state)
391
+
392
+ if errors:
393
+ raise PluginInstallError(
394
+ f"uninstall completed with errors: {len(errors)} components failed to remove cleanly",
395
+ rollback_errors=tuple(errors),
396
+ )
397
+
398
+ return PluginUninstallResult(
399
+ plugin_id=plugin_id,
400
+ scope=scope,
401
+ components_removed=summary,
402
+ )
403
+
404
+
405
+ def enable_plugin(
406
+ *,
407
+ plugin_id: str,
408
+ repo_root: Path,
409
+ project: bool = False,
410
+ user_config_dir: Path | None = None,
411
+ ) -> EnableResult:
412
+ _ = user_config_dir
413
+ return _set_plugin_enabled(
414
+ plugin_id=plugin_id, repo_root=repo_root, project=project, enabled=True
415
+ )
416
+
417
+
418
+ def disable_plugin(
419
+ *,
420
+ plugin_id: str,
421
+ repo_root: Path,
422
+ project: bool = False,
423
+ user_config_dir: Path | None = None,
424
+ ) -> EnableResult:
425
+ _ = user_config_dir
426
+ return _set_plugin_enabled(
427
+ plugin_id=plugin_id,
428
+ repo_root=repo_root,
429
+ project=project,
430
+ enabled=False,
431
+ )
432
+
433
+
434
+ def _set_plugin_enabled(
435
+ *,
436
+ plugin_id: str,
437
+ repo_root: Path,
438
+ project: bool,
439
+ enabled: bool,
440
+ ) -> EnableResult:
441
+ normalized = normalize_extension_id(plugin_id)
442
+ if not normalized:
443
+ raise PluginInstallError("plugin id cannot be empty")
444
+ resolved_repo_root = repo_root.expanduser().resolve()
445
+ if not _is_installed_any_scope(normalized, resolved_repo_root):
446
+ if project:
447
+ raise PluginInstallError("plugin not installed; run `alysis ext install <id>` first")
448
+ raise PluginInstallError(f"plugin not installed: {plugin_id}")
449
+ if project:
450
+ return _set_project_override_enabled(
451
+ plugin_id=normalized,
452
+ repo_root=resolved_repo_root,
453
+ enabled=enabled,
454
+ )
455
+ return _set_user_enabled(plugin_id=normalized, repo_root=resolved_repo_root, enabled=enabled)
456
+
457
+
458
+ def _is_installed_any_scope(plugin_id: str, repo_root: Path) -> bool:
459
+ return _state_has_installed_plugin(
460
+ load_global_state(), plugin_id
461
+ ) or _state_has_installed_plugin(load_project_state(repo_root), plugin_id)
462
+
463
+
464
+ def _state_has_installed_plugin(state: ExtensionState, plugin_id: str) -> bool:
465
+ return any(normalize_extension_id(ext_id) == plugin_id for ext_id in state.installed)
466
+
467
+
468
+ def _set_user_enabled(*, plugin_id: str, repo_root: Path, enabled: bool) -> EnableResult:
469
+ state = load_global_state()
470
+ record_key = _installed_record_key(state, plugin_id)
471
+ normalized_enabled = _normalized_id_set(state.enabled)
472
+ record_enabled = bool(record_key and state.installed[record_key].enabled)
473
+ previous_state: Literal["enabled", "disabled", "absent"]
474
+ if plugin_id in normalized_enabled or record_enabled:
475
+ previous_state = "enabled"
476
+ elif record_key is not None:
477
+ previous_state = "disabled"
478
+ else:
479
+ previous_state = "absent"
480
+
481
+ target_state: Literal["enabled", "disabled"] = "enabled" if enabled else "disabled"
482
+ if previous_state == target_state:
483
+ return EnableResult(
484
+ plugin_id=plugin_id,
485
+ scope="user",
486
+ previous_state=previous_state,
487
+ new_state=target_state,
488
+ no_op=True,
489
+ )
490
+
491
+ updated = state.model_copy(deep=True)
492
+ if enabled:
493
+ updated.enabled = _with_normalized_id(updated.enabled, plugin_id)
494
+ if record_key is not None:
495
+ updated.installed[record_key].enabled = True
496
+ else:
497
+ updated.enabled = _without_normalized_id(updated.enabled, plugin_id)
498
+ if record_key is not None:
499
+ updated.installed[record_key].enabled = False
500
+ _save_extension_state(scope="user", repo_root=repo_root, state=updated)
501
+ return EnableResult(
502
+ plugin_id=plugin_id,
503
+ scope="user",
504
+ previous_state=previous_state,
505
+ new_state=target_state,
506
+ no_op=False,
507
+ )
508
+
509
+
510
+ def _set_project_override_enabled(
511
+ *,
512
+ plugin_id: str,
513
+ repo_root: Path,
514
+ enabled: bool,
515
+ ) -> EnableResult:
516
+ path = project_extensions_path(repo_root)
517
+ raw = _read_project_extensions_raw(path)
518
+ overrides = ProjectExtensionOverrides.model_validate(raw)
519
+ current_enabled = _normalized_id_set(overrides.enabled)
520
+ current_disabled = _normalized_id_set(overrides.disabled)
521
+ if plugin_id in current_enabled:
522
+ previous_state: Literal["enabled", "disabled", "absent"] = "enabled"
523
+ elif plugin_id in current_disabled:
524
+ previous_state = "disabled"
525
+ else:
526
+ previous_state = "absent"
527
+
528
+ target_state: Literal["enabled", "disabled"] = "enabled" if enabled else "disabled"
529
+ if previous_state == target_state:
530
+ return EnableResult(
531
+ plugin_id=plugin_id,
532
+ scope="project",
533
+ previous_state=previous_state,
534
+ new_state=target_state,
535
+ no_op=True,
536
+ )
537
+
538
+ raw["enabled"] = (
539
+ _with_normalized_id(overrides.enabled, plugin_id)
540
+ if enabled
541
+ else _without_normalized_id(overrides.enabled, plugin_id)
542
+ )
543
+ raw["disabled"] = (
544
+ _without_normalized_id(overrides.disabled, plugin_id)
545
+ if enabled
546
+ else _with_normalized_id(overrides.disabled, plugin_id)
547
+ )
548
+ raw.setdefault("schema_version", 1)
549
+ atomic_write_json(path, raw)
550
+ return EnableResult(
551
+ plugin_id=plugin_id,
552
+ scope="project",
553
+ previous_state=previous_state,
554
+ new_state=target_state,
555
+ no_op=False,
556
+ )
557
+
558
+
559
+ def _read_project_extensions_raw(path: Path) -> dict[str, Any]:
560
+ if not path.exists():
561
+ return {"schema_version": 1}
562
+ try:
563
+ raw = json.loads(path.read_text(encoding="utf-8"))
564
+ except json.JSONDecodeError as exc:
565
+ raise RuntimeError(f"Invalid JSON in {path}") from exc
566
+ except OSError as exc:
567
+ raise RuntimeError(f"Failed to read {path}") from exc
568
+ if not isinstance(raw, dict):
569
+ raise RuntimeError(f"Invalid JSON format in {path}: expected object.")
570
+ return raw
571
+
572
+
573
+ def _installed_record_key(state: ExtensionState, plugin_id: str) -> str | None:
574
+ for ext_id in state.installed:
575
+ if normalize_extension_id(ext_id) == plugin_id:
576
+ return ext_id
577
+ return None
578
+
579
+
580
+ def _normalized_id_set(values: list[str]) -> set[str]:
581
+ return {normalized for value in values if (normalized := normalize_extension_id(value))}
582
+
583
+
584
+ def _with_normalized_id(values: list[str], plugin_id: str) -> list[str]:
585
+ return sorted({*_normalized_id_set(values), plugin_id})
586
+
587
+
588
+ def _without_normalized_id(values: list[str], plugin_id: str) -> list[str]:
589
+ return sorted(value for value in _normalized_id_set(values) if value != plugin_id)
590
+
591
+
592
+ def _resolve_source(source: str) -> _ResolvedSource:
593
+ raw = str(source or "").strip()
594
+ if _SOURCE_ID_RE.fullmatch(raw):
595
+ registry = load_registry()
596
+ entry = find_by_id(registry, raw)
597
+ if entry is None:
598
+ raise PluginInstallError(f"registry id not found: {raw}")
599
+ if not _SHA40_RE.fullmatch(entry.commit or ""):
600
+ raise PluginInstallError(f"registry entry has invalid commit for {raw}: {entry.commit}")
601
+ return _ResolvedSource(
602
+ display_url=f"{entry.repo}@{entry.commit}",
603
+ git_url=entry.repo,
604
+ commit=entry.commit.lower(),
605
+ )
606
+
607
+ if raw.startswith("git+https://"):
608
+ base, marker, commit = raw.rpartition("@")
609
+ if not marker or not _SHA40_RE.fullmatch(commit):
610
+ raise PluginInstallError(f"unsupported install source: {raw}")
611
+ return _ResolvedSource(
612
+ display_url=raw,
613
+ git_url=base.removeprefix("git+"),
614
+ commit=commit.lower(),
615
+ )
616
+
617
+ split = urlsplit(raw)
618
+ if split.scheme == "https" and split.netloc and split.fragment:
619
+ commit = split.fragment
620
+ if not _SHA40_RE.fullmatch(commit):
621
+ raise PluginInstallError(f"unsupported install source: {raw}")
622
+ git_url = urlunsplit((split.scheme, split.netloc, split.path, split.query, ""))
623
+ return _ResolvedSource(display_url=raw, git_url=git_url, commit=commit.lower())
624
+
625
+ raise PluginInstallError(f"unsupported install source: {raw}")
626
+
627
+
628
+ def _clone_pinned_git_repo(*, git_url: str, commit: str, destination: Path) -> None:
629
+ _run_git(["git", "init", os.fspath(destination)], cwd=None)
630
+ _run_git(["git", "remote", "add", "origin", git_url], cwd=destination)
631
+ _run_git(["git", "fetch", "--depth", "1", "origin", commit], cwd=destination)
632
+ _run_git(["git", "checkout", "FETCH_HEAD"], cwd=destination)
633
+ result = _run_git(["git", "rev-parse", "HEAD"], cwd=destination)
634
+ actual = result.stdout.strip().lower()
635
+ if actual != commit.lower():
636
+ raise PluginInstallError(
637
+ f"git checkout mismatch: expected {commit.lower()}, got {actual or '<empty>'}"
638
+ )
639
+
640
+
641
+ def _run_git(args: list[str], *, cwd: Path | None) -> subprocess.CompletedProcess[str]:
642
+ try:
643
+ return subprocess.run(
644
+ args,
645
+ cwd=os.fspath(cwd) if cwd is not None else None,
646
+ shell=False,
647
+ check=True,
648
+ timeout=120,
649
+ capture_output=True,
650
+ text=True,
651
+ )
652
+ except subprocess.TimeoutExpired as exc:
653
+ raise PluginInstallError(f"git command timed out after 120s: {' '.join(args)}") from exc
654
+ except subprocess.CalledProcessError as exc:
655
+ stderr = str(exc.stderr or "").strip()
656
+ detail = f": {stderr}" if stderr else ""
657
+ raise PluginInstallError(f"git command failed: {' '.join(args)}{detail}") from exc
658
+
659
+
660
+ def _check_compatibility(manifest: PluginManifest) -> None:
661
+ specifier = SpecifierSet(manifest.compatibility.alysis)
662
+ running = Version(__version__)
663
+ if running not in specifier:
664
+ raise PluginInstallError(
665
+ "plugin is not compatible with this Alysis Code version: "
666
+ f"running {running}, requires {manifest.compatibility.alysis}"
667
+ )
668
+ platform = _current_platform()
669
+ if platform not in set(manifest.compatibility.platforms):
670
+ allowed = ", ".join(manifest.compatibility.platforms)
671
+ raise PluginInstallError(
672
+ f"plugin is not compatible with this platform: running {platform}, supports {allowed}"
673
+ )
674
+
675
+
676
+ def _current_platform() -> Literal["linux", "darwin", "windows"]:
677
+ if sys.platform.startswith("linux"):
678
+ return "linux"
679
+ if sys.platform == "darwin":
680
+ return "darwin"
681
+ if sys.platform.startswith(("win", "cygwin", "msys")):
682
+ return "windows"
683
+ return "linux"
684
+
685
+
686
+ def _sha256_file(path: Path) -> str:
687
+ return hashlib.sha256(path.read_bytes()).hexdigest()
688
+
689
+
690
+ def _component_summary(manifest: PluginManifest) -> ComponentInstallSummary:
691
+ return ComponentInstallSummary(
692
+ skill_ids=tuple(_effective_id(item.id, item.path) for item in manifest.components.skill),
693
+ tool_ids=tuple(_effective_id(item.id, item.path) for item in manifest.components.tool),
694
+ mcp_server_ids=tuple(item.id for item in manifest.components.mcp_server),
695
+ hook_ids=tuple(_effective_id(item.id, item.path) for item in manifest.components.hook),
696
+ )
697
+
698
+
699
+ def _permissions_summary(manifest: PluginManifest) -> PermissionsSummary:
700
+ required_env = {
701
+ env_name for tool in manifest.components.tool for env_name in tool.required_env
702
+ } | {env_name for server in manifest.components.mcp_server for env_name in server.env}
703
+ return PermissionsSummary(
704
+ network=any(tool.network for tool in manifest.components.tool),
705
+ filesystem_write=any(tool.filesystem == "write" for tool in manifest.components.tool),
706
+ required_env=tuple(sorted(required_env)),
707
+ mcp_scopes=tuple(
708
+ sorted({scope for server in manifest.components.mcp_server for scope in server.scopes})
709
+ ),
710
+ hook_events=tuple(sorted({hook.event for hook in manifest.components.hook})),
711
+ )
712
+
713
+
714
+ def _effective_id(component_id: str | None, path: str) -> str:
715
+ return str(component_id or Path(path).name)
716
+
717
+
718
+ def _summary_from_record(record: InstalledExtensionState) -> ComponentInstallSummary:
719
+ ids = record.component_ids or {}
720
+ return ComponentInstallSummary(
721
+ skill_ids=tuple(str(item) for item in ids.get("skill", [])),
722
+ tool_ids=tuple(str(item) for item in ids.get("tool", [])),
723
+ mcp_server_ids=tuple(str(item) for item in ids.get("mcp_server", [])),
724
+ hook_ids=tuple(str(item) for item in ids.get("hook", [])),
725
+ )
726
+
727
+
728
+ def _load_extension_state(*, scope: Scope, repo_root: Path) -> ExtensionState:
729
+ return load_project_state(repo_root) if scope == "project" else load_global_state()
730
+
731
+
732
+ def _save_extension_state(*, scope: Scope, repo_root: Path, state: ExtensionState) -> None:
733
+ if scope == "project":
734
+ save_project_state(repo_root, state)
735
+ return
736
+ save_global_state(state)
737
+
738
+
739
+ def _state_with_installed_record(
740
+ state: ExtensionState,
741
+ *,
742
+ plugin_id: str,
743
+ version: str,
744
+ commit: str,
745
+ source_url: str,
746
+ manifest_sha256: str,
747
+ scope: Scope,
748
+ summary: ComponentInstallSummary,
749
+ ) -> ExtensionState:
750
+ updated = state.model_copy(deep=True)
751
+ updated.installed[plugin_id] = InstalledExtensionState(
752
+ id=plugin_id,
753
+ version=version,
754
+ commit=commit,
755
+ source=source_url,
756
+ source_url=source_url,
757
+ trust="manifest-sha256",
758
+ enabled=True,
759
+ manifest_sha256=manifest_sha256,
760
+ installed_at=datetime.now(UTC).replace(microsecond=0).isoformat(),
761
+ scope=scope,
762
+ component_ids={
763
+ "skill": list(summary.skill_ids),
764
+ "tool": list(summary.tool_ids),
765
+ "mcp_server": list(summary.mcp_server_ids),
766
+ "hook": list(summary.hook_ids),
767
+ },
768
+ )
769
+ updated.enabled = _with_normalized_id(updated.enabled, plugin_id)
770
+ return updated
771
+
772
+
773
+ def _install_plugin_tree(
774
+ *,
775
+ source_root: Path,
776
+ target_root: Path,
777
+ rollback: list[RollbackFn],
778
+ cleanup_after_success: list[RollbackFn],
779
+ ) -> None:
780
+ target_root.parent.mkdir(parents=True, exist_ok=True)
781
+ backup_path: Path | None = None
782
+ if target_root.exists() or target_root.is_symlink():
783
+ backup_path = target_root.parent / f".{target_root.name}.backup-{os.getpid()}"
784
+ if backup_path.exists():
785
+ _remove_path(backup_path)
786
+ os.replace(target_root, backup_path)
787
+ shutil.copytree(source_root, target_root, ignore=shutil.ignore_patterns(".git"))
788
+
789
+ def rollback_plugin_tree() -> None:
790
+ _remove_path(target_root)
791
+ if backup_path is not None and (backup_path.exists() or backup_path.is_symlink()):
792
+ os.replace(backup_path, target_root)
793
+
794
+ rollback.append(rollback_plugin_tree)
795
+ if backup_path is not None:
796
+ cleanup_after_success.append(lambda path=backup_path: _remove_path(path))
797
+
798
+
799
+ def _install_skills(
800
+ *,
801
+ manifest: PluginManifest,
802
+ staging_root: Path,
803
+ repo_root: Path,
804
+ project: bool,
805
+ user_config_dir: Path | None,
806
+ force: bool,
807
+ rollback: list[RollbackFn],
808
+ ) -> list[str]:
809
+ installed: list[str] = []
810
+ for component in manifest.components.skill:
811
+ if not component.enabled:
812
+ continue
813
+ result = install_skill_bundle(
814
+ source=os.fspath(staging_root / component.path),
815
+ workspace_root=repo_root,
816
+ project=project,
817
+ force=force,
818
+ user_config_dir=user_config_dir,
819
+ )
820
+ installed.append(result.installed_name)
821
+ rollback.append(
822
+ lambda name=result.installed_name: remove_managed_skill(
823
+ name=name,
824
+ workspace_root=repo_root,
825
+ project=project,
826
+ user_config_dir=user_config_dir,
827
+ )
828
+ )
829
+ return installed
830
+
831
+
832
+ def _install_tools(
833
+ *,
834
+ manifest: PluginManifest,
835
+ staging_root: Path,
836
+ repo_root: Path,
837
+ project: bool,
838
+ user_config_dir: Path | None,
839
+ rollback: list[RollbackFn],
840
+ ) -> list[str]:
841
+ installed: list[str] = []
842
+ tools_root = (
843
+ project_custom_tools_root(repo_root)
844
+ if project
845
+ else global_custom_tools_root(user_config_dir=user_config_dir)
846
+ )
847
+ plugin_dir = tools_root / "plugins" / plugin_slug_from_id(manifest.plugin.id)
848
+ for component in manifest.components.tool:
849
+ if not component.enabled:
850
+ continue
851
+ tool_id = _effective_id(component.id, component.path)
852
+ source_path = staging_root / component.path
853
+ dest_path = plugin_dir / f"{_safe_component_slug(tool_id)}{source_path.suffix or '.py'}"
854
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
855
+ shutil.copy2(source_path, dest_path)
856
+ file_hash = _sha256_file(dest_path)
857
+ record = _ToolRecord(
858
+ plugin_id=manifest.plugin.id,
859
+ tool_id=tool_id,
860
+ scope="project" if project else "user",
861
+ relative_path=dest_path.relative_to(tools_root).as_posix(),
862
+ file_hash=file_hash,
863
+ )
864
+ _mutate_plugin_managed_tools_state(
865
+ tools_root,
866
+ lambda current, item=record: _with_tool_record(current, item),
867
+ )
868
+ trust_snapshot = load_tool_trust_state(user_config_dir=user_config_dir)
869
+ if project:
870
+ _trust_project_plugin_tool(
871
+ repo_root=repo_root,
872
+ dest_path=dest_path,
873
+ file_hash=file_hash,
874
+ user_config_dir=user_config_dir,
875
+ )
876
+
877
+ rollback.append(
878
+ lambda item=record, path=dest_path, snapshot=trust_snapshot: _rollback_tool_install(
879
+ tools_root=tools_root,
880
+ record=item,
881
+ path=path,
882
+ project=project,
883
+ user_config_dir=user_config_dir,
884
+ trust_snapshot=snapshot,
885
+ )
886
+ )
887
+ installed.append(tool_id)
888
+ return installed
889
+
890
+
891
+ def _install_mcp_servers(
892
+ *,
893
+ manifest: PluginManifest,
894
+ rollback: list[RollbackFn],
895
+ user_config_dir: Path | None,
896
+ ) -> list[str]:
897
+ installed = [server.id for server in manifest.components.mcp_server if server.enabled]
898
+ if not installed:
899
+ return []
900
+ path = _user_mcp_config_path(user_config_dir=user_config_dir)
901
+ snapshot = _read_optional_text(path)
902
+ config = _load_user_mcp_config(path) if path.exists() else UserMcpConfigFile()
903
+ dumped = config.model_dump(mode="json", exclude_none=True)
904
+ servers = dict(dumped.get("servers", {}))
905
+ for server in manifest.components.mcp_server:
906
+ if not server.enabled:
907
+ continue
908
+ key = _mcp_server_key(manifest.plugin.id, server.id)
909
+ payload: dict[str, Any] = {
910
+ "transport": server.transport,
911
+ "enabled": True,
912
+ "trust": "explicit",
913
+ }
914
+ if server.transport == "stdio":
915
+ command = list(server.command or [])
916
+ payload["command"] = command[0]
917
+ payload["args"] = command[1:]
918
+ if server.env:
919
+ payload["env"] = {name: f"${{{name}}}" for name in server.env}
920
+ else:
921
+ payload["url"] = str(server.url)
922
+ if server.oauth is not None:
923
+ payload["oauth"] = server.oauth
924
+ servers[key] = payload
925
+ updated = UserMcpConfigFile.model_validate(
926
+ {"schema_version": config.schema_version, "servers": servers}
927
+ )
928
+ _write_model_json(path, updated)
929
+ rollback.append(lambda target=path, text=snapshot: _restore_optional_text(target, text))
930
+ return installed
931
+
932
+
933
+ def _install_hooks(
934
+ *,
935
+ manifest: PluginManifest,
936
+ staging_root: Path,
937
+ repo_root: Path,
938
+ project: bool,
939
+ user_config_dir: Path | None,
940
+ rollback: list[RollbackFn],
941
+ ) -> list[str]:
942
+ installed: list[str] = []
943
+ enabled_hooks = [hook for hook in manifest.components.hook if hook.enabled]
944
+ if not enabled_hooks:
945
+ return installed
946
+
947
+ config_path = _hooks_config_path(
948
+ repo_root=repo_root,
949
+ project=project,
950
+ user_config_dir=user_config_dir,
951
+ )
952
+ hook_root = _hooks_component_root(
953
+ manifest.plugin.id,
954
+ repo_root=repo_root,
955
+ project=project,
956
+ user_config_dir=user_config_dir,
957
+ )
958
+ snapshot = _read_optional_text(config_path)
959
+ trust_snapshot = load_hooks_trust_state(user_config_dir=user_config_dir)
960
+ raw_config = _read_json_config(config_path, default={"schema_version": 1, "hooks": {}})
961
+ hooks_root = raw_config.setdefault("hooks", {})
962
+ if not isinstance(hooks_root, dict):
963
+ raise PluginInstallError(f"Invalid hooks config format: {config_path}")
964
+
965
+ for component in enabled_hooks:
966
+ hook_id = _effective_id(component.id, component.path)
967
+ source_path = staging_root / component.path
968
+ dest_path = hook_root / f"{_safe_component_slug(hook_id)}{source_path.suffix}"
969
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
970
+ shutil.copy2(source_path, dest_path)
971
+ command = _hook_command(dest_path)
972
+ event = _hook_event_name(component.event)
973
+ group = {
974
+ "hooks": [
975
+ {
976
+ "type": "command",
977
+ "id": _hook_config_id(manifest.plugin.id, hook_id),
978
+ "description": f"Plugin hook from {manifest.plugin.id}",
979
+ "command": command,
980
+ "enabled": True,
981
+ }
982
+ ]
983
+ }
984
+ hooks_root.setdefault(event, []).append(group)
985
+ installed.append(hook_id)
986
+
987
+ HookConfigFile.model_validate(raw_config)
988
+ atomic_write_json(config_path, raw_config)
989
+ if project:
990
+ _trust_project_hooks_config(
991
+ workspace_root=repo_root,
992
+ config_path=config_path,
993
+ user_config_dir=user_config_dir,
994
+ )
995
+ rollback.append(
996
+ lambda target=config_path, text=snapshot, root=hook_root, trust=trust_snapshot: (
997
+ _restore_optional_text(target, text),
998
+ _remove_path(root),
999
+ save_hooks_trust_state(trust, user_config_dir=user_config_dir),
1000
+ )
1001
+ )
1002
+ return installed
1003
+
1004
+
1005
+ def _remove_tool_component(
1006
+ *,
1007
+ plugin_id: str,
1008
+ tool_id: str,
1009
+ repo_root: Path,
1010
+ project: bool,
1011
+ user_config_dir: Path | None,
1012
+ ) -> None:
1013
+ tools_root = (
1014
+ project_custom_tools_root(repo_root)
1015
+ if project
1016
+ else global_custom_tools_root(user_config_dir=user_config_dir)
1017
+ )
1018
+ state = _load_plugin_managed_tools_state(tools_root)
1019
+ matching = [
1020
+ item for item in state.tools if item.plugin_id == plugin_id and item.tool_id == tool_id
1021
+ ]
1022
+ for record in matching:
1023
+ _remove_path(tools_root / record.relative_path)
1024
+ if project:
1025
+ _untrust_project_plugin_tool(
1026
+ repo_root=repo_root,
1027
+ relative_path=(tools_root / record.relative_path).relative_to(repo_root).as_posix(),
1028
+ user_config_dir=user_config_dir,
1029
+ )
1030
+ _mutate_plugin_managed_tools_state(
1031
+ tools_root,
1032
+ lambda current: _without_tool_record(current, plugin_id=plugin_id, tool_id=tool_id),
1033
+ )
1034
+
1035
+
1036
+ def _remove_mcp_server_component(
1037
+ *,
1038
+ plugin_id: str,
1039
+ server_id: str,
1040
+ user_config_dir: Path | None,
1041
+ ) -> None:
1042
+ path = _user_mcp_config_path(user_config_dir=user_config_dir)
1043
+ if not path.exists():
1044
+ return
1045
+ config = _load_user_mcp_config(path)
1046
+ dumped = config.model_dump(mode="json", exclude_none=True)
1047
+ servers = dict(dumped.get("servers", {}))
1048
+ servers.pop(_mcp_server_key(plugin_id, server_id), None)
1049
+ updated = UserMcpConfigFile.model_validate(
1050
+ {"schema_version": config.schema_version, "servers": servers}
1051
+ )
1052
+ _write_model_json(path, updated)
1053
+
1054
+
1055
+ def _remove_hook_component(
1056
+ *,
1057
+ plugin_id: str,
1058
+ hook_id: str,
1059
+ repo_root: Path,
1060
+ project: bool,
1061
+ user_config_dir: Path | None,
1062
+ ) -> None:
1063
+ config_path = _hooks_config_path(
1064
+ repo_root=repo_root,
1065
+ project=project,
1066
+ user_config_dir=user_config_dir,
1067
+ )
1068
+ if config_path.exists():
1069
+ raw = _read_json_config(config_path, default={"schema_version": 1, "hooks": {}})
1070
+ config_hook_id = _hook_config_id(plugin_id, hook_id)
1071
+ hooks_root = raw.get("hooks")
1072
+ if isinstance(hooks_root, dict):
1073
+ for event_name, groups in list(hooks_root.items()):
1074
+ if not isinstance(groups, list):
1075
+ continue
1076
+ retained_groups: list[Any] = []
1077
+ for group in groups:
1078
+ if not isinstance(group, dict):
1079
+ retained_groups.append(group)
1080
+ continue
1081
+ hook_list = group.get("hooks")
1082
+ if not isinstance(hook_list, list):
1083
+ retained_groups.append(group)
1084
+ continue
1085
+ retained_hooks = [
1086
+ hook
1087
+ for hook in hook_list
1088
+ if not (isinstance(hook, dict) and hook.get("id") == config_hook_id)
1089
+ ]
1090
+ if retained_hooks:
1091
+ group["hooks"] = retained_hooks
1092
+ retained_groups.append(group)
1093
+ if retained_groups:
1094
+ hooks_root[event_name] = retained_groups
1095
+ else:
1096
+ hooks_root.pop(event_name, None)
1097
+ HookConfigFile.model_validate(raw)
1098
+ atomic_write_json(config_path, raw)
1099
+ if project:
1100
+ _trust_project_hooks_config(
1101
+ workspace_root=repo_root,
1102
+ config_path=config_path,
1103
+ user_config_dir=user_config_dir,
1104
+ )
1105
+ hook_root = _hooks_component_root(
1106
+ plugin_id,
1107
+ repo_root=repo_root,
1108
+ project=project,
1109
+ user_config_dir=user_config_dir,
1110
+ )
1111
+ for candidate in hook_root.glob(f"{_safe_component_slug(hook_id)}*"):
1112
+ _remove_path(candidate)
1113
+
1114
+
1115
+ def _collect_remove_error(
1116
+ errors: list[str],
1117
+ kind: str,
1118
+ component_id: str,
1119
+ remove: Callable[[], object],
1120
+ ) -> None:
1121
+ try:
1122
+ remove()
1123
+ except Exception as exc: # noqa: BLE001
1124
+ errors.append(f"{kind} {component_id}: {exc}")
1125
+
1126
+
1127
+ def _run_rollback(rollback: list[RollbackFn]) -> tuple[str, ...]:
1128
+ errors: list[str] = []
1129
+ for action in reversed(rollback):
1130
+ try:
1131
+ action()
1132
+ except Exception as exc: # noqa: BLE001
1133
+ errors.append(str(exc))
1134
+ return tuple(errors)
1135
+
1136
+
1137
+ def _plugin_managed_tools_state_path(tools_root: Path) -> Path:
1138
+ return tools_root / "plugin_managed_tools.json"
1139
+
1140
+
1141
+ def _load_plugin_managed_tools_state(tools_root: Path) -> _PluginManagedToolsState:
1142
+ path = _plugin_managed_tools_state_path(tools_root)
1143
+ if not path.exists():
1144
+ return _PluginManagedToolsState()
1145
+ raw = json.loads(path.read_text(encoding="utf-8"))
1146
+ if not isinstance(raw, dict):
1147
+ raise PluginInstallError(f"Invalid plugin-managed tools state: {path}")
1148
+ if raw.get("schema_version", _PLUGIN_MANAGED_TOOLS_SCHEMA_VERSION) != (
1149
+ _PLUGIN_MANAGED_TOOLS_SCHEMA_VERSION
1150
+ ):
1151
+ raise PluginInstallError(f"Unsupported plugin-managed tools state schema: {path}")
1152
+ tools_raw = raw.get("tools", [])
1153
+ if not isinstance(tools_raw, list):
1154
+ raise PluginInstallError(f"Invalid plugin-managed tools state tools payload: {path}")
1155
+ records: list[_ToolRecord] = []
1156
+ for item in tools_raw:
1157
+ if not isinstance(item, dict):
1158
+ continue
1159
+ records.append(
1160
+ _ToolRecord(
1161
+ plugin_id=str(item.get("plugin_id") or ""),
1162
+ tool_id=str(item.get("tool_id") or ""),
1163
+ scope="project" if item.get("scope") == "project" else "user",
1164
+ relative_path=str(item.get("relative_path") or ""),
1165
+ file_hash=str(item.get("file_hash") or ""),
1166
+ )
1167
+ )
1168
+ return _PluginManagedToolsState(tools=tuple(record for record in records if record.plugin_id))
1169
+
1170
+
1171
+ def _save_plugin_managed_tools_state(
1172
+ tools_root: Path,
1173
+ state: _PluginManagedToolsState,
1174
+ ) -> None:
1175
+ payload = {
1176
+ "schema_version": _PLUGIN_MANAGED_TOOLS_SCHEMA_VERSION,
1177
+ "tools": [
1178
+ {
1179
+ "plugin_id": record.plugin_id,
1180
+ "tool_id": record.tool_id,
1181
+ "scope": record.scope,
1182
+ "relative_path": record.relative_path,
1183
+ "file_hash": record.file_hash,
1184
+ }
1185
+ for record in sorted(
1186
+ state.tools,
1187
+ key=lambda item: (item.plugin_id, item.tool_id, item.relative_path),
1188
+ )
1189
+ ],
1190
+ }
1191
+ atomic_write_json(_plugin_managed_tools_state_path(tools_root), payload)
1192
+
1193
+
1194
+ def _mutate_plugin_managed_tools_state(
1195
+ tools_root: Path,
1196
+ mutation: Callable[[_PluginManagedToolsState], _PluginManagedToolsState],
1197
+ ) -> None:
1198
+ current = _load_plugin_managed_tools_state(tools_root)
1199
+ updated = mutation(current)
1200
+ _save_plugin_managed_tools_state(tools_root, updated)
1201
+
1202
+
1203
+ def _with_tool_record(
1204
+ state: _PluginManagedToolsState,
1205
+ record: _ToolRecord,
1206
+ ) -> _PluginManagedToolsState:
1207
+ retained = [
1208
+ item
1209
+ for item in state.tools
1210
+ if not (item.plugin_id == record.plugin_id and item.tool_id == record.tool_id)
1211
+ ]
1212
+ retained.append(record)
1213
+ return _PluginManagedToolsState(tools=tuple(retained))
1214
+
1215
+
1216
+ def _without_tool_record(
1217
+ state: _PluginManagedToolsState,
1218
+ *,
1219
+ plugin_id: str,
1220
+ tool_id: str,
1221
+ ) -> _PluginManagedToolsState:
1222
+ return _PluginManagedToolsState(
1223
+ tools=tuple(
1224
+ item
1225
+ for item in state.tools
1226
+ if not (item.plugin_id == plugin_id and item.tool_id == tool_id)
1227
+ )
1228
+ )
1229
+
1230
+
1231
+ def _rollback_tool_install(
1232
+ *,
1233
+ tools_root: Path,
1234
+ record: _ToolRecord,
1235
+ path: Path,
1236
+ project: bool,
1237
+ user_config_dir: Path | None,
1238
+ trust_snapshot: ProjectToolTrustState,
1239
+ ) -> None:
1240
+ _remove_path(path)
1241
+ _mutate_plugin_managed_tools_state(
1242
+ tools_root,
1243
+ lambda current: _without_tool_record(
1244
+ current,
1245
+ plugin_id=record.plugin_id,
1246
+ tool_id=record.tool_id,
1247
+ ),
1248
+ )
1249
+ if project:
1250
+ save_tool_trust_state(trust_snapshot, user_config_dir=user_config_dir)
1251
+
1252
+
1253
+ def _trust_project_plugin_tool(
1254
+ *,
1255
+ repo_root: Path,
1256
+ dest_path: Path,
1257
+ file_hash: str,
1258
+ user_config_dir: Path | None,
1259
+ ) -> None:
1260
+ state = load_tool_trust_state(user_config_dir=user_config_dir)
1261
+ key = ProjectToolTrustKey(
1262
+ workspace_root=os.fspath(repo_root.resolve()),
1263
+ relative_tool_path=dest_path.relative_to(repo_root.resolve()).as_posix(),
1264
+ file_hash=file_hash,
1265
+ )
1266
+ updated = ProjectToolTrustState(
1267
+ trusted_tools=tuple(
1268
+ sorted(
1269
+ {*(state.trusted_tools), key},
1270
+ key=lambda item: (item.workspace_root, item.relative_tool_path, item.file_hash),
1271
+ )
1272
+ )
1273
+ )
1274
+ save_tool_trust_state(updated, user_config_dir=user_config_dir)
1275
+
1276
+
1277
+ def _untrust_project_plugin_tool(
1278
+ *,
1279
+ repo_root: Path,
1280
+ relative_path: str,
1281
+ user_config_dir: Path | None,
1282
+ ) -> None:
1283
+ state = load_tool_trust_state(user_config_dir=user_config_dir)
1284
+ workspace = os.fspath(repo_root.resolve())
1285
+ updated = ProjectToolTrustState(
1286
+ trusted_tools=tuple(
1287
+ key
1288
+ for key in state.trusted_tools
1289
+ if not (key.workspace_root == workspace and key.relative_tool_path == relative_path)
1290
+ )
1291
+ )
1292
+ save_tool_trust_state(updated, user_config_dir=user_config_dir)
1293
+
1294
+
1295
+ def _user_mcp_config_path(*, user_config_dir: Path | None) -> Path:
1296
+ if user_config_dir is not None:
1297
+ return user_config_dir.expanduser().resolve() / "mcp.json"
1298
+ return user_mcp_config_path()
1299
+
1300
+
1301
+ def _mcp_server_key(plugin_id: str, server_id: str) -> str:
1302
+ return f"{plugin_id}/{server_id}".lower()
1303
+
1304
+
1305
+ def _hooks_config_path(
1306
+ *,
1307
+ repo_root: Path,
1308
+ project: bool,
1309
+ user_config_dir: Path | None,
1310
+ ) -> Path:
1311
+ if project:
1312
+ return project_hooks_config_path(repo_root)
1313
+ if user_config_dir is not None:
1314
+ return user_config_dir.expanduser().resolve() / "hooks.json"
1315
+ return user_hooks_config_path()
1316
+
1317
+
1318
+ def _hooks_component_root(
1319
+ plugin_id: str,
1320
+ *,
1321
+ repo_root: Path,
1322
+ project: bool,
1323
+ user_config_dir: Path | None,
1324
+ ) -> Path:
1325
+ base = (
1326
+ repo_root.resolve() / ".alysis"
1327
+ if project
1328
+ else (
1329
+ user_config_dir.expanduser().resolve()
1330
+ if user_config_dir is not None
1331
+ else user_hooks_config_path().parent
1332
+ )
1333
+ )
1334
+ return base / "hooks" / "plugins" / plugin_slug_from_id(plugin_id)
1335
+
1336
+
1337
+ def _hook_event_name(event_name: str) -> str:
1338
+ if event_name == "SessionStop":
1339
+ return "SessionEnd"
1340
+ return event_name
1341
+
1342
+
1343
+ def _hook_command(path: Path) -> str:
1344
+ if path.suffix.casefold() == ".py":
1345
+ return subprocess.list2cmdline([sys.executable, os.fspath(path)])
1346
+ return subprocess.list2cmdline([os.fspath(path)])
1347
+
1348
+
1349
+ def _hook_config_id(plugin_id: str, hook_id: str) -> str:
1350
+ return f"{plugin_slug_from_id(plugin_id)}.{_safe_component_slug(hook_id)}"
1351
+
1352
+
1353
+ def _trust_project_hooks_config(
1354
+ *,
1355
+ workspace_root: Path,
1356
+ config_path: Path,
1357
+ user_config_dir: Path | None,
1358
+ ) -> None:
1359
+ state = load_hooks_trust_state(user_config_dir=user_config_dir)
1360
+ key = ProjectHooksTrustKey(
1361
+ workspace_root=os.fspath(workspace_root.resolve()),
1362
+ relative_config_path=config_path.resolve().relative_to(workspace_root.resolve()).as_posix(),
1363
+ file_hash=_sha256_file(config_path),
1364
+ )
1365
+ updated = ProjectHooksTrustState(
1366
+ trusted_configs=tuple(
1367
+ sorted(
1368
+ {*(state.trusted_configs), key},
1369
+ key=lambda item: (item.workspace_root, item.relative_config_path, item.file_hash),
1370
+ )
1371
+ )
1372
+ )
1373
+ save_hooks_trust_state(updated, user_config_dir=user_config_dir)
1374
+
1375
+
1376
+ def _safe_component_slug(value: str) -> str:
1377
+ candidate = _UNSAFE_PATH_CHARS_RE.sub("-", str(value or "").strip().lower()).strip(".-")
1378
+ return candidate or "component"
1379
+
1380
+
1381
+ def _read_json_config(path: Path, *, default: dict[str, Any]) -> dict[str, Any]:
1382
+ if not path.exists():
1383
+ return dict(default)
1384
+ raw = json.loads(path.read_text(encoding="utf-8"))
1385
+ if not isinstance(raw, dict):
1386
+ raise PluginInstallError(f"Invalid JSON config format: {path}")
1387
+ return raw
1388
+
1389
+
1390
+ def _write_model_json(path: Path, model: BaseModel) -> None:
1391
+ atomic_write_json(path, model.model_dump(mode="json", exclude_none=True))
1392
+
1393
+
1394
+ def _read_optional_text(path: Path) -> str | None:
1395
+ if not path.exists():
1396
+ return None
1397
+ return path.read_text(encoding="utf-8")
1398
+
1399
+
1400
+ def _restore_optional_text(path: Path, text: str | None) -> None:
1401
+ if text is None:
1402
+ if path.exists():
1403
+ path.unlink()
1404
+ return
1405
+ atomic_write_text(path, text)
1406
+
1407
+
1408
+ def _remove_path(path: Path) -> None:
1409
+ if not path.exists() and not path.is_symlink():
1410
+ return
1411
+ if path.is_dir() and not path.is_symlink():
1412
+ shutil.rmtree(path, onerror=_rmtree_onerror)
1413
+ return
1414
+ path.unlink()
1415
+
1416
+
1417
+ def _rmtree_onerror(
1418
+ func: Callable[[str], object],
1419
+ path: str,
1420
+ _exc_info: object,
1421
+ ) -> None:
1422
+ os.chmod(path, stat.S_IWRITE)
1423
+ func(path)
1424
+
1425
+
1426
+ __all__ = [
1427
+ "ComponentInstallSummary",
1428
+ "PermissionsSummary",
1429
+ "PluginInstallError",
1430
+ "PluginInstallResult",
1431
+ "PluginUninstallResult",
1432
+ "TrustPromptFn",
1433
+ "TrustPromptRequest",
1434
+ "install_plugin",
1435
+ "uninstall_plugin",
1436
+ ]