@deftai/directive-content 0.55.1 → 0.56.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 (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env python3
2
+ """setup_ghx.py -- consent-gated ghx proxy installer for `task setup` (#884).
3
+
4
+ Wraps the `brunoborges/ghx <https://github.com/brunoborges/ghx>`_ caching
5
+ proxy installer behind an explicit-consent prompt so the maintainer's
6
+ ``task setup`` never auto-installs network-fetched binaries by default. ghx
7
+ is the recommended drop-in proxy for ``gh`` -- it caches read-only API
8
+ calls so multi-agent swarms (and the deft ``scm:*`` task surface) do not
9
+ hammer the GitHub rate limiter; v0.26.0's ``scm:*`` stub already prefers
10
+ ``ghx`` over ``gh`` at runtime via :mod:`scripts.scm`'s
11
+ ``_BINARY_PREFERENCE`` ladder when ``ghx`` is on PATH. It is maintainer and
12
+ swarm tooling, not a consumer installer prerequisite: consumer installs require
13
+ ``gh`` for GitHub-backed workflows and transparently ignore ``ghx`` when absent.
14
+
15
+ Behaviour matrix:
16
+
17
+ - ``ghx`` already on PATH -> print a one-line acknowledgement, exit 0.
18
+ - ``ghx`` missing, default (interactive) -> prompt for explicit consent
19
+ (default *no*); on decline print a one-line "recommended for speed"
20
+ note and exit 0.
21
+ - ``ghx`` missing, ``--yes`` flag -> skip the prompt and install
22
+ unconditionally (CI / scripted approval path).
23
+ - ``ghx`` missing, ``--check`` flag -> never install, never prompt;
24
+ print a one-line note when missing and exit 0. Used by the Taskfile
25
+ step so ``task setup`` is non-interactive on a clean re-run.
26
+
27
+ Install dispatch is host-platform aware:
28
+
29
+ - Windows -> ``pwsh -Command "irm <install.ps1> | iex"``
30
+ - macOS / Linux -> ``curl -fsSL <install.sh> | bash``
31
+
32
+ The upstream URLs come from the ghx README; both installers honour the
33
+ upstream's documented contract. Network failures during install are
34
+ surfaced as exit 1 (the script does NOT retry).
35
+
36
+ Three-state exit (mirrors :mod:`scripts.preflight_branch` (#747) and
37
+ :mod:`scripts.migrate_preflight` (#793)):
38
+
39
+ - ``0`` -- ghx already present, user declined, or install succeeded.
40
+ - ``1`` -- install failure (subprocess non-zero, network error, or no
41
+ install method available for the detected host).
42
+ - ``2`` -- config error (e.g. ``--yes`` and ``--check`` combined).
43
+
44
+ This script is intentionally pure-stdlib + ``subprocess`` so it can be
45
+ invoked from a fresh maintainer worktree before ``uv sync`` has run.
46
+
47
+ Refs #884.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import argparse
53
+ import os
54
+ import platform
55
+ import shutil
56
+ import subprocess
57
+ import sys
58
+ from collections.abc import Sequence
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Constants
62
+ # ---------------------------------------------------------------------------
63
+
64
+ #: Pinned ghx version. CI workflows reference the same constant via env-var
65
+ #: indirection (see ``.github/workflows/ci.yml``); bump both surfaces in
66
+ #: lockstep so a future ghx security advisory only requires one edit.
67
+ GHX_VERSION: str = "v1.5.1"
68
+
69
+ #: Upstream installer URLs, pinned to :data:`GHX_VERSION` so the script the
70
+ #: pipe-trampoline executes is the script as it existed at the pinned tag
71
+ #: (closes Greptile #950 P2). The PowerShell installer drops binaries under
72
+ #: ``%LOCALAPPDATA%\\ghx\\bin`` and adds them to the user PATH; the bash
73
+ #: installer drops them under ``/usr/local/bin`` (override via
74
+ #: ``INSTALL_DIR=...``). Pinning the URL by tag rather than ``main``
75
+ #: prevents an upstream regression -- or a hypothetical compromise of the
76
+ #: default branch between when CI runs and when an operator runs
77
+ #: ``task setup:ghx`` -- from silently feeding altered shell into either
78
+ #: trampoline. Bump in lockstep with ``.github/workflows/ci.yml``
79
+ #: ``env.GHX_VERSION`` and the URLs under each ``Install ghx`` step.
80
+ INSTALL_PS1_URL: str = (
81
+ f"https://raw.githubusercontent.com/brunoborges/ghx/{GHX_VERSION}/install.ps1"
82
+ )
83
+ INSTALL_SH_URL: str = (
84
+ f"https://raw.githubusercontent.com/brunoborges/ghx/{GHX_VERSION}/install.sh"
85
+ )
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Detection
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def ghx_present() -> bool:
94
+ """Return True when ``ghx`` (or ``ghx.exe`` on Windows) is on PATH.
95
+
96
+ Mirrors :func:`scripts.scm.resolve_binary` so the Taskfile-side check
97
+ and the ``scm:*`` runtime ladder agree on the detection contract.
98
+ """
99
+ return shutil.which("ghx") is not None
100
+
101
+
102
+ def detect_host() -> str:
103
+ """Return the canonical host tag: ``windows`` / ``darwin`` / ``linux``.
104
+
105
+ Falls back to ``platform.system().lower()`` for anything else; the
106
+ install-dispatch branch raises a friendly error in that case.
107
+ """
108
+ system = platform.system().lower()
109
+ # Normalise the macOS reporting (``platform.system()`` returns
110
+ # ``Darwin``); other hosts come through with sensible names already.
111
+ if system == "darwin":
112
+ return "darwin"
113
+ if system == "windows":
114
+ return "windows"
115
+ if system == "linux":
116
+ return "linux"
117
+ return system
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Consent prompt
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def prompt_consent(stream_in: object | None = None, stream_out: object | None = None) -> bool:
126
+ """Render an interactive y/N consent prompt; default *no*.
127
+
128
+ Args:
129
+ stream_in: Optional input stream override (tests inject
130
+ ``io.StringIO``). Defaults to ``sys.stdin``.
131
+ stream_out: Optional output stream override. Defaults to
132
+ ``sys.stdout``.
133
+
134
+ Returns:
135
+ True when the operator typed ``y`` / ``yes`` (case-insensitive);
136
+ False on empty / EOF / anything else. Default-deny matches the
137
+ #884 constraint that install MUST require explicit consent.
138
+ """
139
+ sin = stream_in if stream_in is not None else sys.stdin
140
+ sout = stream_out if stream_out is not None else sys.stdout
141
+ print(
142
+ "\n[setup_ghx] ghx is the recommended GitHub CLI cache proxy for deft "
143
+ "maintainers (prevents rate-limiting in multi-agent swarms; speeds up "
144
+ "scm:* calls). Consumer projects only require gh.",
145
+ file=sout,
146
+ )
147
+ print(f"[setup_ghx] Upstream: https://github.com/brunoborges/ghx ({GHX_VERSION})", file=sout)
148
+ print("[setup_ghx] Install ghx via the upstream installer? [y/N]: ", end="", file=sout)
149
+ sout.flush()
150
+ try:
151
+ # ``readline`` returns ``""`` on EOF (e.g. piped non-tty); treat
152
+ # as decline so a non-interactive ``task setup`` never installs by
153
+ # accident -- ``--yes`` is the explicit non-interactive path.
154
+ line = sin.readline()
155
+ except (EOFError, KeyboardInterrupt):
156
+ return False
157
+ answer = (line or "").strip().lower()
158
+ return answer in ("y", "yes")
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Install dispatch
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ def build_install_command(host: str) -> list[str]:
167
+ """Return the argv that will fetch + run the upstream installer.
168
+
169
+ Tests assert against the returned shape so a regression that changed
170
+ the installer URL or the shell trampoline would fail loudly here
171
+ rather than silently dispatching the wrong network call.
172
+
173
+ Args:
174
+ host: One of ``windows`` / ``darwin`` / ``linux``. Anything else
175
+ raises :class:`RuntimeError`.
176
+
177
+ Returns:
178
+ Argv list ready for :func:`subprocess.run`.
179
+ """
180
+ if host == "windows":
181
+ # Use ``pwsh`` (PS 7+) when available, falling back to
182
+ # ``powershell`` (Windows PS 5.1). The installer itself is
183
+ # ASCII-only per the upstream README so PS 5.1's cp1252 default
184
+ # does not corrupt the script body.
185
+ ps_bin = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
186
+ return [
187
+ ps_bin,
188
+ "-NoProfile",
189
+ "-ExecutionPolicy",
190
+ "Bypass",
191
+ "-Command",
192
+ f"irm {INSTALL_PS1_URL} | iex",
193
+ ]
194
+ if host in ("darwin", "linux"):
195
+ # ``curl | bash`` mirrors the upstream README's "Quick install
196
+ # script" path. ``-fsSL`` makes curl fail loud on HTTP 4xx/5xx
197
+ # rather than piping an HTML error page into bash.
198
+ return ["bash", "-c", f"curl -fsSL {INSTALL_SH_URL} | bash"]
199
+ raise RuntimeError(
200
+ f"no upstream ghx installer available for host {host!r}; "
201
+ "see https://github.com/brunoborges/ghx#install for manual options"
202
+ )
203
+
204
+
205
+ def install_ghx(host: str, *, runner: object | None = None) -> int:
206
+ """Invoke the upstream installer. Returns the subprocess exit code.
207
+
208
+ Args:
209
+ host: The detected host tag (see :func:`detect_host`).
210
+ runner: Optional ``subprocess.run``-compatible callable for
211
+ test injection. Defaults to :func:`subprocess.run`.
212
+
213
+ Returns:
214
+ The installer's exit code (0 on success).
215
+
216
+ Closes Greptile #950 P1: ``GHX_VERSION`` MUST be injected into the
217
+ subprocess environment because the upstream ``install.sh`` /
218
+ ``install.ps1`` honour ``${GHX_VERSION}`` as the version-pin hook.
219
+ Without this, the version constant in this module was a no-op at
220
+ install time -- the operator-side ``task setup:ghx`` could install a
221
+ different binary version than the CI pre-install step despite the
222
+ documented lockstep contract.
223
+ """
224
+ cmd = build_install_command(host)
225
+ run = runner if runner is not None else subprocess.run
226
+ print(f"[setup_ghx] Invoking upstream installer: {' '.join(cmd)}", file=sys.stderr)
227
+ install_env = {**os.environ, "GHX_VERSION": GHX_VERSION}
228
+ proc = run(cmd, check=False, env=install_env)
229
+ return int(getattr(proc, "returncode", 1))
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # CLI
234
+ # ---------------------------------------------------------------------------
235
+
236
+
237
+ def _build_parser() -> argparse.ArgumentParser:
238
+ parser = argparse.ArgumentParser(
239
+ prog="setup_ghx.py",
240
+ description=(
241
+ "Consent-gated installer for the ghx GitHub CLI cache proxy "
242
+ "(brunoborges/ghx). See #884 for the adoption rationale."
243
+ ),
244
+ )
245
+ parser.add_argument(
246
+ "--yes",
247
+ action="store_true",
248
+ help=(
249
+ "Non-interactive consent (CI / scripted approval). Skip the y/N "
250
+ "prompt and install unconditionally when ghx is missing."
251
+ ),
252
+ )
253
+ parser.add_argument(
254
+ "--check",
255
+ action="store_true",
256
+ help=(
257
+ "Detection-only mode: print whether ghx is on PATH, then exit 0. "
258
+ "Never prompt, never install. Used by the Taskfile step so "
259
+ "`task setup` stays non-interactive on a clean re-run."
260
+ ),
261
+ )
262
+ return parser
263
+
264
+
265
+ def main(argv: Sequence[str] | None = None) -> int:
266
+ """Entry point. See module docstring for the exit-code contract."""
267
+ parser = _build_parser()
268
+ args = parser.parse_args(argv)
269
+
270
+ if args.yes and args.check:
271
+ print(
272
+ "[setup_ghx] error: --yes and --check are mutually exclusive.",
273
+ file=sys.stderr,
274
+ )
275
+ return 2
276
+
277
+ if ghx_present():
278
+ # ASCII-only success line so PS 5.1 cp1252 stdout never corrupts
279
+ # the output. The leading tag mirrors `[setup_windows] ...` in
280
+ # scripts/setup_windows.ps1 so operators see one consistent
281
+ # provenance prefix across the setup surface.
282
+ print("[setup_ghx] ghx already on PATH -- skipping install.")
283
+ return 0
284
+
285
+ if args.check:
286
+ print(
287
+ "[setup_ghx] ghx not on PATH; recommended for speed -- "
288
+ "run `task setup` (without --check) to opt in. Consumer projects "
289
+ "only require gh. Refs #884."
290
+ )
291
+ return 0
292
+
293
+ consent: bool
294
+ if args.yes:
295
+ consent = True
296
+ print("[setup_ghx] --yes provided; skipping interactive consent prompt.")
297
+ else:
298
+ # Honour the documented opt-out env-var so non-interactive shells
299
+ # (CI hooks, dotfile bootstraps) can suppress the prompt without
300
+ # passing --check explicitly. This is purely additive -- the
301
+ # default still requires explicit consent.
302
+ if os.environ.get("DEFT_SETUP_GHX_SKIP", "").strip() in ("1", "true", "yes"):
303
+ print(
304
+ "[setup_ghx] DEFT_SETUP_GHX_SKIP set; skipping ghx install. "
305
+ "Refs #884."
306
+ )
307
+ return 0
308
+ consent = prompt_consent()
309
+
310
+ if not consent:
311
+ print(
312
+ "[setup_ghx] Skipping ghx install. ghx is recommended for speed "
313
+ "for maintainers and swarm runs; consumer projects only require gh "
314
+ "(see https://github.com/brunoborges/ghx, #884)."
315
+ )
316
+ return 0
317
+
318
+ host = detect_host()
319
+ try:
320
+ rc = install_ghx(host)
321
+ except RuntimeError as exc:
322
+ print(f"[setup_ghx] error: {exc}", file=sys.stderr)
323
+ return 1
324
+ if rc != 0:
325
+ print(
326
+ f"[setup_ghx] error: upstream installer exited {rc}. "
327
+ "See https://github.com/brunoborges/ghx#install for manual options.",
328
+ file=sys.stderr,
329
+ )
330
+ return 1
331
+ print(
332
+ "[setup_ghx] ghx installed. Open a fresh shell so the updated PATH "
333
+ "takes effect, then re-run `task setup` to verify."
334
+ )
335
+ return 0
336
+
337
+
338
+ if __name__ == "__main__":
339
+ raise SystemExit(main())
@@ -0,0 +1,220 @@
1
+ # setup_windows.ps1 -- idempotent winget bootstrap for the deft Windows
2
+ # maintainer toolchain (Go, Python 3.12+, uv, Task, GitHub CLI).
3
+ #
4
+ # Probes each tool via Get-Command first; only invokes `winget install` when
5
+ # the tool is missing. After installs, dot-sources scripts/refresh-path.ps1 so
6
+ # the running session sees the newly-installed binaries without requiring a
7
+ # fresh shell.
8
+ #
9
+ # Usage:
10
+ # pwsh -ExecutionPolicy Bypass -File scripts\setup_windows.ps1
11
+ # # or, via the parent Taskfile alias:
12
+ # task setup:toolchain
13
+ #
14
+ # Tests: tests/scripts/test_setup_windows.ps1
15
+ # Companion: scripts/refresh-path.ps1
16
+ # Issue: #902
17
+ #
18
+ # ASCII-only by policy (AGENTS.md PowerShell rule). Do not introduce em
19
+ # dashes, smart quotes, arrows, or other non-ASCII glyphs in this file.
20
+
21
+ [CmdletBinding()]
22
+ param(
23
+ # When set, the script only reports what it would do without invoking
24
+ # winget. Useful for dry-run validation in tests and CI.
25
+ [switch] $WhatIfOnly,
26
+
27
+ # Test seam: list of probe names to treat as missing regardless of the
28
+ # actual host PATH. Lets the regression suite exercise the
29
+ # "winget install" branch without mutating the host.
30
+ [string[]] $ForceMissing = @(),
31
+
32
+ # Test seam: scriptblock invoked instead of winget for each missing tool.
33
+ # The block is invoked with the canonical winget package id as its single
34
+ # argument. When unset, the script invokes `winget install` directly.
35
+ [scriptblock] $InstallOverride,
36
+
37
+ # Test seam: when set, skips the post-install dot-source of
38
+ # refresh-path.ps1. Tests use this to keep $env:PATH stable.
39
+ [switch] $SkipRefresh
40
+ )
41
+
42
+ # NOTE: $ErrorActionPreference is set INSIDE Invoke-DeftWindowsSetup so it
43
+ # scopes to the function body and never leaks into a dot-source caller's
44
+ # scope (e.g. the Pester Describe blocks that dot-source this file via
45
+ # BeforeAll). See #909 cycle-3 P1 finding.
46
+
47
+ # Capture $PSScriptRoot at script load time (before any function definitions)
48
+ # so the refresh-path.ps1 lookup inside Invoke-DeftWindowsSetup remains
49
+ # correct when the script is dot-sourced from a different directory. The
50
+ # $script: scope qualifier ensures the value persists across the function-
51
+ # definition / function-call boundary. See #909 cycle-3 P1 finding.
52
+ $script:DeftSetupScriptRoot = $PSScriptRoot
53
+
54
+ # Tool registry. Each entry maps a probe command (the binary name resolved
55
+ # via Get-Command) to its canonical winget package id. The id list is the
56
+ # acceptance criterion in #902 plus the GitHub.cli sibling.
57
+ $DeftWindowsTools = @(
58
+ [pscustomobject]@{ Name = 'go'; Probe = 'go'; WingetId = 'GoLang.Go' },
59
+ [pscustomobject]@{ Name = 'python'; Probe = 'python'; WingetId = 'Python.Python.3.12' },
60
+ [pscustomobject]@{ Name = 'uv'; Probe = 'uv'; WingetId = 'astral-sh.uv' },
61
+ [pscustomobject]@{ Name = 'task'; Probe = 'task'; WingetId = 'Task.Task' },
62
+ [pscustomobject]@{ Name = 'gh'; Probe = 'gh'; WingetId = 'GitHub.cli' }
63
+ )
64
+
65
+ function Test-DeftWindowsAppsStub {
66
+ [CmdletBinding()]
67
+ [OutputType([bool])]
68
+ param(
69
+ [Parameter(Mandatory)]
70
+ [AllowNull()]
71
+ [object] $Command
72
+ )
73
+ # Windows App Installer ships %LOCALAPPDATA%\Microsoft\WindowsApps\<name>.exe
74
+ # stubs (notably python.exe) that redirect to the Microsoft Store rather
75
+ # than launching a real interpreter. Get-Command resolves these stubs, so
76
+ # a naive presence check causes `winget install` to be skipped silently.
77
+ # Treat any binary whose Source path is anchored under WindowsApps as a
78
+ # stub so the install branch fires on stock Windows 10/11 hosts.
79
+ if ($null -eq $Command) { return $false }
80
+ if (-not $Command.Source) { return $false }
81
+ return ($Command.Source -match '\\WindowsApps\\')
82
+ }
83
+
84
+ function Test-DeftToolPresent {
85
+ [CmdletBinding()]
86
+ [OutputType([bool])]
87
+ param(
88
+ [Parameter(Mandatory)]
89
+ [string] $Probe,
90
+
91
+ [string[]] $ForceMissing = @()
92
+ )
93
+ if ($ForceMissing -contains $Probe) { return $false }
94
+ $cmd = Get-Command -Name $Probe -ErrorAction SilentlyContinue
95
+ if ($null -eq $cmd) { return $false }
96
+ if (Test-DeftWindowsAppsStub -Command $cmd) { return $false }
97
+ return $true
98
+ }
99
+
100
+ function Test-DeftWingetSuccess {
101
+ [CmdletBinding()]
102
+ [OutputType([bool])]
103
+ param(
104
+ [Parameter(Mandatory)]
105
+ [int] $ExitCode
106
+ )
107
+ # 3010 = ERROR_SUCCESS_REBOOT_REQUIRED -- the install succeeded but a
108
+ # reboot is needed (Python's MSI, Go's installer, etc. propagate this
109
+ # via winget). Treating it as a failure causes the script to add the
110
+ # tool to $failed and exit 1 even though the binary is installed --
111
+ # which means a fresh-machine bootstrap visibly "fails" on first run.
112
+ # The downstream PATH refresh handles the session PATH; an actual
113
+ # reboot is only required for kernel-level changes that this toolchain
114
+ # does not produce. See #909 cycle-4 P1 finding.
115
+ return ($ExitCode -eq 0 -or $ExitCode -eq 3010)
116
+ }
117
+
118
+ function Invoke-DeftWingetInstall {
119
+ [CmdletBinding()]
120
+ param(
121
+ [Parameter(Mandatory)]
122
+ [string] $WingetId
123
+ )
124
+ $wingetArgs = @(
125
+ 'install',
126
+ '--id', $WingetId,
127
+ '-e',
128
+ '--silent',
129
+ '--accept-source-agreements',
130
+ '--accept-package-agreements'
131
+ )
132
+ & winget @wingetArgs
133
+ if (-not (Test-DeftWingetSuccess -ExitCode $LASTEXITCODE)) {
134
+ throw ("winget install --id {0} exited with code {1}" -f $WingetId, $LASTEXITCODE)
135
+ }
136
+ }
137
+
138
+ function Invoke-DeftWindowsSetup {
139
+ [CmdletBinding()]
140
+ [OutputType([pscustomobject])]
141
+ param(
142
+ [switch] $WhatIfOnly,
143
+ [string[]] $ForceMissing = @(),
144
+ [scriptblock] $InstallOverride,
145
+ [switch] $SkipRefresh
146
+ )
147
+
148
+ # Scope $ErrorActionPreference to the function body so it does not
149
+ # mutate the caller's scope when this file is dot-sourced. See #909.
150
+ $ErrorActionPreference = 'Stop'
151
+
152
+ $installed = New-Object System.Collections.ArrayList
153
+ $alreadyPresent = New-Object System.Collections.ArrayList
154
+ $failed = New-Object System.Collections.ArrayList
155
+
156
+ foreach ($tool in $DeftWindowsTools) {
157
+ $present = Test-DeftToolPresent -Probe $tool.Probe -ForceMissing $ForceMissing
158
+ if ($present) {
159
+ [void]$alreadyPresent.Add($tool.Name)
160
+ Write-Host ("[setup_windows] {0}: present (skip)" -f $tool.Name)
161
+ continue
162
+ }
163
+
164
+ Write-Host ("[setup_windows] {0}: missing -- installing {1}" -f $tool.Name, $tool.WingetId)
165
+ if ($WhatIfOnly) {
166
+ [void]$installed.Add($tool.Name)
167
+ continue
168
+ }
169
+
170
+ try {
171
+ if ($null -ne $InstallOverride) {
172
+ & $InstallOverride $tool.WingetId
173
+ } else {
174
+ Invoke-DeftWingetInstall -WingetId $tool.WingetId
175
+ }
176
+ [void]$installed.Add($tool.Name)
177
+ } catch {
178
+ Write-Warning ("[setup_windows] failed to install {0}: {1}" -f $tool.Name, $_)
179
+ [void]$failed.Add($tool.Name)
180
+ }
181
+ }
182
+
183
+ if (-not $SkipRefresh -and -not $WhatIfOnly -and $installed.Count -gt 0) {
184
+ # Use the script-scope variable captured at dot-source time. Bare
185
+ # $PSScriptRoot here would resolve to the caller's directory when
186
+ # this function is invoked from a dot-sourced context. See #909.
187
+ $refreshScript = Join-Path $script:DeftSetupScriptRoot 'refresh-path.ps1'
188
+ if (Test-Path -LiteralPath $refreshScript) {
189
+ . $refreshScript
190
+ } else {
191
+ Write-Warning ("[setup_windows] refresh-path.ps1 not found at {0}" -f $refreshScript)
192
+ }
193
+ }
194
+
195
+ $installedStr = if ($installed.Count -gt 0) { $installed -join ', ' } else { 'none' }
196
+ $presentStr = if ($alreadyPresent.Count -gt 0) { $alreadyPresent -join ', ' } else { 'none' }
197
+ Write-Host ("[setup_windows] Installed: {0}. Already present: {1}." -f $installedStr, $presentStr)
198
+ if ($failed.Count -gt 0) {
199
+ Write-Warning ("[setup_windows] Failed: {0}" -f ($failed -join ', '))
200
+ }
201
+
202
+ return [pscustomobject]@{
203
+ Installed = @($installed)
204
+ AlreadyPresent = @($alreadyPresent)
205
+ Failed = @($failed)
206
+ }
207
+ }
208
+
209
+ # Run the bootstrap unless the script was dot-sourced (the test suite dot-
210
+ # sources to access the helper functions without triggering the main flow).
211
+ if ($MyInvocation.InvocationName -ne '.') {
212
+ $result = Invoke-DeftWindowsSetup `
213
+ -WhatIfOnly:$WhatIfOnly `
214
+ -ForceMissing $ForceMissing `
215
+ -InstallOverride $InstallOverride `
216
+ -SkipRefresh:$SkipRefresh
217
+ if ($result.Failed.Count -gt 0) {
218
+ exit 1
219
+ }
220
+ }