happy-stacks 0.6.11 → 0.6.13

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 (66) hide show
  1. package/docs/commit-audits/happy/_tools/generate-plans.mjs +453 -0
  2. package/docs/commit-audits/happy/_tools/generate-pr-assignment.mjs +430 -0
  3. package/docs/commit-audits/happy/_tools/init-pr-assignment-working.mjs +107 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +1849 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +747 -1
  6. package/docs/commit-audits/happy/leeroy-wip.commit-index.json +11740 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-index.tsv +252 -0
  8. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +18 -11
  9. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1297 -140
  10. package/docs/commit-audits/happy/leeroy-wip.maintainers-overview.draft.md +448 -0
  11. package/docs/commit-audits/happy/leeroy-wip.pr-assignment.draft.tsv +252 -0
  12. package/docs/commit-audits/happy/leeroy-wip.pr-assignment.working.tsv +288 -0
  13. package/docs/commit-audits/happy/leeroy-wip.pr-catalog.draft.md +245 -0
  14. package/docs/commit-audits/happy/leeroy-wip.pr-stack-plan.draft.md +350 -0
  15. package/docs/commit-audits/happy/leeroy-wip.rewrite-deferred-fragments.tsv +65 -0
  16. package/docs/commit-audits/happy/leeroy-wip.rewrite-ledger.tsv +56 -0
  17. package/docs/commit-audits/happy/leeroy-wip.rewrite-process.md +240 -0
  18. package/docs/commit-audits/happy/leeroy-wip.rewrite-status.tsv +39 -0
  19. package/docs/commit-audits/happy/leeroy-wip.split-plan.draft.md +93 -0
  20. package/docs/commit-audits/happy/leeroy-wip.topic-buckets.md +76 -0
  21. package/docs/commit-audits/happy/pr-desc.extraction-ledger.tsv +279 -0
  22. package/docs/commit-audits/happy/pr-desc.original.md +0 -0
  23. package/docs/commit-audits/happy/pr-desc.post-audit-extraction-ledger.tsv +54 -0
  24. package/docs/commit-audits/happy/pr-desc.working-document.md +536 -0
  25. package/docs/happy-development.md +18 -1
  26. package/docs/isolated-linux-vm.md +23 -1
  27. package/docs/stacks.md +21 -1
  28. package/package.json +1 -1
  29. package/scripts/auth.mjs +46 -8
  30. package/scripts/daemon.mjs +44 -21
  31. package/scripts/doctor.mjs +2 -2
  32. package/scripts/doctor_cmd.test.mjs +67 -0
  33. package/scripts/happy.mjs +18 -5
  34. package/scripts/import.mjs +19 -13
  35. package/scripts/monorepo.mjs +414 -23
  36. package/scripts/monorepo_port.test.mjs +136 -0
  37. package/scripts/provision/linux-ubuntu-review-pr.sh +5 -1
  38. package/scripts/provision/macos-lima-happy-vm.sh +34 -2
  39. package/scripts/review.mjs +347 -124
  40. package/scripts/review_pr.mjs +78 -2
  41. package/scripts/run.mjs +2 -1
  42. package/scripts/stack.mjs +265 -19
  43. package/scripts/stack_daemon_cmd.test.mjs +196 -0
  44. package/scripts/stack_happy_cmd.test.mjs +103 -0
  45. package/scripts/utils/cli/prereqs.mjs +12 -1
  46. package/scripts/utils/cli/wizard.mjs +19 -1
  47. package/scripts/utils/dev/daemon.mjs +3 -1
  48. package/scripts/utils/llm/assist.mjs +64 -31
  49. package/scripts/utils/llm/happys_runner.mjs +60 -0
  50. package/scripts/utils/llm/tools.mjs +3 -3
  51. package/scripts/utils/proc/pm.mjs +1 -1
  52. package/scripts/utils/review/detached_worktree.mjs +61 -0
  53. package/scripts/utils/review/detached_worktree.test.mjs +62 -0
  54. package/scripts/utils/review/findings.mjs +133 -20
  55. package/scripts/utils/review/findings.test.mjs +88 -1
  56. package/scripts/utils/review/runners/augment.mjs +71 -0
  57. package/scripts/utils/review/runners/augment.test.mjs +42 -0
  58. package/scripts/utils/review/runners/coderabbit.mjs +54 -10
  59. package/scripts/utils/review/runners/coderabbit.test.mjs +15 -48
  60. package/scripts/utils/review/sliced_runner.mjs +39 -0
  61. package/scripts/utils/review/sliced_runner.test.mjs +47 -0
  62. package/scripts/utils/review/tool_home_seed.mjs +99 -0
  63. package/scripts/utils/review/tool_home_seed.test.mjs +113 -0
  64. package/scripts/utils/stack/cli_identities.mjs +29 -0
  65. package/scripts/utils/stack/startup.mjs +45 -7
  66. package/scripts/worktrees.mjs +8 -5
@@ -18,6 +18,14 @@ function runNode(args, { cwd, env }) {
18
18
  });
19
19
  }
20
20
 
21
+ async function writeDummyAuth({ cliHomeDir }) {
22
+ // For these tests, we don't care about the auth format—only that credentials exist.
23
+ // Happy Stacks will short-circuit daemon start when access.key is missing.
24
+ await mkdir(cliHomeDir, { recursive: true });
25
+ await writeFile(join(cliHomeDir, 'access.key'), 'dummy\n', 'utf-8');
26
+ await writeFile(join(cliHomeDir, 'settings.json'), JSON.stringify({ machineId: 'test-machine' }) + '\n', 'utf-8');
27
+ }
28
+
21
29
  async function writeStubHappyCli({ cliDir }) {
22
30
  await mkdir(join(cliDir, 'bin'), { recursive: true });
23
31
  await mkdir(join(cliDir, 'dist'), { recursive: true });
@@ -107,6 +115,7 @@ test('happys stack daemon <name> restart restarts only the daemon', async () =>
107
115
  const cliDir = await writeStubHappyCli({ cliDir: join(tmp, 'stub-happy-cli') });
108
116
  const stackCliHome = join(storageDir, stackName, 'cli');
109
117
  await mkdir(stackCliHome, { recursive: true });
118
+ await writeDummyAuth({ cliHomeDir: stackCliHome });
110
119
 
111
120
  const envPath = join(storageDir, stackName, 'env');
112
121
  await mkdir(dirname(envPath), { recursive: true });
@@ -162,3 +171,190 @@ test('happys stack daemon <name> restart restarts only the daemon', async () =>
162
171
  await rm(tmp, { recursive: true, force: true });
163
172
  });
164
173
 
174
+ test('happys stack <name> daemon start works (stack name first)', async () => {
175
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
176
+ const rootDir = dirname(scriptsDir);
177
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-daemon-name-first-'));
178
+
179
+ const storageDir = join(tmp, 'storage');
180
+ const homeDir = join(tmp, 'home');
181
+ const stackName = 'exp-test';
182
+
183
+ const cliDir = await writeStubHappyCli({ cliDir: join(tmp, 'stub-happy-cli') });
184
+ const stackCliHome = join(storageDir, stackName, 'cli');
185
+ await mkdir(stackCliHome, { recursive: true });
186
+ await writeDummyAuth({ cliHomeDir: stackCliHome });
187
+
188
+ const envPath = join(storageDir, stackName, 'env');
189
+ await mkdir(dirname(envPath), { recursive: true });
190
+ await writeFile(
191
+ envPath,
192
+ [
193
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliDir}`,
194
+ `HAPPY_STACKS_CLI_HOME_DIR=${stackCliHome}`,
195
+ `HAPPY_STACKS_SERVER_PORT=4101`,
196
+ '',
197
+ ].join('\n'),
198
+ 'utf-8'
199
+ );
200
+
201
+ const baseEnv = {
202
+ ...process.env,
203
+ HAPPY_STACKS_HOME_DIR: homeDir,
204
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
205
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
206
+ };
207
+
208
+ const startRes = await runNode([join(rootDir, 'bin', 'happys.mjs'), 'stack', stackName, 'daemon', 'start', '--json'], {
209
+ cwd: rootDir,
210
+ env: baseEnv,
211
+ });
212
+ assert.equal(
213
+ startRes.code,
214
+ 0,
215
+ `expected start exit 0, got ${startRes.code}\nstdout:\n${startRes.stdout}\nstderr:\n${startRes.stderr}`
216
+ );
217
+ assert.ok(!startRes.stdout.includes('[stack] unknown command'), `unexpected unknown command output\n${startRes.stdout}`);
218
+
219
+ const logPath = join(stackCliHome, 'stub-daemon.log');
220
+ const logText = await (await import('node:fs/promises')).readFile(logPath, 'utf-8').then(String);
221
+ assert.ok(logText.includes('start'), `expected stub daemon start to be called\n${logText}`);
222
+
223
+ await runNode([join(rootDir, 'bin', 'happys.mjs'), 'stack', stackName, 'daemon', 'stop', '--json'], { cwd: rootDir, env: baseEnv });
224
+ await rm(tmp, { recursive: true, force: true });
225
+ });
226
+
227
+ test('happys stack daemon <name> start/stop with --identity uses an isolated cli home dir', async () => {
228
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
229
+ const rootDir = dirname(scriptsDir);
230
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-daemon-identity-'));
231
+
232
+ const storageDir = join(tmp, 'storage');
233
+ const homeDir = join(tmp, 'home');
234
+ const stackName = 'exp-test';
235
+ const identity = 'account-b';
236
+
237
+ const cliDir = await writeStubHappyCli({ cliDir: join(tmp, 'stub-happy-cli') });
238
+ const stackCliHome = join(storageDir, stackName, 'cli');
239
+ await mkdir(stackCliHome, { recursive: true });
240
+ const identityHome = join(storageDir, stackName, 'cli-identities', identity);
241
+ await writeDummyAuth({ cliHomeDir: identityHome });
242
+
243
+ const envPath = join(storageDir, stackName, 'env');
244
+ await mkdir(dirname(envPath), { recursive: true });
245
+ await writeFile(
246
+ envPath,
247
+ [
248
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliDir}`,
249
+ `HAPPY_STACKS_CLI_HOME_DIR=${stackCliHome}`,
250
+ `HAPPY_STACKS_SERVER_PORT=4101`,
251
+ '',
252
+ ].join('\n'),
253
+ 'utf-8'
254
+ );
255
+
256
+ const baseEnv = {
257
+ ...process.env,
258
+ HAPPY_STACKS_HOME_DIR: homeDir,
259
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
260
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
261
+ };
262
+
263
+ const startRes = await runNode(
264
+ [join(rootDir, 'bin', 'happys.mjs'), 'stack', 'daemon', stackName, 'start', `--identity=${identity}`, '--json'],
265
+ { cwd: rootDir, env: baseEnv }
266
+ );
267
+ assert.equal(
268
+ startRes.code,
269
+ 0,
270
+ `expected start exit 0, got ${startRes.code}\nstdout:\n${startRes.stdout}\nstderr:\n${startRes.stderr}`
271
+ );
272
+
273
+ const logPath = join(identityHome, 'stub-daemon.log');
274
+ const logText = await (await import('node:fs/promises')).readFile(logPath, 'utf-8').then(String);
275
+ assert.ok(logText.includes('start'), `expected stub daemon start to be called in identity home\n${logText}`);
276
+
277
+ const stopRes = await runNode(
278
+ [join(rootDir, 'bin', 'happys.mjs'), 'stack', 'daemon', stackName, 'stop', `--identity=${identity}`, '--json'],
279
+ { cwd: rootDir, env: baseEnv }
280
+ );
281
+ assert.equal(
282
+ stopRes.code,
283
+ 0,
284
+ `expected stop exit 0, got ${stopRes.code}\nstdout:\n${stopRes.stdout}\nstderr:\n${stopRes.stderr}`
285
+ );
286
+
287
+ const logTextAfter = await (await import('node:fs/promises')).readFile(logPath, 'utf-8').then(String);
288
+ assert.ok(logTextAfter.includes('stop'), `expected stub daemon stop to be called for identity\n${logTextAfter}`);
289
+
290
+ await rm(tmp, { recursive: true, force: true });
291
+ });
292
+
293
+ test('happys stack auth <name> login --identity=<name> --print prints identity-scoped HAPPY_HOME_DIR', async () => {
294
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
295
+ const rootDir = dirname(scriptsDir);
296
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-auth-identity-'));
297
+
298
+ const storageDir = join(tmp, 'storage');
299
+ const homeDir = join(tmp, 'home');
300
+ const stackName = 'exp-test';
301
+ const identity = 'account-b';
302
+
303
+ const cliDir = await writeStubHappyCli({ cliDir: join(tmp, 'stub-happy-cli') });
304
+ const stackCliHome = join(storageDir, stackName, 'cli');
305
+ await mkdir(stackCliHome, { recursive: true });
306
+ await writeDummyAuth({ cliHomeDir: stackCliHome });
307
+
308
+ const envPath = join(storageDir, stackName, 'env');
309
+ await mkdir(dirname(envPath), { recursive: true });
310
+ await writeFile(
311
+ envPath,
312
+ [
313
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliDir}`,
314
+ `HAPPY_STACKS_CLI_HOME_DIR=${stackCliHome}`,
315
+ `HAPPY_STACKS_SERVER_PORT=4101`,
316
+ '',
317
+ ].join('\n'),
318
+ 'utf-8'
319
+ );
320
+
321
+ const baseEnv = {
322
+ ...process.env,
323
+ HAPPY_STACKS_HOME_DIR: homeDir,
324
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
325
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
326
+ };
327
+
328
+ const res = await runNode(
329
+ [
330
+ join(rootDir, 'bin', 'happys.mjs'),
331
+ 'stack',
332
+ 'auth',
333
+ stackName,
334
+ 'login',
335
+ `--identity=${identity}`,
336
+ '--no-open',
337
+ '--print',
338
+ '--json',
339
+ ],
340
+ { cwd: rootDir, env: baseEnv }
341
+ );
342
+ assert.equal(
343
+ res.code,
344
+ 0,
345
+ `expected auth login --print exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`
346
+ );
347
+
348
+ const parsed = JSON.parse(res.stdout.trim());
349
+ assert.equal(parsed?.cliIdentity, identity);
350
+ assert.ok(
351
+ parsed?.cmd?.includes(`HAPPY_HOME_DIR="${join(storageDir, stackName, 'cli-identities', identity)}"`),
352
+ `expected printed cmd to include identity home dir\n${parsed?.cmd}`
353
+ );
354
+ assert.ok(
355
+ parsed?.cmd?.includes('--no-open'),
356
+ `expected printed cmd to include --no-open\n${parsed?.cmd}`
357
+ );
358
+
359
+ await rm(tmp, { recursive: true, force: true });
360
+ });
@@ -38,6 +38,21 @@ async function writeStubHappyCli({ root, message }) {
38
38
  return cliDir;
39
39
  }
40
40
 
41
+ async function writeFailingStubHappyCli({ root, errorMessage }) {
42
+ const cliDir = join(root, 'happy-cli');
43
+ await mkdir(join(cliDir, 'dist'), { recursive: true });
44
+ await writeFile(
45
+ join(cliDir, 'dist', 'index.mjs'),
46
+ [
47
+ `console.error(${JSON.stringify(errorMessage)});`,
48
+ `process.exit(1);`,
49
+ '',
50
+ ].join('\n'),
51
+ 'utf-8'
52
+ );
53
+ return cliDir;
54
+ }
55
+
41
56
  test('happys stack happy <name> runs happy-cli under that stack env', async () => {
42
57
  const scriptsDir = dirname(fileURLToPath(import.meta.url));
43
58
  const rootDir = dirname(scriptsDir);
@@ -82,6 +97,53 @@ test('happys stack happy <name> runs happy-cli under that stack env', async () =
82
97
  assert.equal(out.serverUrl, 'http://127.0.0.1:3999');
83
98
  });
84
99
 
100
+ test('happys stack happy <name> --identity=<name> uses identity-scoped HAPPY_HOME_DIR', async () => {
101
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
102
+ const rootDir = dirname(scriptsDir);
103
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-happy-identity-'));
104
+
105
+ const storageDir = join(tmp, 'storage');
106
+ const homeDir = join(tmp, 'home');
107
+ const stackName = 'exp-test';
108
+ const identity = 'account-a';
109
+
110
+ const stubRoot = join(tmp, 'stub-components');
111
+ const cliDir = await writeStubHappyCli({ root: stubRoot, message: 'identity' });
112
+
113
+ const stackCliHome = join(storageDir, stackName, 'cli');
114
+ const envPath = join(storageDir, stackName, 'env');
115
+ await mkdir(dirname(envPath), { recursive: true });
116
+ await writeFile(
117
+ envPath,
118
+ [
119
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliDir}`,
120
+ `HAPPY_STACKS_CLI_HOME_DIR=${stackCliHome}`,
121
+ `HAPPY_STACKS_SERVER_PORT=3999`,
122
+ '',
123
+ ].join('\n'),
124
+ 'utf-8'
125
+ );
126
+
127
+ const baseEnv = {
128
+ ...process.env,
129
+ HAPPY_STACKS_HOME_DIR: homeDir,
130
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
131
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
132
+ };
133
+
134
+ const res = await runNode(
135
+ [join(rootDir, 'bin', 'happys.mjs'), 'stack', 'happy', stackName, `--identity=${identity}`],
136
+ { cwd: rootDir, env: baseEnv }
137
+ );
138
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
139
+
140
+ const out = JSON.parse(res.stdout.trim());
141
+ assert.equal(out.message, 'identity');
142
+ assert.equal(out.stack, stackName);
143
+ assert.equal(out.homeDir, join(storageDir, stackName, 'cli-identities', identity));
144
+ assert.equal(out.serverUrl, 'http://127.0.0.1:3999');
145
+ });
146
+
85
147
  test('happys <stack> happy ... shorthand runs happy-cli under that stack env', async () => {
86
148
  const scriptsDir = dirname(fileURLToPath(import.meta.url));
87
149
  const rootDir = dirname(scriptsDir);
@@ -124,3 +186,44 @@ test('happys <stack> happy ... shorthand runs happy-cli under that stack env', a
124
186
  assert.equal(out.serverUrl, 'http://127.0.0.1:4101');
125
187
  });
126
188
 
189
+ test('happys stack happy <name> does not print wrapper stack traces on happy-cli failure', async () => {
190
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
191
+ const rootDir = dirname(scriptsDir);
192
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-happy-fail-'));
193
+
194
+ const storageDir = join(tmp, 'storage');
195
+ const homeDir = join(tmp, 'home');
196
+ const stackName = 'exp-test';
197
+
198
+ const stubRoot = join(tmp, 'stub-components');
199
+ const cliDir = await writeFailingStubHappyCli({ root: stubRoot, errorMessage: 'stub failure' });
200
+
201
+ const stackCliHome = join(storageDir, stackName, 'cli');
202
+ const envPath = join(storageDir, stackName, 'env');
203
+ await mkdir(dirname(envPath), { recursive: true });
204
+ await writeFile(
205
+ envPath,
206
+ [
207
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliDir}`,
208
+ `HAPPY_STACKS_CLI_HOME_DIR=${stackCliHome}`,
209
+ `HAPPY_STACKS_SERVER_PORT=3999`,
210
+ '',
211
+ ].join('\n'),
212
+ 'utf-8'
213
+ );
214
+
215
+ const baseEnv = {
216
+ ...process.env,
217
+ HAPPY_STACKS_HOME_DIR: homeDir,
218
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
219
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
220
+ }; const res = await runNode([join(rootDir, 'bin', 'happys.mjs'), 'stack', 'happy', stackName, 'attach', 'abc'], {
221
+ cwd: rootDir,
222
+ env: baseEnv,
223
+ });
224
+ assert.equal(res.code, 1, `expected exit 1, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
225
+ assert.ok(res.stderr.includes('stub failure'), `expected stderr to include stub failure, got:\n${res.stderr}`);
226
+ assert.ok(!res.stderr.includes('[happy] failed:'), `expected no [happy] failed stack trace, got:\n${res.stderr}`);
227
+ assert.ok(!res.stderr.includes('[stack] failed:'), `expected no [stack] failed stack trace, got:\n${res.stderr}`);
228
+ assert.ok(!res.stderr.includes('node:internal'), `expected no node:internal stack trace, got:\n${res.stderr}`);
229
+ });
@@ -4,7 +4,7 @@ function formatMissingTool({ name, why, install }) {
4
4
  return [`- ${name}: ${why}`, ...(install?.length ? install.map((l) => ` ${l}`) : [])].join('\n');
5
5
  }
6
6
 
7
- export async function assertCliPrereqs({ git = false, pnpm = false, codex = false, coderabbit = false } = {}) {
7
+ export async function assertCliPrereqs({ git = false, pnpm = false, codex = false, coderabbit = false, augment = false } = {}) {
8
8
  const missing = [];
9
9
 
10
10
  if (git) {
@@ -65,6 +65,17 @@ export async function assertCliPrereqs({ git = false, pnpm = false, codex = fals
65
65
  }
66
66
  }
67
67
 
68
+ if (augment) {
69
+ const hasAuggie = await commandExists('auggie');
70
+ if (!hasAuggie) {
71
+ missing.push({
72
+ name: 'auggie',
73
+ why: 'required to run Augment (Auggie) review',
74
+ install: ['Install Auggie CLI: `npm install -g @augmentcode/auggie`', 'Then authenticate: `auggie login`'],
75
+ });
76
+ }
77
+ }
78
+
68
79
  if (!missing.length) return;
69
80
 
70
81
  throw new Error(
@@ -36,7 +36,25 @@ export async function promptSelect(rl, { title, options, defaultIndex = 0 }) {
36
36
  console.log(` ${i + 1}) ${options[i].label}`);
37
37
  }
38
38
  const answer = (await rl.question(`Pick [1-${options.length}] (default: ${defaultIndex + 1}): `)).trim();
39
- const n = answer ? Number(answer) : defaultIndex + 1;
39
+ const token = answer.match(/\d+/)?.[0] ?? '';
40
+ let n = defaultIndex + 1;
41
+ if (token) {
42
+ const parsed = Number(token);
43
+ if (Number.isFinite(parsed)) {
44
+ // Heuristic: in some nested-readline situations (or odd terminals), single-digit input can get duplicated
45
+ // (e.g. "2" becomes "22"). If that happens and all digits are identical, treat it as the intended single digit.
46
+ if (
47
+ token.length > 1 &&
48
+ token.split('').every((c) => c === token[0]) &&
49
+ Number(token[0]) >= 1 &&
50
+ Number(token[0]) <= options.length
51
+ ) {
52
+ n = Number(token[0]);
53
+ } else {
54
+ n = parsed;
55
+ }
56
+ }
57
+ }
40
58
  const idx = Math.max(1, Math.min(options.length, Number.isFinite(n) ? n : defaultIndex + 1)) - 1;
41
59
  return options[idx].value;
42
60
  }
@@ -47,7 +47,9 @@ export async function prepareDaemonAuthSeed({
47
47
  serverComponentName,
48
48
  serverDir,
49
49
  env: serverEnv,
50
- bestEffort: serverComponentName === 'happy-server',
50
+ // This probe is used only for auth seeding heuristics (and should never block stack startup).
51
+ // For unified server-light, running migrations here can race the running server and lock SQLite.
52
+ bestEffort: true,
51
53
  });
52
54
  return await prepareDaemonAuthSeedIfNeeded({
53
55
  rootDir,
@@ -27,8 +27,16 @@ function buildInteractiveLaunchScript({ toolCmd, cd, title, promptText }) {
27
27
  if (!cwd) throw new Error('[llm] launch: missing cwd');
28
28
  if (!cmd) throw new Error('[llm] launch: missing toolCmd');
29
29
 
30
- // Best-effort: print the prompt and open the CLI.
31
- // We intentionally do NOT try to auto-feed stdin for tools we don't fully control.
30
+ const execLine = (() => {
31
+ // Prefer providing the prompt directly when the CLI supports it.
32
+ // - Claude Code: `claude "query"` starts an interactive session with an initial prompt.
33
+ // - OpenCode: `opencode --prompt "..."` starts the TUI with an initial prompt.
34
+ if (cmd === 'claude') return 'exec command claude "$HS_PROMPT"';
35
+ if (cmd === 'opencode') return 'exec command opencode --prompt "$HS_PROMPT"';
36
+ // Fallback: start the tool and ask the user to paste.
37
+ return `exec command ${JSON.stringify(cmd)}`;
38
+ })();
39
+
32
40
  return [
33
41
  '#!/usr/bin/env bash',
34
42
  'set -euo pipefail',
@@ -37,20 +45,29 @@ function buildInteractiveLaunchScript({ toolCmd, cd, title, promptText }) {
37
45
  '',
38
46
  `echo ${JSON.stringify(t)}`,
39
47
  'echo',
40
- 'echo "Prompt (also copied to clipboard if supported by the parent process):"',
48
+ 'echo "Prompt:"',
41
49
  'echo "------------------------------------------------------------"',
42
- "cat <<'HS_PROMPT_EOF'",
50
+ 'HS_PROMPT="$(cat <<\'HS_PROMPT_EOF\'',
43
51
  prompt,
44
52
  'HS_PROMPT_EOF',
53
+ ')"',
54
+ 'echo "$HS_PROMPT"',
45
55
  'echo "------------------------------------------------------------"',
46
56
  'echo',
47
- `echo "Starting: ${cmd}"`,
48
- `exec command ${JSON.stringify(cmd)}`,
57
+ 'if command -v pbcopy >/dev/null 2>&1; then',
58
+ ' printf "%s" "$HS_PROMPT" | pbcopy',
59
+ ' echo "Copied prompt to clipboard (pbcopy)."',
60
+ 'fi',
61
+ 'echo',
62
+ 'echo "Press Enter to start now, or Ctrl+C to cancel."',
63
+ 'read -r _',
64
+ execLine,
49
65
  '',
50
66
  ].join('\n');
51
67
  }
52
68
 
53
69
  export async function launchLlmAssistant({
70
+ rl: providedRl = null,
54
71
  title,
55
72
  subtitle,
56
73
  promptText,
@@ -73,27 +90,31 @@ export async function launchLlmAssistant({
73
90
  return { ok: false, reason: 'no supported LLM CLI detected', terminalSupport };
74
91
  }
75
92
 
93
+ const withMaybeRl = async (fn) => {
94
+ if (providedRl) return await fn(providedRl);
95
+ return await withRl(fn);
96
+ };
97
+
76
98
  const chosenTool =
77
99
  tools.length === 1
78
100
  ? tools[0]
79
101
  : tools.find((t) => t.id === preferredToolId) ||
80
- (await withRl(async (rl) => {
81
- const defaultIndex = Math.max(
82
- 0,
83
- tools.findIndex((t) => t.id === 'codex')
84
- );
85
- const picked = await promptSelect(rl, {
86
- title:
87
- `${bold('Pick an LLM CLI')}\n` +
88
- `${dim('We will launch it with a pre-filled migration prompt so it can run the port and resolve conflicts.')}`,
89
- options: tools.map((t) => ({
90
- value: t.id,
91
- label: `${cyan(t.id)} — ${t.label}${t.note ? ` ${dim(`— ${t.note}`)}` : ''}`,
92
- })),
93
- defaultIndex,
94
- });
95
- return tools.find((t) => t.id === picked) || tools[0];
96
- }));
102
+ (!isTty()
103
+ ? tools.find((t) => t.id === 'codex') || tools[0]
104
+ : await withMaybeRl(async (rl) => {
105
+ const defaultIndex = Math.max(0, tools.findIndex((t) => t.id === 'codex'));
106
+ const picked = await promptSelect(rl, {
107
+ title:
108
+ `${bold('Pick an LLM CLI')}\n` +
109
+ `${dim('We will launch it with a pre-filled migration prompt so it can run the port and resolve conflicts.')}`,
110
+ options: tools.map((t) => ({
111
+ value: t.id,
112
+ label: `${cyan(t.id)} — ${t.label}${t.note ? ` ${dim(`— ${t.note}`)}` : ''}`,
113
+ })),
114
+ defaultIndex,
115
+ });
116
+ return tools.find((t) => t.id === picked) || tools[0];
117
+ }));
97
118
 
98
119
  const launchOptions = [];
99
120
  if (terminalSupport.ok) {
@@ -109,20 +130,22 @@ export async function launchLlmAssistant({
109
130
  const launchMode =
110
131
  launchOptions.length === 1
111
132
  ? launchOptions[0].value
112
- : await withRl(async (rl) => {
113
- return await promptSelect(rl, {
114
- title: `${bold('How do you want to run the migration assistant?')}`,
115
- options: launchOptions,
116
- defaultIndex: 0,
133
+ : !isTty()
134
+ ? launchOptions[0].value
135
+ : await withMaybeRl(async (rl) => {
136
+ return await promptSelect(rl, {
137
+ title: `${bold('How do you want to run the migration assistant?')}`,
138
+ options: launchOptions,
139
+ defaultIndex: 0,
140
+ });
117
141
  });
118
- });
119
142
 
120
143
  const permissionMode =
121
144
  chosenTool.id !== 'codex'
122
145
  ? null
123
146
  : CODEX_PERMISSION_MODES.length === 1 || !isTty()
124
147
  ? defaultPermissionMode
125
- : await withRl(async (rl) => {
148
+ : await withMaybeRl(async (rl) => {
126
149
  const opts = codexPermissionOptions();
127
150
  const v = await promptSelect(rl, {
128
151
  title:
@@ -173,7 +196,17 @@ export async function launchLlmAssistant({
173
196
  // eslint-disable-next-line no-console
174
197
  console.log(dim(`Starting: ${chosenTool.cmd}`));
175
198
 
176
- await run(chosenTool.cmd, [], { cwd: cd, env: env ?? process.env, stdio: 'inherit' });
199
+ if (chosenTool.cmd === 'claude') {
200
+ // Claude Code supports starting interactive mode with an initial prompt:
201
+ // `claude "query"`.
202
+ await run('claude', [prompt], { cwd: cd, env: env ?? process.env, stdio: 'inherit' });
203
+ } else if (chosenTool.cmd === 'opencode') {
204
+ // OpenCode supports starting the TUI with an initial prompt:
205
+ // `opencode --prompt "..."`.
206
+ await run('opencode', ['--prompt', prompt], { cwd: cd, env: env ?? process.env, stdio: 'inherit' });
207
+ } else {
208
+ await run(chosenTool.cmd, [], { cwd: cd, env: env ?? process.env, stdio: 'inherit' });
209
+ }
177
210
  return { ok: true, launched: true, mode: 'here', tool: chosenTool.id, permissionMode, terminalSupport };
178
211
  }
179
212
 
@@ -0,0 +1,60 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ /**
6
+ * Returns an absolute path to this package's `bin/happys.mjs` if present.
7
+ * This is the most reliable way to re-run Happy Stacks commands from an LLM prompt
8
+ * when `npx` is unreliable (e.g. npm cache permission issues).
9
+ */
10
+ export function resolveLocalHappyStacksHappysMjsPath() {
11
+ try {
12
+ const here = dirname(fileURLToPath(import.meta.url)); // scripts/utils/llm
13
+ const root = resolve(here, '../../..'); // package root (contains bin/ and scripts/)
14
+ const p = join(root, 'bin', 'happys.mjs');
15
+ return existsSync(p) ? p : '';
16
+ } catch {
17
+ return '';
18
+ }
19
+ }
20
+
21
+ export function buildHappyStacksRunnerShellSnippet({ preferLocalBin = true } = {}) {
22
+ const localBin = preferLocalBin ? resolveLocalHappyStacksHappysMjsPath() : '';
23
+ const localClause = localBin
24
+ ? [
25
+ `HS_LOCAL_BIN=${JSON.stringify(localBin)}`,
26
+ ' if [ -f "$HS_LOCAL_BIN" ]; then',
27
+ ' node "$HS_LOCAL_BIN" "$@"',
28
+ ' return $?',
29
+ ' fi',
30
+ ].join('\n')
31
+ : '';
32
+
33
+ return [
34
+ 'Happy Stacks command runner:',
35
+ '- In the commands below, run `hs ...`.',
36
+ '- This avoids `npx` flakiness by preferring a local `bin/happys.mjs` when available.',
37
+ '',
38
+ '```bash',
39
+ 'hs() {',
40
+ ' # Prefer an installed `happys` if present.',
41
+ ' if command -v happys >/dev/null 2>&1; then',
42
+ ' happys "$@"',
43
+ ' return $?',
44
+ ' fi',
45
+ localClause,
46
+ ' # Fallback: npx. Work around broken ~/.npm perms by using a fresh writable cache dir.',
47
+ ' if command -v npx >/dev/null 2>&1; then',
48
+ ' local cache_dir',
49
+ ' cache_dir="${HAPPY_STACKS_NPX_CACHE_DIR:-$(mktemp -d)}"',
50
+ ' npm_config_cache="$cache_dir" npm_config_update_notifier=false npx --yes happy-stacks@latest "$@"',
51
+ ' return $?',
52
+ ' fi',
53
+ ' echo "Missing happys and npx. Install Node/npm or install happy-stacks."',
54
+ ' return 1',
55
+ '}',
56
+ '```',
57
+ '',
58
+ ].join('\n');
59
+ }
60
+
@@ -17,7 +17,7 @@ const KNOWN_LLM_TOOLS = [
17
17
  id: 'claude',
18
18
  cmd: 'claude',
19
19
  label: 'Claude CLI',
20
- note: 'Interactive launch supported (prompt will be copied/printed).',
20
+ note: 'Supports starting interactive mode with an initial prompt.',
21
21
  supportsPromptStdin: false,
22
22
  supportsAutoExec: false,
23
23
  },
@@ -25,7 +25,7 @@ const KNOWN_LLM_TOOLS = [
25
25
  id: 'opencode',
26
26
  cmd: 'opencode',
27
27
  label: 'OpenCode',
28
- note: 'Interactive launch supported (prompt will be copied/printed).',
28
+ note: 'Supports starting TUI with an initial prompt.',
29
29
  supportsPromptStdin: false,
30
30
  supportsAutoExec: false,
31
31
  },
@@ -33,7 +33,7 @@ const KNOWN_LLM_TOOLS = [
33
33
  id: 'aider',
34
34
  cmd: 'aider',
35
35
  label: 'Aider',
36
- note: 'Interactive launch supported (prompt will be copied/printed).',
36
+ note: 'Prompt injection varies by mode; copy/paste fallback supported.',
37
37
  supportsPromptStdin: false,
38
38
  supportsAutoExec: false,
39
39
  },
@@ -105,7 +105,7 @@ function resolveStackCacheBaseDirFromEnv(env) {
105
105
  }
106
106
  }
107
107
 
108
- async function applyStackCacheEnv(baseEnv) {
108
+ export async function applyStackCacheEnv(baseEnv) {
109
109
  const env = { ...(baseEnv && typeof baseEnv === 'object' ? baseEnv : process.env) };
110
110
  const envFile = (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim();
111
111
  const stackCacheBase = resolveStackCacheBaseDirFromEnv(env);