@haposoft/cafekit 0.11.0 → 0.11.2

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.
@@ -247,6 +247,9 @@ function buildContext(argv, runId) {
247
247
  const interactive = isInteractive() && !options.yes;
248
248
  // Language: --lang wins; otherwise English (an interactive prompt may change it).
249
249
  const lang = options.lang ? resolveLang(options.lang) : 'en';
250
+ // ctx.locale = the string that gets written to CLAUDE.md / runtime.json as the AI's response language.
251
+ // For known codes it mirrors lang; for "other", it's set later by setLang.
252
+ const locale = options.lang || 'en';
250
253
  return {
251
254
  argv,
252
255
  runId,
@@ -254,11 +257,13 @@ function buildContext(argv, runId) {
254
257
  dryRun: options.dryRun,
255
258
  interactive,
256
259
  ui: createUI(interactive),
257
- lang,
260
+ lang, // UI language (one of SUPPORTED)
261
+ locale, // AI response language label (may be freeform, e.g. "Korean")
258
262
  t: createTranslator(lang),
259
- /** Update the active language + translator (used by the language prompt). */
260
- setLang(code) {
263
+ /** Update UI lang + AI locale. rawLabel is the freeform label when user picks "Other". */
264
+ setLang(code, rawLabel) {
261
265
  this.lang = resolveLang(code);
266
+ this.locale = rawLabel || LANGUAGE_LABELS[this.lang] || this.lang;
262
267
  this.t = createTranslator(this.lang);
263
268
  },
264
269
  manifest: loadClaudeMigrationManifest(), // migration manifest (skills/agents/runtime lists)
package/bin/lib/i18n.js CHANGED
@@ -11,6 +11,7 @@
11
11
  const SUPPORTED = ['en', 'ja', 'vi'];
12
12
 
13
13
  const LANGUAGE_LABELS = { en: 'English', ja: '日本語', vi: 'Tiếng Việt' };
14
+ const OTHER_LABEL = { en: 'Other...', ja: 'その他...', vi: 'Ngôn ngữ khác...' };
14
15
 
15
16
  const MESSAGES = {
16
17
  // ─── English ───────────────────────────────────────────────────────────────
@@ -264,4 +265,4 @@ function createTranslator(lang) {
264
265
  };
265
266
  }
266
267
 
267
- module.exports = { SUPPORTED, LANGUAGE_LABELS, MESSAGES, resolveLang, createTranslator };
268
+ module.exports = { SUPPORTED, LANGUAGE_LABELS, OTHER_LABEL, MESSAGES, resolveLang, createTranslator };
@@ -30,8 +30,9 @@ function patchSettingsLanguage(ctx) {
30
30
  return;
31
31
  }
32
32
 
33
- const label = LANGUAGE_LABELS[ctx.lang] || ctx.lang;
34
- if (settings.language === label) return; // already up to date
33
+ // Use locale (freeform label) so "Korean" shows correctly, not just "en".
34
+ const label = ctx.locale || LANGUAGE_LABELS[ctx.lang] || ctx.lang;
35
+ if (settings.language === label) return;
35
36
  settings.language = label;
36
37
  fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
37
38
  }
@@ -46,13 +47,16 @@ function patchSettingsLanguage(ctx) {
46
47
  */
47
48
  function patchLanguageSection(ctx) {
48
49
  if (!ctx.platforms.includes('claude')) return;
49
- if (ctx.lang === 'en') return; // English is the default — no override needed.
50
+ // Skip when locale is English (default — no override needed in CLAUDE.md).
51
+ const locale = ctx.locale || ctx.lang;
52
+ if (!locale || locale === 'en' || locale === 'English') return;
50
53
 
51
54
  const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
52
55
  if (!fs.existsSync(claudeMdFile)) return;
53
56
 
54
57
  const LANG_LABEL = { vi: 'Vietnamese', ja: 'Japanese', en: 'English' };
55
- const label = LANG_LABEL[ctx.lang] || ctx.lang;
58
+ // Use locale directly when set (e.g. "Korean"), or map from lang code.
59
+ const label = locale in LANG_LABEL ? LANG_LABEL[locale] : locale;
56
60
 
57
61
  const newSection = `## Language Consistency <!-- cafekit:lang -->
58
62
 
@@ -86,8 +90,10 @@ function patchRuntimeLocale(ctx) {
86
90
  continue;
87
91
  }
88
92
  data.locale = data.locale || {};
89
- if (data.locale.responseLanguage === ctx.lang) continue;
90
- data.locale.responseLanguage = ctx.lang;
93
+ // Use locale (freeform label) so custom languages propagate to the AI hook.
94
+ const locale = ctx.locale || ctx.lang;
95
+ if (data.locale.responseLanguage === locale) continue;
96
+ data.locale.responseLanguage = locale;
91
97
  fs.writeFileSync(rtPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
92
98
  if (ctx.trackers[key]) ctx.trackers[key].record(rtPath);
93
99
  }
@@ -12,21 +12,38 @@ const {
12
12
  getPlatformKeys,
13
13
  warnLegacyClaudeFolder
14
14
  } = require('../lib/context');
15
- const { SUPPORTED, LANGUAGE_LABELS } = require('../lib/i18n');
15
+ const { SUPPORTED, LANGUAGE_LABELS, OTHER_LABEL } = require('../lib/i18n');
16
+
17
+ const OTHER = '__other__';
16
18
 
17
19
  /** First step: pick the installer/UI language (interactive only). */
18
20
  async function selectLanguage(ctx) {
19
- // Honor an explicit --lang or non-interactive default (English) without prompting.
20
21
  if (!ctx.interactive || ctx.options.lang) return ctx;
21
22
 
23
+ const options = [
24
+ ...SUPPORTED.map((code) => ({ value: code, label: LANGUAGE_LABELS[code] })),
25
+ { value: OTHER, label: OTHER_LABEL.en + ' / その他 / Ngôn ngữ khác' }
26
+ ];
27
+
22
28
  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
- },
29
+ { message: 'Select language · 言語を選択 · Chọn ngôn ngữ', options },
27
30
  ctx.lang
28
31
  );
29
- if (!ctx.ui.isCancel(r)) ctx.setLang(r);
32
+ if (ctx.ui.isCancel(r)) return ctx;
33
+
34
+ if (r === OTHER) {
35
+ // User types their language name — UI stays English, AI responds in that language.
36
+ const typed = await ctx.ui.text(
37
+ { message: 'Enter your language (e.g. Korean, French, Spanish…)', placeholder: 'Language name in English' },
38
+ ''
39
+ );
40
+ if (!ctx.ui.isCancel(typed) && typed && typed.trim()) {
41
+ ctx.setLang('en', typed.trim()); // UI = English, locale = user's label
42
+ }
43
+ } else {
44
+ ctx.setLang(r, LANGUAGE_LABELS[r]);
45
+ }
46
+
30
47
  return ctx;
31
48
  }
32
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
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",
@@ -17,3 +17,4 @@ hooks/.logs/
17
17
  agent-memory/
18
18
  settings.bak.json
19
19
  .mcp.json
20
+ .cafekit-update-cache.json
@@ -18,8 +18,6 @@ try {
18
18
  const { execSync } = require('child_process');
19
19
 
20
20
  // ── Utilities ─────────────────────────────────────────────────────────────
21
-
22
- /** Run a shell command safely, return stdout or fallback */
23
21
  function run(cmd, fallback = '') {
24
22
  try {
25
23
  return execSync(cmd, {
@@ -88,8 +86,97 @@ try {
88
86
  return '';
89
87
  }
90
88
 
89
+ // ── CafeKit Update Check ──────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Fetch the latest published version of @haposoft/cafekit from the npm registry.
93
+ * Returns a string (e.g. "0.11.2") or null on any failure. Timeout: 3 s.
94
+ */
95
+ function fetchLatestVersion() {
96
+ try {
97
+ const https = require('https');
98
+ return new Promise((resolve) => {
99
+ const req = https.get(
100
+ 'https://registry.npmjs.org/@haposoft/cafekit/latest',
101
+ { timeout: 3000, headers: { Accept: 'application/json' } },
102
+ (res) => {
103
+ let body = '';
104
+ res.on('data', (chunk) => { body += chunk; });
105
+ res.on('end', () => {
106
+ try { resolve(JSON.parse(body).version || null); }
107
+ catch { resolve(null); }
108
+ });
109
+ }
110
+ );
111
+ req.on('error', () => resolve(null));
112
+ req.on('timeout', () => { req.destroy(); resolve(null); });
113
+ });
114
+ } catch { return Promise.resolve(null); }
115
+ }
116
+
117
+ /** Simple semver compare: returns true when b > a. */
118
+ function isNewer(a, b) {
119
+ const n = (v) => String(v).split('.').map((x) => parseInt(x, 10) || 0);
120
+ const [aN, bN] = [n(a), n(b)];
121
+ for (let i = 0; i < 3; i++) {
122
+ if (bN[i] > aN[i]) return true;
123
+ if (bN[i] < aN[i]) return false;
124
+ }
125
+ return false;
126
+ }
127
+
128
+ /**
129
+ * Read the installed CafeKit version from .claude/cafekit.json.
130
+ * Returns null if not found.
131
+ */
132
+ function getInstalledVersion(cwd) {
133
+ try {
134
+ const p = path.join(cwd, '.claude', 'cafekit.json');
135
+ if (!fs.existsSync(p)) return null;
136
+ const meta = JSON.parse(fs.readFileSync(p, 'utf8'));
137
+ return typeof meta.version === 'string' ? meta.version : null;
138
+ } catch { return null; }
139
+ }
140
+
141
+ /**
142
+ * Check for a newer CafeKit version and return an update notice string,
143
+ * or an empty string if already up to date or check fails.
144
+ * Result is cached in .claude/.cafekit-update-cache.json for 12 hours.
145
+ */
146
+ async function checkCafeKitUpdate(cwd) {
147
+ const installed = getInstalledVersion(cwd);
148
+ if (!installed) return '';
149
+
150
+ const CACHE_TTL = 12 * 60 * 60 * 1000; // 12 hours
151
+ const cachePath = path.join(cwd, '.claude', '.cafekit-update-cache.json');
152
+
153
+ // Return cached result if still fresh.
154
+ try {
155
+ if (fs.existsSync(cachePath)) {
156
+ const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
157
+ if (Date.now() - (cache.ts || 0) < CACHE_TTL) {
158
+ return cache.notice || '';
159
+ }
160
+ }
161
+ } catch { /* stale or corrupt — refetch */ }
162
+
163
+ // Fetch from npm registry.
164
+ const latest = await fetchLatestVersion();
165
+ const notice = (latest && isNewer(installed, latest))
166
+ ? `\n⚡ CafeKit update available: ${installed} → ${latest}\n Run: npx @haposoft/cafekit\n`
167
+ : '';
168
+
169
+ // Persist cache (fail-open if write fails).
170
+ try {
171
+ fs.writeFileSync(cachePath, JSON.stringify({ ts: Date.now(), notice }), 'utf8');
172
+ } catch { /* fail-open */ }
173
+
174
+ return notice;
175
+ }
176
+
91
177
  // ── Main ──────────────────────────────────────────────────────────────────
92
178
 
179
+ (async () => {
93
180
  const stdin = fs.readFileSync(0, 'utf8').trim();
94
181
  const payload = stdin ? JSON.parse(stdin) : {};
95
182
  const source = payload.source || 'unknown';
@@ -97,6 +184,9 @@ try {
97
184
  const cwd = process.cwd();
98
185
  const runtime = readRuntime(cwd);
99
186
 
187
+ // Check CafeKit update in parallel with project detection (async, fail-open).
188
+ const updateCheckPromise = checkCafeKitUpdate(cwd);
189
+
100
190
  // Project detection
101
191
  const projectType = runtime.project?.type !== 'auto'
102
192
  ? (runtime.project?.type || detectProjectType())
@@ -137,14 +227,15 @@ try {
137
227
  writeEnv(envFile, 'LOCALE', process.env.LANG || '');
138
228
  }
139
229
 
140
- // Session summary
230
+ // Session summary + update notice
231
+ const updateNotice = await updateCheckPromise;
141
232
  const parts = [];
142
233
  if (projectType) parts.push(`Type: ${projectType}`);
143
234
  if (packageManager) parts.push(`PM: ${packageManager}`);
144
235
  if (framework) parts.push(`Framework: ${framework}`);
145
236
  if (gitBranch) parts.push(`Branch: ${gitBranch}`);
146
237
 
147
- console.log(`Session ${source}. ${parts.length ? parts.join(' | ') : 'No project info detected.'}`);
238
+ console.log(`Session ${source}. ${parts.length ? parts.join(' | ') : 'No project info detected.'}${updateNotice}`);
148
239
 
149
240
  // Compact warning — context compaction can lose pending approval state
150
241
  if (source === 'compact') {
@@ -155,6 +246,7 @@ try {
155
246
  }
156
247
 
157
248
  process.exit(0);
249
+ })(); // end async IIFE
158
250
 
159
251
  } catch (e) {
160
252
  try {