@opengsd/gsd-path 1.0.0
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.
- package/AGENTS.md +397 -0
- package/DOCS.md +341 -0
- package/FULL.md +475 -0
- package/GUIDE.md +23 -0
- package/HOOKS.md +273 -0
- package/LICENSE +21 -0
- package/MIGRATE.md +100 -0
- package/QUICK.md +146 -0
- package/README.md +280 -0
- package/RUNTIME.md +229 -0
- package/UPDATE.md +196 -0
- package/WORKFLOW.md +683 -0
- package/package.json +80 -0
- package/platforms/claude/dispatch.md +63 -0
- package/platforms/copilot/dispatch.md +61 -0
- package/platforms/cursor/agent.md +17 -0
- package/platforms/cursor/dispatch.md +63 -0
- package/platforms/grok/dispatch.md +61 -0
- package/platforms/kimi/dispatch.md +64 -0
- package/platforms/kiro/dispatch.md +58 -0
- package/platforms/opencode/dispatch.md +62 -0
- package/platforms/qwen/dispatch.md +60 -0
- package/platforms/shared-agents/dispatch.md +131 -0
- package/plugin.json +14 -0
- package/scripts/_common.py +180 -0
- package/scripts/archive_milestone.py +2265 -0
- package/scripts/bootstrap_repository.py +710 -0
- package/scripts/build_state.py +854 -0
- package/scripts/check_docs_audit.py +463 -0
- package/scripts/check_handoffs.py +1855 -0
- package/scripts/check_task_briefs.py +376 -0
- package/scripts/check_update.py +93 -0
- package/scripts/core_hook_gate.py +34 -0
- package/scripts/core_hook_settings.py +119 -0
- package/scripts/detect_project.py +1517 -0
- package/scripts/discussion_records.py +619 -0
- package/scripts/discussion_validate.py +1321 -0
- package/scripts/dispatch_driver.py +1812 -0
- package/scripts/git_guard.py +759 -0
- package/scripts/guard_hook.py +2124 -0
- package/scripts/install.mjs +3239 -0
- package/scripts/install.py +3008 -0
- package/scripts/integration.py +1433 -0
- package/scripts/isolation.py +3538 -0
- package/scripts/lean_verification.py +256 -0
- package/scripts/loop_run.py +837 -0
- package/scripts/migrate_core.py +169 -0
- package/scripts/pipeline_diagnose.py +715 -0
- package/scripts/pipeline_git.py +940 -0
- package/scripts/pipeline_state.py +2291 -0
- package/scripts/pipeline_undo.py +1039 -0
- package/scripts/promote_lookahead.py +415 -0
- package/scripts/review_findings.py +714 -0
- package/scripts/review_panel.py +553 -0
- package/scripts/skill-resources.json +340 -0
- package/scripts/state_checkpoint.py +1039 -0
- package/scripts/state_promote.py +743 -0
- package/scripts/status_runtime.py +122 -0
- package/scripts/sync_skill_resources.py +259 -0
- package/scripts/token_budget.py +146 -0
- package/scripts/wizard.mjs +179 -0
- package/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/BUILD.md +700 -0
- package/skills/gsd-path/DECIDE.md +115 -0
- package/skills/gsd-path/DEFINE.md +297 -0
- package/skills/gsd-path/DOCS-AUDIT.md +190 -0
- package/skills/gsd-path/INSPECT.md +167 -0
- package/skills/gsd-path/PLAN.md +411 -0
- package/skills/gsd-path/RESEARCH.md +159 -0
- package/skills/gsd-path/ROADMAP.md +215 -0
- package/skills/gsd-path/SHIP.md +412 -0
- package/skills/gsd-path/SKILL.md +471 -0
- package/skills/gsd-path/agents/openai.yaml +6 -0
- package/skills/gsd-path/references/codebase-mapper.md +52 -0
- package/skills/gsd-path/references/coder.md +90 -0
- package/skills/gsd-path/references/decider.md +36 -0
- package/skills/gsd-path/references/dispatch.md +131 -0
- package/skills/gsd-path/references/docs-auditor.md +60 -0
- package/skills/gsd-path/references/planner.md +104 -0
- package/skills/gsd-path/references/researcher.md +42 -0
- package/skills/gsd-path/references/reviewer.md +188 -0
- package/skills/gsd-path/references/roadmapper.md +48 -0
- package/skills/gsd-path/scripts/_common.py +180 -0
- package/skills/gsd-path/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path/scripts/bootstrap_repository.py +710 -0
- package/skills/gsd-path/scripts/build_state.py +854 -0
- package/skills/gsd-path/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path/scripts/check_update.py +93 -0
- package/skills/gsd-path/scripts/detect_project.py +1517 -0
- package/skills/gsd-path/scripts/discussion_records.py +619 -0
- package/skills/gsd-path/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path/scripts/integration.py +1433 -0
- package/skills/gsd-path/scripts/isolation.py +3538 -0
- package/skills/gsd-path/scripts/lean_verification.py +256 -0
- package/skills/gsd-path/scripts/loop_run.py +837 -0
- package/skills/gsd-path/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path/scripts/review_findings.py +714 -0
- package/skills/gsd-path/scripts/review_panel.py +553 -0
- package/skills/gsd-path/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path/scripts/state_promote.py +743 -0
- package/skills/gsd-path/scripts/token_budget.py +146 -0
- package/skills/gsd-path/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/templates/answers.md +29 -0
- package/skills/gsd-path/templates/archive-manifest.md +37 -0
- package/skills/gsd-path/templates/charter.md +50 -0
- package/skills/gsd-path/templates/codebase.md +46 -0
- package/skills/gsd-path/templates/dialogue.md +21 -0
- package/skills/gsd-path/templates/docs-audit.md +69 -0
- package/skills/gsd-path/templates/evidence.md +24 -0
- package/skills/gsd-path/templates/final-review.md +27 -0
- package/skills/gsd-path/templates/gap-review.md +20 -0
- package/skills/gsd-path/templates/intent.md +98 -0
- package/skills/gsd-path/templates/loop.md +59 -0
- package/skills/gsd-path/templates/patch-findings.md +16 -0
- package/skills/gsd-path/templates/plan-panel.md +25 -0
- package/skills/gsd-path/templates/plan.md +89 -0
- package/skills/gsd-path/templates/repository.md +16 -0
- package/skills/gsd-path/templates/research-handoff.md +25 -0
- package/skills/gsd-path/templates/roadmap.md +38 -0
- package/skills/gsd-path/templates/skeptic.md +36 -0
- package/skills/gsd-path/templates/state.md +27 -0
- package/skills/gsd-path/templates/synthesis.md +36 -0
- package/skills/gsd-path/templates/task.md +74 -0
- package/skills/gsd-path/templates/wave-panel.md +25 -0
- package/skills/gsd-path/templates/wave-review.md +64 -0
- package/skills/gsd-path-build/SKILL.md +700 -0
- package/skills/gsd-path-build/agents/openai.yaml +6 -0
- package/skills/gsd-path-build/references/coder.md +90 -0
- package/skills/gsd-path-build/references/dispatch.md +131 -0
- package/skills/gsd-path-build/references/reviewer.md +188 -0
- package/skills/gsd-path-build/scripts/_common.py +180 -0
- package/skills/gsd-path-build/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-build/scripts/build_state.py +854 -0
- package/skills/gsd-path-build/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-build/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-build/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-build/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-build/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path-build/scripts/integration.py +1433 -0
- package/skills/gsd-path-build/scripts/isolation.py +3538 -0
- package/skills/gsd-path-build/scripts/loop_run.py +837 -0
- package/skills/gsd-path-build/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-build/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-build/scripts/review_findings.py +714 -0
- package/skills/gsd-path-build/scripts/review_panel.py +553 -0
- package/skills/gsd-path-build/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-build/scripts/state_promote.py +743 -0
- package/skills/gsd-path-build/scripts/token_budget.py +146 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-build/templates/skeptic.md +36 -0
- package/skills/gsd-path-build/templates/task.md +74 -0
- package/skills/gsd-path-build/templates/wave-panel.md +25 -0
- package/skills/gsd-path-build/templates/wave-review.md +64 -0
- package/skills/gsd-path-decide/SKILL.md +115 -0
- package/skills/gsd-path-decide/agents/openai.yaml +6 -0
- package/skills/gsd-path-decide/references/decider.md +36 -0
- package/skills/gsd-path-decide/references/dispatch.md +131 -0
- package/skills/gsd-path-decide/scripts/_common.py +180 -0
- package/skills/gsd-path-decide/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-decide/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-decide/scripts/isolation.py +3538 -0
- package/skills/gsd-path-decide/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-decide/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-decide/scripts/state_promote.py +743 -0
- package/skills/gsd-path-decide/templates/synthesis.md +36 -0
- package/skills/gsd-path-define/SKILL.md +297 -0
- package/skills/gsd-path-define/agents/openai.yaml +6 -0
- package/skills/gsd-path-define/scripts/_common.py +180 -0
- package/skills/gsd-path-define/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-define/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-define/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-define/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-define/scripts/isolation.py +3538 -0
- package/skills/gsd-path-define/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-define/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-define/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-define/scripts/state_promote.py +743 -0
- package/skills/gsd-path-define/templates/charter.md +50 -0
- package/skills/gsd-path-define/templates/intent.md +98 -0
- package/skills/gsd-path-define/templates/state.md +27 -0
- package/skills/gsd-path-discuss/SKILL.md +162 -0
- package/skills/gsd-path-discuss/agents/openai.yaml +6 -0
- package/skills/gsd-path-discuss/scripts/_common.py +180 -0
- package/skills/gsd-path-discuss/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-discuss/templates/answers.md +29 -0
- package/skills/gsd-path-discuss/templates/dialogue.md +21 -0
- package/skills/gsd-path-docs-audit/SKILL.md +190 -0
- package/skills/gsd-path-docs-audit/agents/openai.yaml +6 -0
- package/skills/gsd-path-docs-audit/references/dispatch.md +131 -0
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +60 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +180 -0
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-docs-audit/scripts/isolation.py +3538 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +743 -0
- package/skills/gsd-path-docs-audit/templates/docs-audit.md +69 -0
- package/skills/gsd-path-forensics/SKILL.md +39 -0
- package/skills/gsd-path-forensics/agents/openai.yaml +6 -0
- package/skills/gsd-path-forensics/scripts/_common.py +180 -0
- package/skills/gsd-path-forensics/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +715 -0
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +1039 -0
- package/skills/gsd-path-inspect/SKILL.md +167 -0
- package/skills/gsd-path-inspect/agents/openai.yaml +6 -0
- package/skills/gsd-path-inspect/references/codebase-mapper.md +52 -0
- package/skills/gsd-path-inspect/references/dispatch.md +131 -0
- package/skills/gsd-path-inspect/references/docs-auditor.md +60 -0
- package/skills/gsd-path-inspect/scripts/_common.py +180 -0
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-inspect/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-inspect/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-inspect/scripts/isolation.py +3538 -0
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-inspect/scripts/state_promote.py +743 -0
- package/skills/gsd-path-inspect/templates/codebase.md +46 -0
- package/skills/gsd-path-inspect/templates/docs-audit.md +69 -0
- package/skills/gsd-path-inspect/templates/state.md +27 -0
- package/skills/gsd-path-loop/SKILL.md +98 -0
- package/skills/gsd-path-loop/agents/openai.yaml +6 -0
- package/skills/gsd-path-loop/examples/ci-repair.LOOP.md +61 -0
- package/skills/gsd-path-loop/scripts/_common.py +180 -0
- package/skills/gsd-path-loop/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-loop/scripts/loop_run.py +837 -0
- package/skills/gsd-path-loop/templates/loop.md +59 -0
- package/skills/gsd-path-migrate/SKILL.md +134 -0
- package/skills/gsd-path-migrate/agents/openai.yaml +6 -0
- package/skills/gsd-path-migrate/scripts/core_hook_gate.py +34 -0
- package/skills/gsd-path-migrate/scripts/core_hook_settings.py +119 -0
- package/skills/gsd-path-migrate/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-migrate/scripts/migrate_core.py +169 -0
- package/skills/gsd-path-plan/SKILL.md +411 -0
- package/skills/gsd-path-plan/agents/openai.yaml +6 -0
- package/skills/gsd-path-plan/references/dispatch.md +131 -0
- package/skills/gsd-path-plan/references/planner.md +104 -0
- package/skills/gsd-path-plan/scripts/_common.py +180 -0
- package/skills/gsd-path-plan/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-plan/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-plan/scripts/isolation.py +3538 -0
- package/skills/gsd-path-plan/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-plan/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-plan/scripts/review_panel.py +553 -0
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-plan/scripts/state_promote.py +743 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-plan/templates/patch-findings.md +16 -0
- package/skills/gsd-path-plan/templates/plan-panel.md +25 -0
- package/skills/gsd-path-plan/templates/plan.md +89 -0
- package/skills/gsd-path-plan/templates/task.md +74 -0
- package/skills/gsd-path-research/SKILL.md +159 -0
- package/skills/gsd-path-research/agents/openai.yaml +6 -0
- package/skills/gsd-path-research/references/dispatch.md +131 -0
- package/skills/gsd-path-research/references/researcher.md +42 -0
- package/skills/gsd-path-research/scripts/_common.py +180 -0
- package/skills/gsd-path-research/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-research/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-research/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-research/scripts/isolation.py +3538 -0
- package/skills/gsd-path-research/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-research/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-research/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-research/scripts/state_promote.py +743 -0
- package/skills/gsd-path-research/templates/evidence.md +24 -0
- package/skills/gsd-path-research/templates/research-handoff.md +25 -0
- package/skills/gsd-path-roadmap/SKILL.md +215 -0
- package/skills/gsd-path-roadmap/agents/openai.yaml +6 -0
- package/skills/gsd-path-roadmap/references/dispatch.md +131 -0
- package/skills/gsd-path-roadmap/references/roadmapper.md +48 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +180 -0
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-roadmap/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-roadmap/scripts/integration.py +1433 -0
- package/skills/gsd-path-roadmap/scripts/isolation.py +3538 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path-roadmap/scripts/review_panel.py +553 -0
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-roadmap/scripts/state_promote.py +743 -0
- package/skills/gsd-path-roadmap/templates/roadmap.md +38 -0
- package/skills/gsd-path-ship/SKILL.md +412 -0
- package/skills/gsd-path-ship/agents/openai.yaml +6 -0
- package/skills/gsd-path-ship/references/dispatch.md +131 -0
- package/skills/gsd-path-ship/references/reviewer.md +188 -0
- package/skills/gsd-path-ship/scripts/_common.py +180 -0
- package/skills/gsd-path-ship/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-ship/scripts/build_state.py +854 -0
- package/skills/gsd-path-ship/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-ship/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-ship/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-ship/scripts/integration.py +1433 -0
- package/skills/gsd-path-ship/scripts/isolation.py +3538 -0
- package/skills/gsd-path-ship/scripts/lean_verification.py +256 -0
- package/skills/gsd-path-ship/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-ship/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-ship/scripts/review_panel.py +553 -0
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-ship/scripts/state_promote.py +743 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-ship/templates/archive-manifest.md +37 -0
- package/skills/gsd-path-ship/templates/final-review.md +27 -0
- package/skills/gsd-path-ship/templates/gap-review.md +20 -0
- package/skills/gsd-path-ship/templates/patch-findings.md +16 -0
- package/skills/gsd-path-ship/templates/wave-review.md +64 -0
- package/skills/gsd-path-undo/SKILL.md +45 -0
- package/skills/gsd-path-undo/agents/openai.yaml +6 -0
- package/skills/gsd-path-undo/scripts/_common.py +180 -0
- package/skills/gsd-path-undo/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +1039 -0
- package/skills/path/BUILD.md +700 -0
- package/skills/path/DECIDE.md +115 -0
- package/skills/path/DEFINE.md +297 -0
- package/skills/path/DOCS-AUDIT.md +190 -0
- package/skills/path/INSPECT.md +167 -0
- package/skills/path/PLAN.md +411 -0
- package/skills/path/RESEARCH.md +159 -0
- package/skills/path/ROADMAP.md +215 -0
- package/skills/path/SHIP.md +412 -0
- package/skills/path/SKILL.md +471 -0
- package/skills/path/agents/openai.yaml +6 -0
- package/skills/path/references/codebase-mapper.md +52 -0
- package/skills/path/references/coder.md +90 -0
- package/skills/path/references/decider.md +36 -0
- package/skills/path/references/dispatch.md +131 -0
- package/skills/path/references/docs-auditor.md +60 -0
- package/skills/path/references/planner.md +104 -0
- package/skills/path/references/researcher.md +42 -0
- package/skills/path/references/reviewer.md +188 -0
- package/skills/path/references/roadmapper.md +48 -0
- package/skills/path/scripts/_common.py +180 -0
- package/skills/path/scripts/archive_milestone.py +2265 -0
- package/skills/path/scripts/bootstrap_repository.py +710 -0
- package/skills/path/scripts/build_state.py +854 -0
- package/skills/path/scripts/check_docs_audit.py +463 -0
- package/skills/path/scripts/check_handoffs.py +1855 -0
- package/skills/path/scripts/check_task_briefs.py +376 -0
- package/skills/path/scripts/check_update.py +93 -0
- package/skills/path/scripts/detect_project.py +1517 -0
- package/skills/path/scripts/discussion_records.py +619 -0
- package/skills/path/scripts/discussion_validate.py +1321 -0
- package/skills/path/scripts/dispatch_driver.py +1812 -0
- package/skills/path/scripts/integration.py +1433 -0
- package/skills/path/scripts/isolation.py +3538 -0
- package/skills/path/scripts/lean_verification.py +256 -0
- package/skills/path/scripts/loop_run.py +837 -0
- package/skills/path/scripts/pipeline_git.py +940 -0
- package/skills/path/scripts/pipeline_state.py +2291 -0
- package/skills/path/scripts/promote_lookahead.py +415 -0
- package/skills/path/scripts/review_findings.py +714 -0
- package/skills/path/scripts/review_panel.py +553 -0
- package/skills/path/scripts/state_checkpoint.py +1039 -0
- package/skills/path/scripts/state_promote.py +743 -0
- package/skills/path/scripts/token_budget.py +146 -0
- package/skills/path/scripts/workflow_run.py +117 -0
- package/skills/path/templates/answers.md +29 -0
- package/skills/path/templates/archive-manifest.md +37 -0
- package/skills/path/templates/charter.md +50 -0
- package/skills/path/templates/codebase.md +46 -0
- package/skills/path/templates/dialogue.md +21 -0
- package/skills/path/templates/docs-audit.md +69 -0
- package/skills/path/templates/evidence.md +24 -0
- package/skills/path/templates/final-review.md +27 -0
- package/skills/path/templates/gap-review.md +20 -0
- package/skills/path/templates/intent.md +98 -0
- package/skills/path/templates/loop.md +59 -0
- package/skills/path/templates/patch-findings.md +16 -0
- package/skills/path/templates/plan-panel.md +25 -0
- package/skills/path/templates/plan.md +89 -0
- package/skills/path/templates/repository.md +16 -0
- package/skills/path/templates/research-handoff.md +25 -0
- package/skills/path/templates/roadmap.md +38 -0
- package/skills/path/templates/skeptic.md +36 -0
- package/skills/path/templates/state.md +27 -0
- package/skills/path/templates/synthesis.md +36 -0
- package/skills/path/templates/task.md +74 -0
- package/skills/path/templates/wave-panel.md +25 -0
- package/skills/path/templates/wave-review.md +64 -0
|
@@ -0,0 +1,3008 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install GSD Path skills for supported coding agents."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import errno
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
|
|
16
|
+
|
|
17
|
+
sys.dont_write_bytecode = True
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from . import sync_skill_resources
|
|
21
|
+
except ImportError: # Direct execution from scripts/.
|
|
22
|
+
import sync_skill_resources # type: ignore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def skill_names_for_manifest(manifest: Mapping) -> Tuple[str, ...]:
|
|
26
|
+
return tuple(manifest["skills"])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def targets_for_manifest(manifest: Mapping) -> Tuple[str, ...]:
|
|
30
|
+
return tuple(manifest["hosts"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def local_roots_for_manifest(manifest: Mapping) -> Mapping[str, str]:
|
|
34
|
+
return {
|
|
35
|
+
target: config["local_root"]
|
|
36
|
+
for target, config in manifest["hosts"].items()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
TARGETS = targets_for_manifest(sync_skill_resources.RESOURCE_MANIFEST)
|
|
41
|
+
LOCAL_ROOTS = local_roots_for_manifest(sync_skill_resources.RESOURCE_MANIFEST)
|
|
42
|
+
SKILL_NAMES = skill_names_for_manifest(sync_skill_resources.RESOURCE_MANIFEST)
|
|
43
|
+
SKILL_ALIASES = dict(sync_skill_resources.RESOURCE_MANIFEST["skill_aliases"])
|
|
44
|
+
ROUTER_ALIASES = dict(sync_skill_resources.RESOURCE_MANIFEST["router_aliases"])
|
|
45
|
+
CLAUDE_BRIDGE = "@../AGENTS.md\n@../WORKFLOW.md\n"
|
|
46
|
+
HOOKS_DIRECTORY = ".gsd-path"
|
|
47
|
+
GUARD_SCRIPTS = ("guard_hook.py", "git_guard.py")
|
|
48
|
+
GUARD_MARKER = "gsd-path guard"
|
|
49
|
+
PROJECT_RUNTIME_SCRIPTS = (
|
|
50
|
+
"pipeline_state.py",
|
|
51
|
+
"check_handoffs.py",
|
|
52
|
+
"check_task_briefs.py",
|
|
53
|
+
"isolation.py",
|
|
54
|
+
"discussion_records.py",
|
|
55
|
+
"pipeline_git.py",
|
|
56
|
+
"promote_lookahead.py",
|
|
57
|
+
"detect_project.py",
|
|
58
|
+
"pipeline_diagnose.py",
|
|
59
|
+
"pipeline_undo.py",
|
|
60
|
+
"archive_milestone.py",
|
|
61
|
+
"review_panel.py",
|
|
62
|
+
"_common.py",
|
|
63
|
+
"state_checkpoint.py",
|
|
64
|
+
"state_promote.py",
|
|
65
|
+
"discussion_validate.py",
|
|
66
|
+
"integration.py",
|
|
67
|
+
)
|
|
68
|
+
PROJECT_RUNTIME_MARKER = "gsd-path project runtime"
|
|
69
|
+
PROJECT_STATUS_LAUNCHER = "status_runtime.py"
|
|
70
|
+
PROJECT_STATUS_MARKER = "gsd-path project status launcher"
|
|
71
|
+
INSTALL_LOCK_NAME = ".gsd-path-install-lock"
|
|
72
|
+
INSTALL_LOCK_OWNER = "owner.json"
|
|
73
|
+
INSTALL_LOCK_SCHEMA = "gsd-path/install-lock/v2"
|
|
74
|
+
PROJECT_CONTRACTS = (
|
|
75
|
+
("AGENTS.md", "## Plain-prompt re-entry"),
|
|
76
|
+
("WORKFLOW.md", "### Plain-prompt re-entry"),
|
|
77
|
+
)
|
|
78
|
+
STATUS_ACTIONS = frozenset(
|
|
79
|
+
{
|
|
80
|
+
"bind-initial",
|
|
81
|
+
"block",
|
|
82
|
+
"resume-checkpoint",
|
|
83
|
+
"resume-next-handoff",
|
|
84
|
+
"resume-promotion",
|
|
85
|
+
"resume-shipment",
|
|
86
|
+
"resume-undo",
|
|
87
|
+
"run-phase",
|
|
88
|
+
"validate-integrated",
|
|
89
|
+
"wait",
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
STATUS_PHASES = frozenset(
|
|
93
|
+
{
|
|
94
|
+
"inspect",
|
|
95
|
+
"define",
|
|
96
|
+
"research",
|
|
97
|
+
"decide",
|
|
98
|
+
"roadmap",
|
|
99
|
+
"plan",
|
|
100
|
+
"build",
|
|
101
|
+
"ship",
|
|
102
|
+
"shipped",
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
STATUS_VALUES = frozenset({"active", "done", "blocked"})
|
|
106
|
+
STATUS_STATE_FIELDS = frozenset(
|
|
107
|
+
{
|
|
108
|
+
"pipeline",
|
|
109
|
+
"project",
|
|
110
|
+
"milestone",
|
|
111
|
+
"phase",
|
|
112
|
+
"status",
|
|
113
|
+
"branch",
|
|
114
|
+
"archive",
|
|
115
|
+
"integration_default",
|
|
116
|
+
"integration",
|
|
117
|
+
"integration_source",
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
STATUS_INTEGRATION_MODES = frozenset({"direct", "pull-request"})
|
|
121
|
+
STATUS_INTEGRATION_SOURCES = frozenset({"default", "milestone"})
|
|
122
|
+
STATUS_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
123
|
+
STATUS_BRANCH = re.compile(r"^gsd-path/M(\d{3,})$")
|
|
124
|
+
STATUS_ARCHIVE = re.compile(r"^\.project/archive/(\d{3,})-([a-z0-9][a-z0-9-]*)/?$")
|
|
125
|
+
STATUS_TRANSITIONS = {
|
|
126
|
+
"inspect": frozenset({"inspect", "define"}),
|
|
127
|
+
"define": frozenset({"define", "research", "plan"}),
|
|
128
|
+
"research": frozenset({"research", "decide"}),
|
|
129
|
+
"decide": frozenset({"decide", "roadmap", "plan"}),
|
|
130
|
+
"roadmap": frozenset({"roadmap", "define"}),
|
|
131
|
+
"plan": frozenset({"plan", "build"}),
|
|
132
|
+
"build": frozenset({"build"}),
|
|
133
|
+
"ship": frozenset({"ship", "plan"}),
|
|
134
|
+
"shipped": frozenset({"ship"}),
|
|
135
|
+
}
|
|
136
|
+
CLAUDE_MATCHER = ".*"
|
|
137
|
+
def _claude_guard_entry(interpreter: str) -> dict:
|
|
138
|
+
"""The managed PreToolUse guard entry, as an object."""
|
|
139
|
+
return {
|
|
140
|
+
"matcher": CLAUDE_MATCHER,
|
|
141
|
+
"hooks": [
|
|
142
|
+
{
|
|
143
|
+
"type": "command",
|
|
144
|
+
"command": (
|
|
145
|
+
f"{interpreter} \"$CLAUDE_PROJECT_DIR/"
|
|
146
|
+
f"{HOOKS_DIRECTORY}/guard_hook.py\""
|
|
147
|
+
),
|
|
148
|
+
}
|
|
149
|
+
],
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def claude_hooks_settings(interpreter: str) -> str:
|
|
154
|
+
return (
|
|
155
|
+
json.dumps(
|
|
156
|
+
{"hooks": {"PreToolUse": [_claude_guard_entry(interpreter)]}},
|
|
157
|
+
indent=2,
|
|
158
|
+
)
|
|
159
|
+
+ "\n"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _codex_guard_command(interpreter: str) -> str:
|
|
164
|
+
return (
|
|
165
|
+
f'{interpreter} "$(git rev-parse --show-toplevel)/'
|
|
166
|
+
f'{HOOKS_DIRECTORY}/guard_hook.py"'
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _codex_guard_command_windows(interpreter: str) -> str:
|
|
171
|
+
return (
|
|
172
|
+
'powershell.exe -NoProfile -NonInteractive -Command '
|
|
173
|
+
'"$root = git rev-parse --show-toplevel; '
|
|
174
|
+
f"& {interpreter} (Join-Path $root '{HOOKS_DIRECTORY}\\guard_hook.py')\""
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def codex_hooks_settings(interpreter: str) -> str:
|
|
179
|
+
return json.dumps(
|
|
180
|
+
{"hooks": {"PreToolUse": [_codex_guard_entry(interpreter)]}},
|
|
181
|
+
indent=2,
|
|
182
|
+
) + "\n"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _codex_guard_entry(interpreter: str) -> dict:
|
|
186
|
+
return {
|
|
187
|
+
"matcher": ".*",
|
|
188
|
+
"hooks": [
|
|
189
|
+
{
|
|
190
|
+
"type": "command",
|
|
191
|
+
"command": _codex_guard_command(interpreter),
|
|
192
|
+
"commandWindows": _codex_guard_command_windows(interpreter),
|
|
193
|
+
}
|
|
194
|
+
],
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cursor_hooks_settings(interpreter: str) -> str:
|
|
199
|
+
return json.dumps(
|
|
200
|
+
{
|
|
201
|
+
"version": 1,
|
|
202
|
+
"hooks": {"preToolUse": [_cursor_guard_entry(interpreter)]},
|
|
203
|
+
},
|
|
204
|
+
indent=2,
|
|
205
|
+
) + "\n"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _cursor_guard_entry(interpreter: str) -> dict:
|
|
209
|
+
return {
|
|
210
|
+
"command": f'{interpreter} "{HOOKS_DIRECTORY}/guard_hook.py"',
|
|
211
|
+
"matcher": ".*",
|
|
212
|
+
"failClosed": True,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def pre_commit_hook(interpreter: str) -> str:
|
|
217
|
+
return (
|
|
218
|
+
"#!/bin/sh\n"
|
|
219
|
+
"# gsd-path guard: archive immutability before commit.\n"
|
|
220
|
+
f"exec {interpreter} \"$(git rev-parse --show-toplevel)/"
|
|
221
|
+
f"{HOOKS_DIRECTORY}/git_guard.py\" pre-commit\n"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def commit_msg_hook(interpreter: str) -> str:
|
|
226
|
+
return (
|
|
227
|
+
"#!/bin/sh\n"
|
|
228
|
+
"# gsd-path guard: archive immutability and ship-commit purity.\n"
|
|
229
|
+
f"exec {interpreter} \"$(git rev-parse --show-toplevel)/"
|
|
230
|
+
f"{HOOKS_DIRECTORY}/git_guard.py\" commit-msg \"$1\"\n"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def pre_push_hook(interpreter: str) -> str:
|
|
235
|
+
return (
|
|
236
|
+
"#!/bin/sh\n"
|
|
237
|
+
"# gsd-path guard: bound branches publish only as ship commits.\n"
|
|
238
|
+
f"exec {interpreter} \"$(git rev-parse --show-toplevel)/"
|
|
239
|
+
f"{HOOKS_DIRECTORY}/git_guard.py\" pre-push \"$@\"\n"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
GIT_HOOKS = (
|
|
244
|
+
("pre-commit", pre_commit_hook),
|
|
245
|
+
("commit-msg", commit_msg_hook),
|
|
246
|
+
("pre-push", pre_push_hook),
|
|
247
|
+
)
|
|
248
|
+
GIT_HOOK_NAMES = tuple(name for name, _ in GIT_HOOKS)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _detect_python_interpreter() -> Optional[str]:
|
|
252
|
+
"""Probe for a runnable interpreter (python3, then python).
|
|
253
|
+
|
|
254
|
+
Emitted hooks must never hard-code an interpreter that does not exist on
|
|
255
|
+
this machine (python3 is typically absent on Windows).
|
|
256
|
+
"""
|
|
257
|
+
for candidate in ("python3", "python"):
|
|
258
|
+
try:
|
|
259
|
+
result = subprocess.run(
|
|
260
|
+
[
|
|
261
|
+
candidate,
|
|
262
|
+
"-B",
|
|
263
|
+
"-c",
|
|
264
|
+
"import sys; raise SystemExit(sys.version_info < (3, 9))",
|
|
265
|
+
],
|
|
266
|
+
capture_output=True,
|
|
267
|
+
check=False,
|
|
268
|
+
)
|
|
269
|
+
except OSError:
|
|
270
|
+
continue
|
|
271
|
+
if result.returncode == 0:
|
|
272
|
+
return candidate
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _effective_interpreter() -> Optional[str]:
|
|
277
|
+
"""Single owner of the interpreter probe and its policy.
|
|
278
|
+
|
|
279
|
+
Hook installation fails before writing when no interpreter works.
|
|
280
|
+
"""
|
|
281
|
+
return _detect_python_interpreter()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _resolve_git_hooks_path(project: "Path") -> Optional["Path"]:
|
|
285
|
+
"""Effective hooks dir via `git rev-parse --git-path hooks`.
|
|
286
|
+
|
|
287
|
+
Honors core.hooksPath and linked worktrees. Returns None when git cannot
|
|
288
|
+
resolve it for this project.
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
result = subprocess.run(
|
|
292
|
+
["git", "rev-parse", "--show-toplevel", "--git-path", "hooks"],
|
|
293
|
+
cwd=os.fspath(project),
|
|
294
|
+
capture_output=True,
|
|
295
|
+
text=True,
|
|
296
|
+
check=False,
|
|
297
|
+
)
|
|
298
|
+
except OSError:
|
|
299
|
+
return None
|
|
300
|
+
if result.returncode != 0:
|
|
301
|
+
return None
|
|
302
|
+
lines = [line for line in result.stdout.splitlines() if line]
|
|
303
|
+
if len(lines) != 2:
|
|
304
|
+
return None
|
|
305
|
+
toplevel, hooks_path = lines
|
|
306
|
+
if not _same_path(Path(toplevel), project):
|
|
307
|
+
return None
|
|
308
|
+
return Path(os.path.abspath(os.path.join(os.fspath(project), hooks_path)))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _git_hooks_directory(project: "Path") -> Optional["Path"]:
|
|
312
|
+
return _git_hooks_location(project)[0]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _git_hooks_location(project: "Path") -> Tuple[Optional["Path"], bool]:
|
|
316
|
+
dot_git = project / ".git"
|
|
317
|
+
if not _lexists(dot_git):
|
|
318
|
+
return None, False
|
|
319
|
+
resolved = _resolve_git_hooks_path(project)
|
|
320
|
+
if resolved is not None:
|
|
321
|
+
return resolved, True
|
|
322
|
+
# Fallback when git is not runnable: only a plain .git directory is safe.
|
|
323
|
+
return (dot_git / "hooks" if dot_git.is_dir() else None), False
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _required_python_runtime(
|
|
327
|
+
command: str, selected: Sequence[str] = ()
|
|
328
|
+
) -> str:
|
|
329
|
+
suffix = f" for selected hosts: {', '.join(selected)}" if selected else ""
|
|
330
|
+
interpreter = _effective_interpreter()
|
|
331
|
+
if interpreter is None:
|
|
332
|
+
raise InstallerError(
|
|
333
|
+
f"{command} requires a working Python interpreter{suffix}"
|
|
334
|
+
)
|
|
335
|
+
return interpreter
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _required_hook_runtime(
|
|
339
|
+
project: Path, command: str, selected: Sequence[str] = ()
|
|
340
|
+
) -> Tuple[str, Path]:
|
|
341
|
+
suffix = f" for selected hosts: {', '.join(selected)}" if selected else ""
|
|
342
|
+
interpreter = _required_python_runtime(command, selected)
|
|
343
|
+
hooks_dir, resolved = _git_hooks_location(project)
|
|
344
|
+
if not resolved or hooks_dir is None:
|
|
345
|
+
raise InstallerError(
|
|
346
|
+
f"{command} requires an initialized Git repository with a "
|
|
347
|
+
f"resolvable hooks directory{suffix}"
|
|
348
|
+
)
|
|
349
|
+
return interpreter, hooks_dir
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
OPENCODE_NOTE = (
|
|
353
|
+
"note: OpenCode stable discovers the skills but has no documented hard "
|
|
354
|
+
'explicit-only switch; OpenCode v2 honors opencode/autoinvoke="false" '
|
|
355
|
+
"and /gsd-path."
|
|
356
|
+
)
|
|
357
|
+
ANTIGRAVITY_NOTE = (
|
|
358
|
+
"note: Antigravity discovers /gsd-path but has no documented hard "
|
|
359
|
+
"explicit-only skill switch; invoke the skill explicitly."
|
|
360
|
+
)
|
|
361
|
+
KIRO_NOTE = (
|
|
362
|
+
"note: Kiro discovers /gsd-path but has no documented hard explicit-only "
|
|
363
|
+
"skill switch; invoke the skill explicitly."
|
|
364
|
+
)
|
|
365
|
+
HOST_NOTES = {
|
|
366
|
+
"opencode": OPENCODE_NOTE,
|
|
367
|
+
"antigravity": ANTIGRAVITY_NOTE,
|
|
368
|
+
"kiro": KIRO_NOTE,
|
|
369
|
+
}
|
|
370
|
+
EXPLICIT_ONLY_TARGETS = frozenset(
|
|
371
|
+
{"claude", "grok", "copilot", "qwen", "cursor", "zed", "kimi", "shared-agents"}
|
|
372
|
+
)
|
|
373
|
+
SHARED_AGENT_TARGETS = frozenset({"codex", "antigravity", "zed"})
|
|
374
|
+
SHARED_AGENT_PROFILE = "shared-agents"
|
|
375
|
+
CURSOR_AGENT_FILENAME = "gsd-path.md"
|
|
376
|
+
CURSOR_AGENT_BACKUP_NAME = "cursor-agent-gsd-path.md"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _shared_invocations(text: str) -> str:
|
|
380
|
+
hosts = sync_skill_resources.RESOURCE_MANIFEST["hosts"]
|
|
381
|
+
|
|
382
|
+
def replace(match: re.Match) -> str:
|
|
383
|
+
quote = match.group("quote") or ""
|
|
384
|
+
skill = match.group("skill")[1:]
|
|
385
|
+
arguments = match.group("arguments") or ""
|
|
386
|
+
paired_quote = match.group("paired_quote") or ""
|
|
387
|
+
paired_skill = match.group("paired_skill")
|
|
388
|
+
paired_arguments = match.group("paired_arguments") or ""
|
|
389
|
+
if paired_skill is not None and (
|
|
390
|
+
paired_skill != skill
|
|
391
|
+
or paired_arguments != arguments
|
|
392
|
+
or paired_quote != quote
|
|
393
|
+
):
|
|
394
|
+
raise InstallerError("shared invocation pair is inconsistent")
|
|
395
|
+
codex = f"{hosts['codex']['invocation_prefix']}{skill}"
|
|
396
|
+
others = f"{hosts['antigravity']['invocation_prefix']}{skill}"
|
|
397
|
+
return (
|
|
398
|
+
f"{quote}{codex}{arguments}{quote} (Codex) or "
|
|
399
|
+
f"{quote}{others}{arguments}{quote} (Antigravity/Zed)"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
return re.sub(
|
|
403
|
+
r"(?P<quote>`?)(?P<skill>\$(?:gsd-path(?:-[a-z0-9]+)*|path))"
|
|
404
|
+
r"(?P<arguments> status)?(?P=quote)"
|
|
405
|
+
r"(?: \(Codex\) (?:and|or) (?P<paired_quote>`?)/"
|
|
406
|
+
r"(?P<paired_skill>gsd-path(?:-[a-z0-9]+)*|path)"
|
|
407
|
+
r"(?P<paired_arguments> status)?(?P=paired_quote)"
|
|
408
|
+
r" \((?:other hosts|Antigravity/Zed)\))?",
|
|
409
|
+
replace,
|
|
410
|
+
text,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class InstallerError(RuntimeError):
|
|
415
|
+
"""A safe, user-facing installation failure."""
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@dataclass(frozen=True)
|
|
419
|
+
class TargetPlan:
|
|
420
|
+
name: str
|
|
421
|
+
root: Path
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@dataclass(frozen=True)
|
|
425
|
+
class DeploymentPlan:
|
|
426
|
+
profile: str
|
|
427
|
+
root: Path
|
|
428
|
+
targets: Tuple[str, ...]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@dataclass
|
|
432
|
+
class TargetTransaction:
|
|
433
|
+
root: Path
|
|
434
|
+
created_directories: List[Path] = field(default_factory=list)
|
|
435
|
+
backup: Optional[Path] = None
|
|
436
|
+
moved: List[Tuple[Path, Path]] = field(default_factory=list)
|
|
437
|
+
installed: List[Path] = field(default_factory=list)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@dataclass
|
|
441
|
+
class ProjectTransaction:
|
|
442
|
+
created_directories: List[Path] = field(default_factory=list)
|
|
443
|
+
copied: List[Path] = field(default_factory=list)
|
|
444
|
+
replaced: List[Tuple[Path, bytes, int]] = field(default_factory=list)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def absolute_path(path: Path) -> Path:
|
|
448
|
+
return Path(os.path.abspath(os.fspath(path.expanduser())))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def local_root(target: str, project_dir: Path) -> Path:
|
|
452
|
+
try:
|
|
453
|
+
relative = LOCAL_ROOTS[target]
|
|
454
|
+
except KeyError as error:
|
|
455
|
+
raise ValueError(f"unsupported target: {target}") from error
|
|
456
|
+
return absolute_path(project_dir) / relative
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def default_root(target: str, environ: Optional[Mapping[str, str]] = None) -> Path:
|
|
460
|
+
env = os.environ if environ is None else environ
|
|
461
|
+
if target == "codex":
|
|
462
|
+
return absolute_path(Path("~/.agents/skills"))
|
|
463
|
+
if target == "claude":
|
|
464
|
+
return absolute_path(Path(env.get("CLAUDE_CONFIG_DIR") or "~/.claude") / "skills")
|
|
465
|
+
if target == "grok":
|
|
466
|
+
return absolute_path(Path(env.get("GROK_HOME") or "~/.grok") / "skills")
|
|
467
|
+
if target == "opencode":
|
|
468
|
+
config_dir = env.get("OPENCODE_CONFIG_DIR")
|
|
469
|
+
if config_dir:
|
|
470
|
+
base = Path(config_dir)
|
|
471
|
+
else:
|
|
472
|
+
config_file = env.get("OPENCODE_CONFIG")
|
|
473
|
+
if config_file:
|
|
474
|
+
base = absolute_path(Path(config_file)).parent
|
|
475
|
+
else:
|
|
476
|
+
base = Path(env.get("XDG_CONFIG_HOME") or "~/.config") / "opencode"
|
|
477
|
+
return absolute_path(base / "skills")
|
|
478
|
+
if target == "copilot":
|
|
479
|
+
return absolute_path(Path(env.get("COPILOT_HOME") or "~/.copilot") / "skills")
|
|
480
|
+
if target == "qwen":
|
|
481
|
+
return absolute_path(Path(env.get("QWEN_HOME") or "~/.qwen") / "skills")
|
|
482
|
+
if target == "antigravity":
|
|
483
|
+
return absolute_path(Path("~/.gemini/antigravity-cli/skills"))
|
|
484
|
+
if target == "cursor":
|
|
485
|
+
return absolute_path(Path("~/.cursor/skills"))
|
|
486
|
+
if target == "zed":
|
|
487
|
+
return absolute_path(Path("~/.agents/skills"))
|
|
488
|
+
if target == "kiro":
|
|
489
|
+
return absolute_path(Path(env.get("KIRO_HOME") or "~/.kiro") / "skills")
|
|
490
|
+
if target == "kimi":
|
|
491
|
+
return absolute_path(
|
|
492
|
+
Path(env.get("KIMI_CODE_HOME") or "~/.kimi-code") / "skills"
|
|
493
|
+
)
|
|
494
|
+
raise ValueError(f"unsupported target: {target}")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def legacy_codex_root(environ: Optional[Mapping[str, str]] = None) -> Path:
|
|
498
|
+
env = os.environ if environ is None else environ
|
|
499
|
+
return absolute_path(Path(env.get("CODEX_HOME") or "~/.codex") / "skills")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _is_managed_name(name: str) -> bool:
|
|
503
|
+
normalized = name.casefold()
|
|
504
|
+
return (
|
|
505
|
+
normalized in ("ogsd", "gsd-path")
|
|
506
|
+
or normalized in ROUTER_ALIASES
|
|
507
|
+
or normalized.startswith(("ogsd-", "gsd-path-"))
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _is_owned_router_alias(skill_dir: Path) -> bool:
|
|
512
|
+
runtime = skill_dir / "scripts" / "pipeline_state.py"
|
|
513
|
+
if not runtime.is_file():
|
|
514
|
+
return False
|
|
515
|
+
version_file = skill_dir / "VERSION"
|
|
516
|
+
if not version_file.is_file(): # installs before alias version stamping
|
|
517
|
+
return _is_managed_project_runtime(runtime)
|
|
518
|
+
try:
|
|
519
|
+
version = version_file.read_text(encoding="utf-8").strip()
|
|
520
|
+
except (OSError, UnicodeError):
|
|
521
|
+
return False
|
|
522
|
+
return re.fullmatch(r"[0-9]+(?:\.[0-9]+)+", version) is not None
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _is_managed_install_entry(root: Path, name: str) -> bool:
|
|
526
|
+
if not _is_managed_name(name):
|
|
527
|
+
return False
|
|
528
|
+
if name.casefold() not in ROUTER_ALIASES:
|
|
529
|
+
return True
|
|
530
|
+
return _is_owned_router_alias(root / name)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _reject_router_alias_collisions(root: Path) -> None:
|
|
534
|
+
if not root.is_dir():
|
|
535
|
+
return
|
|
536
|
+
for alias in ROUTER_ALIASES:
|
|
537
|
+
candidate = root / alias
|
|
538
|
+
if not _lexists(candidate):
|
|
539
|
+
continue
|
|
540
|
+
if _is_owned_router_alias(candidate):
|
|
541
|
+
continue
|
|
542
|
+
raise InstallerError(
|
|
543
|
+
f"refusing to replace unrelated skill {candidate}; "
|
|
544
|
+
"rename it or move it aside before installing GSD Path"
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _stamp_installed_versions(staged_root: Path, version: Optional[str]) -> None:
|
|
549
|
+
if not version:
|
|
550
|
+
return
|
|
551
|
+
for name in {"gsd-path", *ROUTER_ALIASES}:
|
|
552
|
+
(staged_root / name / "VERSION").write_text(f"{version}\n", encoding="utf-8")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _lexists(path: Path) -> bool:
|
|
556
|
+
return os.path.lexists(os.fspath(path))
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _validate_directory_destination(path: Path, label: str) -> None:
|
|
560
|
+
if _lexists(path):
|
|
561
|
+
if path.is_symlink():
|
|
562
|
+
raise InstallerError(f"{label} is a symlink: {path}")
|
|
563
|
+
if not path.is_dir():
|
|
564
|
+
raise InstallerError(f"{label} is not a directory: {path}")
|
|
565
|
+
return
|
|
566
|
+
parent = path.parent
|
|
567
|
+
while not _lexists(parent):
|
|
568
|
+
parent = parent.parent
|
|
569
|
+
if parent.is_symlink() or not parent.is_dir():
|
|
570
|
+
raise InstallerError(f"{label} has an unsafe parent: {parent}")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _reject_source_symlinks(path: Path) -> None:
|
|
574
|
+
for directory, names, files in os.walk(path, followlinks=False):
|
|
575
|
+
base = Path(directory)
|
|
576
|
+
for name in names + files:
|
|
577
|
+
candidate = base / name
|
|
578
|
+
if candidate.is_symlink():
|
|
579
|
+
raise InstallerError(f"source contains a symlink: {candidate}")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def validate_source(source_root: Path, profiles: Sequence[str]) -> Tuple[str, ...]:
|
|
583
|
+
problems = sync_skill_resources.mismatches(source_root)
|
|
584
|
+
if problems:
|
|
585
|
+
raise InstallerError("source resources are stale: " + "; ".join(problems))
|
|
586
|
+
|
|
587
|
+
skills_root = source_root / "skills"
|
|
588
|
+
if not skills_root.is_dir() or skills_root.is_symlink():
|
|
589
|
+
raise InstallerError(f"missing safe skills directory: {skills_root}")
|
|
590
|
+
found = {
|
|
591
|
+
entry.name
|
|
592
|
+
for entry in skills_root.iterdir()
|
|
593
|
+
if _is_managed_name(entry.name)
|
|
594
|
+
and (entry.name.startswith("gsd-path") or entry.name in ROUTER_ALIASES)
|
|
595
|
+
}
|
|
596
|
+
if found != set(SKILL_NAMES):
|
|
597
|
+
raise InstallerError(
|
|
598
|
+
f"expected exactly {len(SKILL_NAMES)} GSD Path skills; found "
|
|
599
|
+
+ ", ".join(sorted(found))
|
|
600
|
+
)
|
|
601
|
+
for name in SKILL_NAMES:
|
|
602
|
+
skill = skills_root / name
|
|
603
|
+
if skill.is_symlink() or not skill.is_dir() or not (skill / "SKILL.md").is_file():
|
|
604
|
+
raise InstallerError(f"invalid skill directory: {skill}")
|
|
605
|
+
_reject_source_symlinks(skill)
|
|
606
|
+
|
|
607
|
+
for profile in profiles:
|
|
608
|
+
adapter = source_root / "platforms" / profile / "dispatch.md"
|
|
609
|
+
if adapter.is_symlink() or not adapter.is_file():
|
|
610
|
+
raise InstallerError(f"missing dispatch adapter: {adapter}")
|
|
611
|
+
if profile == "cursor":
|
|
612
|
+
agent = source_root / "platforms" / "cursor" / "agent.md"
|
|
613
|
+
if agent.is_symlink() or not agent.is_file():
|
|
614
|
+
raise InstallerError(f"missing Cursor subagent: {agent}")
|
|
615
|
+
return SKILL_NAMES
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _augment_frontmatter(text: str, target: str) -> str:
|
|
619
|
+
lines = text.splitlines()
|
|
620
|
+
if not lines or lines[0] != "---":
|
|
621
|
+
raise InstallerError("SKILL.md is missing YAML frontmatter")
|
|
622
|
+
try:
|
|
623
|
+
end = lines.index("---", 1)
|
|
624
|
+
except ValueError as error:
|
|
625
|
+
raise InstallerError("SKILL.md has unterminated YAML frontmatter") from error
|
|
626
|
+
header = lines[1:end]
|
|
627
|
+
|
|
628
|
+
if target in EXPLICIT_ONLY_TARGETS:
|
|
629
|
+
key = "disable-model-invocation:"
|
|
630
|
+
replacement = "disable-model-invocation: true"
|
|
631
|
+
for index, line in enumerate(header):
|
|
632
|
+
if line.startswith(key):
|
|
633
|
+
header[index] = replacement
|
|
634
|
+
break
|
|
635
|
+
else:
|
|
636
|
+
header.append(replacement)
|
|
637
|
+
if target in {"opencode", SHARED_AGENT_PROFILE}:
|
|
638
|
+
try:
|
|
639
|
+
metadata = header.index("metadata:")
|
|
640
|
+
except ValueError:
|
|
641
|
+
header.extend(
|
|
642
|
+
[
|
|
643
|
+
"metadata:",
|
|
644
|
+
' opencode/autoinvoke: "false"',
|
|
645
|
+
' opencode/slash: "true"',
|
|
646
|
+
]
|
|
647
|
+
)
|
|
648
|
+
else:
|
|
649
|
+
section_end = metadata + 1
|
|
650
|
+
while section_end < len(header) and (
|
|
651
|
+
not header[section_end] or header[section_end][0].isspace()
|
|
652
|
+
):
|
|
653
|
+
section_end += 1
|
|
654
|
+
values = {
|
|
655
|
+
"opencode/autoinvoke:": ' opencode/autoinvoke: "false"',
|
|
656
|
+
"opencode/slash:": ' opencode/slash: "true"',
|
|
657
|
+
}
|
|
658
|
+
for key, replacement in values.items():
|
|
659
|
+
for index in range(metadata + 1, section_end):
|
|
660
|
+
if header[index].strip().startswith(key):
|
|
661
|
+
header[index] = replacement
|
|
662
|
+
break
|
|
663
|
+
else:
|
|
664
|
+
header.insert(section_end, replacement)
|
|
665
|
+
section_end += 1
|
|
666
|
+
|
|
667
|
+
transformed = ["---", *header, "---", *lines[end + 1 :]]
|
|
668
|
+
return "\n".join(transformed) + ("\n" if text.endswith(("\n", "\r")) else "")
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def stage_target(source_root: Path, target: str, staged_root: Path) -> None:
|
|
672
|
+
skills_root = source_root / "skills"
|
|
673
|
+
for name in SKILL_NAMES:
|
|
674
|
+
shutil.copytree(skills_root / name, staged_root / name)
|
|
675
|
+
|
|
676
|
+
manifest = source_root / "package.json"
|
|
677
|
+
if manifest.is_file():
|
|
678
|
+
try:
|
|
679
|
+
version = json.loads(manifest.read_text(encoding="utf-8")).get("version")
|
|
680
|
+
except (json.JSONDecodeError, AttributeError):
|
|
681
|
+
version = None
|
|
682
|
+
_stamp_installed_versions(staged_root, version)
|
|
683
|
+
|
|
684
|
+
adapter = (source_root / "platforms" / target / "dispatch.md").read_text(
|
|
685
|
+
encoding="utf-8"
|
|
686
|
+
)
|
|
687
|
+
for dispatch in sorted(staged_root.glob("*/references/dispatch.md")):
|
|
688
|
+
dispatch.write_text(adapter, encoding="utf-8")
|
|
689
|
+
if target == "cursor":
|
|
690
|
+
shutil.copy2(
|
|
691
|
+
source_root / "platforms" / "cursor" / "agent.md",
|
|
692
|
+
staged_root / CURSOR_AGENT_FILENAME,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
if target != "codex":
|
|
696
|
+
if target != SHARED_AGENT_PROFILE:
|
|
697
|
+
for name in SKILL_NAMES:
|
|
698
|
+
metadata = staged_root / name / "agents"
|
|
699
|
+
if metadata.is_dir():
|
|
700
|
+
shutil.rmtree(metadata)
|
|
701
|
+
invocation = "gsd-path" if target == "opencode" else "/gsd-path"
|
|
702
|
+
path_invocation = "path" if target == "opencode" else "/path"
|
|
703
|
+
for name in SKILL_NAMES:
|
|
704
|
+
entrypoint = staged_root / name / "SKILL.md"
|
|
705
|
+
entrypoint.write_text(
|
|
706
|
+
_augment_frontmatter(entrypoint.read_text(encoding="utf-8"), target),
|
|
707
|
+
encoding="utf-8",
|
|
708
|
+
)
|
|
709
|
+
for markdown in sorted(staged_root.rglob("*.md")):
|
|
710
|
+
content = markdown.read_text(encoding="utf-8")
|
|
711
|
+
transformed = (
|
|
712
|
+
_shared_invocations(content)
|
|
713
|
+
if target == SHARED_AGENT_PROFILE
|
|
714
|
+
else content.replace("$gsd-path", invocation).replace("$path", path_invocation)
|
|
715
|
+
)
|
|
716
|
+
markdown.write_text(transformed, encoding="utf-8")
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _missing_directories(path: Path) -> List[Path]:
|
|
720
|
+
missing = []
|
|
721
|
+
cursor = path
|
|
722
|
+
while not _lexists(cursor):
|
|
723
|
+
missing.append(cursor)
|
|
724
|
+
cursor = cursor.parent
|
|
725
|
+
return list(reversed(missing))
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _create_directory(path: Path, created: List[Path]) -> None:
|
|
729
|
+
missing = _missing_directories(path)
|
|
730
|
+
created.extend(missing)
|
|
731
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _release_install_locks(locks: Sequence[Path], created: Sequence[Path]) -> None:
|
|
735
|
+
for lock in reversed(locks):
|
|
736
|
+
(lock / INSTALL_LOCK_OWNER).unlink(missing_ok=True)
|
|
737
|
+
lock.rmdir()
|
|
738
|
+
_remove_empty_directories(created)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _process_identity(pid: int) -> Optional[str]:
|
|
742
|
+
if os.name == "nt":
|
|
743
|
+
command = [
|
|
744
|
+
"powershell.exe",
|
|
745
|
+
"-NoProfile",
|
|
746
|
+
"-NonInteractive",
|
|
747
|
+
"-Command",
|
|
748
|
+
f"(Get-Process -Id {pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks",
|
|
749
|
+
]
|
|
750
|
+
else:
|
|
751
|
+
command = ["ps", "-o", "lstart=", "-p", str(pid)]
|
|
752
|
+
try:
|
|
753
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
754
|
+
except OSError:
|
|
755
|
+
return None
|
|
756
|
+
value = result.stdout.strip()
|
|
757
|
+
return f"{os.name}:{value}" if result.returncode == 0 and value else None
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _process_alive(pid: int) -> bool:
|
|
761
|
+
try:
|
|
762
|
+
os.kill(pid, 0)
|
|
763
|
+
except OSError as error:
|
|
764
|
+
return error.errno == errno.EPERM
|
|
765
|
+
return True
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _stale_install_lock_snapshot(lock: Path) -> Tuple[os.stat_result, bytes]:
|
|
769
|
+
if lock.is_symlink() or not lock.is_dir():
|
|
770
|
+
raise InstallerError(f"unsafe installation lock: {lock}")
|
|
771
|
+
try:
|
|
772
|
+
observed = lock.stat()
|
|
773
|
+
entries = list(lock.iterdir())
|
|
774
|
+
owner_bytes = (lock / INSTALL_LOCK_OWNER).read_bytes()
|
|
775
|
+
owner = json.loads(owner_bytes)
|
|
776
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
777
|
+
raise InstallerError(
|
|
778
|
+
f"installation already in progress for {lock.parent}"
|
|
779
|
+
) from error
|
|
780
|
+
pid = owner.get("pid")
|
|
781
|
+
identity = owner.get("identity")
|
|
782
|
+
valid = (
|
|
783
|
+
owner.get("schema") == INSTALL_LOCK_SCHEMA
|
|
784
|
+
and isinstance(pid, int)
|
|
785
|
+
and not isinstance(pid, bool)
|
|
786
|
+
and pid > 0
|
|
787
|
+
and isinstance(identity, str)
|
|
788
|
+
and identity
|
|
789
|
+
and len(entries) == 1
|
|
790
|
+
and entries[0].name == INSTALL_LOCK_OWNER
|
|
791
|
+
)
|
|
792
|
+
current_identity = _process_identity(pid) if valid else None
|
|
793
|
+
if (
|
|
794
|
+
not valid
|
|
795
|
+
or current_identity == identity
|
|
796
|
+
or (current_identity is None and _process_alive(pid))
|
|
797
|
+
):
|
|
798
|
+
raise InstallerError(f"installation already in progress for {lock.parent}")
|
|
799
|
+
return observed, owner_bytes
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _recover_stale_install_lock(lock: Path, quarantine: Path) -> Optional[Path]:
|
|
803
|
+
legacy_quarantine = lock.with_name(f"{lock.name}.stale")
|
|
804
|
+
if not _lexists(lock):
|
|
805
|
+
prefix = f"{lock.name}.stale-"
|
|
806
|
+
for orphan in lock.parent.glob(f"{prefix}*"):
|
|
807
|
+
staging = lock.parent / orphan.name.removeprefix(prefix)
|
|
808
|
+
try:
|
|
809
|
+
_stale_install_lock_snapshot(orphan)
|
|
810
|
+
if _lexists(staging):
|
|
811
|
+
_stale_install_lock_snapshot(staging)
|
|
812
|
+
except InstallerError:
|
|
813
|
+
continue
|
|
814
|
+
if _lexists(staging):
|
|
815
|
+
shutil.rmtree(staging)
|
|
816
|
+
shutil.rmtree(orphan)
|
|
817
|
+
if _lexists(legacy_quarantine):
|
|
818
|
+
_stale_install_lock_snapshot(legacy_quarantine)
|
|
819
|
+
shutil.rmtree(legacy_quarantine)
|
|
820
|
+
return None
|
|
821
|
+
observed, owner_bytes = _stale_install_lock_snapshot(lock)
|
|
822
|
+
if _lexists(legacy_quarantine):
|
|
823
|
+
_stale_install_lock_snapshot(legacy_quarantine)
|
|
824
|
+
shutil.rmtree(legacy_quarantine)
|
|
825
|
+
try:
|
|
826
|
+
lock.rename(quarantine)
|
|
827
|
+
except (FileNotFoundError, FileExistsError) as error:
|
|
828
|
+
raise InstallerError(
|
|
829
|
+
f"installation already in progress for {lock.parent}"
|
|
830
|
+
) from error
|
|
831
|
+
try:
|
|
832
|
+
moved = quarantine.stat()
|
|
833
|
+
moved_owner = (quarantine / INSTALL_LOCK_OWNER).read_bytes()
|
|
834
|
+
except OSError as error:
|
|
835
|
+
raise InstallerError(
|
|
836
|
+
f"installation already in progress for {lock.parent}"
|
|
837
|
+
) from error
|
|
838
|
+
if (
|
|
839
|
+
(moved.st_dev, moved.st_ino) != (observed.st_dev, observed.st_ino)
|
|
840
|
+
or moved_owner != owner_bytes
|
|
841
|
+
):
|
|
842
|
+
if not _lexists(lock):
|
|
843
|
+
quarantine.rename(lock)
|
|
844
|
+
raise InstallerError(f"installation already in progress for {lock.parent}")
|
|
845
|
+
return quarantine
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _create_install_lock(lock: Path) -> None:
|
|
849
|
+
identity = _process_identity(os.getpid())
|
|
850
|
+
if identity is None:
|
|
851
|
+
raise InstallerError("cannot determine installer process identity")
|
|
852
|
+
staging = Path(tempfile.mkdtemp(prefix=".install-lock-stage-", dir=lock.parent))
|
|
853
|
+
recovery = lock.with_name(f"{lock.name}.stale-{staging.name}")
|
|
854
|
+
quarantine = None
|
|
855
|
+
published = False
|
|
856
|
+
try:
|
|
857
|
+
(staging / INSTALL_LOCK_OWNER).write_text(
|
|
858
|
+
json.dumps(
|
|
859
|
+
{
|
|
860
|
+
"schema": INSTALL_LOCK_SCHEMA,
|
|
861
|
+
"pid": os.getpid(),
|
|
862
|
+
"identity": identity,
|
|
863
|
+
}
|
|
864
|
+
)
|
|
865
|
+
+ "\n",
|
|
866
|
+
encoding="utf-8",
|
|
867
|
+
)
|
|
868
|
+
quarantine = _recover_stale_install_lock(lock, recovery)
|
|
869
|
+
staging.rename(lock)
|
|
870
|
+
published = True
|
|
871
|
+
if quarantine is not None:
|
|
872
|
+
shutil.rmtree(quarantine)
|
|
873
|
+
except BaseException:
|
|
874
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
875
|
+
if quarantine is not None and _lexists(quarantine):
|
|
876
|
+
if published and _lexists(lock):
|
|
877
|
+
shutil.rmtree(lock)
|
|
878
|
+
if not _lexists(lock):
|
|
879
|
+
quarantine.rename(lock)
|
|
880
|
+
else:
|
|
881
|
+
shutil.rmtree(quarantine)
|
|
882
|
+
if _lexists(lock):
|
|
883
|
+
raise InstallerError(
|
|
884
|
+
f"installation already in progress for {lock.parent}"
|
|
885
|
+
)
|
|
886
|
+
raise
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _acquire_install_locks(roots: Iterable[Path]) -> Tuple[List[Path], List[Path]]:
|
|
890
|
+
locks: List[Path] = []
|
|
891
|
+
for root in roots:
|
|
892
|
+
candidate = root.parent / INSTALL_LOCK_NAME
|
|
893
|
+
if not any(_same_path(candidate, existing) for existing in locks):
|
|
894
|
+
locks.append(candidate)
|
|
895
|
+
locks.sort(key=lambda candidate: os.path.normcase(os.fspath(candidate)))
|
|
896
|
+
acquired: List[Path] = []
|
|
897
|
+
created: List[Path] = []
|
|
898
|
+
try:
|
|
899
|
+
for lock in locks:
|
|
900
|
+
_create_directory(lock.parent, created)
|
|
901
|
+
_create_install_lock(lock)
|
|
902
|
+
acquired.append(lock)
|
|
903
|
+
except BaseException:
|
|
904
|
+
_release_install_locks(acquired, created)
|
|
905
|
+
raise
|
|
906
|
+
return acquired, created
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _backup_path(root: Path, reserved: Sequence[Path] = ()) -> Path:
|
|
910
|
+
candidate = root.parent / "disabled-gsd-skills"
|
|
911
|
+
number = 1
|
|
912
|
+
while _lexists(candidate) or any(
|
|
913
|
+
_same_path(candidate, path) for path in reserved
|
|
914
|
+
):
|
|
915
|
+
candidate = root.parent / f"disabled-gsd-skills-{number}"
|
|
916
|
+
number += 1
|
|
917
|
+
return candidate
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _backup_existing(
|
|
921
|
+
transaction: TargetTransaction,
|
|
922
|
+
extras: Sequence[Tuple[Path, str]] = (),
|
|
923
|
+
) -> None:
|
|
924
|
+
existing = sorted(
|
|
925
|
+
(
|
|
926
|
+
(entry, entry.name)
|
|
927
|
+
for entry in transaction.root.iterdir()
|
|
928
|
+
if _is_managed_install_entry(transaction.root, entry.name)
|
|
929
|
+
),
|
|
930
|
+
key=lambda item: item[0].name,
|
|
931
|
+
)
|
|
932
|
+
existing.extend((path, backup_name) for path, backup_name in extras if _lexists(path))
|
|
933
|
+
if existing:
|
|
934
|
+
transaction.backup = _backup_path(transaction.root)
|
|
935
|
+
transaction.backup.mkdir()
|
|
936
|
+
for entry, backup_name in existing:
|
|
937
|
+
stored = transaction.backup / backup_name
|
|
938
|
+
transaction.moved.append((entry, stored))
|
|
939
|
+
os.replace(entry, stored)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _reserve_directory(path: Path) -> None:
|
|
943
|
+
path.mkdir()
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _reserve_file(path: Path) -> None:
|
|
947
|
+
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
948
|
+
try:
|
|
949
|
+
os.close(descriptor)
|
|
950
|
+
except BaseException:
|
|
951
|
+
path.unlink(missing_ok=True)
|
|
952
|
+
raise
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _apply_target(
|
|
956
|
+
plan: DeploymentPlan, staged_root: Path, transaction: TargetTransaction
|
|
957
|
+
) -> None:
|
|
958
|
+
_create_directory(plan.root, transaction.created_directories)
|
|
959
|
+
extras = []
|
|
960
|
+
cursor_agent = None
|
|
961
|
+
if plan.profile == "cursor":
|
|
962
|
+
cursor_agent = plan.root.parent / "agents" / CURSOR_AGENT_FILENAME
|
|
963
|
+
_create_directory(cursor_agent.parent, transaction.created_directories)
|
|
964
|
+
extras.append((cursor_agent, CURSOR_AGENT_BACKUP_NAME))
|
|
965
|
+
_reject_router_alias_collisions(plan.root)
|
|
966
|
+
_backup_existing(transaction, extras)
|
|
967
|
+
for name in SKILL_NAMES:
|
|
968
|
+
destination = plan.root / name
|
|
969
|
+
_reserve_directory(destination)
|
|
970
|
+
transaction.installed.append(destination)
|
|
971
|
+
shutil.copytree(staged_root / name, destination, dirs_exist_ok=True)
|
|
972
|
+
if cursor_agent is not None:
|
|
973
|
+
_reserve_file(cursor_agent)
|
|
974
|
+
transaction.installed.append(cursor_agent)
|
|
975
|
+
shutil.copy2(staged_root / CURSOR_AGENT_FILENAME, cursor_agent)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _remove_path(path: Path) -> None:
|
|
979
|
+
if not _lexists(path):
|
|
980
|
+
return
|
|
981
|
+
if path.is_symlink() or not path.is_dir():
|
|
982
|
+
path.unlink()
|
|
983
|
+
else:
|
|
984
|
+
shutil.rmtree(path)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _remove_empty_directories(paths: Iterable[Path]) -> None:
|
|
988
|
+
for path in reversed(list(paths)):
|
|
989
|
+
try:
|
|
990
|
+
path.rmdir()
|
|
991
|
+
except OSError:
|
|
992
|
+
pass
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _rollback_target(transaction: TargetTransaction) -> None:
|
|
996
|
+
for destination in reversed(transaction.installed):
|
|
997
|
+
_remove_path(destination)
|
|
998
|
+
for original, stored in reversed(transaction.moved):
|
|
999
|
+
if _lexists(stored):
|
|
1000
|
+
os.replace(stored, original)
|
|
1001
|
+
if transaction.backup is not None:
|
|
1002
|
+
try:
|
|
1003
|
+
transaction.backup.rmdir()
|
|
1004
|
+
except OSError:
|
|
1005
|
+
pass
|
|
1006
|
+
_remove_empty_directories(transaction.created_directories)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _project_destinations(
|
|
1010
|
+
project: Path,
|
|
1011
|
+
selected: Sequence[str],
|
|
1012
|
+
hooks: bool,
|
|
1013
|
+
interpreter: str,
|
|
1014
|
+
hooks_dir: Optional[Path],
|
|
1015
|
+
) -> List[Tuple[Path, Optional[str], Optional[str], bool]]:
|
|
1016
|
+
"""(destination, source name, literal content, executable) per file.
|
|
1017
|
+
|
|
1018
|
+
hooks_dir is the pre-resolved git hooks directory (or None); resolving
|
|
1019
|
+
it once per run avoids repeated `git rev-parse` spawns.
|
|
1020
|
+
"""
|
|
1021
|
+
destinations: List[Tuple[Path, Optional[str], Optional[str], bool]] = [
|
|
1022
|
+
(project / "AGENTS.md", "AGENTS.md", None, False),
|
|
1023
|
+
(project / "WORKFLOW.md", "WORKFLOW.md", None, False),
|
|
1024
|
+
(
|
|
1025
|
+
project / HOOKS_DIRECTORY / PROJECT_STATUS_LAUNCHER,
|
|
1026
|
+
f"scripts/{PROJECT_STATUS_LAUNCHER}",
|
|
1027
|
+
None,
|
|
1028
|
+
False,
|
|
1029
|
+
),
|
|
1030
|
+
]
|
|
1031
|
+
destinations.extend(
|
|
1032
|
+
(
|
|
1033
|
+
project / HOOKS_DIRECTORY / "runtime" / name,
|
|
1034
|
+
f"scripts/{name}",
|
|
1035
|
+
None,
|
|
1036
|
+
False,
|
|
1037
|
+
)
|
|
1038
|
+
for name in PROJECT_RUNTIME_SCRIPTS
|
|
1039
|
+
)
|
|
1040
|
+
if "claude" in selected:
|
|
1041
|
+
destinations.append(
|
|
1042
|
+
(project / ".claude" / "CLAUDE.md", None, CLAUDE_BRIDGE, False)
|
|
1043
|
+
)
|
|
1044
|
+
if hooks:
|
|
1045
|
+
for name in GUARD_SCRIPTS:
|
|
1046
|
+
destinations.append(
|
|
1047
|
+
(project / HOOKS_DIRECTORY / name, f"scripts/{name}", None, False)
|
|
1048
|
+
)
|
|
1049
|
+
if "claude" in selected:
|
|
1050
|
+
destinations.append(
|
|
1051
|
+
(
|
|
1052
|
+
project / ".claude" / "settings.json",
|
|
1053
|
+
None,
|
|
1054
|
+
claude_hooks_settings(interpreter),
|
|
1055
|
+
False,
|
|
1056
|
+
)
|
|
1057
|
+
)
|
|
1058
|
+
if "codex" in selected:
|
|
1059
|
+
destinations.append(
|
|
1060
|
+
(
|
|
1061
|
+
project / ".codex" / "hooks.json",
|
|
1062
|
+
None,
|
|
1063
|
+
codex_hooks_settings(interpreter),
|
|
1064
|
+
False,
|
|
1065
|
+
)
|
|
1066
|
+
)
|
|
1067
|
+
if "cursor" in selected:
|
|
1068
|
+
destinations.append(
|
|
1069
|
+
(
|
|
1070
|
+
project / ".cursor" / "hooks.json",
|
|
1071
|
+
None,
|
|
1072
|
+
cursor_hooks_settings(interpreter),
|
|
1073
|
+
False,
|
|
1074
|
+
)
|
|
1075
|
+
)
|
|
1076
|
+
if hooks_dir is not None:
|
|
1077
|
+
for hook_name, generator in GIT_HOOKS:
|
|
1078
|
+
destinations.append(
|
|
1079
|
+
(hooks_dir / hook_name, None, generator(interpreter), True)
|
|
1080
|
+
)
|
|
1081
|
+
return destinations
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _native_settings_mergers(
|
|
1085
|
+
project: Path, selected: Sequence[str], hooks: bool
|
|
1086
|
+
) -> Mapping[Path, Callable[[Path, str], str]]:
|
|
1087
|
+
mergers = {}
|
|
1088
|
+
if not hooks:
|
|
1089
|
+
return mergers
|
|
1090
|
+
if "claude" in selected:
|
|
1091
|
+
mergers[project / ".claude" / "settings.json"] = _merged_claude_settings
|
|
1092
|
+
if "codex" in selected:
|
|
1093
|
+
mergers[project / ".codex" / "hooks.json"] = _merged_codex_settings
|
|
1094
|
+
if "cursor" in selected:
|
|
1095
|
+
mergers[project / ".cursor" / "hooks.json"] = _merged_cursor_settings
|
|
1096
|
+
return mergers
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _update_replacement(
|
|
1100
|
+
project: Path, destination: Path, hooks_dir: Optional[Path]
|
|
1101
|
+
) -> Optional[Callable[[Path], bool]]:
|
|
1102
|
+
"""--update replaces managed runtime, guard, and git hook files in place
|
|
1103
|
+
and keeps everything else (AGENTS.md, WORKFLOW.md, CLAUDE.md). Returns
|
|
1104
|
+
the managed-file check for a replaceable destination, or None for a
|
|
1105
|
+
kept one."""
|
|
1106
|
+
parent = project / HOOKS_DIRECTORY
|
|
1107
|
+
directory = destination.parent
|
|
1108
|
+
if _same_path(directory, parent / "runtime"):
|
|
1109
|
+
return _is_managed_project_runtime
|
|
1110
|
+
if _same_path(destination, parent / PROJECT_STATUS_LAUNCHER):
|
|
1111
|
+
return _is_managed_project_status_launcher
|
|
1112
|
+
if any(_same_path(destination, parent / name) for name in GUARD_SCRIPTS):
|
|
1113
|
+
return _is_managed_guard_script
|
|
1114
|
+
if hooks_dir is not None and _same_path(directory, hooks_dir):
|
|
1115
|
+
return _is_managed_git_hook
|
|
1116
|
+
return None
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _describe_project_path(project: Path, destination: Path) -> str:
|
|
1120
|
+
try:
|
|
1121
|
+
return destination.relative_to(project).as_posix()
|
|
1122
|
+
except ValueError:
|
|
1123
|
+
return destination.as_posix()
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _project_files(
|
|
1127
|
+
project: Path,
|
|
1128
|
+
selected: Sequence[str],
|
|
1129
|
+
hooks: bool,
|
|
1130
|
+
interpreter: str,
|
|
1131
|
+
hooks_dir: Optional[Path],
|
|
1132
|
+
) -> str:
|
|
1133
|
+
return ", ".join(
|
|
1134
|
+
_describe_project_path(project, destination)
|
|
1135
|
+
for destination, _, _, _ in _project_destinations(
|
|
1136
|
+
project, selected, hooks, interpreter, hooks_dir
|
|
1137
|
+
)
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _project_result(
|
|
1142
|
+
project: Path,
|
|
1143
|
+
selected: Sequence[str],
|
|
1144
|
+
hooks: bool,
|
|
1145
|
+
interpreter: str,
|
|
1146
|
+
hooks_dir: Optional[Path],
|
|
1147
|
+
update: bool,
|
|
1148
|
+
dry_run: bool,
|
|
1149
|
+
) -> str:
|
|
1150
|
+
if not update:
|
|
1151
|
+
files = _project_files(project, selected, hooks, interpreter, hooks_dir)
|
|
1152
|
+
verb = "would copy" if dry_run else "copied"
|
|
1153
|
+
return f"project: {verb} {files} to {project}"
|
|
1154
|
+
mergers = _native_settings_mergers(project, selected, hooks)
|
|
1155
|
+
refreshed = []
|
|
1156
|
+
kept = []
|
|
1157
|
+
for destination, _, _, _ in _project_destinations(
|
|
1158
|
+
project, selected, hooks, interpreter, hooks_dir
|
|
1159
|
+
):
|
|
1160
|
+
replaceable = (
|
|
1161
|
+
destination in mergers
|
|
1162
|
+
or _update_replacement(project, destination, hooks_dir) is not None
|
|
1163
|
+
)
|
|
1164
|
+
bucket = kept if _lexists(destination) and not replaceable else refreshed
|
|
1165
|
+
bucket.append(_describe_project_path(project, destination))
|
|
1166
|
+
verb = "would refresh" if dry_run else "refreshed"
|
|
1167
|
+
line = f"project: {verb} {', '.join(refreshed)} in {project}"
|
|
1168
|
+
if kept:
|
|
1169
|
+
line += f"; kept {', '.join(kept)}"
|
|
1170
|
+
return line
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _existing_contract_error(destination: Path) -> "InstallerError":
|
|
1174
|
+
return InstallerError(
|
|
1175
|
+
f"project contract already exists: {destination} — the installer never "
|
|
1176
|
+
"overwrites project files. Run --update --project PATH to refresh managed "
|
|
1177
|
+
"runtime files; merge template changes manually (see UPDATE.md)."
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _validate_project_git_root(project: Path) -> None:
|
|
1182
|
+
probe = project
|
|
1183
|
+
while not _lexists(probe):
|
|
1184
|
+
probe = probe.parent
|
|
1185
|
+
try:
|
|
1186
|
+
result = subprocess.run(
|
|
1187
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
1188
|
+
cwd=probe,
|
|
1189
|
+
capture_output=True,
|
|
1190
|
+
text=True,
|
|
1191
|
+
check=False,
|
|
1192
|
+
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
1193
|
+
)
|
|
1194
|
+
except OSError:
|
|
1195
|
+
return
|
|
1196
|
+
top_level = result.stdout.strip()
|
|
1197
|
+
if (
|
|
1198
|
+
result.returncode == 0
|
|
1199
|
+
and top_level
|
|
1200
|
+
and not _same_path(Path(top_level), project)
|
|
1201
|
+
):
|
|
1202
|
+
raise InstallerError(f"project path is not the Git worktree root: {project}")
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _validate_project(
|
|
1206
|
+
source_root: Path,
|
|
1207
|
+
project: Path,
|
|
1208
|
+
selected: Sequence[str],
|
|
1209
|
+
hooks: bool,
|
|
1210
|
+
reserved_roots: Sequence[Tuple[str, Path]],
|
|
1211
|
+
interpreter: str,
|
|
1212
|
+
hooks_dir: Optional[Path],
|
|
1213
|
+
update: bool = False,
|
|
1214
|
+
) -> None:
|
|
1215
|
+
_validate_directory_destination(project, "project path")
|
|
1216
|
+
_validate_project_git_root(project)
|
|
1217
|
+
_validate_directory_destination(
|
|
1218
|
+
project / HOOKS_DIRECTORY, "project runtime parent directory"
|
|
1219
|
+
)
|
|
1220
|
+
_validate_directory_destination(
|
|
1221
|
+
project / HOOKS_DIRECTORY / "runtime", "project runtime directory"
|
|
1222
|
+
)
|
|
1223
|
+
project_directories = []
|
|
1224
|
+
if "claude" in selected:
|
|
1225
|
+
project_directories.append(("Claude", project / ".claude"))
|
|
1226
|
+
if hooks and "codex" in selected:
|
|
1227
|
+
project_directories.append(("Codex", project / ".codex"))
|
|
1228
|
+
if hooks and "cursor" in selected:
|
|
1229
|
+
project_directories.append(("Cursor", project / ".cursor"))
|
|
1230
|
+
for label, directory in project_directories:
|
|
1231
|
+
if _lexists(directory) and (
|
|
1232
|
+
directory.is_symlink() or not directory.is_dir()
|
|
1233
|
+
):
|
|
1234
|
+
raise InstallerError(f"unsafe {label} project directory: {directory}")
|
|
1235
|
+
sources = [
|
|
1236
|
+
"AGENTS.md",
|
|
1237
|
+
"WORKFLOW.md",
|
|
1238
|
+
f"scripts/{PROJECT_STATUS_LAUNCHER}",
|
|
1239
|
+
*(f"scripts/{name}" for name in PROJECT_RUNTIME_SCRIPTS),
|
|
1240
|
+
]
|
|
1241
|
+
if hooks:
|
|
1242
|
+
sources.extend(f"scripts/{name}" for name in GUARD_SCRIPTS)
|
|
1243
|
+
for source_name in sources:
|
|
1244
|
+
source = source_root / source_name
|
|
1245
|
+
if source.is_symlink() or not source.is_file():
|
|
1246
|
+
raise InstallerError(f"missing project contract: {source}")
|
|
1247
|
+
mergers = _native_settings_mergers(project, selected, hooks)
|
|
1248
|
+
for destination, _, _, _ in _project_destinations(
|
|
1249
|
+
project, selected, hooks, interpreter, hooks_dir
|
|
1250
|
+
):
|
|
1251
|
+
if _lexists(destination):
|
|
1252
|
+
merge = mergers.get(destination)
|
|
1253
|
+
managed = (
|
|
1254
|
+
_update_replacement(project, destination, hooks_dir) if update else None
|
|
1255
|
+
)
|
|
1256
|
+
if destination.is_symlink() or (merge is None and not update):
|
|
1257
|
+
raise _existing_contract_error(destination)
|
|
1258
|
+
if merge is not None:
|
|
1259
|
+
merge(destination, interpreter)
|
|
1260
|
+
elif managed is not None and not managed(destination):
|
|
1261
|
+
raise InstallerError(
|
|
1262
|
+
f"not a managed GSD Path project file: {destination}"
|
|
1263
|
+
)
|
|
1264
|
+
for label, root in reserved_roots:
|
|
1265
|
+
if _paths_overlap(destination, root):
|
|
1266
|
+
raise InstallerError(
|
|
1267
|
+
f"project contract overlaps {label}: {destination}, {root}"
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def _apply_project(
|
|
1272
|
+
source_root: Path,
|
|
1273
|
+
project: Path,
|
|
1274
|
+
selected: Sequence[str],
|
|
1275
|
+
hooks: bool,
|
|
1276
|
+
transaction: ProjectTransaction,
|
|
1277
|
+
interpreter: str,
|
|
1278
|
+
hooks_dir: Optional[Path],
|
|
1279
|
+
update: bool = False,
|
|
1280
|
+
) -> None:
|
|
1281
|
+
_create_directory(project, transaction.created_directories)
|
|
1282
|
+
mergers = _native_settings_mergers(project, selected, hooks)
|
|
1283
|
+
for destination, source_name, content, executable in _project_destinations(
|
|
1284
|
+
project, selected, hooks, interpreter, hooks_dir
|
|
1285
|
+
):
|
|
1286
|
+
_create_directory(destination.parent, transaction.created_directories)
|
|
1287
|
+
merge = mergers.get(destination)
|
|
1288
|
+
if _lexists(destination) and not destination.is_symlink():
|
|
1289
|
+
replaceable = (
|
|
1290
|
+
update
|
|
1291
|
+
and _update_replacement(project, destination, hooks_dir) is not None
|
|
1292
|
+
)
|
|
1293
|
+
if merge is not None or replaceable:
|
|
1294
|
+
original = destination.read_bytes()
|
|
1295
|
+
mode = destination.stat().st_mode & 0o777
|
|
1296
|
+
if merge is not None:
|
|
1297
|
+
replacement: Union[str, bytes] = merge(destination, interpreter)
|
|
1298
|
+
elif source_name:
|
|
1299
|
+
replacement = (source_root / source_name).read_bytes()
|
|
1300
|
+
else:
|
|
1301
|
+
replacement = content or ""
|
|
1302
|
+
_atomic_write(
|
|
1303
|
+
destination, replacement, 0o755 if executable else mode
|
|
1304
|
+
)
|
|
1305
|
+
transaction.replaced.append((destination, original, mode))
|
|
1306
|
+
continue
|
|
1307
|
+
if update:
|
|
1308
|
+
continue
|
|
1309
|
+
created = False
|
|
1310
|
+
try:
|
|
1311
|
+
if source_name:
|
|
1312
|
+
with (source_root / source_name).open("rb") as source:
|
|
1313
|
+
with destination.open("xb") as output:
|
|
1314
|
+
created = True
|
|
1315
|
+
shutil.copyfileobj(source, output)
|
|
1316
|
+
else:
|
|
1317
|
+
with destination.open("x", encoding="utf-8") as output:
|
|
1318
|
+
created = True
|
|
1319
|
+
output.write(content)
|
|
1320
|
+
if executable:
|
|
1321
|
+
destination.chmod(0o755)
|
|
1322
|
+
transaction.copied.append(destination)
|
|
1323
|
+
except FileExistsError as error:
|
|
1324
|
+
raise _existing_contract_error(destination) from error
|
|
1325
|
+
except (Exception, KeyboardInterrupt):
|
|
1326
|
+
if created:
|
|
1327
|
+
_remove_path(destination)
|
|
1328
|
+
raise
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _rollback_project(transaction: ProjectTransaction) -> None:
|
|
1332
|
+
for destination, original, mode in reversed(transaction.replaced):
|
|
1333
|
+
_atomic_write(destination, original, mode)
|
|
1334
|
+
for destination in reversed(transaction.copied):
|
|
1335
|
+
_remove_path(destination)
|
|
1336
|
+
_remove_empty_directories(transaction.created_directories)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _managed_file_contains(destination: Path, marker: str, label: str) -> bool:
|
|
1340
|
+
if not destination.is_file():
|
|
1341
|
+
return False
|
|
1342
|
+
try:
|
|
1343
|
+
content = destination.read_text(encoding="utf-8", errors="replace")
|
|
1344
|
+
except OSError as error:
|
|
1345
|
+
raise InstallerError(f"cannot read {label}: {destination}") from error
|
|
1346
|
+
return marker in content
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def _is_managed_guard_script(destination: Path) -> bool:
|
|
1350
|
+
return _managed_file_contains(destination, GUARD_MARKER, "guard script")
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def _is_managed_project_status_launcher(destination: Path) -> bool:
|
|
1354
|
+
return _managed_file_contains(
|
|
1355
|
+
destination, PROJECT_STATUS_MARKER, "project status launcher"
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _is_managed_project_runtime(destination: Path) -> bool:
|
|
1360
|
+
return _managed_file_contains(
|
|
1361
|
+
destination, PROJECT_RUNTIME_MARKER, "project runtime"
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _is_managed_git_hook_content(text: str) -> bool:
|
|
1366
|
+
return GUARD_MARKER in text and "git_guard.py" in text
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def _is_managed_git_hook(destination: Path) -> bool:
|
|
1370
|
+
if not destination.is_file():
|
|
1371
|
+
return False
|
|
1372
|
+
try:
|
|
1373
|
+
text = destination.read_text(encoding="utf-8", errors="replace")
|
|
1374
|
+
except OSError:
|
|
1375
|
+
return False
|
|
1376
|
+
return _is_managed_git_hook_content(text)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _is_managed_hook_settings(destination: Path) -> bool:
|
|
1380
|
+
if not destination.is_file():
|
|
1381
|
+
return False
|
|
1382
|
+
try:
|
|
1383
|
+
parsed = _parsed_managed_settings(destination)
|
|
1384
|
+
except InstallerError:
|
|
1385
|
+
return False
|
|
1386
|
+
return _has_managed_hook_settings(parsed)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _has_managed_guard_wiring(project: Path) -> bool:
|
|
1390
|
+
settings = (
|
|
1391
|
+
project / ".claude" / "settings.json",
|
|
1392
|
+
project / ".codex" / "hooks.json",
|
|
1393
|
+
project / ".cursor" / "hooks.json",
|
|
1394
|
+
)
|
|
1395
|
+
if any(_is_managed_hook_settings(path) for path in settings):
|
|
1396
|
+
return True
|
|
1397
|
+
hooks_dir = _git_hooks_directory(project)
|
|
1398
|
+
return hooks_dir is not None and any(
|
|
1399
|
+
_is_managed_git_hook(hooks_dir / name)
|
|
1400
|
+
for name in GIT_HOOK_NAMES
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def _atomic_temporary(destination: Path) -> Tuple[int, Path]:
|
|
1405
|
+
descriptor, name = tempfile.mkstemp(
|
|
1406
|
+
prefix=f".{destination.name}.gsd-path-tmp-", dir=destination.parent
|
|
1407
|
+
)
|
|
1408
|
+
return descriptor, Path(name)
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _atomic_write(
|
|
1412
|
+
destination: Path, content: Union[str, bytes], mode: Optional[int] = None
|
|
1413
|
+
) -> None:
|
|
1414
|
+
descriptor, temporary = _atomic_temporary(destination)
|
|
1415
|
+
try:
|
|
1416
|
+
if isinstance(content, bytes):
|
|
1417
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
1418
|
+
output.write(content)
|
|
1419
|
+
else:
|
|
1420
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
1421
|
+
output.write(content)
|
|
1422
|
+
if mode is not None:
|
|
1423
|
+
temporary.chmod(mode)
|
|
1424
|
+
elif destination.is_file() and not destination.is_symlink():
|
|
1425
|
+
temporary.chmod(destination.stat().st_mode & 0o777)
|
|
1426
|
+
os.replace(temporary, destination)
|
|
1427
|
+
except BaseException:
|
|
1428
|
+
_remove_path(temporary)
|
|
1429
|
+
raise
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def _atomic_copy(source: Path, destination: Path) -> None:
|
|
1433
|
+
descriptor, temporary = _atomic_temporary(destination)
|
|
1434
|
+
try:
|
|
1435
|
+
with source.open("rb") as input_file:
|
|
1436
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
1437
|
+
shutil.copyfileobj(input_file, output)
|
|
1438
|
+
os.replace(temporary, destination)
|
|
1439
|
+
except BaseException:
|
|
1440
|
+
_remove_path(temporary)
|
|
1441
|
+
raise
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def _is_managed_hook_entry(entry) -> bool:
|
|
1445
|
+
"""A PreToolUse entry is ours when one of its commands runs the guard."""
|
|
1446
|
+
if not isinstance(entry, dict):
|
|
1447
|
+
return False
|
|
1448
|
+
hooks_list = entry.get("hooks")
|
|
1449
|
+
if not isinstance(hooks_list, list):
|
|
1450
|
+
return False
|
|
1451
|
+
return any(_is_managed_command_hook(hook) for hook in hooks_list)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _is_guard_command(command: str) -> bool:
|
|
1455
|
+
normalized = command.replace("\\", "/")
|
|
1456
|
+
match = re.fullmatch(
|
|
1457
|
+
r"(?:python3|python)\s+(?:\"([^\"\r\n]+)\"|'([^'\r\n]+)'|(\S+))",
|
|
1458
|
+
normalized,
|
|
1459
|
+
)
|
|
1460
|
+
if match is None:
|
|
1461
|
+
return False
|
|
1462
|
+
script = next(value for value in match.groups() if value is not None)
|
|
1463
|
+
managed_script = f"{HOOKS_DIRECTORY}/guard_hook.py"
|
|
1464
|
+
return script == managed_script or script.endswith(f"/{managed_script}")
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def _is_managed_command_hook(hook) -> bool:
|
|
1468
|
+
return (
|
|
1469
|
+
isinstance(hook, dict)
|
|
1470
|
+
and isinstance(hook.get("command"), str)
|
|
1471
|
+
and _is_guard_command(hook["command"])
|
|
1472
|
+
)
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def _is_managed_direct_hook_entry(entry) -> bool:
|
|
1476
|
+
return _is_managed_command_hook(entry)
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _has_managed_hook_settings(parsed: dict) -> bool:
|
|
1480
|
+
hooks = parsed.get("hooks")
|
|
1481
|
+
if not isinstance(hooks, dict):
|
|
1482
|
+
return False
|
|
1483
|
+
nested = hooks.get("PreToolUse")
|
|
1484
|
+
direct = hooks.get("preToolUse")
|
|
1485
|
+
return (
|
|
1486
|
+
isinstance(nested, list)
|
|
1487
|
+
and any(_is_managed_hook_entry(entry) for entry in nested)
|
|
1488
|
+
) or (
|
|
1489
|
+
isinstance(direct, list)
|
|
1490
|
+
and any(_is_managed_direct_hook_entry(entry) for entry in direct)
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def _parsed_managed_settings(settings: Path) -> dict:
|
|
1495
|
+
try:
|
|
1496
|
+
content = settings.read_text(encoding="utf-8", errors="replace")
|
|
1497
|
+
except OSError as error:
|
|
1498
|
+
raise InstallerError(
|
|
1499
|
+
f"cannot read managed hook settings file: {settings}: {error}"
|
|
1500
|
+
) from error
|
|
1501
|
+
try:
|
|
1502
|
+
parsed = json.loads(content)
|
|
1503
|
+
except json.JSONDecodeError as error:
|
|
1504
|
+
raise InstallerError(
|
|
1505
|
+
f"managed hook settings file is not valid JSON: {settings}"
|
|
1506
|
+
) from error
|
|
1507
|
+
if not isinstance(parsed, dict):
|
|
1508
|
+
raise InstallerError(
|
|
1509
|
+
f"managed hook settings file is not a JSON object: {settings}"
|
|
1510
|
+
)
|
|
1511
|
+
return parsed
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def _merged_hook_settings(
|
|
1515
|
+
settings: Path, event_name: str, managed_entry: dict, is_managed_entry
|
|
1516
|
+
) -> str:
|
|
1517
|
+
parsed = _parsed_managed_settings(settings)
|
|
1518
|
+
hooks_object = parsed.get("hooks")
|
|
1519
|
+
if not isinstance(hooks_object, dict):
|
|
1520
|
+
hooks_object = {}
|
|
1521
|
+
existing = hooks_object.get(event_name)
|
|
1522
|
+
if not isinstance(existing, list):
|
|
1523
|
+
existing = []
|
|
1524
|
+
merged = []
|
|
1525
|
+
replaced = False
|
|
1526
|
+
for entry in existing:
|
|
1527
|
+
if is_managed_entry(entry):
|
|
1528
|
+
if not replaced:
|
|
1529
|
+
merged.append(managed_entry)
|
|
1530
|
+
replaced = True
|
|
1531
|
+
else:
|
|
1532
|
+
merged.append(entry)
|
|
1533
|
+
if not replaced:
|
|
1534
|
+
merged.append(managed_entry)
|
|
1535
|
+
hooks_object[event_name] = merged
|
|
1536
|
+
parsed["hooks"] = hooks_object
|
|
1537
|
+
return json.dumps(parsed, indent=2) + "\n"
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _merged_nested_hook_settings(settings: Path, managed_entry: dict) -> str:
|
|
1541
|
+
parsed = _parsed_managed_settings(settings)
|
|
1542
|
+
hooks_object = parsed.get("hooks")
|
|
1543
|
+
if not isinstance(hooks_object, dict):
|
|
1544
|
+
hooks_object = {}
|
|
1545
|
+
existing = hooks_object.get("PreToolUse")
|
|
1546
|
+
if not isinstance(existing, list):
|
|
1547
|
+
existing = []
|
|
1548
|
+
merged = []
|
|
1549
|
+
replaced = False
|
|
1550
|
+
for entry in existing:
|
|
1551
|
+
if not _is_managed_hook_entry(entry):
|
|
1552
|
+
merged.append(entry)
|
|
1553
|
+
continue
|
|
1554
|
+
unrelated_hooks = [
|
|
1555
|
+
hook for hook in entry["hooks"] if not _is_managed_command_hook(hook)
|
|
1556
|
+
]
|
|
1557
|
+
if not replaced:
|
|
1558
|
+
merged.append(managed_entry)
|
|
1559
|
+
replaced = True
|
|
1560
|
+
if unrelated_hooks:
|
|
1561
|
+
merged.append({**entry, "hooks": unrelated_hooks})
|
|
1562
|
+
if not replaced:
|
|
1563
|
+
merged.append(managed_entry)
|
|
1564
|
+
hooks_object["PreToolUse"] = merged
|
|
1565
|
+
parsed["hooks"] = hooks_object
|
|
1566
|
+
return json.dumps(parsed, indent=2) + "\n"
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def _merged_claude_settings(settings: Path, interpreter: str) -> str:
|
|
1570
|
+
return _merged_nested_hook_settings(settings, _claude_guard_entry(interpreter))
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def _merged_codex_settings(settings: Path, interpreter: str) -> str:
|
|
1574
|
+
managed = codex_hooks_settings(interpreter)
|
|
1575
|
+
managed_entry = json.loads(managed)["hooks"]["PreToolUse"][0]
|
|
1576
|
+
return _merged_nested_hook_settings(settings, managed_entry)
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def _merged_cursor_settings(settings: Path, interpreter: str) -> str:
|
|
1580
|
+
managed = cursor_hooks_settings(interpreter)
|
|
1581
|
+
managed_entry = json.loads(managed)["hooks"]["preToolUse"][0]
|
|
1582
|
+
return _merged_hook_settings(
|
|
1583
|
+
settings, "preToolUse", managed_entry, _is_managed_direct_hook_entry
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def _refreshes_guards(project: Path, full: bool, initialize: bool) -> bool:
|
|
1588
|
+
return full or initialize or _has_managed_guard_wiring(project) or any(
|
|
1589
|
+
_lexists(project / HOOKS_DIRECTORY / name) for name in GUARD_SCRIPTS
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def _contract_section(content: str, heading: str) -> Optional[str]:
|
|
1594
|
+
normalized = content.replace("\r\n", "\n")
|
|
1595
|
+
match = re.search(rf"(?m)^{re.escape(heading)}$", normalized)
|
|
1596
|
+
if match is None:
|
|
1597
|
+
return None
|
|
1598
|
+
depth = heading.index(" ")
|
|
1599
|
+
after_heading = normalized.find("\n", match.start())
|
|
1600
|
+
body_start = len(normalized) if after_heading == -1 else after_heading + 1
|
|
1601
|
+
next_heading = re.search(rf"(?m)^#{{1,{depth}}}\s", normalized[body_start:])
|
|
1602
|
+
end = len(normalized) if next_heading is None else body_start + next_heading.start()
|
|
1603
|
+
return normalized[match.start():end].rstrip()
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def _has_legacy_project_contracts(source_root: Path, project: Path) -> bool:
|
|
1607
|
+
for name, heading in PROJECT_CONTRACTS:
|
|
1608
|
+
contract = project / name
|
|
1609
|
+
source = source_root / name
|
|
1610
|
+
if (
|
|
1611
|
+
contract.is_symlink()
|
|
1612
|
+
or not contract.is_file()
|
|
1613
|
+
or source.is_symlink()
|
|
1614
|
+
or not source.is_file()
|
|
1615
|
+
):
|
|
1616
|
+
return False
|
|
1617
|
+
installed = contract.read_text(encoding="utf-8", errors="replace")
|
|
1618
|
+
canonical = source.read_text(encoding="utf-8", errors="replace")
|
|
1619
|
+
installed_section = _contract_section(installed, heading)
|
|
1620
|
+
if (
|
|
1621
|
+
installed_section is None
|
|
1622
|
+
or installed_section != _contract_section(canonical, heading)
|
|
1623
|
+
):
|
|
1624
|
+
return False
|
|
1625
|
+
return True
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def _validate_hooks_refresh(
|
|
1629
|
+
source_root: Path,
|
|
1630
|
+
project: Path,
|
|
1631
|
+
full: bool,
|
|
1632
|
+
hooks_dir: Optional[Path],
|
|
1633
|
+
selected: Sequence[str],
|
|
1634
|
+
initialize: bool = False,
|
|
1635
|
+
) -> None:
|
|
1636
|
+
_validate_directory_destination(project, "project path")
|
|
1637
|
+
_validate_project_git_root(project)
|
|
1638
|
+
_validate_directory_destination(project / HOOKS_DIRECTORY, "guard hooks directory")
|
|
1639
|
+
_validate_directory_destination(
|
|
1640
|
+
project / HOOKS_DIRECTORY / "runtime", "project runtime directory"
|
|
1641
|
+
)
|
|
1642
|
+
refresh_guards = _refreshes_guards(project, full, initialize)
|
|
1643
|
+
runtime_exists = any(
|
|
1644
|
+
_lexists(project / HOOKS_DIRECTORY / "runtime" / name)
|
|
1645
|
+
for name in PROJECT_RUNTIME_SCRIPTS
|
|
1646
|
+
)
|
|
1647
|
+
if (
|
|
1648
|
+
not initialize
|
|
1649
|
+
and not refresh_guards
|
|
1650
|
+
and not runtime_exists
|
|
1651
|
+
and not _has_legacy_project_contracts(source_root, project)
|
|
1652
|
+
):
|
|
1653
|
+
raise InstallerError(
|
|
1654
|
+
f"no managed GSD Path hooks or runtime found in project: {project}"
|
|
1655
|
+
)
|
|
1656
|
+
runtime = project / HOOKS_DIRECTORY / "runtime"
|
|
1657
|
+
launcher = project / HOOKS_DIRECTORY / PROJECT_STATUS_LAUNCHER
|
|
1658
|
+
launcher_source = source_root / "scripts" / PROJECT_STATUS_LAUNCHER
|
|
1659
|
+
if launcher.is_symlink() or (
|
|
1660
|
+
_lexists(launcher) and not _is_managed_project_status_launcher(launcher)
|
|
1661
|
+
):
|
|
1662
|
+
raise InstallerError(f"not a managed GSD Path status launcher: {launcher}")
|
|
1663
|
+
if launcher_source.is_symlink() or not launcher_source.is_file():
|
|
1664
|
+
raise InstallerError(
|
|
1665
|
+
f"missing project status launcher source: {launcher_source}"
|
|
1666
|
+
)
|
|
1667
|
+
if runtime.is_dir():
|
|
1668
|
+
unexpected = [
|
|
1669
|
+
entry.name for entry in runtime.iterdir() if entry.name not in PROJECT_RUNTIME_SCRIPTS
|
|
1670
|
+
]
|
|
1671
|
+
if unexpected:
|
|
1672
|
+
raise InstallerError(
|
|
1673
|
+
"unexpected project runtime entries: " + ", ".join(unexpected)
|
|
1674
|
+
)
|
|
1675
|
+
if refresh_guards:
|
|
1676
|
+
for name in GUARD_SCRIPTS:
|
|
1677
|
+
destination = project / HOOKS_DIRECTORY / name
|
|
1678
|
+
exists = _lexists(destination)
|
|
1679
|
+
if destination.is_symlink():
|
|
1680
|
+
raise InstallerError(f"refusing to refresh a symlink: {destination}")
|
|
1681
|
+
if exists and not _is_managed_guard_script(destination):
|
|
1682
|
+
raise InstallerError(
|
|
1683
|
+
f"not a managed GSD Path guard script: {destination}"
|
|
1684
|
+
)
|
|
1685
|
+
source = source_root / "scripts" / name
|
|
1686
|
+
if source.is_symlink() or not source.is_file():
|
|
1687
|
+
raise InstallerError(f"missing guard script source: {source}")
|
|
1688
|
+
for name in PROJECT_RUNTIME_SCRIPTS:
|
|
1689
|
+
destination = project / HOOKS_DIRECTORY / "runtime" / name
|
|
1690
|
+
if destination.is_symlink():
|
|
1691
|
+
raise InstallerError(f"refusing to refresh a symlink: {destination}")
|
|
1692
|
+
if _lexists(destination) and not _is_managed_project_runtime(destination):
|
|
1693
|
+
raise InstallerError(
|
|
1694
|
+
f"not a managed GSD Path project runtime: {destination}"
|
|
1695
|
+
)
|
|
1696
|
+
source = source_root / "scripts" / name
|
|
1697
|
+
if source.is_symlink() or not source.is_file():
|
|
1698
|
+
raise InstallerError(f"missing project runtime source: {source}")
|
|
1699
|
+
if full:
|
|
1700
|
+
for target, label, settings in (
|
|
1701
|
+
("claude", "Claude", project / ".claude" / "settings.json"),
|
|
1702
|
+
("codex", "Codex", project / ".codex" / "hooks.json"),
|
|
1703
|
+
("cursor", "Cursor", project / ".cursor" / "hooks.json"),
|
|
1704
|
+
):
|
|
1705
|
+
exists = _lexists(settings)
|
|
1706
|
+
if initialize and target not in selected:
|
|
1707
|
+
continue
|
|
1708
|
+
if not exists and target not in selected:
|
|
1709
|
+
continue
|
|
1710
|
+
_validate_directory_destination(
|
|
1711
|
+
settings.parent, f"unsafe {label} project directory"
|
|
1712
|
+
)
|
|
1713
|
+
if settings.is_symlink():
|
|
1714
|
+
raise InstallerError(f"refusing to refresh a symlink: {settings}")
|
|
1715
|
+
if exists:
|
|
1716
|
+
parsed = _parsed_managed_settings(settings)
|
|
1717
|
+
if target not in selected and not _has_managed_hook_settings(parsed):
|
|
1718
|
+
raise InstallerError(
|
|
1719
|
+
f"not a managed GSD Path hook settings file: {settings}"
|
|
1720
|
+
)
|
|
1721
|
+
if hooks_dir is not None:
|
|
1722
|
+
for hook_name in GIT_HOOK_NAMES:
|
|
1723
|
+
hook_path = hooks_dir / hook_name
|
|
1724
|
+
if hook_path.is_symlink():
|
|
1725
|
+
raise InstallerError(
|
|
1726
|
+
f"refusing to refresh a symlink: {hook_path}"
|
|
1727
|
+
)
|
|
1728
|
+
if _lexists(hook_path) and not _is_managed_git_hook(hook_path):
|
|
1729
|
+
raise InstallerError(
|
|
1730
|
+
f"not a managed GSD Path git hook: {hook_path}"
|
|
1731
|
+
)
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
def _refresh_hooks_unlocked(
|
|
1735
|
+
source_root: Path,
|
|
1736
|
+
project: Path,
|
|
1737
|
+
full: bool,
|
|
1738
|
+
dry_run: bool = False,
|
|
1739
|
+
selected: Sequence[str] = (),
|
|
1740
|
+
initialize: bool = False,
|
|
1741
|
+
) -> List[str]:
|
|
1742
|
+
"""Refreshed project-relative paths; "note:"-prefixed entries are
|
|
1743
|
+
user-facing notes rather than refreshed files."""
|
|
1744
|
+
hooks_dir = _git_hooks_directory(project) if full else None
|
|
1745
|
+
interpreter: Optional[str] = None
|
|
1746
|
+
if not dry_run:
|
|
1747
|
+
if full:
|
|
1748
|
+
interpreter, hooks_dir = _required_hook_runtime(
|
|
1749
|
+
project,
|
|
1750
|
+
"--hooks-init" if initialize else "--hooks-refresh-full",
|
|
1751
|
+
selected,
|
|
1752
|
+
)
|
|
1753
|
+
else:
|
|
1754
|
+
interpreter = _required_python_runtime("--hooks-refresh", selected)
|
|
1755
|
+
_validate_hooks_refresh(
|
|
1756
|
+
source_root, project, full, hooks_dir, selected, initialize
|
|
1757
|
+
)
|
|
1758
|
+
refreshed: List[str] = []
|
|
1759
|
+
refresh_guards = _refreshes_guards(project, full, initialize)
|
|
1760
|
+
runtime = project / HOOKS_DIRECTORY / "runtime"
|
|
1761
|
+
if not dry_run:
|
|
1762
|
+
runtime.parent.mkdir(parents=True, exist_ok=True)
|
|
1763
|
+
staging = Path(tempfile.mkdtemp(prefix=".runtime-stage-", dir=runtime.parent))
|
|
1764
|
+
previous = staging.with_name(staging.name + "-previous")
|
|
1765
|
+
moved_previous = False
|
|
1766
|
+
published_runtime = False
|
|
1767
|
+
launcher = runtime.parent / PROJECT_STATUS_LAUNCHER
|
|
1768
|
+
launcher_original = launcher.read_bytes() if _lexists(launcher) else None
|
|
1769
|
+
launcher_mode = (
|
|
1770
|
+
launcher.stat().st_mode & 0o777 if launcher_original is not None else None
|
|
1771
|
+
)
|
|
1772
|
+
guard_originals: List[Tuple[Path, Optional[bytes], Optional[int]]] = []
|
|
1773
|
+
try:
|
|
1774
|
+
for name in PROJECT_RUNTIME_SCRIPTS:
|
|
1775
|
+
shutil.copy2(source_root / "scripts" / name, staging / name)
|
|
1776
|
+
_atomic_copy(source_root / "scripts" / PROJECT_STATUS_LAUNCHER, launcher)
|
|
1777
|
+
if _lexists(runtime):
|
|
1778
|
+
os.replace(runtime, previous)
|
|
1779
|
+
moved_previous = True
|
|
1780
|
+
os.replace(staging, runtime)
|
|
1781
|
+
published_runtime = True
|
|
1782
|
+
if refresh_guards:
|
|
1783
|
+
for name in GUARD_SCRIPTS:
|
|
1784
|
+
destination = project / HOOKS_DIRECTORY / name
|
|
1785
|
+
original = destination.read_bytes() if _lexists(destination) else None
|
|
1786
|
+
mode = (
|
|
1787
|
+
destination.stat().st_mode & 0o777
|
|
1788
|
+
if original is not None
|
|
1789
|
+
else None
|
|
1790
|
+
)
|
|
1791
|
+
guard_originals.append((destination, original, mode))
|
|
1792
|
+
_atomic_copy(source_root / "scripts" / name, destination)
|
|
1793
|
+
if moved_previous:
|
|
1794
|
+
_remove_path(previous)
|
|
1795
|
+
except BaseException as error:
|
|
1796
|
+
for destination, original, mode in reversed(guard_originals):
|
|
1797
|
+
if original is None:
|
|
1798
|
+
_remove_path(destination)
|
|
1799
|
+
else:
|
|
1800
|
+
_atomic_write(destination, original, mode)
|
|
1801
|
+
if launcher_original is None:
|
|
1802
|
+
_remove_path(launcher)
|
|
1803
|
+
else:
|
|
1804
|
+
_atomic_write(launcher, launcher_original, launcher_mode)
|
|
1805
|
+
if published_runtime and _lexists(runtime):
|
|
1806
|
+
_remove_path(runtime)
|
|
1807
|
+
if not _lexists(runtime) and moved_previous and _lexists(previous):
|
|
1808
|
+
os.replace(previous, runtime)
|
|
1809
|
+
if isinstance(error, Exception):
|
|
1810
|
+
raise InstallerError(
|
|
1811
|
+
f"project runtime refresh failed: {error}"
|
|
1812
|
+
) from error
|
|
1813
|
+
raise
|
|
1814
|
+
finally:
|
|
1815
|
+
_remove_path(staging)
|
|
1816
|
+
for name in PROJECT_RUNTIME_SCRIPTS:
|
|
1817
|
+
refreshed.append(_describe_project_path(project, runtime / name))
|
|
1818
|
+
refreshed.append(
|
|
1819
|
+
_describe_project_path(
|
|
1820
|
+
project, project / HOOKS_DIRECTORY / PROJECT_STATUS_LAUNCHER
|
|
1821
|
+
)
|
|
1822
|
+
)
|
|
1823
|
+
if refresh_guards:
|
|
1824
|
+
for name in GUARD_SCRIPTS:
|
|
1825
|
+
destination = project / HOOKS_DIRECTORY / name
|
|
1826
|
+
refreshed.append(_describe_project_path(project, destination))
|
|
1827
|
+
if full:
|
|
1828
|
+
for target, settings, merge, generated in (
|
|
1829
|
+
(
|
|
1830
|
+
"claude",
|
|
1831
|
+
project / ".claude" / "settings.json",
|
|
1832
|
+
_merged_claude_settings,
|
|
1833
|
+
claude_hooks_settings,
|
|
1834
|
+
),
|
|
1835
|
+
(
|
|
1836
|
+
"codex",
|
|
1837
|
+
project / ".codex" / "hooks.json",
|
|
1838
|
+
_merged_codex_settings,
|
|
1839
|
+
codex_hooks_settings,
|
|
1840
|
+
),
|
|
1841
|
+
(
|
|
1842
|
+
"cursor",
|
|
1843
|
+
project / ".cursor" / "hooks.json",
|
|
1844
|
+
_merged_cursor_settings,
|
|
1845
|
+
cursor_hooks_settings,
|
|
1846
|
+
),
|
|
1847
|
+
):
|
|
1848
|
+
if initialize and target not in selected:
|
|
1849
|
+
continue
|
|
1850
|
+
exists = _lexists(settings)
|
|
1851
|
+
if exists or target in selected:
|
|
1852
|
+
if not dry_run:
|
|
1853
|
+
settings.parent.mkdir(parents=True, exist_ok=True)
|
|
1854
|
+
content = (
|
|
1855
|
+
merge(settings, interpreter)
|
|
1856
|
+
if exists
|
|
1857
|
+
else generated(interpreter)
|
|
1858
|
+
)
|
|
1859
|
+
_atomic_write(settings, content)
|
|
1860
|
+
refreshed.append(_describe_project_path(project, settings))
|
|
1861
|
+
if hooks_dir is not None:
|
|
1862
|
+
for hook_name, generator in GIT_HOOKS:
|
|
1863
|
+
hook_path = hooks_dir / hook_name
|
|
1864
|
+
if not dry_run:
|
|
1865
|
+
content = generator(interpreter)
|
|
1866
|
+
hook_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1867
|
+
_atomic_write(hook_path, content, mode=0o755)
|
|
1868
|
+
refreshed.append(_describe_project_path(project, hook_path))
|
|
1869
|
+
return refreshed
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
def refresh_hooks(
|
|
1873
|
+
source_root: Path,
|
|
1874
|
+
project: Path,
|
|
1875
|
+
full: bool,
|
|
1876
|
+
dry_run: bool = False,
|
|
1877
|
+
selected: Sequence[str] = (),
|
|
1878
|
+
initialize: bool = False,
|
|
1879
|
+
) -> List[str]:
|
|
1880
|
+
if dry_run:
|
|
1881
|
+
return _refresh_hooks_unlocked(
|
|
1882
|
+
source_root, project, full, True, selected, initialize
|
|
1883
|
+
)
|
|
1884
|
+
_validate_directory_destination(project, "project path")
|
|
1885
|
+
locks, created = _acquire_install_locks([project / HOOKS_DIRECTORY])
|
|
1886
|
+
try:
|
|
1887
|
+
return _refresh_hooks_unlocked(
|
|
1888
|
+
source_root, project, full, False, selected, initialize
|
|
1889
|
+
)
|
|
1890
|
+
finally:
|
|
1891
|
+
_release_install_locks(locks, created)
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
def _comparison_path(path: Path) -> Path:
|
|
1895
|
+
value = os.path.normcase(os.fspath(path.resolve(strict=False)))
|
|
1896
|
+
if sys.platform == "darwin":
|
|
1897
|
+
value = value.casefold()
|
|
1898
|
+
return Path(value)
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
def _paths_overlap(left: Path, right: Path) -> bool:
|
|
1902
|
+
left = _comparison_path(left)
|
|
1903
|
+
right = _comparison_path(right)
|
|
1904
|
+
return left == right or left in right.parents or right in left.parents
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
def _same_path(left: Path, right: Path) -> bool:
|
|
1908
|
+
return _comparison_path(left) == _comparison_path(right)
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
def _deployment_plans(plans: Sequence[TargetPlan]) -> List[DeploymentPlan]:
|
|
1912
|
+
if len({plan.name for plan in plans}) != len(plans):
|
|
1913
|
+
raise InstallerError("each target may be selected only once")
|
|
1914
|
+
unsupported = sorted({plan.name for plan in plans} - set(TARGETS))
|
|
1915
|
+
if unsupported:
|
|
1916
|
+
raise InstallerError("unsupported target: " + ", ".join(unsupported))
|
|
1917
|
+
|
|
1918
|
+
groups: List[List[TargetPlan]] = []
|
|
1919
|
+
for plan in plans:
|
|
1920
|
+
for group in groups:
|
|
1921
|
+
if _same_path(group[0].root, plan.root):
|
|
1922
|
+
group.append(plan)
|
|
1923
|
+
break
|
|
1924
|
+
else:
|
|
1925
|
+
groups.append([plan])
|
|
1926
|
+
|
|
1927
|
+
deployments = []
|
|
1928
|
+
for group in groups:
|
|
1929
|
+
targets = tuple(plan.name for plan in group)
|
|
1930
|
+
target_set = set(targets)
|
|
1931
|
+
if len(group) > 1:
|
|
1932
|
+
if not target_set <= SHARED_AGENT_TARGETS:
|
|
1933
|
+
labels = ", ".join(targets)
|
|
1934
|
+
raise InstallerError(
|
|
1935
|
+
"only Codex, Antigravity, and Zed may share a skills root: "
|
|
1936
|
+
f"{labels}"
|
|
1937
|
+
)
|
|
1938
|
+
profile = SHARED_AGENT_PROFILE
|
|
1939
|
+
elif group[0].name in SHARED_AGENT_TARGETS:
|
|
1940
|
+
profile = SHARED_AGENT_PROFILE
|
|
1941
|
+
elif _same_path(group[0].root, default_root("codex")):
|
|
1942
|
+
raise InstallerError(
|
|
1943
|
+
f"{group[0].name} cannot install a host-specific bundle to the "
|
|
1944
|
+
"shared ~/.agents/skills root"
|
|
1945
|
+
)
|
|
1946
|
+
else:
|
|
1947
|
+
profile = group[0].name
|
|
1948
|
+
deployments.append(DeploymentPlan(profile, group[0].root, targets))
|
|
1949
|
+
return deployments
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
def _validate_distinct_roots(plans: Sequence[DeploymentPlan]) -> None:
|
|
1953
|
+
for index, left in enumerate(plans):
|
|
1954
|
+
for right in plans[index + 1 :]:
|
|
1955
|
+
if _paths_overlap(left.root, right.root):
|
|
1956
|
+
raise InstallerError(
|
|
1957
|
+
"target roots overlap: "
|
|
1958
|
+
f"{'+'.join(left.targets)}={left.root}, "
|
|
1959
|
+
f"{'+'.join(right.targets)}={right.root}"
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1962
|
+
cursor_plans = [plan for plan in plans if plan.profile == "cursor"]
|
|
1963
|
+
for cursor_plan in cursor_plans:
|
|
1964
|
+
agent_root = cursor_plan.root.parent / "agents"
|
|
1965
|
+
if _paths_overlap(agent_root, cursor_plan.root):
|
|
1966
|
+
raise InstallerError(
|
|
1967
|
+
"Cursor skills and agent roots overlap: "
|
|
1968
|
+
f"skills={cursor_plan.root}, agents={agent_root}"
|
|
1969
|
+
)
|
|
1970
|
+
for plan in plans:
|
|
1971
|
+
if plan is cursor_plan:
|
|
1972
|
+
continue
|
|
1973
|
+
if _paths_overlap(agent_root, plan.root):
|
|
1974
|
+
raise InstallerError(
|
|
1975
|
+
"Cursor agent root overlaps a target root: "
|
|
1976
|
+
f"cursor={agent_root}, {'+'.join(plan.targets)}={plan.root}"
|
|
1977
|
+
)
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
def _append_host_notes(results: List[str], selected: Sequence[str]) -> None:
|
|
1981
|
+
for target in TARGETS:
|
|
1982
|
+
if target in selected and target in HOST_NOTES:
|
|
1983
|
+
results.append(HOST_NOTES[target])
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
def _install_result(
|
|
1987
|
+
plan: DeploymentPlan, dry_run: bool = False, update: bool = False
|
|
1988
|
+
) -> str:
|
|
1989
|
+
verb = "update" if update else "install"
|
|
1990
|
+
action = f"would {verb}" if dry_run else ("updated" if update else "installed")
|
|
1991
|
+
label = "+".join(plan.targets)
|
|
1992
|
+
skill_count = len(SKILL_NAMES)
|
|
1993
|
+
if plan.profile == "cursor":
|
|
1994
|
+
agent = plan.root.parent / "agents" / CURSOR_AGENT_FILENAME
|
|
1995
|
+
return (
|
|
1996
|
+
f"{label}: {action} {skill_count} skills to {plan.root} "
|
|
1997
|
+
f"and custom subagent to {agent}"
|
|
1998
|
+
)
|
|
1999
|
+
shared = " shared" if plan.profile == SHARED_AGENT_PROFILE else ""
|
|
2000
|
+
return f"{label}: {action} {skill_count}{shared} skills to {plan.root}"
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def _managed_entry_count(plan: DeploymentPlan) -> int:
|
|
2004
|
+
count = 0
|
|
2005
|
+
if plan.root.is_dir():
|
|
2006
|
+
count = sum(
|
|
2007
|
+
1
|
|
2008
|
+
for entry in plan.root.iterdir()
|
|
2009
|
+
if _is_managed_install_entry(plan.root, entry.name)
|
|
2010
|
+
)
|
|
2011
|
+
if plan.profile == "cursor" and _lexists(
|
|
2012
|
+
plan.root.parent / "agents" / CURSOR_AGENT_FILENAME
|
|
2013
|
+
):
|
|
2014
|
+
count += 1
|
|
2015
|
+
return count
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
def _has_managed_install(root: Path) -> bool:
|
|
2019
|
+
return root.is_dir() and any(
|
|
2020
|
+
_is_managed_install_entry(root, entry.name) for entry in root.iterdir()
|
|
2021
|
+
)
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
STATE_SCHEMA = "gsd-path/state/v1"
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
def _read_package_version(manifest: Path) -> Optional[str]:
|
|
2028
|
+
try:
|
|
2029
|
+
parsed = json.loads(manifest.read_text(encoding="utf-8"))
|
|
2030
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
2031
|
+
return None
|
|
2032
|
+
version = parsed.get("version") if isinstance(parsed, dict) else None
|
|
2033
|
+
return version if isinstance(version, str) and version else None
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
def _validated_project_state(source_root: Path, project: Path) -> dict:
|
|
2037
|
+
validator = source_root / "scripts" / "pipeline_state.py"
|
|
2038
|
+
if validator.is_symlink() or not validator.is_file():
|
|
2039
|
+
raise InstallerError(f"canonical state validator is unavailable: {validator}")
|
|
2040
|
+
interpreter = _required_python_runtime("--doctor")
|
|
2041
|
+
try:
|
|
2042
|
+
result = subprocess.run(
|
|
2043
|
+
[interpreter, "-B", str(validator), "validate", "--repo", str(project)],
|
|
2044
|
+
cwd=project,
|
|
2045
|
+
capture_output=True,
|
|
2046
|
+
text=True,
|
|
2047
|
+
check=False,
|
|
2048
|
+
)
|
|
2049
|
+
except OSError as error:
|
|
2050
|
+
raise InstallerError(f"canonical state validation failed: {error}") from error
|
|
2051
|
+
if result.returncode != 0:
|
|
2052
|
+
detail = result.stderr.strip() or result.stdout.strip() or "unknown failure"
|
|
2053
|
+
raise InstallerError(f"canonical state validation failed: {detail}")
|
|
2054
|
+
try:
|
|
2055
|
+
payload = json.loads(result.stdout)
|
|
2056
|
+
except json.JSONDecodeError as error:
|
|
2057
|
+
raise InstallerError("canonical state validator returned invalid JSON") from error
|
|
2058
|
+
if not isinstance(payload, dict):
|
|
2059
|
+
raise InstallerError("canonical state validator returned an invalid payload")
|
|
2060
|
+
state = payload.get("state")
|
|
2061
|
+
if (
|
|
2062
|
+
payload.get("schema") != STATE_SCHEMA
|
|
2063
|
+
or payload.get("status") != "valid"
|
|
2064
|
+
or not isinstance(state, dict)
|
|
2065
|
+
or not isinstance(state.get("phase"), str)
|
|
2066
|
+
or not isinstance(state.get("status"), str)
|
|
2067
|
+
):
|
|
2068
|
+
raise InstallerError("canonical state validator returned an invalid payload")
|
|
2069
|
+
return state
|
|
2070
|
+
|
|
2071
|
+
|
|
2072
|
+
def _validate_project_runtime_status(source_root: Path, project: Path) -> None:
|
|
2073
|
+
interpreter = _required_python_runtime("--doctor")
|
|
2074
|
+
runtime = source_root / "scripts" / "pipeline_state.py"
|
|
2075
|
+
environment = os.environ.copy()
|
|
2076
|
+
environment["GIT_OPTIONAL_LOCKS"] = "0"
|
|
2077
|
+
try:
|
|
2078
|
+
result = subprocess.run(
|
|
2079
|
+
[interpreter, "-B", str(runtime), "status", "--repo", str(project)],
|
|
2080
|
+
cwd=project,
|
|
2081
|
+
capture_output=True,
|
|
2082
|
+
text=True,
|
|
2083
|
+
check=False,
|
|
2084
|
+
env=environment,
|
|
2085
|
+
)
|
|
2086
|
+
except OSError as error:
|
|
2087
|
+
raise InstallerError(f"project runtime status failed: {error}") from error
|
|
2088
|
+
if result.returncode != 0:
|
|
2089
|
+
detail = result.stderr.strip() or result.stdout.strip() or "unknown failure"
|
|
2090
|
+
raise InstallerError(f"project runtime status failed: {detail}")
|
|
2091
|
+
try:
|
|
2092
|
+
payload = json.loads(result.stdout)
|
|
2093
|
+
except json.JSONDecodeError as error:
|
|
2094
|
+
raise InstallerError("project runtime status returned invalid JSON") from error
|
|
2095
|
+
if not _valid_status_payload(payload, project):
|
|
2096
|
+
raise InstallerError("project runtime status returned an invalid payload")
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def _project_runtime_matches(source_root: Path, project: Path) -> bool:
|
|
2100
|
+
pairs = [
|
|
2101
|
+
(
|
|
2102
|
+
project / HOOKS_DIRECTORY / PROJECT_STATUS_LAUNCHER,
|
|
2103
|
+
source_root / "scripts" / PROJECT_STATUS_LAUNCHER,
|
|
2104
|
+
),
|
|
2105
|
+
*(
|
|
2106
|
+
(
|
|
2107
|
+
project / HOOKS_DIRECTORY / "runtime" / name,
|
|
2108
|
+
source_root / "scripts" / name,
|
|
2109
|
+
)
|
|
2110
|
+
for name in PROJECT_RUNTIME_SCRIPTS
|
|
2111
|
+
),
|
|
2112
|
+
]
|
|
2113
|
+
try:
|
|
2114
|
+
return all(
|
|
2115
|
+
not destination.is_symlink()
|
|
2116
|
+
and destination.is_file()
|
|
2117
|
+
and destination.read_bytes() == source.read_bytes()
|
|
2118
|
+
for destination, source in pairs
|
|
2119
|
+
)
|
|
2120
|
+
except OSError:
|
|
2121
|
+
return False
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def _valid_status_payload(payload: object, project: Path) -> bool:
|
|
2125
|
+
if not isinstance(payload, dict) or payload.get("schema") != "gsd-path/status/v1":
|
|
2126
|
+
return False
|
|
2127
|
+
state = payload.get("state")
|
|
2128
|
+
route = payload.get("route")
|
|
2129
|
+
status_path = payload.get("path")
|
|
2130
|
+
if (
|
|
2131
|
+
payload.get("advance") is not False
|
|
2132
|
+
or not isinstance(state, dict)
|
|
2133
|
+
or not _valid_status_state(state)
|
|
2134
|
+
or not isinstance(route, dict)
|
|
2135
|
+
or route.get("action") not in STATUS_ACTIONS
|
|
2136
|
+
or not isinstance(route.get("reason"), str)
|
|
2137
|
+
or not route["reason"]
|
|
2138
|
+
or not isinstance(status_path, str)
|
|
2139
|
+
or not Path(status_path).is_absolute()
|
|
2140
|
+
or not _same_path(Path(status_path), project / ".project" / "STATE.md")
|
|
2141
|
+
):
|
|
2142
|
+
return False
|
|
2143
|
+
action = route["action"]
|
|
2144
|
+
if action == "run-phase":
|
|
2145
|
+
phase = route.get("phase")
|
|
2146
|
+
return (
|
|
2147
|
+
isinstance(phase, str)
|
|
2148
|
+
and phase in STATUS_TRANSITIONS[state["phase"]]
|
|
2149
|
+
and state["branch"] is not None
|
|
2150
|
+
and payload.get("next_skill") == f"gsd-path-{phase}"
|
|
2151
|
+
)
|
|
2152
|
+
if "phase" in route:
|
|
2153
|
+
return False
|
|
2154
|
+
if action == "bind-initial":
|
|
2155
|
+
branch = route.get("branch")
|
|
2156
|
+
return (
|
|
2157
|
+
state["branch"] is None
|
|
2158
|
+
and isinstance(branch, str)
|
|
2159
|
+
and _valid_status_branch(branch) is not None
|
|
2160
|
+
and payload.get("next_skill") == "gsd-path"
|
|
2161
|
+
)
|
|
2162
|
+
if state["branch"] is None:
|
|
2163
|
+
return False
|
|
2164
|
+
if action == "validate-integrated" and not (
|
|
2165
|
+
state["phase"] == "shipped" and state["status"] == "done"
|
|
2166
|
+
):
|
|
2167
|
+
return False
|
|
2168
|
+
if action == "wait" and not (
|
|
2169
|
+
state["phase"] == "plan" and state["status"] == "done"
|
|
2170
|
+
):
|
|
2171
|
+
return False
|
|
2172
|
+
expected = "gsd-path-undo" if action == "resume-undo" else None
|
|
2173
|
+
if action not in {"resume-undo", "wait"}:
|
|
2174
|
+
expected = "gsd-path"
|
|
2175
|
+
return payload.get("next_skill") == expected
|
|
2176
|
+
|
|
2177
|
+
|
|
2178
|
+
def _valid_status_state(state: dict) -> bool:
|
|
2179
|
+
if set(state) != STATUS_STATE_FIELDS:
|
|
2180
|
+
return False
|
|
2181
|
+
milestone = state["milestone"]
|
|
2182
|
+
branch = state["branch"]
|
|
2183
|
+
archive = state["archive"]
|
|
2184
|
+
integration_default = state["integration_default"]
|
|
2185
|
+
integration = state["integration"]
|
|
2186
|
+
integration_source = state["integration_source"]
|
|
2187
|
+
archive_match = (
|
|
2188
|
+
STATUS_ARCHIVE.fullmatch(archive or "")
|
|
2189
|
+
if isinstance(archive, (str, type(None)))
|
|
2190
|
+
else None
|
|
2191
|
+
)
|
|
2192
|
+
if (
|
|
2193
|
+
state["pipeline"] != "gsd-path/v2"
|
|
2194
|
+
or not isinstance(state["project"], str)
|
|
2195
|
+
or STATUS_SLUG.fullmatch(state["project"]) is None
|
|
2196
|
+
or not (
|
|
2197
|
+
milestone is None
|
|
2198
|
+
or isinstance(milestone, str)
|
|
2199
|
+
and STATUS_SLUG.fullmatch(milestone) is not None
|
|
2200
|
+
)
|
|
2201
|
+
or not isinstance(state["phase"], str)
|
|
2202
|
+
or state["phase"] not in STATUS_PHASES
|
|
2203
|
+
or not isinstance(state["status"], str)
|
|
2204
|
+
or state["status"] not in STATUS_VALUES
|
|
2205
|
+
or integration_default not in STATUS_INTEGRATION_MODES
|
|
2206
|
+
or integration not in STATUS_INTEGRATION_MODES
|
|
2207
|
+
or integration_source not in STATUS_INTEGRATION_SOURCES
|
|
2208
|
+
or (
|
|
2209
|
+
integration_source == "default"
|
|
2210
|
+
and integration != integration_default
|
|
2211
|
+
)
|
|
2212
|
+
or not (branch is None or _valid_status_branch(branch) is not None)
|
|
2213
|
+
or not (archive is None or archive_match is not None)
|
|
2214
|
+
):
|
|
2215
|
+
return False
|
|
2216
|
+
if state["phase"] == "shipped" and (state["status"] != "done" or archive is None):
|
|
2217
|
+
return False
|
|
2218
|
+
if archive is not None and state["phase"] not in {"build", "ship", "shipped"}:
|
|
2219
|
+
return False
|
|
2220
|
+
if state["phase"] in {"ship", "shipped"} and branch is None:
|
|
2221
|
+
return False
|
|
2222
|
+
if archive_match is not None:
|
|
2223
|
+
branch_match = _valid_status_branch(branch)
|
|
2224
|
+
return (
|
|
2225
|
+
milestone == archive_match.group(2)
|
|
2226
|
+
and branch_match is not None
|
|
2227
|
+
and int(branch_match.group(1)) == int(archive_match.group(1))
|
|
2228
|
+
)
|
|
2229
|
+
return True
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
def _valid_status_branch(value: object) -> Optional[re.Match[str]]:
|
|
2233
|
+
match = STATUS_BRANCH.fullmatch(value) if isinstance(value, str) else None
|
|
2234
|
+
return match if match is not None and int(match.group(1)) >= 1 else None
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
def _is_executable(path: Path) -> bool:
|
|
2238
|
+
try:
|
|
2239
|
+
return bool(path.stat().st_mode & 0o111)
|
|
2240
|
+
except OSError:
|
|
2241
|
+
return False
|
|
2242
|
+
|
|
2243
|
+
|
|
2244
|
+
def _native_guard_contract(
|
|
2245
|
+
target: str, project: Path
|
|
2246
|
+
) -> Optional[Tuple[Path, str, Callable[[str], dict]]]:
|
|
2247
|
+
if target == "claude":
|
|
2248
|
+
return project / ".claude" / "settings.json", "PreToolUse", _claude_guard_entry
|
|
2249
|
+
if target == "codex":
|
|
2250
|
+
return project / ".codex" / "hooks.json", "PreToolUse", _codex_guard_entry
|
|
2251
|
+
if target == "cursor":
|
|
2252
|
+
return project / ".cursor" / "hooks.json", "preToolUse", _cursor_guard_entry
|
|
2253
|
+
return None
|
|
2254
|
+
|
|
2255
|
+
|
|
2256
|
+
def doctor(
|
|
2257
|
+
source_root: Path,
|
|
2258
|
+
targets: Sequence[str],
|
|
2259
|
+
root_for: Callable[[str], Path],
|
|
2260
|
+
project: Optional[Path] = None,
|
|
2261
|
+
) -> List[dict]:
|
|
2262
|
+
findings: List[dict] = []
|
|
2263
|
+
|
|
2264
|
+
def push(level: str, text: str) -> None:
|
|
2265
|
+
findings.append({"level": level, "text": text})
|
|
2266
|
+
|
|
2267
|
+
def read_project_file(path: Path, label: str) -> Optional[bytes]:
|
|
2268
|
+
try:
|
|
2269
|
+
return path.read_bytes()
|
|
2270
|
+
except OSError as error:
|
|
2271
|
+
push("fail", f"{label} cannot be read: {error}")
|
|
2272
|
+
return None
|
|
2273
|
+
|
|
2274
|
+
version = _read_package_version(source_root / "package.json")
|
|
2275
|
+
if version is None:
|
|
2276
|
+
push("fail", "package: version cannot be read")
|
|
2277
|
+
seen: List[Tuple[str, Path]] = []
|
|
2278
|
+
installed_targets = set()
|
|
2279
|
+
for target in targets:
|
|
2280
|
+
root = root_for(target)
|
|
2281
|
+
prior = next(
|
|
2282
|
+
(name for name, other in seen if _same_path(other, root)), None
|
|
2283
|
+
)
|
|
2284
|
+
if prior is not None:
|
|
2285
|
+
push("note", f"{target}: shares {prior}'s skills root")
|
|
2286
|
+
continue
|
|
2287
|
+
seen.append((target, root))
|
|
2288
|
+
try:
|
|
2289
|
+
managed_install = _has_managed_install(root)
|
|
2290
|
+
except OSError as error:
|
|
2291
|
+
push("fail", f"{target}: skills root cannot be read: {error}")
|
|
2292
|
+
continue
|
|
2293
|
+
if not managed_install:
|
|
2294
|
+
push("note", f"{target}: not installed ({root})")
|
|
2295
|
+
continue
|
|
2296
|
+
installed_targets.add(target)
|
|
2297
|
+
missing = [name for name in SKILL_NAMES if not (root / name).is_dir()]
|
|
2298
|
+
if missing:
|
|
2299
|
+
push(
|
|
2300
|
+
"fail",
|
|
2301
|
+
f"{target}: incomplete install at {root} — missing {', '.join(missing)}",
|
|
2302
|
+
)
|
|
2303
|
+
continue
|
|
2304
|
+
try:
|
|
2305
|
+
stamp = (root / "gsd-path" / "VERSION").read_text(
|
|
2306
|
+
encoding="utf-8"
|
|
2307
|
+
).strip()
|
|
2308
|
+
except (OSError, UnicodeError):
|
|
2309
|
+
stamp = ""
|
|
2310
|
+
if not stamp:
|
|
2311
|
+
push(
|
|
2312
|
+
"warn",
|
|
2313
|
+
f"{target}: {len(SKILL_NAMES)} skills at {root}, no VERSION stamp — run --update",
|
|
2314
|
+
)
|
|
2315
|
+
elif version and stamp != version:
|
|
2316
|
+
push(
|
|
2317
|
+
"warn",
|
|
2318
|
+
f"{target}: stale install at {root} (v{stamp}, current v{version}) — run --update",
|
|
2319
|
+
)
|
|
2320
|
+
else:
|
|
2321
|
+
push("ok", f"{target}: {len(SKILL_NAMES)} skills at {root} (v{stamp})")
|
|
2322
|
+
|
|
2323
|
+
if project is None:
|
|
2324
|
+
return findings
|
|
2325
|
+
|
|
2326
|
+
for name, heading in PROJECT_CONTRACTS:
|
|
2327
|
+
contract = project / name
|
|
2328
|
+
if contract.is_symlink():
|
|
2329
|
+
push("fail", f"project: contract {name} is a symlink")
|
|
2330
|
+
continue
|
|
2331
|
+
if not contract.is_file():
|
|
2332
|
+
push("fail", f'project: missing contract {name} — run --project "{project}"')
|
|
2333
|
+
continue
|
|
2334
|
+
content = read_project_file(contract, f"project: {name}")
|
|
2335
|
+
if content is None:
|
|
2336
|
+
continue
|
|
2337
|
+
canonical = read_project_file(source_root / name, f"package: {name}")
|
|
2338
|
+
if canonical is None:
|
|
2339
|
+
continue
|
|
2340
|
+
installed_section = _contract_section(
|
|
2341
|
+
content.decode("utf-8", errors="replace"), heading
|
|
2342
|
+
)
|
|
2343
|
+
canonical_section = _contract_section(
|
|
2344
|
+
canonical.decode("utf-8", errors="replace"), heading
|
|
2345
|
+
)
|
|
2346
|
+
if installed_section is not None and installed_section == canonical_section:
|
|
2347
|
+
push("ok", f"project: {name} present")
|
|
2348
|
+
else:
|
|
2349
|
+
push(
|
|
2350
|
+
"fail",
|
|
2351
|
+
f"project: {name} lacks plain-prompt re-entry — merge the current contract",
|
|
2352
|
+
)
|
|
2353
|
+
|
|
2354
|
+
bridge = project / ".claude" / "CLAUDE.md"
|
|
2355
|
+
if not bridge.is_file():
|
|
2356
|
+
push(
|
|
2357
|
+
"note",
|
|
2358
|
+
"project: no .claude/CLAUDE.md bridge (only written for --claude installs)",
|
|
2359
|
+
)
|
|
2360
|
+
else:
|
|
2361
|
+
content = read_project_file(bridge, "project: .claude/CLAUDE.md")
|
|
2362
|
+
if content is not None:
|
|
2363
|
+
if content == CLAUDE_BRIDGE.encode():
|
|
2364
|
+
push("ok", "project: .claude/CLAUDE.md bridge present")
|
|
2365
|
+
else:
|
|
2366
|
+
push(
|
|
2367
|
+
"note",
|
|
2368
|
+
"project: .claude/CLAUDE.md exists but is not the managed bridge",
|
|
2369
|
+
)
|
|
2370
|
+
|
|
2371
|
+
runtime = project / HOOKS_DIRECTORY / "runtime"
|
|
2372
|
+
launcher = project / HOOKS_DIRECTORY / PROJECT_STATUS_LAUNCHER
|
|
2373
|
+
runtime_current = True
|
|
2374
|
+
if launcher.is_symlink():
|
|
2375
|
+
push("fail", "project: status launcher is a symlink")
|
|
2376
|
+
runtime_current = False
|
|
2377
|
+
elif not launcher.is_file():
|
|
2378
|
+
push("fail", "project: missing status launcher")
|
|
2379
|
+
runtime_current = False
|
|
2380
|
+
else:
|
|
2381
|
+
launcher_content = read_project_file(launcher, "project: status launcher")
|
|
2382
|
+
launcher_source = read_project_file(
|
|
2383
|
+
source_root / "scripts" / PROJECT_STATUS_LAUNCHER,
|
|
2384
|
+
"package: status launcher",
|
|
2385
|
+
)
|
|
2386
|
+
if launcher_content is None or launcher_source is None:
|
|
2387
|
+
runtime_current = False
|
|
2388
|
+
else:
|
|
2389
|
+
if PROJECT_STATUS_MARKER.encode() not in launcher_content:
|
|
2390
|
+
push("warn", "project: status launcher is not managed")
|
|
2391
|
+
runtime_current = False
|
|
2392
|
+
elif launcher_content != launcher_source:
|
|
2393
|
+
push(
|
|
2394
|
+
"warn",
|
|
2395
|
+
"project: status launcher is stale — refresh it with the project contracts",
|
|
2396
|
+
)
|
|
2397
|
+
runtime_current = False
|
|
2398
|
+
else:
|
|
2399
|
+
push("ok", "project: status launcher current")
|
|
2400
|
+
try:
|
|
2401
|
+
_validate_directory_destination(
|
|
2402
|
+
project / HOOKS_DIRECTORY, "project runtime parent directory"
|
|
2403
|
+
)
|
|
2404
|
+
_validate_directory_destination(runtime, "project runtime directory")
|
|
2405
|
+
except InstallerError as error:
|
|
2406
|
+
push("fail", f"project: unsafe runtime — {error}")
|
|
2407
|
+
runtime_current = False
|
|
2408
|
+
else:
|
|
2409
|
+
for name in PROJECT_RUNTIME_SCRIPTS:
|
|
2410
|
+
destination = runtime / name
|
|
2411
|
+
if destination.is_symlink():
|
|
2412
|
+
push("fail", f"project: runtime {name} is a symlink")
|
|
2413
|
+
runtime_current = False
|
|
2414
|
+
continue
|
|
2415
|
+
if not destination.is_file():
|
|
2416
|
+
push("fail", f"project: missing runtime {name}")
|
|
2417
|
+
runtime_current = False
|
|
2418
|
+
continue
|
|
2419
|
+
content = read_project_file(destination, f"project: runtime {name}")
|
|
2420
|
+
if content is None:
|
|
2421
|
+
runtime_current = False
|
|
2422
|
+
continue
|
|
2423
|
+
source = read_project_file(
|
|
2424
|
+
source_root / "scripts" / name, f"package: runtime {name}"
|
|
2425
|
+
)
|
|
2426
|
+
if source is None:
|
|
2427
|
+
runtime_current = False
|
|
2428
|
+
continue
|
|
2429
|
+
if PROJECT_RUNTIME_MARKER.encode() not in content:
|
|
2430
|
+
push("warn", f"project: runtime {name} is not managed")
|
|
2431
|
+
runtime_current = False
|
|
2432
|
+
elif content != source:
|
|
2433
|
+
push(
|
|
2434
|
+
"warn",
|
|
2435
|
+
f"project: runtime {name} is stale — refresh it with the project contracts",
|
|
2436
|
+
)
|
|
2437
|
+
runtime_current = False
|
|
2438
|
+
else:
|
|
2439
|
+
push("ok", f"project: runtime {name} current")
|
|
2440
|
+
|
|
2441
|
+
guard_installed = any(
|
|
2442
|
+
_lexists(project / HOOKS_DIRECTORY / name) for name in GUARD_SCRIPTS
|
|
2443
|
+
)
|
|
2444
|
+
native_wired_targets = set()
|
|
2445
|
+
for target in targets:
|
|
2446
|
+
contract = _native_guard_contract(target, project)
|
|
2447
|
+
if contract is not None and (
|
|
2448
|
+
contract[0].is_symlink()
|
|
2449
|
+
or _is_managed_hook_settings(contract[0])
|
|
2450
|
+
):
|
|
2451
|
+
native_wired_targets.add(target)
|
|
2452
|
+
guard_wired = bool(native_wired_targets)
|
|
2453
|
+
unreadable_git_hooks = {}
|
|
2454
|
+
if _lexists(project / ".git"):
|
|
2455
|
+
hooks_dir = _git_hooks_directory(project)
|
|
2456
|
+
if hooks_dir is not None:
|
|
2457
|
+
for name in GIT_HOOK_NAMES:
|
|
2458
|
+
hook_path = hooks_dir / name
|
|
2459
|
+
if not _lexists(hook_path):
|
|
2460
|
+
continue
|
|
2461
|
+
if hook_path.is_symlink():
|
|
2462
|
+
guard_wired = True
|
|
2463
|
+
continue
|
|
2464
|
+
try:
|
|
2465
|
+
hook_text = hook_path.read_text(
|
|
2466
|
+
encoding="utf-8", errors="replace"
|
|
2467
|
+
)
|
|
2468
|
+
except OSError as error:
|
|
2469
|
+
unreadable_git_hooks[hook_path] = error
|
|
2470
|
+
else:
|
|
2471
|
+
guard_wired = (
|
|
2472
|
+
_is_managed_git_hook_content(hook_text) or guard_wired
|
|
2473
|
+
)
|
|
2474
|
+
for hook_path, error in unreadable_git_hooks.items():
|
|
2475
|
+
label = _describe_project_path(project, hook_path)
|
|
2476
|
+
push("fail", f"hooks: {label} cannot be read: {error}")
|
|
2477
|
+
guard_installed = guard_installed or guard_wired
|
|
2478
|
+
if not guard_installed:
|
|
2479
|
+
push("note", "hooks: guard hooks not installed (opt in with --hooks; see HOOKS.md)")
|
|
2480
|
+
else:
|
|
2481
|
+
for name in GUARD_SCRIPTS:
|
|
2482
|
+
destination = project / HOOKS_DIRECTORY / name
|
|
2483
|
+
if destination.is_symlink():
|
|
2484
|
+
push("fail", f"hooks: {HOOKS_DIRECTORY}/{name} is a symlink")
|
|
2485
|
+
continue
|
|
2486
|
+
if not destination.is_file():
|
|
2487
|
+
push("fail", f"hooks: missing {HOOKS_DIRECTORY}/{name} — run --hooks-refresh")
|
|
2488
|
+
continue
|
|
2489
|
+
content = read_project_file(destination, f"hooks: {HOOKS_DIRECTORY}/{name}")
|
|
2490
|
+
if content is None:
|
|
2491
|
+
continue
|
|
2492
|
+
source = read_project_file(
|
|
2493
|
+
source_root / "scripts" / name, f"package: guard {name}"
|
|
2494
|
+
)
|
|
2495
|
+
if source is None:
|
|
2496
|
+
continue
|
|
2497
|
+
if GUARD_MARKER.encode() not in content:
|
|
2498
|
+
push("warn", f"hooks: {HOOKS_DIRECTORY}/{name} is not a managed guard script")
|
|
2499
|
+
elif content != source:
|
|
2500
|
+
push("warn", f"hooks: {HOOKS_DIRECTORY}/{name} is stale — run --hooks-refresh")
|
|
2501
|
+
else:
|
|
2502
|
+
push("ok", f"hooks: {HOOKS_DIRECTORY}/{name} current")
|
|
2503
|
+
|
|
2504
|
+
hosts = sync_skill_resources.RESOURCE_MANIFEST["hosts"]
|
|
2505
|
+
for target in targets:
|
|
2506
|
+
if target not in installed_targets and target not in native_wired_targets:
|
|
2507
|
+
continue
|
|
2508
|
+
contract = _native_guard_contract(target, project)
|
|
2509
|
+
if contract is None:
|
|
2510
|
+
if hosts[target].get("guard_tier") != "git-only":
|
|
2511
|
+
push("fail", f"hooks: {target} declares a native guard without a health contract")
|
|
2512
|
+
continue
|
|
2513
|
+
settings, event, entry = contract
|
|
2514
|
+
if settings.is_symlink():
|
|
2515
|
+
push("fail", f"hooks: {target} native guard wiring is a symlink")
|
|
2516
|
+
continue
|
|
2517
|
+
if not settings.is_file():
|
|
2518
|
+
push("fail", f"hooks: {target} native guard wiring is missing — run --hooks-refresh-full")
|
|
2519
|
+
continue
|
|
2520
|
+
try:
|
|
2521
|
+
parsed = json.loads(settings.read_text(encoding="utf-8"))
|
|
2522
|
+
entries = parsed.get("hooks", {}).get(event)
|
|
2523
|
+
variants = [entry(candidate) for candidate in ("python3", "python")]
|
|
2524
|
+
current = isinstance(entries, list) and any(
|
|
2525
|
+
candidate in variants for candidate in entries
|
|
2526
|
+
)
|
|
2527
|
+
except (OSError, UnicodeError, json.JSONDecodeError, AttributeError):
|
|
2528
|
+
current = False
|
|
2529
|
+
if current:
|
|
2530
|
+
push("ok", f"hooks: {target} native guard wiring present")
|
|
2531
|
+
else:
|
|
2532
|
+
push("fail", f"hooks: {target} native guard wiring is stale — run --hooks-refresh-full")
|
|
2533
|
+
|
|
2534
|
+
dot_git = project / ".git"
|
|
2535
|
+
if _lexists(dot_git):
|
|
2536
|
+
hooks_dir = _git_hooks_directory(project)
|
|
2537
|
+
if hooks_dir is None:
|
|
2538
|
+
push(
|
|
2539
|
+
"fail",
|
|
2540
|
+
"hooks: cannot resolve the git hooks directory (is git runnable?); git hooks unverified",
|
|
2541
|
+
)
|
|
2542
|
+
else:
|
|
2543
|
+
custom = not _same_path(hooks_dir, dot_git / "hooks")
|
|
2544
|
+
missing_from_custom = False
|
|
2545
|
+
for hook_name, generator in GIT_HOOKS:
|
|
2546
|
+
hook_path = hooks_dir / hook_name
|
|
2547
|
+
label = _describe_project_path(project, hook_path)
|
|
2548
|
+
if hook_path in unreadable_git_hooks:
|
|
2549
|
+
continue
|
|
2550
|
+
if not _lexists(hook_path):
|
|
2551
|
+
push(
|
|
2552
|
+
"fail",
|
|
2553
|
+
f"hooks: {hook_name} is missing from the effective git hooks directory "
|
|
2554
|
+
f"{hooks_dir} — run --hooks-refresh-full",
|
|
2555
|
+
)
|
|
2556
|
+
if custom:
|
|
2557
|
+
missing_from_custom = True
|
|
2558
|
+
elif hook_path.is_symlink():
|
|
2559
|
+
push("fail", f"hooks: {label} is a symlink")
|
|
2560
|
+
else:
|
|
2561
|
+
content = read_project_file(hook_path, f"hooks: {label}")
|
|
2562
|
+
if content is None:
|
|
2563
|
+
continue
|
|
2564
|
+
hook_text = content.decode("utf-8", errors="replace")
|
|
2565
|
+
if not _is_managed_git_hook_content(hook_text):
|
|
2566
|
+
push("warn", f"hooks: {label} is not a managed GSD Path git hook")
|
|
2567
|
+
elif not any(
|
|
2568
|
+
hook_text == generator(candidate)
|
|
2569
|
+
for candidate in ("python3", "python")
|
|
2570
|
+
):
|
|
2571
|
+
push("warn", f"hooks: {label} is stale — run --hooks-refresh-full")
|
|
2572
|
+
elif not _is_executable(hook_path):
|
|
2573
|
+
push(
|
|
2574
|
+
"warn",
|
|
2575
|
+
f"hooks: {label} is not executable — run --hooks-refresh-full",
|
|
2576
|
+
)
|
|
2577
|
+
else:
|
|
2578
|
+
push("ok", f"hooks: {label} wired")
|
|
2579
|
+
if missing_from_custom:
|
|
2580
|
+
push(
|
|
2581
|
+
"warn",
|
|
2582
|
+
f"hooks: core.hooksPath points this repository at {hooks_dir}, "
|
|
2583
|
+
"but the guard hooks are not wired there",
|
|
2584
|
+
)
|
|
2585
|
+
|
|
2586
|
+
project_state = project / ".project"
|
|
2587
|
+
state_file = project_state / "STATE.md"
|
|
2588
|
+
if not project_state.is_dir():
|
|
2589
|
+
push("note", "state: no .project/ pipeline state (nothing started yet)")
|
|
2590
|
+
elif not state_file.is_file():
|
|
2591
|
+
push("warn", "state: .project/ exists but STATE.md is missing")
|
|
2592
|
+
else:
|
|
2593
|
+
try:
|
|
2594
|
+
state = _validated_project_state(source_root, project)
|
|
2595
|
+
if not runtime_current:
|
|
2596
|
+
raise InstallerError(
|
|
2597
|
+
"project runtime status was not executed because the installed runtime is not current"
|
|
2598
|
+
)
|
|
2599
|
+
_validate_project_runtime_status(source_root, project)
|
|
2600
|
+
if not _project_runtime_matches(source_root, project):
|
|
2601
|
+
raise InstallerError("project runtime changed during doctor validation")
|
|
2602
|
+
except InstallerError as error:
|
|
2603
|
+
push("fail", f"state: {error}")
|
|
2604
|
+
else:
|
|
2605
|
+
push("ok", f"state: {state['phase']}/{state['status']}")
|
|
2606
|
+
return findings
|
|
2607
|
+
|
|
2608
|
+
|
|
2609
|
+
def detect_installs(
|
|
2610
|
+
targets: Sequence[str], roots: Mapping[str, Path]
|
|
2611
|
+
) -> List[TargetPlan]:
|
|
2612
|
+
return [
|
|
2613
|
+
TargetPlan(target, roots[target])
|
|
2614
|
+
for target in targets
|
|
2615
|
+
if _has_managed_install(roots[target])
|
|
2616
|
+
]
|
|
2617
|
+
|
|
2618
|
+
|
|
2619
|
+
def _planned_backup_roots(
|
|
2620
|
+
legacy_root: Optional[Path], deployments: Sequence[DeploymentPlan]
|
|
2621
|
+
) -> List[Tuple[str, Path]]:
|
|
2622
|
+
transactions = []
|
|
2623
|
+
if legacy_root is not None and legacy_root.is_dir() and any(
|
|
2624
|
+
_is_managed_name(entry.name) for entry in legacy_root.iterdir()
|
|
2625
|
+
):
|
|
2626
|
+
transactions.append(("Codex legacy backup", legacy_root))
|
|
2627
|
+
transactions.extend(
|
|
2628
|
+
(f"{'+'.join(plan.targets)} backup", plan.root)
|
|
2629
|
+
for plan in deployments
|
|
2630
|
+
if _managed_entry_count(plan)
|
|
2631
|
+
)
|
|
2632
|
+
|
|
2633
|
+
planned = []
|
|
2634
|
+
reserved = []
|
|
2635
|
+
for label, root in transactions:
|
|
2636
|
+
backup = _backup_path(root, reserved)
|
|
2637
|
+
reserved.append(backup)
|
|
2638
|
+
planned.append((label, backup))
|
|
2639
|
+
return planned
|
|
2640
|
+
|
|
2641
|
+
|
|
2642
|
+
def install(
|
|
2643
|
+
source_root: Path,
|
|
2644
|
+
plans: Sequence[TargetPlan],
|
|
2645
|
+
project: Optional[Path] = None,
|
|
2646
|
+
dry_run: bool = False,
|
|
2647
|
+
hooks: bool = False,
|
|
2648
|
+
migrate_legacy: bool = True,
|
|
2649
|
+
update: bool = False,
|
|
2650
|
+
) -> List[str]:
|
|
2651
|
+
if hooks and project is None:
|
|
2652
|
+
raise InstallerError("--hooks requires --project")
|
|
2653
|
+
selected = [plan.name for plan in plans]
|
|
2654
|
+
interpreter = "python3"
|
|
2655
|
+
hooks_dir: Optional[Path] = None
|
|
2656
|
+
if project is not None and not hooks and not dry_run:
|
|
2657
|
+
interpreter = _required_python_runtime("--project", selected)
|
|
2658
|
+
elif hooks:
|
|
2659
|
+
interpreter, hooks_dir = _required_hook_runtime(project, "--hooks", selected)
|
|
2660
|
+
deployments = _deployment_plans(plans)
|
|
2661
|
+
adapters = list(dict.fromkeys(deployment.profile for deployment in deployments))
|
|
2662
|
+
validate_source(source_root, adapters)
|
|
2663
|
+
_validate_distinct_roots(deployments)
|
|
2664
|
+
for plan in plans:
|
|
2665
|
+
_validate_directory_destination(plan.root, f"{plan.name} skills root")
|
|
2666
|
+
if _paths_overlap(plan.root, source_root):
|
|
2667
|
+
raise InstallerError(
|
|
2668
|
+
f"{plan.name} skills root overlaps source repository: {plan.root}"
|
|
2669
|
+
)
|
|
2670
|
+
for deployment in deployments:
|
|
2671
|
+
if deployment.profile != "cursor":
|
|
2672
|
+
continue
|
|
2673
|
+
cursor_agent_root = deployment.root.parent / "agents"
|
|
2674
|
+
_validate_directory_destination(cursor_agent_root, "Cursor agent root")
|
|
2675
|
+
if _paths_overlap(cursor_agent_root, source_root):
|
|
2676
|
+
raise InstallerError(
|
|
2677
|
+
f"Cursor agent root overlaps source repository: {cursor_agent_root}"
|
|
2678
|
+
)
|
|
2679
|
+
legacy_root = (
|
|
2680
|
+
legacy_codex_root() if migrate_legacy and "codex" in selected else None
|
|
2681
|
+
)
|
|
2682
|
+
reserved_roots = [
|
|
2683
|
+
(f"{'+'.join(plan.targets)} skills root", plan.root) for plan in deployments
|
|
2684
|
+
]
|
|
2685
|
+
reserved_roots.extend(
|
|
2686
|
+
("Cursor agent root", plan.root.parent / "agents")
|
|
2687
|
+
for plan in deployments
|
|
2688
|
+
if plan.profile == "cursor"
|
|
2689
|
+
)
|
|
2690
|
+
if legacy_root is not None:
|
|
2691
|
+
codex_roots = [
|
|
2692
|
+
plan.root for plan in deployments if "codex" in plan.targets
|
|
2693
|
+
]
|
|
2694
|
+
if any(_same_path(legacy_root, root) for root in codex_roots):
|
|
2695
|
+
legacy_root = None
|
|
2696
|
+
elif _paths_overlap(legacy_root, source_root):
|
|
2697
|
+
raise InstallerError(
|
|
2698
|
+
f"Codex legacy root overlaps source repository: {legacy_root}"
|
|
2699
|
+
)
|
|
2700
|
+
else:
|
|
2701
|
+
for label, root in reserved_roots:
|
|
2702
|
+
if _paths_overlap(legacy_root, root):
|
|
2703
|
+
raise InstallerError(
|
|
2704
|
+
f"Codex legacy root overlaps {label}: {legacy_root}, {root}"
|
|
2705
|
+
)
|
|
2706
|
+
if legacy_root is not None:
|
|
2707
|
+
_validate_directory_destination(legacy_root, "Codex legacy skills root")
|
|
2708
|
+
|
|
2709
|
+
planned_backups = _planned_backup_roots(legacy_root, deployments)
|
|
2710
|
+
mutation_roots = list(reserved_roots)
|
|
2711
|
+
if legacy_root is not None:
|
|
2712
|
+
mutation_roots.append(("Codex legacy skills root", legacy_root))
|
|
2713
|
+
for backup_label, backup in planned_backups:
|
|
2714
|
+
for root_label, root in mutation_roots:
|
|
2715
|
+
if _paths_overlap(backup, root):
|
|
2716
|
+
raise InstallerError(
|
|
2717
|
+
f"{backup_label} overlaps {root_label}: {backup}, {root}"
|
|
2718
|
+
)
|
|
2719
|
+
|
|
2720
|
+
if project is not None:
|
|
2721
|
+
_validate_directory_destination(project, "project path")
|
|
2722
|
+
if dry_run:
|
|
2723
|
+
_validate_project(
|
|
2724
|
+
source_root,
|
|
2725
|
+
project,
|
|
2726
|
+
selected,
|
|
2727
|
+
hooks,
|
|
2728
|
+
[*mutation_roots, *planned_backups],
|
|
2729
|
+
interpreter,
|
|
2730
|
+
hooks_dir,
|
|
2731
|
+
update,
|
|
2732
|
+
)
|
|
2733
|
+
|
|
2734
|
+
results = []
|
|
2735
|
+
with tempfile.TemporaryDirectory(prefix="gsd-path-install-") as temporary:
|
|
2736
|
+
staging = Path(temporary)
|
|
2737
|
+
staged = []
|
|
2738
|
+
for index, plan in enumerate(deployments):
|
|
2739
|
+
staged_root = staging / f"{index}-{plan.profile}"
|
|
2740
|
+
staged.append(staged_root)
|
|
2741
|
+
staged_root.mkdir()
|
|
2742
|
+
stage_target(source_root, plan.profile, staged_root)
|
|
2743
|
+
|
|
2744
|
+
if dry_run:
|
|
2745
|
+
if legacy_root is not None and legacy_root.is_dir():
|
|
2746
|
+
legacy_count = sum(
|
|
2747
|
+
1 for entry in legacy_root.iterdir() if _is_managed_name(entry.name)
|
|
2748
|
+
)
|
|
2749
|
+
if legacy_count:
|
|
2750
|
+
results.append(
|
|
2751
|
+
f"codex-legacy: would back up {legacy_count} entries from {legacy_root}"
|
|
2752
|
+
)
|
|
2753
|
+
for plan in deployments:
|
|
2754
|
+
_reject_router_alias_collisions(plan.root)
|
|
2755
|
+
count = _managed_entry_count(plan)
|
|
2756
|
+
suffix = f"; would back up {count} entries" if count else ""
|
|
2757
|
+
results.append(
|
|
2758
|
+
_install_result(plan, dry_run=True, update=update) + suffix
|
|
2759
|
+
)
|
|
2760
|
+
if project is not None:
|
|
2761
|
+
results.append(
|
|
2762
|
+
_project_result(
|
|
2763
|
+
project, selected, hooks, interpreter, hooks_dir, update, True
|
|
2764
|
+
)
|
|
2765
|
+
)
|
|
2766
|
+
_append_host_notes(results, selected)
|
|
2767
|
+
return results
|
|
2768
|
+
|
|
2769
|
+
lock_roots = [plan.root for plan in deployments]
|
|
2770
|
+
if legacy_root is not None and legacy_root.is_dir():
|
|
2771
|
+
lock_roots.append(legacy_root)
|
|
2772
|
+
if project is not None:
|
|
2773
|
+
lock_roots.append(project / HOOKS_DIRECTORY)
|
|
2774
|
+
install_locks, lock_directories = _acquire_install_locks(lock_roots)
|
|
2775
|
+
target_transactions: List[TargetTransaction] = []
|
|
2776
|
+
project_transaction = ProjectTransaction()
|
|
2777
|
+
try:
|
|
2778
|
+
if project is not None:
|
|
2779
|
+
_validate_project(
|
|
2780
|
+
source_root,
|
|
2781
|
+
project,
|
|
2782
|
+
selected,
|
|
2783
|
+
hooks,
|
|
2784
|
+
[*mutation_roots, *planned_backups],
|
|
2785
|
+
interpreter,
|
|
2786
|
+
hooks_dir,
|
|
2787
|
+
update,
|
|
2788
|
+
)
|
|
2789
|
+
if legacy_root is not None and legacy_root.is_dir():
|
|
2790
|
+
legacy_transaction = TargetTransaction(legacy_root)
|
|
2791
|
+
target_transactions.append(legacy_transaction)
|
|
2792
|
+
_backup_existing(legacy_transaction)
|
|
2793
|
+
if legacy_transaction.backup is not None:
|
|
2794
|
+
results.append(
|
|
2795
|
+
"codex-legacy: backed up "
|
|
2796
|
+
f"{len(legacy_transaction.moved)} entries to "
|
|
2797
|
+
f"{legacy_transaction.backup}"
|
|
2798
|
+
)
|
|
2799
|
+
for index, plan in enumerate(deployments):
|
|
2800
|
+
transaction = TargetTransaction(plan.root)
|
|
2801
|
+
target_transactions.append(transaction)
|
|
2802
|
+
_apply_target(plan, staged[index], transaction)
|
|
2803
|
+
results.append(_install_result(plan, update=update))
|
|
2804
|
+
if transaction.backup is not None:
|
|
2805
|
+
results.append(
|
|
2806
|
+
f"{'+'.join(plan.targets)}: backed up "
|
|
2807
|
+
f"{len(transaction.moved)} entries "
|
|
2808
|
+
f"to {transaction.backup}"
|
|
2809
|
+
)
|
|
2810
|
+
if project is not None:
|
|
2811
|
+
result_line = _project_result(
|
|
2812
|
+
project, selected, hooks, interpreter, hooks_dir, update, False
|
|
2813
|
+
)
|
|
2814
|
+
_apply_project(
|
|
2815
|
+
source_root,
|
|
2816
|
+
project,
|
|
2817
|
+
selected,
|
|
2818
|
+
hooks,
|
|
2819
|
+
project_transaction,
|
|
2820
|
+
interpreter,
|
|
2821
|
+
hooks_dir,
|
|
2822
|
+
update,
|
|
2823
|
+
)
|
|
2824
|
+
results.append(result_line)
|
|
2825
|
+
except (Exception, KeyboardInterrupt) as error:
|
|
2826
|
+
rollback_errors = []
|
|
2827
|
+
try:
|
|
2828
|
+
_rollback_project(project_transaction)
|
|
2829
|
+
except (Exception, KeyboardInterrupt) as rollback_error:
|
|
2830
|
+
rollback_errors.append(str(rollback_error))
|
|
2831
|
+
for transaction in reversed(target_transactions):
|
|
2832
|
+
try:
|
|
2833
|
+
_rollback_target(transaction)
|
|
2834
|
+
except (Exception, KeyboardInterrupt) as rollback_error:
|
|
2835
|
+
rollback_errors.append(str(rollback_error))
|
|
2836
|
+
detail = ""
|
|
2837
|
+
if rollback_errors:
|
|
2838
|
+
detail = f"; rollback incomplete: {'; '.join(rollback_errors)}"
|
|
2839
|
+
reason = "interrupted" if isinstance(error, KeyboardInterrupt) else str(error)
|
|
2840
|
+
raise InstallerError(
|
|
2841
|
+
f"installation failed and was rolled back: {reason}{detail}"
|
|
2842
|
+
) from error
|
|
2843
|
+
finally:
|
|
2844
|
+
_release_install_locks(install_locks, lock_directories)
|
|
2845
|
+
_append_host_notes(results, selected)
|
|
2846
|
+
return results
|
|
2847
|
+
|
|
2848
|
+
|
|
2849
|
+
def parser() -> argparse.ArgumentParser:
|
|
2850
|
+
argument_parser = argparse.ArgumentParser(description=__doc__)
|
|
2851
|
+
for target in TARGETS:
|
|
2852
|
+
argument_parser.add_argument(f"--{target}", action="store_true")
|
|
2853
|
+
argument_parser.add_argument(f"--{target}-root", type=Path)
|
|
2854
|
+
argument_parser.add_argument("--all", action="store_true", dest="all_targets")
|
|
2855
|
+
argument_parser.add_argument("--update", action="store_true")
|
|
2856
|
+
argument_parser.add_argument("--local", action="store_true")
|
|
2857
|
+
argument_parser.add_argument("--dry-run", action="store_true")
|
|
2858
|
+
argument_parser.add_argument("--project", type=Path)
|
|
2859
|
+
argument_parser.add_argument("--doctor", action="store_true")
|
|
2860
|
+
argument_parser.add_argument("--hooks", action="store_true")
|
|
2861
|
+
argument_parser.add_argument("--hooks-init", action="store_true")
|
|
2862
|
+
argument_parser.add_argument("--hooks-refresh", action="store_true")
|
|
2863
|
+
argument_parser.add_argument("--hooks-refresh-full", action="store_true")
|
|
2864
|
+
argument_parser.add_argument(
|
|
2865
|
+
"--source-root",
|
|
2866
|
+
type=Path,
|
|
2867
|
+
default=Path(__file__).resolve().parents[1],
|
|
2868
|
+
help=argparse.SUPPRESS,
|
|
2869
|
+
)
|
|
2870
|
+
return argument_parser
|
|
2871
|
+
|
|
2872
|
+
|
|
2873
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
2874
|
+
argument_parser = parser()
|
|
2875
|
+
arguments = argument_parser.parse_args(argv)
|
|
2876
|
+
source_root = absolute_path(arguments.source_root)
|
|
2877
|
+
project = (
|
|
2878
|
+
absolute_path(arguments.project) if arguments.project is not None else None
|
|
2879
|
+
)
|
|
2880
|
+
if arguments.doctor:
|
|
2881
|
+
named = [target for target in TARGETS if getattr(arguments, target)]
|
|
2882
|
+
selected = named if named and not arguments.all_targets else list(TARGETS)
|
|
2883
|
+
|
|
2884
|
+
def root_for(target: str) -> Path:
|
|
2885
|
+
override = getattr(arguments, f"{target}_root")
|
|
2886
|
+
if override is not None:
|
|
2887
|
+
return absolute_path(override)
|
|
2888
|
+
if arguments.local:
|
|
2889
|
+
return local_root(target, Path.cwd())
|
|
2890
|
+
return default_root(target)
|
|
2891
|
+
|
|
2892
|
+
findings = doctor(source_root, selected, root_for, project)
|
|
2893
|
+
for finding in findings:
|
|
2894
|
+
level = finding["level"]
|
|
2895
|
+
text = finding["text"]
|
|
2896
|
+
if level == "fail":
|
|
2897
|
+
print(f"error: {text}", file=sys.stderr)
|
|
2898
|
+
elif level == "ok":
|
|
2899
|
+
print(text)
|
|
2900
|
+
else:
|
|
2901
|
+
print(f"note: {text}")
|
|
2902
|
+
failed = sum(finding["level"] == "fail" for finding in findings)
|
|
2903
|
+
print(
|
|
2904
|
+
f"{failed} problem{'s' if failed != 1 else ''} found."
|
|
2905
|
+
if failed
|
|
2906
|
+
else "Healthy."
|
|
2907
|
+
)
|
|
2908
|
+
return 1 if failed else 0
|
|
2909
|
+
hooks_init = arguments.hooks_init
|
|
2910
|
+
if hooks_init or arguments.hooks_refresh or arguments.hooks_refresh_full:
|
|
2911
|
+
if project is None:
|
|
2912
|
+
option = "--hooks-init" if hooks_init else "--hooks-refresh"
|
|
2913
|
+
print(f"error: {option} requires --project", file=sys.stderr)
|
|
2914
|
+
return 2
|
|
2915
|
+
try:
|
|
2916
|
+
selected = [
|
|
2917
|
+
target
|
|
2918
|
+
for target in TARGETS
|
|
2919
|
+
if arguments.all_targets or getattr(arguments, target)
|
|
2920
|
+
]
|
|
2921
|
+
if hooks_init and not selected:
|
|
2922
|
+
print(
|
|
2923
|
+
"error: --hooks-init requires at least one target or --all",
|
|
2924
|
+
file=sys.stderr,
|
|
2925
|
+
)
|
|
2926
|
+
return 2
|
|
2927
|
+
problems = sync_skill_resources.mismatches(source_root)
|
|
2928
|
+
if problems:
|
|
2929
|
+
raise InstallerError(
|
|
2930
|
+
"source resources are stale: " + "; ".join(problems)
|
|
2931
|
+
)
|
|
2932
|
+
refreshed = refresh_hooks(
|
|
2933
|
+
source_root,
|
|
2934
|
+
project,
|
|
2935
|
+
hooks_init or arguments.hooks_refresh_full,
|
|
2936
|
+
arguments.dry_run,
|
|
2937
|
+
selected,
|
|
2938
|
+
hooks_init,
|
|
2939
|
+
)
|
|
2940
|
+
notes = [line for line in refreshed if line.startswith("note:")]
|
|
2941
|
+
files = [line for line in refreshed if not line.startswith("note:")]
|
|
2942
|
+
if arguments.dry_run:
|
|
2943
|
+
prefix = "would initialize" if hooks_init else "would refresh"
|
|
2944
|
+
else:
|
|
2945
|
+
prefix = "initialized" if hooks_init else "refreshed"
|
|
2946
|
+
print(f"hooks: {prefix} {', '.join(files)}")
|
|
2947
|
+
for note in notes:
|
|
2948
|
+
print(note)
|
|
2949
|
+
except InstallerError as error:
|
|
2950
|
+
print(f"error: {error}", file=sys.stderr)
|
|
2951
|
+
return 1
|
|
2952
|
+
return 0
|
|
2953
|
+
selected = [
|
|
2954
|
+
target
|
|
2955
|
+
for target in TARGETS
|
|
2956
|
+
if arguments.all_targets or getattr(arguments, target)
|
|
2957
|
+
]
|
|
2958
|
+
if not selected:
|
|
2959
|
+
if arguments.update:
|
|
2960
|
+
selected = list(TARGETS)
|
|
2961
|
+
else:
|
|
2962
|
+
argument_parser.error("select at least one target or --all")
|
|
2963
|
+
|
|
2964
|
+
roots = {}
|
|
2965
|
+
for target in selected:
|
|
2966
|
+
override = getattr(arguments, f"{target}_root")
|
|
2967
|
+
roots[target] = (
|
|
2968
|
+
absolute_path(override)
|
|
2969
|
+
if override is not None
|
|
2970
|
+
else (
|
|
2971
|
+
local_root(target, Path.cwd())
|
|
2972
|
+
if arguments.local
|
|
2973
|
+
else default_root(target)
|
|
2974
|
+
)
|
|
2975
|
+
)
|
|
2976
|
+
plans = (
|
|
2977
|
+
detect_installs(selected, roots)
|
|
2978
|
+
if arguments.update
|
|
2979
|
+
else [TargetPlan(target, roots[target]) for target in selected]
|
|
2980
|
+
)
|
|
2981
|
+
if arguments.update and not plans:
|
|
2982
|
+
scope = "this project" if arguments.local else "global roots"
|
|
2983
|
+
print(
|
|
2984
|
+
"error: no existing GSD Path skills found to update "
|
|
2985
|
+
f"({scope}); run an install first, e.g. --all"
|
|
2986
|
+
+ (" --local" if arguments.local else ""),
|
|
2987
|
+
file=sys.stderr,
|
|
2988
|
+
)
|
|
2989
|
+
return 1
|
|
2990
|
+
try:
|
|
2991
|
+
for result in install(
|
|
2992
|
+
source_root,
|
|
2993
|
+
plans,
|
|
2994
|
+
project,
|
|
2995
|
+
arguments.dry_run,
|
|
2996
|
+
arguments.hooks,
|
|
2997
|
+
migrate_legacy=not arguments.local,
|
|
2998
|
+
update=arguments.update,
|
|
2999
|
+
):
|
|
3000
|
+
print(result)
|
|
3001
|
+
except InstallerError as error:
|
|
3002
|
+
print(f"error: {error}", file=sys.stderr)
|
|
3003
|
+
return 1
|
|
3004
|
+
return 0
|
|
3005
|
+
|
|
3006
|
+
|
|
3007
|
+
if __name__ == "__main__":
|
|
3008
|
+
raise SystemExit(main())
|