@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,3239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Install GSD Path skills for supported coding agents (Node port of install.py).
|
|
3
|
+
// Dependency-free; requires Node >= 18.17.
|
|
4
|
+
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
+
|
|
12
|
+
export const CLAUDE_BRIDGE = "@../AGENTS.md\n@../WORKFLOW.md\n";
|
|
13
|
+
export const HOOKS_DIRECTORY = ".gsd-path";
|
|
14
|
+
export const GUARD_SCRIPTS = ["guard_hook.py", "git_guard.py"];
|
|
15
|
+
export const GUARD_MARKER = "gsd-path guard";
|
|
16
|
+
export const PROJECT_RUNTIME_SCRIPTS = [
|
|
17
|
+
"pipeline_state.py",
|
|
18
|
+
"check_handoffs.py",
|
|
19
|
+
"check_task_briefs.py",
|
|
20
|
+
"isolation.py",
|
|
21
|
+
"discussion_records.py",
|
|
22
|
+
"pipeline_git.py",
|
|
23
|
+
"promote_lookahead.py",
|
|
24
|
+
"detect_project.py",
|
|
25
|
+
"pipeline_diagnose.py",
|
|
26
|
+
"pipeline_undo.py",
|
|
27
|
+
"archive_milestone.py",
|
|
28
|
+
"review_panel.py",
|
|
29
|
+
"_common.py",
|
|
30
|
+
"state_checkpoint.py",
|
|
31
|
+
"state_promote.py",
|
|
32
|
+
"discussion_validate.py",
|
|
33
|
+
"integration.py",
|
|
34
|
+
];
|
|
35
|
+
export const PROJECT_RUNTIME_MARKER = "gsd-path project runtime";
|
|
36
|
+
export const PROJECT_STATUS_LAUNCHER = "status_runtime.py";
|
|
37
|
+
export const PROJECT_STATUS_MARKER = "gsd-path project status launcher";
|
|
38
|
+
const INSTALL_LOCK_NAME = ".gsd-path-install-lock";
|
|
39
|
+
const INSTALL_LOCK_OWNER = "owner.json";
|
|
40
|
+
const INSTALL_LOCK_SCHEMA = "gsd-path/install-lock/v2";
|
|
41
|
+
const PROJECT_CONTRACTS = [
|
|
42
|
+
["AGENTS.md", "## Plain-prompt re-entry"],
|
|
43
|
+
["WORKFLOW.md", "### Plain-prompt re-entry"],
|
|
44
|
+
];
|
|
45
|
+
const STATUS_ACTIONS = new Set([
|
|
46
|
+
"bind-initial",
|
|
47
|
+
"block",
|
|
48
|
+
"resume-checkpoint",
|
|
49
|
+
"resume-next-handoff",
|
|
50
|
+
"resume-promotion",
|
|
51
|
+
"resume-shipment",
|
|
52
|
+
"resume-undo",
|
|
53
|
+
"run-phase",
|
|
54
|
+
"validate-integrated",
|
|
55
|
+
"wait",
|
|
56
|
+
]);
|
|
57
|
+
const STATUS_PHASES = new Set(["inspect", "define", "research", "decide", "roadmap", "plan", "build", "ship", "shipped"]);
|
|
58
|
+
const STATUS_VALUES = new Set(["active", "done", "blocked"]);
|
|
59
|
+
const STATUS_STATE_FIELDS = [
|
|
60
|
+
"archive",
|
|
61
|
+
"branch",
|
|
62
|
+
"integration",
|
|
63
|
+
"integration_default",
|
|
64
|
+
"integration_source",
|
|
65
|
+
"milestone",
|
|
66
|
+
"phase",
|
|
67
|
+
"pipeline",
|
|
68
|
+
"project",
|
|
69
|
+
"status",
|
|
70
|
+
];
|
|
71
|
+
const STATUS_INTEGRATION_MODES = new Set(["direct", "pull-request"]);
|
|
72
|
+
const STATUS_INTEGRATION_SOURCES = new Set(["default", "milestone"]);
|
|
73
|
+
const STATUS_TRANSITIONS = new Map([
|
|
74
|
+
["inspect", new Set(["inspect", "define"])],
|
|
75
|
+
["define", new Set(["define", "research", "plan"])],
|
|
76
|
+
["research", new Set(["research", "decide"])],
|
|
77
|
+
["decide", new Set(["decide", "roadmap", "plan"])],
|
|
78
|
+
["roadmap", new Set(["roadmap", "define"])],
|
|
79
|
+
["plan", new Set(["plan", "build"])],
|
|
80
|
+
["build", new Set(["build"])],
|
|
81
|
+
["ship", new Set(["ship", "plan"])],
|
|
82
|
+
["shipped", new Set(["ship"])],
|
|
83
|
+
]);
|
|
84
|
+
const STATUS_SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
85
|
+
const STATUS_BRANCH = /^gsd-path\/M(\d{3,})$/;
|
|
86
|
+
const STATUS_ARCHIVE = /^\.project\/archive\/(\d{3,})-([a-z0-9][a-z0-9-]*)\/?$/;
|
|
87
|
+
export const CLAUDE_MATCHER = ".*";
|
|
88
|
+
// The managed PreToolUse guard entry, as an object.
|
|
89
|
+
export function claudeGuardEntry(interpreter) {
|
|
90
|
+
return {
|
|
91
|
+
matcher: CLAUDE_MATCHER,
|
|
92
|
+
hooks: [
|
|
93
|
+
{
|
|
94
|
+
type: "command",
|
|
95
|
+
command: `${interpreter} "$CLAUDE_PROJECT_DIR/${HOOKS_DIRECTORY}/guard_hook.py"`,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export function claudeHooksSettings(interpreter) {
|
|
101
|
+
return (
|
|
102
|
+
JSON.stringify({ hooks: { PreToolUse: [claudeGuardEntry(interpreter)] } }, null, 2) + "\n"
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
function codexGuardCommand(interpreter) {
|
|
106
|
+
return `${interpreter} "$(git rev-parse --show-toplevel)/${HOOKS_DIRECTORY}/guard_hook.py"`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function codexGuardCommandWindows(interpreter) {
|
|
110
|
+
return (
|
|
111
|
+
"powershell.exe -NoProfile -NonInteractive -Command " +
|
|
112
|
+
`"$root = git rev-parse --show-toplevel; & ${interpreter} ` +
|
|
113
|
+
`(Join-Path $root '${HOOKS_DIRECTORY}\\guard_hook.py')"`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function codexGuardEntry(interpreter) {
|
|
118
|
+
return {
|
|
119
|
+
matcher: ".*",
|
|
120
|
+
hooks: [
|
|
121
|
+
{
|
|
122
|
+
type: "command",
|
|
123
|
+
command: codexGuardCommand(interpreter),
|
|
124
|
+
commandWindows: codexGuardCommandWindows(interpreter),
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function codexHooksSettings(interpreter) {
|
|
131
|
+
return (
|
|
132
|
+
JSON.stringify(
|
|
133
|
+
{
|
|
134
|
+
hooks: {
|
|
135
|
+
PreToolUse: [codexGuardEntry(interpreter)],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
null,
|
|
139
|
+
2
|
|
140
|
+
) + "\n"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function cursorGuardEntry(interpreter) {
|
|
145
|
+
return {
|
|
146
|
+
command: `${interpreter} "${HOOKS_DIRECTORY}/guard_hook.py"`,
|
|
147
|
+
matcher: ".*",
|
|
148
|
+
failClosed: true,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function cursorHooksSettings(interpreter) {
|
|
153
|
+
return (
|
|
154
|
+
JSON.stringify(
|
|
155
|
+
{
|
|
156
|
+
version: 1,
|
|
157
|
+
hooks: {
|
|
158
|
+
preToolUse: [cursorGuardEntry(interpreter)],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
null,
|
|
162
|
+
2
|
|
163
|
+
) + "\n"
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
export function preCommitHook(interpreter) {
|
|
167
|
+
return (
|
|
168
|
+
"#!/bin/sh\n" +
|
|
169
|
+
"# gsd-path guard: archive immutability before commit.\n" +
|
|
170
|
+
`exec ${interpreter} "$(git rev-parse --show-toplevel)/${HOOKS_DIRECTORY}/git_guard.py" pre-commit\n`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
export function prePushHook(interpreter) {
|
|
174
|
+
return (
|
|
175
|
+
"#!/bin/sh\n" +
|
|
176
|
+
"# gsd-path guard: bound branches publish only as ship commits.\n" +
|
|
177
|
+
`exec ${interpreter} "$(git rev-parse --show-toplevel)/${HOOKS_DIRECTORY}/git_guard.py" pre-push "$@"\n`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function commitMsgHook(interpreter) {
|
|
182
|
+
return (
|
|
183
|
+
"#!/bin/sh\n" +
|
|
184
|
+
"# gsd-path guard: archive immutability and ship-commit purity.\n" +
|
|
185
|
+
`exec ${interpreter} "$(git rev-parse --show-toplevel)/${HOOKS_DIRECTORY}/git_guard.py" commit-msg "$1"\n`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const GIT_HOOKS = [
|
|
190
|
+
["pre-commit", preCommitHook],
|
|
191
|
+
["commit-msg", commitMsgHook],
|
|
192
|
+
["pre-push", prePushHook],
|
|
193
|
+
];
|
|
194
|
+
export const GIT_HOOK_NAMES = GIT_HOOKS.map(([name]) => name);
|
|
195
|
+
|
|
196
|
+
// Every interpreter a managed hook may legitimately be pinned to.
|
|
197
|
+
const INTERPRETER_CANDIDATES = ["python3", "python"];
|
|
198
|
+
|
|
199
|
+
// Probe for a runnable Python interpreter (python3, then python) so emitted
|
|
200
|
+
// hooks never hard-code an interpreter that does not exist on this machine
|
|
201
|
+
// (python3 is typically absent on Windows).
|
|
202
|
+
export function detectPythonInterpreter() {
|
|
203
|
+
for (const candidate of INTERPRETER_CANDIDATES) {
|
|
204
|
+
const result = spawnSync(
|
|
205
|
+
candidate,
|
|
206
|
+
["-B", "-c", "import sys; raise SystemExit(sys.version_info < (3, 9))"],
|
|
207
|
+
{ stdio: "ignore" }
|
|
208
|
+
);
|
|
209
|
+
if (!result.error && result.status === 0) return candidate;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Single owner of the interpreter probe and its policy. Hook installation
|
|
215
|
+
// fails before writing when no interpreter works.
|
|
216
|
+
function effectiveInterpreter() {
|
|
217
|
+
return hooks.detectPythonInterpreter();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Resolve the repository's effective hooks directory via
|
|
221
|
+
// `git rev-parse --git-path hooks`, which honors core.hooksPath and linked
|
|
222
|
+
// worktrees. Returns null when git cannot resolve it for this project.
|
|
223
|
+
export function resolveGitHooksPath(project) {
|
|
224
|
+
const result = spawnSync(
|
|
225
|
+
"git",
|
|
226
|
+
["rev-parse", "--show-toplevel", "--git-path", "hooks"],
|
|
227
|
+
{ cwd: project, encoding: "utf8" }
|
|
228
|
+
);
|
|
229
|
+
if (result.error || result.status !== 0) return null;
|
|
230
|
+
const lines = result.stdout.split(/\r?\n/).filter((line) => line !== "");
|
|
231
|
+
if (lines.length !== 2) return null;
|
|
232
|
+
const [toplevel, hooksPath] = lines;
|
|
233
|
+
if (!samePath(toplevel, project)) return null;
|
|
234
|
+
return path.resolve(project, hooksPath);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function gitHooksDirectory(project) {
|
|
238
|
+
return gitHooksLocation(project).directory;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function gitHooksLocation(project) {
|
|
242
|
+
const dotGit = path.join(project, ".git");
|
|
243
|
+
if (!lexists(dotGit)) return { directory: null, resolved: false };
|
|
244
|
+
const resolved = hooks.resolveGitHooksPath(project);
|
|
245
|
+
if (resolved !== null) return { directory: resolved, resolved: true };
|
|
246
|
+
// Fallback when git is not runnable: only a plain .git directory is safe.
|
|
247
|
+
return {
|
|
248
|
+
directory: isDirectory(dotGit) ? path.join(dotGit, "hooks") : null,
|
|
249
|
+
resolved: false,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function requiredPythonRuntime(command, selected = []) {
|
|
254
|
+
const targetSuffix = selected.length ? ` for selected hosts: ${selected.join(", ")}` : "";
|
|
255
|
+
const interpreter = effectiveInterpreter();
|
|
256
|
+
if (interpreter === null) {
|
|
257
|
+
throw new InstallerError(
|
|
258
|
+
`${command} requires a working Python interpreter${targetSuffix}`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return interpreter;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function requiredHookRuntime(project, command, selected = []) {
|
|
265
|
+
const targetSuffix = selected.length ? ` for selected hosts: ${selected.join(", ")}` : "";
|
|
266
|
+
const interpreter = requiredPythonRuntime(command, selected);
|
|
267
|
+
const location = gitHooksLocation(project);
|
|
268
|
+
if (!location.resolved) {
|
|
269
|
+
throw new InstallerError(
|
|
270
|
+
`${command} requires an initialized Git repository with a resolvable hooks ` +
|
|
271
|
+
`directory${targetSuffix}`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return { interpreter, hooksDir: location.directory };
|
|
275
|
+
}
|
|
276
|
+
const HOST_NOTES = {
|
|
277
|
+
opencode:
|
|
278
|
+
"note: OpenCode stable discovers the skills but has no documented hard " +
|
|
279
|
+
'explicit-only switch; OpenCode v2 honors opencode/autoinvoke="false" ' +
|
|
280
|
+
"and /gsd-path.",
|
|
281
|
+
antigravity:
|
|
282
|
+
"note: Antigravity discovers /gsd-path but has no documented hard " +
|
|
283
|
+
"explicit-only skill switch; invoke the skill explicitly.",
|
|
284
|
+
kiro:
|
|
285
|
+
"note: Kiro discovers /gsd-path but has no documented hard explicit-only " +
|
|
286
|
+
"skill switch; invoke the skill explicitly.",
|
|
287
|
+
};
|
|
288
|
+
export const EXPLICIT_ONLY_TARGETS = new Set([
|
|
289
|
+
"claude",
|
|
290
|
+
"grok",
|
|
291
|
+
"copilot",
|
|
292
|
+
"qwen",
|
|
293
|
+
"cursor",
|
|
294
|
+
"zed",
|
|
295
|
+
"kimi",
|
|
296
|
+
"shared-agents",
|
|
297
|
+
]);
|
|
298
|
+
const SHARED_AGENT_TARGETS = new Set(["codex", "antigravity", "zed"]);
|
|
299
|
+
export const SHARED_AGENT_PROFILE = "shared-agents";
|
|
300
|
+
export const CURSOR_AGENT_FILENAME = "gsd-path.md";
|
|
301
|
+
export const CURSOR_AGENT_BACKUP_NAME = "cursor-agent-gsd-path.md";
|
|
302
|
+
|
|
303
|
+
const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
304
|
+
|
|
305
|
+
// Single source of truth for the resource tables, shared with
|
|
306
|
+
// sync_skill_resources.py and shipped in the npm package.
|
|
307
|
+
const MANIFEST = JSON.parse(
|
|
308
|
+
fs.readFileSync(path.join(SCRIPT_DIRECTORY, "skill-resources.json"), "utf8")
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
function sharedInvocations(text) {
|
|
312
|
+
const pattern = /(`?)(\$gsd-path(?:-[a-z0-9]+)*|\$path)( status)?\1(?: \(Codex\) (?:and|or) (`?)\/(gsd-path(?:-[a-z0-9]+)*|path)( status)?\4 \((?:other hosts|Antigravity\/Zed)\))?/g;
|
|
313
|
+
return text.replace(pattern, (_match, quote, token, arguments_, pairedQuote, pairedSkill, pairedArguments) => {
|
|
314
|
+
const skill = token.slice(1);
|
|
315
|
+
const argumentsText = arguments_ || "";
|
|
316
|
+
if (pairedSkill && (pairedSkill !== skill || (pairedArguments || "") !== argumentsText || pairedQuote !== quote)) {
|
|
317
|
+
throw new InstallerError("shared invocation pair is inconsistent");
|
|
318
|
+
}
|
|
319
|
+
const codex = `${MANIFEST.hosts.codex.invocation_prefix}${skill}`;
|
|
320
|
+
const others = `${MANIFEST.hosts.antigravity.invocation_prefix}${skill}`;
|
|
321
|
+
return `${quote}${codex}${argumentsText}${quote} (Codex) or ${quote}${others}${argumentsText}${quote} (Antigravity/Zed)`;
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
export function skillNamesForManifest(manifest) {
|
|
325
|
+
return [...manifest.skills];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function targetsForManifest(manifest) {
|
|
329
|
+
return Object.keys(manifest.hosts);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function localRootsForManifest(manifest) {
|
|
333
|
+
return Object.fromEntries(
|
|
334
|
+
Object.entries(manifest.hosts).map(([target, config]) => [target, config.local_root])
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export const TARGETS = targetsForManifest(MANIFEST);
|
|
339
|
+
export const LOCAL_ROOTS = localRootsForManifest(MANIFEST);
|
|
340
|
+
export const SKILL_NAMES = skillNamesForManifest(MANIFEST);
|
|
341
|
+
export const SKILL_ALIASES = { ...MANIFEST.skill_aliases };
|
|
342
|
+
export const ROUTER_ALIASES = { ...MANIFEST.router_aliases };
|
|
343
|
+
const PHASE_RESOURCES = MANIFEST.phase_resources;
|
|
344
|
+
const SCRIPT_TARGETS = MANIFEST.script_targets;
|
|
345
|
+
const PHASE_CONTRACT_TARGETS = MANIFEST.phase_contract_targets;
|
|
346
|
+
const SHARED_DISPATCH_TARGETS = MANIFEST.shared_dispatch_targets;
|
|
347
|
+
|
|
348
|
+
export class InstallerError extends Error {}
|
|
349
|
+
|
|
350
|
+
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
351
|
+
|
|
352
|
+
const messageOf = (error) => String(error && error.message ? error.message : error);
|
|
353
|
+
|
|
354
|
+
function readPackageVersion(manifest) {
|
|
355
|
+
try {
|
|
356
|
+
const version = JSON.parse(fs.readFileSync(manifest, "utf8")).version;
|
|
357
|
+
return typeof version === "string" && version ? version : null;
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function targetPlan(name, root) {
|
|
364
|
+
return { name, root };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function lexists(candidate) {
|
|
368
|
+
try {
|
|
369
|
+
fs.lstatSync(candidate);
|
|
370
|
+
return true;
|
|
371
|
+
} catch {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isSymlink(candidate) {
|
|
377
|
+
try {
|
|
378
|
+
return fs.lstatSync(candidate).isSymbolicLink();
|
|
379
|
+
} catch {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isDirectory(candidate) {
|
|
385
|
+
try {
|
|
386
|
+
return fs.statSync(candidate).isDirectory();
|
|
387
|
+
} catch {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isFile(candidate) {
|
|
393
|
+
try {
|
|
394
|
+
return fs.statSync(candidate).isFile();
|
|
395
|
+
} catch {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function expandUser(value) {
|
|
401
|
+
if (value === "~") return os.homedir();
|
|
402
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
403
|
+
return path.join(os.homedir(), value.slice(2));
|
|
404
|
+
}
|
|
405
|
+
return value;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function absolutePath(value) {
|
|
409
|
+
return path.resolve(expandUser(String(value)));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function defaultRoot(target, env = process.env) {
|
|
413
|
+
if (target === "codex") return absolutePath("~/.agents/skills");
|
|
414
|
+
if (target === "claude") {
|
|
415
|
+
return absolutePath(path.join(env.CLAUDE_CONFIG_DIR || expandUser("~/.claude"), "skills"));
|
|
416
|
+
}
|
|
417
|
+
if (target === "grok") {
|
|
418
|
+
return absolutePath(path.join(env.GROK_HOME || expandUser("~/.grok"), "skills"));
|
|
419
|
+
}
|
|
420
|
+
if (target === "opencode") {
|
|
421
|
+
let base;
|
|
422
|
+
if (env.OPENCODE_CONFIG_DIR) {
|
|
423
|
+
base = env.OPENCODE_CONFIG_DIR;
|
|
424
|
+
} else if (env.OPENCODE_CONFIG) {
|
|
425
|
+
base = path.dirname(absolutePath(env.OPENCODE_CONFIG));
|
|
426
|
+
} else {
|
|
427
|
+
base = path.join(env.XDG_CONFIG_HOME || expandUser("~/.config"), "opencode");
|
|
428
|
+
}
|
|
429
|
+
return absolutePath(path.join(base, "skills"));
|
|
430
|
+
}
|
|
431
|
+
if (target === "copilot") {
|
|
432
|
+
return absolutePath(path.join(env.COPILOT_HOME || expandUser("~/.copilot"), "skills"));
|
|
433
|
+
}
|
|
434
|
+
if (target === "qwen") {
|
|
435
|
+
return absolutePath(path.join(env.QWEN_HOME || expandUser("~/.qwen"), "skills"));
|
|
436
|
+
}
|
|
437
|
+
if (target === "antigravity") return absolutePath("~/.gemini/antigravity-cli/skills");
|
|
438
|
+
if (target === "cursor") return absolutePath("~/.cursor/skills");
|
|
439
|
+
if (target === "zed") return absolutePath("~/.agents/skills");
|
|
440
|
+
if (target === "kiro") {
|
|
441
|
+
return absolutePath(path.join(env.KIRO_HOME || expandUser("~/.kiro"), "skills"));
|
|
442
|
+
}
|
|
443
|
+
if (target === "kimi") {
|
|
444
|
+
return absolutePath(path.join(env.KIMI_CODE_HOME || expandUser("~/.kimi-code"), "skills"));
|
|
445
|
+
}
|
|
446
|
+
throw new Error(`unsupported target: ${target}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function legacyCodexRoot(env = process.env) {
|
|
450
|
+
return absolutePath(path.join(env.CODEX_HOME || expandUser("~/.codex"), "skills"));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function isManagedName(name) {
|
|
454
|
+
const normalized = name.toLowerCase();
|
|
455
|
+
return (
|
|
456
|
+
normalized === "ogsd" ||
|
|
457
|
+
normalized === "gsd-path" ||
|
|
458
|
+
Object.hasOwn(ROUTER_ALIASES, normalized) ||
|
|
459
|
+
normalized.startsWith("ogsd-") ||
|
|
460
|
+
normalized.startsWith("gsd-path-")
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function isOwnedRouterAlias(skillDir) {
|
|
465
|
+
const runtime = path.join(skillDir, "scripts", "pipeline_state.py");
|
|
466
|
+
if (!isFile(runtime)) return false;
|
|
467
|
+
const versionFile = path.join(skillDir, "VERSION");
|
|
468
|
+
if (!isFile(versionFile)) return isManagedProjectRuntime(runtime); // installs before alias version stamping
|
|
469
|
+
try {
|
|
470
|
+
return /^[0-9]+(?:\.[0-9]+)+$/.test(fs.readFileSync(versionFile, "utf8").trim());
|
|
471
|
+
} catch {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function isManagedInstallEntry(root, name) {
|
|
477
|
+
if (!isManagedName(name)) return false;
|
|
478
|
+
if (!Object.hasOwn(ROUTER_ALIASES, name.toLowerCase())) return true;
|
|
479
|
+
return isOwnedRouterAlias(path.join(root, name));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function rejectRouterAliasCollisions(root) {
|
|
483
|
+
if (!isDirectory(root)) return;
|
|
484
|
+
for (const alias of Object.keys(ROUTER_ALIASES)) {
|
|
485
|
+
const candidate = path.join(root, alias);
|
|
486
|
+
if (!lexists(candidate)) continue;
|
|
487
|
+
if (isOwnedRouterAlias(candidate)) continue;
|
|
488
|
+
throw new InstallerError(
|
|
489
|
+
`refusing to replace unrelated skill ${candidate}; rename it or move it aside before installing GSD Path`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function stampInstalledVersions(stagedRoot, version) {
|
|
495
|
+
if (!version) return;
|
|
496
|
+
const names = new Set(["gsd-path", ...Object.keys(ROUTER_ALIASES)]);
|
|
497
|
+
for (const name of names) {
|
|
498
|
+
fs.writeFileSync(path.join(stagedRoot, name, "VERSION"), `${version}\n`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function rewriteRouterAliasSkill(text, alias, canonical) {
|
|
503
|
+
if (!text.startsWith("---\n")) {
|
|
504
|
+
throw new InstallerError("SKILL.md is missing YAML frontmatter");
|
|
505
|
+
}
|
|
506
|
+
const end = text.indexOf("\n---\n", 4);
|
|
507
|
+
if (end === -1) {
|
|
508
|
+
throw new InstallerError("SKILL.md has unterminated YAML frontmatter");
|
|
509
|
+
}
|
|
510
|
+
const header = text.slice(4, end);
|
|
511
|
+
const rest = text.slice(end);
|
|
512
|
+
const expected = `name: ${canonical}`;
|
|
513
|
+
const replacement = `name: ${alias}`;
|
|
514
|
+
const lines = header.split("\n");
|
|
515
|
+
if (!lines.includes(expected)) {
|
|
516
|
+
throw new InstallerError(`router alias ${alias} is missing ${expected}`);
|
|
517
|
+
}
|
|
518
|
+
const rewritten = lines.map((line) => (line === expected ? replacement : line));
|
|
519
|
+
if (rewritten.filter((line) => line === replacement).length !== 1) {
|
|
520
|
+
throw new InstallerError(`router alias ${alias} name rewrite was not unique`);
|
|
521
|
+
}
|
|
522
|
+
const invoke = `invokes $${canonical}.`;
|
|
523
|
+
const invokeWithAlias = `invokes $${alias} or $${canonical}.`;
|
|
524
|
+
let headerText = rewritten.join("\n");
|
|
525
|
+
if (!headerText.includes(invoke)) {
|
|
526
|
+
throw new InstallerError(`router alias ${alias} is missing ${invoke}`);
|
|
527
|
+
}
|
|
528
|
+
headerText = headerText.replace(invoke, invokeWithAlias);
|
|
529
|
+
return `---\n${headerText}${rest}`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function materializedResourceBytes(source, destination) {
|
|
533
|
+
const data = fs.readFileSync(source);
|
|
534
|
+
if (path.basename(destination) !== "SKILL.md") return data;
|
|
535
|
+
const alias = path.basename(path.dirname(destination));
|
|
536
|
+
const canonical = ROUTER_ALIASES[alias];
|
|
537
|
+
if (!canonical) return data;
|
|
538
|
+
return Buffer.from(rewriteRouterAliasSkill(data.toString("utf8"), alias, canonical));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function resolveNonStrict(value) {
|
|
542
|
+
let prefix = path.resolve(value);
|
|
543
|
+
const suffix = [];
|
|
544
|
+
for (;;) {
|
|
545
|
+
try {
|
|
546
|
+
return path.join(fs.realpathSync(prefix), ...suffix);
|
|
547
|
+
} catch {
|
|
548
|
+
const parent = path.dirname(prefix);
|
|
549
|
+
if (parent === prefix) return path.join(prefix, ...suffix);
|
|
550
|
+
suffix.unshift(path.basename(prefix));
|
|
551
|
+
prefix = parent;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function comparisonPath(value) {
|
|
557
|
+
const resolved = resolveNonStrict(value);
|
|
558
|
+
if (process.platform === "darwin" || process.platform === "win32") {
|
|
559
|
+
return resolved.toLowerCase();
|
|
560
|
+
}
|
|
561
|
+
return resolved;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function isAncestor(ancestor, descendant) {
|
|
565
|
+
const relative = path.relative(ancestor, descendant);
|
|
566
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function pathsOverlap(left, right) {
|
|
570
|
+
const a = comparisonPath(left);
|
|
571
|
+
const b = comparisonPath(right);
|
|
572
|
+
return a === b || isAncestor(a, b) || isAncestor(b, a);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function samePath(left, right) {
|
|
576
|
+
return comparisonPath(left) === comparisonPath(right);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function validateProjectGitRoot(project) {
|
|
580
|
+
let probe = project;
|
|
581
|
+
while (!lexists(probe)) probe = path.dirname(probe);
|
|
582
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
583
|
+
cwd: probe,
|
|
584
|
+
encoding: "utf8",
|
|
585
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
586
|
+
});
|
|
587
|
+
const topLevel = result.status === 0 ? result.stdout.trim() : "";
|
|
588
|
+
if (topLevel && !samePath(topLevel, project)) {
|
|
589
|
+
throw new InstallerError(`project path is not the Git worktree root: ${project}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function validateDirectoryDestination(candidate, label) {
|
|
594
|
+
if (lexists(candidate)) {
|
|
595
|
+
if (isSymlink(candidate)) throw new InstallerError(`${label} is a symlink: ${candidate}`);
|
|
596
|
+
if (!isDirectory(candidate)) throw new InstallerError(`${label} is not a directory: ${candidate}`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
let parent = path.dirname(candidate);
|
|
600
|
+
while (!lexists(parent)) parent = path.dirname(parent);
|
|
601
|
+
if (isSymlink(parent) || !isDirectory(parent)) {
|
|
602
|
+
throw new InstallerError(`${label} has an unsafe parent: ${parent}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function walk(directory, visit) {
|
|
607
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
608
|
+
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
|
609
|
+
const candidate = path.join(directory, entry.name);
|
|
610
|
+
visit(candidate, entry);
|
|
611
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) walk(candidate, visit);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function rejectSourceSymlinks(root) {
|
|
616
|
+
walk(root, (candidate, entry) => {
|
|
617
|
+
if (entry.isSymbolicLink()) {
|
|
618
|
+
throw new InstallerError(`source contains a symlink: ${candidate}`);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function mismatches(root) {
|
|
624
|
+
const problems = [];
|
|
625
|
+
const pairs = [];
|
|
626
|
+
const expectedSkills = [
|
|
627
|
+
"gsd-path",
|
|
628
|
+
...[
|
|
629
|
+
...Object.keys(PHASE_RESOURCES),
|
|
630
|
+
...Object.keys(SKILL_ALIASES),
|
|
631
|
+
...Object.keys(ROUTER_ALIASES),
|
|
632
|
+
].sort(),
|
|
633
|
+
];
|
|
634
|
+
if (JSON.stringify(SKILL_NAMES) !== JSON.stringify(expectedSkills)) {
|
|
635
|
+
problems.push("manifest skills must be root gsd-path plus every canonical skill and alias");
|
|
636
|
+
}
|
|
637
|
+
for (const [legacy, canonicalSkill] of Object.entries(SKILL_ALIASES)) {
|
|
638
|
+
if (Object.hasOwn(PHASE_RESOURCES, legacy)) {
|
|
639
|
+
problems.push("skill aliases cannot also own phase resources");
|
|
640
|
+
}
|
|
641
|
+
if (!Object.hasOwn(PHASE_RESOURCES, canonicalSkill)) {
|
|
642
|
+
problems.push(`skill alias target is not canonical: ${legacy} -> ${canonicalSkill}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
for (const [alias, canonicalSkill] of Object.entries(ROUTER_ALIASES)) {
|
|
646
|
+
if (Object.hasOwn(PHASE_RESOURCES, alias)) {
|
|
647
|
+
problems.push("router aliases cannot also own phase resources");
|
|
648
|
+
}
|
|
649
|
+
if (Object.hasOwn(SKILL_ALIASES, alias)) {
|
|
650
|
+
problems.push("router aliases cannot also be skill aliases");
|
|
651
|
+
}
|
|
652
|
+
if (canonicalSkill !== "gsd-path") {
|
|
653
|
+
problems.push(`router alias target is not the router: ${alias} -> ${canonicalSkill}`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
for (const [source, destination] of SHARED_DISPATCH_TARGETS) {
|
|
657
|
+
pairs.push([path.join(root, source), path.join(root, destination)]);
|
|
658
|
+
}
|
|
659
|
+
const canonical = path.join(root, "skills", "gsd-path");
|
|
660
|
+
for (const [skill, resources] of Object.entries(PHASE_RESOURCES)) {
|
|
661
|
+
for (const relative of resources) {
|
|
662
|
+
pairs.push([path.join(canonical, relative), path.join(root, "skills", skill, relative)]);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
for (const [source, destination] of [...SCRIPT_TARGETS, ...PHASE_CONTRACT_TARGETS]) {
|
|
666
|
+
pairs.push([path.join(root, source), path.join(root, destination)]);
|
|
667
|
+
}
|
|
668
|
+
for (const [legacy, canonicalSkill] of Object.entries(SKILL_ALIASES)) {
|
|
669
|
+
const legacyDirectory = path.join(root, "skills", legacy);
|
|
670
|
+
pairs.push([
|
|
671
|
+
path.join(root, "skills", canonicalSkill, "SKILL.md"),
|
|
672
|
+
path.join(legacyDirectory, "CANONICAL.md"),
|
|
673
|
+
]);
|
|
674
|
+
for (const relative of PHASE_RESOURCES[canonicalSkill] || []) {
|
|
675
|
+
pairs.push([path.join(canonical, relative), path.join(legacyDirectory, relative)]);
|
|
676
|
+
}
|
|
677
|
+
const canonicalPrefix = `skills/${canonicalSkill}/`;
|
|
678
|
+
for (const [source, destination] of SCRIPT_TARGETS) {
|
|
679
|
+
if (destination.startsWith(canonicalPrefix)) {
|
|
680
|
+
pairs.push([
|
|
681
|
+
path.join(root, source),
|
|
682
|
+
path.join(legacyDirectory, destination.slice(canonicalPrefix.length)),
|
|
683
|
+
]);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
for (const [alias, canonicalSkill] of Object.entries(ROUTER_ALIASES)) {
|
|
688
|
+
const canonicalDirectory = path.join(root, "skills", canonicalSkill);
|
|
689
|
+
const aliasDirectory = path.join(root, "skills", alias);
|
|
690
|
+
walk(canonicalDirectory, (candidate, entry) => {
|
|
691
|
+
if (!entry.isFile() || path.basename(candidate) === ".DS_Store") return;
|
|
692
|
+
const relative = path.relative(canonicalDirectory, candidate);
|
|
693
|
+
if (relative.split(path.sep).includes("__pycache__")) return;
|
|
694
|
+
pairs.push([candidate, path.join(aliasDirectory, relative)]);
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
for (const [source, destination] of pairs) {
|
|
698
|
+
if (!isFile(source)) {
|
|
699
|
+
problems.push(`missing canonical resource: ${path.relative(root, source)}`);
|
|
700
|
+
} else if (!isFile(destination)) {
|
|
701
|
+
problems.push(`missing generated resource: ${path.relative(root, destination)}`);
|
|
702
|
+
} else {
|
|
703
|
+
try {
|
|
704
|
+
if (!materializedResourceBytes(source, destination).equals(fs.readFileSync(destination))) {
|
|
705
|
+
problems.push(`stale generated resource: ${path.relative(root, destination)}`);
|
|
706
|
+
}
|
|
707
|
+
} catch (error) {
|
|
708
|
+
problems.push(messageOf(error));
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
walk(root, (candidate, entry) => {
|
|
713
|
+
if (entry.name === ".DS_Store") {
|
|
714
|
+
problems.push(`unexpected package metadata: ${path.relative(root, candidate)}`);
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
return problems;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function validateSource(sourceRoot, profiles) {
|
|
721
|
+
const problems = hooks.mismatches(sourceRoot);
|
|
722
|
+
if (problems.length) {
|
|
723
|
+
throw new InstallerError("source resources are stale: " + problems.join("; "));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const skillsRoot = path.join(sourceRoot, "skills");
|
|
727
|
+
if (!isDirectory(skillsRoot) || isSymlink(skillsRoot)) {
|
|
728
|
+
throw new InstallerError(`missing safe skills directory: ${skillsRoot}`);
|
|
729
|
+
}
|
|
730
|
+
const found = fs
|
|
731
|
+
.readdirSync(skillsRoot)
|
|
732
|
+
.filter(
|
|
733
|
+
(name) =>
|
|
734
|
+
isManagedName(name) &&
|
|
735
|
+
(name.startsWith("gsd-path") || Object.hasOwn(ROUTER_ALIASES, name))
|
|
736
|
+
);
|
|
737
|
+
const expected = new Set(SKILL_NAMES);
|
|
738
|
+
if (found.length !== expected.size || !found.every((name) => expected.has(name))) {
|
|
739
|
+
throw new InstallerError(
|
|
740
|
+
`expected exactly ${SKILL_NAMES.length} GSD Path skills; found ` +
|
|
741
|
+
[...found].sort().join(", ")
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
for (const name of SKILL_NAMES) {
|
|
745
|
+
const skill = path.join(skillsRoot, name);
|
|
746
|
+
if (isSymlink(skill) || !isDirectory(skill) || !isFile(path.join(skill, "SKILL.md"))) {
|
|
747
|
+
throw new InstallerError(`invalid skill directory: ${skill}`);
|
|
748
|
+
}
|
|
749
|
+
rejectSourceSymlinks(skill);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
for (const profile of profiles) {
|
|
753
|
+
const adapter = path.join(sourceRoot, "platforms", profile, "dispatch.md");
|
|
754
|
+
if (isSymlink(adapter) || !isFile(adapter)) {
|
|
755
|
+
throw new InstallerError(`missing dispatch adapter: ${adapter}`);
|
|
756
|
+
}
|
|
757
|
+
if (profile === "cursor") {
|
|
758
|
+
const agent = path.join(sourceRoot, "platforms", "cursor", "agent.md");
|
|
759
|
+
if (isSymlink(agent) || !isFile(agent)) {
|
|
760
|
+
throw new InstallerError(`missing Cursor subagent: ${agent}`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return SKILL_NAMES;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function splitLines(text) {
|
|
768
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
769
|
+
if (lines.length && lines[lines.length - 1] === "" && /[\n\r]$/.test(text)) {
|
|
770
|
+
lines.pop();
|
|
771
|
+
}
|
|
772
|
+
return lines;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function augmentFrontmatter(text, target) {
|
|
776
|
+
const lines = splitLines(text);
|
|
777
|
+
if (!lines.length || lines[0] !== "---") {
|
|
778
|
+
throw new InstallerError("SKILL.md is missing YAML frontmatter");
|
|
779
|
+
}
|
|
780
|
+
const end = lines.indexOf("---", 1);
|
|
781
|
+
if (end === -1) {
|
|
782
|
+
throw new InstallerError("SKILL.md has unterminated YAML frontmatter");
|
|
783
|
+
}
|
|
784
|
+
const header = lines.slice(1, end);
|
|
785
|
+
|
|
786
|
+
if (EXPLICIT_ONLY_TARGETS.has(target)) {
|
|
787
|
+
const key = "disable-model-invocation:";
|
|
788
|
+
const replacement = "disable-model-invocation: true";
|
|
789
|
+
const index = header.findIndex((line) => line.startsWith(key));
|
|
790
|
+
if (index === -1) header.push(replacement);
|
|
791
|
+
else header[index] = replacement;
|
|
792
|
+
}
|
|
793
|
+
if (target === "opencode" || target === SHARED_AGENT_PROFILE) {
|
|
794
|
+
const metadata = header.indexOf("metadata:");
|
|
795
|
+
if (metadata === -1) {
|
|
796
|
+
header.push("metadata:", ' opencode/autoinvoke: "false"', ' opencode/slash: "true"');
|
|
797
|
+
} else {
|
|
798
|
+
let sectionEnd = metadata + 1;
|
|
799
|
+
while (
|
|
800
|
+
sectionEnd < header.length &&
|
|
801
|
+
(header[sectionEnd] === "" || /^\s/.test(header[sectionEnd]))
|
|
802
|
+
) {
|
|
803
|
+
sectionEnd += 1;
|
|
804
|
+
}
|
|
805
|
+
const values = [
|
|
806
|
+
["opencode/autoinvoke:", ' opencode/autoinvoke: "false"'],
|
|
807
|
+
["opencode/slash:", ' opencode/slash: "true"'],
|
|
808
|
+
];
|
|
809
|
+
for (const [key, replacement] of values) {
|
|
810
|
+
let replaced = false;
|
|
811
|
+
for (let index = metadata + 1; index < sectionEnd; index += 1) {
|
|
812
|
+
if (header[index].trim().startsWith(key)) {
|
|
813
|
+
header[index] = replacement;
|
|
814
|
+
replaced = true;
|
|
815
|
+
break;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (!replaced) {
|
|
819
|
+
header.splice(sectionEnd, 0, replacement);
|
|
820
|
+
sectionEnd += 1;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const transformed = ["---", ...header, "---", ...lines.slice(end + 1)];
|
|
827
|
+
return transformed.join("\n") + (/[\n\r]$/.test(text) ? "\n" : "");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function markdownFiles(root) {
|
|
831
|
+
const files = [];
|
|
832
|
+
walk(root, (candidate, entry) => {
|
|
833
|
+
if (entry.isFile() && candidate.endsWith(".md")) files.push(candidate);
|
|
834
|
+
});
|
|
835
|
+
return files.sort();
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export function stageTarget(sourceRoot, target, stagedRoot) {
|
|
839
|
+
const skillsRoot = path.join(sourceRoot, "skills");
|
|
840
|
+
for (const name of SKILL_NAMES) {
|
|
841
|
+
fs.cpSync(path.join(skillsRoot, name), path.join(stagedRoot, name), { recursive: true });
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
stampInstalledVersions(stagedRoot, readPackageVersion(path.join(sourceRoot, "package.json")));
|
|
845
|
+
|
|
846
|
+
const adapter = fs.readFileSync(path.join(sourceRoot, "platforms", target, "dispatch.md"), "utf8");
|
|
847
|
+
for (const name of SKILL_NAMES) {
|
|
848
|
+
const dispatch = path.join(stagedRoot, name, "references", "dispatch.md");
|
|
849
|
+
if (isFile(dispatch)) fs.writeFileSync(dispatch, adapter);
|
|
850
|
+
}
|
|
851
|
+
if (target === "cursor") {
|
|
852
|
+
fs.copyFileSync(
|
|
853
|
+
path.join(sourceRoot, "platforms", "cursor", "agent.md"),
|
|
854
|
+
path.join(stagedRoot, CURSOR_AGENT_FILENAME)
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (target !== "codex") {
|
|
859
|
+
if (target !== SHARED_AGENT_PROFILE) {
|
|
860
|
+
for (const name of SKILL_NAMES) {
|
|
861
|
+
const metadata = path.join(stagedRoot, name, "agents");
|
|
862
|
+
if (isDirectory(metadata)) fs.rmSync(metadata, { recursive: true });
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const invocation = target === "opencode" ? "gsd-path" : "/gsd-path";
|
|
866
|
+
const pathInvocation = target === "opencode" ? "path" : "/path";
|
|
867
|
+
for (const name of SKILL_NAMES) {
|
|
868
|
+
const entrypoint = path.join(stagedRoot, name, "SKILL.md");
|
|
869
|
+
fs.writeFileSync(entrypoint, augmentFrontmatter(fs.readFileSync(entrypoint, "utf8"), target));
|
|
870
|
+
}
|
|
871
|
+
for (const markdown of markdownFiles(stagedRoot)) {
|
|
872
|
+
const content = fs.readFileSync(markdown, "utf8");
|
|
873
|
+
fs.writeFileSync(
|
|
874
|
+
markdown,
|
|
875
|
+
target === SHARED_AGENT_PROFILE
|
|
876
|
+
? sharedInvocations(content)
|
|
877
|
+
: content.replaceAll("$gsd-path", invocation).replaceAll("$path", pathInvocation)
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function missingDirectories(candidate) {
|
|
884
|
+
const missing = [];
|
|
885
|
+
let cursor = candidate;
|
|
886
|
+
while (!lexists(cursor)) {
|
|
887
|
+
missing.push(cursor);
|
|
888
|
+
cursor = path.dirname(cursor);
|
|
889
|
+
}
|
|
890
|
+
return missing.reverse();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function createDirectory(candidate, created) {
|
|
894
|
+
created.push(...missingDirectories(candidate));
|
|
895
|
+
fs.mkdirSync(candidate, { recursive: true });
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function releaseInstallLocks(locks, createdDirectories) {
|
|
899
|
+
for (const lock of [...locks].reverse()) {
|
|
900
|
+
fs.rmSync(path.join(lock, INSTALL_LOCK_OWNER), { force: true });
|
|
901
|
+
fs.rmdirSync(lock);
|
|
902
|
+
}
|
|
903
|
+
removeEmptyDirectories(createdDirectories);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function processIdentity(pid) {
|
|
907
|
+
const windows = process.platform === "win32";
|
|
908
|
+
const command = windows
|
|
909
|
+
? [
|
|
910
|
+
"powershell.exe",
|
|
911
|
+
[
|
|
912
|
+
"-NoProfile",
|
|
913
|
+
"-NonInteractive",
|
|
914
|
+
"-Command",
|
|
915
|
+
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
|
|
916
|
+
],
|
|
917
|
+
]
|
|
918
|
+
: ["ps", ["-o", "lstart=", "-p", String(pid)]];
|
|
919
|
+
const result = spawnSync(command[0], command[1], { encoding: "utf8", windowsHide: true });
|
|
920
|
+
const value = result.status === 0 ? result.stdout.trim() : "";
|
|
921
|
+
return value ? `${windows ? "nt" : "posix"}:${value}` : null;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function processAlive(pid) {
|
|
925
|
+
try {
|
|
926
|
+
process.kill(pid, 0);
|
|
927
|
+
return true;
|
|
928
|
+
} catch (error) {
|
|
929
|
+
return error.code !== "ESRCH";
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function staleInstallLockSnapshot(lock) {
|
|
934
|
+
if (isSymlink(lock) || !isDirectory(lock)) {
|
|
935
|
+
throw new InstallerError(`unsafe installation lock: ${lock}`);
|
|
936
|
+
}
|
|
937
|
+
let entries;
|
|
938
|
+
let observed;
|
|
939
|
+
let ownerBytes;
|
|
940
|
+
let owner;
|
|
941
|
+
try {
|
|
942
|
+
observed = fs.statSync(lock);
|
|
943
|
+
entries = fs.readdirSync(lock);
|
|
944
|
+
ownerBytes = fs.readFileSync(path.join(lock, INSTALL_LOCK_OWNER));
|
|
945
|
+
owner = JSON.parse(ownerBytes.toString("utf8"));
|
|
946
|
+
} catch {
|
|
947
|
+
throw new InstallerError(`installation already in progress for ${path.dirname(lock)}`);
|
|
948
|
+
}
|
|
949
|
+
const valid =
|
|
950
|
+
owner.schema === INSTALL_LOCK_SCHEMA &&
|
|
951
|
+
Number.isInteger(owner.pid) &&
|
|
952
|
+
owner.pid > 0 &&
|
|
953
|
+
typeof owner.identity === "string" &&
|
|
954
|
+
owner.identity &&
|
|
955
|
+
entries.length === 1 &&
|
|
956
|
+
entries[0] === INSTALL_LOCK_OWNER;
|
|
957
|
+
const currentIdentity = valid ? processIdentity(owner.pid) : null;
|
|
958
|
+
if (
|
|
959
|
+
!valid ||
|
|
960
|
+
currentIdentity === owner.identity ||
|
|
961
|
+
(currentIdentity === null && processAlive(owner.pid))
|
|
962
|
+
) {
|
|
963
|
+
throw new InstallerError(`installation already in progress for ${path.dirname(lock)}`);
|
|
964
|
+
}
|
|
965
|
+
return { observed, ownerBytes };
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function recoverStaleInstallLock(lock, quarantine) {
|
|
969
|
+
const legacyQuarantine = `${lock}.stale`;
|
|
970
|
+
if (!lexists(lock)) {
|
|
971
|
+
const prefix = `${path.basename(lock)}.stale-`;
|
|
972
|
+
for (const name of fs.readdirSync(path.dirname(lock))) {
|
|
973
|
+
if (!name.startsWith(prefix)) continue;
|
|
974
|
+
const orphan = path.join(path.dirname(lock), name);
|
|
975
|
+
const staging = path.join(path.dirname(lock), name.slice(prefix.length));
|
|
976
|
+
try {
|
|
977
|
+
staleInstallLockSnapshot(orphan);
|
|
978
|
+
if (lexists(staging)) staleInstallLockSnapshot(staging);
|
|
979
|
+
} catch {
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (lexists(staging)) fs.rmSync(staging, { recursive: true });
|
|
983
|
+
fs.rmSync(orphan, { recursive: true });
|
|
984
|
+
}
|
|
985
|
+
if (lexists(legacyQuarantine)) {
|
|
986
|
+
staleInstallLockSnapshot(legacyQuarantine);
|
|
987
|
+
fs.rmSync(legacyQuarantine, { recursive: true });
|
|
988
|
+
}
|
|
989
|
+
return null;
|
|
990
|
+
}
|
|
991
|
+
const { observed, ownerBytes } = staleInstallLockSnapshot(lock);
|
|
992
|
+
if (lexists(legacyQuarantine)) {
|
|
993
|
+
staleInstallLockSnapshot(legacyQuarantine);
|
|
994
|
+
fs.rmSync(legacyQuarantine, { recursive: true });
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
hooks.renameInstallLock(lock, quarantine);
|
|
998
|
+
} catch (error) {
|
|
999
|
+
if (error.code !== "ENOENT" && !lexists(quarantine)) throw error;
|
|
1000
|
+
throw new InstallerError(`installation already in progress for ${path.dirname(lock)}`);
|
|
1001
|
+
}
|
|
1002
|
+
let moved;
|
|
1003
|
+
let movedOwner;
|
|
1004
|
+
try {
|
|
1005
|
+
moved = fs.statSync(quarantine);
|
|
1006
|
+
movedOwner = fs.readFileSync(path.join(quarantine, INSTALL_LOCK_OWNER));
|
|
1007
|
+
} catch {
|
|
1008
|
+
throw new InstallerError(`installation already in progress for ${path.dirname(lock)}`);
|
|
1009
|
+
}
|
|
1010
|
+
if (
|
|
1011
|
+
moved.dev !== observed.dev ||
|
|
1012
|
+
moved.ino !== observed.ino ||
|
|
1013
|
+
!movedOwner.equals(ownerBytes)
|
|
1014
|
+
) {
|
|
1015
|
+
if (!lexists(lock)) fs.renameSync(quarantine, lock);
|
|
1016
|
+
throw new InstallerError(`installation already in progress for ${path.dirname(lock)}`);
|
|
1017
|
+
}
|
|
1018
|
+
return quarantine;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function createInstallLock(lock) {
|
|
1022
|
+
const identity = processIdentity(process.pid);
|
|
1023
|
+
if (identity === null) throw new InstallerError("cannot determine installer process identity");
|
|
1024
|
+
const staging = fs.mkdtempSync(path.join(path.dirname(lock), ".install-lock-stage-"));
|
|
1025
|
+
const recovery = `${lock}.stale-${path.basename(staging)}`;
|
|
1026
|
+
let quarantine = null;
|
|
1027
|
+
let published = false;
|
|
1028
|
+
try {
|
|
1029
|
+
fs.writeFileSync(
|
|
1030
|
+
path.join(staging, INSTALL_LOCK_OWNER),
|
|
1031
|
+
JSON.stringify({ schema: INSTALL_LOCK_SCHEMA, pid: process.pid, identity }) + "\n",
|
|
1032
|
+
{ flag: "wx" }
|
|
1033
|
+
);
|
|
1034
|
+
quarantine = recoverStaleInstallLock(lock, recovery);
|
|
1035
|
+
hooks.renameInstallStage(staging, lock);
|
|
1036
|
+
published = true;
|
|
1037
|
+
if (quarantine !== null) fs.rmSync(quarantine, { recursive: true });
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
1040
|
+
if (quarantine !== null && lexists(quarantine)) {
|
|
1041
|
+
if (published && lexists(lock)) fs.rmSync(lock, { recursive: true });
|
|
1042
|
+
if (!lexists(lock)) fs.renameSync(quarantine, lock);
|
|
1043
|
+
else fs.rmSync(quarantine, { recursive: true });
|
|
1044
|
+
}
|
|
1045
|
+
if (lexists(lock)) {
|
|
1046
|
+
throw new InstallerError(`installation already in progress for ${path.dirname(lock)}`);
|
|
1047
|
+
}
|
|
1048
|
+
throw error;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function acquireInstallLocks(roots) {
|
|
1053
|
+
const locks = [];
|
|
1054
|
+
for (const root of roots) {
|
|
1055
|
+
const candidate = path.join(path.dirname(root), INSTALL_LOCK_NAME);
|
|
1056
|
+
if (!locks.some((lock) => samePath(lock, candidate))) locks.push(candidate);
|
|
1057
|
+
}
|
|
1058
|
+
locks.sort();
|
|
1059
|
+
const acquired = [];
|
|
1060
|
+
const createdDirectories = [];
|
|
1061
|
+
try {
|
|
1062
|
+
for (const lock of locks) {
|
|
1063
|
+
createDirectory(path.dirname(lock), createdDirectories);
|
|
1064
|
+
createInstallLock(lock);
|
|
1065
|
+
acquired.push(lock);
|
|
1066
|
+
}
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
releaseInstallLocks(acquired, createdDirectories);
|
|
1069
|
+
throw error;
|
|
1070
|
+
}
|
|
1071
|
+
return { locks: acquired, createdDirectories };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function backupPath(root, reserved = []) {
|
|
1075
|
+
let candidate = path.join(path.dirname(root), "disabled-gsd-skills");
|
|
1076
|
+
let number = 1;
|
|
1077
|
+
while (lexists(candidate) || reserved.some((entry) => samePath(candidate, entry))) {
|
|
1078
|
+
candidate = path.join(path.dirname(root), `disabled-gsd-skills-${number}`);
|
|
1079
|
+
number += 1;
|
|
1080
|
+
}
|
|
1081
|
+
return candidate;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function backupExisting(transaction, extras = []) {
|
|
1085
|
+
const existing = fs
|
|
1086
|
+
.readdirSync(transaction.root)
|
|
1087
|
+
.filter((name) => isManagedInstallEntry(transaction.root, name))
|
|
1088
|
+
.sort()
|
|
1089
|
+
.map((name) => [path.join(transaction.root, name), name]);
|
|
1090
|
+
for (const [candidate, backupName] of extras) {
|
|
1091
|
+
if (lexists(candidate)) existing.push([candidate, backupName]);
|
|
1092
|
+
}
|
|
1093
|
+
if (existing.length) {
|
|
1094
|
+
transaction.backup = backupPath(transaction.root);
|
|
1095
|
+
fs.mkdirSync(transaction.backup);
|
|
1096
|
+
for (const [entry, backupName] of existing) {
|
|
1097
|
+
const stored = path.join(transaction.backup, backupName);
|
|
1098
|
+
transaction.moved.push([entry, stored]);
|
|
1099
|
+
hooks.rename(entry, stored);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async function applyTarget(plan, stagedRoot, transaction) {
|
|
1105
|
+
createDirectory(plan.root, transaction.createdDirectories);
|
|
1106
|
+
rejectRouterAliasCollisions(plan.root);
|
|
1107
|
+
const extras = [];
|
|
1108
|
+
let cursorAgent = null;
|
|
1109
|
+
if (plan.profile === "cursor") {
|
|
1110
|
+
cursorAgent = path.join(path.dirname(plan.root), "agents", CURSOR_AGENT_FILENAME);
|
|
1111
|
+
createDirectory(path.dirname(cursorAgent), transaction.createdDirectories);
|
|
1112
|
+
extras.push([cursorAgent, CURSOR_AGENT_BACKUP_NAME]);
|
|
1113
|
+
}
|
|
1114
|
+
backupExisting(transaction, extras);
|
|
1115
|
+
for (const name of SKILL_NAMES) {
|
|
1116
|
+
const destination = path.join(plan.root, name);
|
|
1117
|
+
hooks.reserveDirectory(destination);
|
|
1118
|
+
transaction.installed.push(destination);
|
|
1119
|
+
fs.cpSync(path.join(stagedRoot, name), destination, { recursive: true, errorOnExist: true, force: false });
|
|
1120
|
+
await tick();
|
|
1121
|
+
}
|
|
1122
|
+
if (cursorAgent !== null) {
|
|
1123
|
+
hooks.reserveFile(cursorAgent);
|
|
1124
|
+
transaction.installed.push(cursorAgent);
|
|
1125
|
+
fs.copyFileSync(path.join(stagedRoot, CURSOR_AGENT_FILENAME), cursorAgent);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function reserveDirectory(destination) {
|
|
1130
|
+
fs.mkdirSync(destination);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function reserveFile(destination) {
|
|
1134
|
+
const descriptor = fs.openSync(destination, "wx", 0o644);
|
|
1135
|
+
try {
|
|
1136
|
+
fs.closeSync(descriptor);
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
fs.rmSync(destination, { force: true });
|
|
1139
|
+
throw error;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function removePath(candidate) {
|
|
1144
|
+
if (!lexists(candidate)) return;
|
|
1145
|
+
if (isSymlink(candidate) || !isDirectory(candidate)) fs.unlinkSync(candidate);
|
|
1146
|
+
else fs.rmSync(candidate, { recursive: true });
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function removeEmptyDirectories(paths) {
|
|
1150
|
+
for (const candidate of [...paths].reverse()) {
|
|
1151
|
+
try {
|
|
1152
|
+
fs.rmdirSync(candidate);
|
|
1153
|
+
} catch {
|
|
1154
|
+
// Not empty or already gone; leave it.
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function rollbackTarget(transaction) {
|
|
1160
|
+
for (const destination of [...transaction.installed].reverse()) {
|
|
1161
|
+
removePath(destination);
|
|
1162
|
+
}
|
|
1163
|
+
for (const [original, stored] of [...transaction.moved].reverse()) {
|
|
1164
|
+
if (lexists(stored)) fs.renameSync(stored, original);
|
|
1165
|
+
}
|
|
1166
|
+
if (transaction.backup !== null) {
|
|
1167
|
+
try {
|
|
1168
|
+
fs.rmdirSync(transaction.backup);
|
|
1169
|
+
} catch {
|
|
1170
|
+
// Leave a non-empty backup in place.
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
removeEmptyDirectories(transaction.createdDirectories);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Each entry: [destination, sourceName, literalContent, executable].
|
|
1177
|
+
// hooksDir is the pre-resolved git hooks directory (or null); resolving it
|
|
1178
|
+
// once per run avoids repeated `git rev-parse` spawns.
|
|
1179
|
+
function projectDestinations(project, selected, hooksEnabled, interpreter, hooksDir) {
|
|
1180
|
+
const includeClaude = selected.includes("claude");
|
|
1181
|
+
const destinations = [
|
|
1182
|
+
[path.join(project, "AGENTS.md"), "AGENTS.md", null, false],
|
|
1183
|
+
[path.join(project, "WORKFLOW.md"), "WORKFLOW.md", null, false],
|
|
1184
|
+
[
|
|
1185
|
+
path.join(project, HOOKS_DIRECTORY, PROJECT_STATUS_LAUNCHER),
|
|
1186
|
+
path.join("scripts", PROJECT_STATUS_LAUNCHER),
|
|
1187
|
+
null,
|
|
1188
|
+
false,
|
|
1189
|
+
],
|
|
1190
|
+
];
|
|
1191
|
+
for (const name of PROJECT_RUNTIME_SCRIPTS) {
|
|
1192
|
+
destinations.push([
|
|
1193
|
+
path.join(project, HOOKS_DIRECTORY, "runtime", name),
|
|
1194
|
+
path.join("scripts", name),
|
|
1195
|
+
null,
|
|
1196
|
+
false,
|
|
1197
|
+
]);
|
|
1198
|
+
}
|
|
1199
|
+
if (includeClaude) {
|
|
1200
|
+
destinations.push([path.join(project, ".claude", "CLAUDE.md"), null, CLAUDE_BRIDGE, false]);
|
|
1201
|
+
}
|
|
1202
|
+
if (hooksEnabled) {
|
|
1203
|
+
for (const name of GUARD_SCRIPTS) {
|
|
1204
|
+
destinations.push([
|
|
1205
|
+
path.join(project, HOOKS_DIRECTORY, name),
|
|
1206
|
+
path.join("scripts", name),
|
|
1207
|
+
null,
|
|
1208
|
+
false,
|
|
1209
|
+
]);
|
|
1210
|
+
}
|
|
1211
|
+
if (includeClaude) {
|
|
1212
|
+
destinations.push([
|
|
1213
|
+
path.join(project, ".claude", "settings.json"),
|
|
1214
|
+
null,
|
|
1215
|
+
claudeHooksSettings(interpreter),
|
|
1216
|
+
false,
|
|
1217
|
+
]);
|
|
1218
|
+
}
|
|
1219
|
+
if (selected.includes("codex")) {
|
|
1220
|
+
destinations.push([
|
|
1221
|
+
path.join(project, ".codex", "hooks.json"),
|
|
1222
|
+
null,
|
|
1223
|
+
codexHooksSettings(interpreter),
|
|
1224
|
+
false,
|
|
1225
|
+
]);
|
|
1226
|
+
}
|
|
1227
|
+
if (selected.includes("cursor")) {
|
|
1228
|
+
destinations.push([
|
|
1229
|
+
path.join(project, ".cursor", "hooks.json"),
|
|
1230
|
+
null,
|
|
1231
|
+
cursorHooksSettings(interpreter),
|
|
1232
|
+
false,
|
|
1233
|
+
]);
|
|
1234
|
+
}
|
|
1235
|
+
if (hooksDir !== null) {
|
|
1236
|
+
for (const [hookName, generator] of GIT_HOOKS) {
|
|
1237
|
+
destinations.push([path.join(hooksDir, hookName), null, generator(interpreter), true]);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return destinations;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function nativeSettingsMergers(project, selected, hooksEnabled) {
|
|
1245
|
+
const mergers = new Map();
|
|
1246
|
+
if (!hooksEnabled) return mergers;
|
|
1247
|
+
if (selected.includes("claude")) {
|
|
1248
|
+
mergers.set(path.join(project, ".claude", "settings.json"), mergedClaudeSettings);
|
|
1249
|
+
}
|
|
1250
|
+
if (selected.includes("codex")) {
|
|
1251
|
+
mergers.set(path.join(project, ".codex", "hooks.json"), mergedCodexSettings);
|
|
1252
|
+
}
|
|
1253
|
+
if (selected.includes("cursor")) {
|
|
1254
|
+
mergers.set(path.join(project, ".cursor", "hooks.json"), mergedCursorSettings);
|
|
1255
|
+
}
|
|
1256
|
+
return mergers;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// --update replaces managed runtime, guard, and git hook files in place and
|
|
1260
|
+
// keeps everything else (AGENTS.md, WORKFLOW.md, CLAUDE.md). Returns the
|
|
1261
|
+
// managed-file check for a replaceable destination, or null for a kept one.
|
|
1262
|
+
function updateReplacement(project, destination, hooksDir) {
|
|
1263
|
+
const parent = path.join(project, HOOKS_DIRECTORY);
|
|
1264
|
+
const directory = path.dirname(destination);
|
|
1265
|
+
if (samePath(directory, path.join(parent, "runtime"))) return isManagedProjectRuntime;
|
|
1266
|
+
if (samePath(destination, path.join(parent, PROJECT_STATUS_LAUNCHER))) {
|
|
1267
|
+
return isManagedProjectStatusLauncher;
|
|
1268
|
+
}
|
|
1269
|
+
if (GUARD_SCRIPTS.some((name) => samePath(destination, path.join(parent, name)))) {
|
|
1270
|
+
return isManagedGuardScript;
|
|
1271
|
+
}
|
|
1272
|
+
if (hooksDir !== null && samePath(directory, hooksDir)) return isManagedGitHook;
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function describeProjectPath(project, destination) {
|
|
1277
|
+
const relative = path.relative(project, destination);
|
|
1278
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
1279
|
+
return destination.split(path.sep).join("/");
|
|
1280
|
+
}
|
|
1281
|
+
return relative.split(path.sep).join("/");
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function projectFiles(project, selected, hooksEnabled, interpreter, hooksDir) {
|
|
1285
|
+
return projectDestinations(project, selected, hooksEnabled, interpreter, hooksDir)
|
|
1286
|
+
.map(([destination]) => describeProjectPath(project, destination))
|
|
1287
|
+
.join(", ");
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function projectResult(project, selected, hooksEnabled, interpreter, hooksDir, update, dryRun) {
|
|
1291
|
+
if (!update) {
|
|
1292
|
+
const files = projectFiles(project, selected, hooksEnabled, interpreter, hooksDir);
|
|
1293
|
+
return `project: ${dryRun ? "would copy" : "copied"} ${files} to ${project}`;
|
|
1294
|
+
}
|
|
1295
|
+
const mergers = nativeSettingsMergers(project, selected, hooksEnabled);
|
|
1296
|
+
const refreshed = [];
|
|
1297
|
+
const kept = [];
|
|
1298
|
+
for (const [destination] of projectDestinations(project, selected, hooksEnabled, interpreter, hooksDir)) {
|
|
1299
|
+
const replaceable =
|
|
1300
|
+
mergers.has(destination) || updateReplacement(project, destination, hooksDir) !== null;
|
|
1301
|
+
(lexists(destination) && !replaceable ? kept : refreshed).push(describeProjectPath(project, destination));
|
|
1302
|
+
}
|
|
1303
|
+
let line = `project: ${dryRun ? "would refresh" : "refreshed"} ${refreshed.join(", ")} in ${project}`;
|
|
1304
|
+
if (kept.length) line += `; kept ${kept.join(", ")}`;
|
|
1305
|
+
return line;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function existingContractError(destination) {
|
|
1309
|
+
return new InstallerError(
|
|
1310
|
+
`project contract already exists: ${destination} — the installer never ` +
|
|
1311
|
+
"overwrites project files. Run --update --project PATH to refresh managed " +
|
|
1312
|
+
"runtime files; merge template changes manually (see UPDATE.md)."
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function validateProject(sourceRoot, project, selected, hooksEnabled, reservedRoots, interpreter, hooksDir, update = false) {
|
|
1317
|
+
const includeClaude = selected.includes("claude");
|
|
1318
|
+
validateDirectoryDestination(project, "project path");
|
|
1319
|
+
validateProjectGitRoot(project);
|
|
1320
|
+
validateDirectoryDestination(
|
|
1321
|
+
path.join(project, HOOKS_DIRECTORY),
|
|
1322
|
+
"project runtime parent directory"
|
|
1323
|
+
);
|
|
1324
|
+
validateDirectoryDestination(
|
|
1325
|
+
path.join(project, HOOKS_DIRECTORY, "runtime"),
|
|
1326
|
+
"project runtime directory"
|
|
1327
|
+
);
|
|
1328
|
+
const projectDirectories = [];
|
|
1329
|
+
if (includeClaude) projectDirectories.push(["Claude", path.join(project, ".claude")]);
|
|
1330
|
+
if (hooksEnabled && selected.includes("codex")) {
|
|
1331
|
+
projectDirectories.push(["Codex", path.join(project, ".codex")]);
|
|
1332
|
+
}
|
|
1333
|
+
if (hooksEnabled && selected.includes("cursor")) {
|
|
1334
|
+
projectDirectories.push(["Cursor", path.join(project, ".cursor")]);
|
|
1335
|
+
}
|
|
1336
|
+
for (const [label, directory] of projectDirectories) {
|
|
1337
|
+
if (lexists(directory) && (isSymlink(directory) || !isDirectory(directory))) {
|
|
1338
|
+
throw new InstallerError(`unsafe ${label} project directory: ${directory}`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
const sources = [
|
|
1342
|
+
"AGENTS.md",
|
|
1343
|
+
"WORKFLOW.md",
|
|
1344
|
+
path.join("scripts", PROJECT_STATUS_LAUNCHER),
|
|
1345
|
+
...PROJECT_RUNTIME_SCRIPTS.map((name) => path.join("scripts", name)),
|
|
1346
|
+
];
|
|
1347
|
+
if (hooksEnabled) {
|
|
1348
|
+
sources.push(...GUARD_SCRIPTS.map((name) => path.join("scripts", name)));
|
|
1349
|
+
}
|
|
1350
|
+
for (const sourceName of sources) {
|
|
1351
|
+
const source = path.join(sourceRoot, sourceName);
|
|
1352
|
+
if (isSymlink(source) || !isFile(source)) {
|
|
1353
|
+
throw new InstallerError(`missing project contract: ${source}`);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
const mergers = nativeSettingsMergers(project, selected, hooksEnabled);
|
|
1357
|
+
for (const [destination] of projectDestinations(project, selected, hooksEnabled, interpreter, hooksDir)) {
|
|
1358
|
+
if (lexists(destination)) {
|
|
1359
|
+
const merge = mergers.get(destination);
|
|
1360
|
+
const managed = update ? updateReplacement(project, destination, hooksDir) : null;
|
|
1361
|
+
if (isSymlink(destination) || (merge === undefined && !update)) {
|
|
1362
|
+
throw existingContractError(destination);
|
|
1363
|
+
}
|
|
1364
|
+
if (merge !== undefined) {
|
|
1365
|
+
merge(destination, interpreter);
|
|
1366
|
+
} else if (managed !== null && !managed(destination)) {
|
|
1367
|
+
throw new InstallerError(`not a managed GSD Path project file: ${destination}`);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
for (const [label, root] of reservedRoots) {
|
|
1371
|
+
if (pathsOverlap(destination, root)) {
|
|
1372
|
+
throw new InstallerError(`project contract overlaps ${label}: ${destination}, ${root}`);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function applyProject(sourceRoot, project, selected, hooksEnabled, transaction, interpreter, hooksDir, update = false) {
|
|
1379
|
+
createDirectory(project, transaction.createdDirectories);
|
|
1380
|
+
const mergers = nativeSettingsMergers(project, selected, hooksEnabled);
|
|
1381
|
+
for (const [destination, sourceName, literal, executable] of projectDestinations(
|
|
1382
|
+
project,
|
|
1383
|
+
selected,
|
|
1384
|
+
hooksEnabled,
|
|
1385
|
+
interpreter,
|
|
1386
|
+
hooksDir
|
|
1387
|
+
)) {
|
|
1388
|
+
createDirectory(path.dirname(destination), transaction.createdDirectories);
|
|
1389
|
+
const merge = mergers.get(destination);
|
|
1390
|
+
const content = sourceName
|
|
1391
|
+
? fs.readFileSync(path.join(sourceRoot, sourceName))
|
|
1392
|
+
: Buffer.from(literal, "utf8");
|
|
1393
|
+
if (lexists(destination) && !isSymlink(destination)) {
|
|
1394
|
+
const replaceable = update && updateReplacement(project, destination, hooksDir) !== null;
|
|
1395
|
+
if (merge !== undefined || replaceable) {
|
|
1396
|
+
const original = fs.readFileSync(destination);
|
|
1397
|
+
const mode = fs.statSync(destination).mode & 0o777;
|
|
1398
|
+
const replacement = merge !== undefined ? merge(destination, interpreter) : content;
|
|
1399
|
+
writeFileAtomic(destination, replacement, executable ? 0o755 : mode);
|
|
1400
|
+
transaction.replaced.push({ destination, original, mode });
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
if (update) continue;
|
|
1404
|
+
}
|
|
1405
|
+
let fd = null;
|
|
1406
|
+
try {
|
|
1407
|
+
fd = fs.openSync(destination, "wx");
|
|
1408
|
+
fs.writeSync(fd, content);
|
|
1409
|
+
if (executable) fs.fchmodSync(fd, 0o755);
|
|
1410
|
+
fs.closeSync(fd);
|
|
1411
|
+
fd = null;
|
|
1412
|
+
transaction.copied.push(destination);
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
if (fd !== null) fs.closeSync(fd);
|
|
1415
|
+
if (error && error.code === "EEXIST") {
|
|
1416
|
+
throw existingContractError(destination);
|
|
1417
|
+
}
|
|
1418
|
+
removePath(destination);
|
|
1419
|
+
throw error;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function rollbackProject(transaction) {
|
|
1425
|
+
for (const { destination, original, mode } of [...transaction.replaced].reverse()) {
|
|
1426
|
+
writeFileAtomic(destination, original, mode);
|
|
1427
|
+
}
|
|
1428
|
+
for (const destination of [...transaction.copied].reverse()) {
|
|
1429
|
+
removePath(destination);
|
|
1430
|
+
}
|
|
1431
|
+
removeEmptyDirectories(transaction.createdDirectories);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function managedFileContains(destination, marker, label) {
|
|
1435
|
+
if (!isFile(destination)) return false;
|
|
1436
|
+
try {
|
|
1437
|
+
return fs.readFileSync(destination, "utf8").includes(marker);
|
|
1438
|
+
} catch {
|
|
1439
|
+
throw new InstallerError(`cannot read ${label}: ${destination}`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function isManagedGuardScript(destination) {
|
|
1444
|
+
return managedFileContains(destination, GUARD_MARKER, "guard script");
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function isManagedProjectRuntime(destination) {
|
|
1448
|
+
return managedFileContains(destination, PROJECT_RUNTIME_MARKER, "project runtime");
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function isManagedProjectStatusLauncher(destination) {
|
|
1452
|
+
return managedFileContains(
|
|
1453
|
+
destination,
|
|
1454
|
+
PROJECT_STATUS_MARKER,
|
|
1455
|
+
"project status launcher"
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function isManagedGitHook(destination) {
|
|
1460
|
+
if (!isFile(destination)) return false;
|
|
1461
|
+
return isManagedGitHookContent(fs.readFileSync(destination, "utf8"));
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function isManagedGitHookContent(text) {
|
|
1465
|
+
return text.includes(GUARD_MARKER) && text.includes("git_guard.py");
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function isManagedHookSettings(destination) {
|
|
1469
|
+
if (!isFile(destination)) return false;
|
|
1470
|
+
try {
|
|
1471
|
+
return hasManagedHookSettings(parsedManagedSettings(destination));
|
|
1472
|
+
} catch {
|
|
1473
|
+
return false;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function hasManagedGuardWiring(project) {
|
|
1478
|
+
const settings = [
|
|
1479
|
+
path.join(project, ".claude", "settings.json"),
|
|
1480
|
+
path.join(project, ".codex", "hooks.json"),
|
|
1481
|
+
path.join(project, ".cursor", "hooks.json"),
|
|
1482
|
+
];
|
|
1483
|
+
if (settings.some(isManagedHookSettings)) return true;
|
|
1484
|
+
const hooksDir = gitHooksDirectory(project);
|
|
1485
|
+
return (
|
|
1486
|
+
hooksDir !== null &&
|
|
1487
|
+
GIT_HOOK_NAMES.some((name) => isManagedGitHook(path.join(hooksDir, name)))
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function atomicTemporary(destination) {
|
|
1492
|
+
const directory = fs.mkdtempSync(
|
|
1493
|
+
path.join(path.dirname(destination), `.${path.basename(destination)}.gsd-path-tmp-`)
|
|
1494
|
+
);
|
|
1495
|
+
return { directory, temporary: path.join(directory, "value") };
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function writeFileAtomic(destination, content, mode) {
|
|
1499
|
+
const { directory, temporary } = atomicTemporary(destination);
|
|
1500
|
+
try {
|
|
1501
|
+
fs.writeFileSync(temporary, content, { flag: "wx" });
|
|
1502
|
+
if (mode !== undefined) {
|
|
1503
|
+
fs.chmodSync(temporary, mode);
|
|
1504
|
+
} else if (isFile(destination) && !isSymlink(destination)) {
|
|
1505
|
+
fs.chmodSync(temporary, fs.statSync(destination).mode & 0o777);
|
|
1506
|
+
}
|
|
1507
|
+
fs.renameSync(temporary, destination);
|
|
1508
|
+
} finally {
|
|
1509
|
+
removePath(directory);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function copyFileAtomic(source, destination) {
|
|
1514
|
+
const { directory, temporary } = atomicTemporary(destination);
|
|
1515
|
+
try {
|
|
1516
|
+
fs.copyFileSync(source, temporary, fs.constants.COPYFILE_EXCL);
|
|
1517
|
+
fs.renameSync(temporary, destination);
|
|
1518
|
+
} finally {
|
|
1519
|
+
removePath(directory);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// A PreToolUse entry is ours when one of its commands runs the guard hook.
|
|
1524
|
+
function isGuardCommand(command) {
|
|
1525
|
+
const normalized = command.replaceAll("\\", "/");
|
|
1526
|
+
const match = /^(?:python3|python)\s+(?:"([^"\r\n]+)"|'([^'\r\n]+)'|(\S+))$/.exec(
|
|
1527
|
+
normalized
|
|
1528
|
+
);
|
|
1529
|
+
if (match === null) return false;
|
|
1530
|
+
const script = match[1] || match[2] || match[3];
|
|
1531
|
+
const managedScript = `${HOOKS_DIRECTORY}/guard_hook.py`;
|
|
1532
|
+
return script === managedScript || script.endsWith(`/${managedScript}`);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function isManagedCommandHook(hook) {
|
|
1536
|
+
return Boolean(
|
|
1537
|
+
hook &&
|
|
1538
|
+
typeof hook === "object" &&
|
|
1539
|
+
!Array.isArray(hook) &&
|
|
1540
|
+
typeof hook.command === "string" &&
|
|
1541
|
+
isGuardCommand(hook.command)
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function isManagedHookEntry(entry) {
|
|
1546
|
+
return Boolean(
|
|
1547
|
+
entry &&
|
|
1548
|
+
typeof entry === "object" &&
|
|
1549
|
+
!Array.isArray(entry) &&
|
|
1550
|
+
Array.isArray(entry.hooks) &&
|
|
1551
|
+
entry.hooks.some(isManagedCommandHook)
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
function isManagedDirectHookEntry(entry) {
|
|
1556
|
+
return isManagedCommandHook(entry);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function hasManagedHookSettings(parsed) {
|
|
1560
|
+
const hooks = parsed.hooks;
|
|
1561
|
+
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) return false;
|
|
1562
|
+
const nested = hooks.PreToolUse;
|
|
1563
|
+
const direct = hooks.preToolUse;
|
|
1564
|
+
return (
|
|
1565
|
+
(Array.isArray(nested) && nested.some(isManagedHookEntry)) ||
|
|
1566
|
+
(Array.isArray(direct) && direct.some(isManagedDirectHookEntry))
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function parsedManagedSettings(settings) {
|
|
1571
|
+
let parsed;
|
|
1572
|
+
try {
|
|
1573
|
+
parsed = JSON.parse(fs.readFileSync(settings, "utf8"));
|
|
1574
|
+
} catch {
|
|
1575
|
+
throw new InstallerError(`managed hook settings file is not valid JSON: ${settings}`);
|
|
1576
|
+
}
|
|
1577
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1578
|
+
throw new InstallerError(`managed hook settings file is not a JSON object: ${settings}`);
|
|
1579
|
+
}
|
|
1580
|
+
return parsed;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
function mergedHookSettings(settings, eventName, managedEntry, isManagedEntry) {
|
|
1584
|
+
const parsed = parsedManagedSettings(settings);
|
|
1585
|
+
const hooksObject =
|
|
1586
|
+
parsed.hooks && typeof parsed.hooks === "object" && !Array.isArray(parsed.hooks)
|
|
1587
|
+
? parsed.hooks
|
|
1588
|
+
: {};
|
|
1589
|
+
const existing = Array.isArray(hooksObject[eventName]) ? hooksObject[eventName] : [];
|
|
1590
|
+
const merged = [];
|
|
1591
|
+
let replaced = false;
|
|
1592
|
+
for (const entry of existing) {
|
|
1593
|
+
if (isManagedEntry(entry)) {
|
|
1594
|
+
if (!replaced) {
|
|
1595
|
+
merged.push(managedEntry);
|
|
1596
|
+
replaced = true;
|
|
1597
|
+
}
|
|
1598
|
+
} else {
|
|
1599
|
+
merged.push(entry);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
if (!replaced) merged.push(managedEntry);
|
|
1603
|
+
hooksObject[eventName] = merged;
|
|
1604
|
+
parsed.hooks = hooksObject;
|
|
1605
|
+
return JSON.stringify(parsed, null, 2) + "\n";
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function mergedNestedHookSettings(settings, managedEntry) {
|
|
1609
|
+
const parsed = parsedManagedSettings(settings);
|
|
1610
|
+
const hooksObject =
|
|
1611
|
+
parsed.hooks && typeof parsed.hooks === "object" && !Array.isArray(parsed.hooks)
|
|
1612
|
+
? parsed.hooks
|
|
1613
|
+
: {};
|
|
1614
|
+
const existing = Array.isArray(hooksObject.PreToolUse) ? hooksObject.PreToolUse : [];
|
|
1615
|
+
const merged = [];
|
|
1616
|
+
let replaced = false;
|
|
1617
|
+
for (const entry of existing) {
|
|
1618
|
+
if (!isManagedHookEntry(entry)) {
|
|
1619
|
+
merged.push(entry);
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
const unrelatedHooks = entry.hooks.filter((hook) => !isManagedCommandHook(hook));
|
|
1623
|
+
if (!replaced) {
|
|
1624
|
+
merged.push(managedEntry);
|
|
1625
|
+
replaced = true;
|
|
1626
|
+
}
|
|
1627
|
+
if (unrelatedHooks.length > 0) {
|
|
1628
|
+
merged.push({ ...entry, hooks: unrelatedHooks });
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
if (!replaced) merged.push(managedEntry);
|
|
1632
|
+
hooksObject.PreToolUse = merged;
|
|
1633
|
+
parsed.hooks = hooksObject;
|
|
1634
|
+
return JSON.stringify(parsed, null, 2) + "\n";
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function mergedClaudeSettings(settings, interpreter) {
|
|
1638
|
+
return mergedNestedHookSettings(settings, claudeGuardEntry(interpreter));
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function mergedCodexSettings(settings, interpreter) {
|
|
1642
|
+
const managedEntry = JSON.parse(codexHooksSettings(interpreter)).hooks.PreToolUse[0];
|
|
1643
|
+
return mergedNestedHookSettings(settings, managedEntry);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function mergedCursorSettings(settings, interpreter) {
|
|
1647
|
+
const managedEntry = JSON.parse(cursorHooksSettings(interpreter)).hooks.preToolUse[0];
|
|
1648
|
+
return mergedHookSettings(settings, "preToolUse", managedEntry, isManagedDirectHookEntry);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function refreshesGuards(project, full, initialize) {
|
|
1652
|
+
return (
|
|
1653
|
+
full ||
|
|
1654
|
+
initialize ||
|
|
1655
|
+
hasManagedGuardWiring(project) ||
|
|
1656
|
+
GUARD_SCRIPTS.some((name) => lexists(path.join(project, HOOKS_DIRECTORY, name)))
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
function contractSection(content, heading) {
|
|
1661
|
+
const normalized = content.replaceAll("\r\n", "\n");
|
|
1662
|
+
const startPattern = new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m");
|
|
1663
|
+
const startMatch = startPattern.exec(normalized);
|
|
1664
|
+
if (startMatch === null) return null;
|
|
1665
|
+
const depth = heading.indexOf(" ");
|
|
1666
|
+
const afterHeading = normalized.indexOf("\n", startMatch.index);
|
|
1667
|
+
const bodyStart = afterHeading === -1 ? normalized.length : afterHeading + 1;
|
|
1668
|
+
const nextHeading = new RegExp(`^#{1,${depth}}\\s`, "m").exec(normalized.slice(bodyStart));
|
|
1669
|
+
const end = nextHeading === null ? normalized.length : bodyStart + nextHeading.index;
|
|
1670
|
+
return normalized.slice(startMatch.index, end).trimEnd();
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function hasLegacyProjectContracts(sourceRoot, project) {
|
|
1674
|
+
return PROJECT_CONTRACTS.every(([name, heading]) => {
|
|
1675
|
+
const candidate = path.join(project, name);
|
|
1676
|
+
const source = path.join(sourceRoot, name);
|
|
1677
|
+
if (
|
|
1678
|
+
isSymlink(candidate) ||
|
|
1679
|
+
!isFile(candidate) ||
|
|
1680
|
+
isSymlink(source) ||
|
|
1681
|
+
!isFile(source)
|
|
1682
|
+
) return false;
|
|
1683
|
+
const installed = contractSection(fs.readFileSync(candidate, "utf8"), heading);
|
|
1684
|
+
const canonical = contractSection(fs.readFileSync(source, "utf8"), heading);
|
|
1685
|
+
return installed !== null && installed === canonical;
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function validateHooksRefresh(sourceRoot, project, full, hooksDir, selected, initialize = false) {
|
|
1690
|
+
validateDirectoryDestination(project, "project path");
|
|
1691
|
+
validateProjectGitRoot(project);
|
|
1692
|
+
validateDirectoryDestination(
|
|
1693
|
+
path.join(project, HOOKS_DIRECTORY),
|
|
1694
|
+
"guard hooks directory"
|
|
1695
|
+
);
|
|
1696
|
+
validateDirectoryDestination(
|
|
1697
|
+
path.join(project, HOOKS_DIRECTORY, "runtime"),
|
|
1698
|
+
"project runtime directory"
|
|
1699
|
+
);
|
|
1700
|
+
const refreshGuards = refreshesGuards(project, full, initialize);
|
|
1701
|
+
const runtimeExists = PROJECT_RUNTIME_SCRIPTS.some((name) =>
|
|
1702
|
+
lexists(path.join(project, HOOKS_DIRECTORY, "runtime", name))
|
|
1703
|
+
);
|
|
1704
|
+
if (
|
|
1705
|
+
!initialize &&
|
|
1706
|
+
!refreshGuards &&
|
|
1707
|
+
!runtimeExists &&
|
|
1708
|
+
!hasLegacyProjectContracts(sourceRoot, project)
|
|
1709
|
+
) {
|
|
1710
|
+
throw new InstallerError(
|
|
1711
|
+
`no managed GSD Path hooks or runtime found in project: ${project}`
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
const runtime = path.join(project, HOOKS_DIRECTORY, "runtime");
|
|
1715
|
+
const launcher = path.join(project, HOOKS_DIRECTORY, PROJECT_STATUS_LAUNCHER);
|
|
1716
|
+
const launcherSource = path.join(sourceRoot, "scripts", PROJECT_STATUS_LAUNCHER);
|
|
1717
|
+
if (isSymlink(launcher) || (lexists(launcher) && !isManagedProjectStatusLauncher(launcher))) {
|
|
1718
|
+
throw new InstallerError(`not a managed GSD Path status launcher: ${launcher}`);
|
|
1719
|
+
}
|
|
1720
|
+
if (isSymlink(launcherSource) || !isFile(launcherSource)) {
|
|
1721
|
+
throw new InstallerError(`missing project status launcher source: ${launcherSource}`);
|
|
1722
|
+
}
|
|
1723
|
+
if (isDirectory(runtime)) {
|
|
1724
|
+
const unexpected = fs
|
|
1725
|
+
.readdirSync(runtime)
|
|
1726
|
+
.filter((name) => !PROJECT_RUNTIME_SCRIPTS.includes(name));
|
|
1727
|
+
if (unexpected.length) {
|
|
1728
|
+
throw new InstallerError(`unexpected project runtime entries: ${unexpected.join(", ")}`);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
if (refreshGuards) {
|
|
1732
|
+
for (const name of GUARD_SCRIPTS) {
|
|
1733
|
+
const destination = path.join(project, HOOKS_DIRECTORY, name);
|
|
1734
|
+
const exists = lexists(destination);
|
|
1735
|
+
if (isSymlink(destination)) {
|
|
1736
|
+
throw new InstallerError(`refusing to refresh a symlink: ${destination}`);
|
|
1737
|
+
}
|
|
1738
|
+
if (exists && !isManagedGuardScript(destination)) {
|
|
1739
|
+
throw new InstallerError(`not a managed GSD Path guard script: ${destination}`);
|
|
1740
|
+
}
|
|
1741
|
+
const source = path.join(sourceRoot, "scripts", name);
|
|
1742
|
+
if (isSymlink(source) || !isFile(source)) {
|
|
1743
|
+
throw new InstallerError(`missing guard script source: ${source}`);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
for (const name of PROJECT_RUNTIME_SCRIPTS) {
|
|
1748
|
+
const destination = path.join(project, HOOKS_DIRECTORY, "runtime", name);
|
|
1749
|
+
if (isSymlink(destination)) {
|
|
1750
|
+
throw new InstallerError(`refusing to refresh a symlink: ${destination}`);
|
|
1751
|
+
}
|
|
1752
|
+
if (lexists(destination) && !isManagedProjectRuntime(destination)) {
|
|
1753
|
+
throw new InstallerError(`not a managed GSD Path project runtime: ${destination}`);
|
|
1754
|
+
}
|
|
1755
|
+
const source = path.join(sourceRoot, "scripts", name);
|
|
1756
|
+
if (isSymlink(source) || !isFile(source)) {
|
|
1757
|
+
throw new InstallerError(`missing project runtime source: ${source}`);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
if (full) {
|
|
1761
|
+
for (const [target, label, settings] of [
|
|
1762
|
+
["claude", "Claude", path.join(project, ".claude", "settings.json")],
|
|
1763
|
+
["codex", "Codex", path.join(project, ".codex", "hooks.json")],
|
|
1764
|
+
["cursor", "Cursor", path.join(project, ".cursor", "hooks.json")],
|
|
1765
|
+
]) {
|
|
1766
|
+
const exists = lexists(settings);
|
|
1767
|
+
if (initialize && !selected.includes(target)) continue;
|
|
1768
|
+
if (!exists && !selected.includes(target)) continue;
|
|
1769
|
+
validateDirectoryDestination(
|
|
1770
|
+
path.dirname(settings),
|
|
1771
|
+
`unsafe ${label} project directory`
|
|
1772
|
+
);
|
|
1773
|
+
if (isSymlink(settings)) {
|
|
1774
|
+
throw new InstallerError(`refusing to refresh a symlink: ${settings}`);
|
|
1775
|
+
}
|
|
1776
|
+
if (exists) {
|
|
1777
|
+
const parsed = parsedManagedSettings(settings);
|
|
1778
|
+
if (!selected.includes(target) && !hasManagedHookSettings(parsed)) {
|
|
1779
|
+
throw new InstallerError(`not a managed GSD Path hook settings file: ${settings}`);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
if (hooksDir !== null) {
|
|
1784
|
+
for (const hookName of GIT_HOOK_NAMES) {
|
|
1785
|
+
const hookPath = path.join(hooksDir, hookName);
|
|
1786
|
+
if (isSymlink(hookPath)) {
|
|
1787
|
+
throw new InstallerError(`refusing to refresh a symlink: ${hookPath}`);
|
|
1788
|
+
}
|
|
1789
|
+
if (lexists(hookPath) && !isManagedGitHook(hookPath)) {
|
|
1790
|
+
throw new InstallerError(`not a managed GSD Path git hook: ${hookPath}`);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function publishProjectRuntime(sourceRoot, project, refreshGuards) {
|
|
1798
|
+
const parent = path.join(project, HOOKS_DIRECTORY);
|
|
1799
|
+
const runtime = path.join(parent, "runtime");
|
|
1800
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
1801
|
+
const staging = fs.mkdtempSync(path.join(parent, ".runtime-stage-"));
|
|
1802
|
+
const previous = `${staging}-previous`;
|
|
1803
|
+
let movedPrevious = false;
|
|
1804
|
+
let publishedRuntime = false;
|
|
1805
|
+
const launcher = path.join(parent, PROJECT_STATUS_LAUNCHER);
|
|
1806
|
+
const launcherOriginal = lexists(launcher) ? fs.readFileSync(launcher) : null;
|
|
1807
|
+
const launcherMode = launcherOriginal === null ? undefined : fs.statSync(launcher).mode & 0o777;
|
|
1808
|
+
const guardOriginals = [];
|
|
1809
|
+
try {
|
|
1810
|
+
for (const name of PROJECT_RUNTIME_SCRIPTS) {
|
|
1811
|
+
hooks.copyRuntimeFile(
|
|
1812
|
+
path.join(sourceRoot, "scripts", name),
|
|
1813
|
+
path.join(staging, name)
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
copyFileAtomic(path.join(sourceRoot, "scripts", PROJECT_STATUS_LAUNCHER), launcher);
|
|
1817
|
+
if (lexists(runtime)) {
|
|
1818
|
+
fs.renameSync(runtime, previous);
|
|
1819
|
+
movedPrevious = true;
|
|
1820
|
+
}
|
|
1821
|
+
fs.renameSync(staging, runtime);
|
|
1822
|
+
publishedRuntime = true;
|
|
1823
|
+
if (refreshGuards) {
|
|
1824
|
+
for (const name of GUARD_SCRIPTS) {
|
|
1825
|
+
const destination = path.join(parent, name);
|
|
1826
|
+
const original = lexists(destination) ? fs.readFileSync(destination) : null;
|
|
1827
|
+
const mode =
|
|
1828
|
+
original === null ? undefined : fs.statSync(destination).mode & 0o777;
|
|
1829
|
+
guardOriginals.push([destination, original, mode]);
|
|
1830
|
+
hooks.copyGuardFile(path.join(sourceRoot, "scripts", name), destination);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
if (movedPrevious) removePath(previous);
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
for (const [destination, original, mode] of guardOriginals.reverse()) {
|
|
1836
|
+
if (original === null) removePath(destination);
|
|
1837
|
+
else writeFileAtomic(destination, original, mode);
|
|
1838
|
+
}
|
|
1839
|
+
if (launcherOriginal === null) removePath(launcher);
|
|
1840
|
+
else writeFileAtomic(launcher, launcherOriginal, launcherMode);
|
|
1841
|
+
if (publishedRuntime && lexists(runtime)) removePath(runtime);
|
|
1842
|
+
if (!lexists(runtime) && movedPrevious && lexists(previous)) {
|
|
1843
|
+
fs.renameSync(previous, runtime);
|
|
1844
|
+
}
|
|
1845
|
+
throw new InstallerError(`project runtime refresh failed: ${error.message}`);
|
|
1846
|
+
} finally {
|
|
1847
|
+
removePath(staging);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// Returns refreshed project-relative paths; entries prefixed "note:" are
|
|
1852
|
+
// user-facing notes rather than refreshed files.
|
|
1853
|
+
function refreshHooksUnlocked(sourceRoot, project, full, dryRun, selected, initialize) {
|
|
1854
|
+
let hooksDir = full ? gitHooksDirectory(project) : null;
|
|
1855
|
+
let interpreter = null;
|
|
1856
|
+
if (!dryRun) {
|
|
1857
|
+
if (full) {
|
|
1858
|
+
({ interpreter, hooksDir } = requiredHookRuntime(
|
|
1859
|
+
project,
|
|
1860
|
+
initialize ? "--hooks-init" : "--hooks-refresh-full",
|
|
1861
|
+
selected
|
|
1862
|
+
));
|
|
1863
|
+
} else {
|
|
1864
|
+
interpreter = requiredPythonRuntime("--hooks-refresh", selected);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
validateHooksRefresh(sourceRoot, project, full, hooksDir, selected, initialize);
|
|
1868
|
+
const refreshed = [];
|
|
1869
|
+
const refreshGuards = refreshesGuards(project, full, initialize);
|
|
1870
|
+
if (!dryRun) publishProjectRuntime(sourceRoot, project, refreshGuards);
|
|
1871
|
+
for (const name of PROJECT_RUNTIME_SCRIPTS) {
|
|
1872
|
+
refreshed.push(
|
|
1873
|
+
describeProjectPath(project, path.join(project, HOOKS_DIRECTORY, "runtime", name))
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
refreshed.push(
|
|
1877
|
+
describeProjectPath(
|
|
1878
|
+
project,
|
|
1879
|
+
path.join(project, HOOKS_DIRECTORY, PROJECT_STATUS_LAUNCHER)
|
|
1880
|
+
)
|
|
1881
|
+
);
|
|
1882
|
+
if (refreshGuards) {
|
|
1883
|
+
for (const name of GUARD_SCRIPTS) {
|
|
1884
|
+
const destination = path.join(project, HOOKS_DIRECTORY, name);
|
|
1885
|
+
refreshed.push(describeProjectPath(project, destination));
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
if (full) {
|
|
1889
|
+
for (const [target, settings, merge, generated] of [
|
|
1890
|
+
[
|
|
1891
|
+
"claude",
|
|
1892
|
+
path.join(project, ".claude", "settings.json"),
|
|
1893
|
+
mergedClaudeSettings,
|
|
1894
|
+
claudeHooksSettings,
|
|
1895
|
+
],
|
|
1896
|
+
[
|
|
1897
|
+
"codex",
|
|
1898
|
+
path.join(project, ".codex", "hooks.json"),
|
|
1899
|
+
mergedCodexSettings,
|
|
1900
|
+
codexHooksSettings,
|
|
1901
|
+
],
|
|
1902
|
+
[
|
|
1903
|
+
"cursor",
|
|
1904
|
+
path.join(project, ".cursor", "hooks.json"),
|
|
1905
|
+
mergedCursorSettings,
|
|
1906
|
+
cursorHooksSettings,
|
|
1907
|
+
],
|
|
1908
|
+
]) {
|
|
1909
|
+
if (initialize && !selected.includes(target)) continue;
|
|
1910
|
+
const exists = lexists(settings);
|
|
1911
|
+
if (exists || selected.includes(target)) {
|
|
1912
|
+
if (!dryRun) {
|
|
1913
|
+
fs.mkdirSync(path.dirname(settings), { recursive: true });
|
|
1914
|
+
const content = exists ? merge(settings, interpreter) : generated(interpreter);
|
|
1915
|
+
writeFileAtomic(settings, content);
|
|
1916
|
+
}
|
|
1917
|
+
refreshed.push(describeProjectPath(project, settings));
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
if (hooksDir !== null) {
|
|
1921
|
+
for (const [hookName, generator] of GIT_HOOKS) {
|
|
1922
|
+
const hookPath = path.join(hooksDir, hookName);
|
|
1923
|
+
if (!dryRun) {
|
|
1924
|
+
const content = generator(interpreter);
|
|
1925
|
+
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
|
|
1926
|
+
writeFileAtomic(hookPath, content, 0o755);
|
|
1927
|
+
}
|
|
1928
|
+
refreshed.push(describeProjectPath(project, hookPath));
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
return refreshed;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function refreshHooks(sourceRoot, project, full, dryRun, selected = [], initialize = false) {
|
|
1936
|
+
if (dryRun) {
|
|
1937
|
+
return refreshHooksUnlocked(sourceRoot, project, full, true, selected, initialize);
|
|
1938
|
+
}
|
|
1939
|
+
validateDirectoryDestination(project, "project path");
|
|
1940
|
+
const ownership = acquireInstallLocks([path.join(project, HOOKS_DIRECTORY)]);
|
|
1941
|
+
try {
|
|
1942
|
+
return refreshHooksUnlocked(sourceRoot, project, full, false, selected, initialize);
|
|
1943
|
+
} finally {
|
|
1944
|
+
releaseInstallLocks(ownership.locks, ownership.createdDirectories);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
const STATE_SCHEMA = "gsd-path/state/v1";
|
|
1949
|
+
|
|
1950
|
+
function validatedProjectState(sourceRoot, project) {
|
|
1951
|
+
const validator = path.join(sourceRoot, "scripts", "pipeline_state.py");
|
|
1952
|
+
if (isSymlink(validator) || !isFile(validator)) {
|
|
1953
|
+
throw new InstallerError(`canonical state validator is unavailable: ${validator}`);
|
|
1954
|
+
}
|
|
1955
|
+
const interpreter = effectiveInterpreter();
|
|
1956
|
+
if (interpreter === null) {
|
|
1957
|
+
throw new InstallerError("canonical state validation requires a working Python interpreter");
|
|
1958
|
+
}
|
|
1959
|
+
const result = spawnSync(
|
|
1960
|
+
interpreter,
|
|
1961
|
+
["-B", validator, "validate", "--repo", project],
|
|
1962
|
+
{ cwd: project, encoding: "utf8" }
|
|
1963
|
+
);
|
|
1964
|
+
if (result.error || result.status !== 0) {
|
|
1965
|
+
const detail =
|
|
1966
|
+
result.error?.message ||
|
|
1967
|
+
(result.stderr || "").trim() ||
|
|
1968
|
+
(result.stdout || "").trim() ||
|
|
1969
|
+
"unknown failure";
|
|
1970
|
+
throw new InstallerError(`canonical state validation failed: ${detail}`);
|
|
1971
|
+
}
|
|
1972
|
+
let payload;
|
|
1973
|
+
try {
|
|
1974
|
+
payload = JSON.parse(result.stdout);
|
|
1975
|
+
} catch {
|
|
1976
|
+
throw new InstallerError("canonical state validator returned invalid JSON");
|
|
1977
|
+
}
|
|
1978
|
+
if (!payload || typeof payload !== "object") {
|
|
1979
|
+
throw new InstallerError("canonical state validator returned an invalid payload");
|
|
1980
|
+
}
|
|
1981
|
+
const state = payload.state;
|
|
1982
|
+
if (
|
|
1983
|
+
payload.schema !== STATE_SCHEMA ||
|
|
1984
|
+
payload.status !== "valid" ||
|
|
1985
|
+
!state ||
|
|
1986
|
+
typeof state !== "object" ||
|
|
1987
|
+
typeof state.phase !== "string" ||
|
|
1988
|
+
typeof state.status !== "string"
|
|
1989
|
+
) {
|
|
1990
|
+
throw new InstallerError("canonical state validator returned an invalid payload");
|
|
1991
|
+
}
|
|
1992
|
+
return state;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function hasManagedInstall(root) {
|
|
1996
|
+
return (
|
|
1997
|
+
isDirectory(root) &&
|
|
1998
|
+
fs.readdirSync(root).some((name) => isManagedInstallEntry(root, name))
|
|
1999
|
+
);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
function validateProjectRuntimeStatus(sourceRoot, project) {
|
|
2003
|
+
const interpreter = requiredPythonRuntime("--doctor");
|
|
2004
|
+
const runtime = path.join(sourceRoot, "scripts", "pipeline_state.py");
|
|
2005
|
+
const result = spawnSync(
|
|
2006
|
+
interpreter,
|
|
2007
|
+
["-B", runtime, "status", "--repo", project],
|
|
2008
|
+
{
|
|
2009
|
+
cwd: project,
|
|
2010
|
+
encoding: "utf8",
|
|
2011
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
2012
|
+
}
|
|
2013
|
+
);
|
|
2014
|
+
if (result.error || result.status !== 0) {
|
|
2015
|
+
const detail = result.error?.message || result.stderr?.trim() || result.stdout?.trim() || "unknown failure";
|
|
2016
|
+
throw new InstallerError(`project runtime status failed: ${detail}`);
|
|
2017
|
+
}
|
|
2018
|
+
let payload;
|
|
2019
|
+
try {
|
|
2020
|
+
payload = JSON.parse(result.stdout);
|
|
2021
|
+
} catch {
|
|
2022
|
+
throw new InstallerError("project runtime status returned invalid JSON");
|
|
2023
|
+
}
|
|
2024
|
+
if (!validStatusPayload(payload, project)) {
|
|
2025
|
+
throw new InstallerError("project runtime status returned an invalid payload");
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
function projectRuntimeMatches(sourceRoot, project) {
|
|
2030
|
+
const pairs = [
|
|
2031
|
+
[
|
|
2032
|
+
path.join(project, HOOKS_DIRECTORY, PROJECT_STATUS_LAUNCHER),
|
|
2033
|
+
path.join(sourceRoot, "scripts", PROJECT_STATUS_LAUNCHER),
|
|
2034
|
+
],
|
|
2035
|
+
...PROJECT_RUNTIME_SCRIPTS.map((name) => [
|
|
2036
|
+
path.join(project, HOOKS_DIRECTORY, "runtime", name),
|
|
2037
|
+
path.join(sourceRoot, "scripts", name),
|
|
2038
|
+
]),
|
|
2039
|
+
];
|
|
2040
|
+
try {
|
|
2041
|
+
return pairs.every(
|
|
2042
|
+
([destination, source]) =>
|
|
2043
|
+
!isSymlink(destination) &&
|
|
2044
|
+
isFile(destination) &&
|
|
2045
|
+
fs.readFileSync(destination).equals(fs.readFileSync(source))
|
|
2046
|
+
);
|
|
2047
|
+
} catch {
|
|
2048
|
+
return false;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function validStatusPayload(payload, project) {
|
|
2053
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
2054
|
+
const state = payload.state;
|
|
2055
|
+
const route = payload.route;
|
|
2056
|
+
if (
|
|
2057
|
+
payload.schema !== "gsd-path/status/v1" ||
|
|
2058
|
+
payload.advance !== false ||
|
|
2059
|
+
!state ||
|
|
2060
|
+
typeof state !== "object" ||
|
|
2061
|
+
!validStatusState(state) ||
|
|
2062
|
+
!route ||
|
|
2063
|
+
typeof route !== "object" ||
|
|
2064
|
+
!STATUS_ACTIONS.has(route.action) ||
|
|
2065
|
+
typeof route.reason !== "string" ||
|
|
2066
|
+
!route.reason ||
|
|
2067
|
+
typeof payload.path !== "string" ||
|
|
2068
|
+
!path.isAbsolute(payload.path) ||
|
|
2069
|
+
!samePath(payload.path, path.join(project, ".project", "STATE.md"))
|
|
2070
|
+
) {
|
|
2071
|
+
return false;
|
|
2072
|
+
}
|
|
2073
|
+
let expectedNextSkill = null;
|
|
2074
|
+
if (route.action === "run-phase") {
|
|
2075
|
+
if (
|
|
2076
|
+
typeof route.phase !== "string" ||
|
|
2077
|
+
!STATUS_TRANSITIONS.get(state.phase).has(route.phase) ||
|
|
2078
|
+
state.branch === null
|
|
2079
|
+
) return false;
|
|
2080
|
+
expectedNextSkill = `gsd-path-${route.phase}`;
|
|
2081
|
+
} else if (Object.hasOwn(route, "phase")) {
|
|
2082
|
+
return false;
|
|
2083
|
+
} else if (route.action === "bind-initial") {
|
|
2084
|
+
return (
|
|
2085
|
+
state.branch === null &&
|
|
2086
|
+
typeof route.branch === "string" &&
|
|
2087
|
+
validStatusBranch(route.branch) !== null &&
|
|
2088
|
+
payload.next_skill === "gsd-path"
|
|
2089
|
+
);
|
|
2090
|
+
} else if (route.action === "resume-undo") {
|
|
2091
|
+
expectedNextSkill = "gsd-path-undo";
|
|
2092
|
+
} else if (route.action !== "wait") {
|
|
2093
|
+
expectedNextSkill = "gsd-path";
|
|
2094
|
+
}
|
|
2095
|
+
if (state.branch === null) return false;
|
|
2096
|
+
if (route.action === "validate-integrated" && !(state.phase === "shipped" && state.status === "done")) return false;
|
|
2097
|
+
if (route.action === "wait" && !(state.phase === "plan" && state.status === "done")) return false;
|
|
2098
|
+
return payload.next_skill === expectedNextSkill;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
function validStatusState(state) {
|
|
2102
|
+
if (Object.keys(state).sort().join("\0") !== STATUS_STATE_FIELDS.join("\0")) return false;
|
|
2103
|
+
const archiveMatch = typeof state.archive === "string" ? STATUS_ARCHIVE.exec(state.archive) : null;
|
|
2104
|
+
if (
|
|
2105
|
+
state.pipeline !== "gsd-path/v2" ||
|
|
2106
|
+
typeof state.project !== "string" ||
|
|
2107
|
+
!STATUS_SLUG.test(state.project) ||
|
|
2108
|
+
!(state.milestone === null || typeof state.milestone === "string" && STATUS_SLUG.test(state.milestone)) ||
|
|
2109
|
+
!STATUS_PHASES.has(state.phase) ||
|
|
2110
|
+
!STATUS_VALUES.has(state.status) ||
|
|
2111
|
+
!STATUS_INTEGRATION_MODES.has(state.integration_default) ||
|
|
2112
|
+
!STATUS_INTEGRATION_MODES.has(state.integration) ||
|
|
2113
|
+
!STATUS_INTEGRATION_SOURCES.has(state.integration_source) ||
|
|
2114
|
+
(state.integration_source === "default" &&
|
|
2115
|
+
state.integration !== state.integration_default) ||
|
|
2116
|
+
!(state.branch === null || validStatusBranch(state.branch) !== null) ||
|
|
2117
|
+
!(state.archive === null || archiveMatch)
|
|
2118
|
+
) return false;
|
|
2119
|
+
if (state.phase === "shipped" && (state.status !== "done" || state.archive === null)) return false;
|
|
2120
|
+
if (
|
|
2121
|
+
state.archive !== null &&
|
|
2122
|
+
state.phase !== "build" &&
|
|
2123
|
+
state.phase !== "ship" &&
|
|
2124
|
+
state.phase !== "shipped"
|
|
2125
|
+
) return false;
|
|
2126
|
+
if ((state.phase === "ship" || state.phase === "shipped") && state.branch === null) return false;
|
|
2127
|
+
if (archiveMatch) {
|
|
2128
|
+
const branchMatch = validStatusBranch(state.branch);
|
|
2129
|
+
return state.milestone === archiveMatch[2] && branchMatch !== null && Number(branchMatch[1]) === Number(archiveMatch[1]);
|
|
2130
|
+
}
|
|
2131
|
+
return true;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
function validStatusBranch(value) {
|
|
2135
|
+
if (typeof value !== "string") return null;
|
|
2136
|
+
const match = STATUS_BRANCH.exec(value);
|
|
2137
|
+
return match !== null && Number(match[1]) >= 1 ? match : null;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
function isExecutable(candidate) {
|
|
2141
|
+
try {
|
|
2142
|
+
return (fs.statSync(candidate).mode & 0o111) !== 0;
|
|
2143
|
+
} catch {
|
|
2144
|
+
return false;
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function nativeGuardContract(target, project) {
|
|
2149
|
+
if (target === "claude") {
|
|
2150
|
+
return {
|
|
2151
|
+
settings: path.join(project, ".claude", "settings.json"),
|
|
2152
|
+
event: "PreToolUse",
|
|
2153
|
+
entry: claudeGuardEntry,
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
if (target === "codex") {
|
|
2157
|
+
return {
|
|
2158
|
+
settings: path.join(project, ".codex", "hooks.json"),
|
|
2159
|
+
event: "PreToolUse",
|
|
2160
|
+
entry: codexGuardEntry,
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
if (target === "cursor") {
|
|
2164
|
+
return {
|
|
2165
|
+
settings: path.join(project, ".cursor", "hooks.json"),
|
|
2166
|
+
event: "preToolUse",
|
|
2167
|
+
entry: cursorGuardEntry,
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
return null;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
// Read-only health check: host installs, project contracts, guard hooks,
|
|
2174
|
+
// and pipeline state. Never writes.
|
|
2175
|
+
export function doctor(sourceRoot, { targets, rootFor, project = null }) {
|
|
2176
|
+
const findings = [];
|
|
2177
|
+
const push = (level, text) => findings.push({ level, text });
|
|
2178
|
+
function readProjectFile(candidate, label) {
|
|
2179
|
+
try {
|
|
2180
|
+
return fs.readFileSync(candidate);
|
|
2181
|
+
} catch (error) {
|
|
2182
|
+
push("fail", `${label} cannot be read: ${error.message}`);
|
|
2183
|
+
return null;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
const version = readPackageVersion(path.join(sourceRoot, "package.json"));
|
|
2187
|
+
if (version === null) push("fail", "package: version cannot be read");
|
|
2188
|
+
|
|
2189
|
+
const seen = [];
|
|
2190
|
+
const installedTargets = new Set();
|
|
2191
|
+
for (const target of targets) {
|
|
2192
|
+
const root = rootFor(target);
|
|
2193
|
+
const prior = seen.find(([, other]) => samePath(other, root));
|
|
2194
|
+
if (prior) {
|
|
2195
|
+
push("note", `${target}: shares ${prior[0]}'s skills root`);
|
|
2196
|
+
continue;
|
|
2197
|
+
}
|
|
2198
|
+
seen.push([target, root]);
|
|
2199
|
+
let managedInstall;
|
|
2200
|
+
try {
|
|
2201
|
+
managedInstall = hasManagedInstall(root);
|
|
2202
|
+
} catch (error) {
|
|
2203
|
+
push("fail", `${target}: skills root cannot be read: ${error.message}`);
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
if (!managedInstall) {
|
|
2207
|
+
push("note", `${target}: not installed (${root})`);
|
|
2208
|
+
continue;
|
|
2209
|
+
}
|
|
2210
|
+
installedTargets.add(target);
|
|
2211
|
+
const missing = SKILL_NAMES.filter((name) => !isDirectory(path.join(root, name)));
|
|
2212
|
+
if (missing.length) {
|
|
2213
|
+
push("fail", `${target}: incomplete install at ${root} — missing ${missing.join(", ")}`);
|
|
2214
|
+
continue;
|
|
2215
|
+
}
|
|
2216
|
+
let stamp = null;
|
|
2217
|
+
try {
|
|
2218
|
+
stamp = fs.readFileSync(path.join(root, "gsd-path", "VERSION"), "utf8").trim();
|
|
2219
|
+
} catch {
|
|
2220
|
+
// No stamp; reported below.
|
|
2221
|
+
}
|
|
2222
|
+
if (!stamp) {
|
|
2223
|
+
push("warn", `${target}: ${SKILL_NAMES.length} skills at ${root}, no VERSION stamp — run --update`);
|
|
2224
|
+
} else if (version && stamp !== version) {
|
|
2225
|
+
push("warn", `${target}: stale install at ${root} (v${stamp}, current v${version}) — run --update`);
|
|
2226
|
+
} else {
|
|
2227
|
+
push("ok", `${target}: ${SKILL_NAMES.length} skills at ${root} (v${stamp})`);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
if (project === null) return findings;
|
|
2232
|
+
|
|
2233
|
+
for (const [name, heading] of PROJECT_CONTRACTS) {
|
|
2234
|
+
const contract = path.join(project, name);
|
|
2235
|
+
if (isSymlink(contract)) {
|
|
2236
|
+
push("fail", `project: contract ${name} is a symlink`);
|
|
2237
|
+
continue;
|
|
2238
|
+
}
|
|
2239
|
+
if (!isFile(contract)) {
|
|
2240
|
+
push("fail", `project: missing contract ${name} — run --project "${project}"`);
|
|
2241
|
+
continue;
|
|
2242
|
+
}
|
|
2243
|
+
const content = readProjectFile(contract, `project: ${name}`);
|
|
2244
|
+
if (content === null) continue;
|
|
2245
|
+
const canonical = readProjectFile(
|
|
2246
|
+
path.join(sourceRoot, name),
|
|
2247
|
+
`package: ${name}`
|
|
2248
|
+
);
|
|
2249
|
+
if (canonical === null) continue;
|
|
2250
|
+
const installedSection = contractSection(content.toString("utf8"), heading);
|
|
2251
|
+
const canonicalSection = contractSection(canonical.toString("utf8"), heading);
|
|
2252
|
+
if (installedSection !== null && installedSection === canonicalSection) {
|
|
2253
|
+
push("ok", `project: ${name} present`);
|
|
2254
|
+
} else {
|
|
2255
|
+
push("fail", `project: ${name} lacks plain-prompt re-entry — merge the current contract`);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
const bridge = path.join(project, ".claude", "CLAUDE.md");
|
|
2259
|
+
if (!isFile(bridge)) {
|
|
2260
|
+
push("note", "project: no .claude/CLAUDE.md bridge (only written for --claude installs)");
|
|
2261
|
+
} else {
|
|
2262
|
+
const bridgeContent = readProjectFile(bridge, "project: .claude/CLAUDE.md");
|
|
2263
|
+
if (bridgeContent !== null) {
|
|
2264
|
+
if (bridgeContent.toString("utf8") === CLAUDE_BRIDGE) {
|
|
2265
|
+
push("ok", "project: .claude/CLAUDE.md bridge present");
|
|
2266
|
+
} else {
|
|
2267
|
+
push("note", "project: .claude/CLAUDE.md exists but is not the managed bridge");
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
const runtime = path.join(project, HOOKS_DIRECTORY, "runtime");
|
|
2273
|
+
const launcher = path.join(project, HOOKS_DIRECTORY, PROJECT_STATUS_LAUNCHER);
|
|
2274
|
+
let runtimeCurrent = true;
|
|
2275
|
+
if (isSymlink(launcher)) {
|
|
2276
|
+
push("fail", "project: status launcher is a symlink");
|
|
2277
|
+
runtimeCurrent = false;
|
|
2278
|
+
} else if (!isFile(launcher)) {
|
|
2279
|
+
push("fail", "project: missing status launcher");
|
|
2280
|
+
runtimeCurrent = false;
|
|
2281
|
+
} else {
|
|
2282
|
+
const launcherContent = readProjectFile(launcher, "project: status launcher");
|
|
2283
|
+
const launcherSource = readProjectFile(
|
|
2284
|
+
path.join(sourceRoot, "scripts", PROJECT_STATUS_LAUNCHER),
|
|
2285
|
+
"package: status launcher"
|
|
2286
|
+
);
|
|
2287
|
+
if (launcherContent === null || launcherSource === null) {
|
|
2288
|
+
runtimeCurrent = false;
|
|
2289
|
+
} else {
|
|
2290
|
+
if (!launcherContent.toString("utf8").includes(PROJECT_STATUS_MARKER)) {
|
|
2291
|
+
push("warn", "project: status launcher is not managed");
|
|
2292
|
+
runtimeCurrent = false;
|
|
2293
|
+
} else if (!launcherContent.equals(launcherSource)) {
|
|
2294
|
+
push(
|
|
2295
|
+
"warn",
|
|
2296
|
+
"project: status launcher is stale — refresh it with the project contracts"
|
|
2297
|
+
);
|
|
2298
|
+
runtimeCurrent = false;
|
|
2299
|
+
} else {
|
|
2300
|
+
push("ok", "project: status launcher current");
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
let runtimeSafe = true;
|
|
2305
|
+
try {
|
|
2306
|
+
validateDirectoryDestination(
|
|
2307
|
+
path.join(project, HOOKS_DIRECTORY),
|
|
2308
|
+
"project runtime parent directory"
|
|
2309
|
+
);
|
|
2310
|
+
validateDirectoryDestination(runtime, "project runtime directory");
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
if (!(error instanceof InstallerError)) throw error;
|
|
2313
|
+
push("fail", `project: unsafe runtime — ${error.message}`);
|
|
2314
|
+
runtimeSafe = false;
|
|
2315
|
+
runtimeCurrent = false;
|
|
2316
|
+
}
|
|
2317
|
+
if (runtimeSafe) {
|
|
2318
|
+
for (const name of PROJECT_RUNTIME_SCRIPTS) {
|
|
2319
|
+
const destination = path.join(runtime, name);
|
|
2320
|
+
if (isSymlink(destination)) {
|
|
2321
|
+
push("fail", `project: runtime ${name} is a symlink`);
|
|
2322
|
+
runtimeCurrent = false;
|
|
2323
|
+
continue;
|
|
2324
|
+
}
|
|
2325
|
+
if (!isFile(destination)) {
|
|
2326
|
+
push("fail", `project: missing runtime ${name}`);
|
|
2327
|
+
runtimeCurrent = false;
|
|
2328
|
+
continue;
|
|
2329
|
+
}
|
|
2330
|
+
const content = readProjectFile(destination, `project: runtime ${name}`);
|
|
2331
|
+
if (content === null) {
|
|
2332
|
+
runtimeCurrent = false;
|
|
2333
|
+
continue;
|
|
2334
|
+
}
|
|
2335
|
+
const source = readProjectFile(
|
|
2336
|
+
path.join(sourceRoot, "scripts", name),
|
|
2337
|
+
`package: runtime ${name}`
|
|
2338
|
+
);
|
|
2339
|
+
if (source === null) {
|
|
2340
|
+
runtimeCurrent = false;
|
|
2341
|
+
continue;
|
|
2342
|
+
}
|
|
2343
|
+
if (!content.toString("utf8").includes(PROJECT_RUNTIME_MARKER)) {
|
|
2344
|
+
push("warn", `project: runtime ${name} is not managed`);
|
|
2345
|
+
runtimeCurrent = false;
|
|
2346
|
+
} else if (!content.equals(source)) {
|
|
2347
|
+
push("warn", `project: runtime ${name} is stale — refresh it with the project contracts`);
|
|
2348
|
+
runtimeCurrent = false;
|
|
2349
|
+
} else {
|
|
2350
|
+
push("ok", `project: runtime ${name} current`);
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
let guardInstalled = GUARD_SCRIPTS.some((name) =>
|
|
2356
|
+
lexists(path.join(project, HOOKS_DIRECTORY, name))
|
|
2357
|
+
);
|
|
2358
|
+
const nativeWiredTargets = new Set();
|
|
2359
|
+
for (const target of targets) {
|
|
2360
|
+
const contract = nativeGuardContract(target, project);
|
|
2361
|
+
if (
|
|
2362
|
+
contract !== null &&
|
|
2363
|
+
(isSymlink(contract.settings) || isManagedHookSettings(contract.settings))
|
|
2364
|
+
) {
|
|
2365
|
+
nativeWiredTargets.add(target);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
let guardWired = nativeWiredTargets.size > 0;
|
|
2369
|
+
const unreadableGitHooks = new Map();
|
|
2370
|
+
if (lexists(path.join(project, ".git"))) {
|
|
2371
|
+
const hooksDir = gitHooksDirectory(project);
|
|
2372
|
+
if (hooksDir !== null) {
|
|
2373
|
+
for (const name of GIT_HOOK_NAMES) {
|
|
2374
|
+
const hookPath = path.join(hooksDir, name);
|
|
2375
|
+
if (!lexists(hookPath)) continue;
|
|
2376
|
+
if (isSymlink(hookPath)) {
|
|
2377
|
+
guardWired = true;
|
|
2378
|
+
continue;
|
|
2379
|
+
}
|
|
2380
|
+
try {
|
|
2381
|
+
const managed = isManagedGitHookContent(fs.readFileSync(hookPath, "utf8"));
|
|
2382
|
+
guardWired = managed || guardWired;
|
|
2383
|
+
} catch (error) {
|
|
2384
|
+
unreadableGitHooks.set(hookPath, error.message);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
for (const [hookPath, error] of unreadableGitHooks) {
|
|
2390
|
+
const label = describeProjectPath(project, hookPath);
|
|
2391
|
+
push("fail", `hooks: ${label} cannot be read: ${error}`);
|
|
2392
|
+
}
|
|
2393
|
+
guardInstalled = guardInstalled || guardWired;
|
|
2394
|
+
if (!guardInstalled) {
|
|
2395
|
+
push("note", "hooks: guard hooks not installed (opt in with --hooks; see HOOKS.md)");
|
|
2396
|
+
} else {
|
|
2397
|
+
for (const name of GUARD_SCRIPTS) {
|
|
2398
|
+
const destination = path.join(project, HOOKS_DIRECTORY, name);
|
|
2399
|
+
if (isSymlink(destination)) {
|
|
2400
|
+
push("fail", `hooks: ${HOOKS_DIRECTORY}/${name} is a symlink`);
|
|
2401
|
+
continue;
|
|
2402
|
+
}
|
|
2403
|
+
if (!isFile(destination)) {
|
|
2404
|
+
push("fail", `hooks: missing ${HOOKS_DIRECTORY}/${name} — run --hooks-refresh`);
|
|
2405
|
+
continue;
|
|
2406
|
+
}
|
|
2407
|
+
const content = readProjectFile(destination, `hooks: ${HOOKS_DIRECTORY}/${name}`);
|
|
2408
|
+
if (content === null) continue;
|
|
2409
|
+
const source = readProjectFile(
|
|
2410
|
+
path.join(sourceRoot, "scripts", name),
|
|
2411
|
+
`package: guard ${name}`
|
|
2412
|
+
);
|
|
2413
|
+
if (source === null) continue;
|
|
2414
|
+
if (!content.toString("utf8").includes(GUARD_MARKER)) {
|
|
2415
|
+
push("warn", `hooks: ${HOOKS_DIRECTORY}/${name} is not a managed guard script`);
|
|
2416
|
+
} else if (!content.equals(source)) {
|
|
2417
|
+
push("warn", `hooks: ${HOOKS_DIRECTORY}/${name} is stale — run --hooks-refresh`);
|
|
2418
|
+
} else {
|
|
2419
|
+
push("ok", `hooks: ${HOOKS_DIRECTORY}/${name} current`);
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
for (const target of targets) {
|
|
2423
|
+
if (!installedTargets.has(target) && !nativeWiredTargets.has(target)) continue;
|
|
2424
|
+
const contract = nativeGuardContract(target, project);
|
|
2425
|
+
if (contract === null) {
|
|
2426
|
+
if (MANIFEST.hosts[target]?.guard_tier !== "git-only") {
|
|
2427
|
+
push("fail", `hooks: ${target} declares a native guard without a health contract`);
|
|
2428
|
+
}
|
|
2429
|
+
continue;
|
|
2430
|
+
}
|
|
2431
|
+
if (isSymlink(contract.settings)) {
|
|
2432
|
+
push("fail", `hooks: ${target} native guard wiring is a symlink`);
|
|
2433
|
+
continue;
|
|
2434
|
+
}
|
|
2435
|
+
if (!isFile(contract.settings)) {
|
|
2436
|
+
push("fail", `hooks: ${target} native guard wiring is missing — run --hooks-refresh-full`);
|
|
2437
|
+
continue;
|
|
2438
|
+
}
|
|
2439
|
+
let current = false;
|
|
2440
|
+
try {
|
|
2441
|
+
const parsed = JSON.parse(fs.readFileSync(contract.settings, "utf8"));
|
|
2442
|
+
const entries =
|
|
2443
|
+
parsed && typeof parsed === "object" && parsed.hooks
|
|
2444
|
+
? parsed.hooks[contract.event]
|
|
2445
|
+
: null;
|
|
2446
|
+
const variants = INTERPRETER_CANDIDATES.map((candidate) =>
|
|
2447
|
+
JSON.stringify(contract.entry(candidate))
|
|
2448
|
+
);
|
|
2449
|
+
current =
|
|
2450
|
+
Array.isArray(entries) &&
|
|
2451
|
+
entries.some((entry) => variants.includes(JSON.stringify(entry)));
|
|
2452
|
+
} catch {
|
|
2453
|
+
current = false;
|
|
2454
|
+
}
|
|
2455
|
+
if (current) {
|
|
2456
|
+
push("ok", `hooks: ${target} native guard wiring present`);
|
|
2457
|
+
} else {
|
|
2458
|
+
push("fail", `hooks: ${target} native guard wiring is stale — run --hooks-refresh-full`);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
const dotGit = path.join(project, ".git");
|
|
2462
|
+
if (lexists(dotGit)) {
|
|
2463
|
+
const hooksDir = gitHooksDirectory(project);
|
|
2464
|
+
if (hooksDir === null) {
|
|
2465
|
+
push(
|
|
2466
|
+
"fail",
|
|
2467
|
+
"hooks: cannot resolve the git hooks directory (is git runnable?); git hooks unverified"
|
|
2468
|
+
);
|
|
2469
|
+
} else {
|
|
2470
|
+
const custom = !samePath(hooksDir, path.join(dotGit, "hooks"));
|
|
2471
|
+
let missingFromCustom = false;
|
|
2472
|
+
for (const [hookName, generator] of GIT_HOOKS) {
|
|
2473
|
+
const hookPath = path.join(hooksDir, hookName);
|
|
2474
|
+
const label = describeProjectPath(project, hookPath);
|
|
2475
|
+
if (unreadableGitHooks.has(hookPath)) {
|
|
2476
|
+
continue;
|
|
2477
|
+
}
|
|
2478
|
+
if (!lexists(hookPath)) {
|
|
2479
|
+
push(
|
|
2480
|
+
"fail",
|
|
2481
|
+
`hooks: ${hookName} is missing from the effective git hooks directory ` +
|
|
2482
|
+
`${hooksDir} — run --hooks-refresh-full`
|
|
2483
|
+
);
|
|
2484
|
+
if (custom) missingFromCustom = true;
|
|
2485
|
+
} else if (isSymlink(hookPath)) {
|
|
2486
|
+
push("fail", `hooks: ${label} is a symlink`);
|
|
2487
|
+
} else {
|
|
2488
|
+
const hookContent = readProjectFile(hookPath, `hooks: ${label}`);
|
|
2489
|
+
if (hookContent === null) continue;
|
|
2490
|
+
const hookText = hookContent.toString("utf8");
|
|
2491
|
+
if (!isManagedGitHookContent(hookText)) {
|
|
2492
|
+
push("warn", `hooks: ${label} is not a managed GSD Path git hook`);
|
|
2493
|
+
} else if (
|
|
2494
|
+
!INTERPRETER_CANDIDATES.some(
|
|
2495
|
+
(candidate) => hookText === generator(candidate)
|
|
2496
|
+
)
|
|
2497
|
+
) {
|
|
2498
|
+
push("warn", `hooks: ${label} is stale — run --hooks-refresh-full`);
|
|
2499
|
+
} else if (!isExecutable(hookPath)) {
|
|
2500
|
+
push("warn", `hooks: ${label} is not executable — run --hooks-refresh-full`);
|
|
2501
|
+
} else {
|
|
2502
|
+
push("ok", `hooks: ${label} wired`);
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
if (missingFromCustom) {
|
|
2507
|
+
push(
|
|
2508
|
+
"warn",
|
|
2509
|
+
`hooks: core.hooksPath points this repository at ${hooksDir}, ` +
|
|
2510
|
+
"but the guard hooks are not wired there"
|
|
2511
|
+
);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
const stateFile = path.join(project, ".project", "STATE.md");
|
|
2518
|
+
if (!isDirectory(path.join(project, ".project"))) {
|
|
2519
|
+
push("note", "state: no .project/ pipeline state (nothing started yet)");
|
|
2520
|
+
} else if (!isFile(stateFile)) {
|
|
2521
|
+
push("warn", "state: .project/ exists but STATE.md is missing");
|
|
2522
|
+
} else {
|
|
2523
|
+
try {
|
|
2524
|
+
const state = hooks.validatedProjectState(sourceRoot, project);
|
|
2525
|
+
if (!runtimeCurrent) {
|
|
2526
|
+
throw new InstallerError(
|
|
2527
|
+
"project runtime status was not executed because the installed runtime is not current"
|
|
2528
|
+
);
|
|
2529
|
+
}
|
|
2530
|
+
validateProjectRuntimeStatus(sourceRoot, project);
|
|
2531
|
+
if (!projectRuntimeMatches(sourceRoot, project)) {
|
|
2532
|
+
throw new InstallerError("project runtime changed during doctor validation");
|
|
2533
|
+
}
|
|
2534
|
+
push("ok", `state: ${state.phase}/${state.status}`);
|
|
2535
|
+
} catch (error) {
|
|
2536
|
+
push("fail", `state: ${messageOf(error)}`);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
return findings;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
export function deploymentPlans(plans) {
|
|
2543
|
+
const names = plans.map((plan) => plan.name);
|
|
2544
|
+
if (new Set(names).size !== plans.length) {
|
|
2545
|
+
throw new InstallerError("each target may be selected only once");
|
|
2546
|
+
}
|
|
2547
|
+
const unsupported = [...new Set(names.filter((name) => !TARGETS.includes(name)))].sort();
|
|
2548
|
+
if (unsupported.length) {
|
|
2549
|
+
throw new InstallerError("unsupported target: " + unsupported.join(", "));
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
const groups = [];
|
|
2553
|
+
for (const plan of plans) {
|
|
2554
|
+
const group = groups.find((entries) => samePath(entries[0].root, plan.root));
|
|
2555
|
+
if (group) group.push(plan);
|
|
2556
|
+
else groups.push([plan]);
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
const deployments = [];
|
|
2560
|
+
for (const group of groups) {
|
|
2561
|
+
const targets = group.map((plan) => plan.name);
|
|
2562
|
+
let profile;
|
|
2563
|
+
if (group.length > 1) {
|
|
2564
|
+
if (!targets.every((name) => SHARED_AGENT_TARGETS.has(name))) {
|
|
2565
|
+
throw new InstallerError(
|
|
2566
|
+
`only Codex, Antigravity, and Zed may share a skills root: ${targets.join(", ")}`
|
|
2567
|
+
);
|
|
2568
|
+
}
|
|
2569
|
+
profile = SHARED_AGENT_PROFILE;
|
|
2570
|
+
} else if (SHARED_AGENT_TARGETS.has(group[0].name)) {
|
|
2571
|
+
profile = SHARED_AGENT_PROFILE;
|
|
2572
|
+
} else if (samePath(group[0].root, defaultRoot("codex"))) {
|
|
2573
|
+
throw new InstallerError(
|
|
2574
|
+
`${group[0].name} cannot install a host-specific bundle to the ` +
|
|
2575
|
+
"shared ~/.agents/skills root"
|
|
2576
|
+
);
|
|
2577
|
+
} else {
|
|
2578
|
+
profile = group[0].name;
|
|
2579
|
+
}
|
|
2580
|
+
deployments.push({ profile, root: group[0].root, targets });
|
|
2581
|
+
}
|
|
2582
|
+
return deployments;
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
function validateDistinctRoots(plans) {
|
|
2586
|
+
for (let index = 0; index < plans.length; index += 1) {
|
|
2587
|
+
for (const right of plans.slice(index + 1)) {
|
|
2588
|
+
const left = plans[index];
|
|
2589
|
+
if (pathsOverlap(left.root, right.root)) {
|
|
2590
|
+
throw new InstallerError(
|
|
2591
|
+
"target roots overlap: " +
|
|
2592
|
+
`${left.targets.join("+")}=${left.root}, ` +
|
|
2593
|
+
`${right.targets.join("+")}=${right.root}`
|
|
2594
|
+
);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
const cursorPlans = plans.filter((plan) => plan.profile === "cursor");
|
|
2600
|
+
for (const cursorPlan of cursorPlans) {
|
|
2601
|
+
const agentRoot = path.join(path.dirname(cursorPlan.root), "agents");
|
|
2602
|
+
if (pathsOverlap(agentRoot, cursorPlan.root)) {
|
|
2603
|
+
throw new InstallerError(
|
|
2604
|
+
"Cursor skills and agent roots overlap: " +
|
|
2605
|
+
`skills=${cursorPlan.root}, agents=${agentRoot}`
|
|
2606
|
+
);
|
|
2607
|
+
}
|
|
2608
|
+
for (const plan of plans) {
|
|
2609
|
+
if (plan === cursorPlan) continue;
|
|
2610
|
+
if (pathsOverlap(agentRoot, plan.root)) {
|
|
2611
|
+
throw new InstallerError(
|
|
2612
|
+
"Cursor agent root overlaps a target root: " +
|
|
2613
|
+
`cursor=${agentRoot}, ${plan.targets.join("+")}=${plan.root}`
|
|
2614
|
+
);
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
function appendHostNotes(results, selected) {
|
|
2621
|
+
for (const target of TARGETS) {
|
|
2622
|
+
if (selected.includes(target) && HOST_NOTES[target]) {
|
|
2623
|
+
results.push(HOST_NOTES[target]);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
function installResult(plan, dryRun = false, update = false) {
|
|
2629
|
+
const verb = update ? "update" : "install";
|
|
2630
|
+
const action = dryRun ? `would ${verb}` : `${verb}${update ? "d" : "ed"}`;
|
|
2631
|
+
const label = plan.targets.join("+");
|
|
2632
|
+
const skillCount = SKILL_NAMES.length;
|
|
2633
|
+
if (plan.profile === "cursor") {
|
|
2634
|
+
const agent = path.join(path.dirname(plan.root), "agents", CURSOR_AGENT_FILENAME);
|
|
2635
|
+
return `${label}: ${action} ${skillCount} skills to ${plan.root} and custom subagent to ${agent}`;
|
|
2636
|
+
}
|
|
2637
|
+
const shared = plan.profile === SHARED_AGENT_PROFILE ? " shared" : "";
|
|
2638
|
+
return `${label}: ${action} ${skillCount}${shared} skills to ${plan.root}`;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
function managedEntryCount(plan) {
|
|
2642
|
+
let count = 0;
|
|
2643
|
+
if (isDirectory(plan.root)) {
|
|
2644
|
+
count = fs.readdirSync(plan.root).filter((name) => isManagedInstallEntry(plan.root, name)).length;
|
|
2645
|
+
}
|
|
2646
|
+
if (
|
|
2647
|
+
plan.profile === "cursor" &&
|
|
2648
|
+
lexists(path.join(path.dirname(plan.root), "agents", CURSOR_AGENT_FILENAME))
|
|
2649
|
+
) {
|
|
2650
|
+
count += 1;
|
|
2651
|
+
}
|
|
2652
|
+
return count;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
function plannedBackupRoots(legacyRoot, deployments) {
|
|
2656
|
+
const transactions = [];
|
|
2657
|
+
if (
|
|
2658
|
+
legacyRoot !== null &&
|
|
2659
|
+
isDirectory(legacyRoot) &&
|
|
2660
|
+
fs.readdirSync(legacyRoot).some((name) => isManagedName(name))
|
|
2661
|
+
) {
|
|
2662
|
+
transactions.push(["Codex legacy backup", legacyRoot]);
|
|
2663
|
+
}
|
|
2664
|
+
for (const plan of deployments) {
|
|
2665
|
+
if (managedEntryCount(plan)) {
|
|
2666
|
+
transactions.push([`${plan.targets.join("+")} backup`, plan.root]);
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
const planned = [];
|
|
2671
|
+
const reserved = [];
|
|
2672
|
+
for (const [label, root] of transactions) {
|
|
2673
|
+
const backup = backupPath(root, reserved);
|
|
2674
|
+
reserved.push(backup);
|
|
2675
|
+
planned.push([label, backup]);
|
|
2676
|
+
}
|
|
2677
|
+
return planned;
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
// Test-injection points; production callers never touch these.
|
|
2681
|
+
export const hooks = {
|
|
2682
|
+
mismatches,
|
|
2683
|
+
applyTarget,
|
|
2684
|
+
rename: fs.renameSync.bind(fs),
|
|
2685
|
+
renameInstallLock: fs.renameSync.bind(fs),
|
|
2686
|
+
renameInstallStage: fs.renameSync.bind(fs),
|
|
2687
|
+
validatedProjectState,
|
|
2688
|
+
processIdentity,
|
|
2689
|
+
reserveDirectory,
|
|
2690
|
+
reserveFile,
|
|
2691
|
+
detectPythonInterpreter,
|
|
2692
|
+
resolveGitHooksPath,
|
|
2693
|
+
copyRuntimeFile: fs.copyFileSync.bind(fs),
|
|
2694
|
+
copyGuardFile: copyFileAtomic,
|
|
2695
|
+
};
|
|
2696
|
+
|
|
2697
|
+
export async function install(sourceRoot, plans, options = {}) {
|
|
2698
|
+
const {
|
|
2699
|
+
project = null,
|
|
2700
|
+
dryRun = false,
|
|
2701
|
+
env = process.env,
|
|
2702
|
+
onProgress = null,
|
|
2703
|
+
migrateLegacy = true,
|
|
2704
|
+
update = false,
|
|
2705
|
+
hooks: hooksEnabled = false,
|
|
2706
|
+
} = options;
|
|
2707
|
+
if (hooksEnabled && project === null) {
|
|
2708
|
+
throw new InstallerError("--hooks requires --project");
|
|
2709
|
+
}
|
|
2710
|
+
const selected = plans.map((plan) => plan.name);
|
|
2711
|
+
let interpreter = "python3";
|
|
2712
|
+
let hooksDir = null;
|
|
2713
|
+
if (project !== null && !hooksEnabled && !dryRun) {
|
|
2714
|
+
interpreter = requiredPythonRuntime("--project", selected);
|
|
2715
|
+
} else if (hooksEnabled) {
|
|
2716
|
+
({ interpreter, hooksDir } = requiredHookRuntime(project, "--hooks", selected));
|
|
2717
|
+
}
|
|
2718
|
+
const progress = async (text) => {
|
|
2719
|
+
if (onProgress) onProgress(text);
|
|
2720
|
+
await tick();
|
|
2721
|
+
};
|
|
2722
|
+
const deployments = deploymentPlans(plans);
|
|
2723
|
+
const adapters = [...new Set(deployments.map((deployment) => deployment.profile))];
|
|
2724
|
+
await progress("Validating synchronized package");
|
|
2725
|
+
validateSource(sourceRoot, adapters);
|
|
2726
|
+
validateDistinctRoots(deployments);
|
|
2727
|
+
for (const plan of plans) {
|
|
2728
|
+
validateDirectoryDestination(plan.root, `${plan.name} skills root`);
|
|
2729
|
+
if (pathsOverlap(plan.root, sourceRoot)) {
|
|
2730
|
+
throw new InstallerError(
|
|
2731
|
+
`${plan.name} skills root overlaps source repository: ${plan.root}`
|
|
2732
|
+
);
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
for (const deployment of deployments) {
|
|
2736
|
+
if (deployment.profile !== "cursor") continue;
|
|
2737
|
+
const cursorAgentRoot = path.join(path.dirname(deployment.root), "agents");
|
|
2738
|
+
validateDirectoryDestination(cursorAgentRoot, "Cursor agent root");
|
|
2739
|
+
if (pathsOverlap(cursorAgentRoot, sourceRoot)) {
|
|
2740
|
+
throw new InstallerError(
|
|
2741
|
+
`Cursor agent root overlaps source repository: ${cursorAgentRoot}`
|
|
2742
|
+
);
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
let legacyRoot = migrateLegacy && selected.includes("codex") ? legacyCodexRoot(env) : null;
|
|
2746
|
+
const reservedRoots = deployments.map((plan) => [
|
|
2747
|
+
`${plan.targets.join("+")} skills root`,
|
|
2748
|
+
plan.root,
|
|
2749
|
+
]);
|
|
2750
|
+
for (const plan of deployments) {
|
|
2751
|
+
if (plan.profile === "cursor") {
|
|
2752
|
+
reservedRoots.push(["Cursor agent root", path.join(path.dirname(plan.root), "agents")]);
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
if (legacyRoot !== null) {
|
|
2756
|
+
const codexRoots = deployments
|
|
2757
|
+
.filter((plan) => plan.targets.includes("codex"))
|
|
2758
|
+
.map((plan) => plan.root);
|
|
2759
|
+
if (codexRoots.some((root) => samePath(legacyRoot, root))) {
|
|
2760
|
+
legacyRoot = null;
|
|
2761
|
+
} else if (pathsOverlap(legacyRoot, sourceRoot)) {
|
|
2762
|
+
throw new InstallerError(
|
|
2763
|
+
`Codex legacy root overlaps source repository: ${legacyRoot}`
|
|
2764
|
+
);
|
|
2765
|
+
} else {
|
|
2766
|
+
for (const [label, root] of reservedRoots) {
|
|
2767
|
+
if (pathsOverlap(legacyRoot, root)) {
|
|
2768
|
+
throw new InstallerError(
|
|
2769
|
+
`Codex legacy root overlaps ${label}: ${legacyRoot}, ${root}`
|
|
2770
|
+
);
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
if (legacyRoot !== null) {
|
|
2776
|
+
validateDirectoryDestination(legacyRoot, "Codex legacy skills root");
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
const plannedBackups = plannedBackupRoots(legacyRoot, deployments);
|
|
2780
|
+
const mutationRoots = [...reservedRoots];
|
|
2781
|
+
if (legacyRoot !== null) {
|
|
2782
|
+
mutationRoots.push(["Codex legacy skills root", legacyRoot]);
|
|
2783
|
+
}
|
|
2784
|
+
for (const [backupLabel, backup] of plannedBackups) {
|
|
2785
|
+
for (const [rootLabel, root] of mutationRoots) {
|
|
2786
|
+
if (pathsOverlap(backup, root)) {
|
|
2787
|
+
throw new InstallerError(`${backupLabel} overlaps ${rootLabel}: ${backup}, ${root}`);
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
if (project !== null) {
|
|
2793
|
+
validateDirectoryDestination(project, "project path");
|
|
2794
|
+
if (dryRun) {
|
|
2795
|
+
validateProject(
|
|
2796
|
+
sourceRoot,
|
|
2797
|
+
project,
|
|
2798
|
+
selected,
|
|
2799
|
+
hooksEnabled,
|
|
2800
|
+
[...mutationRoots, ...plannedBackups],
|
|
2801
|
+
interpreter,
|
|
2802
|
+
hooksDir,
|
|
2803
|
+
update
|
|
2804
|
+
);
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
const results = [];
|
|
2809
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-path-install-"));
|
|
2810
|
+
try {
|
|
2811
|
+
const staged = [];
|
|
2812
|
+
for (const [index, plan] of deployments.entries()) {
|
|
2813
|
+
await progress(`Staging ${plan.profile} bundle`);
|
|
2814
|
+
const stagedRoot = path.join(staging, `${index}-${plan.profile}`);
|
|
2815
|
+
staged.push(stagedRoot);
|
|
2816
|
+
fs.mkdirSync(stagedRoot);
|
|
2817
|
+
stageTarget(sourceRoot, plan.profile, stagedRoot);
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
if (dryRun) {
|
|
2821
|
+
if (legacyRoot !== null && isDirectory(legacyRoot)) {
|
|
2822
|
+
const legacyCount = fs.readdirSync(legacyRoot).filter((name) => isManagedName(name)).length;
|
|
2823
|
+
if (legacyCount) {
|
|
2824
|
+
results.push(`codex-legacy: would back up ${legacyCount} entries from ${legacyRoot}`);
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
for (const plan of deployments) {
|
|
2828
|
+
rejectRouterAliasCollisions(plan.root);
|
|
2829
|
+
const count = managedEntryCount(plan);
|
|
2830
|
+
const suffix = count ? `; would back up ${count} entries` : "";
|
|
2831
|
+
results.push(installResult(plan, true, update) + suffix);
|
|
2832
|
+
}
|
|
2833
|
+
if (project !== null) {
|
|
2834
|
+
results.push(projectResult(project, selected, hooksEnabled, interpreter, hooksDir, update, true));
|
|
2835
|
+
}
|
|
2836
|
+
appendHostNotes(results, selected);
|
|
2837
|
+
return results;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
const lockRoots = deployments.map((plan) => plan.root);
|
|
2841
|
+
if (legacyRoot !== null && isDirectory(legacyRoot)) lockRoots.push(legacyRoot);
|
|
2842
|
+
if (project !== null) lockRoots.push(path.join(project, HOOKS_DIRECTORY));
|
|
2843
|
+
const ownership = acquireInstallLocks(lockRoots);
|
|
2844
|
+
const targetTransactions = [];
|
|
2845
|
+
const projectTransaction = { createdDirectories: [], copied: [], replaced: [] };
|
|
2846
|
+
try {
|
|
2847
|
+
if (project !== null) {
|
|
2848
|
+
validateProject(
|
|
2849
|
+
sourceRoot,
|
|
2850
|
+
project,
|
|
2851
|
+
selected,
|
|
2852
|
+
hooksEnabled,
|
|
2853
|
+
[...mutationRoots, ...plannedBackups],
|
|
2854
|
+
interpreter,
|
|
2855
|
+
hooksDir,
|
|
2856
|
+
update
|
|
2857
|
+
);
|
|
2858
|
+
}
|
|
2859
|
+
if (legacyRoot !== null && isDirectory(legacyRoot)) {
|
|
2860
|
+
await progress("Backing up legacy Codex skills");
|
|
2861
|
+
const legacyTransaction = {
|
|
2862
|
+
root: legacyRoot,
|
|
2863
|
+
createdDirectories: [],
|
|
2864
|
+
backup: null,
|
|
2865
|
+
moved: [],
|
|
2866
|
+
installed: [],
|
|
2867
|
+
};
|
|
2868
|
+
targetTransactions.push(legacyTransaction);
|
|
2869
|
+
backupExisting(legacyTransaction);
|
|
2870
|
+
if (legacyTransaction.backup !== null) {
|
|
2871
|
+
results.push(
|
|
2872
|
+
`codex-legacy: backed up ${legacyTransaction.moved.length} entries to ` +
|
|
2873
|
+
`${legacyTransaction.backup}`
|
|
2874
|
+
);
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
for (const [index, plan] of deployments.entries()) {
|
|
2878
|
+
await progress(`Installing ${plan.targets.join("+")} skills`);
|
|
2879
|
+
const transaction = {
|
|
2880
|
+
root: plan.root,
|
|
2881
|
+
createdDirectories: [],
|
|
2882
|
+
backup: null,
|
|
2883
|
+
moved: [],
|
|
2884
|
+
installed: [],
|
|
2885
|
+
};
|
|
2886
|
+
targetTransactions.push(transaction);
|
|
2887
|
+
await hooks.applyTarget(plan, staged[index], transaction);
|
|
2888
|
+
results.push(installResult(plan, false, update));
|
|
2889
|
+
if (transaction.backup !== null) {
|
|
2890
|
+
results.push(
|
|
2891
|
+
`${plan.targets.join("+")}: backed up ${transaction.moved.length} entries ` +
|
|
2892
|
+
`to ${transaction.backup}`
|
|
2893
|
+
);
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
if (project !== null) {
|
|
2897
|
+
await progress("Writing project contracts");
|
|
2898
|
+
const resultLine = projectResult(project, selected, hooksEnabled, interpreter, hooksDir, update, false);
|
|
2899
|
+
applyProject(
|
|
2900
|
+
sourceRoot,
|
|
2901
|
+
project,
|
|
2902
|
+
selected,
|
|
2903
|
+
hooksEnabled,
|
|
2904
|
+
projectTransaction,
|
|
2905
|
+
interpreter,
|
|
2906
|
+
hooksDir,
|
|
2907
|
+
update
|
|
2908
|
+
);
|
|
2909
|
+
results.push(resultLine);
|
|
2910
|
+
}
|
|
2911
|
+
} catch (error) {
|
|
2912
|
+
const rollbackErrors = [];
|
|
2913
|
+
try {
|
|
2914
|
+
rollbackProject(projectTransaction);
|
|
2915
|
+
} catch (rollbackError) {
|
|
2916
|
+
rollbackErrors.push(messageOf(rollbackError));
|
|
2917
|
+
}
|
|
2918
|
+
for (const transaction of [...targetTransactions].reverse()) {
|
|
2919
|
+
try {
|
|
2920
|
+
rollbackTarget(transaction);
|
|
2921
|
+
} catch (rollbackError) {
|
|
2922
|
+
rollbackErrors.push(messageOf(rollbackError));
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
const detail = rollbackErrors.length
|
|
2926
|
+
? `; rollback incomplete: ${rollbackErrors.join("; ")}`
|
|
2927
|
+
: "";
|
|
2928
|
+
throw new InstallerError(
|
|
2929
|
+
`installation failed and was rolled back: ${messageOf(error)}${detail}`
|
|
2930
|
+
);
|
|
2931
|
+
} finally {
|
|
2932
|
+
releaseInstallLocks(ownership.locks, ownership.createdDirectories);
|
|
2933
|
+
}
|
|
2934
|
+
} finally {
|
|
2935
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
2936
|
+
}
|
|
2937
|
+
appendHostNotes(results, selected);
|
|
2938
|
+
return results;
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
// Documented project-relative skill roots (verified against each host's
|
|
2942
|
+
// official skills docs). Codex, Zed, and Antigravity all discover the
|
|
2943
|
+
// project-level .agents/skills standard directory.
|
|
2944
|
+
export function localRoot(target, projectDir) {
|
|
2945
|
+
const relative = LOCAL_ROOTS[target];
|
|
2946
|
+
if (!relative) throw new Error(`unsupported target: ${target}`);
|
|
2947
|
+
return path.join(absolutePath(projectDir), relative);
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
export function detectInstalls(targets, rootFor) {
|
|
2951
|
+
const plans = [];
|
|
2952
|
+
for (const target of targets) {
|
|
2953
|
+
const root = rootFor(target);
|
|
2954
|
+
if (hasManagedInstall(root)) {
|
|
2955
|
+
plans.push(targetPlan(target, root));
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
return plans;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
export function parseCli(argv) {
|
|
2962
|
+
const options = {
|
|
2963
|
+
all: { type: "boolean", default: false },
|
|
2964
|
+
update: { type: "boolean", default: false },
|
|
2965
|
+
"dry-run": { type: "boolean", default: false },
|
|
2966
|
+
local: { type: "boolean", default: false },
|
|
2967
|
+
project: { type: "string" },
|
|
2968
|
+
doctor: { type: "boolean", default: false },
|
|
2969
|
+
hooks: { type: "boolean", default: false },
|
|
2970
|
+
"hooks-init": { type: "boolean", default: false },
|
|
2971
|
+
"hooks-refresh": { type: "boolean", default: false },
|
|
2972
|
+
"hooks-refresh-full": { type: "boolean", default: false },
|
|
2973
|
+
"source-root": { type: "string" },
|
|
2974
|
+
"no-color": { type: "boolean", default: false },
|
|
2975
|
+
help: { type: "boolean", default: false },
|
|
2976
|
+
};
|
|
2977
|
+
for (const target of TARGETS) {
|
|
2978
|
+
options[target] = { type: "boolean", default: false };
|
|
2979
|
+
options[`${target}-root`] = { type: "string" };
|
|
2980
|
+
}
|
|
2981
|
+
return parseArgs({ args: argv, options, allowPositionals: false }).values;
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
function packageVersion() {
|
|
2985
|
+
return readPackageVersion(path.join(SCRIPT_DIRECTORY, "..", "package.json")) || "";
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
function makeUi(colorEnabled) {
|
|
2989
|
+
const tty = Boolean(process.stdout.isTTY);
|
|
2990
|
+
const colored = colorEnabled && tty && !process.env.NO_COLOR;
|
|
2991
|
+
const paint = (code) => (text) => (colored ? `\u001b[${code}m${text}\u001b[0m` : text);
|
|
2992
|
+
const ui = {
|
|
2993
|
+
cyan: paint("36"),
|
|
2994
|
+
green: paint("32"),
|
|
2995
|
+
red: paint("31"),
|
|
2996
|
+
yellow: paint("33"),
|
|
2997
|
+
dim: paint("2"),
|
|
2998
|
+
bold: paint("1"),
|
|
2999
|
+
};
|
|
3000
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
3001
|
+
ui.spinner = (initial) => {
|
|
3002
|
+
if (!tty) return { update() {}, stop() {} };
|
|
3003
|
+
let text = initial;
|
|
3004
|
+
let index = 0;
|
|
3005
|
+
const render = () => {
|
|
3006
|
+
process.stdout.write(`\r\u001b[2K ${ui.cyan(frames[index])} ${ui.dim(text)}`);
|
|
3007
|
+
index = (index + 1) % frames.length;
|
|
3008
|
+
};
|
|
3009
|
+
render();
|
|
3010
|
+
const timer = setInterval(render, 80);
|
|
3011
|
+
return {
|
|
3012
|
+
update(next) {
|
|
3013
|
+
text = next;
|
|
3014
|
+
},
|
|
3015
|
+
stop() {
|
|
3016
|
+
clearInterval(timer);
|
|
3017
|
+
process.stdout.write("\r\u001b[2K");
|
|
3018
|
+
},
|
|
3019
|
+
};
|
|
3020
|
+
};
|
|
3021
|
+
ui.banner = (mode) => {
|
|
3022
|
+
const version = packageVersion();
|
|
3023
|
+
const title = ui.bold(ui.cyan("GSD Path")) + ui.dim(` installer${version ? ` v${version}` : ""}`);
|
|
3024
|
+
console.log(`\n ${ui.cyan("✦")} ${title}`);
|
|
3025
|
+
console.log(` ${ui.dim(mode)}\n`);
|
|
3026
|
+
};
|
|
3027
|
+
ui.result = (line) => {
|
|
3028
|
+
if (line.startsWith("note:")) {
|
|
3029
|
+
console.log(` ${ui.yellow("!")} ${ui.dim(line.slice(5).trim())}`);
|
|
3030
|
+
} else {
|
|
3031
|
+
console.log(` ${ui.green("✓")} ${line}`);
|
|
3032
|
+
}
|
|
3033
|
+
};
|
|
3034
|
+
ui.error = (message) => {
|
|
3035
|
+
console.error(` ${ui.red("✗")} ${message}`);
|
|
3036
|
+
};
|
|
3037
|
+
return ui;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
function usage() {
|
|
3041
|
+
const flags = TARGETS.map((target) => `--${target}`).join(" ");
|
|
3042
|
+
return (
|
|
3043
|
+
"GSD Path installer — multi-host skills and project contracts.\n" +
|
|
3044
|
+
"Docs: DOCS.md (hub) · QUICK.md (first run) · FULL.md · UPDATE.md\n\n" +
|
|
3045
|
+
"usage: gsd-path (no flags on a terminal opens the interactive wizard)\n" +
|
|
3046
|
+
" gsd-path [--all] [--update] [--local] [--dry-run] [--project PATH]\n" +
|
|
3047
|
+
" [--hooks] [--hooks-init] [--hooks-refresh] [--hooks-refresh-full] [target flags]\n\n" +
|
|
3048
|
+
" First install: gsd-path --all --dry-run && gsd-path --all\n" +
|
|
3049
|
+
" New repo: gsd-path --all --project /path/to/repo\n" +
|
|
3050
|
+
" Update skills: gsd-path --update (or npx @opengsd/gsd-path@latest --update)\n\n" +
|
|
3051
|
+
`targets: ${flags}\n` +
|
|
3052
|
+
" --update refresh existing installs in place; with --project also\n" +
|
|
3053
|
+
" refreshes .gsd-path/ and keeps AGENTS.md/WORKFLOW.md\n" +
|
|
3054
|
+
" --local install into this project's per-host skill dirs\n" +
|
|
3055
|
+
" --project PATH write project contracts and status runtime; requires Python 3.9+\n" +
|
|
3056
|
+
" --doctor read-only health check of installs, hooks, and state\n" +
|
|
3057
|
+
" --hooks with --project: install guard hooks (see HOOKS.md)\n" +
|
|
3058
|
+
" --hooks-init add guards to an existing project without changing its contracts\n" +
|
|
3059
|
+
" --hooks-refresh with --project: overwrite managed .gsd-path scripts\n" +
|
|
3060
|
+
" --hooks-refresh-full refresh native settings/git hooks; target flags create missing configs\n" +
|
|
3061
|
+
" --dry-run preview without writing\n" +
|
|
3062
|
+
" each target also accepts --<target>-root PATH to override its skills root"
|
|
3063
|
+
);
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
export async function main(argv, env = process.env) {
|
|
3067
|
+
let values;
|
|
3068
|
+
try {
|
|
3069
|
+
values = parseCli(argv);
|
|
3070
|
+
} catch (error) {
|
|
3071
|
+
console.error(`error: ${error.message}`);
|
|
3072
|
+
console.error(usage());
|
|
3073
|
+
return 2;
|
|
3074
|
+
}
|
|
3075
|
+
if (values.help) {
|
|
3076
|
+
console.log(usage());
|
|
3077
|
+
return 0;
|
|
3078
|
+
}
|
|
3079
|
+
if (!argv.length && process.stdin.isTTY && process.stdout.isTTY) {
|
|
3080
|
+
const { wizard } = await import("./wizard.mjs");
|
|
3081
|
+
const chosen = await wizard({
|
|
3082
|
+
input: process.stdin,
|
|
3083
|
+
output: process.stdout,
|
|
3084
|
+
colored: !process.env.NO_COLOR,
|
|
3085
|
+
version: packageVersion(),
|
|
3086
|
+
targets: TARGETS,
|
|
3087
|
+
installed: (target, local) =>
|
|
3088
|
+
hasManagedInstall(local ? localRoot(target, process.cwd()) : defaultRoot(target, env)),
|
|
3089
|
+
});
|
|
3090
|
+
return chosen ? main(chosen, env) : 0;
|
|
3091
|
+
}
|
|
3092
|
+
const ui = makeUi(!values["no-color"]);
|
|
3093
|
+
const sourceRoot = values["source-root"]
|
|
3094
|
+
? absolutePath(values["source-root"])
|
|
3095
|
+
: path.resolve(SCRIPT_DIRECTORY, "..");
|
|
3096
|
+
const project =
|
|
3097
|
+
values.project !== undefined ? absolutePath(values.project) : null;
|
|
3098
|
+
const local = values.local;
|
|
3099
|
+
const rootFor = (target) => {
|
|
3100
|
+
const override = values[`${target}-root`];
|
|
3101
|
+
if (override !== undefined) return absolutePath(override);
|
|
3102
|
+
return local ? localRoot(target, process.cwd()) : defaultRoot(target, env);
|
|
3103
|
+
};
|
|
3104
|
+
if (values.doctor) {
|
|
3105
|
+
const named = TARGETS.filter((target) => values[target]);
|
|
3106
|
+
const targets = named.length && !values.all ? named : [...TARGETS];
|
|
3107
|
+
ui.banner(`doctor · ${local ? "project" : "global"} installs${project ? ` · ${project}` : ""}`);
|
|
3108
|
+
const findings = doctor(sourceRoot, { targets, rootFor, project });
|
|
3109
|
+
for (const { level, text } of findings) {
|
|
3110
|
+
if (level === "fail") ui.error(text);
|
|
3111
|
+
else ui.result(level === "ok" ? text : `note: ${text}`);
|
|
3112
|
+
}
|
|
3113
|
+
const failed = findings.filter(({ level }) => level === "fail").length;
|
|
3114
|
+
console.log(`\n ${ui.dim(failed ? `${failed} problem${failed === 1 ? "" : "s"} found.` : "Healthy.")}\n`);
|
|
3115
|
+
return failed ? 1 : 0;
|
|
3116
|
+
}
|
|
3117
|
+
const hooksInit = values["hooks-init"];
|
|
3118
|
+
const hooksRefresh = values["hooks-refresh"] || values["hooks-refresh-full"];
|
|
3119
|
+
if (hooksInit || hooksRefresh) {
|
|
3120
|
+
if (project === null) {
|
|
3121
|
+
ui.error(`${hooksInit ? "--hooks-init" : "--hooks-refresh"} requires --project`);
|
|
3122
|
+
return 2;
|
|
3123
|
+
}
|
|
3124
|
+
const selected = TARGETS.filter((target) => values.all || values[target]);
|
|
3125
|
+
if (hooksInit && !selected.length) {
|
|
3126
|
+
ui.error("--hooks-init requires at least one target or --all");
|
|
3127
|
+
return 2;
|
|
3128
|
+
}
|
|
3129
|
+
const refreshMode =
|
|
3130
|
+
`hooks ${hooksInit ? "initialization" : "refresh"} for ${project}` +
|
|
3131
|
+
(values["dry-run"] ? " · dry run" : "");
|
|
3132
|
+
ui.banner(refreshMode);
|
|
3133
|
+
const spin = ui.spinner("Validating synchronized package");
|
|
3134
|
+
try {
|
|
3135
|
+
const problems = hooks.mismatches(sourceRoot);
|
|
3136
|
+
if (problems.length) {
|
|
3137
|
+
throw new InstallerError("source resources are stale: " + problems.join("; "));
|
|
3138
|
+
}
|
|
3139
|
+
spin.update("Refreshing guard scripts");
|
|
3140
|
+
const refreshed = refreshHooks(
|
|
3141
|
+
sourceRoot,
|
|
3142
|
+
project,
|
|
3143
|
+
hooksInit || values["hooks-refresh-full"],
|
|
3144
|
+
values["dry-run"],
|
|
3145
|
+
selected,
|
|
3146
|
+
hooksInit
|
|
3147
|
+
);
|
|
3148
|
+
spin.stop();
|
|
3149
|
+
const notes = refreshed.filter((line) => line.startsWith("note:"));
|
|
3150
|
+
const files = refreshed.filter((line) => !line.startsWith("note:"));
|
|
3151
|
+
ui.result(
|
|
3152
|
+
values["dry-run"]
|
|
3153
|
+
? `hooks: would ${hooksInit ? "initialize" : "refresh"} ${files.join(", ")}`
|
|
3154
|
+
: `hooks: ${hooksInit ? "initialized" : "refreshed"} ${files.join(", ")}`
|
|
3155
|
+
);
|
|
3156
|
+
for (const note of notes) ui.result(note);
|
|
3157
|
+
console.log(
|
|
3158
|
+
`\n ${ui.dim(values["dry-run"] ? "Dry run — nothing was written." : "Done.")}\n`
|
|
3159
|
+
);
|
|
3160
|
+
} catch (error) {
|
|
3161
|
+
spin.stop();
|
|
3162
|
+
if (error instanceof InstallerError) {
|
|
3163
|
+
ui.error(error.message);
|
|
3164
|
+
return 1;
|
|
3165
|
+
}
|
|
3166
|
+
throw error;
|
|
3167
|
+
}
|
|
3168
|
+
return 0;
|
|
3169
|
+
}
|
|
3170
|
+
let selected = TARGETS.filter((target) => values.all || values[target]);
|
|
3171
|
+
if (!selected.length) {
|
|
3172
|
+
if (values.update) {
|
|
3173
|
+
selected = [...TARGETS];
|
|
3174
|
+
} else {
|
|
3175
|
+
console.error("error: select at least one target or --all");
|
|
3176
|
+
console.error(usage());
|
|
3177
|
+
return 2;
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
const sourceRootForInstall = sourceRoot;
|
|
3181
|
+
const scope = local ? `project ${values.update ? "update in" : "install into"} ${process.cwd()}` : `global ${values.update ? "update" : "install"}`;
|
|
3182
|
+
const mode = scope + (values["dry-run"] ? " · dry run" : "");
|
|
3183
|
+
ui.banner(mode);
|
|
3184
|
+
let plans;
|
|
3185
|
+
if (values.update) {
|
|
3186
|
+
plans = detectInstalls(selected, rootFor);
|
|
3187
|
+
if (!plans.length) {
|
|
3188
|
+
ui.error(
|
|
3189
|
+
`no existing GSD Path skills found to update (${local ? "this project" : "global roots"}); ` +
|
|
3190
|
+
"run an install first, e.g. --all" + (local ? " --local" : "")
|
|
3191
|
+
);
|
|
3192
|
+
return 1;
|
|
3193
|
+
}
|
|
3194
|
+
} else {
|
|
3195
|
+
plans = selected.map((target) => targetPlan(target, rootFor(target)));
|
|
3196
|
+
}
|
|
3197
|
+
const spin = ui.spinner("Preparing");
|
|
3198
|
+
try {
|
|
3199
|
+
const results = await install(sourceRootForInstall, plans, {
|
|
3200
|
+
project,
|
|
3201
|
+
dryRun: values["dry-run"],
|
|
3202
|
+
env,
|
|
3203
|
+
migrateLegacy: !local,
|
|
3204
|
+
update: values.update,
|
|
3205
|
+
hooks: values.hooks,
|
|
3206
|
+
onProgress: (text) => spin.update(text),
|
|
3207
|
+
});
|
|
3208
|
+
spin.stop();
|
|
3209
|
+
for (const result of results) {
|
|
3210
|
+
ui.result(result);
|
|
3211
|
+
}
|
|
3212
|
+
const closing = values["dry-run"]
|
|
3213
|
+
? "Dry run — nothing was written."
|
|
3214
|
+
: values.update
|
|
3215
|
+
? "Updated. Previous copies are in each root's disabled-gsd-skills backup."
|
|
3216
|
+
: "Done. Restart active agent sessions, then invoke the router explicitly.";
|
|
3217
|
+
console.log(`\n ${ui.dim(closing)}\n`);
|
|
3218
|
+
} catch (error) {
|
|
3219
|
+
spin.stop();
|
|
3220
|
+
if (error instanceof InstallerError) {
|
|
3221
|
+
ui.error(error.message);
|
|
3222
|
+
return 1;
|
|
3223
|
+
}
|
|
3224
|
+
throw error;
|
|
3225
|
+
}
|
|
3226
|
+
return 0;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
const invokedDirectly = (() => {
|
|
3230
|
+
if (!process.argv[1]) return false;
|
|
3231
|
+
try {
|
|
3232
|
+
return import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href;
|
|
3233
|
+
} catch {
|
|
3234
|
+
return false;
|
|
3235
|
+
}
|
|
3236
|
+
})();
|
|
3237
|
+
if (invokedDirectly) {
|
|
3238
|
+
process.exit(await main(process.argv.slice(2)));
|
|
3239
|
+
}
|