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