@haposoft/cafekit 0.10.3 → 0.11.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.
package/bin/install.js CHANGED
@@ -20,7 +20,7 @@ const lock = require('./lib/lock');
20
20
  const backup = require('./lib/backup');
21
21
  const manifestLib = require('./lib/manifest');
22
22
 
23
- const { resolvePlatforms } = require('./phases/select-platform');
23
+ const { selectLanguage, resolvePlatforms } = require('./phases/select-platform');
24
24
  const { copyPlatformFiles } = require('./phases/copy-payload');
25
25
  const {
26
26
  copyRoutingFile,
@@ -32,6 +32,7 @@ const {
32
32
  const { mergeClaudeSettings } = require('./phases/claude-settings');
33
33
  const { installOpenCodeRuntime } = require('./phases/opencode-runtime');
34
34
  const { writePlatformVersionMetadata } = require('./phases/write-metadata');
35
+ const { checkVersions } = require('./lib/version-check');
35
36
  const { ensureGitignore } = require('./phases/root-config');
36
37
  const { runPostInstall } = require('./phases/post-install');
37
38
  const { setupSkillDeps } = require('./phases/skills-setup');
@@ -46,7 +47,7 @@ function installPlatform(ctx, platformKey) {
46
47
  ctx.trackers[platformKey] = manifestLib.createTracker(platform.folder, packageJson.version);
47
48
 
48
49
  const before = { copied: ctx.results.copied, updated: ctx.results.updated, skills: ctx.results.installedSkills };
49
- ctx.ui.startSpinner(`Installing ${platform.name} (${platform.folder}/)`);
50
+ ctx.ui.startSpinner(ctx.t('installingPlatform', { name: platform.name }));
50
51
 
51
52
  copyPlatformFiles(ctx, platformKey);
52
53
 
@@ -66,13 +67,48 @@ function installPlatform(ctx, platformKey) {
66
67
  writePlatformVersionMetadata(ctx, platformKey);
67
68
 
68
69
  const wrote = (ctx.results.copied - before.copied) + (ctx.results.updated - before.updated);
69
- const verb = ctx.dryRun ? 'would install' : 'installed';
70
- ctx.ui.stopSpinner(`${platform.name} ${verb} — ${wrote} file(s), ${ctx.results.installedSkills - before.skills} skill(s)`);
70
+ const skills = ctx.results.installedSkills - before.skills;
71
+ ctx.ui.stopSpinner(ctx.t(ctx.dryRun ? 'platformDryInstalled' : 'platformInstalled', {
72
+ name: platform.name, files: wrote, skills
73
+ }));
71
74
 
72
75
  ctx.results.targets.push(platform.commandsDir);
73
76
  }
74
77
 
78
+ function printHelp() {
79
+ console.log(`CafeKit installer (cafekit) v${packageJson.version}
80
+
81
+ Usage: npx @haposoft/cafekit [options]
82
+
83
+ Installs CafeKit skills, agents, rules and runtime into the current project
84
+ (.claude/ and/or .opencode/). Re-runs are selective: managed files are updated,
85
+ your edits are preserved.
86
+
87
+ Options:
88
+ --dry-run Preview changes; write nothing
89
+ --force-overwrite Overwrite user-modified managed files (backup kept)
90
+ -u, --upgrade, -f, --force Alias of --force-overwrite
91
+ --with-skills-deps Install skill dependencies (Python venv + pip, npm,
92
+ Chromium/Playwright). Otherwise prompted interactively.
93
+ -y, --yes Non-interactive: skip prompts, use defaults (CI)
94
+ -h, --help Show this help
95
+ -v, --version Print version
96
+
97
+ Docs: https://github.com/haposoft/cafekit`);
98
+ }
99
+
75
100
  async function main() {
101
+ // Info flags short-circuit before any side effects.
102
+ const argv = process.argv.slice(2);
103
+ if (argv.includes('--help') || argv.includes('-h')) {
104
+ printHelp();
105
+ process.exit(0);
106
+ }
107
+ if (argv.includes('--version') || argv.includes('-v')) {
108
+ console.log(packageJson.version);
109
+ process.exit(0);
110
+ }
111
+
76
112
  // ── Concurrency guard (before context; plain output) ────
77
113
  const got = lock.acquire();
78
114
  if (!got.acquired) {
@@ -87,13 +123,15 @@ async function main() {
87
123
  try {
88
124
  ctx = buildContext(process.argv, `${Date.now()}`);
89
125
  ctx.ui.intro(`${ctx.ui.pc.bgCyan(ctx.ui.pc.black(' CafeKit '))}Installer v${packageJson.version} · Multi-platform SDD`);
90
- if (got.reclaimed) ctx.ui.info('Reclaimed a stale install lock from a dead process.');
126
+ if (got.reclaimed) ctx.ui.info(ctx.t('reclaimed'));
91
127
 
128
+ await selectLanguage(ctx);
92
129
  await resolvePlatforms(ctx);
93
- if (ctx.cancelled) {
94
- lock.release();
95
- process.exit(0);
96
- }
130
+ if (ctx.cancelled) { lock.release(); process.exit(0); }
131
+
132
+ // Version check: same → exit, downgrade → confirm
133
+ await checkVersions(ctx);
134
+ if (ctx.cancelled) { lock.release(); process.exit(0); }
97
135
 
98
136
  // ── Pre-run snapshot for rollback ─────────────────────
99
137
  // Capture platform folders AND the root files the pipeline mutates
@@ -117,14 +155,14 @@ async function main() {
117
155
  exitCode = ctx.results.errors > 0 ? 1 : 0;
118
156
  } catch (error) {
119
157
  const log = ctx && ctx.ui ? ctx.ui : { error: (m) => console.error(m) };
120
- log.error(`Installation failed: ${error.message}`);
158
+ const t = ctx ? ctx.t : (k) => k;
159
+ log.error(t('installFailed', { reason: error.message }));
121
160
  if (ctx && ctx.backupDir && !ctx.dryRun) {
122
161
  try {
123
162
  backup.restore(ctx.backupDir);
124
- log.error('Rolled back to the pre-install state from snapshot.');
163
+ log.error(t('rolledBack'));
125
164
  } catch (restoreError) {
126
- log.error(`Rollback failed: ${restoreError.message}`);
127
- log.error(`Manual restore available at: ${ctx.backupDir}`);
165
+ log.error(t('rollbackFailed', { reason: restoreError.message, dir: ctx.backupDir }));
128
166
  }
129
167
  }
130
168
  exitCode = 1;
@@ -15,6 +15,7 @@ const path = require('path');
15
15
  const packageJson = require('../../package.json');
16
16
  const { getOpenCodeCopyOptions } = require('./opencode-install');
17
17
  const { isInteractive, createUI } = require('./ui');
18
+ const { resolveLang, createTranslator } = require('./i18n');
18
19
 
19
20
  const INSTALL_COMMAND = `npx ${packageJson.name}@${packageJson.version}`;
20
21
 
@@ -198,6 +199,10 @@ function parseInstallerArgs(argv) {
198
199
  args.yes = true;
199
200
  } else if (arg === '--with-skills-deps') {
200
201
  args.withSkillsDeps = true;
202
+ } else if (arg === '--lang') {
203
+ args.lang = argv[++i];
204
+ } else if (arg.startsWith('--lang=')) {
205
+ args.lang = arg.slice('--lang='.length);
201
206
  }
202
207
  }
203
208
 
@@ -240,6 +245,8 @@ function buildContext(argv, runId) {
240
245
  const options = parseInstallerArgs(argv);
241
246
  // Interactive only on a real TTY and not when --yes/CI forces non-interactive.
242
247
  const interactive = isInteractive() && !options.yes;
248
+ // Language: --lang wins; otherwise English (an interactive prompt may change it).
249
+ const lang = options.lang ? resolveLang(options.lang) : 'en';
243
250
  return {
244
251
  argv,
245
252
  runId,
@@ -247,6 +254,13 @@ function buildContext(argv, runId) {
247
254
  dryRun: options.dryRun,
248
255
  interactive,
249
256
  ui: createUI(interactive),
257
+ lang,
258
+ t: createTranslator(lang),
259
+ /** Update the active language + translator (used by the language prompt). */
260
+ setLang(code) {
261
+ this.lang = resolveLang(code);
262
+ this.t = createTranslator(this.lang);
263
+ },
250
264
  manifest: loadClaudeMigrationManifest(), // migration manifest (skills/agents/runtime lists)
251
265
  platforms: [],
252
266
  results: createResults(),
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Installer i18n — English / 日本語 / Tiếng Việt.
3
+ *
4
+ * Covers every user-visible string: prompts, spinners, warnings, summary and
5
+ * next steps. High-frequency internal debug logs stay English.
6
+ *
7
+ * Tone: concise, friendly, action-oriented. No ellipsis abuse (…); imperative
8
+ * verbs; consistent capitalisation per language norms.
9
+ */
10
+
11
+ const SUPPORTED = ['en', 'ja', 'vi'];
12
+
13
+ const LANGUAGE_LABELS = { en: 'English', ja: '日本語', vi: 'Tiếng Việt' };
14
+
15
+ const MESSAGES = {
16
+ // ─── English ───────────────────────────────────────────────────────────────
17
+ en: {
18
+ // Banner / lock / rollback
19
+ reclaimed: 'Stale install lock reclaimed (previous process was gone).',
20
+ lockHeld: 'Another install is already running (pid {pid}, started {since}).\n If that process has ended, delete .cafekit.lock and try again.',
21
+ installFailed: 'Installation failed: {reason}',
22
+ rolledBack: 'Changes rolled back to the pre-install state.',
23
+ rollbackFailed: 'Rollback failed: {reason}\n Manual restore available at: {dir}',
24
+ dryRunBanner: 'Dry-run — no files will be written.',
25
+
26
+ // Language / platform selection
27
+ selectLanguage: 'Select language',
28
+ selectPlatform: 'Select platform(s) to install',
29
+ allPlatforms: 'All platforms',
30
+ confirmAllDetected: 'Found existing configs: {names}. Install for all?',
31
+ defaultedToClaude: 'No existing config detected — defaulting to Claude Code.',
32
+ cancelled: 'Installation cancelled.',
33
+
34
+ // Mode banner
35
+ modeForce: 'Mode: force-overwrite • user-modified files will be replaced (backup kept)',
36
+ modeDryRun: 'Mode: dry-run • no changes will be written',
37
+ modeInstall: 'Mode: install / update • your customisations are preserved',
38
+
39
+ // Platform install spinner
40
+ installingPlatform: 'Installing {name}',
41
+ platformInstalled: '{name} — {files} file(s), {skills} skill(s)',
42
+ platformDryInstalled: '{name} — {files} file(s), {skills} skill(s) [dry-run]',
43
+
44
+ // Addressing
45
+ addressingQuestion: 'What should the AI call you?',
46
+ addressingPlaceholder: 'e.g. boss, friend — Enter to skip',
47
+ addressingSet: '{name} will address you as "{addr}"',
48
+ addressingInvalid: 'Letters only please — addressing skipped.',
49
+
50
+ // Skills / deps setup
51
+ skillDepsConfirm: 'Install skill dependencies? (Python venv, pip, npm, Chromium — takes a few minutes)',
52
+ skillsSkipped: 'Skill dependencies skipped.\n Run later: npx @haposoft/cafekit --with-skills-deps',
53
+ venvCreating: 'Setting up Python environment',
54
+ venvReady: 'Python environment ready',
55
+ venvFailed: 'Could not create Python environment — check your Python 3 installation.',
56
+ venvNoHost: 'Python 3 not found. Install it, then re-run with --with-skills-deps.',
57
+ pipInstalling: 'Installing Python packages: {skill}',
58
+ pipInstalled: '{skill} — Python packages ready',
59
+ pipFailed: '{skill} — some packages failed. Retry:\n {cmd}',
60
+ npmInstalling: 'Installing Node packages: {skill}',
61
+ npmInstalled: '{skill} — Node packages ready',
62
+ npmFailed: '{skill} — npm install failed. Check the log and retry in: {dir}',
63
+ playwrightInstalling: 'Downloading Playwright browser for {skill}',
64
+ playwrightReady: '{skill} — Playwright browser ready',
65
+ playwrightSkipped: '{skill} — Playwright browser download skipped',
66
+ chromiumInstalling: 'Downloading Chromium for chrome-devtools',
67
+ chromiumReady: 'Chromium ready',
68
+ chromiumSkipped: 'Chromium download skipped — chrome-devtools will prompt on first use.',
69
+ optionalToolsTitle: 'Optional tools to install manually',
70
+
71
+ // Summary stats
72
+ summaryTitle: 'Installation complete',
73
+ summaryDryTitle: 'Dry-run preview',
74
+ labelCopied: 'Installed',
75
+ labelUpdated: 'Updated',
76
+ labelUnchanged: 'Unchanged',
77
+ labelPreserved: 'Preserved',
78
+ labelSkills: 'Skills',
79
+ labelVersion: 'CafeKit Version',
80
+ labelMissing: 'Missing',
81
+ labelErrors: 'Errors',
82
+ yes: 'yes',
83
+ no: 'no',
84
+ preservedNote: '{n} file(s) you edited were kept as-is.\n Re-run with --force-overwrite to reset them (a backup is saved to .cafekit-backup/).',
85
+
86
+ // Next steps
87
+ nextStepsTitle: 'Next steps',
88
+ nsClaude: 'Start coding: /hapo:specs <feature>',
89
+ nsOpencode: 'Start coding: ask the agent to begin a new feature or brainstorm',
90
+ nsKeys: 'Some skills need API keys: {skills}\n Copy <skill>/.env.example → .env and fill in the values',
91
+ nsForce: 'To force-refresh managed files: npx @haposoft/cafekit --force-overwrite',
92
+ dryRunOnly: 'Dry-run complete — no files were changed.\n Re-run without --dry-run to apply.',
93
+ outroDone: 'Done! docs: https://github.com/haposoft/cafekit',
94
+
95
+ // Version check
96
+ versionUpToDate: 'Already up to date ({v}). Use --force-overwrite to reinstall.',
97
+ versionDowngrade: 'Downgrading {from} → {to}. Features may be removed. Continue?'
98
+ },
99
+
100
+ // ─── 日本語 ─────────────────────────────────────────────────────────────────
101
+ ja: {
102
+ reclaimed: '前のプロセスが終了したため、ロックを解除しました。',
103
+ lockHeld: '別のインストールが実行中です(pid {pid}、開始: {since})。\n そのプロセスが終了している場合は .cafekit.lock を削除して再試行してください。',
104
+ installFailed: 'インストールに失敗しました: {reason}',
105
+ rolledBack: 'インストール前の状態に戻しました。',
106
+ rollbackFailed: 'ロールバックに失敗しました: {reason}\n 手動復元可能: {dir}',
107
+ dryRunBanner: 'ドライラン — ファイルへの書き込みは行いません。',
108
+
109
+ selectLanguage: '言語を選択してください',
110
+ selectPlatform: 'インストールするプラットフォームを選択',
111
+ allPlatforms: 'すべてのプラットフォーム',
112
+ confirmAllDetected: '既存の設定が見つかりました: {names}。すべてにインストールしますか?',
113
+ defaultedToClaude: '既存の設定が見つかりません — Claude Code をデフォルトで使用します。',
114
+ cancelled: 'インストールをキャンセルしました。',
115
+
116
+ modeForce: 'モード: 強制上書き • 編集済みファイルを置換します(バックアップあり)',
117
+ modeDryRun: 'モード: ドライラン • ファイルへの変更はありません',
118
+ modeInstall: 'モード: インストール / 更新 • カスタマイズは保持されます',
119
+
120
+ installingPlatform: '{name} をインストール中',
121
+ platformInstalled: '{name} — {files} ファイル、{skills} スキル',
122
+ platformDryInstalled: '{name} — {files} ファイル、{skills} スキル [ドライラン]',
123
+
124
+ addressingQuestion: 'AI にどう呼ばれたいですか?',
125
+ addressingPlaceholder: '例: さん、くん — Enter でスキップ',
126
+ addressingSet: '{name} はあなたを「{addr}」とお呼びします',
127
+ addressingInvalid: '文字のみ入力できます — 呼称の設定をスキップしました。',
128
+
129
+ skillDepsConfirm: 'スキルの依存関係をインストールしますか?(Python venv、pip、npm、Chromium — 数分かかります)',
130
+ skillsSkipped: 'スキルの依存関係をスキップしました。\n 後で実行: npx @haposoft/cafekit --with-skills-deps',
131
+ venvCreating: 'Python 環境を準備しています',
132
+ venvReady: 'Python 環境の準備ができました',
133
+ venvFailed: 'Python 環境を作成できませんでした — Python 3 のインストールを確認してください。',
134
+ venvNoHost: 'Python 3 が見つかりません。インストール後、--with-skills-deps で再実行してください。',
135
+ pipInstalling: 'Python パッケージをインストール中: {skill}',
136
+ pipInstalled: '{skill} — Python パッケージの準備ができました',
137
+ pipFailed: '{skill} — 一部のパッケージが失敗しました。再試行:\n {cmd}',
138
+ npmInstalling: 'Node パッケージをインストール中: {skill}',
139
+ npmInstalled: '{skill} — Node パッケージの準備ができました',
140
+ npmFailed: '{skill} — npm install に失敗しました。{dir} で手動再試行してください。',
141
+ playwrightInstalling: '{skill} 用の Playwright ブラウザをダウンロード中',
142
+ playwrightReady: '{skill} — Playwright ブラウザの準備ができました',
143
+ playwrightSkipped: '{skill} — Playwright ブラウザのダウンロードをスキップしました',
144
+ chromiumInstalling: 'chrome-devtools 用 Chromium をダウンロード中',
145
+ chromiumReady: 'Chromium の準備ができました',
146
+ chromiumSkipped: 'Chromium のダウンロードをスキップ — 初回使用時に chrome-devtools が自動取得します。',
147
+ optionalToolsTitle: '手動でインストールが必要なオプションツール',
148
+
149
+ summaryTitle: 'インストール完了',
150
+ summaryDryTitle: 'ドライラン プレビュー',
151
+ labelCopied: 'インストール',
152
+ labelUpdated: '更新',
153
+ labelUnchanged: '変更なし',
154
+ labelPreserved: '保持',
155
+ labelSkills: 'スキル',
156
+ labelVersion: 'CafeKit Version',
157
+ labelMissing: '不足',
158
+ labelErrors: 'エラー',
159
+ yes: 'はい',
160
+ no: 'いいえ',
161
+ preservedNote: '編集済みファイル {n} 件はそのままにしました。\n --force-overwrite で再実行するとリセットできます(.cafekit-backup/ にバックアップ保存)。',
162
+
163
+ nextStepsTitle: '次のステップ',
164
+ nsClaude: 'コーディング開始: /hapo:specs <機能名>',
165
+ nsOpencode: 'コーディング開始: エージェントに新機能の開始またはブレストを依頼',
166
+ nsKeys: '一部のスキルに API キーが必要です: {skills}\n <skill>/.env.example を .env にコピーし、値を入力してください',
167
+ nsForce: '管理ファイルを強制更新: npx @haposoft/cafekit --force-overwrite',
168
+ dryRunOnly: 'ドライラン完了 — ファイルへの変更はありませんでした。\n 変更を適用するには --dry-run なしで再実行してください。',
169
+ outroDone: '完了! ドキュメント: https://github.com/haposoft/cafekit',
170
+
171
+ versionUpToDate: '最新バージョンです({v})。再インストールするには --force-overwrite を使用してください。',
172
+ versionDowngrade: '{from} → {to} へのダウングレードです。機能が削除される可能性があります。続けますか?'
173
+ },
174
+
175
+ // ─── Tiếng Việt ─────────────────────────────────────────────────────────────
176
+ vi: {
177
+ reclaimed: 'Khóa cài đặt cũ đã được thu hồi (tiến trình trước đã kết thúc).',
178
+ lockHeld: 'Có một phiên cài đặt khác đang chạy (pid {pid}, bắt đầu: {since}).\n Nếu tiến trình đó đã kết thúc, hãy xóa .cafekit.lock rồi thử lại.',
179
+ installFailed: 'Cài đặt thất bại: {reason}',
180
+ rolledBack: 'Đã hoàn tác về trạng thái trước khi cài đặt.',
181
+ rollbackFailed: 'Hoàn tác thất bại: {reason}\n Bản sao lưu thủ công: {dir}',
182
+ dryRunBanner: 'Dry-run — sẽ không có tệp nào được ghi.',
183
+
184
+ selectLanguage: 'Chọn ngôn ngữ',
185
+ selectPlatform: 'Chọn (các) nền tảng cần cài',
186
+ allPlatforms: 'Tất cả nền tảng',
187
+ confirmAllDetected: 'Phát hiện cấu hình sẵn: {names}. Cài cho tất cả?',
188
+ defaultedToClaude: 'Không tìm thấy cấu hình sẵn — mặc định dùng Claude Code.',
189
+ cancelled: 'Đã hủy cài đặt.',
190
+
191
+ modeForce: 'Chế độ: ghi đè • file bạn đã sửa sẽ bị thay (có bản sao lưu)',
192
+ modeDryRun: 'Chế độ: dry-run • sẽ không thay đổi bất kỳ tệp nào',
193
+ modeInstall: 'Chế độ: cài / cập nhật • nội dung bạn tuỳ chỉnh được giữ nguyên',
194
+
195
+ installingPlatform: 'Đang cài {name}',
196
+ platformInstalled: '{name} — {files} tệp, {skills} skill',
197
+ platformDryInstalled: '{name} — {files} tệp, {skills} skill [dry-run]',
198
+
199
+ addressingQuestion: 'Bạn muốn AI gọi bạn là gì?',
200
+ addressingPlaceholder: 'vd: sếp, đại ca — Enter để bỏ qua',
201
+ addressingSet: '{name} sẽ gọi bạn là "{addr}"',
202
+ addressingInvalid: 'Chỉ nhập chữ cái — bỏ qua thiết lập xưng hô.',
203
+
204
+ skillDepsConfirm: 'Cài dependencies cho skill ngay? (Python venv, pip, npm, Chromium — mất vài phút)',
205
+ skillsSkipped: 'Đã bỏ qua dependencies skill.\n Cài sau bằng lệnh: npx @haposoft/cafekit --with-skills-deps',
206
+ venvCreating: 'Đang thiết lập môi trường Python',
207
+ venvReady: 'Môi trường Python đã sẵn sàng',
208
+ venvFailed: 'Không thể tạo môi trường Python — hãy kiểm tra cài đặt Python 3.',
209
+ venvNoHost: 'Không tìm thấy Python 3. Hãy cài Python 3 rồi chạy lại với --with-skills-deps.',
210
+ pipInstalling: 'Đang cài gói Python: {skill}',
211
+ pipInstalled: '{skill} — gói Python đã sẵn sàng',
212
+ pipFailed: '{skill} — một số gói cài thất bại. Chạy lại thủ công:\n {cmd}',
213
+ npmInstalling: 'Đang cài gói Node: {skill}',
214
+ npmInstalled: '{skill} — gói Node đã sẵn sàng',
215
+ npmFailed: '{skill} — npm install thất bại. Hãy thử lại trong: {dir}',
216
+ playwrightInstalling: 'Đang tải Playwright browser cho {skill}',
217
+ playwrightReady: '{skill} — Playwright browser đã sẵn sàng',
218
+ playwrightSkipped: '{skill} — đã bỏ qua tải Playwright browser',
219
+ chromiumInstalling: 'Đang tải Chromium cho chrome-devtools',
220
+ chromiumReady: 'Chromium đã sẵn sàng',
221
+ chromiumSkipped: 'Bỏ qua tải Chromium — chrome-devtools sẽ tự tải khi dùng lần đầu.',
222
+ optionalToolsTitle: 'Công cụ tuỳ chọn cần cài thủ công',
223
+
224
+ summaryTitle: 'Cài đặt hoàn tất',
225
+ summaryDryTitle: 'Xem trước dry-run',
226
+ labelCopied: 'Đã cài',
227
+ labelUpdated: 'Đã cập nhật',
228
+ labelUnchanged: 'Không đổi',
229
+ labelPreserved: 'Đã giữ',
230
+ labelSkills: 'Skill',
231
+ labelVersion: 'CafeKit Version',
232
+ labelMissing: 'Thiếu',
233
+ labelErrors: 'Lỗi',
234
+ yes: 'có',
235
+ no: 'không',
236
+ preservedNote: '{n} tệp bạn đã chỉnh sửa được giữ nguyên.\n Chạy lại với --force-overwrite để cài đè (bản sao lưu ở .cafekit-backup/).',
237
+
238
+ nextStepsTitle: 'Bước tiếp theo',
239
+ nsClaude: 'Bắt đầu code: /hapo:specs <mô-tả-tính-năng>',
240
+ nsOpencode: 'Bắt đầu code: yêu cầu agent khởi động tính năng mới hoặc brainstorm',
241
+ nsKeys: 'Một số skill cần API key: {skills}\n Sao chép <skill>/.env.example → .env rồi điền giá trị',
242
+ nsForce: 'Làm mới file được quản lý: npx @haposoft/cafekit --force-overwrite',
243
+ dryRunOnly: 'Dry-run hoàn tất — không có tệp nào thay đổi.\n Chạy lại không có --dry-run để áp dụng.',
244
+ outroDone: 'Xong! Tài liệu: https://github.com/haposoft/cafekit',
245
+
246
+ versionUpToDate: 'Đã là phiên bản mới nhất ({v}). Dùng --force-overwrite để cài lại.',
247
+ versionDowngrade: 'Đang hạ cấp {from} → {to}. Một số tính năng có thể bị xoá. Tiếp tục?'
248
+ }
249
+ };
250
+
251
+ /** Normalize a language code to a supported one; unknown → 'ja'. */
252
+ function resolveLang(code) {
253
+ if (!code) return 'en';
254
+ const lc = String(code).toLowerCase().slice(0, 2);
255
+ return SUPPORTED.includes(lc) ? lc : 'ja';
256
+ }
257
+
258
+ /** Build a translator. Falls back to English per-key, then the key itself. */
259
+ function createTranslator(lang) {
260
+ const resolved = resolveLang(lang);
261
+ return function t(key, vars = {}) {
262
+ const template = (MESSAGES[resolved] && MESSAGES[resolved][key]) || MESSAGES.en[key] || key;
263
+ return template.replace(/\{(\w+)\}/g, (_, name) => (vars[name] !== undefined ? String(vars[name]) : `{${name}}`));
264
+ };
265
+ }
266
+
267
+ module.exports = { SUPPORTED, LANGUAGE_LABELS, MESSAGES, resolveLang, createTranslator };
@@ -95,6 +95,16 @@ function createTracker(platformFolder, version) {
95
95
  return Object.keys(files).length;
96
96
  },
97
97
 
98
+ /** Keys explicitly pruned this run — must not survive the merge into the prior manifest. */
99
+ _pruned: new Set(),
100
+
101
+ /** Remove a relPath from this run's tracked files (e.g. after obsolete removal). */
102
+ prune(rel) {
103
+ const key = rel.replace(/\\/g, '/');
104
+ delete files[key];
105
+ this._pruned.add(key);
106
+ },
107
+
98
108
  /** Recorded hash for a relPath written earlier this run, or null. */
99
109
  recorded(rel) {
100
110
  const key = rel.replace(/\\/g, '/');
@@ -107,7 +117,10 @@ function createTracker(platformFolder, version) {
107
117
  */
108
118
  write() {
109
119
  const prior = read(platformFolder);
110
- const merged = { ...prior.files, ...files };
120
+ // Start from prior, delete pruned entries, then overlay this run's files.
121
+ const merged = { ...prior.files };
122
+ for (const key of this._pruned) delete merged[key];
123
+ Object.assign(merged, files);
111
124
  const out = {
112
125
  schemaVersion: SCHEMA_VERSION,
113
126
  version,
@@ -69,16 +69,17 @@ function pipUpgrade(skillsDir) {
69
69
  return run(venvPython(skillsDir), ['-m', 'pip', 'install', '--upgrade', 'pip', '--prefer-binary']).ok;
70
70
  }
71
71
 
72
- /** Install a requirements.txt into the venv (wheels first). Returns true on success. */
73
- function pipInstall(skillsDir, requirementsPath) {
74
- return run(venvPython(skillsDir), [
75
- '-m', 'pip', 'install', '-r', requirementsPath, '--prefer-binary'
76
- ]).ok;
72
+ /** Install or upgrade pip requirements in the venv. */
73
+ function pipInstall(skillsDir, requirementsPath, upgrade = false) {
74
+ const args = ['-m', 'pip', 'install', '-r', requirementsPath, '--prefer-binary'];
75
+ if (upgrade) args.push('--upgrade');
76
+ return run(venvPython(skillsDir), args).ok;
77
77
  }
78
78
 
79
- /** `npm install` in a skill scripts dir. Returns true on success. */
80
- function npmInstall(dir) {
81
- return run('npm', ['install', '--no-audit', '--no-fund'], { cwd: dir }).ok;
79
+ /** `npm install` (fresh) or `npm update` (existing) in a skill scripts dir. */
80
+ function npmInstall(dir, upgrade = false) {
81
+ const args = upgrade ? ['update', '--no-audit', '--no-fund'] : ['install', '--no-audit', '--no-fund'];
82
+ return run('npm', args, { cwd: dir }).ok;
82
83
  }
83
84
 
84
85
  /**
@@ -115,6 +116,7 @@ function installPlaywrightBrowser(scriptsDir) {
115
116
  /**
116
117
  * List installed skills that ship a runnable scripts/requirements.txt
117
118
  * (excludes the tests/ requirements). Returns [{ skill, file }].
119
+ * Always returned regardless of whether venv already exists (caller decides upgrade vs fresh).
118
120
  */
119
121
  function collectRequirements(skillsDir) {
120
122
  const out = [];
@@ -127,16 +129,16 @@ function collectRequirements(skillsDir) {
127
129
  }
128
130
 
129
131
  /**
130
- * List installed skills with a scripts/package.json that still needs installing
131
- * (no node_modules present). Returns [{ skill, dir }].
132
+ * List installed skills with a scripts/package.json.
133
+ * Returns [{ skill, dir, hasNodeModules }] — always, so caller can upgrade existing installs.
132
134
  */
133
135
  function collectSkillPackages(skillsDir) {
134
136
  const out = [];
135
137
  if (!fs.existsSync(skillsDir)) return out;
136
138
  for (const skill of fs.readdirSync(skillsDir)) {
137
139
  const dir = path.join(skillsDir, skill, 'scripts');
138
- if (fs.existsSync(path.join(dir, 'package.json')) && !fs.existsSync(path.join(dir, 'node_modules'))) {
139
- out.push({ skill, dir });
140
+ if (fs.existsSync(path.join(dir, 'package.json'))) {
141
+ out.push({ skill, dir, hasNodeModules: fs.existsSync(path.join(dir, 'node_modules')) });
140
142
  }
141
143
  }
142
144
  return out;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Version check before install.
3
+ *
4
+ * Reads each platform's cafekit.json to compare the installed version with the
5
+ * incoming one and decides whether to proceed:
6
+ *
7
+ * same → info + exit 0 (unless --force-overwrite)
8
+ * newer → normal update
9
+ * older → downgrade warning + confirm (interactive) or abort (non-interactive)
10
+ * missing → fresh install, no check needed
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { PLATFORMS, packageJson } = require('./context');
16
+
17
+ /** Simple semver-ish comparison. Returns -1 | 0 | 1. */
18
+ function cmpVersion(a, b) {
19
+ const toNum = (v) => String(v).split('.').map((n) => parseInt(n, 10) || 0);
20
+ const [aN, bN] = [toNum(a), toNum(b)];
21
+ for (let i = 0; i < 3; i++) {
22
+ const diff = (aN[i] || 0) - (bN[i] || 0);
23
+ if (diff !== 0) return diff < 0 ? -1 : 1;
24
+ }
25
+ return 0;
26
+ }
27
+
28
+ /**
29
+ * Read the currently installed version for a platform.
30
+ * Returns null if cafekit.json doesn't exist yet (fresh install).
31
+ */
32
+ function getInstalledVersion(platformKey) {
33
+ const p = path.join(PLATFORMS[platformKey].folder, 'cafekit.json');
34
+ try {
35
+ if (!fs.existsSync(p)) return null;
36
+ const meta = JSON.parse(fs.readFileSync(p, 'utf8'));
37
+ return typeof meta.version === 'string' ? meta.version : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Check versions for all selected platforms and decide whether to proceed.
45
+ * Sets ctx.cancelled on same-version (without --force) or unconfirmed downgrade.
46
+ */
47
+ async function checkVersions(ctx) {
48
+ if (ctx.dryRun) return ctx;
49
+
50
+ const incoming = packageJson.version;
51
+
52
+ for (const key of ctx.platforms) {
53
+ const installed = getInstalledVersion(key);
54
+ if (!installed) continue; // fresh install — no check needed
55
+
56
+ const cmp = cmpVersion(installed, incoming);
57
+
58
+ // ── Same version ─────────────────────────────────────────────────────────
59
+ if (cmp === 0) {
60
+ if (ctx.options.forceOverwrite) continue; // --force-overwrite overrides
61
+ ctx.ui.info(ctx.t ? ctx.t('versionUpToDate', { v: installed }) : `Already up to date (${installed}). Use --force-overwrite to reinstall.`);
62
+ ctx.cancelled = true;
63
+ return ctx;
64
+ }
65
+
66
+ // ── Downgrade ─────────────────────────────────────────────────────────────
67
+ if (cmp > 0) {
68
+ const msg = ctx.t
69
+ ? ctx.t('versionDowngrade', { from: installed, to: incoming })
70
+ : `Downgrading ${installed} → ${incoming}. This may remove features. Continue?`;
71
+
72
+ if (!ctx.interactive) {
73
+ ctx.ui.warn ? ctx.ui.warn(`Downgrade detected (${installed} → ${incoming}). Aborted in non-interactive mode. Use --force-overwrite to proceed.`) : console.warn(msg);
74
+ ctx.cancelled = true;
75
+ return ctx;
76
+ }
77
+
78
+ const yes = await ctx.ui.confirm({ message: msg, initialValue: false }, false);
79
+ if (!yes || ctx.ui.isCancel(yes)) {
80
+ ctx.cancelled = true;
81
+ return ctx;
82
+ }
83
+ }
84
+
85
+ // cmp < 0 → upgrade, proceed normally
86
+ }
87
+
88
+ return ctx;
89
+ }
90
+
91
+ module.exports = { getInstalledVersion, cmpVersion, checkVersions };
@@ -78,7 +78,13 @@ function removeObsoleteClaudeRuntimeFiles(ctx, platformKey) {
78
78
  const targetPath = path.join(targetBase, relPath);
79
79
  if (!fs.existsSync(targetPath)) return;
80
80
 
81
- if (!ctx.dryRun) fs.rmSync(targetPath, { force: true });
81
+ if (!ctx.dryRun) {
82
+ fs.rmSync(targetPath, { force: true });
83
+ // Remove the entry from the manifest so it doesn't linger as a zombie.
84
+ if (ctx.trackers && ctx.trackers.claude) {
85
+ ctx.trackers.claude.prune(relPath);
86
+ }
87
+ }
82
88
  ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete runtime: ${relPath}`);
83
89
  ctx.results.updated++;
84
90
  });
@@ -13,8 +13,86 @@
13
13
 
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
+ const { PLATFORMS } = require('../lib/context');
17
+ const { LANGUAGE_LABELS } = require('../lib/i18n');
16
18
  const { setupOpenCodeModel } = require('../lib/opencode-install');
17
19
 
20
+ /** Write `"language"` field into .claude/settings.json so it's visible at a glance. */
21
+ function patchSettingsLanguage(ctx) {
22
+ if (!ctx.platforms.includes('claude')) return;
23
+ const settingsPath = path.join(process.cwd(), '.claude', 'settings.json');
24
+ if (!fs.existsSync(settingsPath)) return;
25
+
26
+ let settings;
27
+ try {
28
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
29
+ } catch {
30
+ return;
31
+ }
32
+
33
+ const label = LANGUAGE_LABELS[ctx.lang] || ctx.lang;
34
+ if (settings.language === label) return; // already up to date
35
+ settings.language = label;
36
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
37
+ }
38
+
39
+ /**
40
+ * Patch the "## Language Consistency" section in CLAUDE.md with the chosen
41
+ * language so every AI session knows which language to respond in — without
42
+ * relying on hooks reading runtime.json.
43
+ *
44
+ * The section is identified by a stable marker comment so subsequent installs
45
+ * can update it idempotently.
46
+ */
47
+ function patchLanguageSection(ctx) {
48
+ if (!ctx.platforms.includes('claude')) return;
49
+ if (ctx.lang === 'en') return; // English is the default — no override needed.
50
+
51
+ const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
52
+ if (!fs.existsSync(claudeMdFile)) return;
53
+
54
+ const LANG_LABEL = { vi: 'Vietnamese', ja: 'Japanese', en: 'English' };
55
+ const label = LANG_LABEL[ctx.lang] || ctx.lang;
56
+
57
+ const newSection = `## Language Consistency <!-- cafekit:lang -->
58
+
59
+ Always respond in **${label}**. Technical terms, code identifiers, and file paths may remain in English, but all explanations, comments directed at the user, and structured output (specs, docs, reports) must be in ${label}.
60
+
61
+ `;
62
+
63
+ let content = fs.readFileSync(claudeMdFile, 'utf8');
64
+ // Replace if marker present, else replace the generic section.
65
+ const markerRe = /## Language Consistency <!-- cafekit:lang -->[\s\S]*?(?=\n##|\n*$)/;
66
+ const genericRe = /## Language Consistency\n[\s\S]*?(?=\n##|\n*$)/;
67
+
68
+ if (markerRe.test(content)) {
69
+ content = content.replace(markerRe, newSection);
70
+ } else if (genericRe.test(content)) {
71
+ content = content.replace(genericRe, newSection);
72
+ } else {
73
+ content += `\n${newSection}\n`;
74
+ }
75
+
76
+ fs.writeFileSync(claudeMdFile, content, 'utf8');
77
+ }
78
+ function patchRuntimeLocale(ctx) {
79
+ for (const key of ctx.platforms) {
80
+ const rtPath = path.join(PLATFORMS[key].folder, 'runtime.json');
81
+ if (!fs.existsSync(rtPath)) continue;
82
+ let data;
83
+ try {
84
+ data = JSON.parse(fs.readFileSync(rtPath, 'utf8'));
85
+ } catch {
86
+ continue;
87
+ }
88
+ data.locale = data.locale || {};
89
+ if (data.locale.responseLanguage === ctx.lang) continue;
90
+ data.locale.responseLanguage = ctx.lang;
91
+ fs.writeFileSync(rtPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
92
+ if (ctx.trackers[key]) ctx.trackers[key].record(rtPath);
93
+ }
94
+ }
95
+
18
96
  /** Human assistant name for the active platform(s). */
19
97
  function assistantName(ctx) {
20
98
  return ctx.platforms.includes('claude') ? 'Claude Code' : 'OpenCode';
@@ -40,22 +118,23 @@ ${name} always addresses the user as "${userAddress}" throughout the conversatio
40
118
  : `${content.endsWith('\n') ? content : `${content}\n`}\n${addressingSection}\n`;
41
119
 
42
120
  fs.writeFileSync(claudeMdFile, content, 'utf8');
43
- ctx.ui.success(`${name} will call you "${userAddress}"`);
121
+ ctx.ui.success(ctx.t('addressingSet', { name, addr: userAddress }));
44
122
  }
45
123
 
46
124
  async function setupAddressing(ctx) {
47
125
  if (!ctx.platforms.includes('claude')) return;
48
126
 
49
127
  const answer = await ctx.ui.text(
50
- { message: 'How should the AI address you?', placeholder: 'e.g. boss, sir — Enter to skip' },
128
+ { message: ctx.t('addressingQuestion'), placeholder: ctx.t('addressingPlaceholder') },
51
129
  ''
52
130
  );
53
131
  if (ctx.ui.isCancel(answer)) return;
54
132
  const userAddress = (answer || '').trim();
55
133
  if (!userAddress) return;
56
134
 
57
- if (/[^a-zA-ZÀ-ỹ\s]/.test(userAddress)) {
58
- ctx.ui.warn('Invalid input (letters only); skipped addressing');
135
+ // Allow letters from any script (incl. Japanese) + spaces; reject digits/symbols.
136
+ if (/[^\p{L}\s]/u.test(userAddress)) {
137
+ ctx.ui.warn(ctx.t('addressingInvalid'));
59
138
  return;
60
139
  }
61
140
  configureAddressing(ctx, userAddress);
@@ -77,11 +156,22 @@ async function runPostInstall(ctx) {
77
156
 
78
157
  await setupAddressing(ctx);
79
158
 
80
- // Re-record CLAUDE.md baseline so the installer-managed file (template +
81
- // addressing) stays "pristine" and keeps receiving upstream updates.
159
+ // Patch Language Consistency section in CLAUDE.md so AI responds in the chosen language.
160
+ patchLanguageSection(ctx);
161
+
162
+ // Persist chosen language into each platform's runtime.json (records in tracker).
163
+ patchRuntimeLocale(ctx);
164
+
165
+ // Write "language" field into .claude/settings.json for visibility.
166
+ patchSettingsLanguage(ctx);
167
+
168
+ // Re-record post-write baselines so installer-managed files stay "pristine",
169
+ // then flush each touched platform tracker.
82
170
  if (ctx.platforms.includes('claude') && ctx.trackers.claude && fs.existsSync('CLAUDE.md')) {
83
171
  ctx.trackers.claude.record('CLAUDE.md');
84
- ctx.trackers.claude.write();
172
+ }
173
+ for (const key of ctx.platforms) {
174
+ if (ctx.trackers[key]) ctx.trackers[key].write();
85
175
  }
86
176
  return ctx;
87
177
  }
@@ -1,10 +1,9 @@
1
1
  /**
2
- * Phase: platform detection + selection.
2
+ * Phase: language + platform selection.
3
3
  *
4
- * Resolves which platforms to install for: auto-detect existing folders, or
5
- * prompt (clack) when interactive. In non-interactive mode it uses detection
6
- * only and defaults to Claude when nothing is found — never blocks on input.
7
- * Sets ctx.platforms, or ctx.cancelled if the user backs out.
4
+ * Language is chosen first (interactive) so the rest of the installer renders in
5
+ * it; non-interactive uses --lang or English. Then platforms are auto-detected
6
+ * or prompted. Sets ctx.platforms, or ctx.cancelled if the user backs out.
8
7
  */
9
8
 
10
9
  const {
@@ -13,6 +12,23 @@ const {
13
12
  getPlatformKeys,
14
13
  warnLegacyClaudeFolder
15
14
  } = require('../lib/context');
15
+ const { SUPPORTED, LANGUAGE_LABELS } = require('../lib/i18n');
16
+
17
+ /** First step: pick the installer/UI language (interactive only). */
18
+ async function selectLanguage(ctx) {
19
+ // Honor an explicit --lang or non-interactive default (English) without prompting.
20
+ if (!ctx.interactive || ctx.options.lang) return ctx;
21
+
22
+ const r = await ctx.ui.select(
23
+ {
24
+ message: 'Select language · 言語を選択 · Chọn ngôn ngữ',
25
+ options: SUPPORTED.map((code) => ({ value: code, label: LANGUAGE_LABELS[code] }))
26
+ },
27
+ ctx.lang
28
+ );
29
+ if (!ctx.ui.isCancel(r)) ctx.setLang(r);
30
+ return ctx;
31
+ }
16
32
 
17
33
  /** clack select: which platform(s) to install for. */
18
34
  async function promptPlatformSelection(ctx) {
@@ -22,12 +38,9 @@ async function promptPlatformSelection(ctx) {
22
38
  label: PLATFORMS[key].name,
23
39
  hint: `${PLATFORMS[key].folder}/`
24
40
  })),
25
- { value: getPlatformKeys(), label: 'All platforms' }
41
+ { value: getPlatformKeys(), label: ctx.t('allPlatforms') }
26
42
  ];
27
- const r = await ctx.ui.select(
28
- { message: 'Select which platform(s) to install for', options },
29
- []
30
- );
43
+ const r = await ctx.ui.select({ message: ctx.t('selectPlatform'), options }, []);
31
44
  if (ctx.ui.isCancel(r)) return [];
32
45
  return r;
33
46
  }
@@ -36,7 +49,7 @@ async function promptPlatformSelection(ctx) {
36
49
  async function confirmAllDetected(ctx, detected) {
37
50
  const names = detected.map((key) => PLATFORMS[key].name).join(', ');
38
51
  const r = await ctx.ui.confirm(
39
- { message: `Install for all detected platforms? (${names})`, initialValue: true },
52
+ { message: ctx.t('confirmAllDetected', { names }), initialValue: true },
40
53
  true
41
54
  );
42
55
  if (ctx.ui.isCancel(r)) return false;
@@ -53,12 +66,11 @@ async function resolvePlatforms(ctx) {
53
66
  if (ctx.interactive) {
54
67
  platforms = await promptPlatformSelection(ctx);
55
68
  if (platforms.length === 0) {
56
- ctx.ui.outro('Installation cancelled.');
69
+ ctx.ui.outro(ctx.t('cancelled'));
57
70
  ctx.cancelled = true;
58
71
  return ctx;
59
72
  }
60
73
  } else {
61
- // Non-interactive with nothing detected → sensible default, never block.
62
74
  platforms = ['claude'];
63
75
  ctx.ui.info('No platform detected; defaulting to Claude Code (.claude/).');
64
76
  }
@@ -67,7 +79,7 @@ async function resolvePlatforms(ctx) {
67
79
  if (!proceed) {
68
80
  platforms = await promptPlatformSelection(ctx);
69
81
  if (platforms.length === 0) {
70
- ctx.ui.outro('Installation cancelled.');
82
+ ctx.ui.outro(ctx.t('cancelled'));
71
83
  ctx.cancelled = true;
72
84
  return ctx;
73
85
  }
@@ -77,18 +89,18 @@ async function resolvePlatforms(ctx) {
77
89
  ctx.platforms = platforms;
78
90
 
79
91
  const platformNames = platforms.map((key) => PLATFORMS[key].name).join(', ');
80
- ctx.ui.info(`Installing for: ${platformNames}`);
92
+ ctx.ui.info(ctx.t('installingFor', { names: platformNames }));
81
93
  warnLegacyClaudeFolder(platforms);
82
94
 
83
95
  if (ctx.options.forceOverwrite) {
84
- ctx.ui.info('Mode: force-overwrite (replace user-modified files; backup kept)');
96
+ ctx.ui.info(ctx.t('modeForce'));
85
97
  } else if (ctx.dryRun) {
86
- ctx.ui.info('Mode: dry-run (preview only, no changes written)');
98
+ ctx.ui.info(ctx.t('modeDryRun'));
87
99
  } else {
88
- ctx.ui.info('Mode: install/update (selective; preserves user-modified files)');
100
+ ctx.ui.info(ctx.t('modeInstall'));
89
101
  }
90
102
 
91
103
  return ctx;
92
104
  }
93
105
 
94
- module.exports = { promptPlatformSelection, resolvePlatforms };
106
+ module.exports = { selectLanguage, promptPlatformSelection, resolvePlatforms };
@@ -5,6 +5,7 @@
5
5
  * - Python venv at <skillsDir>/.venv + pip install each skill's requirements
6
6
  * - skill-local `npm install` (scripts/package.json without node_modules)
7
7
  * - Puppeteer Chromium binary (chrome-devtools)
8
+ * - Playwright browser binary (pptx html2pptx)
8
9
  *
9
10
  * System binaries (ffmpeg, poppler, librsvg, tesseract) and global npm CLIs are
10
11
  * NOT auto-installed — they are detected and printed as guidance. All steps are
@@ -23,93 +24,96 @@ async function shouldRun(ctx) {
23
24
  if (ctx.options.withSkillsDeps) return true;
24
25
  if (!ctx.interactive) return false;
25
26
  const yes = await ctx.ui.confirm(
26
- { message: 'Install skill dependencies now? (Python venv + pip + Chromium — slower)', initialValue: false },
27
+ { message: ctx.t('skillDepsConfirm'), initialValue: false },
27
28
  false
28
29
  );
29
30
  return yes === true;
30
31
  }
31
32
 
32
- /** Provision Python venv + pip requirements for a platform's skills. */
33
+ /** Provision or upgrade Python venv + pip requirements for a platform's skills. */
33
34
  function setupPython(ctx, skillsDir) {
34
35
  const reqs = dep.collectRequirements(skillsDir);
35
36
  if (reqs.length === 0) return;
36
37
 
37
38
  const py = dep.findPython();
38
39
  if (!py) {
39
- ctx.ui.warn('Python 3 not found — skipped venv. Install Python 3, then re-run with --with-skills-deps.');
40
+ ctx.ui.warn(ctx.t('venvNoHost'));
40
41
  return;
41
42
  }
42
43
 
43
- ctx.ui.startSpinner('Creating Python venv...');
44
+ const alreadyExists = dep.venvValid(skillsDir);
45
+ ctx.ui.startSpinner(ctx.t('venvCreating'));
44
46
  if (!dep.createVenv(skillsDir, py)) {
45
- ctx.ui.stopSpinner('Could not create Python venv');
46
- ctx.ui.warn(`venv creation failed at ${dep.venvDir(skillsDir)} — check your Python install.`);
47
+ ctx.ui.stopSpinner(ctx.t('venvFailed'));
48
+ ctx.ui.warn(ctx.t('venvFailed'));
47
49
  return;
48
50
  }
49
51
  dep.pipUpgrade(skillsDir);
50
- ctx.ui.stopSpinner(`Python venv ready (${dep.venvDir(skillsDir)})`);
52
+ ctx.ui.stopSpinner(`${ctx.t('venvReady')} (${dep.venvDir(skillsDir)})`);
51
53
 
54
+ // Upgrade packages if venv already existed (update run), install fresh otherwise.
55
+ const upgrade = alreadyExists;
52
56
  for (const { skill, file } of reqs) {
53
- ctx.ui.startSpinner(`Installing Python deps: ${skill}...`);
54
- const ok = dep.pipInstall(skillsDir, file);
55
- ctx.ui.stopSpinner(ok ? `${skill}: Python deps installed` : `${skill}: some Python deps failed`);
56
- if (!ok) {
57
- ctx.ui.warn(`pip failed for ${skill}. Retry: "${dep.venvPython(skillsDir)}" -m pip install -r "${file}"`);
57
+ ctx.ui.startSpinner(ctx.t(upgrade ? 'pipInstalling' : 'pipInstalling', { skill }));
58
+ const ok = dep.pipInstall(skillsDir, file, upgrade);
59
+ if (ok) {
60
+ ctx.ui.stopSpinner(ctx.t('pipInstalled', { skill }));
61
+ } else {
62
+ ctx.ui.stopSpinner(ctx.t('pipFailed', { skill, cmd: `"${dep.venvPython(skillsDir)}" -m pip install -r "${file}"` }));
58
63
  }
59
64
  }
60
65
  }
61
66
 
62
- /** Run skill-local npm installs + Chromium for a platform's skills. */
67
+ /** Run skill-local npm installs/upgrades + browser binaries for a platform's skills. */
63
68
  function setupNode(ctx, skillsDir) {
64
- for (const { skill, dir } of dep.collectSkillPackages(skillsDir)) {
65
- ctx.ui.startSpinner(`Installing npm deps: ${skill}...`);
66
- const ok = dep.npmInstall(dir);
67
- ctx.ui.stopSpinner(ok ? `${skill}: npm deps installed` : `${skill}: npm install failed`);
68
- if (!ok) {
69
- ctx.ui.warn(`npm install failed in ${dir} — retry manually.`);
69
+ for (const { skill, dir, hasNodeModules } of dep.collectSkillPackages(skillsDir)) {
70
+ ctx.ui.startSpinner(ctx.t('npmInstalling', { skill }));
71
+ const ok = dep.npmInstall(dir, hasNodeModules); // upgrade if node_modules exists
72
+ if (ok) {
73
+ ctx.ui.stopSpinner(ctx.t('npmInstalled', { skill }));
74
+ } else {
75
+ ctx.ui.stopSpinner(ctx.t('npmFailed', { skill, dir }));
70
76
  continue;
71
77
  }
72
- // Skills using Playwright (e.g. pptx html2pptx) need a browser binary too.
78
+
79
+ // Skills using Playwright (e.g. pptx) also need a browser binary.
73
80
  if (dep.pkgHasDep(dir, 'playwright')) {
74
- ctx.ui.startSpinner(`Downloading Playwright browser for ${skill}...`);
81
+ ctx.ui.startSpinner(ctx.t('playwrightInstalling', { skill }));
75
82
  const pw = dep.installPlaywrightBrowser(dir);
76
- ctx.ui.stopSpinner(pw ? `${skill}: Playwright browser ready` : `${skill}: Playwright browser skipped`);
83
+ ctx.ui.stopSpinner(pw ? ctx.t('playwrightReady', { skill }) : ctx.t('playwrightSkipped', { skill }));
77
84
  }
78
85
  }
79
86
 
87
+ // Chromium for chrome-devtools (puppeteer-based).
80
88
  const chromeScripts = path.join(skillsDir, 'chrome-devtools', 'scripts');
81
89
  if (fs.existsSync(path.join(chromeScripts, 'package.json'))) {
82
- ctx.ui.startSpinner('Downloading Chromium for chrome-devtools...');
90
+ ctx.ui.startSpinner(ctx.t('chromiumInstalling'));
83
91
  const ok = dep.installChromium(chromeScripts);
84
- ctx.ui.stopSpinner(ok ? 'Chromium ready' : 'Chromium download skipped/failed');
85
- if (!ok) ctx.ui.warn('Chromium not downloaded — chrome-devtools may prompt on first use.');
92
+ ctx.ui.stopSpinner(ok ? ctx.t('chromiumReady') : ctx.t('chromiumSkipped'));
86
93
  }
87
94
  }
88
95
 
89
96
  /** Detect (don't install) system binaries + global npm CLIs and print guidance. */
90
97
  function guideManual(ctx) {
91
98
  const missingTools = dep.SYSTEM_TOOLS.filter((t) => !dep.hasCmd(t.cmd));
92
- const missingNpm = dep.GLOBAL_NPM.filter((g) => !dep.hasCmd(g.cmd));
99
+ const missingNpm = dep.GLOBAL_NPM.filter((g) => !dep.hasCmd(g.cmd));
93
100
  if (missingTools.length === 0 && missingNpm.length === 0) return;
94
101
 
95
102
  const lines = [];
96
103
  for (const t of missingTools) lines.push(`${t.cmd} — ${t.why}\n ${dep.systemHint(t)}`);
97
- for (const g of missingNpm) lines.push(`${g.cmd} — ${g.why}\n npm install -g ${g.pkg}`);
98
- ctx.ui.note(lines.join('\n'), 'Optional tools to install manually');
104
+ for (const g of missingNpm) lines.push(`${g.cmd} — ${g.why}\n npm install -g ${g.pkg}`);
105
+ ctx.ui.note(lines.join('\n'), ctx.t('optionalToolsTitle'));
99
106
  }
100
107
 
101
108
  async function setupSkillDeps(ctx) {
102
109
  if (!(await shouldRun(ctx))) {
103
- if (!ctx.dryRun) {
104
- ctx.ui.info('Skill dependencies skipped — run later with: npx @haposoft/cafekit --with-skills-deps');
105
- }
110
+ if (!ctx.dryRun) ctx.ui.info(ctx.t('skillsSkipped'));
106
111
  return ctx;
107
112
  }
108
113
 
109
114
  for (const key of ctx.platforms) {
110
- const skillsDir = PLATFORMS[key].skillsDir;
111
- setupPython(ctx, skillsDir);
112
- setupNode(ctx, skillsDir);
115
+ setupPython(ctx, PLATFORMS[key].skillsDir);
116
+ setupNode(ctx, PLATFORMS[key].skillsDir);
113
117
  }
114
118
 
115
119
  guideManual(ctx);
@@ -6,49 +6,65 @@
6
6
  * plain text otherwise.
7
7
  */
8
8
 
9
+ const fs = require('fs');
10
+ const path = require('path');
9
11
  const { PLATFORMS, packageJson } = require('../lib/context');
10
12
 
13
+ /** Installed skills that ship a .env.example (i.e. need API keys configured). */
14
+ function skillsNeedingKeys(platforms) {
15
+ const found = new Set();
16
+ for (const key of platforms) {
17
+ const skillsDir = PLATFORMS[key].skillsDir;
18
+ if (!fs.existsSync(skillsDir)) continue;
19
+ for (const skill of fs.readdirSync(skillsDir)) {
20
+ if (fs.existsSync(path.join(skillsDir, skill, '.env.example'))) found.add(skill);
21
+ }
22
+ }
23
+ return [...found].sort();
24
+ }
25
+
11
26
  function printSummary(ctx) {
12
- const { results, platforms, options, ui } = ctx;
27
+ const { results, platforms, options, ui, t } = ctx;
28
+ const pad = (s) => `${s}:`.padEnd(16, ' ');
13
29
 
14
30
  const stats = [
15
- `Copied: ${results.copied}`,
16
- `Updated: ${results.updated}`,
17
- `Unchanged: ${results.unchanged}`,
18
- `Preserved: ${results.preserved}`,
19
- `Skills: ${results.installedSkills > 0 ? 'yes' : 'no'}`,
20
- `CafeKit Version: ${packageJson.version}`,
21
- results.missingDependencies > 0 ? `Missing: ${results.missingDependencies}` : null,
22
- results.errors > 0 ? `Errors: ${results.errors}` : null
31
+ `${pad(t('labelCopied'))}${results.copied}`,
32
+ `${pad(t('labelUpdated'))}${results.updated}`,
33
+ `${pad(t('labelUnchanged'))}${results.unchanged}`,
34
+ `${pad(t('labelPreserved'))}${results.preserved}`,
35
+ `${pad(t('labelSkills'))}${results.installedSkills > 0 ? t('yes') : t('no')}`,
36
+ `${pad(t('labelVersion'))}${packageJson.version}`,
37
+ results.missingDependencies > 0 ? `${pad(t('labelMissing'))}${results.missingDependencies}` : null,
38
+ results.errors > 0 ? `${pad(t('labelErrors'))}${results.errors}` : null
23
39
  ].filter(Boolean).join('\n');
24
40
 
25
- ui.note(stats, ctx.dryRun ? 'Dry-run preview' : 'Installation complete');
41
+ ui.note(stats, ctx.dryRun ? t('summaryDryTitle') : t('summaryTitle'));
26
42
 
27
43
  if (results.preserved > 0) {
28
44
  const list = results.preservedFiles.slice(0, 8).map((f) => ` ⊘ ${f}`).join('\n');
29
- const more = results.preservedFiles.length > 8 ? `\n ... and ${results.preservedFiles.length - 8} more` : '';
30
- ui.warn(`${results.preserved} user-modified file(s) preserved:\n${list}${more}\n`
31
- + 'Re-run with --force-overwrite to replace them (a backup is kept under .cafekit-backup/).');
45
+ const more = results.preservedFiles.length > 8 ? `\n ... +${results.preservedFiles.length - 8}` : '';
46
+ ui.warn(`${t('preservedNote', { n: results.preserved })}\n${list}${more}`);
32
47
  }
33
48
 
34
49
  if (ctx.dryRun) {
35
- ui.outro('Dry-run only — re-run without --dry-run to apply.');
50
+ ui.outro(t('dryRunOnly'));
36
51
  return;
37
52
  }
38
53
 
39
54
  const lines = [];
40
55
  for (const key of platforms) {
41
- if (key === 'claude') lines.push('Claude: use /hapo:specs <feature-description>');
42
- else lines.push('OpenCode: ask the agent to start a feature or brainstorm');
56
+ lines.push(key === 'claude' ? t('nsClaude') : t('nsOpencode'));
57
+ }
58
+ const keySkills = skillsNeedingKeys(platforms);
59
+ if (keySkills.length > 0) {
60
+ lines.push(t('nsKeys', { skills: keySkills.join(', ') }));
43
61
  }
44
- const envHosts = platforms.map((key) => `${PLATFORMS[key].folder}/.env`).join(' and ');
45
- lines.push(`API keys isolated in ${envHosts}`);
46
62
  if (!options.forceOverwrite) {
47
- lines.push('Use --force-overwrite to refresh user-modified files');
63
+ lines.push(t('nsForce'));
48
64
  }
49
- ui.note(lines.join('\n'), 'Next steps');
65
+ ui.note(lines.join('\n'), t('nextStepsTitle'));
50
66
 
51
- ui.outro(`Done — docs: https://github.com/haposoft/cafekit`);
67
+ ui.outro(t('outroDone'));
52
68
  }
53
69
 
54
70
  module.exports = { printSummary };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "Claude Code-first spec-driven workflow for AI coding assistants. Bundles CafeKit hapo: skills, runtime hooks, agents, and installer scaffolding.",
5
5
  "author": "Haposoft <nghialt@haposoft.com>",
6
6
  "license": "MIT",