@haposoft/cafekit 0.15.0 → 0.15.3

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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Native spec-driven workflow and runtime bundle for Claude Code, Codex CLI, and OpenCode.
4
4
 
5
- [![Version](https://img.shields.io/badge/version-0.15.0-blue.svg)](https://github.com/haposoft/cafekit)
5
+ [![Version](https://img.shields.io/badge/version-0.15.3-blue.svg)](https://github.com/haposoft/cafekit)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
  [![Claude%20Code](https://img.shields.io/badge/Claude%20Code-Native-orange.svg)](https://claude.ai/code)
8
8
  [![Codex%20CLI](https://img.shields.io/badge/Codex%20CLI-Native-111111.svg)](https://developers.openai.com/codex)
@@ -134,6 +134,8 @@ AGENTS.md
134
134
  CafeKit owns only its marked block in root `AGENTS.md`. Codex discovers skills
135
135
  and custom agents natively after the repository is trusted; no project
136
136
  `config.toml` is generated. Review and trust project hooks with `/hooks`.
137
+ On Windows, installed hook commands are bound to the canonical project path
138
+ without requiring Git and remain stable when a session starts in a subdirectory.
137
139
  CafeKit uses Codex's native status and usage UI instead of installing the
138
140
  Claude statusline.
139
141
 
@@ -18,6 +18,7 @@ const {
18
18
  resolvePlatforms,
19
19
  selectLanguage
20
20
  } = require('../phases/select-platform');
21
+ const { MESSAGES } = require('../lib/i18n');
21
22
 
22
23
  const PACKAGE_ROOT = path.join(__dirname, '../..');
23
24
  const PACKAGE_VERSION = JSON.parse(
@@ -59,6 +60,12 @@ function allFiles(root, predicate) {
59
60
  return found;
60
61
  }
61
62
 
63
+ function allHookHandlers(config) {
64
+ return Object.values(config.hooks).flatMap((groups) => (
65
+ groups.flatMap((group) => group.hooks)
66
+ ));
67
+ }
68
+
62
69
  function parseGeneratedTomlString(content, key) {
63
70
  const match = content.match(new RegExp(`^${key} = (.+)$`, 'm'));
64
71
  assert.ok(match, `missing TOML key: ${key}`);
@@ -196,6 +203,114 @@ test('same-version install can add Codex beside an existing runtime', async () =
196
203
  }
197
204
  });
198
205
 
206
+ test('resolvePlatforms interactive prompts to add more platforms when prior install exists', async () => {
207
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-add-platforms-'));
208
+ const originalCwd = process.cwd();
209
+ try {
210
+ fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
211
+ fs.writeFileSync(
212
+ path.join(root, '.claude', 'cafekit.json'),
213
+ `${JSON.stringify({ version: PACKAGE_VERSION, platform: 'claude' })}\n`
214
+ );
215
+ process.chdir(root);
216
+ const ctx = {
217
+ options: { platforms: [], forceOverwrite: false },
218
+ interactive: true,
219
+ dryRun: false,
220
+ ui: {
221
+ info() {},
222
+ confirm: async ({ message }) => {
223
+ if (message.includes('Existing platforms') || message.includes('addPlatformsPrompt')) return true;
224
+ if (message.includes('confirmAllDetected') || message.includes('existing configs')) return true;
225
+ return true;
226
+ },
227
+ select: async ({ message, options }) => {
228
+ if (message.includes('selectPlatform') || message.includes('Select platform')) {
229
+ return ['codex'];
230
+ }
231
+ return options[0].value;
232
+ },
233
+ isCancel: () => false
234
+ },
235
+ t: (key, vars) => {
236
+ const tpl = (MESSAGES.en && MESSAGES.en[key]) || key;
237
+ if (vars) return tpl.replace(/\{(\w+)\}/g, (_, name) => (vars[name] !== undefined ? String(vars[name]) : `{${name}}`));
238
+ return tpl;
239
+ }
240
+ };
241
+ await resolvePlatforms(ctx);
242
+ assert.deepEqual(ctx.platforms.sort(), ['claude', 'codex'].sort());
243
+ } finally {
244
+ process.chdir(originalCwd);
245
+ fs.rmSync(root, { recursive: true, force: true });
246
+ }
247
+ });
248
+
249
+ test('resolvePlatforms interactive keeps existing platforms when user declines to add more', async () => {
250
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-keep-platforms-'));
251
+ const originalCwd = process.cwd();
252
+ try {
253
+ fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
254
+ fs.writeFileSync(
255
+ path.join(root, '.claude', 'cafekit.json'),
256
+ `${JSON.stringify({ version: PACKAGE_VERSION, platform: 'claude' })}\n`
257
+ );
258
+ process.chdir(root);
259
+ const ctx = {
260
+ options: { platforms: [], forceOverwrite: false },
261
+ interactive: true,
262
+ dryRun: false,
263
+ ui: {
264
+ info() {},
265
+ confirm: async ({ message }) => {
266
+ // Check that the message is properly rendered without {names} placeholder
267
+ // If i18n uses {existing} but ctx passes {names}, the rendered message will contain {existing}
268
+ if (message.includes('Existing platforms') || message.includes('既存プラットフォーム') || message.includes('Nền tảng hiện có')) {
269
+ assert.ok(!message.includes('{'), `Message should not contain unrendered placeholder: ${message}`);
270
+ assert.ok(!message.includes('}'), `Message should not contain unrendered placeholder: ${message}`);
271
+ return false; // decline to add more
272
+ }
273
+ return true; // confirmAllDetected
274
+ },
275
+ select: async () => ['claude'],
276
+ isCancel: () => false
277
+ },
278
+ t: (key, vars) => {
279
+ const tpl = (MESSAGES.en && MESSAGES.en[key]) || key;
280
+ if (vars) return tpl.replace(/\{(\w+)\}/g, (_, name) => (vars[name] !== undefined ? String(vars[name]) : `{${name}}`));
281
+ return tpl;
282
+ }
283
+ };
284
+ await resolvePlatforms(ctx);
285
+ assert.deepEqual(ctx.platforms, ['claude']);
286
+ } finally {
287
+ process.chdir(originalCwd);
288
+ fs.rmSync(root, { recursive: true, force: true });
289
+ }
290
+ });
291
+
292
+ test('addPlatformsPrompt placeholder consistency across all locales', () => {
293
+ const expectedPlaceholders = ['names'];
294
+ const placeholderRegex = /\{(\w+)\}/g;
295
+
296
+ for (const [locale, messages] of Object.entries(MESSAGES)) {
297
+ const template = messages.addPlatformsPrompt;
298
+ assert.ok(template, `Missing addPlatformsPrompt key in locale: ${locale}`);
299
+
300
+ const placeholders = [];
301
+ let match;
302
+ while ((match = placeholderRegex.exec(template)) !== null) {
303
+ placeholders.push(match[1]);
304
+ }
305
+
306
+ assert.deepEqual(
307
+ placeholders,
308
+ expectedPlaceholders,
309
+ `Locale ${locale} addPlatformsPrompt has incorrect placeholders. Template: "${template}". Expected: ${JSON.stringify(expectedPlaceholders)}, Got: ${JSON.stringify(placeholders)}`
310
+ );
311
+ }
312
+ });
313
+
199
314
  test('same-version non-interactive install performs a selective refresh', async () => {
200
315
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-version-'));
201
316
  const originalCwd = process.cwd();
@@ -316,6 +431,64 @@ test('Codex dry-run leaves both managed roots untouched', () => {
316
431
  });
317
432
  });
318
433
 
434
+ test('Codex Windows hook launchers stay project-bound without Git from nested cwd', () => {
435
+ inTempProject((root) => {
436
+ const projectRoot = path.join(root, 'project with spaces');
437
+ fs.mkdirSync(projectRoot, { recursive: true });
438
+ const installed = install(projectRoot);
439
+ assert.equal(installed.status, 0, `${installed.stdout}\n${installed.stderr}`);
440
+
441
+ const config = JSON.parse(
442
+ fs.readFileSync(path.join(projectRoot, '.codex', 'hooks.json'), 'utf8')
443
+ );
444
+ const handlers = allHookHandlers(config);
445
+ assert.equal(handlers.length, 14);
446
+ for (const handler of handlers) {
447
+ assert.doesNotMatch(handler.commandWindows, /\$\(/);
448
+ assert.doesNotMatch(handler.commandWindows, /\bgit\b/i);
449
+ assert.doesNotMatch(handler.commandWindows, /process\.cwd\(\)|existsSync/);
450
+ const encodedPath = handler.commandWindows.match(/\s([A-Za-z0-9_-]+)$/)?.[1];
451
+ assert.ok(encodedPath, `missing encoded hook path in: ${handler.commandWindows}`);
452
+ const target = Buffer.from(encodedPath, 'base64url').toString('utf8');
453
+ assert.equal(path.dirname(target), fs.realpathSync(path.join(projectRoot, '.codex', 'hooks')));
454
+ assert.equal(
455
+ fs.existsSync(target),
456
+ true,
457
+ `missing installed hook: ${path.basename(target)}`
458
+ );
459
+ }
460
+
461
+ const nested = path.join(projectRoot, 'nested', 'workspace');
462
+ fs.mkdirSync(nested, { recursive: true });
463
+ const shadowHooks = path.join(projectRoot, 'nested', '.codex', 'hooks');
464
+ const shadowMarker = path.join(root, 'shadow-hook-ran');
465
+ fs.mkdirSync(shadowHooks, { recursive: true });
466
+ fs.writeFileSync(
467
+ path.join(shadowHooks, 'session.cjs'),
468
+ `require('node:fs').writeFileSync(${JSON.stringify(shadowMarker)}, 'unsafe')\n`
469
+ );
470
+ const session = config.hooks.SessionStart[0].hooks[0];
471
+ const nodeCommand = session.commandWindows.replace(/^node /, `"${process.execPath}" `);
472
+ const noGitEnv = { ...process.env, PATH: '' };
473
+ const launched = spawnSync(nodeCommand, {
474
+ cwd: nested,
475
+ encoding: 'utf8',
476
+ input: JSON.stringify({
477
+ session_id: 'windows-launcher-test',
478
+ cwd: nested,
479
+ hook_event_name: 'SessionStart',
480
+ source: 'startup'
481
+ }),
482
+ env: noGitEnv,
483
+ shell: true
484
+ });
485
+ assert.equal(launched.status, 0, launched.stderr);
486
+ assert.match(launched.stdout, /Session startup\./);
487
+ assert.match(launched.stdout, /CafeKit project root:/);
488
+ assert.equal(fs.existsSync(shadowMarker), false);
489
+ });
490
+ });
491
+
319
492
  test('Codex install is project-local, native, and upgrade-safe', () => {
320
493
  inTempProject((root) => {
321
494
  const userInstructions = '# User rules\n\nKeep this exact.\n';
@@ -456,3 +629,58 @@ test('Codex install is project-local, native, and upgrade-safe', () => {
456
629
  assert.doesNotMatch(fs.readFileSync(questionSkill, 'utf8'), /USER-CODEX-SENTINEL/);
457
630
  });
458
631
  });
632
+
633
+ test('Codex install on top of existing Claude installation preserves content and adds Codex', () => {
634
+ inTempProject((root) => {
635
+ // Setup existing Claude installation with content
636
+ fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
637
+ fs.writeFileSync(
638
+ path.join(root, '.claude', 'cafekit.json'),
639
+ `${JSON.stringify({ version: PACKAGE_VERSION, platform: 'claude' })}\n`
640
+ );
641
+
642
+ // Existing CLAUDE.md with content
643
+ const claudeMdContent = '# CLAUDE.md\n\n## User Instructions\n\nThis is my existing CLAUDE.md content.\n';
644
+ fs.writeFileSync(path.join(root, 'CLAUDE.md'), claudeMdContent);
645
+
646
+ // Existing AGENTS.md with content
647
+ const agentsMdContent = '# AGENTS.md\n\n## User Rules\n\nKeep this exact.\n';
648
+ fs.writeFileSync(path.join(root, 'AGENTS.md'), agentsMdContent);
649
+
650
+ // Run Codex install
651
+ const result = install(root, ['--platform', 'codex']);
652
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
653
+
654
+ // Assert .claude/ and its content still exist
655
+ assert.equal(fs.existsSync(path.join(root, '.claude')), true, '.claude should exist');
656
+ assert.equal(fs.existsSync(path.join(root, '.claude', 'cafekit.json')), true, '.claude/cafekit.json should exist');
657
+ const claudeMetadata = JSON.parse(fs.readFileSync(path.join(root, '.claude', 'cafekit.json'), 'utf8'));
658
+ assert.equal(claudeMetadata.platform, 'claude', '.claude/cafekit.json should still be claude platform');
659
+
660
+ // Assert .codex/ is created with payload
661
+ assert.equal(fs.existsSync(path.join(root, '.codex')), true, '.codex should be created');
662
+ assert.equal(fs.existsSync(path.join(root, '.codex', 'hooks.json')), true, '.codex/hooks.json should exist');
663
+ assert.equal(fs.existsSync(path.join(root, '.codex', 'runtime.json')), true, '.codex/runtime.json should exist');
664
+ assert.equal(fs.existsSync(path.join(root, '.codex', 'hooks', 'privacy-block.cjs')), true, '.codex/hooks/privacy-block.cjs should exist');
665
+ assert.equal(fs.existsSync(path.join(root, '.codex', 'rules', 'workflow.md')), true, '.codex/rules/workflow.md should exist');
666
+ assert.equal(fs.existsSync(path.join(root, '.codex', 'scripts', 'validate-spec-output.cjs')), true, '.codex/scripts/validate-spec-output.cjs should exist');
667
+ assert.equal(fs.existsSync(path.join(root, '.agents', '.gitignore')), true, '.agents/.gitignore should exist');
668
+ assert.equal(fs.existsSync(path.join(root, '.agents', 'skills', 'specs', 'SKILL.md')), true, '.agents/skills/specs/SKILL.md should exist');
669
+
670
+ // Assert .codex/cafekit.json has platform codex
671
+ const codexMetadata = JSON.parse(fs.readFileSync(path.join(root, '.codex', 'cafekit.json'), 'utf8'));
672
+ assert.equal(codexMetadata.platform, 'codex', '.codex/cafekit.json should have platform codex');
673
+ assert.equal(codexMetadata.version, PACKAGE_VERSION, '.codex/cafekit.json should have current version');
674
+
675
+ // Assert AGENTS.md root contains CODEX markers AND preserves original content
676
+ const agentsMd = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
677
+ assert.match(agentsMd, /<!-- CAFEKIT CODEX START -->/, 'AGENTS.md should have CODEX START marker');
678
+ assert.match(agentsMd, /<!-- CAFEKIT CODEX END -->/, 'AGENTS.md should have CODEX END marker');
679
+ assert.match(agentsMd, /Keep this exact\./, 'AGENTS.md should preserve original user content');
680
+ assert.ok(agentsMd.startsWith(agentsMdContent), 'AGENTS.md should start with original user content');
681
+
682
+ // Assert CLAUDE.md is unchanged
683
+ const claudeMd = fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf8');
684
+ assert.equal(claudeMd, claudeMdContent, 'CLAUDE.md should be unchanged');
685
+ });
686
+ });
@@ -11,6 +11,10 @@ const {
11
11
  copyOpenCodeAgentsMdFile,
12
12
  normalizeOpenCodeBody
13
13
  } = require('../lib/opencode-install');
14
+ const {
15
+ commandFailureReason,
16
+ resolvePackageCommand
17
+ } = require('../lib/skill-deps');
14
18
  const { copyClaudeMdFile } = require('../phases/claude-runtime');
15
19
  const { configureAddressing, patchRuntimeLocale } = require('../phases/post-install');
16
20
 
@@ -18,6 +22,45 @@ const START = '<!-- CAFEKIT CLAUDE START -->';
18
22
  const END = '<!-- CAFEKIT CLAUDE END -->';
19
23
  const TEMPLATE = fs.readFileSync(path.join(__dirname, '../../src/claude/CLAUDE.md'), 'utf8');
20
24
 
25
+ test('skill dependency package commands use cmd.exe for Windows npm launchers', () => {
26
+ const windows = resolvePackageCommand(
27
+ 'npm',
28
+ ['install', '--no-audit', '--no-fund'],
29
+ 'win32',
30
+ { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }
31
+ );
32
+ assert.deepEqual(windows, {
33
+ command: 'C:\\Windows\\System32\\cmd.exe',
34
+ args: ['/d', '/s', '/c', 'npm.cmd', 'install', '--no-audit', '--no-fund']
35
+ });
36
+
37
+ const posix = resolvePackageCommand('npx', ['playwright', '--version'], 'linux', {});
38
+ assert.deepEqual(posix, {
39
+ command: 'npx',
40
+ args: ['playwright', '--version']
41
+ });
42
+ });
43
+
44
+ test('skill dependency failures surface the useful npm or spawn error code', () => {
45
+ assert.equal(
46
+ commandFailureReason({
47
+ stderr: "npm error Request failed\nnpm error code ENOTCACHED\nnpm error Log unavailable\n",
48
+ stdout: '',
49
+ status: 1
50
+ }),
51
+ 'npm error code ENOTCACHED'
52
+ );
53
+ assert.equal(
54
+ commandFailureReason({
55
+ stderr: 'spawn npm.cmd EINVAL',
56
+ stdout: '',
57
+ status: null,
58
+ errorCode: 'EINVAL'
59
+ }),
60
+ 'spawn npm.cmd EINVAL'
61
+ );
62
+ });
63
+
21
64
  function inTempProject(run) {
22
65
  const originalCwd = process.cwd();
23
66
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-installer-safety-'));
package/bin/lib/i18n.js CHANGED
@@ -79,7 +79,7 @@ const MESSAGES = {
79
79
  pipFailed: '{skill} — some packages failed. Retry:\n {cmd}',
80
80
  npmInstalling: 'Installing Node packages: {skill}',
81
81
  npmInstalled: '{skill} — Node packages ready',
82
- npmFailed: '{skill} — npm install failed. Check the log and retry in: {dir}',
82
+ npmFailed: '{skill} — npm install failed ({error}). Retry in: {dir}',
83
83
  playwrightInstalling: 'Downloading Playwright browser for {skill}',
84
84
  playwrightReady: '{skill} — Playwright browser ready',
85
85
  playwrightSkipped: '{skill} — Playwright browser download skipped',
@@ -132,6 +132,9 @@ const MESSAGES = {
132
132
  langKept: 'Language: {lang} (saved — skipping prompt)',
133
133
  platformKept: 'Platform: {names} (saved — skipping prompt)',
134
134
 
135
+ // Add more platforms prompt (interactive, prior install exists)
136
+ addPlatformsPrompt: 'Existing platforms: {names}. Add more?',
137
+
135
138
  // Version picker
136
139
  versionPickPrompt: 'Select CafeKit version to install:',
137
140
  versionPickCurrent: '(installed)',
@@ -199,7 +202,7 @@ const MESSAGES = {
199
202
  pipFailed: '{skill} — 一部のパッケージが失敗しました。再試行:\n {cmd}',
200
203
  npmInstalling: 'Node パッケージをインストール中: {skill}',
201
204
  npmInstalled: '{skill} — Node パッケージの準備ができました',
202
- npmFailed: '{skill} — npm install に失敗しました。{dir} で手動再試行してください。',
205
+ npmFailed: '{skill} — npm install に失敗しました ({error})。{dir} で手動再試行してください。',
203
206
  playwrightInstalling: '{skill} 用の Playwright ブラウザをダウンロード中',
204
207
  playwrightReady: '{skill} — Playwright ブラウザの準備ができました',
205
208
  playwrightSkipped: '{skill} — Playwright ブラウザのダウンロードをスキップしました',
@@ -249,6 +252,9 @@ const MESSAGES = {
249
252
  langKept: '言語: {lang} (保存済み — スキップ)',
250
253
  platformKept: 'プラットフォーム: {names} (保存済み — スキップ)',
251
254
 
255
+ // Add more platforms prompt (interactive, prior install exists)
256
+ addPlatformsPrompt: '既存プラットフォーム: {names}。さらに追加しますか?',
257
+
252
258
  // Version picker
253
259
  versionPickPrompt: 'インストールする CafeKit バージョンを選択:',
254
260
  versionPickCurrent: '(インストール済み)',
@@ -316,7 +322,7 @@ const MESSAGES = {
316
322
  pipFailed: '{skill} — một số gói cài thất bại. Chạy lại thủ công:\n {cmd}',
317
323
  npmInstalling: 'Đang cài gói Node: {skill}',
318
324
  npmInstalled: '{skill} — gói Node đã sẵn sàng',
319
- npmFailed: '{skill} — npm install thất bại. Hãy thử lại trong: {dir}',
325
+ npmFailed: '{skill} — npm install thất bại ({error}). Hãy thử lại trong: {dir}',
320
326
  playwrightInstalling: 'Đang tải Playwright browser cho {skill}',
321
327
  playwrightReady: '{skill} — Playwright browser đã sẵn sàng',
322
328
  playwrightSkipped: '{skill} — đã bỏ qua tải Playwright browser',
@@ -366,6 +372,9 @@ const MESSAGES = {
366
372
  langKept: 'Ngôn ngữ: {lang} (đã lưu — bỏ qua chọn lại)',
367
373
  platformKept: 'Nền tảng: {names} (đã lưu — bỏ qua chọn lại)',
368
374
 
375
+ // Add more platforms prompt (interactive, prior install exists)
376
+ addPlatformsPrompt: 'Nền tảng hiện có: {names}. Thêm nền tảng khác?',
377
+
369
378
  // Version picker
370
379
  versionPickPrompt: 'Chọn phiên bản CafeKit để cài:',
371
380
  versionPickCurrent: '(đang cài)',
@@ -17,6 +17,19 @@ const { spawnSync, spawn } = require('child_process');
17
17
 
18
18
  const isWindows = process.platform === 'win32';
19
19
 
20
+ /**
21
+ * npm and npx are `.cmd` launchers on Windows, so Node cannot spawn them
22
+ * directly without a command interpreter. Keep the shell boundary explicit;
23
+ * all arguments passed here are installer-owned constants.
24
+ */
25
+ function resolvePackageCommand(command, args, platform = process.platform, env = process.env) {
26
+ if (platform !== 'win32') return { command, args };
27
+ return {
28
+ command: env.ComSpec || env.COMSPEC || 'cmd.exe',
29
+ args: ['/d', '/s', '/c', `${command}.cmd`, ...args]
30
+ };
31
+ }
32
+
20
33
  /** Run a command synchronously, return { ok, stdout, stderr, status }. Never throws. */
21
34
  function run(cmd, args, opts = {}) {
22
35
  const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
@@ -48,11 +61,32 @@ function runAsync(cmd, args, opts = {}) {
48
61
  });
49
62
  });
50
63
  child.on('error', (err) => {
51
- resolve({ ok: false, status: null, stdout, stderr: stderr || err.message });
64
+ resolve({
65
+ ok: false,
66
+ status: null,
67
+ stdout,
68
+ stderr: stderr || err.message,
69
+ errorCode: err.code || ''
70
+ });
52
71
  });
53
72
  });
54
73
  }
55
74
 
75
+ /** Return one actionable line without flooding the installer UI with npm output. */
76
+ function commandFailureReason(result) {
77
+ const lines = `${result.stderr || ''}\n${result.stdout || ''}`
78
+ .split(/\r?\n/)
79
+ .map((line) => line.trim())
80
+ .filter(Boolean);
81
+ return lines.find((line) => (
82
+ /\bE[A-Z0-9_]{2,}\b/.test(line) || /\bnpm error code\b/i.test(line)
83
+ ))
84
+ || lines.find((line) => /^npm error\b/i.test(line))
85
+ || lines.at(-1)
86
+ || result.errorCode
87
+ || `exit ${result.status ?? 'unknown'}`;
88
+ }
89
+
56
90
  /** Is a command available on PATH? */
57
91
  function hasCmd(name) {
58
92
  const probe = isWindows ? run('where', [name]) : run('which', [name]);
@@ -104,12 +138,12 @@ async function pipInstall(skillsDir, requirementsPath, upgrade = false) {
104
138
  return r.ok;
105
139
  }
106
140
 
107
- /** `npm install` (fresh) or `npm update` (existing) in a skill scripts dir. Async — keeps spinner alive. */
141
+ /** `npm install` (fresh) or `npm update` (existing) in a skill scripts dir. */
108
142
  async function npmInstall(dir, upgrade = false, extraEnv = {}) {
109
143
  const args = upgrade ? ['update', '--no-audit', '--no-fund'] : ['install', '--no-audit', '--no-fund'];
110
144
  const env = { ...process.env, ...extraEnv };
111
- const r = await runAsync('npm', args, { cwd: dir, env });
112
- return r.ok;
145
+ const npm = resolvePackageCommand('npm', args);
146
+ return runAsync(npm.command, npm.args, { cwd: dir, env });
113
147
  }
114
148
 
115
149
  /**
@@ -130,10 +164,11 @@ async function installChromium(scriptsDir, interactive = false) {
130
164
  const r = await runAsync('node', [installMjs], { cwd: scriptsDir });
131
165
  return r.ok;
132
166
  }
167
+ const npx = resolvePackageCommand('npx', ['--yes', '@puppeteer/browsers', 'install', 'chrome']);
133
168
  if (interactive) {
134
- return run('npx', ['--yes', '@puppeteer/browsers', 'install', 'chrome'], { cwd: scriptsDir, stdio: 'inherit' }).ok;
169
+ return run(npx.command, npx.args, { cwd: scriptsDir, stdio: 'inherit' }).ok;
135
170
  }
136
- const r = await runAsync('npx', ['--yes', '@puppeteer/browsers', 'install', 'chrome'], { cwd: scriptsDir });
171
+ const r = await runAsync(npx.command, npx.args, { cwd: scriptsDir });
137
172
  return r.ok;
138
173
  }
139
174
 
@@ -149,7 +184,8 @@ function pkgHasDep(scriptsDir, name) {
149
184
 
150
185
  /** Download the Playwright Chromium browser (for the pptx html2pptx workflow). Async — keeps spinner alive. */
151
186
  async function installPlaywrightBrowser(scriptsDir) {
152
- const r = await runAsync('npx', ['--yes', 'playwright', 'install', 'chromium'], { cwd: scriptsDir });
187
+ const npx = resolvePackageCommand('npx', ['--yes', 'playwright', 'install', 'chromium']);
188
+ const r = await runAsync(npx.command, npx.args, { cwd: scriptsDir });
153
189
  return r.ok;
154
190
  }
155
191
 
@@ -255,6 +291,8 @@ function systemHint(tool) {
255
291
 
256
292
  module.exports = {
257
293
  isWindows,
294
+ resolvePackageCommand,
295
+ commandFailureReason,
258
296
  hasCmd,
259
297
  findPython,
260
298
  venvDir,
@@ -20,6 +20,33 @@ const CODEX_OWN_RUNTIME = [
20
20
  ['hooks.json', 'hooks.json']
21
21
  ];
22
22
 
23
+ const WINDOWS_HOOK_COMMAND = /^node "\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/;
24
+
25
+ function materializeWindowsHookCommands(content, projectRoot = process.cwd()) {
26
+ const config = JSON.parse(content);
27
+ const canonicalRoot = fs.realpathSync(projectRoot);
28
+
29
+ for (const groups of Object.values(config.hooks || {})) {
30
+ for (const group of groups) {
31
+ for (const handler of group.hooks || []) {
32
+ if (typeof handler.commandWindows !== 'string') continue;
33
+ const match = handler.commandWindows.match(WINDOWS_HOOK_COMMAND);
34
+ if (!match) {
35
+ throw new Error(`Unsupported Codex Windows hook command: ${handler.commandWindows}`);
36
+ }
37
+ const hookPath = path.join(canonicalRoot, '.codex', 'hooks', match[1]);
38
+ const encodedPath = Buffer.from(hookPath, 'utf8').toString('base64url');
39
+ handler.commandWindows = (
40
+ 'node -e "require(Buffer.from(process.argv[1],\'base64url\').toString(\'utf8\'))" ' +
41
+ encodedPath
42
+ );
43
+ }
44
+ }
45
+ }
46
+
47
+ return `${JSON.stringify(config, null, 2)}\n`;
48
+ }
49
+
23
50
  function writeSourceFile(ctx, platformKey, src, dest, label, transform) {
24
51
  const platform = PLATFORMS[platformKey];
25
52
  const { action } = writeManagedFile({
@@ -41,7 +68,10 @@ function installRuntimeFiles(ctx, platformKey) {
41
68
  platformKey,
42
69
  path.join(CODEX_SRC, sourceRel),
43
70
  path.join(platform.folder, targetRel),
44
- `Codex runtime: ${targetRel}`
71
+ `Codex runtime: ${targetRel}`,
72
+ sourceRel === 'hooks.json'
73
+ ? (content) => materializeWindowsHookCommands(content, process.cwd())
74
+ : undefined
45
75
  );
46
76
  }
47
77
  writeSourceFile(
@@ -107,4 +137,4 @@ function installCodexRuntime(ctx, platformKey) {
107
137
  return ctx;
108
138
  }
109
139
 
110
- module.exports = { installCodexRuntime };
140
+ module.exports = { installCodexRuntime, materializeWindowsHookCommands };
@@ -128,6 +128,19 @@ function cancelSelection(ctx) {
128
128
  return [];
129
129
  }
130
130
 
131
+ async function promptAddMorePlatforms(ctx, savedPlatforms) {
132
+ const allKeys = getPlatformKeys();
133
+ const available = allKeys.filter((key) => !savedPlatforms.includes(key));
134
+ if (available.length === 0) return false;
135
+
136
+ const r = await ctx.ui.confirm(
137
+ { message: ctx.t('addPlatformsPrompt', { names: platformNames(savedPlatforms) }), initialValue: false },
138
+ false
139
+ );
140
+ if (ctx.ui.isCancel(r)) return false;
141
+ return r;
142
+ }
143
+
131
144
  async function promptPlatforms(ctx) {
132
145
  const platforms = await promptPlatformSelection(ctx);
133
146
  return platforms.length > 0 ? platforms : cancelSelection(ctx);
@@ -164,6 +177,16 @@ async function resolvePlatforms(ctx) {
164
177
  platforms = await promptPlatforms(ctx);
165
178
  if (ctx.cancelled) return ctx;
166
179
  }
180
+ } else if (ctx.interactive) {
181
+ // No newly detected platforms, but user might want to ADD more platforms
182
+ const wantAdd = await promptAddMorePlatforms(ctx, savedPlatforms);
183
+ if (wantAdd) {
184
+ const added = await promptPlatformSelection(ctx);
185
+ if (ctx.cancelled) return ctx;
186
+ if (added.length > 0) {
187
+ platforms = [...new Set([...platforms, ...added])];
188
+ }
189
+ }
167
190
  }
168
191
  ctx.platforms = platforms;
169
192
  ctx.ui.info(ctx.t('platformKept', { names: platformNames(platforms) }));
@@ -79,12 +79,16 @@ async function setupNode(ctx, skillsDir) {
79
79
  const extraEnv = (skill === 'chrome-devtools' && systemChrome)
80
80
  ? { PUPPETEER_SKIP_DOWNLOAD: 'true' }
81
81
  : {};
82
- const ok = await dep.npmInstall(dir, hasNodeModules, extraEnv);
82
+ const result = await dep.npmInstall(dir, hasNodeModules, extraEnv);
83
83
 
84
- if (ok) {
84
+ if (result.ok) {
85
85
  ctx.ui.stopSpinner(ctx.t('npmInstalled', { skill }));
86
86
  } else {
87
- ctx.ui.stopSpinner(ctx.t('npmFailed', { skill, dir }));
87
+ ctx.ui.stopSpinner(ctx.t('npmFailed', {
88
+ skill,
89
+ dir,
90
+ error: dep.commandFailureReason(result)
91
+ }));
88
92
  continue;
89
93
  }
90
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.15.0",
3
+ "version": "0.15.3",
4
4
  "description": "Native spec-driven workflows for Claude Code, Codex CLI, and OpenCode. Bundles CafeKit skills, runtime hooks, agents, and installer scaffolding.",
5
5
  "author": "Haposoft <nghialt@haposoft.com>",
6
6
  "license": "MIT",
@@ -49,7 +49,7 @@
49
49
  "@opencode-ai/plugin": "1.15.11"
50
50
  },
51
51
  "dependencies": {
52
- "@clack/prompts": "^1.5.0",
52
+ "@clack/prompts": "0.11.0",
53
53
  "picocolors": "^1.1.1"
54
54
  }
55
55
  }