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,3414 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import platform
6
+ import re
7
+ import shutil
8
+ import sys
9
+ from collections.abc import Callable, Mapping
10
+ from dataclasses import asdict, is_dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib.parse import urlparse
14
+
15
+ from .. import __version__
16
+ from ..cli_impl.commands.hooks import (
17
+ _HOOK_SESSION_SOURCES,
18
+ _HOOKS_INIT_TEMPLATE,
19
+ _collect_hook_source_statuses,
20
+ _find_hook_in_layer,
21
+ _hook_test_match_result,
22
+ _hooks_config_path_for_layer,
23
+ )
24
+ from ..cli_impl.commands.mcp import (
25
+ _build_mcp_auth_status_rows,
26
+ _manual_mcp_manager_for_path,
27
+ _manual_mcp_resolved_config_for_path,
28
+ _mcp_status_payload,
29
+ _parse_manual_mcp_prompt_runtime,
30
+ _require_http_oauth_server_for_auth,
31
+ _resolve_mcp_server_by_id,
32
+ )
33
+ from ..config import (
34
+ AppConfig,
35
+ ConfigError,
36
+ clear_persisted_profile_key,
37
+ config_path,
38
+ credentials_path,
39
+ load_config,
40
+ rename_persisted_profile_key,
41
+ resolve_api_key,
42
+ resolve_profile_api_key,
43
+ save_config,
44
+ set_config_value,
45
+ )
46
+ from ..custom_tools import (
47
+ CustomToolCatalogEntry,
48
+ global_custom_tools_root,
49
+ project_custom_tools_root,
50
+ trust_project_tool,
51
+ untrust_project_tool,
52
+ )
53
+ from ..extensions.install import (
54
+ PluginInstallError,
55
+ disable_plugin,
56
+ enable_plugin,
57
+ install_plugin,
58
+ uninstall_plugin,
59
+ )
60
+ from ..extensions.models import normalize_extension_id
61
+ from ..extensions.paths import project_extensions_path
62
+ from ..extensions.registry import find_by_id, load_registry
63
+ from ..extensions.registry import search as search_extensions
64
+ from ..extensions.state import (
65
+ compute_effective_enabled,
66
+ load_global_state,
67
+ load_project_overrides,
68
+ load_project_state,
69
+ )
70
+ from ..extensions.workspace_trust import is_workspace_trusted as extension_workspace_trusted
71
+ from ..feedback_report import (
72
+ FeedbackReportError,
73
+ create_feedback_bundle,
74
+ create_feedback_github_issue_draft,
75
+ feedback_github_issue_status_lines,
76
+ resolve_feedback_workspace_root,
77
+ )
78
+ from ..hooks import (
79
+ canonicalize_hook_event_name,
80
+ hook_audit_artifact_path,
81
+ load_hook_config_file,
82
+ load_resolved_hooks_config,
83
+ project_hooks_config_path,
84
+ project_local_hooks_config_path,
85
+ read_hook_audit_events,
86
+ trust_project_hooks_config,
87
+ untrust_project_hooks_config,
88
+ )
89
+ from ..mcp.oauth_store import delete_oauth_token_record
90
+ from ..profile_presets import (
91
+ PROFILE_PRESETS,
92
+ convert_profile_to_preset,
93
+ get_preset,
94
+ make_profile_from_preset,
95
+ normalize_conversion_target,
96
+ preset_protocol_kind,
97
+ preset_selection_label,
98
+ target_preset_for_profile_conversion,
99
+ )
100
+ from ..profiles import (
101
+ SUBSCRIPTION_SELECTION_REQUIRED_KEY,
102
+ ProfileSpec,
103
+ active_subscription_selection_ready,
104
+ add_profile,
105
+ get_active_profile,
106
+ get_profile,
107
+ list_profiles,
108
+ remove_profile,
109
+ rename_profile,
110
+ set_active_profile,
111
+ subscription_selection_supported,
112
+ update_active_profile_defaults,
113
+ update_profile,
114
+ )
115
+ from ..provider_auth import create_provider_auth
116
+ from ..provider_diagnostics import build_provider_diagnostics, validate_active_provider_live
117
+ from ..provider_telemetry import (
118
+ diagnostic_bundle_payload,
119
+ last_provider_call_summary,
120
+ last_web_search_summary,
121
+ )
122
+ from ..runtime_kind import RuntimeKind
123
+ from ..sandbox_doctor import (
124
+ configured_sandbox_images,
125
+ diagnose_sandbox,
126
+ format_sandbox_problem_message,
127
+ pull_sandbox_images,
128
+ sandbox_env_summary,
129
+ )
130
+ from ..session_metrics import score_session_log
131
+ from ..session_store import (
132
+ list_sessions,
133
+ read_session_events,
134
+ resolve_sessions_dir,
135
+ sanitize_session_id,
136
+ )
137
+ from ..skills import (
138
+ SkillLifecycleError,
139
+ discover_skills,
140
+ install_skill_bundle,
141
+ load_global_skill_state,
142
+ load_project_skill_state,
143
+ load_repo_conventions,
144
+ remove_managed_skill,
145
+ render_repo_conventions_context,
146
+ render_skill_info_text,
147
+ resolve_skill_catalog,
148
+ resolve_skill_catalog_entry,
149
+ save_global_skill_state,
150
+ save_project_skill_state,
151
+ scaffold_skill_bundle,
152
+ set_global_skill_disabled,
153
+ set_project_skill_override,
154
+ validate_skill_bundle,
155
+ )
156
+ from ..tools.availability import get_tool_availability
157
+ from ..tools.registry import iter_builtin_tool_metadata
158
+ from ..tools.web_search import resolve_web_search_runtime_status
159
+ from ..updates import check_for_updates, status_from_cache
160
+ from ..usage_tracker import aggregate_usage_from_session_logs
161
+ from ..workspace_binding import WorkspaceAction, WorkspaceBindingError
162
+ from ..workspace_binding_ui import resolve_startup_workspace_binding
163
+ from ..workspace_context import WorkspaceContextError
164
+ from .mcp_oauth_coordinator import McpOAuthLoginRequest
165
+ from .protocol import ProtocolError, RequestId, redact_secrets
166
+
167
+ _SESSION_SHOW_DEFAULT_MAX_EVENTS = 200
168
+ _SESSION_SHOW_MAX_EVENTS = 1_000
169
+ _SESSION_SHOW_DEFAULT_MAX_TOTAL_BYTES = 256 * 1024
170
+ _SESSION_SHOW_MAX_TOTAL_BYTES = 1024 * 1024
171
+ _CONVENTIONS_RENDER_DEFAULT_MAX_CHARS = 4_000
172
+ _CONVENTIONS_RENDER_MAX_CHARS = 100_000
173
+ _CONVENTIONS_RENDER_DEFAULT_MAX_BYTES = 64 * 1024
174
+ _CONVENTIONS_RENDER_MAX_BYTES = 256 * 1024
175
+ _SECRET_ASSIGNMENT_PATTERN = re.compile(
176
+ r"(?i)\b([A-Z0-9_.-]*(?:api[_-]?key|token|secret|password|credential)[A-Z0-9_.-]*\s*[:=]\s*)([^\s,;&]+)"
177
+ )
178
+ _URL_USERINFO_PATTERN = re.compile(
179
+ r"(?i)\b((?:git\+)?https?://)([^/@\s]+)@([A-Za-z0-9.-]+(?::\d+)?)"
180
+ )
181
+ _EXTENSION_REGISTRY_ID_RE = re.compile(r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_-]*$")
182
+ _SHA40_RE = re.compile(r"^[0-9a-fA-F]{40}$")
183
+
184
+ MANAGEMENT_METHODS = (
185
+ "config.get",
186
+ "config.set",
187
+ "config.schema",
188
+ "config.validate",
189
+ "profile.list",
190
+ "profile.show",
191
+ "profile.add",
192
+ "profile.remove",
193
+ "profile.use",
194
+ "profile.rename",
195
+ "profile.presets",
196
+ "profile.preset",
197
+ "profile.convert",
198
+ "session.show",
199
+ "session.usage",
200
+ "session.score",
201
+ "tools.catalog",
202
+ "tool.list",
203
+ "tool.info",
204
+ "tool.trust",
205
+ "tool.untrust",
206
+ "skill.list",
207
+ "skill.info",
208
+ "skill.init",
209
+ "skill.validate",
210
+ "skill.install",
211
+ "skill.enable",
212
+ "skill.disable",
213
+ "skill.remove",
214
+ "doctor.summary",
215
+ "doctor.providers",
216
+ "doctor.providers.live",
217
+ "doctor.bundle",
218
+ "sandbox.doctor",
219
+ "sandbox.setup",
220
+ "sandbox.pull",
221
+ "update.check",
222
+ "report.create",
223
+ "mcp.status",
224
+ "mcp.prompts.list",
225
+ "mcp.prompts.get",
226
+ "mcp.auth.status",
227
+ "mcp.auth.login.start",
228
+ "mcp.auth.login.status",
229
+ "mcp.auth.login.cancel",
230
+ "mcp.auth.logout",
231
+ "hooks.list",
232
+ "hooks.doctor",
233
+ "hooks.trace",
234
+ "hooks.test",
235
+ "hooks.trust",
236
+ "hooks.untrust",
237
+ "hooks.init",
238
+ "hooks.effective",
239
+ "hooks.enable",
240
+ "hooks.disable",
241
+ "conventions.list",
242
+ "conventions.render",
243
+ "ext.search",
244
+ "ext.list",
245
+ "ext.info",
246
+ "ext.install",
247
+ "ext.uninstall",
248
+ "ext.enable",
249
+ "ext.disable",
250
+ )
251
+
252
+ MUTATING_MANAGEMENT_METHODS = frozenset(
253
+ {
254
+ "config.set",
255
+ "profile.add",
256
+ "profile.remove",
257
+ "profile.use",
258
+ "profile.rename",
259
+ "profile.preset",
260
+ "profile.convert",
261
+ "tool.trust",
262
+ "tool.untrust",
263
+ "skill.init",
264
+ "skill.install",
265
+ "skill.enable",
266
+ "skill.disable",
267
+ "skill.remove",
268
+ "sandbox.setup",
269
+ "sandbox.pull",
270
+ "report.create",
271
+ "mcp.auth.login.start",
272
+ "mcp.auth.login.cancel",
273
+ "mcp.auth.logout",
274
+ "hooks.trust",
275
+ "hooks.untrust",
276
+ "hooks.init",
277
+ "hooks.enable",
278
+ "hooks.disable",
279
+ "ext.install",
280
+ "ext.uninstall",
281
+ "ext.enable",
282
+ "ext.disable",
283
+ }
284
+ )
285
+
286
+ WORKSPACE_REQUIRED_MANAGEMENT_METHODS = frozenset(
287
+ {
288
+ "tool.list",
289
+ "tool.info",
290
+ "tool.trust",
291
+ "tool.untrust",
292
+ "skill.list",
293
+ "skill.info",
294
+ "skill.init",
295
+ "skill.validate",
296
+ "skill.install",
297
+ "skill.enable",
298
+ "skill.disable",
299
+ "skill.remove",
300
+ "report.create",
301
+ "mcp.status",
302
+ "mcp.prompts.list",
303
+ "mcp.prompts.get",
304
+ "mcp.auth.status",
305
+ "mcp.auth.login.start",
306
+ "mcp.auth.login.status",
307
+ "mcp.auth.login.cancel",
308
+ "mcp.auth.logout",
309
+ "hooks.list",
310
+ "hooks.doctor",
311
+ "hooks.test",
312
+ "hooks.trust",
313
+ "hooks.untrust",
314
+ "hooks.init",
315
+ "hooks.effective",
316
+ "hooks.enable",
317
+ "hooks.disable",
318
+ "conventions.list",
319
+ "conventions.render",
320
+ "ext.info",
321
+ "ext.list",
322
+ "ext.install",
323
+ "ext.uninstall",
324
+ "ext.enable",
325
+ "ext.disable",
326
+ }
327
+ )
328
+
329
+ MANAGEMENT_METHOD_CAPABILITIES: dict[str, dict[str, Any]] = {
330
+ method: {
331
+ "supported": True,
332
+ "mutates": method in MUTATING_MANAGEMENT_METHODS,
333
+ "trust_required": method in MUTATING_MANAGEMENT_METHODS,
334
+ "workspace_required": method in WORKSPACE_REQUIRED_MANAGEMENT_METHODS,
335
+ "terminal_output_scraping": False,
336
+ "secret_values_in_params": False,
337
+ }
338
+ for method in MANAGEMENT_METHODS
339
+ }
340
+ MANAGEMENT_METHOD_CAPABILITIES["mcp.auth.login.start"].update(
341
+ {
342
+ "supported": True,
343
+ "callable": True,
344
+ "behavior": "returns_authorization_url_and_completes_via_loopback_callback",
345
+ "mutates": True,
346
+ "trust_required": True,
347
+ "passive_safe": False,
348
+ "browser_opened_by_bridge": False,
349
+ "tokens_in_protocol_params": False,
350
+ "secret_store_required": True,
351
+ "lifecycle_methods": [
352
+ "mcp.auth.login.start",
353
+ "mcp.auth.login.status",
354
+ "mcp.auth.login.cancel",
355
+ ],
356
+ "flow_state_persistent": True,
357
+ "loopback_callback_owned": True,
358
+ "callback_host": "127.0.0.1",
359
+ "authorization_code_in_protocol": False,
360
+ }
361
+ )
362
+ MANAGEMENT_METHOD_CAPABILITIES["ext.install"].update(
363
+ {
364
+ "trust_review_required": True,
365
+ "approval_payload": "extension_install_trust_approval_v1",
366
+ "yes_alone_auto_trusts_package": False,
367
+ "source_policy": "registry_id_or_pinned_https_git_source",
368
+ "url_userinfo_rejected": True,
369
+ }
370
+ )
371
+ MANAGEMENT_METHOD_CAPABILITIES["session.show"].update(
372
+ {
373
+ "redacted": True,
374
+ "bounded": True,
375
+ "default_max_events": _SESSION_SHOW_DEFAULT_MAX_EVENTS,
376
+ "max_events": _SESSION_SHOW_MAX_EVENTS,
377
+ "default_max_total_bytes": _SESSION_SHOW_DEFAULT_MAX_TOTAL_BYTES,
378
+ "max_total_bytes": _SESSION_SHOW_MAX_TOTAL_BYTES,
379
+ }
380
+ )
381
+ MANAGEMENT_METHOD_CAPABILITIES["conventions.render"].update(
382
+ {
383
+ "redacted": True,
384
+ "bounded": True,
385
+ "default_max_chars": _CONVENTIONS_RENDER_DEFAULT_MAX_CHARS,
386
+ "max_chars": _CONVENTIONS_RENDER_MAX_CHARS,
387
+ "default_max_bytes": _CONVENTIONS_RENDER_DEFAULT_MAX_BYTES,
388
+ "max_bytes": _CONVENTIONS_RENDER_MAX_BYTES,
389
+ }
390
+ )
391
+ MANAGEMENT_METHOD_CAPABILITIES["update.check"].update(
392
+ {
393
+ "network_default": False,
394
+ "network_requires_explicit_user_intent": True,
395
+ "network_params": ["allow_network", "force"],
396
+ "passive_safe": True,
397
+ }
398
+ )
399
+ MANAGEMENT_METHOD_CAPABILITIES["doctor.providers.live"].update(
400
+ {
401
+ "network_default": False,
402
+ "network_requires_explicit_user_intent": True,
403
+ "network_params": ["allow_live"],
404
+ "live_provider_request": True,
405
+ "passive_safe": False,
406
+ }
407
+ )
408
+ MANAGEMENT_METHOD_CAPABILITIES["skill.install"].update(
409
+ {
410
+ "local_path_policy": "workspace_scoped_regular_dir_or_zip",
411
+ "remote_source_policy": "https_remote_requires_allow_remote_and_confirmation",
412
+ "url_userinfo_rejected": True,
413
+ "rejects_symlinks": True,
414
+ }
415
+ )
416
+
417
+
418
+ def _feature_group(*prefixes: str) -> dict[str, Any]:
419
+ methods = [
420
+ method
421
+ for method in MANAGEMENT_METHODS
422
+ if any(method.startswith(f"{prefix}.") for prefix in prefixes)
423
+ ]
424
+ return {
425
+ "supported": True,
426
+ "methods": methods,
427
+ "mutating_methods": [method for method in methods if method in MUTATING_MANAGEMENT_METHODS],
428
+ "trust_required_methods": [
429
+ method for method in methods if method in MUTATING_MANAGEMENT_METHODS
430
+ ],
431
+ "terminal_output_scraping": False,
432
+ }
433
+
434
+
435
+ MANAGEMENT_FEATURES: dict[str, Any] = {
436
+ "methods": MANAGEMENT_METHOD_CAPABILITIES,
437
+ "secret_policy": {
438
+ "inline_secrets_rejected": True,
439
+ "profile_key_values": "status_only",
440
+ "config_secret_values": "redacted_or_status_only",
441
+ "extension_secret_storage": "VS Code SecretStorage remains extension-side",
442
+ "management_responses_redacted": True,
443
+ "management_errors_redacted": True,
444
+ "urls_with_userinfo_redacted": True,
445
+ "authorization_headers_redacted": True,
446
+ },
447
+ "config": _feature_group("config"),
448
+ "profile": _feature_group("profile"),
449
+ "sessions": _feature_group("session"),
450
+ "tools": _feature_group("tools", "tool"),
451
+ "skills": _feature_group("skill"),
452
+ "doctor": _feature_group("doctor"),
453
+ "sandbox": _feature_group("sandbox"),
454
+ "update": _feature_group("update"),
455
+ "report": _feature_group("report"),
456
+ "mcp": {
457
+ **_feature_group("mcp"),
458
+ "auth_login": {
459
+ "supported": True,
460
+ "callable_probe_method": "mcp.auth.login.start",
461
+ "advertised_lifecycle_methods": True,
462
+ "methods": [
463
+ "mcp.auth.login.start",
464
+ "mcp.auth.login.status",
465
+ "mcp.auth.login.cancel",
466
+ ],
467
+ "flow_state_persistent": True,
468
+ "loopback_callback_owned": True,
469
+ "pkce_s256": True,
470
+ "state_validation": True,
471
+ "host_header_validation": True,
472
+ "timeout_expiry": True,
473
+ "cancel_cleanup": True,
474
+ "logout_fences_late_token_writes": True,
475
+ "encrypted_token_store": True,
476
+ "browser_opened_by_bridge": False,
477
+ "tokens_in_protocol_params": False,
478
+ "secret_store_required": True,
479
+ "authorization_code_in_protocol": False,
480
+ "remote_extension_host_support": False,
481
+ },
482
+ },
483
+ "hooks": {
484
+ **_feature_group("hooks"),
485
+ "watch": {
486
+ "supported": False,
487
+ "advertised_method": False,
488
+ "advertised_lifecycle_methods": False,
489
+ "proposed_methods": [
490
+ "hooks.watch.start",
491
+ "hooks.watch.poll",
492
+ "hooks.watch.stop",
493
+ "hooks.watch.status",
494
+ ],
495
+ "missing_lifecycle_primitives": [
496
+ "subscription_registry",
497
+ "bounded_event_buffer",
498
+ "dropped_event_accounting",
499
+ "watcher_cancellation",
500
+ "stop_cleanup",
501
+ "backend_redaction_for_live_output",
502
+ ],
503
+ "explicit_user_action_required": True,
504
+ "workspace_trust_required": True,
505
+ "passive_safe": True,
506
+ "reason": "hooks.watch requires a managed subscription lifecycle, cancellation, bounded redacted events, dropped-event accounting, and disposable cleanup; it is not a request/response IDE method.",
507
+ },
508
+ },
509
+ "conventions": _feature_group("conventions"),
510
+ "ext": _feature_group("ext"),
511
+ }
512
+
513
+
514
+ def handle_management_method(
515
+ method: str,
516
+ params: dict[str, Any],
517
+ *,
518
+ request_id: RequestId,
519
+ stateful_handlers: Mapping[str, Callable[[dict[str, Any], RequestId], dict[str, Any]]]
520
+ | None = None,
521
+ ) -> dict[str, Any]:
522
+ handler = (stateful_handlers or {}).get(method) or _HANDLERS.get(method)
523
+ if handler is None:
524
+ raise ProtocolError(
525
+ "method_not_found",
526
+ f"Unsupported method: {method}",
527
+ request_id=request_id,
528
+ )
529
+ if method in MUTATING_MANAGEMENT_METHODS:
530
+ _require_workspace_trust(params, method=method, request_id=request_id)
531
+ if method in WORKSPACE_REQUIRED_MANAGEMENT_METHODS:
532
+ _require_workspace_binding_param(params, method=method, request_id=request_id)
533
+ try:
534
+ return _redact_payload(handler(params, request_id))
535
+ except ProtocolError as exc:
536
+ raise ProtocolError(
537
+ exc.code,
538
+ _redact_management_error_message(exc.message),
539
+ request_id=exc.request_id if exc.request_id is not None else request_id,
540
+ ) from exc
541
+ except ConfigError as exc:
542
+ raise ProtocolError(
543
+ "config_error", _redact_management_error_message(str(exc)), request_id=request_id
544
+ ) from exc
545
+ except (
546
+ WorkspaceContextError,
547
+ FeedbackReportError,
548
+ SkillLifecycleError,
549
+ PluginInstallError,
550
+ ) as exc:
551
+ raise ProtocolError(
552
+ "management_error", _redact_management_error_message(str(exc)), request_id=request_id
553
+ ) from exc
554
+ except (OSError, RuntimeError, ValueError) as exc:
555
+ raise ProtocolError(
556
+ "management_error", _redact_management_error_message(str(exc)), request_id=request_id
557
+ ) from exc
558
+
559
+
560
+ def retained_session_usage(
561
+ params: dict[str, Any],
562
+ *,
563
+ request_id: RequestId,
564
+ ) -> dict[str, Any]:
565
+ return _session_usage(params, request_id)
566
+
567
+
568
+ def _config_get(_params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
569
+ cfg = load_config()
570
+ return {
571
+ "config": _safe_config_payload(cfg),
572
+ "active_profile": str((cfg.extra_fields or {}).get("active_profile") or ""),
573
+ "api_key": _api_key_status(resolve_api_key(cfg)),
574
+ "config_path": str(config_path()),
575
+ "credentials_path": str(credentials_path()),
576
+ "secret_values_included": False,
577
+ }
578
+
579
+
580
+ def _config_schema(_params: dict[str, Any], _request_id: RequestId) -> dict[str, Any]:
581
+ return {
582
+ "schema": AppConfig.model_json_schema(),
583
+ "secret_values_included": False,
584
+ }
585
+
586
+
587
+ def _config_validate(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
588
+ raw_values = params.get("values")
589
+ if raw_values is None:
590
+ cfg = load_config()
591
+ else:
592
+ if not isinstance(raw_values, dict):
593
+ raise ProtocolError(
594
+ "invalid_field",
595
+ "config.validate values must be an object when provided.",
596
+ request_id=request_id,
597
+ )
598
+ cfg = AppConfig.model_validate(raw_values)
599
+ return {
600
+ "valid": True,
601
+ "errors": [],
602
+ "config": _safe_config_payload(cfg),
603
+ "secret_values_included": False,
604
+ }
605
+
606
+
607
+ def _config_set(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
608
+ key = _required_str(params, "key", request_id=request_id)
609
+ if _unsafe_config_set_key(key):
610
+ raise ProtocolError(
611
+ "inline_secret_rejected",
612
+ "Secret-bearing config keys cannot be set through IDE protocol params.",
613
+ request_id=request_id,
614
+ )
615
+ if "value" not in params:
616
+ raise ProtocolError("missing_param", "config.set requires value.", request_id=request_id)
617
+ raw_value = params.get("value")
618
+ _reject_url_userinfo_value(raw_value, field=f"config.{key}", request_id=request_id)
619
+ _reject_secret_bearing_value(raw_value, field=f"config.{key}", request_id=request_id)
620
+ value = _config_value_to_cli_string(raw_value)
621
+ cfg = load_config()
622
+ subscription_effort: str | None = None
623
+ if key.strip().lower() == "model" and get_active_profile(cfg).auth_provider:
624
+ subscription_effort = _select_subscription_model_default(
625
+ cfg,
626
+ value,
627
+ request_id=request_id,
628
+ )
629
+ else:
630
+ cfg = set_config_value(cfg, key, value)
631
+ save_config(cfg)
632
+ return {
633
+ "key": key,
634
+ "changed": True,
635
+ "config_path": str(config_path()),
636
+ **(
637
+ {
638
+ "reasoning_effort": subscription_effort,
639
+ "subscription_selection_ready": active_subscription_selection_ready(cfg),
640
+ }
641
+ if subscription_effort is not None
642
+ else {}
643
+ ),
644
+ "secret_values_included": False,
645
+ }
646
+
647
+
648
+ def _select_subscription_model_default(
649
+ cfg: AppConfig,
650
+ model: str,
651
+ *,
652
+ request_id: RequestId,
653
+ ) -> str:
654
+ """Atomically select an account model and its provider-advertised default effort.
655
+
656
+ The VS Code model picker is intentionally a one-step control. Subscription profiles cannot use
657
+ the generic single-field config mutation because that could leave an unsupported model/effort
658
+ pair, so the bridge validates the live account catalog and commits both fields together.
659
+ """
660
+
661
+ profile = get_active_profile(cfg)
662
+ provider_id = str(profile.auth_provider or "").strip()
663
+ if not provider_id:
664
+ raise ProtocolError(
665
+ "config_error",
666
+ "The active profile is not an AI subscription connection.",
667
+ request_id=request_id,
668
+ )
669
+ adapter = create_provider_auth(provider_id)
670
+ status = adapter.account_status()
671
+ if not status.connected:
672
+ raise ProtocolError(
673
+ "provider_login_required",
674
+ "Connect the active AI subscription account before choosing its model.",
675
+ request_id=request_id,
676
+ )
677
+ models = tuple(adapter.list_models(refresh=True))
678
+ selected = next((item for item in models if str(getattr(item, "id", "")) == model), None)
679
+ if selected is None:
680
+ raise ProtocolError(
681
+ "unsupported_subscription_model",
682
+ "The selected model is not available to the connected subscription account.",
683
+ request_id=request_id,
684
+ )
685
+ efforts = tuple(
686
+ str(getattr(item, "id", "") or "").strip()
687
+ for item in tuple(getattr(selected, "reasoning_efforts", ()) or ())
688
+ )
689
+ efforts = tuple(item for item in efforts if item)
690
+ advertised_default = str(getattr(selected, "default_reasoning_effort", "") or "").strip()
691
+ reasoning_effort = (
692
+ advertised_default
693
+ if advertised_default and (not efforts or advertised_default in efforts)
694
+ else ("medium" if "medium" in efforts else (efforts[0] if efforts else "auto"))
695
+ )
696
+ update_active_profile_defaults(
697
+ cfg,
698
+ default_model=model,
699
+ reasoning_effort=reasoning_effort,
700
+ allow_subscription_selection=True,
701
+ )
702
+ updated = get_active_profile(cfg)
703
+ if not subscription_selection_supported(updated, models):
704
+ raise ProtocolError(
705
+ "unsupported_subscription_selection",
706
+ "The selected model and reasoning effort are not supported by the connected account.",
707
+ request_id=request_id,
708
+ )
709
+ cfg.extra_fields.pop(SUBSCRIPTION_SELECTION_REQUIRED_KEY, None)
710
+ cfg.extra_fields.pop("subscription_reconnect_required", None)
711
+ cfg.extra_fields["onboarded"] = True
712
+ return reasoning_effort
713
+
714
+
715
+ def _profile_list(_params: dict[str, Any], _request_id: RequestId) -> dict[str, Any]:
716
+ cfg = load_config()
717
+ active = str((cfg.extra_fields or {}).get("active_profile") or "")
718
+ return {
719
+ "active_profile": active,
720
+ "profiles": [_profile_payload(cfg, profile) for profile in list_profiles(cfg)],
721
+ "secret_values_included": False,
722
+ }
723
+
724
+
725
+ def _profile_show(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
726
+ cfg = load_config()
727
+ name = _required_str(params, "name", request_id=request_id)
728
+ profile = get_profile(cfg, name)
729
+ if profile is None:
730
+ raise ProtocolError(
731
+ "profile_not_found", f"Profile not found: {name}", request_id=request_id
732
+ )
733
+ return {
734
+ "profile": _profile_payload(cfg, profile, include_details=True),
735
+ "secret_values_included": False,
736
+ }
737
+
738
+
739
+ def _profile_add(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
740
+ cfg = load_config()
741
+ extra_headers_action = _extra_headers_secret_storage_action(
742
+ params,
743
+ request_id=request_id,
744
+ )
745
+ if extra_headers_action is not None:
746
+ return {
747
+ "changed": False,
748
+ "profile": None,
749
+ "action": extra_headers_action,
750
+ "secret_values_included": False,
751
+ }
752
+ base_url = _required_str(params, "base_url", request_id=request_id)
753
+ _reject_url_userinfo_value(base_url, field="profile.base_url", request_id=request_id)
754
+ profile = ProfileSpec(
755
+ name=_required_str(params, "name", request_id=request_id),
756
+ protocol=_optional_str(params, "protocol", default="openai_compat"),
757
+ base_url=base_url,
758
+ api_key_env=_optional_str_or_none(params, "api_key_env"),
759
+ extra_headers=_extra_headers_param(params, request_id=request_id),
760
+ default_model=_optional_str(params, "default_model", default=""),
761
+ web_search_adapter=_optional_str(params, "web_search_adapter", default="auto"),
762
+ web_search_model=_optional_str(params, "web_search_model", default=""),
763
+ notes=_optional_str(params, "notes", default=""),
764
+ )
765
+ add_profile(cfg, profile)
766
+ save_config(cfg)
767
+ return {
768
+ "profile": _profile_payload(cfg, profile, include_details=True),
769
+ "changed": True,
770
+ "secret_action": _secret_action_for_profile(profile),
771
+ "secret_values_included": False,
772
+ }
773
+
774
+
775
+ def _profile_remove(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
776
+ if not _confirmed(params):
777
+ return _confirmation_required("profile.remove", "profile removal")
778
+ cfg = load_config()
779
+ name = _required_str(params, "name", request_id=request_id)
780
+ remove_profile(cfg, name)
781
+ clear_persisted_profile_key(name)
782
+ save_config(cfg)
783
+ return {"name": name, "removed": True, "secret_values_included": False}
784
+
785
+
786
+ def _profile_use(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
787
+ cfg = load_config()
788
+ name = _required_str(params, "name", request_id=request_id)
789
+ set_active_profile(cfg, name)
790
+ save_config(cfg)
791
+ profile = get_profile(cfg, name)
792
+ return {
793
+ "active_profile": name,
794
+ "profile": _profile_payload(cfg, profile, include_details=True) if profile else None,
795
+ "changed": True,
796
+ "secret_values_included": False,
797
+ }
798
+
799
+
800
+ def _profile_rename(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
801
+ cfg = load_config()
802
+ old = _required_str(params, "old", request_id=request_id)
803
+ new = _required_str(params, "new", request_id=request_id)
804
+ rename_profile(cfg, old, new)
805
+ rename_persisted_profile_key(old, new)
806
+ save_config(cfg)
807
+ profile = get_profile(cfg, new)
808
+ return {
809
+ "old": old,
810
+ "new": new,
811
+ "profile": _profile_payload(cfg, profile, include_details=True) if profile else None,
812
+ "changed": True,
813
+ "secret_values_included": False,
814
+ }
815
+
816
+
817
+ def _profile_presets(_params: dict[str, Any], _request_id: RequestId) -> dict[str, Any]:
818
+ return {
819
+ "presets": [_preset_payload(preset) for preset in PROFILE_PRESETS],
820
+ "secret_values_included": False,
821
+ }
822
+
823
+
824
+ def _profile_preset(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
825
+ preset_key = _required_str_any(params, ("preset_key", "preset"), request_id=request_id)
826
+ preset = get_preset(preset_key)
827
+ if preset is None:
828
+ raise ProtocolError(
829
+ "unknown_profile_preset",
830
+ f"Unknown profile preset: {preset_key}",
831
+ request_id=request_id,
832
+ )
833
+ cfg = load_config()
834
+ name = _optional_str(params, "name", default=str(preset.key)).strip().lower()
835
+ if get_profile(cfg, name) is not None and not _confirmed(params):
836
+ return _confirmation_required("profile.preset", f"overwrite profile {name}")
837
+ if not preset.base_url and not _optional_str(params, "base_url", default="").strip():
838
+ raise ProtocolError(
839
+ "missing_param",
840
+ "profile.preset requires base_url for the custom preset.",
841
+ request_id=request_id,
842
+ )
843
+ profile = make_profile_from_preset(preset, name=name)
844
+ add_profile(cfg, profile)
845
+ base_url = _optional_str(params, "base_url", default="").strip()
846
+ if base_url and base_url != profile.base_url:
847
+ _reject_url_userinfo_value(base_url, field="profile.base_url", request_id=request_id)
848
+ update_profile(cfg, profile.name, base_url=base_url)
849
+ profile = get_profile(cfg, profile.name) or profile
850
+ save_config(cfg)
851
+ return {
852
+ "profile": _profile_payload(cfg, profile, include_details=True),
853
+ "preset": _preset_payload(preset),
854
+ "changed": True,
855
+ "secret_action": _secret_action_for_profile(profile),
856
+ "secret_values_included": False,
857
+ }
858
+
859
+
860
+ def _profile_convert(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
861
+ cfg = load_config()
862
+ profile_name = _optional_str(
863
+ params,
864
+ "name",
865
+ default=str((cfg.extra_fields or {}).get("active_profile") or ""),
866
+ ).strip()
867
+ if not profile_name:
868
+ raise ProtocolError(
869
+ "missing_param",
870
+ "profile.convert requires name when no active profile is configured.",
871
+ request_id=request_id,
872
+ )
873
+ profile = get_profile(cfg, profile_name)
874
+ if profile is None:
875
+ raise ProtocolError(
876
+ "profile_not_found", f"Profile not found: {profile_name}", request_id=request_id
877
+ )
878
+ target = normalize_conversion_target(_required_str(params, "target", request_id=request_id))
879
+ preset = target_preset_for_profile_conversion(profile, target=target)
880
+ if preset is None:
881
+ raise ProtocolError(
882
+ "unsupported_profile_conversion",
883
+ "Only OpenAI, Anthropic, and Gemini profiles can be converted between native and compatibility protocols.",
884
+ request_id=request_id,
885
+ )
886
+ converted = convert_profile_to_preset(profile, preset)
887
+ preview = {
888
+ "before": _profile_payload(cfg, profile, include_details=True),
889
+ "after": _profile_payload(cfg, converted, include_details=True),
890
+ "target": target,
891
+ "preset": _preset_payload(preset),
892
+ }
893
+ if converted.to_dict() == profile.to_dict():
894
+ return {**preview, "changed": False, "secret_values_included": False}
895
+ if not _confirmed(params):
896
+ return {
897
+ **preview,
898
+ "changed": False,
899
+ "action": {
900
+ "kind": "requires_confirmation",
901
+ "method": "profile.convert",
902
+ "reason": f"Convert profile {profile.name} to {target}.",
903
+ },
904
+ "secret_values_included": False,
905
+ }
906
+ add_profile(cfg, converted)
907
+ if str((cfg.extra_fields or {}).get("active_profile") or "") == profile.name:
908
+ set_active_profile(cfg, profile.name)
909
+ save_config(cfg)
910
+ return {**preview, "changed": True, "secret_values_included": False}
911
+
912
+
913
+ def _session_show(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
914
+ path = _session_log_path(params, request_id=request_id)
915
+ max_events = _positive_int(
916
+ params,
917
+ "max_events",
918
+ default=_SESSION_SHOW_DEFAULT_MAX_EVENTS,
919
+ upper=_SESSION_SHOW_MAX_EVENTS,
920
+ )
921
+ max_total_bytes = _positive_int(
922
+ params,
923
+ "max_total_bytes",
924
+ default=_SESSION_SHOW_DEFAULT_MAX_TOTAL_BYTES,
925
+ upper=_SESSION_SHOW_MAX_TOTAL_BYTES,
926
+ )
927
+ events: list[dict[str, Any]] = []
928
+ event_count = 0
929
+ response_bytes = 0
930
+ truncated_by_events = False
931
+ truncated_by_bytes = False
932
+ for event in read_session_events(path):
933
+ event_count += 1
934
+ if len(events) >= max_events:
935
+ truncated_by_events = True
936
+ continue
937
+ redacted_event = _redact_payload(event)
938
+ event_bytes = _json_size_bytes(redacted_event)
939
+ if response_bytes + event_bytes > max_total_bytes:
940
+ truncated_by_bytes = True
941
+ continue
942
+ events.append(redacted_event)
943
+ response_bytes += event_bytes
944
+ return {
945
+ "session_id": path.stem,
946
+ "path": str(path),
947
+ "events": events,
948
+ "event_count": event_count,
949
+ "returned_event_count": len(events),
950
+ "truncated": truncated_by_events or truncated_by_bytes,
951
+ "truncated_by_events": truncated_by_events,
952
+ "truncated_by_bytes": truncated_by_bytes,
953
+ "max_events": max_events,
954
+ "max_total_bytes": max_total_bytes,
955
+ "response_bytes": response_bytes,
956
+ "secret_values_included": False,
957
+ "redacted": True,
958
+ }
959
+
960
+
961
+ def _session_usage(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
962
+ path = _session_log_path(params, request_id=request_id)
963
+ summary = aggregate_usage_from_session_logs([path])
964
+ totals = summary.totals()
965
+ records = summary.records()
966
+ return {
967
+ "session_id": path.stem,
968
+ "path": str(path),
969
+ "by_model": summary.by_model_rows(),
970
+ "totals": totals,
971
+ "call_count": int(totals.get("calls", len(records)) if isinstance(totals, dict) else 0),
972
+ }
973
+
974
+
975
+ def _session_score(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
976
+ cfg = load_config()
977
+ sessions_dir = resolve_sessions_dir(cfg)
978
+ session_id = str(params.get("session_id") or "").strip()
979
+ latest = _positive_int(params, "latest", default=0, upper=200)
980
+ if session_id and latest > 0:
981
+ raise ProtocolError(
982
+ "invalid_field",
983
+ "Use either session_id or latest, not both.",
984
+ request_id=request_id,
985
+ )
986
+ if session_id:
987
+ paths = [_session_log_path(params, request_id=request_id)]
988
+ else:
989
+ infos = list_sessions(sessions_dir)
990
+ count = latest if latest > 0 else 1
991
+ paths = [info.path for info in infos[:count]]
992
+ scores = [score_session_log(path) for path in paths]
993
+ return {
994
+ "sessions_dir": str(sessions_dir),
995
+ "scores": scores,
996
+ "score": scores[0] if len(scores) == 1 else None,
997
+ }
998
+
999
+
1000
+ def _tools_catalog(_params: dict[str, Any], _request_id: RequestId) -> dict[str, Any]:
1001
+ cfg = load_config()
1002
+ tools = []
1003
+ for spec in iter_builtin_tool_metadata():
1004
+ status = "available"
1005
+ notes: list[str] = []
1006
+ availability = get_tool_availability(spec.name)
1007
+ if spec.optional and availability is not None and availability.unavailable_reason:
1008
+ status = "optional-unavailable"
1009
+ notes.append(str(availability.unavailable_reason))
1010
+ if spec.name == "web_search":
1011
+ api_key = resolve_api_key(cfg)
1012
+ runtime = resolve_web_search_runtime_status(cfg=cfg, api_key=api_key.key)
1013
+ status = runtime.availability_label
1014
+ notes.extend(runtime.notes)
1015
+ if not runtime.registration_ready:
1016
+ notes.append(runtime.setup_hint)
1017
+ tools.append(
1018
+ {
1019
+ "name": spec.name,
1020
+ "description": spec.description,
1021
+ "categories": list(spec.categories),
1022
+ "parameters": spec.copied_parameters(),
1023
+ "display_name": spec.rich.display_name,
1024
+ "optional": spec.optional,
1025
+ "status": status,
1026
+ "notes": [note for note in notes if note],
1027
+ "built_in_subagent_exposure": spec.built_in_subagent_exposure,
1028
+ }
1029
+ )
1030
+ return {"tools": tools, "count": len(tools), "secret_values_included": False}
1031
+
1032
+
1033
+ def _tool_list(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1034
+ workspace_root, entries = _custom_tool_entries(params, request_id=request_id)
1035
+ return {
1036
+ "workspace_root": str(workspace_root),
1037
+ "project_root": str(project_custom_tools_root(workspace_root)),
1038
+ "global_root": str(global_custom_tools_root()),
1039
+ "tools": [_custom_tool_payload(entry, workspace_root=workspace_root) for entry in entries],
1040
+ }
1041
+
1042
+
1043
+ def _tool_info(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1044
+ name = _required_str(params, "name", request_id=request_id)
1045
+ workspace_root, entries = _custom_tool_entries(params, request_id=request_id)
1046
+ matches = [
1047
+ entry for entry in entries if str(getattr(entry, "name", "")).casefold() == name.casefold()
1048
+ ]
1049
+ if not matches:
1050
+ raise ProtocolError(
1051
+ "tool_not_found", f"Custom tool not found: {name}", request_id=request_id
1052
+ )
1053
+ return {
1054
+ "workspace_root": str(workspace_root),
1055
+ "tools": [_custom_tool_payload(entry, workspace_root=workspace_root) for entry in matches],
1056
+ }
1057
+
1058
+
1059
+ def _tool_trust(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1060
+ name = _required_str(params, "name", request_id=request_id)
1061
+ workspace_root, entries = _custom_tool_entries(params, request_id=request_id)
1062
+ entry = _select_project_tool_entry(entries, name=name, request_id=request_id)
1063
+ assert entry.spec is not None
1064
+ trust_project_tool(entry.spec)
1065
+ return {
1066
+ "workspace_root": str(workspace_root),
1067
+ "name": entry.spec.name,
1068
+ "trusted": True,
1069
+ "changed": True,
1070
+ }
1071
+
1072
+
1073
+ def _tool_untrust(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1074
+ name = _required_str(params, "name", request_id=request_id)
1075
+ workspace_root, entries = _custom_tool_entries(params, request_id=request_id)
1076
+ entry = _select_project_tool_entry(entries, name=name, request_id=request_id)
1077
+ assert entry.spec is not None
1078
+ untrust_project_tool(entry.spec)
1079
+ return {
1080
+ "workspace_root": str(workspace_root),
1081
+ "name": entry.spec.name,
1082
+ "trusted": False,
1083
+ "changed": True,
1084
+ }
1085
+
1086
+
1087
+ def _skill_list(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1088
+ workspace_root, catalog = _skill_catalog(params, request_id=request_id)
1089
+ return {
1090
+ "workspace_root": str(workspace_root),
1091
+ "skills": [_skill_entry_payload(entry) for entry in catalog.entries],
1092
+ "issues": _skill_issues_payload(catalog),
1093
+ }
1094
+
1095
+
1096
+ def _skill_info(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1097
+ name = _required_str(params, "name", request_id=request_id)
1098
+ workspace_root, catalog = _skill_catalog(params, request_id=request_id)
1099
+ entry = resolve_skill_catalog_entry(entries=catalog.entries, raw_name=name)
1100
+ if entry is None:
1101
+ raise ProtocolError("skill_not_found", f"Skill not found: {name}", request_id=request_id)
1102
+ return {
1103
+ "workspace_root": str(workspace_root),
1104
+ "skill": _skill_entry_payload(entry),
1105
+ "info_text": render_skill_info_text(entry.skill, catalog_entry=entry),
1106
+ "issues": _skill_issues_payload(catalog),
1107
+ }
1108
+
1109
+
1110
+ def _skill_init(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1111
+ workspace_root = _workspace_root_from_params(params, request_id=request_id, required=True)
1112
+ result = scaffold_skill_bundle(
1113
+ name=_required_str(params, "name", request_id=request_id),
1114
+ description=_optional_str(params, "description", default=""),
1115
+ workspace_root=workspace_root,
1116
+ project=_optional_bool(params, "project", default=True),
1117
+ family=_optional_str(params, "family", default="native"),
1118
+ force=_optional_bool(params, "force", default=False),
1119
+ )
1120
+ return {"result": _dataclass_payload(result), "changed": True}
1121
+
1122
+
1123
+ def _skill_validate(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1124
+ workspace_root, catalog = _skill_catalog(params, request_id=request_id)
1125
+ bundle = _optional_str(params, "bundle", default="").strip()
1126
+ name = _optional_str(params, "name", default="").strip()
1127
+ validate_all = _optional_bool(params, "all", default=False)
1128
+ choices = sum(1 for value in (bool(bundle), bool(name), validate_all) if value)
1129
+ if choices != 1:
1130
+ raise ProtocolError(
1131
+ "invalid_field",
1132
+ "skill.validate requires exactly one of bundle, name, or all.",
1133
+ request_id=request_id,
1134
+ )
1135
+ results = []
1136
+ if bundle:
1137
+ path = _workspace_child_path(workspace_root, bundle, request_id=request_id)
1138
+ results.append(validate_skill_bundle(path))
1139
+ elif name:
1140
+ entry = resolve_skill_catalog_entry(entries=catalog.entries, raw_name=name)
1141
+ if entry is None:
1142
+ raise ProtocolError(
1143
+ "skill_not_found", f"Skill not found: {name}", request_id=request_id
1144
+ )
1145
+ results.append(validate_skill_bundle(entry.skill.bundle_path))
1146
+ else:
1147
+ seen_paths: set[Path] = set()
1148
+ for entry in catalog.entries:
1149
+ if entry.skill.bundle_path in seen_paths:
1150
+ continue
1151
+ seen_paths.add(entry.skill.bundle_path)
1152
+ results.append(validate_skill_bundle(entry.skill.bundle_path))
1153
+ return {
1154
+ "workspace_root": str(workspace_root),
1155
+ "results": [_validation_payload(result) for result in results],
1156
+ "valid": all(bool(getattr(result, "valid", False)) for result in results),
1157
+ }
1158
+
1159
+
1160
+ def _skill_install(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1161
+ workspace_root = _workspace_root_from_params(params, request_id=request_id, required=True)
1162
+ source = _validate_skill_install_source(
1163
+ _required_str(params, "source", request_id=request_id),
1164
+ workspace_root=workspace_root,
1165
+ params=params,
1166
+ request_id=request_id,
1167
+ )
1168
+ result = install_skill_bundle(
1169
+ source=source,
1170
+ workspace_root=workspace_root,
1171
+ project=_optional_bool(params, "project", default=False),
1172
+ subdir=_optional_str_or_none(params, "subdir"),
1173
+ force=_optional_bool(params, "force", default=False),
1174
+ )
1175
+ return {"result": _dataclass_payload(result), "changed": True}
1176
+
1177
+
1178
+ def _skill_enable(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1179
+ return _set_skill_enabled(params, request_id=request_id, enabled=True)
1180
+
1181
+
1182
+ def _skill_disable(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1183
+ return _set_skill_enabled(params, request_id=request_id, enabled=False)
1184
+
1185
+
1186
+ def _skill_remove(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1187
+ workspace_root = _workspace_root_from_params(params, request_id=request_id, required=True)
1188
+ result = remove_managed_skill(
1189
+ name=_required_str(params, "name", request_id=request_id),
1190
+ workspace_root=workspace_root,
1191
+ project=_optional_bool(params, "project", default=False),
1192
+ )
1193
+ return {"result": _dataclass_payload(result), "changed": True}
1194
+
1195
+
1196
+ def _doctor_summary(_params: dict[str, Any], _request_id: RequestId) -> dict[str, Any]:
1197
+ cfg = load_config()
1198
+ api_key = resolve_api_key(cfg)
1199
+ web_search = resolve_web_search_runtime_status(cfg=cfg, api_key=api_key.key)
1200
+ try:
1201
+ sandbox = diagnose_sandbox(cfg, include_smoke=False, include_server_image=False)
1202
+ sandbox_payload: dict[str, Any] = {
1203
+ "ready": sandbox.ready,
1204
+ "status": sandbox.status,
1205
+ "selected_backend": sandbox.selected_backend,
1206
+ }
1207
+ except ConfigError as exc:
1208
+ sandbox_payload = {"ready": False, "status": "config_error", "error": str(exc)}
1209
+ try:
1210
+ update_status = status_from_cache(current_version=__version__, cfg=cfg).to_json()
1211
+ except Exception as exc: # noqa: BLE001 - summary should remain best effort.
1212
+ update_status = {"state": "unknown", "error": str(redact_secrets(str(exc)))}
1213
+ return {
1214
+ "python": sys.version.split()[0],
1215
+ "platform": platform.platform(),
1216
+ "git": shutil.which("git") is not None,
1217
+ "rg": shutil.which("rg") is not None,
1218
+ "model_set": bool(cfg.model),
1219
+ "model": cfg.model,
1220
+ "base_url_host": _url_host(cfg.base_url),
1221
+ "api_key": _api_key_status(api_key),
1222
+ "config_path": str(config_path()),
1223
+ "update": update_status,
1224
+ "web_search": {
1225
+ "availability": web_search.availability_label,
1226
+ "provider": web_search.provider,
1227
+ "setup_hint": web_search.setup_hint,
1228
+ },
1229
+ "sandbox": sandbox_payload,
1230
+ "custom_tools_enabled": bool(cfg.custom_tools_enabled),
1231
+ "skills_enabled": bool(getattr(cfg, "skills_enabled", True)),
1232
+ "stream": bool(cfg.stream),
1233
+ "secret_values_included": False,
1234
+ }
1235
+
1236
+
1237
+ def _doctor_providers(_params: dict[str, Any], _request_id: RequestId) -> dict[str, Any]:
1238
+ cfg = load_config()
1239
+ diagnostics = build_provider_diagnostics(cfg)
1240
+ return {
1241
+ "diagnostics": _rows_to_dict(diagnostics.rows()),
1242
+ "last_provider_call": last_provider_call_summary(),
1243
+ "last_web_search": last_web_search_summary(),
1244
+ "secret_values_included": False,
1245
+ }
1246
+
1247
+
1248
+ def _doctor_providers_live(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1249
+ # One minimal live text request against the active profile. Never passive: the caller must
1250
+ # send allow_live=true, which the extension only does on an explicit user action (Connect /
1251
+ # Test connection). Mirrors `alysis doctor providers --live` and stays fully redacted.
1252
+ if not _optional_bool(params, "allow_live", default=False):
1253
+ raise ProtocolError(
1254
+ "missing_param",
1255
+ "doctor.providers.live requires allow_live=true; it performs one minimal live "
1256
+ "provider request after explicit user intent.",
1257
+ request_id=request_id,
1258
+ )
1259
+ cfg = load_config()
1260
+ validation = validate_active_provider_live(
1261
+ cfg,
1262
+ timeout_s=float(_positive_int(params, "timeout_s", default=15, upper=60)),
1263
+ )
1264
+ return {
1265
+ "validation": {
1266
+ "profile": validation.profile_name,
1267
+ "provider_key": validation.provider_key,
1268
+ "protocol": validation.protocol,
1269
+ "model": validation.model,
1270
+ "status": validation.status,
1271
+ "message": redact_secrets(validation.message),
1272
+ },
1273
+ "ok": validation.ok,
1274
+ "network_used": True,
1275
+ "secret_values_included": False,
1276
+ }
1277
+
1278
+
1279
+ def _doctor_bundle(_params: dict[str, Any], _request_id: RequestId) -> dict[str, Any]:
1280
+ cfg = load_config()
1281
+ diagnostics = build_provider_diagnostics(cfg)
1282
+ return {
1283
+ "bundle": diagnostic_bundle_payload(provider_diagnostics=_rows_to_dict(diagnostics.rows())),
1284
+ "secret_values_included": False,
1285
+ }
1286
+
1287
+
1288
+ def _sandbox_doctor(params: dict[str, Any], _request_id: RequestId) -> dict[str, Any]:
1289
+ cfg = load_config()
1290
+ diagnostic = diagnose_sandbox(
1291
+ cfg,
1292
+ include_smoke=_optional_bool(params, "smoke", default=False),
1293
+ include_server_image=_optional_bool(params, "include_server", default=True),
1294
+ )
1295
+ result = {
1296
+ "diagnostic": _dataclass_payload(diagnostic),
1297
+ "message": format_sandbox_problem_message(diagnostic),
1298
+ }
1299
+ if _optional_bool(params, "env", default=False):
1300
+ result["env"] = sandbox_env_summary()
1301
+ return result
1302
+
1303
+
1304
+ def _sandbox_pull(params: dict[str, Any], _request_id: RequestId) -> dict[str, Any]:
1305
+ cfg = load_config()
1306
+ images = _string_list_param(params.get("images"))
1307
+ if not images:
1308
+ images = list(
1309
+ configured_sandbox_images(
1310
+ cfg,
1311
+ include_server=_optional_bool(params, "include_server", default=True),
1312
+ )
1313
+ )
1314
+ result = pull_sandbox_images(
1315
+ images,
1316
+ timeout_s=_positive_int(params, "timeout_s", default=900, upper=3600),
1317
+ )
1318
+ return {"result": _dataclass_payload(result), "changed": True}
1319
+
1320
+
1321
+ def _sandbox_setup(params: dict[str, Any], _request_id: RequestId) -> dict[str, Any]:
1322
+ cfg = load_config()
1323
+ diagnostic = diagnose_sandbox(cfg, include_smoke=False, include_server_image=True)
1324
+ pull_result = None
1325
+ if (
1326
+ not diagnostic.ready
1327
+ and diagnostic.can_pull
1328
+ and _optional_bool(params, "pull", default=True)
1329
+ ):
1330
+ pull_result = pull_sandbox_images(configured_sandbox_images(cfg, include_server=True))
1331
+ diagnostic = diagnose_sandbox(cfg, include_smoke=True, include_server_image=True)
1332
+ return {
1333
+ "diagnostic": _dataclass_payload(diagnostic),
1334
+ "pull_result": _dataclass_payload(pull_result) if pull_result is not None else None,
1335
+ "message": format_sandbox_problem_message(diagnostic),
1336
+ "changed": pull_result is not None,
1337
+ }
1338
+
1339
+
1340
+ def _update_check(params: dict[str, Any], _request_id: RequestId) -> dict[str, Any]:
1341
+ cfg = load_config()
1342
+ force = _optional_bool(params, "force", default=False)
1343
+ allow_network = _optional_bool(params, "allow_network", default=False)
1344
+ cached = _optional_bool(params, "cached", default=not (force or allow_network))
1345
+ network_used = bool((force or allow_network) and not cached)
1346
+ status = (
1347
+ check_for_updates(current_version=__version__, cfg=cfg, force=True)
1348
+ if network_used
1349
+ else status_from_cache(current_version=__version__, cfg=cfg)
1350
+ )
1351
+ return {
1352
+ "status": status.to_json(),
1353
+ "network_used": network_used,
1354
+ "cached": not network_used,
1355
+ "allow_network": allow_network,
1356
+ "force": force,
1357
+ "secret_values_included": False,
1358
+ }
1359
+
1360
+
1361
+ def _report_create(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1362
+ workspace_root = resolve_feedback_workspace_root(
1363
+ _workspace_root_from_params(params, request_id=request_id, required=True)
1364
+ )
1365
+ cfg = load_config()
1366
+ result = create_feedback_bundle(
1367
+ workspace_root=workspace_root,
1368
+ feedback_text=_optional_str_or_none(params, "feedback"),
1369
+ cfg=cfg,
1370
+ session_id=_optional_str_or_none(params, "session_id"),
1371
+ run_id=_optional_str_or_none(params, "run_id"),
1372
+ latest=_optional_bool(params, "latest", default=False),
1373
+ )
1374
+ github_enabled = params.get("github") if "github" in params else False
1375
+ issue_result = None
1376
+ issue_status_lines: list[str] = []
1377
+ if github_enabled is not False and not _optional_bool(params, "local_only", default=True):
1378
+ issue_result = create_feedback_github_issue_draft(
1379
+ bundle_result=result,
1380
+ feedback_text=_optional_str_or_none(params, "feedback"),
1381
+ cfg=cfg,
1382
+ github_enabled=bool(github_enabled),
1383
+ open_browser=False,
1384
+ )
1385
+ issue_status_lines = list(feedback_github_issue_status_lines(issue_result))
1386
+ return {
1387
+ "bundle": _dataclass_payload(result),
1388
+ "github_issue": _dataclass_payload(issue_result) if issue_result is not None else None,
1389
+ "github_status_lines": issue_status_lines,
1390
+ "changed": True,
1391
+ "secret_values_included": False,
1392
+ }
1393
+
1394
+
1395
+ def _mcp_status(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1396
+ manager = _mcp_manager(params, request_id=request_id)
1397
+ bootstrap_errors: list[str] = []
1398
+ prompt_errors: dict[str, str] = {}
1399
+ try:
1400
+ try:
1401
+ _ = manager.tool_bindings
1402
+ except (ConfigError, RuntimeError) as exc:
1403
+ bootstrap_errors.append(str(redact_secrets(str(exc))))
1404
+ prompt_enabled_ids = [
1405
+ str(server_id)
1406
+ for server_id in manager.catalog_snapshot_metadata().get("prompt_enabled_server_ids")
1407
+ or []
1408
+ ]
1409
+ for prompt_server_id in prompt_enabled_ids:
1410
+ try:
1411
+ manager.list_prompts(server_id=prompt_server_id, limit=1)
1412
+ except (ConfigError, RuntimeError) as exc:
1413
+ prompt_errors[prompt_server_id] = str(redact_secrets(str(exc)))
1414
+ payload = _mcp_status_payload(
1415
+ manager=manager,
1416
+ bootstrap_errors=bootstrap_errors,
1417
+ prompt_errors=prompt_errors,
1418
+ )
1419
+ payload.pop("table_rows", None)
1420
+ payload["secret_values_included"] = False
1421
+ return _redact_payload(payload)
1422
+ finally:
1423
+ manager.close()
1424
+
1425
+
1426
+ def _mcp_prompts_list(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1427
+ manager = _mcp_manager(params, request_id=request_id)
1428
+ try:
1429
+ payload = manager.list_prompts(
1430
+ server_id=_optional_str_or_none(params, "server"),
1431
+ query=_optional_str_or_none(params, "query"),
1432
+ limit=_positive_int(params, "limit", default=20, upper=50),
1433
+ refresh=_optional_bool(params, "refresh", default=False),
1434
+ )
1435
+ payload["secret_values_included"] = False
1436
+ return _redact_payload(payload)
1437
+ finally:
1438
+ manager.close()
1439
+
1440
+
1441
+ def _mcp_prompts_get(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1442
+ manager = _mcp_manager(params, request_id=request_id)
1443
+ try:
1444
+ payload = manager.get_prompt(
1445
+ server_id=_required_str(params, "server_id", request_id=request_id),
1446
+ prompt_name=_required_str_any(params, ("prompt_name", "name"), request_id=request_id),
1447
+ arguments=_string_dict_param(params.get("arguments"), request_id=request_id),
1448
+ refresh=_optional_bool(params, "refresh", default=False),
1449
+ )
1450
+ payload["secret_values_included"] = False
1451
+ return _redact_payload(payload)
1452
+ finally:
1453
+ manager.close()
1454
+
1455
+
1456
+ def _mcp_auth_status(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1457
+ resolved_config = _manual_mcp_resolved_config_for_path(
1458
+ path=_workspace_root_from_params(params, request_id=request_id, required=True)
1459
+ )
1460
+ rows = _build_mcp_auth_status_rows(
1461
+ resolved_config=resolved_config,
1462
+ server_id=_optional_str_or_none(params, "server"),
1463
+ )
1464
+ return {"rows": _redact_payload(rows), "secret_values_included": False}
1465
+
1466
+
1467
+ def prepare_mcp_oauth_login_request(
1468
+ params: dict[str, Any], *, request_id: RequestId
1469
+ ) -> McpOAuthLoginRequest:
1470
+ """Resolve a configured OAuth server into a secret-safe coordinator request."""
1471
+
1472
+ server_id = _required_str(params, "server_id", request_id=request_id)
1473
+ resolved_config = _manual_mcp_resolved_config_for_path(
1474
+ path=_workspace_root_from_params(params, request_id=request_id, required=True)
1475
+ )
1476
+ server = _require_http_oauth_server_for_auth(
1477
+ server=_resolve_mcp_server_by_id(resolved_config=resolved_config, server_id=server_id),
1478
+ command_name="login",
1479
+ )
1480
+ oauth = server.oauth
1481
+ if oauth is None or not server.url:
1482
+ raise ProtocolError(
1483
+ "mcp_oauth_config_error",
1484
+ "MCP server OAuth configuration is incomplete.",
1485
+ request_id=request_id,
1486
+ )
1487
+ return McpOAuthLoginRequest(
1488
+ server_id=server.id,
1489
+ resource_server_url=str(server.url),
1490
+ client_id=oauth.client_id,
1491
+ scopes=tuple(oauth.scopes or ()),
1492
+ authorization_server_url=oauth.authorization_server_url,
1493
+ )
1494
+
1495
+
1496
+ def _mcp_auth_login_start(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1497
+ prepare_mcp_oauth_login_request(params, request_id=request_id)
1498
+ raise ProtocolError(
1499
+ "stateful_bridge_required",
1500
+ "MCP OAuth login requires the stateful IDE bridge coordinator.",
1501
+ request_id=request_id,
1502
+ )
1503
+
1504
+
1505
+ def _mcp_auth_login_status(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1506
+ prepare_mcp_oauth_login_request(params, request_id=request_id)
1507
+ _required_str(params, "flow_id", request_id=request_id)
1508
+ raise ProtocolError(
1509
+ "stateful_bridge_required",
1510
+ "MCP OAuth flow status requires the stateful IDE bridge coordinator.",
1511
+ request_id=request_id,
1512
+ )
1513
+
1514
+
1515
+ def _mcp_auth_login_cancel(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1516
+ prepare_mcp_oauth_login_request(params, request_id=request_id)
1517
+ _required_str(params, "flow_id", request_id=request_id)
1518
+ raise ProtocolError(
1519
+ "stateful_bridge_required",
1520
+ "MCP OAuth flow cancellation requires the stateful IDE bridge coordinator.",
1521
+ request_id=request_id,
1522
+ )
1523
+
1524
+
1525
+ def _mcp_auth_logout(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1526
+ if not _confirmed(params):
1527
+ return _confirmation_required("mcp.auth.logout", "clear stored MCP OAuth tokens")
1528
+ server_id = _required_str(params, "server_id", request_id=request_id)
1529
+ resolved_config = _manual_mcp_resolved_config_for_path(
1530
+ path=_workspace_root_from_params(params, request_id=request_id, required=True)
1531
+ )
1532
+ server = _require_http_oauth_server_for_auth(
1533
+ server=_resolve_mcp_server_by_id(resolved_config=resolved_config, server_id=server_id),
1534
+ command_name="logout",
1535
+ )
1536
+ removed = delete_oauth_token_record(server.id)
1537
+ return {
1538
+ "server_id": server.id,
1539
+ "removed": bool(removed),
1540
+ "changed": bool(removed),
1541
+ "secret_values_included": False,
1542
+ }
1543
+
1544
+
1545
+ def _hooks_list(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1546
+ workspace_root = _workspace_root_from_params(params, request_id=request_id, required=True)
1547
+ entries: list[dict[str, Any]] = []
1548
+ statuses = _collect_hook_source_statuses(workspace_root=workspace_root)
1549
+ for status in statuses:
1550
+ if not status.exists or status.status == "invalid":
1551
+ continue
1552
+ try:
1553
+ config_file = load_hook_config_file(status.path)
1554
+ except ConfigError:
1555
+ continue
1556
+ for event_name, groups in config_file.hooks.items():
1557
+ for group in groups:
1558
+ if not group.enabled:
1559
+ continue
1560
+ for hook in sorted(
1561
+ (item for item in group.hooks if item.enabled),
1562
+ key=lambda item: (-item.priority, item.id or "", item.command),
1563
+ ):
1564
+ entries.append(
1565
+ {
1566
+ "source_scope": status.source_scope,
1567
+ "status": status.status,
1568
+ "trusted": bool(status.trusted),
1569
+ "event": event_name,
1570
+ "matcher": group.matcher or "*",
1571
+ "id": hook.id or "",
1572
+ "priority": hook.priority,
1573
+ "failure_policy": hook.failure_policy,
1574
+ "runtime_kinds": list(hook.runtime_kinds or ()),
1575
+ "session_source": list(hook.session_source or ()),
1576
+ "command": str(redact_secrets(hook.command)),
1577
+ }
1578
+ )
1579
+ return {
1580
+ "workspace_root": str(workspace_root),
1581
+ "sources": [_hook_source_status_payload(status) for status in statuses],
1582
+ "hooks": entries,
1583
+ "count": len(entries),
1584
+ "secret_values_included": False,
1585
+ }
1586
+
1587
+
1588
+ def _hooks_doctor(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1589
+ workspace_root = _workspace_root_from_params(params, request_id=request_id, required=True)
1590
+ statuses = _collect_hook_source_statuses(workspace_root=workspace_root)
1591
+ result: dict[str, Any] = {
1592
+ "workspace_root": str(workspace_root),
1593
+ "sources": [_hook_source_status_payload(status) for status in statuses],
1594
+ "effective": {},
1595
+ "matcher_errors": [],
1596
+ "untrusted_project_paths": [],
1597
+ "secret_values_included": False,
1598
+ }
1599
+ try:
1600
+ resolved = load_resolved_hooks_config(workspace_root)
1601
+ result["effective"] = {
1602
+ event: len(groups) for event, groups in resolved.groups_by_event.items()
1603
+ }
1604
+ matcher_errors: list[dict[str, str]] = []
1605
+ for event_name, groups in resolved.groups_by_event.items():
1606
+ for group in groups:
1607
+ if not group.matcher:
1608
+ continue
1609
+ try:
1610
+ re.compile(group.matcher)
1611
+ except re.error as exc:
1612
+ matcher_errors.append(
1613
+ {
1614
+ "event": event_name,
1615
+ "source_path": str(group.source_path),
1616
+ "matcher": group.matcher,
1617
+ "error": str(redact_secrets(str(exc))),
1618
+ }
1619
+ )
1620
+ result["matcher_errors"] = matcher_errors
1621
+ result["untrusted_project_paths"] = [str(path) for path in resolved.untrusted_project_paths]
1622
+ except ConfigError as exc:
1623
+ result["effective_error"] = str(redact_secrets(str(exc)))
1624
+ return result
1625
+
1626
+
1627
+ def _hooks_trace(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1628
+ cfg = load_config()
1629
+ sessions_dir = resolve_sessions_dir(cfg)
1630
+ session_id = _optional_str_or_none(params, "session_id")
1631
+ if not session_id:
1632
+ infos = list_sessions(sessions_dir)
1633
+ if not infos:
1634
+ return {"session_id": None, "events": [], "count": 0, "secret_values_included": False}
1635
+ session_id = infos[0].session_id
1636
+ artifact_path = hook_audit_artifact_path(sessions_dir=sessions_dir, session_id=session_id)
1637
+ events = list(read_hook_audit_events(artifact_path))
1638
+ limit = _positive_int(params, "limit", default=200, upper=2_000)
1639
+ limited = events[-limit:]
1640
+ return {
1641
+ "session_id": session_id,
1642
+ "artifact_path": str(artifact_path),
1643
+ "events": _redact_payload(limited),
1644
+ "count": len(limited),
1645
+ "total_count": len(events),
1646
+ "secret_values_included": False,
1647
+ }
1648
+
1649
+
1650
+ def _hooks_test(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1651
+ workspace_root = _workspace_root_from_params(params, request_id=request_id, required=True)
1652
+ canonical_event = _canonical_hook_event(params, request_id=request_id)
1653
+ runtime_kind = _runtime_kind_value(
1654
+ params,
1655
+ key="runtime_kind",
1656
+ default="interactive_chat",
1657
+ request_id=request_id,
1658
+ )
1659
+ session_source = _optional_str(params, "session_source", default="startup").strip().lower()
1660
+ if session_source not in _HOOK_SESSION_SOURCES:
1661
+ raise ProtocolError(
1662
+ "invalid_field",
1663
+ "session_source must be one of: startup, resume, fork.",
1664
+ request_id=request_id,
1665
+ )
1666
+ tool = _optional_str(params, "tool", default="").strip()
1667
+ resolved = load_resolved_hooks_config(workspace_root)
1668
+ rows: list[dict[str, Any]] = []
1669
+ for group in resolved.groups_for_event(canonical_event):
1670
+ matcher = str(group.matcher or "")
1671
+ for hook in group.hooks:
1672
+ matched, reason = _hook_test_match_result(
1673
+ event_name=canonical_event,
1674
+ matcher_target=tool,
1675
+ runtime_kind=runtime_kind,
1676
+ session_source=session_source,
1677
+ matcher=matcher,
1678
+ hook=hook,
1679
+ )
1680
+ rows.append(
1681
+ {
1682
+ "source_scope": group.source_scope,
1683
+ "trusted": bool(group.trusted),
1684
+ "matcher": matcher or "*",
1685
+ "id": hook.id or "",
1686
+ "runtime_kinds": list(hook.runtime_kinds or ()),
1687
+ "session_source": list(hook.session_source or ()),
1688
+ "matched": bool(matched),
1689
+ "reason": reason,
1690
+ "command": str(redact_secrets(hook.command)),
1691
+ }
1692
+ )
1693
+ return {
1694
+ "workspace_root": str(workspace_root),
1695
+ "event": canonical_event,
1696
+ "matches": rows,
1697
+ "ignored_untrusted_project_paths": [str(path) for path in resolved.untrusted_project_paths],
1698
+ "secret_values_included": False,
1699
+ }
1700
+
1701
+
1702
+ def _hooks_trust(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1703
+ target = _required_str(params, "target", request_id=request_id)
1704
+ if target != "project_config":
1705
+ raise ProtocolError(
1706
+ "invalid_field",
1707
+ "hooks.trust currently supports only target='project_config'.",
1708
+ request_id=request_id,
1709
+ )
1710
+ workspace_root, config_path = _project_hooks_config(
1711
+ params, request_id=request_id, validate=True
1712
+ )
1713
+ trust_project_hooks_config(workspace_root=workspace_root, config_path=config_path)
1714
+ return {
1715
+ "target": target,
1716
+ "workspace_root": str(workspace_root),
1717
+ "config_path": str(config_path),
1718
+ "trusted": True,
1719
+ "changed": True,
1720
+ "secret_values_included": False,
1721
+ }
1722
+
1723
+
1724
+ def _hooks_untrust(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1725
+ target = _required_str(params, "target", request_id=request_id)
1726
+ if target != "project_config":
1727
+ raise ProtocolError(
1728
+ "invalid_field",
1729
+ "hooks.untrust currently supports only target='project_config'.",
1730
+ request_id=request_id,
1731
+ )
1732
+ workspace_root, config_path = _project_hooks_config(
1733
+ params, request_id=request_id, validate=False
1734
+ )
1735
+ untrust_project_hooks_config(workspace_root=workspace_root, config_path=config_path)
1736
+ return {
1737
+ "target": target,
1738
+ "workspace_root": str(workspace_root),
1739
+ "config_path": str(config_path),
1740
+ "trusted": False,
1741
+ "changed": True,
1742
+ "secret_values_included": False,
1743
+ }
1744
+
1745
+
1746
+ def _hooks_init(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1747
+ workspace_root = _workspace_root_from_params(params, request_id=request_id, required=True)
1748
+ local_config_path = project_local_hooks_config_path(workspace_root)
1749
+ force = _optional_bool(params, "force", default=False)
1750
+ if local_config_path.exists() and not force:
1751
+ return _confirmation_required("hooks.init", "overwrite existing local hooks config")
1752
+ local_config_path.parent.mkdir(parents=True, exist_ok=True)
1753
+ local_config_path.write_text(
1754
+ json.dumps(_HOOKS_INIT_TEMPLATE, indent=2, sort_keys=True) + "\n",
1755
+ encoding="utf-8",
1756
+ )
1757
+ gitignore_changed = False
1758
+ gitignore_path = workspace_root / ".gitignore"
1759
+ gitignore_entry = ".alysis/hooks.local.json"
1760
+ if gitignore_path.exists():
1761
+ existing = gitignore_path.read_text(encoding="utf-8")
1762
+ already_ignored = any(
1763
+ line.strip() == gitignore_entry or line.strip() == ".alysis/"
1764
+ for line in existing.splitlines()
1765
+ )
1766
+ if not already_ignored:
1767
+ with gitignore_path.open("a", encoding="utf-8") as handle:
1768
+ if not existing.endswith("\n"):
1769
+ handle.write("\n")
1770
+ handle.write(f"{gitignore_entry}\n")
1771
+ gitignore_changed = True
1772
+ return {
1773
+ "workspace_root": str(workspace_root),
1774
+ "config_path": str(local_config_path),
1775
+ "changed": True,
1776
+ "gitignore_changed": gitignore_changed,
1777
+ "secret_values_included": False,
1778
+ }
1779
+
1780
+
1781
+ def _hooks_effective(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1782
+ workspace_root = _workspace_root_from_params(params, request_id=request_id, required=True)
1783
+ canonical_event = _canonical_hook_event(params, request_id=request_id)
1784
+ tool = _optional_str_or_none(params, "tool")
1785
+ runtime = _runtime_kind_value(
1786
+ params,
1787
+ key="runtime",
1788
+ default="interactive_chat",
1789
+ request_id=request_id,
1790
+ )
1791
+ session_source = _optional_str_or_none(params, "session_source")
1792
+ resolved = load_resolved_hooks_config(workspace_root)
1793
+ rows: list[dict[str, Any]] = []
1794
+ order = 0
1795
+ for group in resolved.groups_for_event(canonical_event):
1796
+ matcher_pass = True
1797
+ matcher_reason = ""
1798
+ if tool is not None and canonical_event in {"PreToolUse", "PostToolUse", "SubagentStop"}:
1799
+ if group.matcher:
1800
+ try:
1801
+ matcher_pass = re.compile(group.matcher).search(tool) is not None
1802
+ if not matcher_pass:
1803
+ matcher_reason = f"matcher {group.matcher!r} does not match {tool!r}"
1804
+ except re.error as exc:
1805
+ matcher_pass = False
1806
+ matcher_reason = f"matcher regex error: {exc}"
1807
+ for hook in group.hooks:
1808
+ order += 1
1809
+ fires = matcher_pass
1810
+ reason = matcher_reason
1811
+ if fires and hook.runtime_kinds and runtime not in set(hook.runtime_kinds):
1812
+ fires = False
1813
+ reason = f"runtime_kind {runtime!r} not in {list(hook.runtime_kinds)}"
1814
+ if fires and hook.session_source:
1815
+ if session_source is None:
1816
+ fires = False
1817
+ reason = "sessionSource filter set but session_source not provided"
1818
+ elif session_source not in set(hook.session_source):
1819
+ fires = False
1820
+ reason = f"session_source {session_source!r} not in {list(hook.session_source)}"
1821
+ rows.append(
1822
+ {
1823
+ "order": order,
1824
+ "id": hook.id or "",
1825
+ "fires": bool(fires),
1826
+ "reason": reason or "",
1827
+ "priority": hook.priority,
1828
+ "source_scope": group.source_scope,
1829
+ "source_path": str(group.source_path),
1830
+ "command": str(redact_secrets(hook.command)),
1831
+ }
1832
+ )
1833
+ return {
1834
+ "workspace_root": str(workspace_root),
1835
+ "event": canonical_event,
1836
+ "hooks": rows,
1837
+ "count": len(rows),
1838
+ "secret_values_included": False,
1839
+ }
1840
+
1841
+
1842
+ def _hooks_enable(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1843
+ return _set_hook_enabled_for_protocol(params, request_id=request_id, enabled=True)
1844
+
1845
+
1846
+ def _hooks_disable(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1847
+ return _set_hook_enabled_for_protocol(params, request_id=request_id, enabled=False)
1848
+
1849
+
1850
+ def _conventions_list(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1851
+ workspace_root, focus_path = _conventions_paths(params, request_id=request_id)
1852
+ documents = load_repo_conventions(focus_path=focus_path, workspace_root=workspace_root)
1853
+ return {
1854
+ "workspace_root": str(workspace_root),
1855
+ "focus_path": str(focus_path),
1856
+ "documents": [_conventions_document_payload(doc, workspace_root) for doc in documents],
1857
+ "count": len(documents),
1858
+ }
1859
+
1860
+
1861
+ def _conventions_render(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1862
+ workspace_root, focus_path = _conventions_paths(params, request_id=request_id)
1863
+ documents = load_repo_conventions(focus_path=focus_path, workspace_root=workspace_root)
1864
+ max_chars = _positive_int(
1865
+ params,
1866
+ "max_chars",
1867
+ default=_CONVENTIONS_RENDER_DEFAULT_MAX_CHARS,
1868
+ upper=_CONVENTIONS_RENDER_MAX_CHARS,
1869
+ )
1870
+ max_bytes = _positive_int(
1871
+ params,
1872
+ "max_bytes",
1873
+ default=_CONVENTIONS_RENDER_DEFAULT_MAX_BYTES,
1874
+ upper=_CONVENTIONS_RENDER_MAX_BYTES,
1875
+ )
1876
+ rendered = render_repo_conventions_context(
1877
+ documents=documents,
1878
+ max_chars=max_chars,
1879
+ )
1880
+ redacted_rendered = _redact_payload(rendered) if rendered is not None else None
1881
+ if isinstance(redacted_rendered, str):
1882
+ limited_rendered, truncated_by_bytes = _truncate_text_utf8(redacted_rendered, max_bytes)
1883
+ else:
1884
+ limited_rendered = None
1885
+ truncated_by_bytes = False
1886
+ rendered_bytes = len((limited_rendered or "").encode("utf-8"))
1887
+ truncated_by_chars = rendered is not None and len(rendered) >= max_chars
1888
+ return {
1889
+ "workspace_root": str(workspace_root),
1890
+ "focus_path": str(focus_path),
1891
+ "rendered": limited_rendered,
1892
+ "document_count": len(documents),
1893
+ "truncated_or_limited": truncated_by_chars or truncated_by_bytes,
1894
+ "truncated": truncated_by_chars or truncated_by_bytes,
1895
+ "truncated_by_chars": truncated_by_chars,
1896
+ "truncated_by_bytes": truncated_by_bytes,
1897
+ "max_chars": max_chars,
1898
+ "max_bytes": max_bytes,
1899
+ "rendered_chars": len(limited_rendered or ""),
1900
+ "rendered_bytes": rendered_bytes,
1901
+ "secret_values_included": False,
1902
+ "redacted": True,
1903
+ }
1904
+
1905
+
1906
+ def _ext_search(params: dict[str, Any], _request_id: RequestId) -> dict[str, Any]:
1907
+ query = _required_str(params, "query", request_id=_request_id)
1908
+ registry = load_registry()
1909
+ matches = search_extensions(registry, query)
1910
+ return {
1911
+ "extensions": [_registry_entry_payload(entry) for entry in matches],
1912
+ "count": len(matches),
1913
+ }
1914
+
1915
+
1916
+ def _ext_list(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1917
+ repo_root = _workspace_root_from_params(params, request_id=request_id, required=False)
1918
+ global_state = load_global_state()
1919
+ project_overrides = load_project_overrides(repo_root)
1920
+ project_state = load_project_state(repo_root)
1921
+ effective_enabled = compute_effective_enabled(global_state, project_overrides)
1922
+ installed: list[dict[str, Any]] = []
1923
+ for ext_id in sorted(set(global_state.installed) | set(project_state.installed)):
1924
+ record = global_state.installed.get(ext_id) or project_state.installed.get(ext_id)
1925
+ if record is None:
1926
+ continue
1927
+ installed.append(_installed_plugin_payload(ext_id, record, effective_enabled))
1928
+ return {
1929
+ "workspace_root": str(repo_root),
1930
+ "extensions": installed,
1931
+ "count": len(installed),
1932
+ "project_overrides": {
1933
+ "enabled": sorted(project_overrides.enabled),
1934
+ "disabled": sorted(project_overrides.disabled),
1935
+ },
1936
+ "secret_values_included": False,
1937
+ }
1938
+
1939
+
1940
+ def _ext_info(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1941
+ ext_id = _required_str_any(params, ("ext_id", "id", "plugin_id"), request_id=request_id)
1942
+ repo_root = _workspace_root_from_params(params, request_id=request_id, required=False)
1943
+ registry = load_registry()
1944
+ global_state = load_global_state()
1945
+ project_overrides = load_project_overrides(repo_root)
1946
+ project_state = load_project_state(repo_root)
1947
+ entry = find_by_id(registry, ext_id)
1948
+ normalized = normalize_extension_id(entry.id if entry is not None else ext_id)
1949
+ global_record = _installed_record(global_state, normalized)
1950
+ project_record = _installed_record(project_state, normalized)
1951
+ installed_record = global_record or project_record
1952
+ if entry is None and installed_record is None:
1953
+ raise ProtocolError(
1954
+ "extension_not_found", f"Extension not found: {ext_id}", request_id=request_id
1955
+ )
1956
+ effective_enabled = compute_effective_enabled(global_state, project_overrides)
1957
+ return {
1958
+ "workspace_root": str(repo_root),
1959
+ "extension": _registry_entry_payload(entry) if entry is not None else {"id": ext_id},
1960
+ "installed": _dataclass_payload(installed_record),
1961
+ "installed_scopes": [
1962
+ scope
1963
+ for scope, record in (("user", global_record), ("project", project_record))
1964
+ if record is not None
1965
+ ],
1966
+ "enabled_effective": normalized in effective_enabled,
1967
+ "project_override_state": _project_extension_override_state(project_overrides, normalized),
1968
+ "workspace_trust": _extension_workspace_trust_payload(repo_root),
1969
+ "secret_values_included": False,
1970
+ }
1971
+
1972
+
1973
+ def _ext_install(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
1974
+ source = _required_str(params, "source", request_id=request_id)
1975
+ _validate_ext_install_source(source, request_id=request_id)
1976
+ repo_root = _workspace_root_from_params(params, request_id=request_id, required=False)
1977
+ project = _optional_bool(params, "project", default=False)
1978
+ approval = _ext_install_trust_approval_param(params, request_id=request_id)
1979
+ if approval is not None and not _confirmed(params):
1980
+ return _confirmation_required(
1981
+ "ext.install",
1982
+ "install extension package after explicit package trust review",
1983
+ )
1984
+ trust_request: Any | None = None
1985
+
1986
+ def _trust_prompt(request: Any) -> bool:
1987
+ nonlocal trust_request
1988
+ trust_request = request
1989
+ if approval is None:
1990
+ return False
1991
+ _validate_ext_install_trust_approval(
1992
+ request,
1993
+ approval,
1994
+ source=source,
1995
+ project=project,
1996
+ request_id=request_id,
1997
+ )
1998
+ return True
1999
+
2000
+ try:
2001
+ result = install_plugin(
2002
+ source=source,
2003
+ repo_root=repo_root,
2004
+ project=project,
2005
+ trust_prompt=_trust_prompt,
2006
+ )
2007
+ except PluginInstallError as exc:
2008
+ if (
2009
+ trust_request is not None
2010
+ and approval is None
2011
+ and str(exc) == "install rejected by user"
2012
+ ):
2013
+ return _ext_install_trust_review_result(
2014
+ trust_request,
2015
+ source=source,
2016
+ project=project,
2017
+ )
2018
+ raise
2019
+ return {
2020
+ "result": _dataclass_payload(result),
2021
+ "changed": bool(getattr(result, "trust_was_prompted", True)),
2022
+ "secret_values_included": False,
2023
+ }
2024
+
2025
+
2026
+ def _ext_uninstall(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
2027
+ if not _confirmed(params):
2028
+ return _confirmation_required("ext.uninstall", "uninstall extension package")
2029
+ result = uninstall_plugin(
2030
+ plugin_id=_required_str_any(params, ("plugin_id", "ext_id", "id"), request_id=request_id),
2031
+ repo_root=_workspace_root_from_params(params, request_id=request_id, required=False),
2032
+ project=_optional_bool(params, "project", default=False),
2033
+ )
2034
+ return {"result": _dataclass_payload(result), "changed": True, "secret_values_included": False}
2035
+
2036
+
2037
+ def _ext_enable(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
2038
+ return _set_ext_enabled(params, request_id=request_id, enabled=True)
2039
+
2040
+
2041
+ def _ext_disable(params: dict[str, Any], request_id: RequestId) -> dict[str, Any]:
2042
+ return _set_ext_enabled(params, request_id=request_id, enabled=False)
2043
+
2044
+
2045
+ def _set_skill_enabled(
2046
+ params: dict[str, Any],
2047
+ *,
2048
+ request_id: RequestId,
2049
+ enabled: bool,
2050
+ ) -> dict[str, Any]:
2051
+ name = _required_str(params, "name", request_id=request_id)
2052
+ project = _optional_bool(params, "project", default=False)
2053
+ workspace_root, catalog = _skill_catalog(params, request_id=request_id)
2054
+ key = name.strip().casefold()
2055
+ if project:
2056
+ project_state = load_project_skill_state(workspace_root)
2057
+ if resolve_skill_catalog_entry(entries=catalog.entries, raw_name=name) is None and (
2058
+ key not in project_state.managed_installs
2059
+ ):
2060
+ raise ProtocolError(
2061
+ "skill_not_found",
2062
+ f"Skill not found for project override: {name}",
2063
+ request_id=request_id,
2064
+ )
2065
+ state = set_project_skill_override(project_state, name=name, enabled=enabled)
2066
+ save_project_skill_state(workspace_root, state)
2067
+ scope = "project"
2068
+ else:
2069
+ global_state = load_global_skill_state()
2070
+ visible_user_skill = next(
2071
+ (
2072
+ entry
2073
+ for entry in catalog.entries
2074
+ if str(getattr(entry.skill, "source_scope", "")) == "user"
2075
+ and key in entry.skill.lookup_keys()
2076
+ ),
2077
+ None,
2078
+ )
2079
+ if visible_user_skill is None and key not in global_state.managed_installs:
2080
+ raise ProtocolError(
2081
+ "skill_not_found", f"Global skill not found: {name}", request_id=request_id
2082
+ )
2083
+ state = set_global_skill_disabled(global_state, name=name, disabled=not enabled)
2084
+ save_global_skill_state(state)
2085
+ scope = "user"
2086
+ return {
2087
+ "workspace_root": str(workspace_root),
2088
+ "name": name,
2089
+ "enabled": enabled,
2090
+ "scope": scope,
2091
+ "changed": True,
2092
+ }
2093
+
2094
+
2095
+ def _mcp_manager(params: dict[str, Any], *, request_id: RequestId) -> Any:
2096
+ return _manual_mcp_manager_for_path(
2097
+ path=_workspace_root_from_params(params, request_id=request_id, required=True),
2098
+ runtime_kind=_runtime_kind_value(
2099
+ params,
2100
+ key="runtime",
2101
+ default="interactive_chat",
2102
+ request_id=request_id,
2103
+ mcp_prompt_runtime=True,
2104
+ ),
2105
+ )
2106
+
2107
+
2108
+ def _runtime_kind_value(
2109
+ params: dict[str, Any],
2110
+ *,
2111
+ key: str,
2112
+ default: str,
2113
+ request_id: RequestId,
2114
+ mcp_prompt_runtime: bool = False,
2115
+ ) -> str:
2116
+ raw = _optional_str(params, key, default=default)
2117
+ try:
2118
+ runtime = _parse_manual_mcp_prompt_runtime(raw) if mcp_prompt_runtime else RuntimeKind(raw)
2119
+ except (ConfigError, ValueError) as exc:
2120
+ raise ProtocolError("invalid_field", str(exc), request_id=request_id) from exc
2121
+ return runtime.value
2122
+
2123
+
2124
+ def _string_dict_param(value: Any, *, request_id: RequestId) -> dict[str, str]:
2125
+ if value is None:
2126
+ return {}
2127
+ if not isinstance(value, dict):
2128
+ raise ProtocolError(
2129
+ "invalid_field",
2130
+ "arguments must be an object with string keys and values.",
2131
+ request_id=request_id,
2132
+ )
2133
+ parsed: dict[str, str] = {}
2134
+ for key, child in value.items():
2135
+ key_text = str(key or "").strip()
2136
+ if not key_text:
2137
+ raise ProtocolError(
2138
+ "invalid_field", "argument keys cannot be empty.", request_id=request_id
2139
+ )
2140
+ parsed[key_text] = str(child)
2141
+ return parsed
2142
+
2143
+
2144
+ def _hook_source_status_payload(status: Any) -> dict[str, Any]:
2145
+ return {
2146
+ "source_scope": status.source_scope,
2147
+ "path": str(status.path),
2148
+ "exists": bool(status.exists),
2149
+ "trusted": bool(status.trusted),
2150
+ "status": status.status,
2151
+ "event_count": int(status.event_count),
2152
+ "hook_count": int(status.hook_count),
2153
+ "issue": str(redact_secrets(status.issue)),
2154
+ }
2155
+
2156
+
2157
+ def _project_hooks_config(
2158
+ params: dict[str, Any],
2159
+ *,
2160
+ request_id: RequestId,
2161
+ validate: bool,
2162
+ ) -> tuple[Path, Path]:
2163
+ workspace_root = _workspace_root_from_params(params, request_id=request_id, required=True)
2164
+ config_path = project_hooks_config_path(workspace_root)
2165
+ if not config_path.exists():
2166
+ raise ProtocolError(
2167
+ "hooks_config_not_found",
2168
+ "Project hooks config was not found.",
2169
+ request_id=request_id,
2170
+ )
2171
+ if validate:
2172
+ load_hook_config_file(config_path)
2173
+ return workspace_root, config_path
2174
+
2175
+
2176
+ def _canonical_hook_event(params: dict[str, Any], *, request_id: RequestId) -> str:
2177
+ try:
2178
+ return canonicalize_hook_event_name(
2179
+ _required_str_any(params, ("event", "event_name"), request_id=request_id)
2180
+ )
2181
+ except ValueError as exc:
2182
+ raise ProtocolError("invalid_field", str(exc), request_id=request_id) from exc
2183
+
2184
+
2185
+ def _set_hook_enabled_for_protocol(
2186
+ params: dict[str, Any],
2187
+ *,
2188
+ request_id: RequestId,
2189
+ enabled: bool,
2190
+ ) -> dict[str, Any]:
2191
+ workspace_root = _workspace_root_from_params(params, request_id=request_id, required=True)
2192
+ hook_id = _required_str(params, "hook_id", request_id=request_id)
2193
+ layer = _optional_str(params, "layer", default="local")
2194
+ config_path = _hooks_config_path_for_layer(workspace_root=workspace_root, layer=layer)
2195
+ if not config_path.exists():
2196
+ raise ProtocolError(
2197
+ "hooks_config_not_found", "Hooks config was not found.", request_id=request_id
2198
+ )
2199
+ result = _find_hook_in_layer(path=config_path, hook_id=hook_id)
2200
+ if result is None:
2201
+ raise ProtocolError(
2202
+ "hook_not_found", f"Hook id not found: {hook_id}", request_id=request_id
2203
+ )
2204
+ raw, entry = result
2205
+ previous = bool(entry.get("enabled", True))
2206
+ entry["enabled"] = enabled
2207
+ config_path.write_text(json.dumps(raw, indent=2, sort_keys=True) + "\n", encoding="utf-8")
2208
+ return {
2209
+ "workspace_root": str(workspace_root),
2210
+ "config_path": str(config_path),
2211
+ "hook_id": hook_id,
2212
+ "layer": layer,
2213
+ "enabled": enabled,
2214
+ "previous_enabled": previous,
2215
+ "changed": previous != enabled,
2216
+ "secret_values_included": False,
2217
+ }
2218
+
2219
+
2220
+ def _conventions_paths(params: dict[str, Any], *, request_id: RequestId) -> tuple[Path, Path]:
2221
+ raw = params.get("focus_path", params.get("path", params.get("workspace")))
2222
+ if raw is None:
2223
+ raise ProtocolError(
2224
+ "missing_param",
2225
+ "conventions methods require workspace, path, or focus_path.",
2226
+ request_id=request_id,
2227
+ )
2228
+ focus_path = Path(str(raw)).expanduser().resolve()
2229
+ workspace_root = _workspace_root_from_params(
2230
+ {"workspace": str(focus_path if focus_path.is_dir() else focus_path.parent)},
2231
+ request_id=request_id,
2232
+ required=True,
2233
+ )
2234
+ try:
2235
+ focus_path.relative_to(workspace_root.resolve())
2236
+ except ValueError as exc:
2237
+ raise ProtocolError(
2238
+ "path_outside_workspace",
2239
+ "Conventions focus_path must stay inside the resolved workspace.",
2240
+ request_id=request_id,
2241
+ ) from exc
2242
+ return workspace_root, focus_path
2243
+
2244
+
2245
+ def _conventions_document_payload(document: Any, workspace_root: Path) -> dict[str, Any]:
2246
+ try:
2247
+ display_path = document.path.relative_to(workspace_root).as_posix()
2248
+ except ValueError:
2249
+ display_path = str(document.path)
2250
+ return {
2251
+ "name": document.name,
2252
+ "trust_level": document.trust_level,
2253
+ "chars": len(document.content),
2254
+ "path": str(document.path),
2255
+ "workspace_relative_path": display_path,
2256
+ }
2257
+
2258
+
2259
+ def _registry_entry_payload(entry: Any) -> dict[str, Any]:
2260
+ if entry is None:
2261
+ return {}
2262
+ return {
2263
+ "id": entry.id,
2264
+ "name": entry.name,
2265
+ "description": entry.description,
2266
+ "repo": entry.repo,
2267
+ "commit": entry.commit,
2268
+ "version": entry.version,
2269
+ "tags": list(entry.tags),
2270
+ "permissions": list(entry.permissions),
2271
+ }
2272
+
2273
+
2274
+ def _installed_record(state: Any, ext_id: str) -> Any | None:
2275
+ normalized = normalize_extension_id(ext_id)
2276
+ for installed_id, installed in state.installed.items():
2277
+ if normalize_extension_id(installed_id) == normalized:
2278
+ return installed
2279
+ return None
2280
+
2281
+
2282
+ def _installed_plugin_payload(
2283
+ ext_id: str,
2284
+ record: Any,
2285
+ effective_enabled: set[str],
2286
+ ) -> dict[str, Any]:
2287
+ normalized = normalize_extension_id(ext_id)
2288
+ return {
2289
+ "id": ext_id,
2290
+ "enabled_effective": normalized in effective_enabled,
2291
+ "version": getattr(record, "version", "") or "",
2292
+ "trust": getattr(record, "trust", "") or "",
2293
+ "commit": getattr(record, "commit", "") or "",
2294
+ "scope": getattr(record, "scope", "") or "",
2295
+ "installed_at": getattr(record, "installed_at", "") or "",
2296
+ "component_ids": _redact_payload(getattr(record, "component_ids", {}) or {}),
2297
+ }
2298
+
2299
+
2300
+ def _project_extension_override_state(project_overrides: Any, normalized: str) -> str:
2301
+ enabled = {normalize_extension_id(item) for item in project_overrides.enabled}
2302
+ disabled = {normalize_extension_id(item) for item in project_overrides.disabled}
2303
+ if normalized in enabled:
2304
+ return "enabled"
2305
+ if normalized in disabled:
2306
+ return "disabled"
2307
+ return "absent"
2308
+
2309
+
2310
+ def _extension_workspace_trust_payload(repo_root: Path) -> dict[str, Any]:
2311
+ overrides_path = project_extensions_path(repo_root)
2312
+ if not overrides_path.exists():
2313
+ return {"state": "no_overrides_file", "path": str(overrides_path)}
2314
+ raw_bytes = overrides_path.read_bytes()
2315
+ overrides_sha256 = hashlib.sha256(raw_bytes).hexdigest()
2316
+ return {
2317
+ "state": "trusted"
2318
+ if extension_workspace_trusted(repo_root=repo_root, overrides_sha256=overrides_sha256)
2319
+ else "untrusted",
2320
+ "path": str(overrides_path),
2321
+ "sha256": overrides_sha256,
2322
+ }
2323
+
2324
+
2325
+ def _set_ext_enabled(
2326
+ params: dict[str, Any],
2327
+ *,
2328
+ request_id: RequestId,
2329
+ enabled: bool,
2330
+ ) -> dict[str, Any]:
2331
+ method = "ext.enable" if enabled else "ext.disable"
2332
+ if not _confirmed(params):
2333
+ return _confirmation_required(
2334
+ method, f"{'enable' if enabled else 'disable'} extension package"
2335
+ )
2336
+ fn = enable_plugin if enabled else disable_plugin
2337
+ result = fn(
2338
+ plugin_id=_required_str_any(params, ("plugin_id", "ext_id", "id"), request_id=request_id),
2339
+ repo_root=_workspace_root_from_params(params, request_id=request_id, required=False),
2340
+ project=_optional_bool(params, "project", default=False),
2341
+ )
2342
+ return {"result": _dataclass_payload(result), "changed": True, "secret_values_included": False}
2343
+
2344
+
2345
+ def _custom_tool_entries(
2346
+ params: dict[str, Any],
2347
+ *,
2348
+ request_id: RequestId,
2349
+ ) -> tuple[Path, tuple[CustomToolCatalogEntry, ...]]:
2350
+ from ..cli_impl.commands.tools import _discover_custom_tools_for_path
2351
+
2352
+ workspace_root = _workspace_root_from_params(params, request_id=request_id, required=True)
2353
+ cfg = load_config()
2354
+ resolved_root, state = _discover_custom_tools_for_path(path=workspace_root, cfg=cfg)
2355
+ return resolved_root, tuple(getattr(state, "catalog_entries", ()) or ())
2356
+
2357
+
2358
+ def _select_project_tool_entry(
2359
+ entries: tuple[CustomToolCatalogEntry, ...],
2360
+ *,
2361
+ name: str,
2362
+ request_id: RequestId,
2363
+ ) -> CustomToolCatalogEntry:
2364
+ matches = [entry for entry in entries if entry.name.casefold() == name.casefold()]
2365
+ if not matches:
2366
+ raise ProtocolError(
2367
+ "tool_not_found", f"Custom tool not found: {name}", request_id=request_id
2368
+ )
2369
+ for entry in matches:
2370
+ if (
2371
+ entry.source_scope == "project"
2372
+ and entry.spec is not None
2373
+ and entry.status != "shadowed"
2374
+ ):
2375
+ return entry
2376
+ raise ProtocolError(
2377
+ "unsupported_tool_trust_target",
2378
+ "Trust commands only apply to valid project custom tools.",
2379
+ request_id=request_id,
2380
+ )
2381
+
2382
+
2383
+ def _skill_catalog(params: dict[str, Any], *, request_id: RequestId) -> tuple[Path, Any]:
2384
+ workspace_root = _workspace_root_from_params(params, request_id=request_id, required=True)
2385
+ discovered = discover_skills(focus_path=workspace_root, workspace_root=workspace_root)
2386
+ catalog = resolve_skill_catalog(discovered=discovered, workspace_root=workspace_root)
2387
+ return workspace_root, catalog
2388
+
2389
+
2390
+ def _workspace_root_from_params(
2391
+ params: dict[str, Any],
2392
+ *,
2393
+ request_id: RequestId,
2394
+ required: bool,
2395
+ ) -> Path:
2396
+ raw = params.get("workspace", params.get("path"))
2397
+ if raw is None:
2398
+ if required:
2399
+ raise ProtocolError(
2400
+ "missing_param",
2401
+ "This method requires workspace or path.",
2402
+ request_id=request_id,
2403
+ )
2404
+ raw = "."
2405
+ path = Path(str(raw))
2406
+ try:
2407
+ binding = resolve_startup_workspace_binding(
2408
+ requested_path=path,
2409
+ interactive=False,
2410
+ create_if_missing=False,
2411
+ allow_broad_workspace=_optional_bool(params, "allow_broad_workspace", default=False),
2412
+ source="ide_management_param",
2413
+ action=WorkspaceAction.CHAT,
2414
+ )
2415
+ except WorkspaceBindingError as exc:
2416
+ raise ProtocolError(
2417
+ "workspace_binding_error",
2418
+ _redact_management_error_message(str(exc)),
2419
+ request_id=request_id,
2420
+ ) from exc
2421
+ return binding.workspace_context.workspace_root
2422
+
2423
+
2424
+ def _workspace_child_path(workspace_root: Path, raw: str, *, request_id: RequestId) -> Path:
2425
+ candidate = Path(raw).expanduser()
2426
+ if not candidate.is_absolute():
2427
+ candidate = workspace_root / candidate
2428
+ resolved = candidate.resolve()
2429
+ try:
2430
+ resolved.relative_to(workspace_root.resolve())
2431
+ except ValueError as exc:
2432
+ raise ProtocolError(
2433
+ "path_outside_workspace",
2434
+ "IDE management file paths must stay inside the workspace.",
2435
+ request_id=request_id,
2436
+ ) from exc
2437
+ return resolved
2438
+
2439
+
2440
+ def _validate_skill_install_source(
2441
+ source: str,
2442
+ *,
2443
+ workspace_root: Path,
2444
+ params: dict[str, Any],
2445
+ request_id: RequestId,
2446
+ ) -> str:
2447
+ raw = source.strip()
2448
+ if not raw:
2449
+ raise ProtocolError(
2450
+ "missing_param",
2451
+ "skill.install requires source.",
2452
+ request_id=request_id,
2453
+ )
2454
+ _reject_url_userinfo_value(raw, field="skill.install.source", request_id=request_id)
2455
+ if _is_remote_skill_source(raw):
2456
+ if _unsupported_remote_source(raw):
2457
+ raise ProtocolError(
2458
+ "unsupported_remote_source",
2459
+ "IDE skill.install only allows HTTPS remote sources; use the CLI for other remote transports.",
2460
+ request_id=request_id,
2461
+ )
2462
+ if not (_optional_bool(params, "allow_remote", default=False) and _confirmed(params)):
2463
+ raise ProtocolError(
2464
+ "remote_source_requires_confirmation",
2465
+ "Remote skill installs require allow_remote=true and explicit confirmation.",
2466
+ request_id=request_id,
2467
+ )
2468
+ return raw
2469
+
2470
+ candidate = Path(raw).expanduser()
2471
+ if not candidate.is_absolute():
2472
+ candidate = workspace_root / candidate
2473
+ if candidate.is_symlink():
2474
+ raise ProtocolError(
2475
+ "skill_install_symlink_rejected",
2476
+ "skill.install source paths must not be symlinks.",
2477
+ request_id=request_id,
2478
+ )
2479
+ resolved = candidate.resolve(strict=False)
2480
+ try:
2481
+ resolved.relative_to(workspace_root.resolve())
2482
+ except ValueError as exc:
2483
+ raise ProtocolError(
2484
+ "path_outside_workspace",
2485
+ "skill.install local source paths must stay inside the workspace.",
2486
+ request_id=request_id,
2487
+ ) from exc
2488
+ if not resolved.exists():
2489
+ raise ProtocolError(
2490
+ "skill_install_source_not_found",
2491
+ "skill.install local source path was not found.",
2492
+ request_id=request_id,
2493
+ )
2494
+ if resolved.is_symlink():
2495
+ raise ProtocolError(
2496
+ "skill_install_symlink_rejected",
2497
+ "skill.install source paths must not be symlinks.",
2498
+ request_id=request_id,
2499
+ )
2500
+ if resolved.is_dir():
2501
+ return str(resolved)
2502
+ if resolved.is_file() and resolved.suffix.casefold() == ".zip":
2503
+ return str(resolved)
2504
+ raise ProtocolError(
2505
+ "unsupported_skill_install_source",
2506
+ "skill.install local source must be a directory or .zip archive inside the workspace.",
2507
+ request_id=request_id,
2508
+ )
2509
+
2510
+
2511
+ def _validate_ext_install_source(source: str, *, request_id: RequestId) -> None:
2512
+ raw = source.strip()
2513
+ if not raw:
2514
+ raise ProtocolError("missing_param", "ext.install requires source.", request_id=request_id)
2515
+ _reject_url_userinfo_value(raw, field="ext.install.source", request_id=request_id)
2516
+ if _EXTENSION_REGISTRY_ID_RE.fullmatch(raw):
2517
+ return
2518
+ if raw.startswith("git+https://"):
2519
+ base, marker, commit = raw.rpartition("@")
2520
+ if marker and base and _SHA40_RE.fullmatch(commit):
2521
+ return
2522
+ raise ProtocolError(
2523
+ "invalid_extension_source",
2524
+ "ext.install git+https sources must be pinned with @<40-char-commit>.",
2525
+ request_id=request_id,
2526
+ )
2527
+ parsed = urlparse(raw)
2528
+ if parsed.scheme == "https" and parsed.netloc:
2529
+ if parsed.fragment and _SHA40_RE.fullmatch(parsed.fragment):
2530
+ return
2531
+ raise ProtocolError(
2532
+ "invalid_extension_source",
2533
+ "ext.install HTTPS sources must be pinned with #<40-char-commit>.",
2534
+ request_id=request_id,
2535
+ )
2536
+ if _looks_like_local_path(raw) or parsed.scheme or "://" in raw:
2537
+ raise ProtocolError(
2538
+ "invalid_extension_source",
2539
+ "ext.install source must be a registry id or pinned HTTPS git source.",
2540
+ request_id=request_id,
2541
+ )
2542
+ raise ProtocolError(
2543
+ "invalid_extension_source",
2544
+ "ext.install source must be a registry id or pinned HTTPS git source.",
2545
+ request_id=request_id,
2546
+ )
2547
+
2548
+
2549
+ def _is_remote_skill_source(source: str) -> bool:
2550
+ parsed = urlparse(source)
2551
+ return (
2552
+ bool(parsed.scheme)
2553
+ or source.startswith("git@")
2554
+ or source.startswith("ssh://")
2555
+ or source.startswith("git://")
2556
+ )
2557
+
2558
+
2559
+ def _unsupported_remote_source(source: str) -> bool:
2560
+ parsed = urlparse(source)
2561
+ if source.startswith("git+https://"):
2562
+ return False
2563
+ if parsed.scheme == "https":
2564
+ return False
2565
+ return True
2566
+
2567
+
2568
+ def _looks_like_local_path(source: str) -> bool:
2569
+ return (
2570
+ "/" in source
2571
+ or "\\" in source
2572
+ or source.startswith(".")
2573
+ or source.startswith("~")
2574
+ or Path(source).is_absolute()
2575
+ )
2576
+
2577
+
2578
+ def _session_log_path(params: dict[str, Any], *, request_id: RequestId) -> Path:
2579
+ session_id = _required_str(params, "session_id", request_id=request_id)
2580
+ sanitized = sanitize_session_id(session_id)
2581
+ if sanitized != session_id:
2582
+ raise ProtocolError(
2583
+ "invalid_session_id",
2584
+ "Session id contains unsupported characters.",
2585
+ request_id=request_id,
2586
+ )
2587
+ cfg = load_config()
2588
+ path = resolve_sessions_dir(cfg) / f"{session_id}.jsonl"
2589
+ if not path.exists() or not path.is_file():
2590
+ raise ProtocolError(
2591
+ "session_log_not_found", f"Session log not found: {session_id}", request_id=request_id
2592
+ )
2593
+ return path
2594
+
2595
+
2596
+ def _require_workspace_trust(
2597
+ params: dict[str, Any],
2598
+ *,
2599
+ method: str,
2600
+ request_id: RequestId,
2601
+ ) -> None:
2602
+ if params.get("workspace_trusted") is True:
2603
+ return
2604
+ raise ProtocolError(
2605
+ "workspace_trust_required",
2606
+ f"{method} mutates local Alysis Code state and requires Workspace Trust.",
2607
+ request_id=request_id,
2608
+ )
2609
+
2610
+
2611
+ def _require_workspace_binding_param(
2612
+ params: dict[str, Any],
2613
+ *,
2614
+ method: str,
2615
+ request_id: RequestId,
2616
+ ) -> None:
2617
+ for key in ("workspace", "path"):
2618
+ value = params.get(key)
2619
+ if isinstance(value, str) and value.strip():
2620
+ return
2621
+ raise ProtocolError(
2622
+ "missing_param",
2623
+ f"{method} requires an explicit workspace or path.",
2624
+ request_id=request_id,
2625
+ )
2626
+
2627
+
2628
+ def _safe_config_payload(cfg: AppConfig) -> dict[str, Any]:
2629
+ payload = cfg.model_dump()
2630
+ for key in list(payload):
2631
+ if _unsafe_config_response_key(key):
2632
+ payload[key] = "<redacted>" if payload[key] else payload[key]
2633
+ extra = dict(cfg.extra_fields or {})
2634
+ profiles_raw = extra.get("profiles")
2635
+ if isinstance(profiles_raw, dict):
2636
+ extra["profiles"] = {
2637
+ name: _safe_profile_config_dict(name, value)
2638
+ for name, value in sorted(profiles_raw.items(), key=lambda item: str(item[0]))
2639
+ if isinstance(value, dict)
2640
+ }
2641
+ payload["extra_fields"] = _redact_mapping_by_key(extra)
2642
+ return payload
2643
+
2644
+
2645
+ def _safe_profile_config_dict(name: object, value: dict[str, Any]) -> dict[str, Any]:
2646
+ safe = dict(value)
2647
+ env_var = safe.pop("api_key_env", None)
2648
+ if env_var:
2649
+ safe["env_var"] = str(env_var)
2650
+ headers = safe.get("extra_headers")
2651
+ if isinstance(headers, dict):
2652
+ safe["extra_headers"] = {
2653
+ "names": sorted(str(key) for key in headers),
2654
+ "count": len(headers),
2655
+ "values_redacted": True,
2656
+ }
2657
+ safe["name"] = str(name)
2658
+ return _redact_mapping_by_key(safe)
2659
+
2660
+
2661
+ def _profile_payload(
2662
+ cfg: AppConfig,
2663
+ profile: ProfileSpec | None,
2664
+ *,
2665
+ include_details: bool = False,
2666
+ ) -> dict[str, Any]:
2667
+ if profile is None:
2668
+ return {}
2669
+ resolved = resolve_profile_api_key(cfg, profile.name)
2670
+ active = profile.name == str((cfg.extra_fields or {}).get("active_profile") or "")
2671
+ auth_provider = str(profile.auth_provider or "").strip() or None
2672
+ payload: dict[str, Any] = {
2673
+ "name": profile.name,
2674
+ "active": active,
2675
+ "protocol": profile.protocol,
2676
+ "base_url": profile.base_url,
2677
+ "base_url_host": _url_host(profile.base_url),
2678
+ "default_model": profile.default_model,
2679
+ # These are selection metadata, not credentials. The IDE needs the
2680
+ # authoritative active-subscription verdict so it does not enable a
2681
+ # composer merely because an account profile exists while its required
2682
+ # model/reasoning selection is still incomplete.
2683
+ "auth_provider": auth_provider,
2684
+ "reasoning_effort": profile.reasoning_effort,
2685
+ "subscription_selection_ready": (
2686
+ active_subscription_selection_ready(cfg)
2687
+ if active and auth_provider is not None
2688
+ else None
2689
+ ),
2690
+ "web_search_adapter": profile.web_search_adapter,
2691
+ "web_search_model": profile.web_search_model,
2692
+ "notes": profile.notes,
2693
+ "key_env_var": profile.api_key_env,
2694
+ "api_key": _api_key_status(resolved),
2695
+ "extra_headers": {
2696
+ "names": sorted(profile.extra_headers),
2697
+ "count": len(profile.extra_headers),
2698
+ "values_redacted": True,
2699
+ },
2700
+ }
2701
+ if include_details:
2702
+ payload["protocol_kind"] = (
2703
+ "native" if profile.protocol != "openai_compat" else "compatibility"
2704
+ )
2705
+ return payload
2706
+
2707
+
2708
+ def _preset_payload(preset: Any) -> dict[str, Any]:
2709
+ return {
2710
+ "key": preset.key,
2711
+ "label": preset.label,
2712
+ "selection_label": preset_selection_label(preset),
2713
+ "protocol": preset.protocol,
2714
+ "protocol_kind": preset_protocol_kind(preset),
2715
+ "base_url": preset.base_url,
2716
+ "base_url_host": _url_host(preset.base_url),
2717
+ "key_env_var": preset.api_key_env,
2718
+ "suggested_models": list(preset.suggested_models),
2719
+ # Forwarded so IDE pickers can label a model with what it is for instead of showing a
2720
+ # bare slug. Both maps are static catalog prose - never credential-bearing.
2721
+ "suggested_model_descriptions": dict(preset.suggested_model_descriptions or {}),
2722
+ "model_aliases": dict(preset.model_aliases or {}),
2723
+ "validation_model": preset.validation_model,
2724
+ "web_search_adapter": preset.web_search_adapter,
2725
+ "web_search_model": preset.web_search_model,
2726
+ "setup_warning": preset.setup_warning,
2727
+ "notes": preset.notes,
2728
+ }
2729
+
2730
+
2731
+ def _secret_action_for_profile(profile: ProfileSpec) -> dict[str, Any] | None:
2732
+ if not profile.api_key_env:
2733
+ return None
2734
+ return {
2735
+ "kind": "open_configure_provider",
2736
+ "reason": "Provider credentials must be supplied through VS Code SecretStorage or existing CLI credential sources.",
2737
+ "profile": profile.name,
2738
+ "env_var": profile.api_key_env,
2739
+ }
2740
+
2741
+
2742
+ def _api_key_status(value: Any) -> dict[str, Any]:
2743
+ return {
2744
+ "present": bool(getattr(value, "key", None)),
2745
+ "source": str(getattr(value, "source", "missing") or "missing"),
2746
+ }
2747
+
2748
+
2749
+ def _extra_headers_secret_storage_action(
2750
+ params: dict[str, Any], *, request_id: RequestId
2751
+ ) -> dict[str, Any] | None:
2752
+ raw = params.get("extra_headers")
2753
+ if raw is None:
2754
+ return None
2755
+ if not isinstance(raw, dict):
2756
+ raise ProtocolError(
2757
+ "invalid_field",
2758
+ "extra_headers must be an object.",
2759
+ request_id=request_id,
2760
+ )
2761
+ if not raw:
2762
+ return None
2763
+ header_names: list[str] = []
2764
+ for key in raw:
2765
+ key_text = str(key or "").strip()
2766
+ if not key_text:
2767
+ raise ProtocolError(
2768
+ "invalid_field",
2769
+ "extra_headers keys must be non-empty strings.",
2770
+ request_id=request_id,
2771
+ )
2772
+ header_names.append(key_text)
2773
+ return {
2774
+ "kind": "requires_secret_storage",
2775
+ "method": "profile.add",
2776
+ "reason": "Provider extra header values cannot be passed through IDE protocol params.",
2777
+ "next_step": "open_configure_provider",
2778
+ "header_names": sorted(dict.fromkeys(header_names)),
2779
+ "values_redacted": True,
2780
+ }
2781
+
2782
+
2783
+ def _extra_headers_param(params: dict[str, Any], *, request_id: RequestId) -> dict[str, str]:
2784
+ raw = params.get("extra_headers")
2785
+ if raw is None:
2786
+ return {}
2787
+ if not isinstance(raw, dict):
2788
+ raise ProtocolError(
2789
+ "invalid_field",
2790
+ "extra_headers must be an object.",
2791
+ request_id=request_id,
2792
+ )
2793
+ headers: dict[str, str] = {}
2794
+ for key, value in raw.items():
2795
+ key_text = str(key or "").strip()
2796
+ value_text = str(value or "").strip()
2797
+ if not key_text or not value_text:
2798
+ raise ProtocolError(
2799
+ "invalid_field",
2800
+ "extra_headers keys and values must be non-empty strings.",
2801
+ request_id=request_id,
2802
+ )
2803
+ headers[key_text] = value_text
2804
+ return headers
2805
+
2806
+
2807
+ def _reject_secret_bearing_value(
2808
+ value: Any,
2809
+ *,
2810
+ field: str,
2811
+ request_id: RequestId,
2812
+ ) -> None:
2813
+ if _redacted_value_differs(value):
2814
+ raise ProtocolError(
2815
+ "inline_secret_rejected",
2816
+ f"{field} contains a secret-looking value; use configured credential storage instead.",
2817
+ request_id=request_id,
2818
+ )
2819
+
2820
+
2821
+ def _redacted_value_differs(value: Any) -> bool:
2822
+ return _json_safe(redact_secrets(value)) != _json_safe(value)
2823
+
2824
+
2825
+ def _reject_url_userinfo_value(
2826
+ value: Any,
2827
+ *,
2828
+ field: str,
2829
+ request_id: RequestId,
2830
+ ) -> None:
2831
+ if not isinstance(value, str):
2832
+ return
2833
+ parsed = urlparse(value.strip())
2834
+ if parsed.username is None and parsed.password is None:
2835
+ return
2836
+ raise ProtocolError(
2837
+ "invalid_base_url",
2838
+ f"{field} must not include URL userinfo.",
2839
+ request_id=request_id,
2840
+ )
2841
+
2842
+
2843
+ def _custom_tool_payload(
2844
+ entry: CustomToolCatalogEntry,
2845
+ *,
2846
+ workspace_root: Path,
2847
+ ) -> dict[str, Any]:
2848
+ payload: dict[str, Any] = {
2849
+ "name": entry.name,
2850
+ "source_scope": entry.source_scope,
2851
+ "trust": entry.trust,
2852
+ "status": entry.status,
2853
+ "source_path": str(entry.source_path),
2854
+ "detail": entry.detail,
2855
+ "manifest_version": entry.manifest_version,
2856
+ "exposed": entry.exposed,
2857
+ }
2858
+ if entry.spec is not None:
2859
+ payload.update(
2860
+ {
2861
+ "description": entry.spec.description,
2862
+ "relative_tool_path": entry.spec.relative_tool_path,
2863
+ "capabilities": entry.spec.capabilities.to_dict(),
2864
+ "timeout_s": entry.spec.timeout_s,
2865
+ "required_env": list(entry.spec.required_env),
2866
+ "missing_env": list(entry.spec.missing_env),
2867
+ "enabled_in": list(entry.spec.enabled_in),
2868
+ "isolation": entry.spec.isolation,
2869
+ "input_schema": entry.spec.input_schema,
2870
+ "output_schema": entry.spec.output_schema,
2871
+ }
2872
+ )
2873
+ if entry.issue is not None:
2874
+ payload["issue"] = {
2875
+ "code": entry.issue.code,
2876
+ "message": entry.issue.message,
2877
+ "relative_tool_path": entry.issue.relative_tool_path,
2878
+ }
2879
+ try:
2880
+ payload["workspace_relative_path"] = (
2881
+ entry.source_path.resolve().relative_to(workspace_root.resolve()).as_posix()
2882
+ )
2883
+ except ValueError:
2884
+ payload["workspace_relative_path"] = None
2885
+ return payload
2886
+
2887
+
2888
+ def _skill_entry_payload(entry: Any) -> dict[str, Any]:
2889
+ skill = entry.skill
2890
+ payload = _skill_payload(skill)
2891
+ payload.update(
2892
+ {
2893
+ "enabled": bool(entry.enabled),
2894
+ "managed": bool(entry.managed),
2895
+ "disabled_by": entry.disabled_by,
2896
+ "install_record": _dataclass_payload(entry.install_record)
2897
+ if entry.install_record is not None
2898
+ else None,
2899
+ }
2900
+ )
2901
+ return payload
2902
+
2903
+
2904
+ def _skill_payload(skill: Any) -> dict[str, Any]:
2905
+ return {
2906
+ "name": skill.name,
2907
+ "description": skill.description,
2908
+ "bundle_name": skill.bundle_name,
2909
+ "bundle_path": str(skill.bundle_path),
2910
+ "entry_path": str(skill.entry_path),
2911
+ "source_scope": skill.source_scope,
2912
+ "source_kind": skill.source_kind,
2913
+ "source_family": skill.source_family,
2914
+ "source_path": str(skill.source_path),
2915
+ "trust_level": skill.trust_level,
2916
+ "ancestor_distance": skill.ancestor_distance,
2917
+ "aliases": list(skill.aliases),
2918
+ "metadata": dict(skill.metadata),
2919
+ }
2920
+
2921
+
2922
+ def _skill_issues_payload(catalog: Any) -> dict[str, Any]:
2923
+ return {
2924
+ "discovery": [
2925
+ {"source_path": str(issue.source_path), "message": issue.message}
2926
+ for issue in getattr(catalog.effective, "issues", ())
2927
+ ],
2928
+ "lifecycle": [
2929
+ {"source_path": str(issue.source_path), "message": issue.message}
2930
+ for issue in getattr(catalog, "lifecycle_issues", ())
2931
+ ],
2932
+ }
2933
+
2934
+
2935
+ def _validation_payload(result: Any) -> dict[str, Any]:
2936
+ return {
2937
+ "bundle_path": str(result.bundle_path),
2938
+ "entry_path": str(result.entry_path),
2939
+ "valid": bool(result.valid),
2940
+ "name": result.name,
2941
+ "description": result.description,
2942
+ "metadata": dict(result.metadata),
2943
+ "issues": [
2944
+ {
2945
+ "severity": issue.severity,
2946
+ "message": issue.message,
2947
+ "path": str(issue.path),
2948
+ }
2949
+ for issue in result.issues
2950
+ ],
2951
+ }
2952
+
2953
+
2954
+ def _dataclass_payload(value: Any) -> Any:
2955
+ if value is None:
2956
+ return None
2957
+ if is_dataclass(value):
2958
+ return _json_safe(asdict(value))
2959
+ model_dump = getattr(value, "model_dump", None)
2960
+ if callable(model_dump):
2961
+ return _json_safe(model_dump(mode="json"))
2962
+ return _json_safe(value)
2963
+
2964
+
2965
+ def _json_safe(value: Any) -> Any:
2966
+ if isinstance(value, Path):
2967
+ return str(value)
2968
+ if isinstance(value, dict):
2969
+ return {str(key): _json_safe(child) for key, child in value.items()}
2970
+ if isinstance(value, (list, tuple, set)):
2971
+ return [_json_safe(child) for child in value]
2972
+ return value
2973
+
2974
+
2975
+ def _rows_to_dict(rows: tuple[tuple[str, str], ...]) -> dict[str, str]:
2976
+ return {str(key): str(value) for key, value in rows}
2977
+
2978
+
2979
+ def _url_host(value: str | None) -> str:
2980
+ from urllib.parse import urlsplit
2981
+
2982
+ try:
2983
+ return urlsplit(str(value or "")).netloc
2984
+ except ValueError:
2985
+ return ""
2986
+
2987
+
2988
+ def _redact_mapping_by_key(value: Any) -> Any:
2989
+ if isinstance(value, dict):
2990
+ out: dict[str, Any] = {}
2991
+ for key, child in value.items():
2992
+ key_text = str(key)
2993
+ if _unsafe_config_response_key(key_text):
2994
+ out[key_text] = "<redacted>" if child else child
2995
+ else:
2996
+ out[key_text] = _redact_mapping_by_key(child)
2997
+ return out
2998
+ if isinstance(value, list):
2999
+ return [_redact_mapping_by_key(child) for child in value]
3000
+ return value
3001
+
3002
+
3003
+ def _redact_payload(value: Any) -> Any:
3004
+ if isinstance(value, dict):
3005
+ out: dict[str, Any] = {}
3006
+ for key, child in value.items():
3007
+ key_text = str(key)
3008
+ if _unsafe_payload_response_key(key_text) and isinstance(child, str):
3009
+ out[key_text] = "<redacted>" if child else child
3010
+ else:
3011
+ out[key_text] = _redact_payload(child)
3012
+ return out
3013
+ if isinstance(value, list):
3014
+ return [_redact_payload(child) for child in value]
3015
+ if isinstance(value, tuple):
3016
+ return [_redact_payload(child) for child in value]
3017
+ if isinstance(value, str):
3018
+ return _redact_secret_string(value)
3019
+ return value
3020
+
3021
+
3022
+ def _redact_management_error_message(message: str) -> str:
3023
+ return _redact_secret_string(str(message))
3024
+
3025
+
3026
+ def _redact_secret_string(value: str) -> str:
3027
+ redacted = str(redact_secrets(value))
3028
+ redacted = _SECRET_ASSIGNMENT_PATTERN.sub(r"\1<redacted>", redacted)
3029
+ redacted = _URL_USERINFO_PATTERN.sub(r"\1<redacted>@\3", redacted)
3030
+ return redacted
3031
+
3032
+
3033
+ def _unsafe_payload_response_key(key: str) -> bool:
3034
+ normalized = key.strip().lower()
3035
+ if normalized in {
3036
+ "api_key_env",
3037
+ "key_env_var",
3038
+ "secret_action",
3039
+ "secret_policy",
3040
+ "secret_redaction",
3041
+ "secret_values_included",
3042
+ "secret_values_in_params",
3043
+ "values_redacted",
3044
+ }:
3045
+ return False
3046
+ return _unsafe_config_response_key(key)
3047
+
3048
+
3049
+ def _json_size_bytes(value: Any) -> int:
3050
+ return len(
3051
+ json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")
3052
+ )
3053
+
3054
+
3055
+ def _truncate_text_utf8(value: str, max_bytes: int) -> tuple[str, bool]:
3056
+ encoded = value.encode("utf-8")
3057
+ if len(encoded) <= max_bytes:
3058
+ return value, False
3059
+ return encoded[:max_bytes].decode("utf-8", errors="ignore"), True
3060
+
3061
+
3062
+ def _unsafe_config_response_key(key: str) -> bool:
3063
+ normalized = key.strip().lower()
3064
+ if normalized in {"api_key_env", "key_env_var"}:
3065
+ return False
3066
+ return normalized in {"prompt_cache_key"} or any(
3067
+ marker in normalized
3068
+ for marker in ("api_key", "token", "secret", "password", "credential", "authorization")
3069
+ )
3070
+
3071
+
3072
+ def _unsafe_config_set_key(key: str) -> bool:
3073
+ normalized = key.strip().lower()
3074
+ return normalized in {"prompt_cache_key"} or any(
3075
+ marker in normalized
3076
+ for marker in ("api_key", "token", "secret", "password", "credential", "authorization")
3077
+ )
3078
+
3079
+
3080
+ def _config_value_to_cli_string(value: Any) -> str:
3081
+ if isinstance(value, bool):
3082
+ return "true" if value else "false"
3083
+ if isinstance(value, int | float):
3084
+ return str(value)
3085
+ if isinstance(value, list | dict):
3086
+ return json.dumps(value, ensure_ascii=True, sort_keys=True)
3087
+ return str(value)
3088
+
3089
+
3090
+ def _required_str(
3091
+ params: dict[str, Any],
3092
+ key: str,
3093
+ *,
3094
+ request_id: RequestId,
3095
+ ) -> str:
3096
+ value = params.get(key)
3097
+ if not isinstance(value, str) or not value.strip():
3098
+ raise ProtocolError(
3099
+ "missing_param", f"Missing required string param: {key}.", request_id=request_id
3100
+ )
3101
+ return value.strip()
3102
+
3103
+
3104
+ def _required_str_any(
3105
+ params: dict[str, Any],
3106
+ keys: tuple[str, ...],
3107
+ *,
3108
+ request_id: RequestId,
3109
+ ) -> str:
3110
+ for key in keys:
3111
+ if key in params:
3112
+ return _required_str(params, key, request_id=request_id)
3113
+ raise ProtocolError(
3114
+ "missing_param",
3115
+ f"Missing required string param: {' or '.join(keys)}.",
3116
+ request_id=request_id,
3117
+ )
3118
+
3119
+
3120
+ def _optional_str(params: dict[str, Any], key: str, *, default: str) -> str:
3121
+ value = params.get(key)
3122
+ if value is None:
3123
+ return default
3124
+ return str(value)
3125
+
3126
+
3127
+ def _optional_str_or_none(params: dict[str, Any], key: str) -> str | None:
3128
+ if key not in params or params.get(key) is None:
3129
+ return None
3130
+ text = str(params.get(key) or "").strip()
3131
+ return text or None
3132
+
3133
+
3134
+ def _optional_bool(params: dict[str, Any], key: str, *, default: bool) -> bool:
3135
+ value = params.get(key)
3136
+ if value is None:
3137
+ return default
3138
+ if isinstance(value, bool):
3139
+ return value
3140
+ if isinstance(value, str):
3141
+ lowered = value.strip().lower()
3142
+ if lowered in {"1", "true", "yes", "on"}:
3143
+ return True
3144
+ if lowered in {"0", "false", "no", "off"}:
3145
+ return False
3146
+ return default
3147
+
3148
+
3149
+ def _positive_int(
3150
+ params: dict[str, Any],
3151
+ key: str,
3152
+ *,
3153
+ default: int,
3154
+ upper: int,
3155
+ ) -> int:
3156
+ value = params.get(key)
3157
+ if value is None:
3158
+ return default
3159
+ try:
3160
+ parsed = int(value)
3161
+ except (TypeError, ValueError):
3162
+ return default
3163
+ return max(0, min(parsed, upper))
3164
+
3165
+
3166
+ def _string_list_param(value: Any) -> list[str]:
3167
+ if value is None:
3168
+ return []
3169
+ if isinstance(value, str):
3170
+ return [value.strip()] if value.strip() else []
3171
+ if isinstance(value, list):
3172
+ return [str(item).strip() for item in value if str(item).strip()]
3173
+ return []
3174
+
3175
+
3176
+ def _ext_install_trust_approval_param(
3177
+ params: dict[str, Any], *, request_id: RequestId
3178
+ ) -> dict[str, Any] | None:
3179
+ raw = params.get("trust_approval")
3180
+ if raw is None:
3181
+ raw = params.get("trustApproval")
3182
+ if raw is None:
3183
+ return None
3184
+ if not isinstance(raw, dict):
3185
+ raise ProtocolError(
3186
+ "invalid_field",
3187
+ "trust_approval must be an object returned from an ext.install trust review.",
3188
+ request_id=request_id,
3189
+ )
3190
+ if raw.get("approved") is not True:
3191
+ raise ProtocolError(
3192
+ "extension_trust_required",
3193
+ "trust_approval.approved must be true to install extension packages.",
3194
+ request_id=request_id,
3195
+ )
3196
+ required = ("plugin_id", "commit", "manifest_sha256", "approval_fingerprint")
3197
+ approval: dict[str, Any] = {"approved": True}
3198
+ for field in required:
3199
+ value = raw.get(field)
3200
+ if not isinstance(value, str) or not value.strip():
3201
+ raise ProtocolError(
3202
+ "invalid_field",
3203
+ f"trust_approval.{field} must be a non-empty string.",
3204
+ request_id=request_id,
3205
+ )
3206
+ approval[field] = value.strip()
3207
+ if "project" in raw:
3208
+ if not isinstance(raw["project"], bool):
3209
+ raise ProtocolError(
3210
+ "invalid_field",
3211
+ "trust_approval.project must be a boolean when provided.",
3212
+ request_id=request_id,
3213
+ )
3214
+ approval["project"] = raw["project"]
3215
+ for optional in ("source", "source_url"):
3216
+ value = raw.get(optional)
3217
+ if value is not None:
3218
+ if not isinstance(value, str) or not value.strip():
3219
+ raise ProtocolError(
3220
+ "invalid_field",
3221
+ f"trust_approval.{optional} must be a non-empty string when provided.",
3222
+ request_id=request_id,
3223
+ )
3224
+ approval[optional] = value.strip()
3225
+ return approval
3226
+
3227
+
3228
+ def _ext_install_trust_review_result(
3229
+ request: Any,
3230
+ *,
3231
+ source: str,
3232
+ project: bool,
3233
+ ) -> dict[str, Any]:
3234
+ trust_request = _redact_payload(_dataclass_payload(request))
3235
+ approval = _ext_install_required_approval(
3236
+ trust_request,
3237
+ source=source,
3238
+ project=project,
3239
+ )
3240
+ return {
3241
+ "changed": False,
3242
+ "action": {
3243
+ "kind": "requires_extension_trust_review",
3244
+ "method": "ext.install",
3245
+ "reason": "extension package install requires explicit review of manifest, source, permissions, and components",
3246
+ "approval_schema": "extension_install_trust_approval_v1",
3247
+ "required_approval": approval,
3248
+ },
3249
+ "trust_request": trust_request,
3250
+ "secret_values_included": False,
3251
+ }
3252
+
3253
+
3254
+ def _validate_ext_install_trust_approval(
3255
+ request: Any,
3256
+ approval: dict[str, Any],
3257
+ *,
3258
+ source: str,
3259
+ project: bool,
3260
+ request_id: RequestId,
3261
+ ) -> None:
3262
+ trust_request = _redact_payload(_dataclass_payload(request))
3263
+ expected = _ext_install_required_approval(
3264
+ trust_request,
3265
+ source=source,
3266
+ project=project,
3267
+ )
3268
+ for field in ("plugin_id", "commit", "manifest_sha256", "approval_fingerprint"):
3269
+ if approval.get(field) != expected[field]:
3270
+ raise ProtocolError(
3271
+ "extension_trust_mismatch",
3272
+ f"trust_approval.{field} does not match the extension package being installed.",
3273
+ request_id=request_id,
3274
+ )
3275
+ if "project" in approval and approval["project"] != expected["project"]:
3276
+ raise ProtocolError(
3277
+ "extension_trust_mismatch",
3278
+ "trust_approval.project does not match the requested install scope.",
3279
+ request_id=request_id,
3280
+ )
3281
+ if "source" in approval and approval["source"] != expected["source"]:
3282
+ raise ProtocolError(
3283
+ "extension_trust_mismatch",
3284
+ "trust_approval.source does not match the requested install source.",
3285
+ request_id=request_id,
3286
+ )
3287
+ if "source_url" in approval and approval["source_url"] != expected["source_url"]:
3288
+ raise ProtocolError(
3289
+ "extension_trust_mismatch",
3290
+ "trust_approval.source_url does not match the resolved package source.",
3291
+ request_id=request_id,
3292
+ )
3293
+
3294
+
3295
+ def _ext_install_required_approval(
3296
+ trust_request: dict[str, Any],
3297
+ *,
3298
+ source: str,
3299
+ project: bool,
3300
+ ) -> dict[str, Any]:
3301
+ approval = {
3302
+ "approved": True,
3303
+ "plugin_id": str(trust_request.get("plugin_id") or ""),
3304
+ "commit": str(trust_request.get("commit") or ""),
3305
+ "manifest_sha256": str(trust_request.get("manifest_sha256") or ""),
3306
+ "source": str(source or ""),
3307
+ "source_url": str(trust_request.get("source_url") or ""),
3308
+ "project": bool(project),
3309
+ }
3310
+ approval["approval_fingerprint"] = _ext_install_approval_fingerprint(approval)
3311
+ return approval
3312
+
3313
+
3314
+ def _ext_install_approval_fingerprint(approval: dict[str, Any]) -> str:
3315
+ payload = {
3316
+ key: approval[key]
3317
+ for key in (
3318
+ "plugin_id",
3319
+ "commit",
3320
+ "manifest_sha256",
3321
+ "source",
3322
+ "source_url",
3323
+ "project",
3324
+ )
3325
+ }
3326
+ encoded = json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
3327
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
3328
+
3329
+
3330
+ def _confirmed(params: dict[str, Any]) -> bool:
3331
+ return _optional_bool(params, "yes", default=False) or _optional_bool(
3332
+ params, "confirm", default=False
3333
+ )
3334
+
3335
+
3336
+ def _confirmation_required(method: str, reason: str) -> dict[str, Any]:
3337
+ return {
3338
+ "changed": False,
3339
+ "action": {
3340
+ "kind": "requires_confirmation",
3341
+ "method": method,
3342
+ "reason": reason,
3343
+ },
3344
+ "secret_values_included": False,
3345
+ }
3346
+
3347
+
3348
+ _HANDLERS: dict[str, Callable[[dict[str, Any], RequestId], dict[str, Any]]] = {
3349
+ "config.get": _config_get,
3350
+ "config.set": _config_set,
3351
+ "config.schema": _config_schema,
3352
+ "config.validate": _config_validate,
3353
+ "profile.list": _profile_list,
3354
+ "profile.show": _profile_show,
3355
+ "profile.add": _profile_add,
3356
+ "profile.remove": _profile_remove,
3357
+ "profile.use": _profile_use,
3358
+ "profile.rename": _profile_rename,
3359
+ "profile.presets": _profile_presets,
3360
+ "profile.preset": _profile_preset,
3361
+ "profile.convert": _profile_convert,
3362
+ "session.show": _session_show,
3363
+ "session.usage": _session_usage,
3364
+ "session.score": _session_score,
3365
+ "tools.catalog": _tools_catalog,
3366
+ "tool.list": _tool_list,
3367
+ "tool.info": _tool_info,
3368
+ "tool.trust": _tool_trust,
3369
+ "tool.untrust": _tool_untrust,
3370
+ "skill.list": _skill_list,
3371
+ "skill.info": _skill_info,
3372
+ "skill.init": _skill_init,
3373
+ "skill.validate": _skill_validate,
3374
+ "skill.install": _skill_install,
3375
+ "skill.enable": _skill_enable,
3376
+ "skill.disable": _skill_disable,
3377
+ "skill.remove": _skill_remove,
3378
+ "doctor.summary": _doctor_summary,
3379
+ "doctor.providers": _doctor_providers,
3380
+ "doctor.providers.live": _doctor_providers_live,
3381
+ "doctor.bundle": _doctor_bundle,
3382
+ "sandbox.doctor": _sandbox_doctor,
3383
+ "sandbox.setup": _sandbox_setup,
3384
+ "sandbox.pull": _sandbox_pull,
3385
+ "update.check": _update_check,
3386
+ "report.create": _report_create,
3387
+ "mcp.status": _mcp_status,
3388
+ "mcp.prompts.list": _mcp_prompts_list,
3389
+ "mcp.prompts.get": _mcp_prompts_get,
3390
+ "mcp.auth.status": _mcp_auth_status,
3391
+ "mcp.auth.login.start": _mcp_auth_login_start,
3392
+ "mcp.auth.login.status": _mcp_auth_login_status,
3393
+ "mcp.auth.login.cancel": _mcp_auth_login_cancel,
3394
+ "mcp.auth.logout": _mcp_auth_logout,
3395
+ "hooks.list": _hooks_list,
3396
+ "hooks.doctor": _hooks_doctor,
3397
+ "hooks.trace": _hooks_trace,
3398
+ "hooks.test": _hooks_test,
3399
+ "hooks.trust": _hooks_trust,
3400
+ "hooks.untrust": _hooks_untrust,
3401
+ "hooks.init": _hooks_init,
3402
+ "hooks.effective": _hooks_effective,
3403
+ "hooks.enable": _hooks_enable,
3404
+ "hooks.disable": _hooks_disable,
3405
+ "conventions.list": _conventions_list,
3406
+ "conventions.render": _conventions_render,
3407
+ "ext.search": _ext_search,
3408
+ "ext.list": _ext_list,
3409
+ "ext.info": _ext_info,
3410
+ "ext.install": _ext_install,
3411
+ "ext.uninstall": _ext_uninstall,
3412
+ "ext.enable": _ext_enable,
3413
+ "ext.disable": _ext_disable,
3414
+ }