howtofixcli 0.7.24 → 0.7.26

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 (167) hide show
  1. package/dist/account-credential.js +136 -0
  2. package/dist/acp.js +234 -0
  3. package/dist/activity.js +124 -0
  4. package/dist/agent-defs.js +237 -0
  5. package/dist/agent-limiter.js +68 -0
  6. package/dist/agents.js +554 -0
  7. package/dist/anim.js +361 -0
  8. package/dist/args.js +250 -0
  9. package/dist/at-file.js +28 -0
  10. package/dist/brand-extension.js +87 -0
  11. package/dist/brand.js +76 -0
  12. package/dist/browser.js +58 -0
  13. package/dist/cache-report.js +139 -0
  14. package/dist/checkpoint.js +437 -0
  15. package/dist/claims.js +167 -0
  16. package/dist/cli-auth.js +142 -0
  17. package/dist/cli-checkin.js +121 -0
  18. package/dist/cli-credentials.js +254 -0
  19. package/dist/clipboard.js +149 -0
  20. package/dist/collapse.js +129 -0
  21. package/dist/commands.js +381 -0
  22. package/dist/compaction-guard.js +81 -0
  23. package/dist/connect-prompt.js +22 -0
  24. package/dist/context-extension.js +17 -0
  25. package/dist/dcp/commands.js +172 -0
  26. package/dist/dcp/compress-state.js +139 -0
  27. package/dist/dcp/compress.js +329 -0
  28. package/dist/dcp/dcp-extension.js +295 -0
  29. package/dist/dcp/dedup.js +93 -0
  30. package/dist/dcp/ids.js +189 -0
  31. package/dist/dcp/index.js +25 -0
  32. package/dist/dcp/middle-prune.js +76 -0
  33. package/dist/dcp/prompts.js +68 -0
  34. package/dist/dcp/protected-content.js +221 -0
  35. package/dist/dcp/protected.js +161 -0
  36. package/dist/dcp/prune.js +418 -0
  37. package/dist/dcp/state.js +176 -0
  38. package/dist/errors.js +63 -0
  39. package/dist/extensions.js +48 -0
  40. package/dist/fatal.js +35 -0
  41. package/dist/feed.js +498 -0
  42. package/dist/fs-atomic.js +54 -0
  43. package/dist/gateway-errors.js +107 -0
  44. package/dist/git.js +230 -0
  45. package/dist/goal-extension.js +88 -0
  46. package/dist/goal.js +222 -0
  47. package/dist/hooks-extension.js +176 -0
  48. package/dist/hooks.js +443 -0
  49. package/dist/howtofix-account.js +834 -0
  50. package/dist/hud-line.js +158 -0
  51. package/dist/hwid.js +130 -0
  52. package/dist/interrupt.js +60 -0
  53. package/dist/json-events.js +50 -0
  54. package/dist/level-style.js +112 -0
  55. package/dist/loop-guard.js +95 -0
  56. package/dist/lsp-tools.js +32 -0
  57. package/dist/lsp.js +429 -0
  58. package/dist/main.js +573 -98
  59. package/dist/mcp-config.js +85 -0
  60. package/dist/mcp-lifecycle.js +147 -0
  61. package/dist/model-catalog.js +217 -0
  62. package/dist/model-facts.js +35 -0
  63. package/dist/model-resolve.js +65 -0
  64. package/dist/notes.js +78 -0
  65. package/dist/oauth-login.js +568 -0
  66. package/dist/paths.js +61 -0
  67. package/dist/permissions.js +298 -0
  68. package/dist/plan-mode.js +363 -0
  69. package/dist/preset-command.js +227 -0
  70. package/dist/preset-extension.js +35 -0
  71. package/dist/preset-mcp.js +292 -0
  72. package/dist/presets/mcp/rea/preset.json +28 -0
  73. package/dist/presets/mcp/rea/prompt.md +5 -0
  74. package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
  75. package/dist/presets/mcp/remotion/preset.json +27 -0
  76. package/dist/presets/mcp/remotion/prompt.md +7 -0
  77. package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
  78. package/dist/presets.js +727 -0
  79. package/dist/prompt-files.js +103 -0
  80. package/dist/prompt-text.js +41 -0
  81. package/dist/proxy.js +137 -0
  82. package/dist/quality/ast.js +196 -0
  83. package/dist/quality/cognitive.js +83 -0
  84. package/dist/quality/cyclomatic.js +30 -0
  85. package/dist/quality/halstead.js +83 -0
  86. package/dist/quality/index.js +18 -0
  87. package/dist/quality/length.js +3 -0
  88. package/dist/quality/maintainability.js +15 -0
  89. package/dist/quality/nesting.js +57 -0
  90. package/dist/quality/report.js +803 -0
  91. package/dist/quality/smells.js +67 -0
  92. package/dist/quality-tool.js +63 -0
  93. package/dist/read-only-commands.js +597 -0
  94. package/dist/repo-map.js +292 -0
  95. package/dist/roblox-connect.js +125 -0
  96. package/dist/rpc.js +91 -0
  97. package/dist/rtk.js +193 -0
  98. package/dist/sandbox.js +105 -0
  99. package/dist/schema.js +214 -0
  100. package/dist/serve.js +50 -0
  101. package/dist/session-lifecycle.js +19 -0
  102. package/dist/session-retention.js +111 -0
  103. package/dist/session.js +460 -0
  104. package/dist/sessions.js +100 -0
  105. package/dist/settings.js +148 -0
  106. package/dist/skills.js +35 -0
  107. package/dist/slop-tool.js +96 -0
  108. package/dist/spend-cap.js +43 -0
  109. package/dist/subagent.js +171 -0
  110. package/dist/tar.js +146 -0
  111. package/dist/theme.js +79 -0
  112. package/dist/thinking.js +45 -0
  113. package/dist/todo.js +114 -0
  114. package/dist/tool-loadout.js +102 -0
  115. package/dist/tool-render.js +769 -0
  116. package/dist/tools.js +176 -0
  117. package/dist/ui.js +4931 -0
  118. package/dist/update.js +551 -0
  119. package/dist/usage-segment.js +74 -0
  120. package/dist/web-tools.js +46 -0
  121. package/dist/web.js +265 -0
  122. package/dist/welcome.js +179 -0
  123. package/dist/workflow-engine.js +246 -0
  124. package/dist/workflow-progress.js +446 -0
  125. package/dist/workflow-view.js +195 -0
  126. package/dist/workflow.js +349 -0
  127. package/package.json +62 -15
  128. package/dist/chunk-27TRKD65.js +0 -2
  129. package/dist/chunk-3FYCFLNK.js +0 -1
  130. package/dist/chunk-3LZR7OEU.js +0 -1
  131. package/dist/chunk-56SEAGAQ.js +0 -1
  132. package/dist/chunk-5AWTL34S.js +0 -1
  133. package/dist/chunk-75P2SCOT.js +0 -1
  134. package/dist/chunk-7CHDNTH6.js +0 -1
  135. package/dist/chunk-7PHRNXLN.js +0 -34
  136. package/dist/chunk-AGGIYXN7.js +0 -2
  137. package/dist/chunk-AUDMRGWF.js +0 -189
  138. package/dist/chunk-C5E3SFOQ.js +0 -2
  139. package/dist/chunk-CMTKCUL5.js +0 -1
  140. package/dist/chunk-DAPXGSGD.js +0 -12
  141. package/dist/chunk-DOD7MUGX.js +0 -1
  142. package/dist/chunk-FPXVDP32.js +0 -8
  143. package/dist/chunk-GXB7RZHH.js +0 -1
  144. package/dist/chunk-I7KVLBMF.js +0 -2
  145. package/dist/chunk-ILDDDLRC.js +0 -1
  146. package/dist/chunk-JFYVRWJI.js +0 -2
  147. package/dist/chunk-LAHVHRCS.js +0 -2
  148. package/dist/chunk-LSJC73DG.js +0 -1
  149. package/dist/chunk-MNDKS6UH.js +0 -6
  150. package/dist/chunk-N345KWWK.js +0 -1
  151. package/dist/chunk-N3J2J7EQ.js +0 -5
  152. package/dist/chunk-ONZLNFQ2.js +0 -3
  153. package/dist/chunk-ORJ2QZBG.js +0 -3
  154. package/dist/chunk-PCALBZOO.js +0 -2
  155. package/dist/chunk-QPHSHL7H.js +0 -2
  156. package/dist/chunk-TKROS3BE.js +0 -5
  157. package/dist/chunk-TSEKPGK4.js +0 -1
  158. package/dist/chunk-UP7ENLEL.js +0 -117
  159. package/dist/chunk-UTYFLLSM.js +0 -1
  160. package/dist/chunk-V3R4FYTJ.js +0 -1
  161. package/dist/chunk-V656XVXV.js +0 -8
  162. package/dist/chunk-WKUJ7CFO.js +0 -1
  163. package/dist/chunk-WOT6VMZA.js +0 -1
  164. package/dist/chunk-WUPU6LTD.js +0 -4
  165. package/dist/chunk-WX3V5P7U.js +0 -5
  166. package/dist/chunk-XW6LMQGX.js +0 -1
  167. package/dist/chunk-YW7NORH3.js +0 -2
package/dist/web.js ADDED
@@ -0,0 +1,265 @@
1
+ import { promises as dns } from "node:dns";
2
+ const PRIVATE_V4 = /^(127\.|10\.|192\.168\.|169\.254\.|0\.|221\.|222\.|223\.)/;
3
+ // 172.16.0.0/12 is the remaining RFC1918 block.
4
+ function isRfc1918(host) {
5
+ if (PRIVATE_V4.test(host))
6
+ return true;
7
+ const m = /^172\.(\d+)\./.exec(host);
8
+ if (!m)
9
+ return false;
10
+ const second = Number(m[1]);
11
+ return second >= 16 && second <= 31;
12
+ }
13
+ /**
14
+ * Carrier-grade NAT (100.64.0.0/10) and the benchmarking block (198.18.0.0/15)
15
+ * are special-use like RFC1918: not routable on the public internet, and both
16
+ * have shown up in SSRF bypass lists.
17
+ */
18
+ function isSpecialUseIpv4(host) {
19
+ const m = /^100\.(\d+)\.(\d+)\.(\d+)$/.exec(host);
20
+ if (m) {
21
+ const second = Number(m[1]);
22
+ return second >= 64 && second <= 127;
23
+ }
24
+ const b = /^198\.(\d+)\./.exec(host);
25
+ return b !== null && (Number(b[1]) === 18 || Number(b[1]) === 19);
26
+ }
27
+ /**
28
+ * Every address form that must never be fetched: RFC1918 plus special-use
29
+ * IPv4, and the IPv6 equivalents — loopback, unspecified, ULA (fc00::/7),
30
+ * link-local (fe80::/10), and IPv4-mapped (::ffff:a.b.c.d), which otherwise
31
+ * smuggles a private v4 address past a v6-shaped check. Used for both URL
32
+ * hostnames and resolved DNS answers, so the two layers cannot disagree.
33
+ */
34
+ export function isPrivateAddress(raw) {
35
+ const host = raw
36
+ .toLowerCase()
37
+ .replace(/^\[|\]$/g, "")
38
+ .split("%")[0];
39
+ if (host === "localhost" || host.endsWith(".localhost"))
40
+ return true;
41
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(host);
42
+ if (mapped) {
43
+ const v4 = mapped[1];
44
+ return isRfc1918(v4) || isSpecialUseIpv4(v4) || v4 === "0.0.0.0";
45
+ }
46
+ // The URL parser canonises ::ffff:a.b.c.d to the hex-pair form
47
+ // (::ffff:7f00:1); both forms carry a v4 address in the last 32 bits.
48
+ const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
49
+ if (mappedHex) {
50
+ const hi = Number.parseInt(mappedHex[1], 16);
51
+ const lo = Number.parseInt(mappedHex[2], 16);
52
+ const v4 = `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`;
53
+ return isRfc1918(v4) || isSpecialUseIpv4(v4) || v4 === "0.0.0.0";
54
+ }
55
+ if (host.includes(":")) {
56
+ if (host === "::1" || host === "::")
57
+ return true;
58
+ if (/^f[cd][0-9a-f]{2}:/.test(host))
59
+ return true; // fc00::/7 ULA
60
+ if (/^fe[89ab][0-9a-f]:/.test(host))
61
+ return true; // fe80::/10 link-local
62
+ return false;
63
+ }
64
+ if (host === "0.0.0.0")
65
+ return true;
66
+ return isRfc1918(host) || isSpecialUseIpv4(host);
67
+ }
68
+ export const MAX_FETCH_CHARS = 80_000;
69
+ export const SEARCH_LIMIT = 8;
70
+ export function isPublicHttpUrl(raw) {
71
+ let url;
72
+ try {
73
+ url = new URL(raw);
74
+ }
75
+ catch {
76
+ return { ok: false, reason: "not a URL" };
77
+ }
78
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
79
+ return { ok: false, reason: `refused protocol ${url.protocol}` };
80
+ }
81
+ const host = url.hostname.toLowerCase();
82
+ if (host === "localhost" || host.endsWith(".localhost") || host === "::1" || host === "[::1]") {
83
+ return { ok: false, reason: "refused loopback host" };
84
+ }
85
+ if (isPrivateAddress(host))
86
+ return { ok: false, reason: "refused private address" };
87
+ return { ok: true, url };
88
+ }
89
+ export function htmlToText(html) {
90
+ const without = html
91
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
92
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
93
+ .replace(/<noscript[\s\S]*?<\/noscript>/gi, " ")
94
+ .replace(/<[^>]+>/g, " ")
95
+ .replace(/&nbsp;/g, " ")
96
+ .replace(/&amp;/g, "&")
97
+ .replace(/&lt;/g, "<")
98
+ .replace(/&gt;/g, ">")
99
+ .replace(/&quot;/g, '"')
100
+ .replace(/&#39;/g, "'")
101
+ .replace(/\s+/g, " ")
102
+ .trim();
103
+ return without.length > MAX_FETCH_CHARS ? without.slice(0, MAX_FETCH_CHARS) : without;
104
+ }
105
+ export function parseDuckDuckGo(html) {
106
+ const hits = [];
107
+ const re = /class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
108
+ let match;
109
+ while ((match = re.exec(html)) && hits.length < SEARCH_LIMIT) {
110
+ const href = match[1] ?? "";
111
+ const title = htmlToText(match[2] ?? "");
112
+ let url = href;
113
+ const uddg = /[?&]uddg=([^&]+)/.exec(href);
114
+ if (uddg) {
115
+ try {
116
+ url = decodeURIComponent(uddg[1]);
117
+ }
118
+ catch {
119
+ url = href;
120
+ }
121
+ }
122
+ else if (href.startsWith("//")) {
123
+ url = `https:${href}`;
124
+ }
125
+ const allowed = isPublicHttpUrl(url);
126
+ if (!allowed.ok || !title)
127
+ continue;
128
+ hits.push({ title, url: allowed.url.toString(), snippet: "" });
129
+ }
130
+ return hits;
131
+ }
132
+ const DEFAULT_UA = "HowToFix (https://howtofix.id)";
133
+ export async function searchWeb(query, options = {}) {
134
+ const q = query.trim();
135
+ if (!q)
136
+ return { ok: false, error: "empty query" };
137
+ const fetchFn = options.fetchFn ?? fetch;
138
+ const timeout = options.timeoutMs ?? 15_000;
139
+ const headers = { "user-agent": options.userAgent ?? DEFAULT_UA, accept: "text/html, application/json" };
140
+ try {
141
+ if (options.braveKey) {
142
+ const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(q)}&count=${SEARCH_LIMIT}`;
143
+ const response = await fetchFn(url, {
144
+ headers: { ...headers, accept: "application/json", "x-subscription-token": options.braveKey },
145
+ signal: AbortSignal.timeout(timeout),
146
+ });
147
+ if (!response.ok)
148
+ return { ok: false, error: `brave ${response.status}` };
149
+ const hits = parseBrave(await response.json());
150
+ return { ok: true, hits, provider: "brave" };
151
+ }
152
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(q)}`;
153
+ const response = await fetchFn(url, { headers, signal: AbortSignal.timeout(timeout) });
154
+ if (!response.ok)
155
+ return { ok: false, error: `search ${response.status}` };
156
+ const hits = parseDuckDuckGo(await response.text());
157
+ return { ok: true, hits, provider: "ddg" };
158
+ }
159
+ catch (cause) {
160
+ return { ok: false, error: cause instanceof Error ? cause.message : String(cause) };
161
+ }
162
+ }
163
+ export function parseBrave(payload) {
164
+ let rows = [];
165
+ if (payload && typeof payload === "object" && "web" in payload) {
166
+ const web = payload.web;
167
+ if (web && typeof web === "object" && "results" in web && Array.isArray(web.results)) {
168
+ rows = web.results;
169
+ }
170
+ }
171
+ const hits = [];
172
+ for (const row of rows) {
173
+ if (!row || typeof row !== "object" || !("url" in row) || !("title" in row))
174
+ continue;
175
+ const url = row.url;
176
+ const title = row.title;
177
+ if (typeof url !== "string" || typeof title !== "string")
178
+ continue;
179
+ const allowed = isPublicHttpUrl(url);
180
+ if (!allowed.ok)
181
+ continue;
182
+ const description = "description" in row ? row.description : undefined;
183
+ hits.push({
184
+ title,
185
+ url: allowed.url.toString(),
186
+ snippet: typeof description === "string" ? description : "",
187
+ });
188
+ if (hits.length >= SEARCH_LIMIT)
189
+ break;
190
+ }
191
+ return hits;
192
+ }
193
+ const MAX_REDIRECTS = 5;
194
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
195
+ /**
196
+ * The hostname half of the SSRF check: `isPublicHttpUrl` only sees the name,
197
+ * and a public name can resolve anywhere. Every answer must be public — one
198
+ * private record poisons the fetch, because the connection goes wherever DNS
199
+ * says. IP literals are skipped here: they were already judged directly.
200
+ */
201
+ async function resolvePublicHost(hostname, lookup) {
202
+ const bare = hostname.toLowerCase().replace(/^\[|\]$/g, "");
203
+ if (bare.includes(":") || /^[\d.]+$/.test(bare))
204
+ return { ok: true };
205
+ let addresses;
206
+ try {
207
+ addresses = await lookup(bare);
208
+ }
209
+ catch (cause) {
210
+ return { ok: false, error: `could not resolve ${hostname}: ${cause instanceof Error ? cause.message : String(cause)}` };
211
+ }
212
+ if (addresses.length === 0)
213
+ return { ok: false, error: `could not resolve ${hostname}` };
214
+ const bad = addresses.find((address) => isPrivateAddress(address));
215
+ if (bad !== undefined)
216
+ return { ok: false, error: `refused ${hostname}: resolves to private address ${bad}` };
217
+ return { ok: true };
218
+ }
219
+ export async function fetchUrl(raw, options = {}) {
220
+ const allowed = isPublicHttpUrl(raw);
221
+ if (!allowed.ok)
222
+ return { ok: false, error: allowed.reason };
223
+ const fetchFn = options.fetchFn ?? fetch;
224
+ const lookup = options.lookup ?? ((hostname) => dns.lookup(hostname, { all: true }).then((rows) => rows.map((row) => row.address)));
225
+ const signal = AbortSignal.timeout(options.timeoutMs ?? 20_000);
226
+ const headers = { "user-agent": options.userAgent ?? DEFAULT_UA, accept: "text/html, text/plain, application/json" };
227
+ try {
228
+ // Redirects are followed by hand, not by fetch: every hop passes the same
229
+ // URL and DNS checks as the first URL. `redirect: "follow"` would take the
230
+ // connection wherever a 302 pointed — the classic SSRF bypass of a
231
+ // one-time check.
232
+ let current = allowed.url;
233
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
234
+ const hopCheck = isPublicHttpUrl(current.toString());
235
+ if (!hopCheck.ok)
236
+ return { ok: false, error: `redirect refused: ${hopCheck.reason}` };
237
+ const resolved = await resolvePublicHost(hopCheck.url.hostname, lookup);
238
+ if (!resolved.ok)
239
+ return { ok: false, error: resolved.error };
240
+ const response = await fetchFn(hopCheck.url, { headers, signal, redirect: "manual" });
241
+ if (REDIRECT_STATUSES.has(response.status)) {
242
+ const location = response.headers.get("location");
243
+ if (!location)
244
+ return { ok: false, error: `HTTP ${response.status} without a location` };
245
+ try {
246
+ current = new URL(location, hopCheck.url);
247
+ }
248
+ catch {
249
+ return { ok: false, error: "redirect location is not a URL" };
250
+ }
251
+ continue;
252
+ }
253
+ if (!response.ok)
254
+ return { ok: false, error: `HTTP ${response.status}` };
255
+ const contentType = response.headers.get("content-type") ?? "";
256
+ const body = await response.text();
257
+ const text = /html/i.test(contentType) ? htmlToText(body) : body.slice(0, MAX_FETCH_CHARS);
258
+ return { ok: true, url: current.toString(), contentType, text };
259
+ }
260
+ return { ok: false, error: `too many redirects (max ${MAX_REDIRECTS})` };
261
+ }
262
+ catch (cause) {
263
+ return { ok: false, error: cause instanceof Error ? cause.message : String(cause) };
264
+ }
265
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The screen a first-time user meets.
3
+ *
4
+ * Every other CLI in this space prints a line and drops you at an empty prompt,
5
+ * which tells a new user nothing about what the tool is or what to do next. This
6
+ * is the deliberate alternative: on the very first interactive start, before the
7
+ * editor takes over, welcome the user, show the handful of commands that actually
8
+ * matter, and offer to sign in right there.
9
+ *
10
+ * Three rules shape everything below.
11
+ *
12
+ * 1. It runs ONCE. Marking the greeting as seen is the first thing that happens,
13
+ * before the user answers anything, so no exit — picking an option, pressing
14
+ * Escape, or the terminal going away — can leave it to appear again. It is a
15
+ * greeting, not a gate.
16
+ * 2. It never invents a second sign-in path. "Sign in" runs the same `/connect`
17
+ * slash command the user would type, so there is one flow to maintain: when
18
+ * `connect` changes, this screen changes with it.
19
+ * 3. It is interactive only. `--print` never reaches here, so piping the CLI into
20
+ * a script cannot stall on a question nobody is there to answer.
21
+ */
22
+ import { AGENT_DIR } from "./paths.js";
23
+ import { APP_TITLE, APP_VERSION } from "./brand.js";
24
+ import { readWorkbenchSettings, markWelcomeSeen } from "./settings.js";
25
+ /**
26
+ * What the greeting teaches, in the order a new user needs it.
27
+ *
28
+ * One list, filtered per state, rather than a branch per state: the signed-out
29
+ * screen drops nothing and the signed-in screen drops `/connect`, so the two can
30
+ * never drift into disagreeing about what `/help` means.
31
+ */
32
+ const CHEAT_SHEET = [
33
+ ["/connect", "sign in with your howtofix.id email:password — no browser needed"],
34
+ ["/model", "choose which model answers"],
35
+ ["/help", "list every command"],
36
+ ["/quit", "leave"],
37
+ ];
38
+ /**
39
+ * The wordmark, in two alphabets.
40
+ *
41
+ * The block version is the one to prefer: solid and box-drawing characters read as a
42
+ * logo rather than as text. But every character in it is East Asian *Ambiguous* —
43
+ * measured, all seven of them — so a terminal in a CJK locale or with "wide" set
44
+ * gives each glyph two columns instead of one. The rows then wrap, and a logo becomes
45
+ * a smear of half-drawn boxes.
46
+ *
47
+ * The ASCII version is 23 columns of pure printable ASCII, so it is one column per
48
+ * character everywhere: any locale, any code page, including terminals that are not
49
+ * UTF-8 at all (a `cmd.exe` on cp936 or a shell with `LANG=C` turns the block version
50
+ * into mojibake, because every character is above U+007F).
51
+ *
52
+ * Picked per render, not per build, because the terminal that runs an npm-installed
53
+ * CLI is whatever the user happens to have — see `terminalTakesWideBox()`. A real
54
+ * console on Windows is excluded deliberately: it is the one environment where Per's
55
+ * size check holds, and the block dial's Unicode rendering is also a defect there.
56
+ */
57
+ const WORDMARK_BLOCKS = [
58
+ " ██╗ ██╗██╗ ██╗███████╗",
59
+ " ██║ ██║╚██╗██╔╝██╔════╝",
60
+ " ███████║ ╚███╔╝ █████╗ ",
61
+ " ██╔══██║ ██╔██╗ ██╔══╝ ",
62
+ " ██║ ██║██╔╝ ██╗██║ ",
63
+ " ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ",
64
+ ];
65
+ const WORDMARK_ASCII = [
66
+ " _ _ _____ ",
67
+ "| | | | __ __ | ___|",
68
+ "| |_| | \\ \\/ / | |_ ",
69
+ "| _ | > < | _| ",
70
+ "|_| |_| /_/\\_\\ |_| ",
71
+ ];
72
+ /**
73
+ * Whether this terminal gives a box-drawing or block character one column.
74
+ *
75
+ * Two independent things have to hold, and each is checked where it can be known:
76
+ *
77
+ * - **Unicode is usable.** The block set is all above U+007F, so a non-UTF-8 locale
78
+ * renders it as mojibake. `LANG`/`LC_ALL`/`LC_CTYPE` say so directly and are the
79
+ * only reliable signal — Windows keeps its console code page in a syscall rather
80
+ * than an environment variable, so a console there answers "no" by default.
81
+ * - **The width is one column.** Every glyph in the set is East Asian *Ambiguous*,
82
+ * and an ambiguous character is two columns in a CJK locale or under the "wide"
83
+ * setting; `LC_ALL=ja_JP.UTF-8` is the case a real user hits.
84
+ */
85
+ function terminalTakesWideBox() {
86
+ // A native console is the case where Per's rule holds — an ambiguous character is
87
+ // one column — but it also mangles the block dial this CLI draws, so on Windows the
88
+ // logo gets a safe shape and the HUD is left to guard itself.
89
+ if (process.platform === "win32")
90
+ return false;
91
+ const locale = (process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || "").toLowerCase();
92
+ // No locale at all: the byte-oriented default. Assume the narrow ASCII alphabet.
93
+ if (locale === "" || locale === "c" || locale === "posix")
94
+ return false;
95
+ if (!locale.includes("utf-8") && !locale.includes("utf8"))
96
+ return false;
97
+ // An "ambiguous is wide" locale: the block set would double in width.
98
+ const cjk = /ja|ko|zh|_kr|_jp|_cn|_tw|_hk/.test(locale);
99
+ return !cjk;
100
+ }
101
+ /** The wordmark this terminal can draw. */
102
+ function wordmark() {
103
+ if (!terminalTakesWideBox())
104
+ return WORDMARK_ASCII;
105
+ return WORDMARK_BLOCKS;
106
+ }
107
+ /** Where the "what's new" option sends a reader. */
108
+ const RELEASE_NOTES_URL = "https://howtofix.id/cli";
109
+ /**
110
+ * Shrink a path for display: home becomes `~`, and anything before the last two
111
+ * segments is elided.
112
+ *
113
+ * The greeting has one row to say where the user is, and a deep worktree path
114
+ * would push everything else off it. The tail identifies the directory, so the
115
+ * front is what gets dropped.
116
+ */
117
+ function shortenPath(cwd) {
118
+ const home = process.env.HOME ?? "";
119
+ const display = home !== "" && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
120
+ const parts = display.split("/").filter(Boolean);
121
+ return parts.length > 3 ? `…/${parts.slice(-2).join("/")}` : display;
122
+ }
123
+ /** The body of the greeting, as one block of text the feed already knows how to draw. */
124
+ function welcomeBody(options) {
125
+ const cheat = options.signedIn ? CHEAT_SHEET.filter(([command]) => command !== "/connect") : CHEAT_SHEET;
126
+ return [
127
+ ...wordmark(),
128
+ "",
129
+ `${APP_TITLE} ${APP_VERSION} — a terminal coding agent. Describe the change you want and it reads, edits and runs the code in front of you.`,
130
+ "",
131
+ ` working in ${shortenPath(options.cwd)}`,
132
+ ...(options.note ? [` session ${options.note}`] : []),
133
+ "",
134
+ ...(options.signedIn ? [" already signed in to howtofix.id", ""] : []),
135
+ ...cheat.map(([command, summary]) => ` ${command.padEnd(11)}${summary}`),
136
+ "",
137
+ ' Or just start typing. Every message goes to the model; anything beginning with "/" stays local.',
138
+ ].join("\n");
139
+ }
140
+ /** The choices offered when the user has no credential yet. */
141
+ function firstRunChoices() {
142
+ return [
143
+ { value: "connect", label: "Sign in to howtofix.id", description: "opens the browser, or paste a key from the panel" },
144
+ { value: "ready", label: "I have a key — just give me the prompt", description: "drops you at the prompt; run /connect whenever you like" },
145
+ { value: "release", label: "What's in this release", description: `opens the ${APP_VERSION} notes in your browser` },
146
+ ];
147
+ }
148
+ /**
149
+ * Show the welcome screen when — and only when — this is the user's first
150
+ * interactive run.
151
+ *
152
+ * Resolves once the screen is out of the way, so the caller can await it before
153
+ * doing anything that writes to the same rows. Resolves immediately when the
154
+ * screen has already been shown, which keeps the call site free of a branch.
155
+ */
156
+ export async function maybeShowWelcome(options, choose, note) {
157
+ // A settings file that cannot be read falls back to the defaults, and
158
+ // `welcomeSeen` defaults to false — so a broken file shows the greeting again
159
+ // rather than silently swallowing it. That is the harmless direction to fail.
160
+ if (readWorkbenchSettings(AGENT_DIR).welcomeSeen)
161
+ return;
162
+ // Claimed before the user is asked anything, so every exit from here — an
163
+ // answer, an Escape, or a terminal that closes — leaves the flag written.
164
+ markWelcomeSeen(AGENT_DIR);
165
+ note(`welcome to ${APP_TITLE}`, welcomeBody(options));
166
+ // Already signed in: nothing to decide, so stop at the greeting rather than
167
+ // asking a question whose answer is already on disk.
168
+ if (options.signedIn)
169
+ return;
170
+ await new Promise((settle) => {
171
+ choose("First run — how do you want to begin?", firstRunChoices(), (value) => {
172
+ if (value === "connect")
173
+ options.runSlash("/connect");
174
+ if (value === "release")
175
+ options.openExternal(RELEASE_NOTES_URL);
176
+ settle();
177
+ }, () => settle());
178
+ });
179
+ }