ccdeck 3.29.11 → 3.31.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 (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,213 @@
1
+ // Whether a batch candidate's output is cmd.exe saying "no such command".
2
+ //
3
+ // A `.cmd` or `.bat` is launched THROUGH cmd.exe (see viaCmd in exec.mjs),
4
+ // and cmd.exe is always there — so a missing tool is not a spawn error at all.
5
+ // It is a healthy shell exiting non-zero after printing a sentence, and telling
6
+ // that sentence apart from a tool that ran and failed is the whole of what this
7
+ // module does. It decides two things for exec.mjs's candidate loop: whether to
8
+ // try the next spelling, and whether to answer `code: "ENOENT"` — and the first
9
+ // of those re-runs the entire command, which for `cswap remove 3` is not a
10
+ // retry anybody wants made on a guess.
11
+ //
12
+ // A module of its own because it is pure: text and an exit status in, a verdict
13
+ // out, no process and no filesystem. exec.mjs re-exports both names, so every
14
+ // caller that imported them from there still does.
15
+ // The three lines an ENGLISH cmd.exe prints when it cannot find what it was
16
+ // asked to run, anchored to a whole line each. First the two-line pair for a
17
+ // bare name, then the one it uses when a directory in an explicit path does not
18
+ // exist.
19
+ //
20
+ // These are one signal out of three rather than the whole answer — see
21
+ // looksMissing. Windows ships cmd.exe in every language it ships in, and these
22
+ // sentences are translated with it.
23
+ const CMD_UNKNOWN = /^'(.+)' is not recognized as an internal or external command,?$/i;
24
+ const CMD_UNKNOWN_TAIL = /^operable program or batch file\.?$/i;
25
+ const CMD_NO_PATH = /^the system cannot find the (?:path|file) specified\.?$/i;
26
+
27
+ // cmd.exe's own errorlevel for a command token it could not resolve — the one
28
+ // signal here that is not a human sentence, and therefore the only one that is
29
+ // the same on a German install as on an English one. It is the number every CI
30
+ // log in the world prints beside "is not recognized".
31
+ //
32
+ // Not every path reports it: some Windows builds answer a bare `cmd /c missing`
33
+ // with a plain 1 instead, which is why this is a sufficient signal and never a
34
+ // necessary one. A number this large cannot arrive from POSIX at all — a process
35
+ // exit status there is masked to 0-255 before Node ever sees it — so nothing
36
+ // outside cmd.exe can reach it by accident.
37
+ const CMD_NOT_FOUND_EXIT = 9009;
38
+
39
+ /** True when an exit status is cmd.exe saying "no such command", in any locale. */
40
+ export const notFoundExit = (code) => Number(code) === CMD_NOT_FOUND_EXIT;
41
+
42
+ // Every run of text this line wrapped in quotes. cmd.exe quotes the command
43
+ // token it could not find in EVERY locale, and the quote character is the only
44
+ // part of the message that is not a translation: `'…'` in English and French,
45
+ // `"…"` in German, Spanish, Italian, Portuguese, Polish and Russian.
46
+ const quotedRuns = (line) =>
47
+ [...String(line).matchAll(/'([^']+)'|"([^"]+)"/g)].map(m => m[1] ?? m[2]);
48
+
49
+ // cmd.exe echoes the command token exactly as it was given, so an exact match
50
+ // is what we expect; the comparison is only case- and quote-insensitive because
51
+ // Windows paths are.
52
+ const sameCommand = (quoted, name) => {
53
+ const norm = (s) => String(s).trim().replace(/^"+|"+$/g, "").toLowerCase();
54
+ return norm(quoted) === norm(name);
55
+ };
56
+
57
+ /**
58
+ * cmd.exe's way of saying ENOENT — and only cmd.exe's.
59
+ *
60
+ * A .cmd or .bat candidate is launched THROUGH cmd.exe, and cmd.exe exists — so
61
+ * a missing tool is not a spawn error at all. It is a healthy shell exiting 1
62
+ * after printing two lines:
63
+ *
64
+ * 'cswap' is not recognized as an internal or external command,
65
+ * operable program or batch file.
66
+ *
67
+ * Read as a real failure, that stops the candidate loop early AND puts the
68
+ * second line — on its own, meaningless — in front of the user. Reported from
69
+ * Windows on 2026-08-14: the accounts panel said only "operable program or
70
+ * batch file." when sharing an account.
71
+ *
72
+ * The fussiness is about the other direction, which is worse. "The system
73
+ * cannot find the file specified" is Windows' generic text for ENOENT, and it
74
+ * appears INSIDE the output of tools that ran perfectly well — cswap is a
75
+ * Python CLI, and a FileNotFoundError traceback ends in that exact sentence.
76
+ * Believed there, it threw the real error away, told the user the tool was not
77
+ * on PATH when PATH was fine, and — the dangerous half — sent the candidate
78
+ * loop on to re-run the entire command, which for `cswap remove 3` means asking
79
+ * to delete an account a second time.
80
+ *
81
+ * So the output has to be cmd.exe's message and nothing else: cmd.exe prints it
82
+ * INSTEAD of running anything, so any other line, or any prefix on the line,
83
+ * means something ran and this is its report. And when cmd.exe names the
84
+ * command it could not find, that name must be the candidate we asked for — a
85
+ * tool that shells out itself can forward the message about some other command.
86
+ *
87
+ * `name` is the spelling cmd.exe was ACTUALLY GIVEN — which since #457 is the
88
+ * shim's absolute path whenever shimPath found one, not the bare candidate the
89
+ * loop started from. That distinction is the coupling #456 named as its reason
90
+ * for stopping short, and it is a one-way trap rather than a detail: cmd.exe
91
+ * echoes the command token back exactly as it received it, so a check against
92
+ * the bare name stops matching the instant the token becomes a path. What that
93
+ * costs is not a cosmetic mismatch — it is the honesty of the whole answer. A
94
+ * shim that shimPath saw and that is gone by the time cmd.exe looks for it (a
95
+ * stale memo, an uninstall mid-session, a network drive that dropped, a roaming
96
+ * profile still syncing) would come back as an ordinary exit 1 whose stderr is
97
+ * cmd.exe's two-line "is not recognized / operable program or batch file."
98
+ * instead of the `code: "ENOENT"` every panel keys its "not installed" message
99
+ * off. The user would be told their CLI failed, and shown half a sentence about
100
+ * batch files, when the truthful answer is that it is not there.
101
+ *
102
+ * Feeding it the launch spelling keeps the comparison EXACT, which is what the
103
+ * paragraph above is protecting: matching loosely — on the basename, say —
104
+ * would let a tool that shells out itself have its own child's "is not
105
+ * recognized" read as the tool's absence, re-running a command that may be
106
+ * `cswap remove 3`. So the rule is not "compare less", it is "compare against
107
+ * what was actually asked for".
108
+ *
109
+ * Callers that only have the text (failureText) omit it and get the shape rules
110
+ * alone.
111
+ *
112
+ * ── AND NOT ONLY IN ENGLISH (#552) ──────────────────────────────────────────
113
+ *
114
+ * Everything above was written against three English sentences, and cmd.exe is
115
+ * translated. On a German install with cswap genuinely absent it prints
116
+ *
117
+ * Der Befehl "cswap" ist entweder falsch geschrieben oder konnte nicht
118
+ * gefunden werden.
119
+ *
120
+ * — so this answered false, `run` resolved `{ ok: false, code: 1 }`,
121
+ * claude-accounts.mjs picked `reason: "switch_failed"` over `"no_cswap"` and the
122
+ * panel's install affordance never appeared. failureText then fell through to
123
+ * firstUseful, which puts the LAST line of a localized sentence on screen by
124
+ * itself: #457's symptom reproduced for every non-English locale. It also
125
+ * stopped the candidate loop early, so a `.bat` installed after a missing `.cmd`
126
+ * was never reached.
127
+ *
128
+ * Adding the German sentence, and then the French and the Japanese ones, is not
129
+ * a fix — it is the same defect with a longer list. So two signals that are not
130
+ * sentences carry the answer instead, and the English text is what remains when
131
+ * neither is available:
132
+ *
133
+ * 1. THE EXIT STATUS. `exitCode` 9009 is cmd.exe's own errorlevel for a
134
+ * command token it could not resolve, identical in every language. See
135
+ * CMD_NOT_FOUND_EXIT for why it is not required, and the paragraph below
136
+ * for the two cases where it is not believed either. It is subject to the
137
+ * same "cmd.exe printed nothing else" cap as rule 2, because a status is
138
+ * forwarded as easily as a sentence is.
139
+ *
140
+ * 2. THE SHAPE. cmd.exe quotes the command it could not find, in every locale,
141
+ * and prints that INSTEAD of running anything — so the whole output is at
142
+ * most the two lines of one wrapped sentence. Text of at most two lines
143
+ * whose FIRST line quotes exactly the spelling we launched is cmd.exe's
144
+ * verdict about our command whatever the words around it say.
145
+ *
146
+ * Rule 2 needs `name`, and refuses without it. That is the same principle the
147
+ * paragraphs above argue for and not a limitation bolted on: `Error: "account-9"
148
+ * does not exist` is one line with a quoted token in it, and read as an absence
149
+ * it would send the candidate loop back round to re-run `cswap remove 3`. What
150
+ * makes the rule safe is that the quoted token has to be the exact spelling
151
+ * cmd.exe was handed — `cswap.cmd`, or the absolute path shimPath found — which
152
+ * is a string the tool underneath has no reason to print. The two-line cap is
153
+ * the other half: a Python traceback ending in "The system cannot find the file
154
+ * specified" is four lines and can never qualify.
155
+ *
156
+ * THE SAME TRAP THE ENGLISH RULE ALREADY AVOIDS, NOW FOR THE EXIT STATUS. A
157
+ * `.cmd` shim is itself a batch file, so a shim that EXISTS and whose payload
158
+ * interpreter does not — a scoop or npm-style `cswap.cmd` in front of a python
159
+ * that was uninstalled — has cmd.exe print "is not recognized" about PYTHON and
160
+ * hands the shim's caller that same 9009. Believed on its own, the deck would
161
+ * call the tool absent and re-run the command under the next spelling. So a text
162
+ * that positively names a command OTHER than ours vetoes every rule here,
163
+ * including the status: the check that made #457 safe, applied one level up.
164
+ *
165
+ * What is deliberately still missed: a LOCALIZED "the system cannot find the
166
+ * path specified", which carries no quoted token and no structure to key off.
167
+ * That case only arises when shimPath found a shim that then vanished, and it
168
+ * fails in the safe direction — an honest exit 1 rather than a wrong ENOENT.
169
+ */
170
+ export function looksMissing(text, name = "", exitCode = null) {
171
+ const lines = String(text ?? "").split(/\r?\n/).map(l => l.trim()).filter(Boolean);
172
+ // Whatever cmd.exe named, in whatever language it said the rest — the quoting
173
+ // is the part that is not a translation.
174
+ const named = lines.length ? quotedRuns(lines[0]) : [];
175
+ const namesUs = named.some(q => sameCommand(q, name));
176
+
177
+ // The veto, before anything is believed: a message about somebody else's
178
+ // command is not evidence about ours, and neither is the status that came
179
+ // with it. See the header — a `.cmd` shim in front of a missing interpreter
180
+ // forwards both.
181
+ if (name && named.length > 0 && !namesUs) return false;
182
+
183
+ // cmd.exe says this INSTEAD of running anything, so anything longer than one
184
+ // wrapped sentence came from something that DID run — and that outranks both
185
+ // signals below. A shim can forward its child's 9009 after printing pages of
186
+ // its own; a Python traceback is four lines and one of them quotes the very
187
+ // shim we launched.
188
+ const saidNothingElse = lines.length <= 2;
189
+
190
+ // The signal that is not a sentence. It does not need to READ the text, which
191
+ // is the whole reason it exists: on a non-English install there may be nothing
192
+ // in the text this can read.
193
+ if (saidNothingElse && notFoundExit(exitCode)) return true;
194
+ if (!lines.length) return false;
195
+
196
+ // The English shapes, whole-line anchored, exactly as before.
197
+ let english = true;
198
+ for (const line of lines) {
199
+ const unknown = CMD_UNKNOWN.exec(line);
200
+ if (unknown) {
201
+ if (name && !sameCommand(unknown[1], name)) return false;
202
+ continue;
203
+ }
204
+ if (CMD_UNKNOWN_TAIL.test(line) || CMD_NO_PATH.test(line)) continue;
205
+ english = false;
206
+ break;
207
+ }
208
+ if (english) return true;
209
+
210
+ // Otherwise: cmd.exe in some other language, recognised by its shape and by
211
+ // the one word in it that is ours.
212
+ return Boolean(name) && saidNothingElse && namesUs;
213
+ }
@@ -0,0 +1,23 @@
1
+ // What a command that failed said about it, short enough to keep.
2
+ //
3
+ // claude-accounts.mjs, cswap-install.mjs and cswap-auto.mjs read a failed
4
+ // `run` the same way — stderr when the tool wrote any, stdout otherwise,
5
+ // trimmed, and cut to a length a panel can print — and each wrote that out in
6
+ // full, seven times over. One rule spelled seven times is seven places to fix
7
+ // the day a tool starts putting its reason somewhere else. The auto-switch tick
8
+ // (cswap-auto-loop.mjs) had an eighth spelling — stderr or nothing, on the one
9
+ // path where stdout is already known to be empty — and asks here too.
10
+ //
11
+ // A module of its own rather than a line in exec.mjs, which owns `run`: more
12
+ // than a dozen suites replace exec.mjs with a mock that carries `run` and
13
+ // nothing else, and a helper exported from there would be missing from every
14
+ // module those suites load.
15
+
16
+ /**
17
+ * What a failed command said, trimmed and cut to `max` characters: its stderr
18
+ * when it wrote any, its stdout otherwise, because tools differ on which one a
19
+ * failure goes to. `r` is exec.mjs's `run` answer.
20
+ */
21
+ export function failureDetail(r, max) {
22
+ return (r.stderr || r.stdout).trim().slice(0, max);
23
+ }
@@ -0,0 +1,341 @@
1
+ // Which file to run for a command name, and what to hand spawn for it — on
2
+ // Linux, macOS and Windows alike.
3
+ //
4
+ // Everything exec.mjs's header argues for is decided here: the spellings a bare
5
+ // name is tried under (candidates), which of them are batch files that must go
6
+ // through cmd.exe (isBatch, viaCmd), how one argument is quoted for a shell that
7
+ // will parse it (shellQuoteArg), and the absolute path a `.cmd` shim is
8
+ // launched under so it finds its own payload (shimPath, pathLookup,
9
+ // candidateSpec). Nothing here spawns anything. The platform is a parameter
10
+ // throughout, so the Windows answers can be checked from a machine that is not
11
+ // Windows.
12
+ //
13
+ // The one piece of state is the memo of which spelling worked, per command
14
+ // name: `candidates` reads it, and `rememberSpelling` is its one write, which
15
+ // exec.mjs's runners call once a spelling has proved itself.
16
+ //
17
+ // exec.mjs re-exports every public name here, so its callers are unchanged.
18
+ import { existsSync } from "node:fs";
19
+
20
+ // Extensions Windows will execute, most specific first. `.com` is omitted —
21
+ // nothing ships one, and every extra candidate costs a failed spawn.
22
+ const WIN_EXTS = [".exe", ".cmd", ".bat", ""];
23
+
24
+ // Which spelling worked, per command name. A failed spawn is cheap but not
25
+ // free, and these run on a poll.
26
+ const resolved = new Map();
27
+
28
+ /**
29
+ * Remember that `raw` is the spelling of `cmd` that worked, so `candidates`
30
+ * offers it alone from now on.
31
+ *
32
+ * The memo's one write, named. Five places confirm a spelling — a clean `run`,
33
+ * a direct spawn and a clean exit in `runInteractive`, the same two in
34
+ * `runDetached` — and each has its own rule for WHEN a spelling counts as
35
+ * confirmed; see the notes at each. What they record is always the candidate
36
+ * and never a resolved path, for the reason given in `run`.
37
+ */
38
+ export function rememberSpelling(cmd, raw) {
39
+ resolved.set(cmd, raw);
40
+ }
41
+
42
+ /**
43
+ * The spellings to try for `cmd`, best first.
44
+ *
45
+ * Exported with the platform as a parameter for the reason everything else in
46
+ * this file is: the Windows answer decides which candidate becomes a batch one,
47
+ * and a batch candidate is the only kind #457 touches — so a test that wants to
48
+ * follow a caller's bare name all the way to the command line cmd.exe receives
49
+ * has to be able to ask for the Windows list from a machine that is not Windows.
50
+ */
51
+ export function candidates(cmd, platform = process.platform) {
52
+ if (platform !== "win32") return [cmd];
53
+ // An explicit extension is respected as given.
54
+ if (/\.[a-z]+$/i.test(cmd)) return [cmd];
55
+ const known = resolved.get(cmd);
56
+ return known ? [known] : WIN_EXTS.map(ext => cmd + ext);
57
+ }
58
+
59
+ /** Exported for tests: the platform is a parameter so both can be checked. */
60
+ export const isBatch = (file, platform = process.platform) =>
61
+ platform === "win32" && /\.(cmd|bat)$/i.test(file);
62
+
63
+ /**
64
+ * Rewrite a batch-file invocation as a cmd.exe one.
65
+ *
66
+ * Mirrors what Node does internally for `shell: true` on Windows — comspec,
67
+ * /d /s /c, the whole command line as a single quoted argument, and
68
+ * windowsVerbatimArguments so Node does not quote it a second time. Each
69
+ * argument is quoted by shellQuoteArg below, which has to satisfy cmd.exe AND
70
+ * the argv parser of whatever it launches — see the note there, and #624 for
71
+ * the half of that rule this file was missing until a real cmd.exe was asked.
72
+ */
73
+ export function viaCmd(file, args) {
74
+ const line = [file, ...args].map(a => shellQuoteArg(a, "win32")).join(" ");
75
+ return {
76
+ file: process.env.comspec || process.env.ComSpec || "cmd.exe",
77
+ args: ["/d", "/s", "/c", `"${line}"`],
78
+ opts: { windowsVerbatimArguments: true },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Quote ONE argument into a command line a shell will parse.
84
+ *
85
+ * Everything else in this module exists to avoid needing this: an argument
86
+ * vector is handed to spawn untouched and nothing in it is ever read as syntax.
87
+ * Two callers cannot take that route, and they are the reason this is exported.
88
+ * A Claude Code hook is registered as `{type:"command", command:"<string>"}` —
89
+ * the host CLI runs that string THROUGH A SHELL on every tool call, and the
90
+ * format has no argv form to emit instead. So the string has to be built here,
91
+ * correctly, once.
92
+ *
93
+ * The inputs are not user input in the web sense, but they are not constants
94
+ * either: the hook path is built from $CLAUDE_CONFIG_DIR and homedir(), and the
95
+ * node path from process.execPath. Wrapping those in double quotes — what this
96
+ * used to do — is not escaping on POSIX at all: `$(…)`, a backtick and `\` are
97
+ * all still live inside them, so a config dir named `/tmp/a$(id)b` became shell
98
+ * code written into the user's settings.json and executed on every hook fire.
99
+ * A bare `$` in a path is the same bug wearing a duller hat — `$HOME` expands to
100
+ * nothing, the hook path silently becomes wrong, and hooks stop firing with no
101
+ * error anywhere.
102
+ *
103
+ * POSIX gets single quotes, inside which NOTHING is special, with the one
104
+ * escape that form has: close the quote, emit a backslash-quote, reopen.
105
+ *
106
+ * Windows gets the rule below — the same one viaCmd applies above, so this file
107
+ * has one Windows quoting rule rather than two. It has to satisfy TWO parsers,
108
+ * and that is the whole of its difficulty, because they disagree about the
109
+ * backslash:
110
+ *
111
+ * cmd.exe reads the line only far enough to find where the command ends. It
112
+ * has no escape for a quote at all — a `"` toggles "inside quotes", and `&`,
113
+ * `|`, `<`, `>`, `^`, `(` and `)` are syntax only while outside — and it does
114
+ * not treat `\` as anything. Then it hands the rest of the line on AS TEXT.
115
+ *
116
+ * The program on the other end splits that text into argv itself, and for
117
+ * node that is the UCRT parser, which DOES read `\` as an escape in front of
118
+ * a quote: 2n backslashes before a `"` are n backslashes and a quote that
119
+ * toggles, 2n+1 are n backslashes and a literal `"`.
120
+ *
121
+ * So an embedded `"` is written `""` rather than `\"` — two toggles is no net
122
+ * change to cmd.exe's idea of where it is, while the child's parser turns the
123
+ * pair back into one quote — and every run of backslashes that ends up in front
124
+ * of a quote, INCLUDING THE CLOSING ONE THIS ADDS, is doubled so the child does
125
+ * not read it as an escape.
126
+ *
127
+ * That last clause is #624 and it was missing. `"` + arg + `"` alone turns a
128
+ * path ending in a separator — `C:\Program Files\nodejs\` — into
129
+ * `"C:\Program Files\nodejs\"`, whose final `\"` is an escaped quote to the
130
+ * child: the quoted region never closes, and the argument swallows every
131
+ * argument after it on the line. `--provider claude` in the hook command is
132
+ * exactly what it swallowed. A backslash immediately before an embedded quote
133
+ * was the quieter half of the same defect — it was eaten as the escape.
134
+ *
135
+ * The one residual left, unchanged: cmd.exe expands `%VAR%` inside quotes too,
136
+ * and a command line has no escape for it. That is narrower than it sounds,
137
+ * since `%foo%` with no variable `foo` is left alone, and it is a limit of the
138
+ * platform rather than of this function. `!VAR!` is the same limit on a machine
139
+ * with delayed expansion turned on, which is not the default for `cmd /c`.
140
+ *
141
+ * no-shell-hook-commands.test.ts runs the output of this through a real cmd.exe
142
+ * on the Windows leg of the matrix and compares what the child RECEIVED against
143
+ * what was intended. Four literals said this function agreed with itself for
144
+ * three releases; they could not say whether it agreed with Windows.
145
+ */
146
+ export function shellQuoteArg(arg, platform = process.platform) {
147
+ const s = String(arg ?? "");
148
+ if (platform !== "win32") return `'${s.split("'").join("'\\''")}'`;
149
+ let out = '"';
150
+ let slashes = 0;
151
+ for (const ch of s) {
152
+ if (ch === "\\") { slashes++; continue; }
153
+ if (ch === '"') { out += "\\".repeat(slashes * 2) + '""'; slashes = 0; continue; }
154
+ out += "\\".repeat(slashes) + ch;
155
+ slashes = 0;
156
+ }
157
+ // A run that reaches the end of the argument is in front of the closing quote,
158
+ // which is a quote like any other.
159
+ return `${out}${"\\".repeat(slashes * 2)}"`;
160
+ }
161
+
162
+ /**
163
+ * The absolute path of a Windows command shim, or null when nothing answers to
164
+ * that name.
165
+ *
166
+ * Why a bare name is not good enough, which is the whole of #456. npm's `.cmd`
167
+ * shims locate every file they need relative to THEMSELVES:
168
+ *
169
+ * SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
170
+ * SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
171
+ *
172
+ * `%~dp0` is the drive and path of `%0`, and `%0` is the command token cmd.exe
173
+ * was given. Handed `cmd.exe /d /s /c ""npm.cmd" "install" …`, that token
174
+ * carries no directory of its own, so `%~dp0` came out as the deck's WORKING
175
+ * DIRECTORY instead of the shim's. Reported from Windows 10 with the deck
176
+ * started from `C:\Users\vceban`:
177
+ *
178
+ * Error: Cannot find module 'C:\Users\vceban\node_modules\npm\bin\npm-prefix.js'
179
+ * Error: Cannot find module 'C:\Users\vceban\node_modules\npm\bin\npx-cli.js'
180
+ *
181
+ * — two stacks, one defect, and `C:\Users\vceban` is the cwd rather than
182
+ * anything to do with npm: `where npx` on that machine printed
183
+ * `C:\Program Files\nodejs\npx.cmd` and `npx ccdeck` ran perfectly from a
184
+ * prompt. It is ours, introduced when #362 replaced `shell: true` with viaCmd:
185
+ * the quoting was the fix and the bare name was the cost, and it broke the
186
+ * managed install and the npx fallback in the same stroke, which is why every
187
+ * diagnosis of one half kept half-fitting.
188
+ *
189
+ * Node's own directory is tried before PATH because node and npm ship together
190
+ * and that is where the shims are — the same preference npxCliCandidates in
191
+ * npx.mjs states for npm's CLI scripts, so the repo has one rule rather than
192
+ * two. PATH is then walked in its own order, which is what cmd.exe would have
193
+ * done, minus the current directory it searches first: a deck that resolves its
194
+ * npm out of whatever folder it happens to be sitting in is the bug above
195
+ * wearing a hat.
196
+ *
197
+ * The path arithmetic is spelled out rather than done through `node:path` for
198
+ * the reason npxCliCandidates gives: `path` is the platform running the SUITE,
199
+ * so a Windows layout checked from macOS would come back with forward slashes.
200
+ * `execPath`, `pathEnv` and `exists` are injected for the same reason — the
201
+ * Windows answer has to be checkable from an OS that cannot run it.
202
+ *
203
+ * The walk itself now lives in `pathLookup` below, because #433 needed the same
204
+ * one for a tool that is not a shim; this is that walk with the two answers a
205
+ * shim needs — Windows, and node's own directory first.
206
+ */
207
+ export const shimPath = (name, deps) =>
208
+ pathLookup(name, "win32", { besideNode: true, ...deps });
209
+
210
+ /**
211
+ * Where `name` actually lives on PATH, as an absolute path, or null when
212
+ * nothing on PATH answers to it.
213
+ *
214
+ * This is the general form of shimPath above, and it exists because #433 asked
215
+ * for a third way to reach ccusage: the copy a user installed themselves. The
216
+ * deck's own error text has told people to "put ccusage on PATH yourself" since
217
+ * before there was anything that looked, so the choice was to either look or
218
+ * stop saying it — see getRunner in ccusage.mjs for which way that went.
219
+ *
220
+ * Writing it as ONE walk rather than a second one is the point. A PATH search
221
+ * on Windows is not a PATH search plus a note: `ccusage` there is `ccusage.cmd`
222
+ * or `ccusage.exe` and never the bare name, because PATHEXT is a shell's job
223
+ * and spawn is not a shell — which is the whole reason `candidates` exists, so
224
+ * that is what supplies the spellings here. And a `.cmd` found this way is
225
+ * returned as a FULL PATH, which is what keeps #456 fixed: launched by its bare
226
+ * name through cmd.exe, a shim computes `%~dp0` from the deck's working
227
+ * directory and goes hunting for its payload there.
228
+ *
229
+ * `besideNode` is the one thing a shim wants and a tool does not. npm ships
230
+ * beside node, so looking there first is right for `npm.cmd`; for anything else
231
+ * it would quietly overrule the order the user put their own PATH in, which is
232
+ * the one statement of preference they actually made.
233
+ *
234
+ * The check is existence, not executability. That is what `run`'s candidate
235
+ * loop effectively asks too — it spawns and moves on if the spawn fails — and
236
+ * an executable bit is not a thing Windows has. The residue is a directory on
237
+ * PATH that happens to be named after the tool; it would be resolved here and
238
+ * fail on spawn, with the failure naming the path, which is a better place to
239
+ * find out than a silent miss.
240
+ */
241
+ export function pathLookup(name, platform = process.platform, {
242
+ execPath = process.execPath,
243
+ pathEnv = process.env.PATH ?? process.env.Path ?? "",
244
+ exists = existsSync,
245
+ besideNode = false,
246
+ } = {}) {
247
+ // A name that already carries a directory needs no lookup, and re-rooting it
248
+ // would be a way to run something else entirely.
249
+ if (typeof name !== "string" || !name || /[\\/]/.test(name)) return null;
250
+ const win = platform === "win32";
251
+ const sep = win ? "\\" : "/";
252
+ const dirs = [];
253
+ if (besideNode) {
254
+ const beside = String(execPath ?? "").split(/[\\/]/).slice(0, -1).join(sep);
255
+ if (beside) dirs.push(beside);
256
+ }
257
+ // `;` on Windows, `:` everywhere else. Splitting on the wrong one is not a
258
+ // near miss: a POSIX PATH read with `;` is one enormous directory that
259
+ // exists nowhere, so every lookup would answer null and the feature would
260
+ // look like it had never been written.
261
+ for (const raw of String(pathEnv ?? "").split(win ? ";" : ":")) {
262
+ // A PATH entry may be quoted, and may end in a separator; neither is part
263
+ // of the directory, and both would produce a path nothing exists at.
264
+ const dir = raw.trim().replace(/^"|"$/g, "").replace(/[\\/]+$/, "");
265
+ if (dir) dirs.push(dir);
266
+ }
267
+ // On POSIX this is `[name]`, so the inner loop runs once and the cost is one
268
+ // stat per directory, exactly as before.
269
+ const spellings = candidates(name, platform);
270
+ for (const dir of dirs) {
271
+ for (const spelling of spellings) {
272
+ const full = `${dir}${sep}${spelling}`;
273
+ try {
274
+ if (exists(full)) return full;
275
+ } catch {
276
+ // An entry that cannot even be stat'ed — a disconnected network drive
277
+ // is the usual one — is a miss, not a reason to stop looking.
278
+ }
279
+ }
280
+ }
281
+ return null;
282
+ }
283
+
284
+ /**
285
+ * What to hand `spawn`/`execFile` for `file` and `args` on this platform, with
286
+ * the argument vector intact and no shell.
287
+ *
288
+ * The alternative every caller reaches for first — `shell: true`, because a
289
+ * .cmd cannot be spawned any other way — is the one thing that must not be
290
+ * used: Node joins the array into a command line with a single space and no
291
+ * per-argument quoting, so `--workspace C:\Users\John Smith\proj` arrives as
292
+ * two arguments and an `&` in a path ends the command early. Batch files go
293
+ * through cmd.exe with each argument quoted; everything else is spawned as
294
+ * given. The platform is a parameter so both branches can be tested.
295
+ *
296
+ * The one thing quoting cannot cover: cmd.exe expands `%VAR%` inside quotes
297
+ * too, and a command line has no escape for it. Every other metacharacter —
298
+ * `&`, `|`, `>`, `^`, `(` — is inert once quoted.
299
+ */
300
+ export const spawnSpec = (file, args, platform = process.platform) =>
301
+ isBatch(file, platform) ? viaCmd(file, args) : { file, args, opts: {} };
302
+
303
+ /**
304
+ * The same thing for ONE CANDIDATE SPELLING out of the list above — which on
305
+ * Windows means deciding the NAME the shim is launched under before deciding
306
+ * how, because those are not the same question.
307
+ *
308
+ * `spawnSpec` answers "how". This answers what #457 put in front of it. A batch
309
+ * candidate runs THROUGH cmd.exe (see viaCmd), and a `.cmd` shim locates its own
310
+ * payload relative to `%~dp0` — the drive and path of the command token cmd.exe
311
+ * was handed. A bare `claude.cmd` carries no directory at all, so `%~dp0` came
312
+ * out as the deck's WORKING DIRECTORY and the shim went hunting for its
313
+ * JavaScript under whatever folder the deck happened to be started from. #456
314
+ * proved exactly that for ccusage's `npm.cmd` and `npx.cmd`; it left exec.mjs's
315
+ * three runners alone, and they carry the shims the panels depend on — the
316
+ * `claude.cmd` behind the quota poll and the sign-in, and whatever `.cmd` a
317
+ * Python installer left for cswap. Same defect, same machine, wider blast
318
+ * radius.
319
+ *
320
+ * `?? raw` is the whole safety story and is not optional: when shimPath can see
321
+ * no layout it answers null, and the candidate stays the bare name today's code
322
+ * already uses, so nothing that works now can start failing. It is the same
323
+ * `?? name` ccusage.mjs and npx.mjs spell, so the repo has one rule rather than
324
+ * three. shimPath also refuses any name that already carries a directory, which
325
+ * is what keeps a caller's own absolute candidate — quotaClaudeBin's
326
+ * `%APPDATA%\npm\claude.cmd`, cswapCandidates' `~/.local/bin/cswap.exe` — from
327
+ * being re-rooted somewhere else entirely.
328
+ *
329
+ * `launch` comes back beside the spec because looksMissing has to be told the
330
+ * spelling cmd.exe was ACTUALLY GIVEN rather than the one the loop started
331
+ * from; its own header explains what breaks otherwise, and that coupling is the
332
+ * reason #456 stopped short of doing this.
333
+ *
334
+ * On POSIX `isBatch` is false, so no lookup happens, no filesystem is touched,
335
+ * `launch` is `raw`, and the spec is the object spawnSpec always returned —
336
+ * byte-identical, which is the point.
337
+ */
338
+ export function candidateSpec(raw, args, platform = process.platform, deps) {
339
+ const launch = isBatch(raw, platform) ? (shimPath(raw, deps) ?? raw) : raw;
340
+ return { ...spawnSpec(launch, args, platform), launch };
341
+ }