ultimate-pi 0.18.0 → 0.19.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.
Files changed (314) hide show
  1. package/.agents/skills/harness-debate-plan/SKILL.md +1 -1
  2. package/.agents/skills/harness-decisions/SKILL.md +2 -3
  3. package/.agents/skills/harness-governor/SKILL.md +6 -5
  4. package/.agents/skills/harness-orchestration/SKILL.md +4 -4
  5. package/.agents/skills/harness-review/SKILL.md +7 -7
  6. package/.agents/skills/harness-sentrux-setup/SKILL.md +4 -3
  7. package/.agents/skills/harness-steer/SKILL.md +1 -1
  8. package/.agents/skills/sentrux/SKILL.md +9 -9
  9. package/.pi/PACKAGING.md +4 -4
  10. package/.pi/SYSTEM.md +54 -120
  11. package/.pi/agents/harness/incident-recorder.md +0 -1
  12. package/.pi/agents/harness/planning/decompose.md +1 -3
  13. package/.pi/agents/harness/planning/execution-plan-author.md +0 -2
  14. package/.pi/agents/harness/planning/hypothesis-validator.md +0 -2
  15. package/.pi/agents/harness/planning/hypothesis.md +0 -2
  16. package/.pi/agents/harness/planning/implementation-researcher.md +0 -2
  17. package/.pi/agents/harness/planning/plan-adversary.md +0 -2
  18. package/.pi/agents/harness/planning/plan-evaluator.md +1 -3
  19. package/.pi/agents/harness/planning/planning-context.md +0 -2
  20. package/.pi/agents/harness/planning/review-integrator.md +0 -2
  21. package/.pi/agents/harness/planning/sprint-contract-auditor.md +0 -2
  22. package/.pi/agents/harness/planning/stack-researcher.md +0 -2
  23. package/.pi/agents/harness/{adversary.md → reviewing/adversary.md} +0 -2
  24. package/.pi/agents/harness/{evaluator.md → reviewing/evaluator.md} +0 -2
  25. package/.pi/agents/harness/{tie-breaker.md → reviewing/tie-breaker.md} +0 -2
  26. package/.pi/agents/harness/{executor.md → running/executor.md} +0 -2
  27. package/.pi/agents/harness/sentrux-bootstrap.md +0 -1
  28. package/.pi/agents/harness/sentrux-steward.md +0 -2
  29. package/.pi/agents/harness/trace-librarian.md +0 -1
  30. package/.pi/extensions/00-harness-project-control.ts +133 -0
  31. package/.pi/extensions/00-posthog-network-bootstrap.ts +1 -1
  32. package/.pi/extensions/agt-kill-switch.ts +57 -0
  33. package/.pi/extensions/agt-prompt-guard.ts +32 -0
  34. package/.pi/extensions/budget-guard.ts +2 -0
  35. package/.pi/extensions/custom-footer.ts +46 -145
  36. package/.pi/extensions/custom-header.ts +1 -1
  37. package/.pi/extensions/custom-system-prompt.ts +1 -1
  38. package/.pi/extensions/debate-orchestrator.ts +7 -5
  39. package/.pi/extensions/harness-ask-user.ts +8 -8
  40. package/.pi/extensions/harness-debate-tools.ts +27 -43
  41. package/.pi/extensions/harness-lens.ts +94 -0
  42. package/.pi/extensions/harness-live-widget.ts +33 -2
  43. package/.pi/extensions/harness-plan-approval.ts +12 -12
  44. package/.pi/extensions/harness-run-context.ts +1214 -852
  45. package/.pi/extensions/harness-subagent-governance.ts +8 -0
  46. package/.pi/extensions/harness-subagent-submit.ts +36 -164
  47. package/.pi/extensions/harness-subagents.ts +4 -4
  48. package/.pi/extensions/harness-telemetry.ts +3 -1
  49. package/.pi/extensions/harness-web-tools.ts +3 -3
  50. package/.pi/extensions/observation-bus.ts +2 -0
  51. package/.pi/extensions/policy-gate.ts +27 -5
  52. package/.pi/extensions/review-integrity.ts +91 -10
  53. package/.pi/extensions/sentrux-rules-sync.ts +3 -1
  54. package/.pi/extensions/subagent-governance.ts +92 -0
  55. package/.pi/extensions/test-diff-integrity.ts +1 -0
  56. package/.pi/extensions/trace-recorder.ts +3 -1
  57. package/.pi/extensions/{ultimate-pi-vcc.ts → vcc-compaction.ts} +1 -1
  58. package/.pi/harness/README.md +6 -2
  59. package/.pi/harness/agents.manifest.json +38 -49
  60. package/.pi/harness/agents.policy.yaml +275 -0
  61. package/.pi/harness/corpus/graphify-kb-updater.config.json +55 -0
  62. package/.pi/harness/docs/adrs/0006-sentrux-dual-layer.md +2 -1
  63. package/.pi/harness/docs/adrs/0030-inhouse-vcc-compaction.md +1 -1
  64. package/.pi/harness/docs/adrs/0035-plan-phase-review-gate.md +1 -1
  65. package/.pi/harness/docs/adrs/0044-harness-steer-loop.md +3 -2
  66. package/.pi/harness/docs/adrs/0045-harness-lens-minimal-contract.md +49 -0
  67. package/.pi/harness/docs/adrs/0045-phase-scoped-agent-directories.md +33 -0
  68. package/.pi/harness/docs/adrs/0046-agt-policy-engine.md +51 -0
  69. package/.pi/harness/docs/adrs/0047-agt-layered-security.md +39 -0
  70. package/.pi/harness/docs/adrs/0048-tool-call-hook-order.md +25 -0
  71. package/.pi/harness/docs/adrs/0049-agents-policy-manifest.md +36 -0
  72. package/.pi/harness/docs/adrs/README.md +6 -0
  73. package/.pi/harness/docs/graphify-kb-updater-runbook.md +11 -5
  74. package/.pi/harness/docs/practice-map.md +2 -2
  75. package/.pi/harness/evolution/README.md +1 -2
  76. package/.pi/harness/examples/agents.policy.project.yaml +19 -0
  77. package/.pi/harness/examples/policies/custom-deny-bash.yaml +9 -0
  78. package/.pi/harness/policies/bash-denylists.yaml +5 -0
  79. package/.pi/harness/policies/defaults.yaml +51 -0
  80. package/.pi/harness/policies/orchestrator.yaml +18 -0
  81. package/.pi/harness/policies/phases.yaml +10 -0
  82. package/.pi/harness/policies/roles.yaml +5 -0
  83. package/.pi/harness/policies/web-guard.yaml +5 -0
  84. package/.pi/harness/policies/workflow-sequences.yaml +9 -0
  85. package/.pi/harness/sentrux/architecture.manifest.json +26 -4
  86. package/.pi/harness/specs/harness-spawn-context.schema.json +1 -1
  87. package/.pi/harness/specs/observation.schema.json +2 -1
  88. package/.pi/lib/agents-policy.d.mts +70 -0
  89. package/.pi/lib/agents-policy.mjs +325 -0
  90. package/.pi/lib/agents-policy.ts +19 -0
  91. package/.pi/lib/agt/audit-run-sink.ts +52 -0
  92. package/.pi/lib/agt/build-evaluation-context.ts +285 -0
  93. package/.pi/lib/agt/config.ts +28 -0
  94. package/.pi/lib/agt/delegation.ts +69 -0
  95. package/.pi/lib/agt/evaluate-policy.ts +56 -0
  96. package/.pi/lib/agt/identity-registry.ts +41 -0
  97. package/.pi/lib/agt/index.ts +55 -0
  98. package/.pi/lib/agt/kill-switch-state.ts +11 -0
  99. package/.pi/lib/agt/legacy-evaluate.ts +101 -0
  100. package/.pi/lib/agt/policy-engine.ts +154 -0
  101. package/.pi/lib/agt/rings.ts +21 -0
  102. package/.pi/lib/agt/sre-hooks.ts +45 -0
  103. package/.pi/lib/agt/trust-run-store.ts +26 -0
  104. package/.pi/lib/agt/workflow-history.ts +29 -0
  105. package/.pi/lib/agt-governance-active.ts +14 -0
  106. package/.pi/lib/agt-tool-guard.ts +78 -0
  107. package/.pi/lib/ask-user/dialog.ts +314 -0
  108. package/.pi/{extensions/lib → lib}/debate-bus-core.ts +10 -10
  109. package/.pi/{extensions/lib → lib}/debate-bus-state.ts +1 -1
  110. package/.pi/{extensions/lib → lib}/extension-load-guard.ts +21 -0
  111. package/.pi/lib/harness-agt-tool-guard.ts +5 -0
  112. package/.pi/{extensions/lib → lib}/harness-artifact-gate.ts +6 -16
  113. package/.pi/lib/harness-debate-core-deps.ts +14 -0
  114. package/.pi/lib/harness-debate-workflow-deps.ts +43 -0
  115. package/.pi/lib/harness-lens/.gitattributes +1 -0
  116. package/.pi/lib/harness-lens/clients/edit-autopatch.ts +88 -0
  117. package/.pi/lib/harness-lens/clients/file-kinds.ts +380 -0
  118. package/.pi/lib/harness-lens/clients/file-time.ts +215 -0
  119. package/.pi/lib/harness-lens/clients/file-utils.ts +484 -0
  120. package/.pi/lib/harness-lens/clients/format-service.ts +276 -0
  121. package/.pi/lib/harness-lens/clients/formatters.ts +1000 -0
  122. package/.pi/lib/harness-lens/clients/git-guard.ts +31 -0
  123. package/.pi/lib/harness-lens/clients/indent-retarget.ts +90 -0
  124. package/.pi/lib/harness-lens/clients/installer/index.ts +2368 -0
  125. package/.pi/lib/harness-lens/clients/latency-logger.ts +80 -0
  126. package/.pi/lib/harness-lens/clients/lens-config.ts +43 -0
  127. package/.pi/lib/harness-lens/clients/lens-events.ts +164 -0
  128. package/.pi/lib/harness-lens/clients/lsp/aggregation.ts +91 -0
  129. package/.pi/lib/harness-lens/clients/lsp/client.ts +1466 -0
  130. package/.pi/lib/harness-lens/clients/lsp/config.ts +216 -0
  131. package/.pi/lib/harness-lens/clients/lsp/edits.ts +297 -0
  132. package/.pi/lib/harness-lens/clients/lsp/index.ts +1355 -0
  133. package/.pi/lib/harness-lens/clients/lsp/interactive-install.ts +424 -0
  134. package/.pi/lib/harness-lens/clients/lsp/language.ts +223 -0
  135. package/.pi/lib/harness-lens/clients/lsp/launch.ts +939 -0
  136. package/.pi/lib/harness-lens/clients/lsp/lsp-index.ts +11 -0
  137. package/.pi/lib/harness-lens/clients/lsp/path-utils.ts +12 -0
  138. package/.pi/lib/harness-lens/clients/lsp/server-strategies.ts +81 -0
  139. package/.pi/lib/harness-lens/clients/lsp/server.ts +1971 -0
  140. package/.pi/lib/harness-lens/clients/path-utils.ts +182 -0
  141. package/.pi/lib/harness-lens/clients/pipeline.ts +360 -0
  142. package/.pi/lib/harness-lens/clients/project-profile.ts +117 -0
  143. package/.pi/lib/harness-lens/clients/runtime-agent-end.ts +112 -0
  144. package/.pi/lib/harness-lens/clients/runtime-config.ts +33 -0
  145. package/.pi/lib/harness-lens/clients/runtime-coordinator.ts +186 -0
  146. package/.pi/lib/harness-lens/clients/runtime-tool-result.ts +171 -0
  147. package/.pi/lib/harness-lens/clients/safe-spawn.ts +339 -0
  148. package/.pi/lib/harness-lens/clients/secrets-scanner.ts +214 -0
  149. package/.pi/lib/harness-lens/clients/tool-policy.ts +2072 -0
  150. package/.pi/lib/harness-lens/clients/types.ts +59 -0
  151. package/.pi/lib/harness-lens/clients/widget-state.ts +283 -0
  152. package/.pi/lib/harness-lens/index.ts +532 -0
  153. package/.pi/lib/harness-lens/tools/lsp-diagnostics.ts +706 -0
  154. package/.pi/lib/harness-lens/tools/lsp-navigation.ts +1246 -0
  155. package/.pi/{extensions/lib → lib}/harness-posthog.ts +3 -0
  156. package/.pi/lib/harness-project-config.ts +91 -0
  157. package/.pi/lib/harness-run-context-responses.ts +9 -0
  158. package/.pi/lib/harness-run-context.ts +1 -3
  159. package/.pi/{extensions/lib/spawn-policy.ts → lib/harness-spawn-policy.ts} +4 -3
  160. package/.pi/{extensions/lib → lib}/harness-spawn-topology.ts +5 -28
  161. package/.pi/lib/harness-subagent-auth.ts +51 -0
  162. package/.pi/{extensions/lib → lib}/harness-subagent-precheck.ts +13 -10
  163. package/.pi/{extensions/lib → lib}/harness-subagent-submit-pipeline.ts +3 -3
  164. package/.pi/lib/harness-subagent-submit-register.ts +163 -0
  165. package/.pi/{extensions/lib → lib}/harness-subagent-submit-registry.ts +1 -55
  166. package/.pi/{extensions/lib → lib}/harness-subagents-bridge.ts +53 -14
  167. package/.pi/{extensions/lib → lib}/harness-subprocess-bootstrap.ts +1 -1
  168. package/.pi/lib/harness-ui-state.ts +27 -12
  169. package/.pi/{extensions/lib → lib}/plan-approval/create-plan.ts +2 -2
  170. package/.pi/{extensions/lib → lib}/plan-approval/format-plan.ts +2 -2
  171. package/.pi/{extensions/lib → lib}/plan-approval/plan-review.ts +162 -201
  172. package/.pi/{extensions/lib → lib}/plan-approval/render.ts +1 -1
  173. package/.pi/{extensions/lib → lib}/plan-approval/resolve-disk.ts +2 -2
  174. package/.pi/{extensions/lib → lib}/plan-approval/types.ts +1 -1
  175. package/.pi/{extensions/lib → lib}/plan-approval/validate.ts +3 -3
  176. package/.pi/{extensions/lib → lib}/plan-approval-readiness.ts +3 -52
  177. package/.pi/{extensions/lib → lib}/plan-debate-envelope.ts +1 -1
  178. package/.pi/{extensions/lib → lib}/plan-debate-gate.ts +1 -1
  179. package/.pi/{extensions/lib → lib}/plan-debate-lane.ts +1 -4
  180. package/.pi/{extensions/lib → lib}/plan-messenger.ts +1 -1
  181. package/.pi/prompts/harness-auto.md +2 -2
  182. package/.pi/prompts/harness-plan.md +4 -6
  183. package/.pi/prompts/harness-review.md +9 -9
  184. package/.pi/prompts/harness-run.md +7 -7
  185. package/.pi/prompts/harness-setup.md +42 -68
  186. package/.pi/prompts/harness-steer.md +2 -2
  187. package/.pi/scripts/README.md +3 -5
  188. package/.pi/scripts/generate-agents-policy-yaml.mjs +148 -0
  189. package/.pi/scripts/graphify-kb-updater.mjs +48 -8
  190. package/.pi/scripts/harness-agents-manifest.mjs +61 -4
  191. package/.pi/scripts/harness-agt-doctor.ts +36 -0
  192. package/.pi/scripts/harness-cli-verify.sh +9 -2
  193. package/.pi/scripts/harness-project-toggle.mjs +129 -0
  194. package/.pi/scripts/harness-sentrux-cli.mjs +142 -0
  195. package/.pi/scripts/harness-verify.mjs +113 -39
  196. package/.pi/scripts/harness-web-policy-guard.mjs +2 -2
  197. package/.pi/scripts/validate-plan-dag.mjs +65 -74
  198. package/.pi/scripts/vendor-pi-vcc-settings.stub.ts +2 -2
  199. package/.pi/scripts/vendor-sync-pi-vcc.sh +1 -1
  200. package/.pi/skills/architecture/broker-domain/SKILL.md +65 -0
  201. package/.pi/skills/architecture/cqrs/SKILL.md +63 -0
  202. package/.pi/skills/architecture/event-driven/SKILL.md +60 -0
  203. package/.pi/skills/architecture/hexagonal-ports-adapters/SKILL.md +66 -0
  204. package/.pi/skills/architecture/layered/SKILL.md +68 -0
  205. package/.pi/skills/architecture/microkernel/SKILL.md +62 -0
  206. package/.pi/skills/architecture/microservices/SKILL.md +64 -0
  207. package/.pi/skills/architecture/modular-monolith/SKILL.md +65 -0
  208. package/.pi/skills/architecture/orchestration-driven-soa/SKILL.md +61 -0
  209. package/.pi/skills/architecture/pipeline/SKILL.md +63 -0
  210. package/.pi/skills/architecture/service-based/SKILL.md +64 -0
  211. package/.pi/skills/architecture/service-mesh/SKILL.md +60 -0
  212. package/.pi/skills/architecture/space-based/SKILL.md +60 -0
  213. package/.pi/skills/ast-grep/SKILL.md +40 -321
  214. package/.pi/skills/delivery/debugging-discipline/SKILL.md +36 -0
  215. package/.pi/skills/delivery/documentation-update/SKILL.md +33 -0
  216. package/.pi/skills/delivery/requirements-to-implementation/SKILL.md +34 -0
  217. package/.pi/skills/delivery/risk-based-verification/SKILL.md +43 -0
  218. package/.pi/skills/delivery/tradeoff-analysis/SKILL.md +34 -0
  219. package/.pi/skills/engineering/api-contract-design/SKILL.md +38 -0
  220. package/.pi/skills/engineering/cohesion-coupling/SKILL.md +43 -0
  221. package/.pi/skills/engineering/complexity-control/SKILL.md +31 -0
  222. package/.pi/skills/engineering/defensive-programming/SKILL.md +38 -0
  223. package/.pi/skills/engineering/dependency-management/SKILL.md +29 -0
  224. package/.pi/skills/engineering/domain-modeling/SKILL.md +32 -0
  225. package/.pi/skills/engineering/error-handling/SKILL.md +37 -0
  226. package/.pi/skills/engineering/legacy-code-seams/SKILL.md +35 -0
  227. package/.pi/skills/engineering/naming-and-intent/SKILL.md +29 -0
  228. package/.pi/skills/engineering/refactoring-safe-evolution/SKILL.md +35 -0
  229. package/.pi/skills/engineering/routine-function-design/SKILL.md +34 -0
  230. package/.pi/skills/engineering/small-change-discipline/SKILL.md +35 -0
  231. package/.pi/skills/lsp-navigation/SKILL.md +89 -0
  232. package/.pi/skills/quality/code-review-self-check/SKILL.md +35 -0
  233. package/.pi/skills/quality/privacy-data-handling/SKILL.md +26 -0
  234. package/.pi/skills/quality/security-review/SKILL.md +34 -0
  235. package/.pi/skills/quality/test-strategy/SKILL.md +33 -0
  236. package/.pi/skills/quality/testability-design/SKILL.md +33 -0
  237. package/.pi/skills/systems/concurrency-safety/SKILL.md +32 -0
  238. package/.pi/skills/systems/data-modeling-migrations/SKILL.md +31 -0
  239. package/.pi/skills/systems/observability-instrumentation/SKILL.md +32 -0
  240. package/.pi/skills/systems/performance-measurement/SKILL.md +35 -0
  241. package/.pi/skills/systems/reliability-design/SKILL.md +32 -0
  242. package/.sentrux/rules.toml +20 -4
  243. package/AGENTS.md +5 -0
  244. package/CHANGELOG.md +26 -0
  245. package/README.md +85 -58
  246. package/THIRD_PARTY_NOTICES.md +12 -21
  247. package/package.json +15 -7
  248. package/vendor/pi-subagents/src/agents.ts +45 -1
  249. package/vendor/pi-subagents/src/subagents.ts +866 -811
  250. package/vendor/pi-vcc/src/core/brief.ts +68 -99
  251. package/vendor/pi-vcc/src/core/settings.ts +2 -2
  252. package/.agents/skills/caveman/SKILL.md +0 -67
  253. package/.pi/agents/harness/meta-optimizer.md +0 -36
  254. package/.pi/agents/harness/planning/scout-graphify.md +0 -39
  255. package/.pi/agents/harness/planning/scout-semantic.md +0 -41
  256. package/.pi/agents/harness/planning/scout-structure.md +0 -37
  257. package/.pi/extensions/lib/ask-user/dialog.ts +0 -260
  258. package/.pi/extensions/lib/harness-subagent-auth.ts +0 -209
  259. package/.pi/extensions/lib/harness-subagent-policy.ts +0 -236
  260. package/.pi/extensions/pi-model-router-harness.ts +0 -42
  261. package/.pi/harness/evolution/meta-optimizer.mjs +0 -99
  262. package/.pi/harness/specs/router-tuning-proposal.schema.json +0 -114
  263. package/.pi/model-router.example.json +0 -36
  264. package/.pi/prompts/harness-critic.md +0 -10
  265. package/.pi/prompts/harness-eval.md +0 -10
  266. package/.pi/prompts/harness-router-tune.md +0 -52
  267. package/.pi/scripts/harness-generate-model-router.mjs +0 -327
  268. package/.pi/scripts/harness-model-router-routing.test.mjs +0 -97
  269. package/.pi/scripts/harness-sync-model-router.mjs +0 -97
  270. package/.pi/scripts/vendor-sync-pi-model-router.sh +0 -47
  271. package/vendor/pi-model-router/.prettierignore +0 -4
  272. package/vendor/pi-model-router/.prettierrc +0 -5
  273. package/vendor/pi-model-router/AGENTS.md +0 -39
  274. package/vendor/pi-model-router/LICENSE +0 -21
  275. package/vendor/pi-model-router/README.md +0 -99
  276. package/vendor/pi-model-router/UPSTREAM_PIN.md +0 -10
  277. package/vendor/pi-model-router/docs/ARCHITECTURE.md +0 -54
  278. package/vendor/pi-model-router/extensions/commands.ts +0 -720
  279. package/vendor/pi-model-router/extensions/config.ts +0 -348
  280. package/vendor/pi-model-router/extensions/constants.ts +0 -1
  281. package/vendor/pi-model-router/extensions/index.ts +0 -478
  282. package/vendor/pi-model-router/extensions/provider.ts +0 -580
  283. package/vendor/pi-model-router/extensions/routing.ts +0 -564
  284. package/vendor/pi-model-router/extensions/state.ts +0 -52
  285. package/vendor/pi-model-router/extensions/types.ts +0 -95
  286. package/vendor/pi-model-router/extensions/ui.ts +0 -144
  287. package/vendor/pi-model-router/model-router.example.json +0 -48
  288. package/vendor/pi-model-router/package.json +0 -48
  289. package/vendor/pi-model-router/tsconfig.json +0 -16
  290. /package/.pi/{prompts → harness/docs}/planning-rubrics.md +0 -0
  291. /package/.pi/{extensions/lib → lib}/ask-user/fallback.ts +0 -0
  292. /package/.pi/{extensions/lib → lib}/ask-user/render.ts +0 -0
  293. /package/.pi/{extensions/lib → lib}/ask-user/schema.ts +0 -0
  294. /package/.pi/{extensions/lib → lib}/ask-user/types.ts +0 -0
  295. /package/.pi/{extensions/lib → lib}/ask-user/validate-core.mjs +0 -0
  296. /package/.pi/{extensions/lib → lib}/ask-user/validate.ts +0 -0
  297. /package/.pi/{extensions/lib → lib}/harness-cocoindex-refresh.ts +0 -0
  298. /package/.pi/{extensions/lib → lib}/harness-paths.ts +0 -0
  299. /package/.pi/{extensions/lib → lib}/harness-spawn-budget.ts +0 -0
  300. /package/.pi/{extensions/lib → lib}/harness-vcc-settings.ts +0 -0
  301. /package/.pi/{extensions/lib → lib}/harness-web/run-cli.ts +0 -0
  302. /package/.pi/{extensions/lib → lib}/plan-approval/dialog.ts +0 -0
  303. /package/.pi/{extensions/lib → lib}/plan-approval/schema.ts +0 -0
  304. /package/.pi/{extensions/lib → lib}/plan-debate-eligibility.ts +0 -0
  305. /package/.pi/{extensions/lib → lib}/plan-debate-focus.ts +0 -0
  306. /package/.pi/{extensions/lib → lib}/plan-debate-id.ts +0 -0
  307. /package/.pi/{extensions/lib → lib}/plan-debate-lanes.ts +0 -0
  308. /package/.pi/{extensions/lib → lib}/plan-debate-round-status.ts +0 -0
  309. /package/.pi/{extensions/lib → lib}/plan-debate-write-guard.ts +0 -0
  310. /package/.pi/{extensions/lib → lib}/plan-review-gate.ts +0 -0
  311. /package/.pi/{extensions/lib → lib}/plan-review-integrator-rules.ts +0 -0
  312. /package/.pi/{extensions/lib → lib}/plan-scope-guard.ts +0 -0
  313. /package/.pi/{extensions/lib → lib}/posthog-client.ts +0 -0
  314. /package/.pi/{extensions/lib → lib}/posthog-node.d.ts +0 -0
@@ -0,0 +1,2368 @@
1
+ /**
2
+ * Auto-Installation System for pi-lens
3
+ *
4
+ * Minimal auto-install: Core tools that run frequently.
5
+ * Other tools require manual installation with clear instructions.
6
+ *
7
+ * Auto-install (22 tools):
8
+ * - typescript-language-server (TypeScript LSP)
9
+ * - pyright (Python LSP)
10
+ * - bash-language-server (Bash LSP)
11
+ * - yaml-language-server (YAML LSP)
12
+ * - vscode-langservers-extracted (JSON LSP)
13
+ * - ruff (Python linting)
14
+ * - @biomejs/biome (JS/TS/JSON linting/formatting)
15
+ * - oxlint (JS/TS linting)
16
+ * - madge (circular dependency detection)
17
+ * - jscpd (duplicate code detection)
18
+ * - @ast-grep/cli (structural code search)
19
+ * - knip (dead code detection)
20
+ * - yamllint (YAML linting)
21
+ * - actionlint (GitHub Actions workflow linting) [GitHub release]
22
+ * - sqlfluff (SQL linting/formatting)
23
+ * - markdownlint-cli2 (Markdown linting)
24
+ * - mypy (Python type checking)
25
+ * - rubocop (Ruby linting/autofix)
26
+ * - stylelint (CSS/SCSS/Less linting)
27
+ * - shellcheck (shell script linting) [GitHub release]
28
+ * - shfmt (shell script formatting) [GitHub release]
29
+ * - rust-analyzer (Rust LSP) [GitHub release]
30
+ * - golangci-lint (Go linting) [GitHub release]
31
+ *
32
+ * Manual install required (25+ tools):
33
+ * - yaml-language-server: npm install -g yaml-language-server
34
+ * - vscode-json-languageserver: npm install -g vscode-langservers-extracted
35
+ * - bash-language-server: npm install -g bash-language-server
36
+ * - svelte-language-server: npm install -g svelte-language-server
37
+ * - vscode-eslint-language-server: npm install -g vscode-langservers-extracted
38
+ * - vscode-css-languageserver: npm install -g vscode-langservers-extracted
39
+ * - @prisma/language-server: npm install -g @prisma/language-server
40
+ * - dockerfile-language-server: npm install -g dockerfile-language-server-nodejs
41
+ * - @vue/language-server: npm install -g @vue/language-server
42
+ * - And all language-specific servers (gopls, rust-analyzer, etc.)
43
+ *
44
+ * Strategies:
45
+ * - npm packages via npx/bun
46
+ * - pip packages
47
+ * - GitHub releases (platform-specific binaries → ~/.pi-lens/bin/)
48
+ */
49
+
50
+ import { spawn } from "node:child_process";
51
+ import { statSync } from "node:fs";
52
+ import fs from "node:fs/promises";
53
+ import https from "node:https";
54
+ import os from "node:os";
55
+ import path from "node:path";
56
+ import { createGunzip } from "node:zlib";
57
+
58
+ // Global installation directory for pi-lens tools
59
+ const TOOLS_DIR = path.join(os.homedir(), ".pi-lens", "tools");
60
+
61
+ // Directory for GitHub-downloaded binaries
62
+ const GITHUB_BIN_DIR = path.join(os.homedir(), ".pi-lens", "bin");
63
+
64
+ // Debug flag - set via PI_LENS_DEBUG=1 or --debug
65
+ const DEBUG =
66
+ process.env.PI_LENS_DEBUG === "1" || process.argv.includes("--debug");
67
+ const SESSIONSTART_LOG_DIR = path.join(os.homedir(), ".pi-lens");
68
+ const SESSIONSTART_LOG = path.join(SESSIONSTART_LOG_DIR, "sessionstart.log");
69
+
70
+ /**
71
+ * Log debug messages only when DEBUG is enabled
72
+ */
73
+ function debugLog(...args: unknown[]): void {
74
+ if (DEBUG) {
75
+ console.error("[auto-install:debug]", ...args);
76
+ }
77
+ }
78
+
79
+ function logSessionStart(msg: string): void {
80
+ if (
81
+ process.env.PI_LENS_TEST_MODE === "1" ||
82
+ (process.env.VITEST && process.env.PI_LENS_TEST_MODE !== "0")
83
+ ) {
84
+ return;
85
+ }
86
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
87
+ void fs
88
+ .mkdir(SESSIONSTART_LOG_DIR, { recursive: true })
89
+ .then(() => fs.appendFile(SESSIONSTART_LOG, line))
90
+ .catch(() => {
91
+ // best-effort logging
92
+ });
93
+ }
94
+
95
+ // --- Tool Definitions ---
96
+
97
+ interface GitHubAssetSpec {
98
+ /** owner/repo on GitHub */
99
+ repo: string;
100
+ /**
101
+ * Return the asset filename substring to match for this platform/arch,
102
+ * or undefined if the platform is unsupported.
103
+ * platform: "linux" | "darwin" | "win32"
104
+ * arch: "x64" | "arm64" | "ia32" | ...
105
+ */
106
+ assetMatch: (platform: string, arch: string) => string | undefined;
107
+ /**
108
+ * If the asset is an archive, the name of the binary inside it.
109
+ * For bare .gz files (e.g. rust-analyzer) leave undefined — the asset IS the binary.
110
+ */
111
+ binaryInArchive?: string;
112
+ hashiCorpReleaseProduct?: string;
113
+ }
114
+
115
+ interface ToolDefinition {
116
+ id: string;
117
+ name: string;
118
+ checkCommand: string;
119
+ checkArgs: string[];
120
+ installStrategy: "npm" | "pip" | "gem" | "github";
121
+ packageName?: string;
122
+ binaryName?: string;
123
+ github?: GitHubAssetSpec;
124
+ }
125
+
126
+ const ALL_TOOLS: ToolDefinition[] = [
127
+ // Core LSP servers
128
+ {
129
+ id: "typescript-language-server",
130
+ name: "TypeScript Language Server",
131
+ checkCommand: "typescript-language-server",
132
+ checkArgs: ["--version"],
133
+ installStrategy: "npm",
134
+ packageName: "typescript-language-server",
135
+ binaryName: "typescript-language-server",
136
+ },
137
+ {
138
+ id: "typescript",
139
+ name: "TypeScript",
140
+ checkCommand: "tsc",
141
+ checkArgs: ["--version"],
142
+ installStrategy: "npm",
143
+ packageName: "typescript",
144
+ binaryName: "tsc",
145
+ },
146
+ {
147
+ id: "pyright",
148
+ name: "Pyright",
149
+ checkCommand: "pyright",
150
+ checkArgs: ["--version"],
151
+ installStrategy: "npm",
152
+ packageName: "pyright",
153
+ binaryName: "pyright",
154
+ },
155
+ // Linting/formatting tools
156
+ {
157
+ id: "prettier",
158
+ name: "Prettier",
159
+ checkCommand: "prettier",
160
+ checkArgs: ["--version"],
161
+ installStrategy: "npm",
162
+ packageName: "prettier",
163
+ binaryName: "prettier",
164
+ },
165
+ {
166
+ id: "ruff",
167
+ name: "Ruff",
168
+ checkCommand: "ruff",
169
+ checkArgs: ["--version"],
170
+ installStrategy: "pip",
171
+ packageName: "ruff",
172
+ binaryName: "ruff",
173
+ },
174
+ {
175
+ id: "biome",
176
+ name: "Biome",
177
+ checkCommand: "biome",
178
+ checkArgs: ["--version"],
179
+ installStrategy: "npm",
180
+ packageName: "@biomejs/biome",
181
+ binaryName: "biome",
182
+ },
183
+ // Analysis tools (run at session start / turn end)
184
+ {
185
+ id: "madge",
186
+ name: "Madge",
187
+ checkCommand: "madge",
188
+ checkArgs: ["--version"],
189
+ installStrategy: "npm",
190
+ packageName: "madge",
191
+ binaryName: "madge",
192
+ },
193
+ {
194
+ id: "jscpd",
195
+ name: "jscpd",
196
+ checkCommand: "jscpd",
197
+ checkArgs: ["--version"],
198
+ installStrategy: "npm",
199
+ packageName: "jscpd@3.5.10", // jscpd v4 introduced reprism dep whose lib/languages/ dir is missing from the published package — v3.5.x is the last stable release
200
+ binaryName: "jscpd",
201
+ },
202
+ // Structural search and dead code detection
203
+ {
204
+ id: "ast-grep",
205
+ name: "ast-grep CLI",
206
+ checkCommand: "ast-grep",
207
+ checkArgs: ["--version"],
208
+ installStrategy: "npm",
209
+ packageName: "@ast-grep/cli",
210
+ binaryName: "ast-grep",
211
+ },
212
+ {
213
+ id: "knip",
214
+ name: "Knip",
215
+ checkCommand: "knip",
216
+ checkArgs: ["--version"],
217
+ installStrategy: "npm",
218
+ packageName: "knip",
219
+ binaryName: "knip",
220
+ },
221
+ {
222
+ id: "yamllint",
223
+ name: "yamllint",
224
+ checkCommand: "yamllint",
225
+ checkArgs: ["--version"],
226
+ installStrategy: "pip",
227
+ packageName: "yamllint",
228
+ binaryName: "yamllint",
229
+ },
230
+ {
231
+ id: "sqlfluff",
232
+ name: "sqlfluff",
233
+ checkCommand: "sqlfluff",
234
+ checkArgs: ["--version"],
235
+ installStrategy: "pip",
236
+ packageName: "sqlfluff",
237
+ binaryName: "sqlfluff",
238
+ },
239
+ {
240
+ id: "bash-language-server",
241
+ name: "Bash Language Server",
242
+ checkCommand: "bash-language-server",
243
+ checkArgs: ["--version"],
244
+ installStrategy: "npm",
245
+ packageName: "bash-language-server",
246
+ binaryName: "bash-language-server",
247
+ },
248
+ {
249
+ id: "yaml-language-server",
250
+ name: "YAML Language Server",
251
+ checkCommand: "yaml-language-server",
252
+ checkArgs: ["--version"],
253
+ installStrategy: "npm",
254
+ packageName: "yaml-language-server",
255
+ binaryName: "yaml-language-server",
256
+ },
257
+ {
258
+ id: "vscode-json-language-server",
259
+ name: "VSCode JSON Language Server",
260
+ checkCommand: "vscode-json-language-server",
261
+ checkArgs: ["--version"],
262
+ installStrategy: "npm",
263
+ packageName: "vscode-langservers-extracted",
264
+ binaryName: "vscode-json-language-server",
265
+ },
266
+ {
267
+ id: "vscode-langservers-extracted",
268
+ name: "VSCode ESLint Language Server",
269
+ checkCommand: "vscode-eslint-language-server",
270
+ checkArgs: ["--version"],
271
+ installStrategy: "npm",
272
+ packageName: "vscode-langservers-extracted",
273
+ binaryName: "vscode-eslint-language-server",
274
+ },
275
+ {
276
+ id: "vscode-html-languageserver-bin",
277
+ name: "VSCode HTML Language Server",
278
+ checkCommand: "vscode-html-language-server",
279
+ checkArgs: ["--version"],
280
+ installStrategy: "npm",
281
+ packageName: "vscode-html-languageserver-bin",
282
+ binaryName: "vscode-html-language-server",
283
+ },
284
+ {
285
+ id: "htmlhint",
286
+ name: "HTMLHint",
287
+ checkCommand: "htmlhint",
288
+ checkArgs: ["--version"],
289
+ installStrategy: "npm",
290
+ packageName: "htmlhint",
291
+ binaryName: "htmlhint",
292
+ },
293
+ {
294
+ id: "hadolint",
295
+ name: "Hadolint",
296
+ checkCommand: "hadolint",
297
+ checkArgs: ["--version"],
298
+ installStrategy: "github",
299
+ binaryName: "hadolint",
300
+ github: {
301
+ repo: "hadolint/hadolint",
302
+ assetMatch: (platform, arch) => {
303
+ if (platform === "linux")
304
+ return arch === "arm64" ? "linux.aarch64" : "linux.x86_64";
305
+ if (platform === "darwin")
306
+ return arch === "arm64" ? "macos-arm64" : "macos-x86_64";
307
+ if (platform === "win32") return "windows-x86_64.exe";
308
+ return undefined;
309
+ },
310
+ },
311
+ },
312
+ {
313
+ id: "vscode-css-languageserver",
314
+ name: "VSCode CSS Language Server",
315
+ checkCommand: "vscode-css-language-server",
316
+ checkArgs: ["--version"],
317
+ installStrategy: "npm",
318
+ packageName: "vscode-css-languageserver",
319
+ binaryName: "vscode-css-language-server",
320
+ },
321
+ {
322
+ id: "dockerfile-language-server-nodejs",
323
+ name: "Dockerfile Language Server",
324
+ checkCommand: "docker-langserver",
325
+ checkArgs: ["--version"],
326
+ installStrategy: "npm",
327
+ packageName: "dockerfile-language-server-nodejs",
328
+ binaryName: "docker-langserver",
329
+ },
330
+ {
331
+ id: "intelephense",
332
+ name: "Intelephense",
333
+ checkCommand: "intelephense",
334
+ checkArgs: ["--version"],
335
+ installStrategy: "npm",
336
+ packageName: "intelephense",
337
+ binaryName: "intelephense",
338
+ },
339
+ {
340
+ id: "@prisma/language-server",
341
+ name: "Prisma Language Server",
342
+ checkCommand: "prisma-language-server",
343
+ checkArgs: ["--version"],
344
+ installStrategy: "npm",
345
+ packageName: "@prisma/language-server",
346
+ binaryName: "prisma-language-server",
347
+ },
348
+ {
349
+ id: "@vue/language-server",
350
+ name: "Vue Language Server",
351
+ checkCommand: "vue-language-server",
352
+ checkArgs: ["--version"],
353
+ installStrategy: "npm",
354
+ packageName: "@vue/language-server",
355
+ binaryName: "vue-language-server",
356
+ },
357
+ {
358
+ id: "svelte-language-server",
359
+ name: "Svelte Language Server",
360
+ checkCommand: "svelteserver",
361
+ checkArgs: ["--version"],
362
+ installStrategy: "npm",
363
+ packageName: "svelte-language-server",
364
+ binaryName: "svelteserver",
365
+ },
366
+ {
367
+ id: "markdownlint",
368
+ name: "markdownlint-cli2",
369
+ checkCommand: "markdownlint-cli2",
370
+ checkArgs: ["--version"],
371
+ installStrategy: "npm",
372
+ packageName: "markdownlint-cli2",
373
+ binaryName: "markdownlint-cli2",
374
+ },
375
+ {
376
+ id: "mypy",
377
+ name: "mypy",
378
+ checkCommand: "mypy",
379
+ checkArgs: ["--version"],
380
+ installStrategy: "pip",
381
+ packageName: "mypy",
382
+ binaryName: "mypy",
383
+ },
384
+ {
385
+ id: "rubocop",
386
+ name: "RuboCop",
387
+ checkCommand: "rubocop",
388
+ checkArgs: ["--version"],
389
+ installStrategy: "gem",
390
+ packageName: "rubocop",
391
+ binaryName: "rubocop",
392
+ },
393
+ {
394
+ id: "stylelint",
395
+ name: "Stylelint",
396
+ checkCommand: "stylelint",
397
+ checkArgs: ["--version"],
398
+ installStrategy: "npm",
399
+ packageName: "stylelint",
400
+ binaryName: "stylelint",
401
+ },
402
+ {
403
+ id: "oxlint",
404
+ name: "Oxlint",
405
+ checkCommand: "oxlint",
406
+ checkArgs: ["--version"],
407
+ installStrategy: "npm",
408
+ packageName: "oxlint",
409
+ binaryName: "oxlint",
410
+ },
411
+ // GitHub release binaries
412
+ {
413
+ id: "shellcheck",
414
+ name: "ShellCheck",
415
+ checkCommand: "shellcheck",
416
+ checkArgs: ["--version"],
417
+ installStrategy: "github",
418
+ binaryName: "shellcheck",
419
+ github: {
420
+ repo: "koalaman/shellcheck",
421
+ assetMatch: (platform, arch) => {
422
+ if (platform === "linux")
423
+ return arch === "arm64"
424
+ ? "linux.aarch64.tar.xz"
425
+ : "linux.x86_64.tar.xz";
426
+ if (platform === "darwin")
427
+ return arch === "arm64"
428
+ ? "darwin.aarch64.tar.xz"
429
+ : "darwin.x86_64.tar.xz";
430
+ if (platform === "win32") return "zip";
431
+ return undefined;
432
+ },
433
+ binaryInArchive: "shellcheck",
434
+ },
435
+ },
436
+ {
437
+ id: "shfmt",
438
+ name: "shfmt",
439
+ checkCommand: "shfmt",
440
+ checkArgs: ["--version"],
441
+ installStrategy: "github",
442
+ binaryName: "shfmt",
443
+ github: {
444
+ repo: "mvdan/sh",
445
+ assetMatch: (platform, arch) => {
446
+ if (platform === "linux")
447
+ return arch === "arm64" ? "linux_arm64" : "linux_amd64";
448
+ if (platform === "darwin")
449
+ return arch === "arm64" ? "darwin_arm64" : "darwin_amd64";
450
+ if (platform === "win32")
451
+ return arch === "arm64" ? "windows_arm64.exe" : "windows_amd64.exe";
452
+ return undefined;
453
+ },
454
+ // bare binary, no archive
455
+ },
456
+ },
457
+ {
458
+ id: "rust-analyzer",
459
+ name: "rust-analyzer",
460
+ checkCommand: "rust-analyzer",
461
+ checkArgs: ["--version"],
462
+ installStrategy: "github",
463
+ binaryName: "rust-analyzer",
464
+ github: {
465
+ repo: "rust-lang/rust-analyzer",
466
+ assetMatch: (platform, arch) => {
467
+ if (platform === "linux")
468
+ return arch === "arm64"
469
+ ? "aarch64-unknown-linux-gnu.gz"
470
+ : "x86_64-unknown-linux-gnu.gz";
471
+ if (platform === "darwin")
472
+ return arch === "arm64"
473
+ ? "aarch64-apple-darwin.gz"
474
+ : "x86_64-apple-darwin.gz";
475
+ if (platform === "win32") return "x86_64-pc-windows-msvc.zip";
476
+ return undefined;
477
+ },
478
+ // Linux/macOS: bare .gz; Windows: .zip archive containing rust-analyzer.exe
479
+ },
480
+ },
481
+ {
482
+ id: "golangci-lint",
483
+ name: "golangci-lint",
484
+ checkCommand: "golangci-lint",
485
+ checkArgs: ["--version"],
486
+ installStrategy: "github",
487
+ binaryName: "golangci-lint",
488
+ github: {
489
+ repo: "golangci/golangci-lint",
490
+ assetMatch: (platform, arch) => {
491
+ if (platform === "linux")
492
+ return arch === "arm64" ? "linux-arm64.tar.gz" : "linux-amd64.tar.gz";
493
+ if (platform === "darwin")
494
+ return arch === "arm64"
495
+ ? "darwin-arm64.tar.gz"
496
+ : "darwin-amd64.tar.gz";
497
+ if (platform === "win32")
498
+ return arch === "arm64" ? "windows-arm64.zip" : "windows-amd64.zip";
499
+ return undefined;
500
+ },
501
+ binaryInArchive: "golangci-lint",
502
+ },
503
+ },
504
+ {
505
+ id: "ktlint",
506
+ name: "ktlint",
507
+ checkCommand: "ktlint",
508
+ checkArgs: ["--version"],
509
+ installStrategy: "github",
510
+ binaryName: "ktlint",
511
+ github: {
512
+ // ktlint ships one universal binary "ktlint" for Linux/macOS (GraalVM native)
513
+ // and "ktlint.bat" for Windows (requires Java). No arm64-specific asset.
514
+ repo: "pinterest/ktlint",
515
+ assetMatch: (platform, _arch) => {
516
+ if (platform === "linux") return "ktlint";
517
+ if (platform === "darwin") return "ktlint";
518
+ if (platform === "win32") return "ktlint.bat";
519
+ return undefined;
520
+ },
521
+ },
522
+ },
523
+ {
524
+ id: "actionlint",
525
+ name: "actionlint",
526
+ checkCommand: "actionlint",
527
+ checkArgs: ["--version"],
528
+ installStrategy: "github",
529
+ binaryName: "actionlint",
530
+ github: {
531
+ repo: "rhysd/actionlint",
532
+ assetMatch: (platform, arch) => {
533
+ if (platform === "linux")
534
+ return arch === "arm64" ? "linux_arm64.tar.gz" : "linux_amd64.tar.gz";
535
+ if (platform === "darwin")
536
+ return arch === "arm64"
537
+ ? "darwin_arm64.tar.gz"
538
+ : "darwin_amd64.tar.gz";
539
+ if (platform === "win32")
540
+ return arch === "arm64" ? "windows_arm64.zip" : "windows_amd64.zip";
541
+ return undefined;
542
+ },
543
+ binaryInArchive: "actionlint",
544
+ },
545
+ },
546
+ {
547
+ id: "tflint",
548
+ name: "tflint",
549
+ checkCommand: "tflint",
550
+ checkArgs: ["--version"],
551
+ installStrategy: "github",
552
+ binaryName: "tflint",
553
+ github: {
554
+ repo: "terraform-linters/tflint",
555
+ assetMatch: (platform, arch) => {
556
+ if (platform === "linux")
557
+ return arch === "arm64" ? "linux_arm64.zip" : "linux_amd64.zip";
558
+ if (platform === "darwin")
559
+ return arch === "arm64" ? "darwin_arm64.zip" : "darwin_amd64.zip";
560
+ if (platform === "win32")
561
+ return arch === "arm64" ? "windows_arm64.zip" : "windows_amd64.zip";
562
+ return undefined;
563
+ },
564
+ binaryInArchive: "tflint",
565
+ },
566
+ },
567
+ {
568
+ id: "swiftlint",
569
+ name: "SwiftLint",
570
+ checkCommand: "swiftlint",
571
+ checkArgs: ["--version"],
572
+ installStrategy: "github",
573
+ binaryName: "swiftlint",
574
+ github: {
575
+ repo: "realm/SwiftLint",
576
+ assetMatch: (platform, arch) => {
577
+ if (platform === "darwin") return "portable_swiftlint.zip";
578
+ if (platform === "linux")
579
+ return arch === "arm64"
580
+ ? "swiftlint_linux_arm64.zip"
581
+ : "swiftlint_linux_amd64.zip";
582
+ return undefined;
583
+ },
584
+ binaryInArchive: "swiftlint",
585
+ },
586
+ },
587
+ {
588
+ id: "taplo",
589
+ name: "taplo",
590
+ checkCommand: "taplo",
591
+ checkArgs: ["--version"],
592
+ installStrategy: "github",
593
+ binaryName: "taplo",
594
+ github: {
595
+ repo: "tamasfe/taplo",
596
+ assetMatch: (platform, arch) => {
597
+ if (platform === "linux")
598
+ return arch === "arm64"
599
+ ? "taplo-linux-aarch64.gz"
600
+ : "taplo-linux-x86_64.gz";
601
+ if (platform === "darwin")
602
+ return arch === "arm64"
603
+ ? "taplo-darwin-aarch64.gz"
604
+ : "taplo-darwin-x86_64.gz";
605
+ if (platform === "win32") return "taplo-windows-x86_64.gz";
606
+ return undefined;
607
+ },
608
+ },
609
+ },
610
+ {
611
+ id: "vale",
612
+ name: "Vale",
613
+ checkCommand: "vale",
614
+ checkArgs: ["--version"],
615
+ installStrategy: "github",
616
+ binaryName: "vale",
617
+ github: {
618
+ repo: "vale-cli/vale",
619
+ assetMatch: (platform, arch) => {
620
+ const version = "3.14.2";
621
+ if (platform === "linux")
622
+ return arch === "arm64"
623
+ ? `vale_${version}_Linux_arm64.tar.gz`
624
+ : `vale_${version}_Linux_64-bit.tar.gz`;
625
+ if (platform === "darwin")
626
+ return arch === "arm64"
627
+ ? `vale_${version}_macOS_arm64.tar.gz`
628
+ : `vale_${version}_macOS_64-bit.tar.gz`;
629
+ if (platform === "win32") return `vale_${version}_Windows_64-bit.zip`;
630
+ return undefined;
631
+ },
632
+ binaryInArchive: "vale",
633
+ },
634
+ },
635
+ {
636
+ id: "terraform-ls",
637
+ name: "terraform-ls",
638
+ checkCommand: "terraform-ls",
639
+ checkArgs: ["version"],
640
+ installStrategy: "github",
641
+ binaryName: "terraform-ls",
642
+ github: {
643
+ repo: "hashicorp/terraform-ls",
644
+ hashiCorpReleaseProduct: "terraform-ls",
645
+ assetMatch: (platform, arch) => {
646
+ if (platform === "linux")
647
+ return arch === "arm64" ? "linux_arm64.zip" : "linux_amd64.zip";
648
+ if (platform === "darwin")
649
+ return arch === "arm64" ? "darwin_arm64.zip" : "darwin_amd64.zip";
650
+ if (platform === "win32")
651
+ return arch === "arm64" ? "windows_arm64.zip" : "windows_amd64.zip";
652
+ return undefined;
653
+ },
654
+ binaryInArchive: "terraform-ls",
655
+ },
656
+ },
657
+ {
658
+ id: "zls",
659
+ name: "zls",
660
+ checkCommand: "zls",
661
+ checkArgs: ["--version"],
662
+ installStrategy: "github",
663
+ binaryName: "zls",
664
+ github: {
665
+ repo: "zigtools/zls",
666
+ assetMatch: (platform, arch) => {
667
+ if (platform === "linux")
668
+ return arch === "arm64"
669
+ ? "aarch64-linux.tar.xz"
670
+ : "x86_64-linux.tar.xz";
671
+ if (platform === "darwin")
672
+ return arch === "arm64"
673
+ ? "aarch64-macos.tar.xz"
674
+ : "x86_64-macos.tar.xz";
675
+ if (platform === "win32")
676
+ return arch === "arm64"
677
+ ? "aarch64-windows.zip"
678
+ : "x86_64-windows.zip";
679
+ return undefined;
680
+ },
681
+ binaryInArchive: "zls",
682
+ },
683
+ },
684
+ ];
685
+
686
+ const NON_LSP_INSTALLER_TOOL_IDS = new Set([
687
+ "prettier",
688
+ "ruff",
689
+ "biome",
690
+ "ast-grep",
691
+ "madge",
692
+ "jscpd",
693
+ "knip",
694
+ "yamllint",
695
+ "sqlfluff",
696
+ "htmlhint",
697
+ "hadolint",
698
+ "oxlint",
699
+ "mypy",
700
+ "rubocop",
701
+ "stylelint",
702
+ "shellcheck",
703
+ "shfmt",
704
+ "golangci-lint",
705
+ "actionlint",
706
+ "ktlint",
707
+ "markdownlint-cli2",
708
+ "black",
709
+ "eslint",
710
+ "semgrep",
711
+ ]);
712
+
713
+ function isHarnessLspInstallerTool(tool: ToolDefinition): boolean {
714
+ if (NON_LSP_INSTALLER_TOOL_IDS.has(tool.id)) return false;
715
+ if (tool.id === "typescript") return true;
716
+ if (
717
+ /language-server|languageserver|analyzer|intelephense|taplo|terraform-ls|pyright|rust-analyzer|zls/i.test(
718
+ tool.id,
719
+ )
720
+ ) {
721
+ return true;
722
+ }
723
+ if (tool.checkCommand.includes("language-server")) return true;
724
+ return false;
725
+ }
726
+
727
+ const TOOLS = ALL_TOOLS.filter(isHarnessLspInstallerTool);
728
+
729
+ const ensureInFlight = new Map<string, Promise<string | undefined>>();
730
+
731
+ // Session-lifetime cache: once a tool path is resolved, skip the process-spawn check on subsequent calls.
732
+ const resolvedPathCache = new Map<string, string>();
733
+
734
+ // --- Persistent probe cache ---
735
+
736
+ interface ProbeCacheEntry {
737
+ path: string;
738
+ mtimeMs: number;
739
+ cachedAt: number;
740
+ }
741
+
742
+ type ProbeCache = Record<string, ProbeCacheEntry>;
743
+
744
+ const PROBE_CACHE_PATH = path.join(
745
+ os.homedir(),
746
+ ".pi-lens",
747
+ "probe-cache.json",
748
+ );
749
+ const PROBE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
750
+
751
+ let _probeCache: ProbeCache | null = null;
752
+ let _probeCacheDirty = false;
753
+ let _probeCacheFlushTimer: ReturnType<typeof setTimeout> | null = null;
754
+
755
+ async function readProbeCache(): Promise<ProbeCache> {
756
+ if (_probeCache !== null) return _probeCache;
757
+ try {
758
+ const raw = await fs.readFile(PROBE_CACHE_PATH, "utf-8");
759
+ _probeCache = JSON.parse(raw) as ProbeCache;
760
+ } catch {
761
+ _probeCache = {};
762
+ }
763
+ return _probeCache;
764
+ }
765
+
766
+ function scheduleProbeFlush(): void {
767
+ if (_probeCacheFlushTimer !== null) return;
768
+ _probeCacheFlushTimer = setTimeout(() => {
769
+ _probeCacheFlushTimer = null;
770
+ if (!_probeCacheDirty || _probeCache === null) return;
771
+ _probeCacheDirty = false;
772
+ void fs
773
+ .writeFile(PROBE_CACHE_PATH, JSON.stringify(_probeCache, null, 2))
774
+ .catch(() => {});
775
+ }, 300);
776
+ }
777
+
778
+ function isAstGrepVersionOutput(output: string): boolean {
779
+ return /\bast[- ]grep\b/i.test(output);
780
+ }
781
+
782
+ async function verifyAstGrepProbePath(binPath: string): Promise<boolean> {
783
+ return new Promise((resolve) => {
784
+ const proc = spawn(binPath, ["--version"], {
785
+ stdio: ["ignore", "pipe", "pipe"],
786
+ shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(binPath),
787
+ timeout: 5000,
788
+ });
789
+ let output = "";
790
+ proc.stdout?.on("data", (data) => (output += data));
791
+ proc.stderr?.on("data", (data) => (output += data));
792
+ proc.on("exit", (code) => {
793
+ resolve(code === 0 && isAstGrepVersionOutput(output));
794
+ });
795
+ proc.on("error", () => resolve(false));
796
+ });
797
+ }
798
+
799
+ // Exported for testing only.
800
+ export async function checkProbeCache(
801
+ toolId: string,
802
+ ): Promise<string | undefined> {
803
+ const cache = await readProbeCache();
804
+ const entry = cache[toolId];
805
+ if (!entry) return undefined;
806
+
807
+ if (Date.now() - entry.cachedAt > PROBE_CACHE_TTL_MS) {
808
+ logSessionStart(`auto-install probe-cache ${toolId}: miss (ttl expired)`);
809
+ delete cache[toolId];
810
+ _probeCacheDirty = true;
811
+ scheduleProbeFlush();
812
+ return undefined;
813
+ }
814
+
815
+ try {
816
+ await fs.access(entry.path);
817
+ const stat = await fs.stat(entry.path);
818
+ if (stat.mtimeMs !== entry.mtimeMs) {
819
+ logSessionStart(
820
+ `auto-install probe-cache ${toolId}: miss (mtime changed)`,
821
+ );
822
+ delete cache[toolId];
823
+ _probeCacheDirty = true;
824
+ scheduleProbeFlush();
825
+ return undefined;
826
+ }
827
+ if (toolId === "ast-grep" && !(await verifyAstGrepProbePath(entry.path))) {
828
+ logSessionStart(
829
+ `auto-install probe-cache ${toolId}: miss (not ast-grep: ${entry.path})`,
830
+ );
831
+ delete cache[toolId];
832
+ _probeCacheDirty = true;
833
+ scheduleProbeFlush();
834
+ return undefined;
835
+ }
836
+ return entry.path;
837
+ } catch {
838
+ logSessionStart(
839
+ `auto-install probe-cache ${toolId}: miss (gone: ${entry.path})`,
840
+ );
841
+ delete cache[toolId];
842
+ _probeCacheDirty = true;
843
+ scheduleProbeFlush();
844
+ return undefined;
845
+ }
846
+ }
847
+
848
+ // Exported for testing only.
849
+ export async function updateProbeCache(
850
+ toolId: string,
851
+ resolvedPath: string,
852
+ ): Promise<void> {
853
+ try {
854
+ const stat = await fs.stat(resolvedPath);
855
+ const cache = await readProbeCache();
856
+ cache[toolId] = {
857
+ path: resolvedPath,
858
+ mtimeMs: stat.mtimeMs,
859
+ cachedAt: Date.now(),
860
+ };
861
+ _probeCacheDirty = true;
862
+ scheduleProbeFlush();
863
+ } catch {
864
+ // best-effort
865
+ }
866
+ }
867
+
868
+ // Exported for testing only.
869
+ export function resetProbeCacheStateForTesting(): void {
870
+ _probeCache = null;
871
+ _probeCacheDirty = false;
872
+ if (_probeCacheFlushTimer !== null) {
873
+ clearTimeout(_probeCacheFlushTimer);
874
+ _probeCacheFlushTimer = null;
875
+ }
876
+ }
877
+
878
+ // --- Check Functions ---
879
+
880
+ /**
881
+ * Check if a command is available in PATH by walking PATH entries and
882
+ * verifying each candidate is a real file with non-zero size.
883
+ * Catches broken symlinks (stat throws ENOENT or returns size 0) without
884
+ * spawning a process — ~μs per candidate vs ~50ms for which/where.
885
+ */
886
+ async function isCommandAvailable(
887
+ command: string,
888
+ _args?: string[],
889
+ ): Promise<boolean> {
890
+ const isWindows = process.platform === "win32";
891
+ const pathEnv =
892
+ process.env.PATH || process.env.Path || process.env.path || "";
893
+ const dirs = pathEnv.split(path.delimiter);
894
+
895
+ // On Windows, probe .exe, .cmd, and .bat extensions in addition to bare name.
896
+ // On Unix, probe bare name and extensionless (scripts, symlinks).
897
+ const names = isWindows
898
+ ? [command, `${command}.exe`, `${command}.cmd`, `${command}.bat`]
899
+ : [command];
900
+
901
+ for (const dir of dirs) {
902
+ if (!dir) continue;
903
+ for (const name of names) {
904
+ const candidate = path.join(dir, name);
905
+ try {
906
+ const stat = statSync(candidate);
907
+ // isFile() returns false for broken symlinks (target missing)
908
+ if (stat.isFile() && stat.size > 0) {
909
+ return true;
910
+ }
911
+ } catch {
912
+ // ENOENT or permission denied — skip this candidate
913
+ }
914
+ }
915
+ }
916
+
917
+ return false;
918
+ }
919
+
920
+ // --- Verification Functions
921
+
922
+ /**
923
+ * Verify a tool binary actually works by running --version
924
+ * This catches broken symlinks, partial installs, and corrupted binaries
925
+ */
926
+ async function verifyToolBinary(binPath: string): Promise<boolean> {
927
+ return new Promise((resolve) => {
928
+ const isWindows = process.platform === "win32";
929
+ const hasKnownWindowsExt = /\.(cmd|exe|ps1)$/i.test(binPath);
930
+
931
+ // On Windows, resolve the best executable path:
932
+ // - extensionless → prefer .cmd (cmd.exe-safe)
933
+ // - .ps1 → prefer .cmd sibling to avoid PowerShell execution-policy hangs
934
+ // - .cmd / .exe → use as-is
935
+ let execPath =
936
+ isWindows && !hasKnownWindowsExt ? `${binPath}.cmd` : binPath;
937
+ let useShell = isWindows && /\.(cmd|bat)$/i.test(execPath);
938
+
939
+ if (isWindows && /\.ps1$/i.test(execPath)) {
940
+ const cmdSibling = `${execPath.slice(0, -4)}.cmd`;
941
+ if (require("node:fs").existsSync(cmdSibling)) {
942
+ execPath = cmdSibling;
943
+ useShell = true;
944
+ } else {
945
+ // Fall back to running without shell — cmd.exe can't run .ps1
946
+ useShell = false;
947
+ }
948
+ }
949
+
950
+ // When shell:true (Windows .cmd), bake args into the command string to avoid DEP0190.
951
+ const spawnCmd = useShell ? `"${execPath}" --version` : execPath;
952
+ const proc = spawn(spawnCmd, useShell ? [] : ["--version"], {
953
+ timeout: 10000,
954
+ stdio: ["ignore", "pipe", "pipe"],
955
+ shell: useShell,
956
+ });
957
+
958
+ let stdout = "";
959
+ let stderr = "";
960
+
961
+ proc.stdout?.on("data", (data) => (stdout += data));
962
+ proc.stderr?.on("data", (data) => (stderr += data));
963
+
964
+ proc.on("exit", (code) => {
965
+ if (code === 0) {
966
+ debugLog(`Verified: ${binPath} (version: ${stdout.trim()})`);
967
+ resolve(true);
968
+ } else {
969
+ logSessionStart(
970
+ `auto-install verify: failed for ${binPath} (exit=${code})`,
971
+ );
972
+ resolve(false);
973
+ }
974
+ });
975
+
976
+ proc.on("error", (err) => {
977
+ logSessionStart(
978
+ `auto-install verify: error for ${binPath}: ${err.message}`,
979
+ );
980
+ resolve(false);
981
+ });
982
+ });
983
+ }
984
+
985
+ export type ToolSource =
986
+ | "global-path"
987
+ | "npm-global"
988
+ | "pip-user"
989
+ | "pi-lens-auto"
990
+ | "github-release"
991
+ | "npx-fallback"
992
+ | "not-installed";
993
+
994
+ export interface ToolStatus {
995
+ id: string;
996
+ name: string;
997
+ installed: boolean;
998
+ source: ToolSource;
999
+ path?: string;
1000
+ version?: string;
1001
+ strategy: "npm" | "pip" | "gem" | "github";
1002
+ }
1003
+
1004
+ /**
1005
+ * Get detailed status for all tools
1006
+ */
1007
+ export async function getAllToolStatuses(): Promise<ToolStatus[]> {
1008
+ const statuses: ToolStatus[] = [];
1009
+
1010
+ for (const tool of TOOLS) {
1011
+ const status: ToolStatus = {
1012
+ id: tool.id,
1013
+ name: tool.name,
1014
+ installed: false,
1015
+ source: "not-installed",
1016
+ strategy: tool.installStrategy,
1017
+ };
1018
+
1019
+ // 1. Check if in PATH (global)
1020
+ if (await isCommandAvailable(tool.checkCommand, tool.checkArgs)) {
1021
+ status.installed = true;
1022
+ status.source = "global-path";
1023
+ status.path = tool.checkCommand;
1024
+ // Try to get version
1025
+ const versionResult = await new Promise<string>((resolve) => {
1026
+ const proc = spawn(tool.checkCommand, ["--version"], {
1027
+ stdio: ["ignore", "pipe", "pipe"],
1028
+ shell: process.platform === "win32",
1029
+ timeout: 5000,
1030
+ });
1031
+ let out = "";
1032
+ proc.stdout?.on("data", (d) => (out += d));
1033
+ proc.stderr?.on("data", (d) => (out += d));
1034
+ proc.on("exit", () =>
1035
+ resolve(out.trim().split("\n")[0]?.slice(0, 30) || ""),
1036
+ );
1037
+ proc.on("error", () => resolve(""));
1038
+ });
1039
+ status.version = versionResult || undefined;
1040
+ statuses.push(status);
1041
+ continue;
1042
+ }
1043
+
1044
+ // 2. Check npm global
1045
+ if (tool.installStrategy === "npm") {
1046
+ const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id);
1047
+ if (npmPath) {
1048
+ status.installed = true;
1049
+ status.source = "npm-global";
1050
+ status.path = npmPath;
1051
+ statuses.push(status);
1052
+ continue;
1053
+ }
1054
+ }
1055
+
1056
+ // 3. Check pip user install
1057
+ if (tool.installStrategy === "pip") {
1058
+ const pipPath = await findPipUserToolPath(tool.binaryName || tool.id);
1059
+ if (pipPath) {
1060
+ status.installed = true;
1061
+ status.source = "pip-user";
1062
+ status.path = pipPath;
1063
+ statuses.push(status);
1064
+ continue;
1065
+ }
1066
+ }
1067
+
1068
+ // 4. Check GitHub releases (~/.pi-lens/bin/)
1069
+ if (tool.installStrategy === "github") {
1070
+ const githubPath = await findGitHubToolPath(tool.binaryName || tool.id);
1071
+ if (githubPath) {
1072
+ status.installed = true;
1073
+ status.source = "github-release";
1074
+ status.path = githubPath;
1075
+ statuses.push(status);
1076
+ continue;
1077
+ }
1078
+ }
1079
+
1080
+ // 5. Check pi-lens auto-install (~/.pi-lens/tools/)
1081
+ const localBase = path.join(
1082
+ TOOLS_DIR,
1083
+ "node_modules",
1084
+ ".bin",
1085
+ tool.binaryName || tool.id,
1086
+ );
1087
+ const localPath =
1088
+ process.platform === "win32" ? `${localBase}.cmd` : localBase;
1089
+ try {
1090
+ await fs.access(localPath);
1091
+ if (await verifyToolBinary(localPath)) {
1092
+ status.installed = true;
1093
+ status.source = "pi-lens-auto";
1094
+ status.path = localPath;
1095
+ statuses.push(status);
1096
+ continue;
1097
+ }
1098
+ } catch {
1099
+ // fall through to not-installed
1100
+ }
1101
+
1102
+ // 6. Not installed - will use npx fallback if npm strategy
1103
+ if (tool.installStrategy === "npm") {
1104
+ status.source = "npx-fallback";
1105
+ }
1106
+
1107
+ statuses.push(status);
1108
+ }
1109
+
1110
+ return statuses;
1111
+ }
1112
+
1113
+ /**
1114
+ * Check if a tool is installed (globally or locally)
1115
+ */
1116
+ export async function isToolInstalled(toolId: string): Promise<boolean> {
1117
+ return (await getToolPath(toolId)) !== undefined;
1118
+ }
1119
+
1120
+ /**
1121
+ * Get the path to a tool (global or local)
1122
+ */
1123
+ export async function getToolPath(toolId: string): Promise<string | undefined> {
1124
+ const tool = TOOLS.find((t) => t.id === toolId);
1125
+ if (!tool) return undefined;
1126
+
1127
+ // Fast path: check local npm install first (where auto-install places tools).
1128
+ // This avoids the ~2-5s overhead of spawning npm global probes and PATH
1129
+ // searches for tools we already manage locally.
1130
+ const localBase = path.join(
1131
+ TOOLS_DIR,
1132
+ "node_modules",
1133
+ ".bin",
1134
+ tool.binaryName || tool.id,
1135
+ );
1136
+ if (process.platform === "win32") {
1137
+ // Prefer .cmd over extensionless — Node.js can't execute POSIX shell scripts on Windows
1138
+ const cmdPath = `${localBase}.cmd`;
1139
+ try {
1140
+ await fs.access(cmdPath);
1141
+ if (await verifyToolBinary(cmdPath)) {
1142
+ return cmdPath;
1143
+ }
1144
+ logSessionStart(
1145
+ `auto-install verify: ${cmdPath} exists but is broken, will reinstall`,
1146
+ );
1147
+ } catch {
1148
+ // fall through to .exe
1149
+ }
1150
+ // Also check .exe — some postinstall scripts (e.g. @ast-grep/cli) place a
1151
+ // .exe directly without a .cmd wrapper
1152
+ const exePath = `${localBase}.exe`;
1153
+ try {
1154
+ await fs.access(exePath);
1155
+ if (await verifyToolBinary(exePath)) {
1156
+ return exePath;
1157
+ }
1158
+ logSessionStart(
1159
+ `auto-install verify: ${exePath} exists but is broken, will reinstall`,
1160
+ );
1161
+ } catch {
1162
+ // fall through to extensionless
1163
+ }
1164
+ }
1165
+ try {
1166
+ await fs.access(localBase);
1167
+ if (await verifyToolBinary(localBase)) {
1168
+ return localBase;
1169
+ }
1170
+ logSessionStart(
1171
+ `auto-install verify: ${localBase} exists but is broken, will reinstall`,
1172
+ );
1173
+ } catch {
1174
+ // fall through to global checks
1175
+ }
1176
+
1177
+ // For github-strategy tools, prefer managed install (~/.pi-lens/bin/) over PATH.
1178
+ // Managed installs are known-good binaries that pi-lens downloaded as a fallback
1179
+ // when a PATH-resolved tool was broken or missing. Checking before PATH ensures
1180
+ // force-reinstall flows find the newly downloaded binary.
1181
+ if (tool.installStrategy === "github") {
1182
+ const githubPath = await findGitHubToolPath(tool.binaryName || tool.id);
1183
+ if (githubPath) return githubPath;
1184
+ }
1185
+
1186
+ // Check if global
1187
+ if (await isCommandAvailable(tool.checkCommand, tool.checkArgs)) {
1188
+ return tool.checkCommand;
1189
+ }
1190
+
1191
+ if (tool.installStrategy === "npm") {
1192
+ const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id);
1193
+ if (npmPath) {
1194
+ return npmPath;
1195
+ }
1196
+ }
1197
+
1198
+ // For pip tools, also probe user-level script locations
1199
+ if (tool.installStrategy === "pip") {
1200
+ const pipPath = await findPipUserToolPath(tool.binaryName || tool.id);
1201
+ if (pipPath) {
1202
+ return pipPath;
1203
+ }
1204
+ }
1205
+
1206
+ return undefined;
1207
+ }
1208
+
1209
+ async function findGitHubToolPath(
1210
+ binaryName: string,
1211
+ ): Promise<string | undefined> {
1212
+ const isWindows = process.platform === "win32";
1213
+ const candidates = isWindows
1214
+ ? [
1215
+ path.join(GITHUB_BIN_DIR, `${binaryName}.exe`),
1216
+ path.join(GITHUB_BIN_DIR, `${binaryName}.bat`),
1217
+ path.join(GITHUB_BIN_DIR, `${binaryName}.cmd`),
1218
+ path.join(GITHUB_BIN_DIR, binaryName),
1219
+ ]
1220
+ : [path.join(GITHUB_BIN_DIR, binaryName)];
1221
+
1222
+ for (const candidate of candidates) {
1223
+ try {
1224
+ await fs.access(candidate);
1225
+ return candidate;
1226
+ } catch {
1227
+ // continue
1228
+ }
1229
+ }
1230
+ return undefined;
1231
+ }
1232
+
1233
+ function hasExecutableExtension(name: string): boolean {
1234
+ return /\.(exe|bat|cmd|ps1)$/i.test(name);
1235
+ }
1236
+
1237
+ function getGitHubInstalledBinaryName(
1238
+ binaryName: string,
1239
+ platform: string,
1240
+ assetName: string,
1241
+ ): string {
1242
+ if (platform !== "win32") return binaryName;
1243
+ if (hasExecutableExtension(binaryName)) return binaryName;
1244
+ if (assetName.endsWith(".bat")) return `${binaryName}.bat`;
1245
+ if (assetName.endsWith(".cmd")) return `${binaryName}.cmd`;
1246
+ return `${binaryName}.exe`;
1247
+ }
1248
+
1249
+ function getArchiveBinaryCandidates(
1250
+ binaryName: string,
1251
+ platform: string,
1252
+ assetName: string,
1253
+ ): string[] {
1254
+ if (platform !== "win32") return [binaryName];
1255
+ if (hasExecutableExtension(binaryName)) return [binaryName];
1256
+ const candidates = new Set<string>();
1257
+ if (assetName.endsWith(".bat")) candidates.add(`${binaryName}.bat`);
1258
+ if (assetName.endsWith(".cmd")) candidates.add(`${binaryName}.cmd`);
1259
+ candidates.add(`${binaryName}.exe`);
1260
+ candidates.add(binaryName);
1261
+ candidates.add(`${binaryName}.bat`);
1262
+ candidates.add(`${binaryName}.cmd`);
1263
+ return [...candidates];
1264
+ }
1265
+
1266
+ async function findNpmGlobalToolPath(
1267
+ binaryName: string,
1268
+ ): Promise<string | undefined> {
1269
+ const isWindows = process.platform === "win32";
1270
+ const binDirs = await getNpmGlobalBinCandidates();
1271
+
1272
+ for (const dir of binDirs) {
1273
+ const candidates = isWindows
1274
+ ? [
1275
+ path.join(dir, `${binaryName}.cmd`),
1276
+ path.join(dir, `${binaryName}.exe`),
1277
+ path.join(dir, binaryName),
1278
+ ]
1279
+ : [path.join(dir, binaryName)];
1280
+
1281
+ for (const candidate of candidates) {
1282
+ try {
1283
+ await fs.access(candidate);
1284
+ if (await verifyToolBinary(candidate)) {
1285
+ return candidate;
1286
+ }
1287
+ } catch {
1288
+ // continue
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ return undefined;
1294
+ }
1295
+
1296
+ async function getNpmGlobalBinCandidates(): Promise<string[]> {
1297
+ const dirs: string[] = [];
1298
+ const seen = new Set<string>();
1299
+
1300
+ const add = (value: string | undefined): void => {
1301
+ if (!value) return;
1302
+ const normalized = path.resolve(value.trim());
1303
+ if (!normalized) return;
1304
+ if (seen.has(normalized)) return;
1305
+ seen.add(normalized);
1306
+ dirs.push(normalized);
1307
+ };
1308
+
1309
+ if (process.platform === "win32") {
1310
+ add(path.join(process.env.APPDATA || "", "npm"));
1311
+ } else {
1312
+ add(path.join(os.homedir(), ".npm-global", "bin"));
1313
+ }
1314
+
1315
+ const pm = process.platform === "win32" ? "npm.cmd" : "npm";
1316
+ const prefix = await new Promise<string>((resolve) => {
1317
+ const proc = spawn(pm, ["config", "get", "prefix"], {
1318
+ stdio: ["ignore", "pipe", "pipe"],
1319
+ shell: process.platform === "win32",
1320
+ });
1321
+
1322
+ let stdout = "";
1323
+ proc.stdout?.on("data", (data: Buffer | string) => (stdout += data));
1324
+ proc.on("exit", (code) => resolve(code === 0 ? stdout.trim() : ""));
1325
+ proc.on("error", () => resolve(""));
1326
+ });
1327
+
1328
+ if (prefix) {
1329
+ add(process.platform === "win32" ? prefix : path.join(prefix, "bin"));
1330
+ }
1331
+
1332
+ return dirs;
1333
+ }
1334
+
1335
+ async function findPipUserToolPath(
1336
+ binaryName: string,
1337
+ ): Promise<string | undefined> {
1338
+ const isWindows = process.platform === "win32";
1339
+ const userBaseCandidates = await getPythonUserBaseCandidates();
1340
+
1341
+ for (const userBase of userBaseCandidates) {
1342
+ const scriptDirs: string[] = [
1343
+ path.join(userBase, isWindows ? "Scripts" : "bin"),
1344
+ ];
1345
+
1346
+ if (isWindows) {
1347
+ try {
1348
+ const children = await fs.readdir(userBase, { withFileTypes: true });
1349
+ for (const entry of children) {
1350
+ if (!entry.isDirectory()) continue;
1351
+ if (!/^python\d+$/i.test(entry.name)) continue;
1352
+ scriptDirs.push(path.join(userBase, entry.name, "Scripts"));
1353
+ }
1354
+ } catch {
1355
+ // ignore
1356
+ }
1357
+ }
1358
+
1359
+ for (const dir of scriptDirs) {
1360
+ const candidates = isWindows
1361
+ ? [
1362
+ path.join(dir, `${binaryName}.exe`),
1363
+ path.join(dir, `${binaryName}.cmd`),
1364
+ path.join(dir, binaryName),
1365
+ ]
1366
+ : [path.join(dir, binaryName)];
1367
+
1368
+ for (const candidate of candidates) {
1369
+ try {
1370
+ await fs.access(candidate);
1371
+ if (await verifyToolBinary(candidate)) {
1372
+ return candidate;
1373
+ }
1374
+ } catch {
1375
+ // continue
1376
+ }
1377
+ }
1378
+ }
1379
+ }
1380
+
1381
+ return undefined;
1382
+ }
1383
+
1384
+ async function getPythonUserBaseCandidates(): Promise<string[]> {
1385
+ const candidates: string[] = [];
1386
+ const seen = new Set<string>();
1387
+
1388
+ const add = (value: string | undefined): void => {
1389
+ if (!value) return;
1390
+ const normalized = value.trim();
1391
+ if (!normalized) return;
1392
+ if (seen.has(normalized)) return;
1393
+ seen.add(normalized);
1394
+ candidates.push(normalized);
1395
+ };
1396
+
1397
+ if (process.platform === "win32") {
1398
+ add(path.join(process.env.APPDATA || "", "Python"));
1399
+ }
1400
+
1401
+ const probes: Array<{ command: string; args: string[] }> =
1402
+ process.platform === "win32"
1403
+ ? [
1404
+ { command: "py", args: ["-m", "site", "--user-base"] },
1405
+ { command: "python", args: ["-m", "site", "--user-base"] },
1406
+ ]
1407
+ : [
1408
+ { command: "python3", args: ["-m", "site", "--user-base"] },
1409
+ { command: "python", args: ["-m", "site", "--user-base"] },
1410
+ ];
1411
+
1412
+ for (const probe of probes) {
1413
+ const userBase = await new Promise<string>((resolve) => {
1414
+ const isWin = process.platform === "win32";
1415
+ // Bake args into command string when shell:true on Windows to avoid DEP0190.
1416
+ const spawnCmd = isWin
1417
+ ? [probe.command, ...probe.args].join(" ")
1418
+ : probe.command;
1419
+ const proc = spawn(spawnCmd, isWin ? [] : probe.args, {
1420
+ stdio: ["ignore", "pipe", "pipe"],
1421
+ shell: isWin,
1422
+ });
1423
+
1424
+ let stdout = "";
1425
+ proc.stdout?.on("data", (data: Buffer | string) => (stdout += data));
1426
+ proc.on("exit", (code) => resolve(code === 0 ? stdout.trim() : ""));
1427
+ proc.on("error", () => resolve(""));
1428
+ });
1429
+ add(userBase);
1430
+ }
1431
+
1432
+ return candidates;
1433
+ }
1434
+
1435
+ // --- Installation Functions
1436
+
1437
+ /**
1438
+ * Fetch a URL, following up to `maxRedirects` redirects.
1439
+ * Returns the raw Buffer of the response body.
1440
+ */
1441
+ function httpsGet(url: string, maxRedirects = 5): Promise<Buffer> {
1442
+ return new Promise((resolve, reject) => {
1443
+ https
1444
+ .get(url, { headers: { "User-Agent": "pi-lens/1.0" } }, (res) => {
1445
+ if (
1446
+ res.statusCode &&
1447
+ res.statusCode >= 300 &&
1448
+ res.statusCode < 400 &&
1449
+ res.headers.location
1450
+ ) {
1451
+ if (maxRedirects === 0)
1452
+ return reject(new Error("Too many redirects"));
1453
+ return resolve(httpsGet(res.headers.location, maxRedirects - 1));
1454
+ }
1455
+ if (res.statusCode !== 200) {
1456
+ res.resume();
1457
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
1458
+ }
1459
+ const chunks: Buffer[] = [];
1460
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
1461
+ res.on("end", () => resolve(Buffer.concat(chunks)));
1462
+ res.on("error", reject);
1463
+ })
1464
+ .on("error", reject);
1465
+ });
1466
+ }
1467
+
1468
+ /**
1469
+ * Run a shell command and return true on exit code 0.
1470
+ */
1471
+ function runCommand(
1472
+ command: string,
1473
+ args: string[],
1474
+ cwd: string,
1475
+ ): Promise<boolean> {
1476
+ return new Promise((resolve) => {
1477
+ const proc = spawn(command, args, {
1478
+ cwd,
1479
+ stdio: "ignore",
1480
+ shell: process.platform === "win32",
1481
+ });
1482
+ proc.on("exit", (code) => resolve(code === 0));
1483
+ proc.on("error", () => resolve(false));
1484
+ });
1485
+ }
1486
+
1487
+ /**
1488
+ * Download and install a tool from a GitHub release.
1489
+ * Returns the path to the installed binary, or undefined on failure.
1490
+ */
1491
+ async function installGitHubTool(
1492
+ tool: ToolDefinition,
1493
+ ): Promise<string | undefined> {
1494
+ const spec = tool.github;
1495
+ if (!spec) return undefined;
1496
+
1497
+ const platform = process.platform; // "linux" | "darwin" | "win32"
1498
+ const arch = process.arch; // "x64" | "arm64" | ...
1499
+ const assetSubstring = spec.assetMatch(platform, arch);
1500
+ if (!assetSubstring) {
1501
+ logSessionStart(
1502
+ `github-install ${tool.id}: unsupported platform=${platform} arch=${arch}`,
1503
+ );
1504
+ return undefined;
1505
+ }
1506
+
1507
+ // Fetch latest release metadata from GitHub API
1508
+ logSessionStart(
1509
+ `github-install ${tool.id}: fetching release metadata from ${spec.repo}`,
1510
+ );
1511
+ let releaseJson: {
1512
+ tag_name?: string;
1513
+ assets: Array<{ name: string; browser_download_url: string }>;
1514
+ };
1515
+ try {
1516
+ const body = await httpsGet(
1517
+ `https://api.github.com/repos/${spec.repo}/releases/latest`,
1518
+ );
1519
+ releaseJson = JSON.parse(body.toString("utf8"));
1520
+ } catch (err) {
1521
+ logSessionStart(
1522
+ `github-install ${tool.id}: release fetch failed: ${(err as Error).message}`,
1523
+ );
1524
+ return undefined;
1525
+ }
1526
+
1527
+ const asset =
1528
+ releaseJson.assets.find((a) => a.name.includes(assetSubstring)) ??
1529
+ deriveHashiCorpReleaseAsset(tool, releaseJson.tag_name, assetSubstring);
1530
+ if (!asset) {
1531
+ logSessionStart(
1532
+ `github-install ${tool.id}: no asset matched "${assetSubstring}"`,
1533
+ );
1534
+ return undefined;
1535
+ }
1536
+
1537
+ logSessionStart(`github-install ${tool.id}: downloading ${asset.name}`);
1538
+ debugLog(
1539
+ `[github] downloading ${asset.name} from ${asset.browser_download_url}`,
1540
+ );
1541
+
1542
+ // Download the asset
1543
+ const downloadStart = Date.now();
1544
+ let assetBuffer: Buffer;
1545
+ try {
1546
+ assetBuffer = await httpsGet(asset.browser_download_url);
1547
+ logSessionStart(
1548
+ `github-install ${tool.id}: downloaded ${asset.name} (${assetBuffer.length} bytes, ${Date.now() - downloadStart}ms)`,
1549
+ );
1550
+ } catch (err) {
1551
+ logSessionStart(
1552
+ `github-install ${tool.id}: download failed: ${(err as Error).message}`,
1553
+ );
1554
+ return undefined;
1555
+ }
1556
+
1557
+ await fs.mkdir(GITHUB_BIN_DIR, { recursive: true });
1558
+
1559
+ const binaryName = tool.binaryName ?? tool.id;
1560
+ const isWindows = platform === "win32";
1561
+ const finalBinaryName = getGitHubInstalledBinaryName(
1562
+ binaryName,
1563
+ platform,
1564
+ asset.name,
1565
+ );
1566
+ const destPath = path.join(GITHUB_BIN_DIR, finalBinaryName);
1567
+
1568
+ const assetName = asset.name;
1569
+
1570
+ try {
1571
+ if (assetName.endsWith(".gz") && !assetName.endsWith(".tar.gz")) {
1572
+ // Bare gzip (e.g. rust-analyzer-x86_64-unknown-linux-gnu.gz) — decompress directly
1573
+ const decompressed = await new Promise<Buffer>((resolve, reject) => {
1574
+ const gunzip = createGunzip();
1575
+ const chunks: Buffer[] = [];
1576
+ gunzip.on("data", (chunk: Buffer) => chunks.push(chunk));
1577
+ gunzip.on("end", () => resolve(Buffer.concat(chunks)));
1578
+ gunzip.on("error", reject);
1579
+ gunzip.end(assetBuffer);
1580
+ });
1581
+ await fs.writeFile(destPath, decompressed, { mode: 0o755 });
1582
+ } else if (assetName.endsWith(".tar.gz") || assetName.endsWith(".tar.xz")) {
1583
+ // Write archive to temp file, extract with system tar
1584
+ const tmpArchive = path.join(GITHUB_BIN_DIR, `_tmp_${assetName}`);
1585
+ await fs.writeFile(tmpArchive, assetBuffer);
1586
+ const tmpDir = path.join(GITHUB_BIN_DIR, `_tmp_extract_${tool.id}`);
1587
+ await fs.mkdir(tmpDir, { recursive: true });
1588
+
1589
+ const extracted = await runCommand(
1590
+ "tar",
1591
+ ["xf", tmpArchive, "-C", tmpDir, "--strip-components=1"],
1592
+ GITHUB_BIN_DIR,
1593
+ );
1594
+ await fs.rm(tmpArchive, { force: true });
1595
+
1596
+ if (!extracted) {
1597
+ await fs.rm(tmpDir, { recursive: true, force: true });
1598
+ logSessionStart(
1599
+ `github-install ${tool.id}: tar extraction failed for ${assetName}`,
1600
+ );
1601
+ return undefined;
1602
+ }
1603
+
1604
+ // Find the binary inside extracted dir
1605
+ const srcBinary = path.join(tmpDir, spec.binaryInArchive ?? binaryName);
1606
+ await fs.rename(srcBinary, destPath);
1607
+ await fs.rm(tmpDir, { recursive: true, force: true });
1608
+ if (!isWindows) await fs.chmod(destPath, 0o750);
1609
+ } else if (assetName.endsWith(".zip")) {
1610
+ // Write zip to temp, extract with unzip (Linux/macOS) or Expand-Archive (Windows)
1611
+ const tmpArchive = path.join(GITHUB_BIN_DIR, `_tmp_${assetName}`);
1612
+ await fs.writeFile(tmpArchive, assetBuffer);
1613
+ const tmpDir = path.join(GITHUB_BIN_DIR, `_tmp_extract_${tool.id}`);
1614
+ await fs.mkdir(tmpDir, { recursive: true });
1615
+
1616
+ const extracted = isWindows
1617
+ ? await runCommand(
1618
+ "powershell",
1619
+ [
1620
+ "-NoProfile",
1621
+ "-Command",
1622
+ `Expand-Archive -LiteralPath '${tmpArchive}' -DestinationPath '${tmpDir}' -Force`,
1623
+ ],
1624
+ GITHUB_BIN_DIR,
1625
+ )
1626
+ : await runCommand(
1627
+ "unzip",
1628
+ ["-q", "-o", tmpArchive, "-d", tmpDir],
1629
+ GITHUB_BIN_DIR,
1630
+ );
1631
+
1632
+ await fs.rm(tmpArchive, { force: true });
1633
+
1634
+ if (!extracted) {
1635
+ await fs.rm(tmpDir, { recursive: true, force: true });
1636
+ logSessionStart(
1637
+ `github-install ${tool.id}: zip extraction failed for ${assetName}`,
1638
+ );
1639
+ return undefined;
1640
+ }
1641
+
1642
+ // Find binary — may be at root or inside a subdir
1643
+ const archiveBinaryName = spec.binaryInArchive ?? binaryName;
1644
+ const srcBinary = await findFirstFileRecursive(
1645
+ tmpDir,
1646
+ getArchiveBinaryCandidates(archiveBinaryName, platform, assetName),
1647
+ );
1648
+ if (!srcBinary) {
1649
+ await fs.rm(tmpDir, { recursive: true, force: true });
1650
+ logSessionStart(
1651
+ `github-install ${tool.id}: binary candidates ${JSON.stringify(
1652
+ getArchiveBinaryCandidates(archiveBinaryName, platform, assetName),
1653
+ )} not found in zip ${assetName}`,
1654
+ );
1655
+ return undefined;
1656
+ }
1657
+ await fs.rename(srcBinary, destPath);
1658
+ await fs.rm(tmpDir, { recursive: true, force: true });
1659
+ if (!isWindows) await fs.chmod(destPath, 0o750);
1660
+ } else {
1661
+ // Bare binary (e.g. shfmt_*_linux_amd64)
1662
+ await fs.writeFile(destPath, assetBuffer, { mode: 0o755 });
1663
+ }
1664
+ } catch (err) {
1665
+ logSessionStart(
1666
+ `github-install ${tool.id}: install failed: ${(err as Error).message}`,
1667
+ );
1668
+ return undefined;
1669
+ }
1670
+
1671
+ debugLog(`[github] installed ${tool.name} → ${destPath}`);
1672
+ logSessionStart(`github-install ${tool.id}: installed → ${destPath}`);
1673
+ return destPath;
1674
+ }
1675
+
1676
+ /** Recursively find the first matching file under a directory. */
1677
+ async function findFirstFileRecursive(
1678
+ dir: string,
1679
+ names: string[],
1680
+ ): Promise<string | undefined> {
1681
+ const wanted = new Set(names.map((name) => name.toLowerCase()));
1682
+ const entries = await fs.readdir(dir, { withFileTypes: true });
1683
+ for (const entry of entries) {
1684
+ const full = path.join(dir, entry.name);
1685
+ if (entry.isDirectory()) {
1686
+ const found = await findFirstFileRecursive(full, names);
1687
+ if (found) return found;
1688
+ } else if (wanted.has(entry.name.toLowerCase())) {
1689
+ return full;
1690
+ }
1691
+ }
1692
+ return undefined;
1693
+ }
1694
+
1695
+ /**
1696
+ * Install an npm package tool
1697
+ */
1698
+ /**
1699
+ * Packages that require postinstall scripts to download native binaries.
1700
+ * All others get --ignore-scripts to prevent arbitrary code execution during install.
1701
+ */
1702
+ const NEEDS_POSTINSTALL = new Set([
1703
+ "@biomejs/biome",
1704
+ "@ast-grep/cli", // postinstall copies platform binary (ast-grep.exe/sg.exe) into place
1705
+ "@ast-grep/napi",
1706
+ "esbuild",
1707
+ "intelephense", // postinstall fetches platform binary; --ignore-scripts breaks install
1708
+ ]);
1709
+
1710
+ async function installNpmTool(
1711
+ packageName: string,
1712
+ binaryName: string,
1713
+ ): Promise<string | undefined> {
1714
+ try {
1715
+ // Ensure tools directory exists
1716
+ await fs.mkdir(TOOLS_DIR, { recursive: true });
1717
+
1718
+ // Create a minimal package.json if it doesn't exist
1719
+ const packageJsonPath = path.join(TOOLS_DIR, "package.json");
1720
+ try {
1721
+ await fs.access(packageJsonPath);
1722
+ } catch {
1723
+ await fs.writeFile(
1724
+ packageJsonPath,
1725
+ JSON.stringify({ name: "pi-lens-tools", version: "1.0.0" }, null, 2),
1726
+ );
1727
+ }
1728
+
1729
+ // Install via npm or bun (use .cmd on Windows)
1730
+ const isWindows = process.platform === "win32";
1731
+ let pm = isWindows ? "npm.cmd" : "npm";
1732
+ if (process.env.BUN_INSTALL) {
1733
+ pm = isWindows ? "bun.exe" : "bun";
1734
+ }
1735
+ // Use --ignore-scripts unless the package explicitly needs postinstall
1736
+ // (e.g. biome downloads a platform-specific native binary via postinstall).
1737
+ const needsScripts = NEEDS_POSTINSTALL.has(packageName);
1738
+ const baseInstallArgs = needsScripts
1739
+ ? ["install", packageName]
1740
+ : ["install", "--ignore-scripts", packageName];
1741
+
1742
+ const INSTALL_TIMEOUT_MS = 120_000;
1743
+ const runInstallAttempt = async (
1744
+ args: string[],
1745
+ ): Promise<{ ok: boolean; stderr: string }> =>
1746
+ new Promise((resolve) => {
1747
+ const proc = spawn(pm, args, {
1748
+ cwd: TOOLS_DIR,
1749
+ stdio: ["ignore", "pipe", "pipe"],
1750
+ shell: isWindows, // Required for .cmd files on Windows
1751
+ });
1752
+
1753
+ let stderr = "";
1754
+ proc.stderr?.on("data", (data) => (stderr += data));
1755
+
1756
+ const timer = setTimeout(() => {
1757
+ proc.kill();
1758
+ resolve({
1759
+ ok: false,
1760
+ stderr: `install timed out after ${INSTALL_TIMEOUT_MS / 1000}s`,
1761
+ });
1762
+ }, INSTALL_TIMEOUT_MS);
1763
+
1764
+ proc.on("exit", (code) => {
1765
+ clearTimeout(timer);
1766
+ resolve({ ok: code === 0, stderr });
1767
+ });
1768
+ proc.on("error", (err) => {
1769
+ clearTimeout(timer);
1770
+ resolve({ ok: false, stderr: err.message });
1771
+ });
1772
+ });
1773
+
1774
+ let outcome = await runInstallAttempt(baseInstallArgs);
1775
+
1776
+ const isNpm = pm === "npm" || pm === "npm.cmd";
1777
+ const erResolve =
1778
+ outcome.ok === false &&
1779
+ /npm\s+error\s+ERESOLVE|\bERESOLVE\b|could not resolve/i.test(
1780
+ outcome.stderr,
1781
+ );
1782
+
1783
+ if (isNpm && erResolve) {
1784
+ const retryArgs = needsScripts
1785
+ ? ["install", "--legacy-peer-deps", packageName]
1786
+ : ["install", "--ignore-scripts", "--legacy-peer-deps", packageName];
1787
+ logSessionStart(
1788
+ `auto-install npm ${packageName}: retry with --legacy-peer-deps after ERESOLVE`,
1789
+ );
1790
+ outcome = await runInstallAttempt(retryArgs);
1791
+ }
1792
+
1793
+ if (!outcome.ok) {
1794
+ throw new Error(`Failed to install ${packageName}: ${outcome.stderr}`);
1795
+ }
1796
+
1797
+ const binPath = path.join(TOOLS_DIR, "node_modules", ".bin", binaryName);
1798
+
1799
+ // Make executable on Unix
1800
+ if (process.platform !== "win32") {
1801
+ try {
1802
+ await fs.chmod(binPath, 0o750);
1803
+ } catch {
1804
+ /* ignore */
1805
+ }
1806
+ }
1807
+
1808
+ // Brief delay — lets npm postinstall scripts finish writing bin wrappers
1809
+ // before we stat/exec them (eliminates a race on slow Windows I/O).
1810
+ await new Promise((r) => setTimeout(r, 500));
1811
+
1812
+ // Verify the binary actually works, retrying with backoff to handle
1813
+ // postinstall scripts that complete asynchronously after npm exits 0.
1814
+ debugLog(`Verifying ${binaryName}...`);
1815
+ let isValid = false;
1816
+ for (let attempt = 1; attempt <= 3; attempt++) {
1817
+ isValid = await verifyToolBinary(binPath);
1818
+ if (isValid) break;
1819
+ if (attempt < 3) {
1820
+ logSessionStart(
1821
+ `auto-install verify ${binaryName}: attempt ${attempt} failed, retrying in ${attempt}s`,
1822
+ );
1823
+ await new Promise((r) => setTimeout(r, 1000 * attempt));
1824
+ }
1825
+ }
1826
+ if (!isValid) {
1827
+ logSessionStart(
1828
+ `auto-install ${packageName}: installed but verification failed, cleaning up`,
1829
+ );
1830
+ // Clean up the broken installation
1831
+ try {
1832
+ const packagePath = path.join(TOOLS_DIR, "node_modules", packageName);
1833
+ await fs.rm(packagePath, { recursive: true, force: true });
1834
+ await fs.rm(binPath, { force: true });
1835
+ if (isWindows) {
1836
+ await fs.rm(`${binPath}.cmd`, { force: true });
1837
+ await fs.rm(`${binPath}.ps1`, { force: true });
1838
+ }
1839
+ } catch {
1840
+ /* ignore cleanup errors */
1841
+ }
1842
+ return undefined;
1843
+ }
1844
+
1845
+ return binPath;
1846
+ } catch (err) {
1847
+ logSessionStart(
1848
+ `auto-install npm ${packageName}: exception: ${(err as Error).message}`,
1849
+ );
1850
+ return undefined;
1851
+ }
1852
+ }
1853
+ /**
1854
+ * Install a pip package tool
1855
+ */
1856
+ async function installPipTool(
1857
+ packageName: string,
1858
+ ): Promise<string | undefined> {
1859
+ try {
1860
+ const isWindows = process.platform === "win32";
1861
+ const pipCandidates = isWindows
1862
+ ? [
1863
+ { command: "pip", args: ["install", "--user", packageName] },
1864
+ {
1865
+ command: "py",
1866
+ args: ["-m", "pip", "install", "--user", packageName],
1867
+ },
1868
+ {
1869
+ command: "python",
1870
+ args: ["-m", "pip", "install", "--user", packageName],
1871
+ },
1872
+ ]
1873
+ : [
1874
+ { command: "pip3", args: ["install", "--user", packageName] },
1875
+ { command: "pip", args: ["install", "--user", packageName] },
1876
+ {
1877
+ command: "python3",
1878
+ args: ["-m", "pip", "install", "--user", packageName],
1879
+ },
1880
+ {
1881
+ command: "python",
1882
+ args: ["-m", "pip", "install", "--user", packageName],
1883
+ },
1884
+ ];
1885
+
1886
+ let lastError = "";
1887
+ for (const candidate of pipCandidates) {
1888
+ const outcome = await new Promise<{ ok: boolean; error: string }>(
1889
+ (resolve) => {
1890
+ const proc = spawn(candidate.command, candidate.args, {
1891
+ stdio: ["ignore", "pipe", "pipe"],
1892
+ shell: isWindows, // Required for .cmd files on Windows
1893
+ });
1894
+
1895
+ let stderr = "";
1896
+ proc.stderr?.on("data", (data) => (stderr += data));
1897
+
1898
+ proc.on("exit", (code) => {
1899
+ if (code === 0) {
1900
+ resolve({ ok: true, error: "" });
1901
+ } else {
1902
+ resolve({ ok: false, error: stderr.trim() });
1903
+ }
1904
+ });
1905
+
1906
+ proc.on("error", (err) => {
1907
+ resolve({ ok: false, error: err.message });
1908
+ });
1909
+ },
1910
+ );
1911
+
1912
+ if (outcome.ok) {
1913
+ // Ensure user-level scripts directory is available in current process PATH.
1914
+ // This helps tools installed via `pip install --user` become immediately callable.
1915
+ const userBaseResult = await new Promise<string>((resolve) => {
1916
+ const probe = spawn(
1917
+ candidate.command,
1918
+ ["-m", "site", "--user-base"],
1919
+ {
1920
+ stdio: ["ignore", "pipe", "pipe"],
1921
+ shell: isWindows,
1922
+ },
1923
+ );
1924
+ let stdout = "";
1925
+ probe.stdout?.on("data", (data) => (stdout += data));
1926
+ probe.on("exit", (code) => {
1927
+ if (code === 0) resolve(stdout.trim());
1928
+ else resolve("");
1929
+ });
1930
+ probe.on("error", () => resolve(""));
1931
+ });
1932
+
1933
+ if (userBaseResult) {
1934
+ const candidateScriptDirs: string[] = [
1935
+ path.join(userBaseResult, isWindows ? "Scripts" : "bin"),
1936
+ ];
1937
+
1938
+ if (isWindows) {
1939
+ // Some Python setups report USER_BASE as ...\Roaming\Python,
1940
+ // while scripts live in ...\Roaming\Python\PythonXY\Scripts.
1941
+ try {
1942
+ const children = await fs.readdir(userBaseResult, {
1943
+ withFileTypes: true,
1944
+ });
1945
+ for (const entry of children) {
1946
+ if (!entry.isDirectory()) continue;
1947
+ if (!/^python\d+$/i.test(entry.name)) continue;
1948
+ candidateScriptDirs.push(
1949
+ path.join(userBaseResult, entry.name, "Scripts"),
1950
+ );
1951
+ }
1952
+ } catch {
1953
+ // ignore
1954
+ }
1955
+ }
1956
+
1957
+ const currentPath =
1958
+ process.env.PATH || process.env.Path || process.env.path || "";
1959
+ const separator = isWindows ? ";" : ":";
1960
+ const normalizedPath = currentPath
1961
+ .toLowerCase()
1962
+ .split(separator)
1963
+ .map((p) => p.trim());
1964
+
1965
+ for (const scriptsDir of candidateScriptDirs) {
1966
+ try {
1967
+ await fs.access(scriptsDir);
1968
+ if (!normalizedPath.includes(scriptsDir.toLowerCase())) {
1969
+ const existingPath =
1970
+ process.env.PATH ||
1971
+ process.env.Path ||
1972
+ process.env.path ||
1973
+ "";
1974
+ const updatedPath = `${scriptsDir}${separator}${existingPath}`;
1975
+ process.env.PATH = updatedPath;
1976
+ if (isWindows) {
1977
+ process.env.Path = updatedPath;
1978
+ }
1979
+ debugLog(`Added pip user scripts dir to PATH: ${scriptsDir}`);
1980
+ }
1981
+ } catch {
1982
+ debugLog(`pip user scripts dir not accessible: ${scriptsDir}`);
1983
+ }
1984
+ }
1985
+ }
1986
+
1987
+ return packageName;
1988
+ }
1989
+
1990
+ lastError = `${candidate.command} ${candidate.args.join(" ")}: ${outcome.error}`;
1991
+ debugLog(`[pip-fallback] ${lastError}`);
1992
+ }
1993
+
1994
+ throw new Error(
1995
+ `Failed to install ${packageName}: no usable pip command found (${lastError || "unknown error"})`,
1996
+ );
1997
+ } catch (err) {
1998
+ logSessionStart(
1999
+ `auto-install pip ${packageName}: exception: ${(err as Error).message}`,
2000
+ );
2001
+ return undefined;
2002
+ }
2003
+ }
2004
+
2005
+ async function installGemTool(
2006
+ packageName: string,
2007
+ ): Promise<string | undefined> {
2008
+ try {
2009
+ const isWindows = process.platform === "win32";
2010
+ const outcome = await new Promise<{ ok: boolean; error: string }>(
2011
+ (resolve) => {
2012
+ const proc = spawn("gem", ["install", packageName, "--no-document"], {
2013
+ stdio: ["ignore", "pipe", "pipe"],
2014
+ shell: isWindows,
2015
+ });
2016
+
2017
+ let stderr = "";
2018
+ proc.stderr?.on("data", (data) => (stderr += data));
2019
+ proc.on("exit", (code) => {
2020
+ resolve({ ok: code === 0, error: stderr.trim() });
2021
+ });
2022
+ proc.on("error", (err) => {
2023
+ resolve({ ok: false, error: err.message });
2024
+ });
2025
+ },
2026
+ );
2027
+
2028
+ if (!outcome.ok) {
2029
+ throw new Error(
2030
+ `Failed to install ${packageName} via gem: ${outcome.error}`,
2031
+ );
2032
+ }
2033
+
2034
+ return packageName;
2035
+ } catch (err) {
2036
+ logSessionStart(
2037
+ `auto-install gem ${packageName}: exception: ${(err as Error).message}`,
2038
+ );
2039
+ return undefined;
2040
+ }
2041
+ }
2042
+
2043
+ /**
2044
+ * Install a tool by ID
2045
+ */
2046
+ export async function installTool(toolId: string): Promise<boolean> {
2047
+ const tool = TOOLS.find((t) => t.id === toolId);
2048
+ if (!tool) {
2049
+ logSessionStart(`auto-install ${toolId}: unknown tool id`);
2050
+ return false;
2051
+ }
2052
+
2053
+ const startedAt = Date.now();
2054
+ logSessionStart(
2055
+ `auto-install ${tool.id}: start strategy=${tool.installStrategy} package=${tool.packageName ?? "n/a"}`,
2056
+ );
2057
+
2058
+ try {
2059
+ switch (tool.installStrategy) {
2060
+ case "npm": {
2061
+ if (!tool.packageName || !tool.binaryName) return false;
2062
+ const npmPath = await installNpmTool(tool.packageName, tool.binaryName);
2063
+ const ok = npmPath !== undefined;
2064
+ logSessionStart(
2065
+ `auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`,
2066
+ );
2067
+ return ok;
2068
+ }
2069
+
2070
+ case "pip": {
2071
+ if (!tool.packageName) return false;
2072
+ const pipPath = await installPipTool(tool.packageName);
2073
+ const ok = pipPath !== undefined;
2074
+ logSessionStart(
2075
+ `auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`,
2076
+ );
2077
+ return ok;
2078
+ }
2079
+
2080
+ case "gem": {
2081
+ if (!tool.packageName) return false;
2082
+ const gemPath = await installGemTool(tool.packageName);
2083
+ const ok = gemPath !== undefined;
2084
+ logSessionStart(
2085
+ `auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`,
2086
+ );
2087
+ return ok;
2088
+ }
2089
+
2090
+ case "github": {
2091
+ if (!tool.github) return false;
2092
+ const ghPath = await installGitHubTool(tool);
2093
+ const ok = ghPath !== undefined;
2094
+ logSessionStart(
2095
+ `auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`,
2096
+ );
2097
+ return ok;
2098
+ }
2099
+
2100
+ default:
2101
+ logSessionStart(`auto-install ${tool.id}: unsupported strategy`);
2102
+ return false;
2103
+ }
2104
+ } catch (err) {
2105
+ logSessionStart(
2106
+ `auto-install ${tool.id}: exception ${(err as Error).message} (${Date.now() - startedAt}ms)`,
2107
+ );
2108
+ return false;
2109
+ }
2110
+ }
2111
+
2112
+ /**
2113
+ * Ensure a tool is installed (check first, install if missing)
2114
+ */
2115
+ export async function ensureTool(
2116
+ toolId: string,
2117
+ opts?: { forceReinstall?: boolean },
2118
+ ): Promise<string | undefined> {
2119
+ // forceReinstall: nuke caches, download from managed source, skip PATH entirely.
2120
+ // Used when a PATH-resolved tool proves broken at launch (e.g. broken symlink).
2121
+ if (opts?.forceReinstall) {
2122
+ const ensureStartMs = Date.now();
2123
+ logSessionStart(
2124
+ `auto-install ensure ${toolId}: force reinstall — clearing caches`,
2125
+ );
2126
+
2127
+ // Clear in-memory session cache
2128
+ resolvedPathCache.delete(toolId);
2129
+
2130
+ // Clear persistent probe cache entry so getToolPath won't return stale PATH result
2131
+ try {
2132
+ const probeCache = await readProbeCache();
2133
+ delete probeCache[toolId];
2134
+ _probeCacheDirty = true;
2135
+ scheduleProbeFlush();
2136
+ } catch {
2137
+ // best-effort
2138
+ }
2139
+
2140
+ // Force download
2141
+ const installed = await installTool(toolId);
2142
+ if (!installed) {
2143
+ logSessionStart(
2144
+ `auto-install ensure ${toolId}: force reinstall failed (${Date.now() - ensureStartMs}ms)`,
2145
+ );
2146
+ return undefined;
2147
+ }
2148
+
2149
+ // Find the newly installed binary (github-local check now comes before PATH)
2150
+ const result = await getToolPath(toolId);
2151
+ if (result) {
2152
+ resolvedPathCache.set(toolId, result);
2153
+ void updateProbeCache(toolId, result);
2154
+ logSessionStart(
2155
+ `auto-install ensure ${toolId}: force reinstall success at ${result} (${Date.now() - ensureStartMs}ms)`,
2156
+ );
2157
+ }
2158
+ return result;
2159
+ }
2160
+
2161
+ // Fast path 1: in-memory session cache — no I/O.
2162
+ const cached = resolvedPathCache.get(toolId);
2163
+ if (cached) return cached;
2164
+
2165
+ // Fast path 2: persistent probe cache — fs.access + stat, no process spawn.
2166
+ const diskCached = await checkProbeCache(toolId);
2167
+ if (diskCached) {
2168
+ resolvedPathCache.set(toolId, diskCached);
2169
+ logSessionStart(
2170
+ `auto-install ensure ${toolId}: probe cache hit → ${diskCached}`,
2171
+ );
2172
+ return diskCached;
2173
+ }
2174
+
2175
+ // Coalesce the whole ensure operation, not just installation. Most startup
2176
+ // duplicates race while checking already-installed tools, before installTool()
2177
+ // would ever run.
2178
+ const inFlight = ensureInFlight.get(toolId);
2179
+ if (inFlight) {
2180
+ logSessionStart(
2181
+ `auto-install ensure ${toolId}: waiting for in-flight ensure`,
2182
+ );
2183
+ return inFlight;
2184
+ }
2185
+
2186
+ const ensureStartMs = Date.now();
2187
+ const ensurePromise = (async () => {
2188
+ logSessionStart(`auto-install ensure ${toolId}: start`);
2189
+
2190
+ // Check if already installed.
2191
+ const existingPath = await getToolPath(toolId);
2192
+ if (existingPath) {
2193
+ resolvedPathCache.set(toolId, existingPath);
2194
+ void updateProbeCache(toolId, existingPath);
2195
+ logSessionStart(
2196
+ `auto-install ensure ${toolId}: already available at ${existingPath} (${Date.now() - ensureStartMs}ms)`,
2197
+ );
2198
+ return existingPath;
2199
+ }
2200
+
2201
+ const installed = await installTool(toolId);
2202
+ if (!installed) {
2203
+ logSessionStart(
2204
+ `auto-install ensure ${toolId}: unavailable (${Date.now() - ensureStartMs}ms)`,
2205
+ );
2206
+ return undefined;
2207
+ }
2208
+
2209
+ const result = await getToolPath(toolId);
2210
+ if (result) {
2211
+ resolvedPathCache.set(toolId, result);
2212
+ void updateProbeCache(toolId, result);
2213
+ logSessionStart(
2214
+ `auto-install ensure ${toolId}: success at ${result} (${Date.now() - ensureStartMs}ms)`,
2215
+ );
2216
+ } else {
2217
+ logSessionStart(
2218
+ `auto-install ensure ${toolId}: unavailable (${Date.now() - ensureStartMs}ms)`,
2219
+ );
2220
+ }
2221
+ return result;
2222
+ })();
2223
+
2224
+ ensureInFlight.set(toolId, ensurePromise);
2225
+ try {
2226
+ return await ensurePromise;
2227
+ } finally {
2228
+ ensureInFlight.delete(toolId);
2229
+ }
2230
+ }
2231
+
2232
+ // --- Integration Helpers ---
2233
+
2234
+ /**
2235
+ * Get environment with tool paths added
2236
+ */
2237
+ export async function getToolEnvironment(): Promise<NodeJS.ProcessEnv> {
2238
+ const localBin = path.join(TOOLS_DIR, "node_modules", ".bin");
2239
+ const currentPath =
2240
+ process.env.PATH || process.env.Path || process.env.path || "";
2241
+ const separator = process.platform === "win32" ? ";" : ":";
2242
+ const nodeDir = path.dirname(process.execPath);
2243
+ const withNode = nodeDir
2244
+ ? `${nodeDir}${separator}${currentPath}`
2245
+ : currentPath;
2246
+ const augmentedPath = `${GITHUB_BIN_DIR}${separator}${localBin}${separator}${withNode}`;
2247
+
2248
+ const env: NodeJS.ProcessEnv = {
2249
+ ...process.env,
2250
+ PATH: augmentedPath,
2251
+ };
2252
+
2253
+ if (process.platform === "win32") {
2254
+ env.Path = augmentedPath;
2255
+ }
2256
+
2257
+ return env;
2258
+ }
2259
+
2260
+ // --- Status Check ---
2261
+
2262
+ /**
2263
+ * Check status of all managed tools
2264
+ */
2265
+ export async function checkAllTools(): Promise<
2266
+ Array<{ id: string; name: string; installed: boolean; path?: string }>
2267
+ > {
2268
+ const results = [];
2269
+ for (const tool of TOOLS) {
2270
+ const path = await getToolPath(tool.id);
2271
+ results.push({
2272
+ id: tool.id,
2273
+ name: tool.name,
2274
+ installed: path !== undefined,
2275
+ path,
2276
+ });
2277
+ }
2278
+ return results;
2279
+ }
2280
+
2281
+ export function isKnownToolId(toolId: string): boolean {
2282
+ return TOOLS.some((tool) => tool.id === toolId);
2283
+ }
2284
+
2285
+ export const GITHUB_TOOLS = [
2286
+ "shellcheck",
2287
+ "shfmt",
2288
+ "rust-analyzer",
2289
+ "golangci-lint",
2290
+ "ktlint",
2291
+ "actionlint",
2292
+ "tflint",
2293
+ "terraform-ls",
2294
+ "zls",
2295
+ ] as const;
2296
+ export type GitHubToolId = (typeof GITHUB_TOOLS)[number];
2297
+
2298
+ /**
2299
+ * Resolve the GitHub asset filename substring for a tool on a given platform/arch.
2300
+ * Returns undefined if the tool has no GitHub spec or no asset for the platform.
2301
+ * Exported for testing only.
2302
+ */
2303
+ export function resolveGitHubAsset(
2304
+ toolId: GitHubToolId,
2305
+ platform: string,
2306
+ arch: string,
2307
+ ): string | undefined {
2308
+ const tool = TOOLS.find((t) => t.id === toolId);
2309
+ return tool?.github?.assetMatch(platform, arch);
2310
+ }
2311
+
2312
+ export function resolveGitHubInstalledBinaryName(
2313
+ toolId: GitHubToolId,
2314
+ platform: string,
2315
+ assetName: string,
2316
+ ): string | undefined {
2317
+ const tool = TOOLS.find((t) => t.id === toolId);
2318
+ if (!tool) return undefined;
2319
+ return getGitHubInstalledBinaryName(
2320
+ tool.binaryName ?? tool.id,
2321
+ platform,
2322
+ assetName,
2323
+ );
2324
+ }
2325
+
2326
+ export function resolveGitHubArchiveBinaryCandidates(
2327
+ toolId: GitHubToolId,
2328
+ platform: string,
2329
+ assetName: string,
2330
+ ): string[] | undefined {
2331
+ const tool = TOOLS.find((t) => t.id === toolId);
2332
+ if (!tool) return undefined;
2333
+ const binaryName = tool.github?.binaryInArchive ?? tool.binaryName ?? tool.id;
2334
+ return getArchiveBinaryCandidates(binaryName, platform, assetName);
2335
+ }
2336
+
2337
+ type DownloadAsset = { name: string; browser_download_url: string };
2338
+
2339
+ function deriveHashiCorpReleaseAsset(
2340
+ tool: ToolDefinition,
2341
+ tagName: string | undefined,
2342
+ assetSubstring: string,
2343
+ ): DownloadAsset | undefined {
2344
+ const product = tool.github?.hashiCorpReleaseProduct;
2345
+ if (!product || !tagName) return undefined;
2346
+
2347
+ const version = tagName.replace(/^v/, "").trim();
2348
+ if (!version) return undefined;
2349
+
2350
+ const assetName = `${product}_${version}_${assetSubstring}`;
2351
+ return {
2352
+ name: assetName,
2353
+ browser_download_url: `https://releases.hashicorp.com/${product}/${version}/${assetName}`,
2354
+ };
2355
+ }
2356
+
2357
+ export function resolveDerivedHashiCorpReleaseAsset(
2358
+ toolId: string,
2359
+ tagName: string,
2360
+ platform: string,
2361
+ arch: string,
2362
+ ): DownloadAsset | undefined {
2363
+ const tool = TOOLS.find((t) => t.id === toolId);
2364
+ if (!tool) return undefined;
2365
+ const assetSubstring = tool.github?.assetMatch(platform, arch);
2366
+ if (!assetSubstring) return undefined;
2367
+ return deriveHashiCorpReleaseAsset(tool, tagName, assetSubstring);
2368
+ }