@haposoft/cafekit 0.11.4 → 0.11.7

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
@@ -15,6 +15,7 @@
15
15
  * - --dry-run — preview only, no filesystem changes.
16
16
  */
17
17
 
18
+ const { spawnSync } = require('child_process');
18
19
  const { buildContext, PLATFORMS, packageJson } = require('./lib/context');
19
20
  const lock = require('./lib/lock');
20
21
  const backup = require('./lib/backup');
@@ -129,10 +130,27 @@ async function main() {
129
130
  await resolvePlatforms(ctx);
130
131
  if (ctx.cancelled) { lock.release(); process.exit(0); }
131
132
 
132
- // Version check: same → exit, downgrade → confirm
133
+ // Version check: same → exit, downgrade → confirm, upgrade → version picker
133
134
  await checkVersions(ctx);
134
135
  if (ctx.cancelled) { lock.release(); process.exit(0); }
135
136
 
137
+ // User picked a different version from the picker — re-exec that version
138
+ if (ctx.reexec) {
139
+ lock.release();
140
+ ctx.ui.info(ctx.t('versionReexec', { v: ctx.reexec }));
141
+ // Forward original flags; add --force-overwrite so the re-exec'd installer
142
+ // skips the version picker and proceeds directly with installation.
143
+ const origFlags = process.argv.slice(2).filter(
144
+ (f) => f !== '--force-overwrite' && f !== '-f' && f !== '-u' && f !== '--upgrade'
145
+ );
146
+ const result = spawnSync(
147
+ 'npx',
148
+ ['-y', `@haposoft/cafekit@${ctx.reexec}`, '--force-overwrite', ...origFlags],
149
+ { stdio: 'inherit', shell: process.platform === 'win32' }
150
+ );
151
+ process.exit(result.status ?? 1);
152
+ }
153
+
136
154
  // ── Pre-run snapshot for rollback ─────────────────────
137
155
  // Capture platform folders AND the root files the pipeline mutates
138
156
  // (CLAUDE.md, .gitignore) so a mid-run failure rolls back cleanly.
package/bin/lib/i18n.js CHANGED
@@ -51,6 +51,11 @@ const MESSAGES = {
51
51
  addressingSet: '{name} will address you as "{addr}"',
52
52
  addressingInvalid: 'Letters only please — addressing skipped.',
53
53
 
54
+ // Addressing update
55
+ addressingUpdatePrompt: 'You are currently addressed as "{name}". Change it?',
56
+ keepAddressingOption: 'Keep "{name}"',
57
+ changeAddressingOption:'Change to something else',
58
+
54
59
  // Skills / deps setup
55
60
  skillDepsConfirm: 'Install skill dependencies? (Python venv, pip, npm, Chromium — takes a few minutes)',
56
61
  skillsSkipped: 'Skill dependencies skipped.\n Run later: npx @haposoft/cafekit --with-skills-deps',
@@ -105,7 +110,19 @@ const MESSAGES = {
105
110
  updateOption: 'Update to {v}',
106
111
  reinstallOption: 'Reinstall (overwrite managed files)',
107
112
  reinstallCurrentOption: 'Reinstall {v}',
108
- skipOption: 'Skip (exit)'
113
+ skipOption: 'Skip (exit)',
114
+
115
+ // Platform/language auto-skip when already installed
116
+ installingFor: 'Installing for: {names}',
117
+ langKept: 'Language: {lang} (saved — skipping prompt)',
118
+ platformKept: 'Platform: {names} (saved — skipping prompt)',
119
+
120
+ // Version picker
121
+ versionPickPrompt: 'Select CafeKit version to install:',
122
+ versionPickCurrent: '(installed)',
123
+ versionPickLatest: '(latest)',
124
+ versionReexec: 'Switching to CafeKit {v}…',
125
+ versionFetchFailed: 'Could not fetch version list. Using default menu.'
109
126
  },
110
127
 
111
128
  // ─── 日本語 ─────────────────────────────────────────────────────────────────
@@ -140,6 +157,11 @@ const MESSAGES = {
140
157
  addressingSet: '{name} はあなたを「{addr}」とお呼びします',
141
158
  addressingInvalid: '文字のみ入力できます — 呼称の設定をスキップしました。',
142
159
 
160
+ // Addressing update
161
+ addressingUpdatePrompt: '現在「{name}」と呼ばれています。変更しますか?',
162
+ keepAddressingOption: '「{name}」のまま',
163
+ changeAddressingOption:'別の名前に変更',
164
+
143
165
  skillDepsConfirm: 'スキルの依存関係をインストールしますか?(Python venv、pip、npm、Chromium — 数分かかります)',
144
166
  skillsSkipped: 'スキルの依存関係をスキップしました。\n 後で実行: npx @haposoft/cafekit --with-skills-deps',
145
167
  venvCreating: 'Python 環境を準備しています',
@@ -190,7 +212,19 @@ const MESSAGES = {
190
212
  updateOption: '{v} にアップデート',
191
213
  reinstallOption: '再インストール(管理対象ファイルを上書き)',
192
214
  reinstallCurrentOption: '{v} を再インストール',
193
- skipOption: 'スキップ(終了)'
215
+ skipOption: 'スキップ(終了)',
216
+
217
+ // Platform/language auto-skip when already installed
218
+ installingFor: 'インストール先: {names}',
219
+ langKept: '言語: {lang} (保存済み — スキップ)',
220
+ platformKept: 'プラットフォーム: {names} (保存済み — スキップ)',
221
+
222
+ // Version picker
223
+ versionPickPrompt: 'インストールする CafeKit バージョンを選択:',
224
+ versionPickCurrent: '(インストール済み)',
225
+ versionPickLatest: '(最新)',
226
+ versionReexec: 'CafeKit {v} に切り替えます…',
227
+ versionFetchFailed: 'バージョン一覧を取得できませんでした。デフォルトメニューを表示します。'
194
228
  },
195
229
 
196
230
  // ─── Tiếng Việt ─────────────────────────────────────────────────────────────
@@ -225,6 +259,11 @@ const MESSAGES = {
225
259
  addressingSet: '{name} sẽ gọi bạn là "{addr}"',
226
260
  addressingInvalid: 'Chỉ nhập chữ cái — bỏ qua thiết lập xưng hô.',
227
261
 
262
+ // Addressing update
263
+ addressingUpdatePrompt: 'Bạn đang được gọi là "{name}". Bạn có muốn đổi không?',
264
+ keepAddressingOption: 'Giữ "{name}"',
265
+ changeAddressingOption:'Đổi tên khác',
266
+
228
267
  skillDepsConfirm: 'Cài dependencies cho skill ngay? (Python venv, pip, npm, Chromium — mất vài phút)',
229
268
  skillsSkipped: 'Đã bỏ qua dependencies skill.\n Cài sau bằng lệnh: npx @haposoft/cafekit --with-skills-deps',
230
269
  venvCreating: 'Đang thiết lập môi trường Python',
@@ -275,7 +314,19 @@ const MESSAGES = {
275
314
  updateOption: 'Cập nhật lên {v}',
276
315
  reinstallOption: 'Cài lại (ghi đè file được quản lý)',
277
316
  reinstallCurrentOption: 'Cài lại {v}',
278
- skipOption: 'Bỏ qua (thoát)'
317
+ skipOption: 'Bỏ qua (thoát)',
318
+
319
+ // Platform/language auto-skip when already installed
320
+ installingFor: 'Cài cho: {names}',
321
+ langKept: 'Ngôn ngữ: {lang} (đã lưu — bỏ qua chọn lại)',
322
+ platformKept: 'Nền tảng: {names} (đã lưu — bỏ qua chọn lại)',
323
+
324
+ // Version picker
325
+ versionPickPrompt: 'Chọn phiên bản CafeKit để cài:',
326
+ versionPickCurrent: '(đang cài)',
327
+ versionPickLatest: '(mới nhất)',
328
+ versionReexec: 'Đang chuyển sang cài CafeKit {v}…',
329
+ versionFetchFailed: 'Không lấy được danh sách phiên bản. Dùng menu mặc định.'
279
330
  }
280
331
  };
281
332
 
@@ -0,0 +1,61 @@
1
+ /**
2
+ * npm registry queries for the installer version picker.
3
+ *
4
+ * Intentionally standalone — no imports from other installer lib files
5
+ * to avoid circular dependencies. Timeout is short (3 s) so a slow/offline
6
+ * network never blocks the install flow; callers must handle a null result.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const https = require('https');
12
+
13
+ const REGISTRY_URL = 'https://registry.npmjs.org/@haposoft/cafekit';
14
+ const FETCH_TIMEOUT_MS = 3000;
15
+ const MAX_VERSIONS = 5;
16
+
17
+ /** Sort version strings descending (newest first), semver-ish. */
18
+ function sortDesc(versions) {
19
+ return [...versions].sort((a, b) => {
20
+ const toNum = (v) => String(v).split('.').map((n) => parseInt(n, 10) || 0);
21
+ const [aN, bN] = [toNum(a), toNum(b)];
22
+ for (let i = 0; i < 3; i++) {
23
+ const d = (bN[i] || 0) - (aN[i] || 0);
24
+ if (d !== 0) return d;
25
+ }
26
+ return 0;
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Fetch up to MAX_VERSIONS recent published versions from the npm registry.
32
+ * Returns an object `{ versions: string[], latest: string }` or null on any error.
33
+ */
34
+ function fetchRecentVersions() {
35
+ return new Promise((resolve) => {
36
+ const req = https.get(
37
+ REGISTRY_URL,
38
+ { timeout: FETCH_TIMEOUT_MS, headers: { Accept: 'application/json' } },
39
+ (res) => {
40
+ let body = '';
41
+ res.on('data', (chunk) => { body += chunk; });
42
+ res.on('end', () => {
43
+ try {
44
+ const data = JSON.parse(body);
45
+ const allVersions = Object.keys(data.versions || {});
46
+ if (allVersions.length === 0) { resolve(null); return; }
47
+ const sorted = sortDesc(allVersions);
48
+ const latest = (data['dist-tags'] && data['dist-tags'].latest) || sorted[0];
49
+ resolve({ versions: sorted.slice(0, MAX_VERSIONS), latest });
50
+ } catch {
51
+ resolve(null);
52
+ }
53
+ });
54
+ }
55
+ );
56
+ req.on('error', () => resolve(null));
57
+ req.on('timeout', () => { req.destroy(); resolve(null); });
58
+ });
59
+ }
60
+
61
+ module.exports = { fetchRecentVersions };
@@ -13,6 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const { PLATFORMS, packageJson } = require('./context');
16
+ const { fetchRecentVersions } = require('./registry');
16
17
 
17
18
  /** Simple semver-ish comparison. Returns -1 | 0 | 1. */
18
19
  function cmpVersion(a, b) {
@@ -43,6 +44,7 @@ function getInstalledVersion(platformKey) {
43
44
  /**
44
45
  * Check versions for all selected platforms and decide whether to proceed.
45
46
  * Sets ctx.cancelled on same-version (without --force) or unconfirmed downgrade.
47
+ * Sets ctx.isUpdate = true when upgrading from an older version.
46
48
  * Shows interactive prompt when version differs, asking user what to do.
47
49
  */
48
50
  async function checkVersions(ctx) {
@@ -60,6 +62,7 @@ async function checkVersions(ctx) {
60
62
 
61
63
  // All fresh installs - skip version check, proceed normally
62
64
  if (platformsWithInstall.length === 0) {
65
+ ctx.isUpdate = false;
63
66
  return ctx;
64
67
  }
65
68
 
@@ -73,6 +76,8 @@ async function checkVersions(ctx) {
73
76
 
74
77
  // ── Same version ───────────────────────────────────────────────────────────
75
78
  if (cmp === 0) {
79
+ ctx.isUpdate = false;
80
+
76
81
  if (ctx.options.forceOverwrite) {
77
82
  ctx.ui.info(ctx.t ? ctx.t('versionForceReinstall', { v: installedVersion }) : `Reinstalling CafeKit ${installedVersion} (--force-overwrite)...`);
78
83
  return ctx;
@@ -85,22 +90,19 @@ async function checkVersions(ctx) {
85
90
  }
86
91
 
87
92
  // Show current vs new and ask what to do
88
- const { isCancel } = await import('@clack/prompts');
89
- const { default: select } = await import('@clack/prompts');
90
-
91
93
  const options = [
92
94
  { value: 'reinstall', label: ctx.t ? ctx.t('reinstallOption') : 'Reinstall (overwrite managed files)' },
93
95
  { value: 'skip', label: ctx.t ? ctx.t('skipOption') : 'Skip (exit)' }
94
96
  ];
95
97
 
96
- const answer = await select({
98
+ const answer = await ctx.ui.select({
97
99
  message: ctx.t
98
100
  ? ctx.t('versionSamePrompt', { v: installedVersion })
99
101
  : `CafeKit ${installedVersion} is already installed. What do you want to do?`,
100
102
  options
101
103
  });
102
104
 
103
- if (isCancel(answer) || answer === 'skip') {
105
+ if (ctx.ui.isCancel(answer) || answer === 'skip') {
104
106
  ctx.cancelled = true;
105
107
  return ctx;
106
108
  }
@@ -114,43 +116,80 @@ async function checkVersions(ctx) {
114
116
 
115
117
  // ── Upgrade available ───────────────────────────────────────────────────────
116
118
  if (cmp < 0) {
117
- if (!ctx.interactive) {
118
- // Non-interactive: proceed with upgrade automatically
119
+ ctx.isUpdate = true;
120
+
121
+ // --force-overwrite or non-interactive: proceed without asking
122
+ if (!ctx.interactive || ctx.options.forceOverwrite) {
119
123
  return ctx;
120
124
  }
121
125
 
122
- // Show current vs new and ask what to do
123
- const { isCancel } = await import('@clack/prompts');
124
- const { default: select } = await import('@clack/prompts');
126
+ // Try to fetch recent versions for the picker; fall back to 3-option menu on failure
127
+ const registry = await fetchRecentVersions();
128
+
129
+ if (registry) {
130
+ // Build picker options: each version labelled with (installed) / (latest) hints
131
+ const pickerOptions = registry.versions.map((v) => {
132
+ let hint = '';
133
+ if (v === firstPlatform.installed) hint = ctx.t ? ctx.t('versionPickCurrent') : '(installed)';
134
+ else if (v === registry.latest) hint = ctx.t ? ctx.t('versionPickLatest') : '(latest)';
135
+ const label = hint ? `${v} ${hint}` : v;
136
+ return { value: v, label };
137
+ });
138
+ pickerOptions.push({ value: 'skip', label: ctx.t ? ctx.t('skipOption') : 'Skip (exit)' });
139
+
140
+ const picked = await ctx.ui.select({
141
+ message: ctx.t ? ctx.t('versionPickPrompt') : 'Select CafeKit version to install:',
142
+ options: pickerOptions
143
+ });
144
+
145
+ if (ctx.ui.isCancel(picked) || picked === 'skip') {
146
+ ctx.cancelled = true;
147
+ return ctx;
148
+ }
149
+
150
+ if (picked !== incoming) {
151
+ // User chose a version different from the one currently running — signal re-exec
152
+ ctx.reexec = picked;
153
+ return ctx;
154
+ }
155
+
156
+ // User confirmed the incoming (latest) version — proceed normally
157
+ return ctx;
158
+ }
125
159
 
126
- const options = [
127
- { value: 'update', label: ctx.t ? ctx.t('updateOption') : `Update to ${incoming}` },
160
+ // Offline or fetch failed: fall back to the classic 3-option menu
161
+ ctx.ui.warn(ctx.t ? ctx.t('versionFetchFailed') : 'Could not fetch version list. Using default menu.');
162
+
163
+ const fallbackOptions = [
164
+ { value: 'update', label: ctx.t ? ctx.t('updateOption', { v: incoming }) : `Update to ${incoming}` },
128
165
  { value: 'reinstall', label: ctx.t ? ctx.t('reinstallCurrentOption', { v: firstPlatform.installed }) : `Reinstall ${firstPlatform.installed}` },
129
166
  { value: 'skip', label: ctx.t ? ctx.t('skipOption') : 'Skip (exit)' }
130
167
  ];
131
168
 
132
- const answer = await select({
169
+ const answer = await ctx.ui.select({
133
170
  message: ctx.t
134
171
  ? ctx.t('versionUpgradePrompt', { from: firstPlatform.installed, to: incoming })
135
172
  : `CafeKit ${firstPlatform.installed} → ${incoming}: Update available!`,
136
- options
173
+ options: fallbackOptions
137
174
  });
138
175
 
139
- if (isCancel(answer) || answer === 'skip') {
176
+ if (ctx.ui.isCancel(answer) || answer === 'skip') {
140
177
  ctx.cancelled = true;
141
178
  return ctx;
142
179
  }
143
180
 
144
181
  if (answer === 'reinstall') {
145
182
  ctx.options.forceOverwrite = true;
183
+ ctx.isUpdate = false; // Reinstall treats as fresh install
146
184
  }
147
185
 
148
- // 'update' proceeds normally (no special flag needed)
149
186
  return ctx;
150
187
  }
151
188
 
152
189
  // ── Downgrade ──────────────────────────────────────────────────────────────
153
190
  if (cmp > 0) {
191
+ ctx.isUpdate = false;
192
+
154
193
  const msg = ctx.t
155
194
  ? ctx.t('versionDowngrade', { from: firstPlatform.installed, to: incoming })
156
195
  : `Downgrading ${firstPlatform.installed} → ${incoming}. This may remove features.`;
@@ -130,6 +130,34 @@ ${name} always addresses the user as "${userAddress}" throughout the conversatio
130
130
  async function setupAddressing(ctx) {
131
131
  if (!ctx.platforms.includes('claude')) return;
132
132
 
133
+ // Check if there's already an addressing section in CLAUDE.md
134
+ const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
135
+ let existingName = null;
136
+ if (fs.existsSync(claudeMdFile)) {
137
+ const content = fs.readFileSync(claudeMdFile, 'utf8');
138
+ const match = content.match(/##[^\n]*Context Overflow Indicator[^\n]*[\s\S]*?always addresses the user as "([^"]+)"/);
139
+ if (match) {
140
+ existingName = match[1];
141
+ }
142
+ }
143
+
144
+ // For updates: ask if user wants to keep the existing name
145
+ if (existingName && ctx.isUpdate) {
146
+ const options = [
147
+ { value: 'keep', label: ctx.t('keepAddressingOption', { addr: existingName }) || `Giữ "${existingName}"` },
148
+ { value: 'change', label: ctx.t('changeAddressingOption') || 'Đổi tên mới' }
149
+ ];
150
+
151
+ const choice = await ctx.ui.select({
152
+ message: ctx.t('addressingUpdatePrompt') || `Bạn đang dùng "${existingName}" để xưng hô. Muốn đổi không?`,
153
+ options
154
+ });
155
+
156
+ if (ctx.ui.isCancel(choice)) return;
157
+ if (choice === 'keep') return;
158
+ // If 'change', continue to ask for new name
159
+ }
160
+
133
161
  const answer = await ctx.ui.text(
134
162
  { message: ctx.t('addressingQuestion'), placeholder: ctx.t('addressingPlaceholder') },
135
163
  ''
@@ -6,6 +6,8 @@
6
6
  * or prompted. Sets ctx.platforms, or ctx.cancelled if the user backs out.
7
7
  */
8
8
 
9
+ const fs = require('fs');
10
+ const path = require('path');
9
11
  const {
10
12
  PLATFORMS,
11
13
  detectPlatforms,
@@ -16,10 +18,34 @@ const { SUPPORTED, LANGUAGE_LABELS, OTHER_LABEL } = require('../lib/i18n');
16
18
 
17
19
  const OTHER = '__other__';
18
20
 
21
+ /**
22
+ * Read saved locale label from .claude/runtime.json (written by post-install).
23
+ * Returns the freeform label string (e.g. "Tiếng Việt") or null.
24
+ */
25
+ function getInstalledLocale() {
26
+ const runtimeJson = path.join(process.cwd(), '.claude', 'runtime.json');
27
+ if (!fs.existsSync(runtimeJson)) return null;
28
+ try {
29
+ const data = JSON.parse(fs.readFileSync(runtimeJson, 'utf8'));
30
+ return (data && data.locale && typeof data.locale.responseLanguage === 'string')
31
+ ? data.locale.responseLanguage
32
+ : null;
33
+ } catch { return null; }
34
+ }
35
+
19
36
  /** First step: pick the installer/UI language (interactive only). */
20
37
  async function selectLanguage(ctx) {
21
38
  if (!ctx.interactive || ctx.options.lang) return ctx;
22
39
 
40
+ // If already installed, restore saved locale and skip the prompt
41
+ const savedLocale = getInstalledLocale();
42
+ if (savedLocale) {
43
+ const code = Object.keys(LANGUAGE_LABELS).find((k) => LANGUAGE_LABELS[k] === savedLocale) || 'en';
44
+ ctx.setLang(code, savedLocale); // updates ctx.t to the saved language
45
+ ctx.ui.info(ctx.t('langKept', { lang: savedLocale }));
46
+ return ctx;
47
+ }
48
+
23
49
  const options = [
24
50
  ...SUPPORTED.map((code) => ({ value: code, label: LANGUAGE_LABELS[code] })),
25
51
  { value: OTHER, label: OTHER_LABEL.en + ' / その他 / Ngôn ngữ khác' }
@@ -75,8 +101,27 @@ async function confirmAllDetected(ctx, detected) {
75
101
 
76
102
  /**
77
103
  * Resolve ctx.platforms via detection + prompts. Sets ctx.cancelled on backout.
104
+ * If cafekit.json exists (prior install), reads platform from it to skip prompt.
105
+ * Note: ctx.isUpdate is set later by checkVersions and cannot be used here.
78
106
  */
79
107
  async function resolvePlatforms(ctx) {
108
+ // Skip platform prompt when a prior install is detected via cafekit.json
109
+ const savedPlatform = getInstalledPlatform();
110
+ if (savedPlatform) {
111
+ ctx.platforms = [savedPlatform];
112
+ const platformNames = PLATFORMS[savedPlatform].name;
113
+ ctx.ui.info(ctx.t('platformKept', { names: platformNames }));
114
+
115
+ if (ctx.options.forceOverwrite) {
116
+ ctx.ui.info(ctx.t('modeForce'));
117
+ } else if (ctx.dryRun) {
118
+ ctx.ui.info(ctx.t('modeDryRun'));
119
+ } else {
120
+ ctx.ui.info(ctx.t('modeInstall'));
121
+ }
122
+ return ctx;
123
+ }
124
+
80
125
  let platforms = detectPlatforms();
81
126
 
82
127
  if (platforms.length === 0) {
@@ -120,4 +165,28 @@ async function resolvePlatforms(ctx) {
120
165
  return ctx;
121
166
  }
122
167
 
168
+ /**
169
+ * Get the installed platform from cafekit.json files.
170
+ * Returns the platform key (e.g. 'claude', 'opencode') or null.
171
+ */
172
+ function getInstalledPlatform() {
173
+ const claudeJson = path.join(process.cwd(), '.claude', 'cafekit.json');
174
+ if (fs.existsSync(claudeJson)) {
175
+ try {
176
+ const data = JSON.parse(fs.readFileSync(claudeJson, 'utf8'));
177
+ if (data.platform) return data.platform;
178
+ } catch { /* ignore */ }
179
+ }
180
+
181
+ const opencodeJson = path.join(process.cwd(), '.opencode', 'cafekit.json');
182
+ if (fs.existsSync(opencodeJson)) {
183
+ try {
184
+ const data = JSON.parse(fs.readFileSync(opencodeJson, 'utf8'));
185
+ if (data.platform) return data.platform;
186
+ } catch { /* ignore */ }
187
+ }
188
+
189
+ return null;
190
+ }
191
+
123
192
  module.exports = { selectLanguage, promptPlatformSelection, resolvePlatforms };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.11.4",
3
+ "version": "0.11.7",
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",