lumina-wiki 1.6.1 → 1.6.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.6.2] - 2026-06-15
9
+
10
+ ### Fixed
11
+
12
+ - Repaired stale Claude skill links during upgrades by validating their real
13
+ targets instead of trusting the previously recorded link strategy.
14
+ - Made POSIX skill links relative so copied, moved, or renamed workspaces can
15
+ be upgraded without retaining links to their old absolute location.
16
+ - Reconciled removed packs and IDE targets by deleting obsolete
17
+ installer-managed skills, tools, links, and unchanged generated stubs while
18
+ preserving modified or user-owned files.
19
+ - Made `npx lumina-wiki install` detect and upgrade an enclosing workspace when
20
+ invoked from a nested directory, while explicit `--directory` and `--cwd`
21
+ targets remain exact.
22
+ - Fixed interactive locale switching for existing and legacy workspaces,
23
+ including default-language cascading and confirmation binding to the final
24
+ resolved locale.
25
+ - Made installation fail clearly when required Claude skill links cannot be
26
+ created instead of writing successful state for a partial install.
27
+
8
28
  ## [1.6.1] - 2026-05-18
9
29
 
10
30
  ### Fixed
package/README.md CHANGED
@@ -93,6 +93,8 @@ The agent will help you check the research tools and save keys to a local `.env`
93
93
 
94
94
  If you reinstall Lumina-Wiki on a project that already has a `wiki/` from an earlier version, just run `npx lumina-wiki install` again. The installer updates scripts, schemas, and skills; **your content in `wiki/`, `raw/`, and `log.md` is not modified**.
95
95
 
96
+ You can run the command from the project root or one of its subfolders. If you remove a pack or AI tool from the setup, Lumina removes its old managed commands and unchanged setup files. Files you edited are kept with a warning. If the whole project was copied, moved, or renamed, Lumina repairs its managed links during the upgrade.
97
+
96
98
  If the installer warns that older entries are missing newer frontmatter fields, you have two ways to backfill them:
97
99
 
98
100
  - **Recommended:** open your AI chat and run `/lumi-migrate-legacy`.
package/README.vi.md CHANGED
@@ -93,6 +93,8 @@ Agent sẽ hướng dẫn bạn kiểm tra công cụ nghiên cứu và lưu key
93
93
 
94
94
  Nếu bạn cài lại Lumina-Wiki trên một dự án đã có `wiki/` từ phiên bản trước, cứ chạy lại `npx lumina-wiki install`. Installer cập nhật scripts, schemas và skills; **nội dung của bạn trong `wiki/`, `raw/`, `log.md` không bị chỉnh sửa**.
95
95
 
96
+ Bạn có thể chạy lệnh tại thư mục gốc hoặc một thư mục con của dự án. Nếu bỏ một gói hoặc công cụ AI khỏi thiết lập, Lumina sẽ dọn các lệnh cũ và những tệp thiết lập chưa được bạn sửa. Tệp đã chỉnh sửa được giữ lại kèm cảnh báo. Nếu toàn bộ dự án được sao chép, di chuyển hoặc đổi tên, Lumina sẽ sửa lại các liên kết do hệ thống quản lý khi nâng cấp.
97
+
96
98
  Nếu installer cảnh báo entry cũ thiếu frontmatter mới, có hai cách backfill:
97
99
 
98
100
  - **Khuyến nghị:** mở chat AI và chạy `/lumi-migrate-legacy`.
package/README.zh.md CHANGED
@@ -93,6 +93,8 @@ Agent 会引导您检查研究工具,并在需要时把 key 保存到本地 `.
93
93
 
94
94
  如果您在已经有旧版 `wiki/` 的项目上重新安装 Lumina-Wiki,直接再次运行 `npx lumina-wiki install` 即可。安装器会更新 scripts、schemas 和 skills;**您在 `wiki/`、`raw/`、`log.md` 中的内容不会被修改**。
95
95
 
96
+ 您可以在项目根目录或其子目录中运行该命令。如果从设置中移除某个包或 AI 工具,Lumina 会清理旧命令和未经您修改的设置文件。您修改过的文件会保留,并显示警告。如果整个项目被复制、移动或重命名,Lumina 会在升级时修复由系统管理的链接。
97
+
96
98
  如果安装器提示旧条目缺少新的 frontmatter 字段,可以用两种方式回填:
97
99
 
98
100
  - **推荐:** 打开 AI 对话并运行 `/lumi-migrate-legacy`。
package/bin/lumina.js CHANGED
@@ -199,6 +199,12 @@ program
199
199
  .option('--force-locale-switch', 'allow switching installer locale during upgrade')
200
200
  .action(async (cmdOpts) => {
201
201
  const globalOpts = program.opts();
202
+ const hasExplicitDirectory = (
203
+ cmdOpts.directory != null
204
+ || cmdOpts.cwd != null
205
+ || globalOpts.directory != null
206
+ || globalOpts.cwd != null
207
+ );
202
208
  const mergedDir = cmdOpts.directory ?? cmdOpts.cwd ?? globalOpts.directory ?? globalOpts.cwd ?? process.cwd();
203
209
  const mergedYes = cmdOpts.yes ?? globalOpts.yes ?? false;
204
210
  const mergedReLink = cmdOpts.reLink ?? globalOpts.reLink ?? false;
@@ -220,6 +226,7 @@ program
220
226
  documentOutputLang: cmdOpts.documentOutputLanguage,
221
227
  lang: cmdOpts.lang,
222
228
  forceLocaleSwitch: Boolean(cmdOpts.forceLocaleSwitch),
229
+ searchParents: !hasExplicitDirectory,
223
230
  });
224
231
  } catch (err) {
225
232
  // Top-level catch: locale may not be resolved yet (pre-loadLocale path).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "lumina-wiki",
4
- "version": "1.6.1",
4
+ "version": "1.6.2",
5
5
  "description": "Domain-agnostic, multi-IDE wiki scaffolder — Karpathy's LLM-Wiki vision, cross-platform and pack-based.",
6
6
  "keywords": [
7
7
  "llm-wiki",
@@ -88,7 +88,8 @@
88
88
  "devDependencies": {},
89
89
  "scripts": {
90
90
  "test": "npm run test:installer",
91
- "test:installer": "node --test bin/lumina.flags.test.js bin/lumina.deprecations.test.js bin/lumina.cancel.test.js src/installer/commands.test.js src/installer/fs.test.js src/installer/locales.test.js src/installer/manifest.test.js src/installer/prompts.test.js src/installer/readme-templates.test.js src/installer/template-engine.test.js src/installer/update-check.test.js",
91
+ "test:installer": "npm run test:installer:commands && node --test bin/lumina.flags.test.js bin/lumina.deprecations.test.js bin/lumina.cancel.test.js src/installer/fs.test.js src/installer/locales.test.js src/installer/manifest.test.js src/installer/prompts.test.js src/installer/readme-templates.test.js src/installer/template-engine.test.js src/installer/update-check.test.js",
92
+ "test:installer:commands": "node src/installer/commands.test.js",
92
93
  "test:scripts": "node --test src/scripts/lint.test.mjs src/scripts/reset.test.mjs src/scripts/wiki.test.mjs src/scripts/discover-runner.test.mjs src/scripts/external-ids.test.mjs src/scripts/parse-ids.test.mjs src/scripts/merge-ids.test.mjs src/scripts/build-source.test.mjs src/scripts/wiki-yaml-object.test.mjs src/scripts/schemas.test.mjs",
93
94
  "test:python": "node scripts/run-pytest.mjs",
94
95
  "test:all": "npm run test:installer && npm run test:scripts && npm run test:python",
@@ -127,6 +127,37 @@ const LUMINA_DIRS = [
127
127
 
128
128
  const VALID_PACKS = new Set(['core', 'research', 'reading', 'learning']);
129
129
  const VALID_IDE_TARGETS = new Set(['claude_code', 'codex', 'cursor', 'gemini_cli', 'qwen', 'iflow', 'generic']);
130
+ const RESEARCH_TOOL_FILES = [
131
+ '_env.py', '_cache.py', 'discover.py', 'init_discovery.py', 'prepare_source.py',
132
+ 'fetch_arxiv.py', 'fetch_wikipedia.py', 'fetch_s2.py', 'fetch_deepxiv.py',
133
+ 'fetch_openalex.py', 'fetch_unpaywall.py', 'fetch_core.py', 'resolve_pdf.py',
134
+ 'fetch_rss.py',
135
+ ];
136
+
137
+ async function findEnclosingWorkspace(startDir) {
138
+ let current = resolve(startDir);
139
+ while (true) {
140
+ try {
141
+ await access(join(current, '_lumina', 'manifest.json'), fsConstants.F_OK);
142
+ return current;
143
+ } catch (err) {
144
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err;
145
+ }
146
+ const parent = dirname(current);
147
+ if (parent === current) return null;
148
+ current = parent;
149
+ }
150
+ }
151
+
152
+ async function readManifestForInstall(projectRoot) {
153
+ try {
154
+ return await readManifest(projectRoot);
155
+ } catch (err) {
156
+ const e = new Error(`MANIFEST_READ_FAILED: ${err.message} (path: ${projectRoot}/_lumina/manifest.json)`);
157
+ e.code = 2;
158
+ throw e;
159
+ }
160
+ }
130
161
 
131
162
  // ---------------------------------------------------------------------------
132
163
  // install command
@@ -144,32 +175,24 @@ const VALID_IDE_TARGETS = new Set(['claude_code', 'codex', 'cursor', 'gemini_cli
144
175
  * @param {string} [opts.projectName] - Hidden escape hatch; default = basename(directory)
145
176
  * @param {string} [opts.communicationLang]
146
177
  * @param {string} [opts.documentOutputLang]
178
+ * @param {boolean} [opts.searchParents] - Find an enclosing Lumina workspace when no directory flag was used
147
179
  */
148
180
  export async function installCommand(opts = {}) {
149
181
  const { yes = false, reLink = false } = opts;
150
182
  const initialDir = opts.directory ?? opts.cwd ?? process.cwd();
151
- let projectRoot = resolve(initialDir);
183
+ const requestedRoot = resolve(initialDir);
184
+ let projectRoot = opts.searchParents
185
+ ? (await findEnclosingWorkspace(requestedRoot) ?? requestedRoot)
186
+ : requestedRoot;
152
187
  const colors = await getColorFns();
153
188
 
154
189
  // 1. Read existing manifest at the initial path (upgrade detection)
155
190
  // Distinguish ENOENT (fresh install) from real I/O errors. Real errors must
156
191
  // bail loud — silently treating them as "fresh install" would let a transient
157
192
  // permission failure quietly nuke an existing install.
158
- let existingManifest = null;
159
- try {
160
- existingManifest = await readManifest(projectRoot);
161
- } catch (err) {
162
- if (err.code === 'ENOENT') {
163
- existingManifest = null;
164
- } else {
165
- // Pre-loadLocale path — intentionally EN-only and machine-readable.
166
- const e = new Error(`MANIFEST_READ_FAILED: ${err.message} (path: ${projectRoot}/_lumina/manifest.json)`);
167
- e.code = 2;
168
- throw e;
169
- }
170
- }
193
+ let existingManifest = await readManifestForInstall(projectRoot);
171
194
 
172
- const isUpgrade = existingManifest !== null;
195
+ let isUpgrade = existingManifest !== null;
173
196
 
174
197
  // 2. Collect answers (locale not yet loaded; prompts use EN fallback literals)
175
198
  let answers;
@@ -182,13 +205,27 @@ export async function installCommand(opts = {}) {
182
205
  cwd: projectRoot,
183
206
  existingManifest,
184
207
  defaultLocale: opts.lang ?? 'en',
208
+ resolveDestination: async (directory) => {
209
+ const manifest = await readManifestForInstall(directory);
210
+ if (!manifest) return null;
211
+ return {
212
+ existingManifest: manifest,
213
+ answers: await readAnswersFromConfig(directory, manifest),
214
+ };
215
+ },
185
216
  });
186
217
  // Re-resolve projectRoot from the directory the user typed.
187
218
  if (answers.directory) {
188
219
  projectRoot = resolve(answers.directory);
189
220
  }
221
+ existingManifest = await readManifestForInstall(projectRoot);
222
+ isUpgrade = existingManifest !== null;
190
223
  }
191
224
  answers = applyInstallOverrides(answers, opts);
225
+ const previousSkillRows = isUpgrade
226
+ ? previousManagedSkillRows(await readSkillsManifest(projectRoot), existingManifest)
227
+ : [];
228
+ const previousFileRows = isUpgrade ? await readFilesManifest(projectRoot) : [];
192
229
 
193
230
  // 2b. Load locale module ONCE after applyInstallOverrides resolves locale.
194
231
  const localeMod = await loadLocale(answers.locale ?? 'en');
@@ -209,7 +246,11 @@ export async function installCommand(opts = {}) {
209
246
  ?? migrateManifest(existingManifest, MANIFEST_SCHEMA_VERSION).locale
210
247
  ?? 'en'
211
248
  );
212
- if (installedLocale !== answers.locale && !opts.forceLocaleSwitch) {
249
+ if (
250
+ installedLocale !== answers.locale
251
+ && !opts.forceLocaleSwitch
252
+ && answers.localeSwitchConfirmedFor !== answers.locale
253
+ ) {
213
254
  const e = new Error(
214
255
  `LOCALE_SWITCH_REFUSED: installed locale '${installedLocale}' differs from resolved locale '${answers.locale}'. ` +
215
256
  `Pass --force-locale-switch to confirm (this will rewrite README.md and IDE stubs in the new locale).`,
@@ -223,6 +264,9 @@ export async function installCommand(opts = {}) {
223
264
  const hasResearch = packs.includes('research');
224
265
  const hasReading = packs.includes('reading');
225
266
  const hasLearning = packs.includes('learning');
267
+ const previousProjectRoot = existingManifest?.resolvedPaths?.projectRoot;
268
+ const relocated = Boolean(previousProjectRoot && resolve(previousProjectRoot) !== projectRoot);
269
+ const effectiveReLink = reLink || relocated;
226
270
 
227
271
  console.log('');
228
272
  if (isUpgrade) {
@@ -230,6 +274,9 @@ export async function installCommand(opts = {}) {
230
274
  } else {
231
275
  console.log(colors.bold(t('progress.installing', { dir: projectRoot })));
232
276
  }
277
+ if (relocated) {
278
+ console.log(colors.yellow(t('warn.relocated', { from: previousProjectRoot, to: projectRoot })));
279
+ }
233
280
 
234
281
  // 3. Scaffold directories
235
282
  const dirsToCreate = [
@@ -252,6 +299,20 @@ export async function installCommand(opts = {}) {
252
299
  await ensureDir(join(projectRoot, dir));
253
300
  }
254
301
 
302
+ await reconcileRemovedIdeTargets({
303
+ projectRoot,
304
+ previousIdeTargets: existingManifest?.ideTargets ?? [],
305
+ currentIdeTargets: ideTargets,
306
+ previousFileRows,
307
+ previousSkillRows,
308
+ colors,
309
+ t,
310
+ });
311
+
312
+ if (isUpgrade && existingManifest?.packs?.research && !hasResearch) {
313
+ await cleanupRemovedResearchPack(projectRoot, previousFileRows, colors, t);
314
+ }
315
+
255
316
  // 4. Template variables
256
317
  const templateVars = {
257
318
  project_name: projectName,
@@ -282,7 +343,10 @@ export async function installCommand(opts = {}) {
282
343
  await copyChangelog(projectRoot);
283
344
 
284
345
  // 9. Copy skills
285
- const skillRows = await copySkills(projectRoot, packs);
346
+ const skillRows = await copySkills(projectRoot, packs, {
347
+ claudeCode: ideTargets.includes('claude_code'),
348
+ });
349
+ await reconcileRemovedSkills(projectRoot, previousSkillRows, skillRows);
286
350
 
287
351
  // 10. Copy Python tools (core: extract_pdf; research pack: discovery/fetchers)
288
352
  await copyTools(projectRoot, { research: hasResearch });
@@ -307,10 +371,18 @@ export async function installCommand(opts = {}) {
307
371
  // 15. Create per-skill symlinks (.claude/skills/lumi-*) for Claude Code
308
372
  const symlinkStrategies = {};
309
373
  if (ideTargets.includes('claude_code')) {
310
- const { strategies } = await createSkillSymlinks(
311
- projectRoot, skillRows, existingManifest, reLink, colors, t
374
+ const { strategies, errors } = await createSkillSymlinks(
375
+ projectRoot, skillRows, existingManifest, effectiveReLink, colors, t
312
376
  );
313
377
  Object.assign(symlinkStrategies, strategies);
378
+ if (errors.length > 0) {
379
+ const e = new Error(
380
+ `SKILL_LINKS_INCOMPLETE: ${errors.length} of ${skillRows.length} Claude skill links failed: ` +
381
+ errors.map(item => `${item.skill}: ${item.error.message}`).join('; '),
382
+ );
383
+ e.code = 2;
384
+ throw e;
385
+ }
314
386
  }
315
387
 
316
388
  // 16. Build files-manifest rows
@@ -677,7 +749,9 @@ function applyInstallOverrides(answers, opts) {
677
749
  const next = { ...answers };
678
750
  const priorLocale = answers.locale ?? null;
679
751
 
680
- // Locale: --lang flag overrides; case-insensitive normalize.
752
+ // Locale: --lang overrides the interactive selector. Keep the installed
753
+ // locale in answers.locale until this point so default language values can
754
+ // cascade correctly when an existing destination is selected.
681
755
  // Pre-loadLocale error → EN-only string (chicken-and-egg, machine-readable).
682
756
  if (opts.lang !== undefined && opts.lang !== null && opts.lang !== '') {
683
757
  const normalized = String(opts.lang).toLowerCase().trim();
@@ -687,9 +761,12 @@ function applyInstallOverrides(answers, opts) {
687
761
  throw e;
688
762
  }
689
763
  next.locale = normalized;
764
+ } else if (next.selectedLocale) {
765
+ next.locale = next.selectedLocale;
690
766
  } else if (!next.locale) {
691
767
  next.locale = 'en';
692
768
  }
769
+ delete next.selectedLocale;
693
770
 
694
771
  // Cascade: if --lang changed locale and user didn't explicitly pass language
695
772
  // overrides, refresh the language defaults to match the new locale.
@@ -929,6 +1006,113 @@ function ideTargetStubFiles(ideTargets) {
929
1006
  .filter(Boolean);
930
1007
  }
931
1008
 
1009
+ async function removeManagedFileIfUnchanged(projectRoot, relPath, previousFileRows, colors, t) {
1010
+ const absPath = safePath(projectRoot, relPath);
1011
+ try {
1012
+ await access(absPath, fsConstants.F_OK);
1013
+ } catch (err) {
1014
+ if (err.code === 'ENOENT') return;
1015
+ throw err;
1016
+ }
1017
+
1018
+ const previous = previousFileRows.find(row => row.relative_path === relPath);
1019
+ if (previous?.sha256) {
1020
+ try {
1021
+ if (await fileHash(absPath) === previous.sha256) {
1022
+ await unlink(absPath);
1023
+ return;
1024
+ }
1025
+ } catch (_) {}
1026
+ }
1027
+
1028
+ console.log(colors.yellow(t('warn.preserved_modified_file', { path: relPath })));
1029
+ }
1030
+
1031
+ function previousManagedSkillRows(previousSkillRows, existingManifest) {
1032
+ const rowsById = new Map(previousSkillRows.map(row => [row.canonical_id, row]));
1033
+ const previousStrategies = existingManifest?.symlinkStrategies ?? {};
1034
+ const claudeWasSelected = existingManifest?.ideTargets?.includes('claude_code') ?? false;
1035
+ const previousPacks = Object.keys(existingManifest?.packs ?? {});
1036
+ for (const skill of getSkillDefs(previousPacks)) {
1037
+ if (!rowsById.has(skill.canonicalId)) {
1038
+ rowsById.set(skill.canonicalId, {
1039
+ canonical_id: skill.canonicalId,
1040
+ relative_path: join('.agents', 'skills', skill.canonicalId),
1041
+ target_link_path: existingManifest?.ideTargets?.includes('claude_code')
1042
+ ? join('.claude', 'skills', skill.canonicalId)
1043
+ : '',
1044
+ });
1045
+ }
1046
+ }
1047
+ for (const canonicalId of Object.keys(previousStrategies)) {
1048
+ if (!rowsById.has(canonicalId)) {
1049
+ rowsById.set(canonicalId, {
1050
+ canonical_id: canonicalId,
1051
+ relative_path: join('.agents', 'skills', canonicalId),
1052
+ target_link_path: join('.claude', 'skills', canonicalId),
1053
+ });
1054
+ }
1055
+ }
1056
+ return [...rowsById.values()].map(row => ({
1057
+ ...row,
1058
+ managed_link: claudeWasSelected
1059
+ || Object.prototype.hasOwnProperty.call(previousStrategies, row.canonical_id),
1060
+ }));
1061
+ }
1062
+
1063
+ async function removeManagedSkillLink(projectRoot, skill) {
1064
+ if (skill.managed_link === false) return;
1065
+ const relPath = skill.target_link_path || join('.claude', 'skills', skill.canonical_id);
1066
+ await rm(safePath(projectRoot, relPath), { recursive: true, force: true });
1067
+ }
1068
+
1069
+ async function reconcileRemovedIdeTargets({
1070
+ projectRoot,
1071
+ previousIdeTargets,
1072
+ currentIdeTargets,
1073
+ previousFileRows,
1074
+ previousSkillRows,
1075
+ colors,
1076
+ t,
1077
+ }) {
1078
+ const removedTargets = previousIdeTargets.filter(target => !currentIdeTargets.includes(target));
1079
+ for (const target of removedTargets) {
1080
+ if (target === 'claude_code') {
1081
+ await Promise.all(previousSkillRows.map(skill => removeManagedSkillLink(projectRoot, skill)));
1082
+ }
1083
+
1084
+ const relPath = ideTargetStubFiles([target])[0];
1085
+ if (relPath) {
1086
+ await removeManagedFileIfUnchanged(projectRoot, relPath, previousFileRows, colors, t);
1087
+ }
1088
+ }
1089
+ }
1090
+
1091
+ async function reconcileRemovedSkills(projectRoot, previousSkillRows, currentSkillRows) {
1092
+ const currentIds = new Set(currentSkillRows.map(row => row.canonical_id));
1093
+ const obsolete = previousSkillRows.filter(row => !currentIds.has(row.canonical_id));
1094
+
1095
+ for (const skill of obsolete) {
1096
+ if (skill.relative_path) {
1097
+ await rm(safePath(projectRoot, skill.relative_path), { recursive: true, force: true });
1098
+ }
1099
+ await removeManagedSkillLink(projectRoot, skill);
1100
+ }
1101
+ }
1102
+
1103
+ async function cleanupRemovedResearchPack(projectRoot, previousFileRows, colors, t) {
1104
+ await Promise.all(RESEARCH_TOOL_FILES.map(file => (
1105
+ rm(safePath(projectRoot, join('_lumina', 'tools', file)), { force: true })
1106
+ )));
1107
+ await removeManagedFileIfUnchanged(
1108
+ projectRoot,
1109
+ '.env.example',
1110
+ previousFileRows,
1111
+ colors,
1112
+ t,
1113
+ );
1114
+ }
1115
+
932
1116
  function buildIdeStub(target, vars) {
933
1117
  const name = vars.project_name || 'this wiki';
934
1118
  switch (target) {
@@ -983,7 +1167,7 @@ async function copyChangelog(projectRoot) {
983
1167
  }
984
1168
  }
985
1169
 
986
- async function copySkills(projectRoot, packs) {
1170
+ async function copySkills(projectRoot, packs, { claudeCode = false } = {}) {
987
1171
  const skillRows = [];
988
1172
  const skillDefs = getSkillDefs(packs);
989
1173
 
@@ -1003,7 +1187,7 @@ async function copySkills(projectRoot, packs) {
1003
1187
  pack: skill.pack,
1004
1188
  source: 'built-in',
1005
1189
  relative_path: `.agents/skills/${skill.canonicalId}`,
1006
- target_link_path: join('.claude', 'skills', skill.canonicalId),
1190
+ target_link_path: claudeCode ? join('.claude', 'skills', skill.canonicalId) : '',
1007
1191
  version: PKG.version,
1008
1192
  });
1009
1193
  }
@@ -1085,13 +1269,7 @@ function getSkillDefs(packs) {
1085
1269
  async function copyTools(projectRoot, { research }) {
1086
1270
  const destDir = join(projectRoot, '_lumina', 'tools');
1087
1271
  const coreTools = ['extract_pdf.py', 'fetch_pdf.py', 'id_utils.py'];
1088
- const researchTools = [
1089
- '_env.py', '_cache.py', 'discover.py', 'init_discovery.py', 'prepare_source.py',
1090
- 'fetch_arxiv.py', 'fetch_wikipedia.py', 'fetch_s2.py', 'fetch_deepxiv.py',
1091
- 'fetch_openalex.py', 'fetch_unpaywall.py', 'fetch_core.py', 'resolve_pdf.py',
1092
- 'fetch_rss.py',
1093
- ];
1094
- const toolFiles = research ? [...coreTools, ...researchTools] : coreTools;
1272
+ const toolFiles = research ? [...coreTools, ...RESEARCH_TOOL_FILES] : coreTools;
1095
1273
  // Parallelize: each copy is independent and destDir already exists.
1096
1274
  // Sequential awaits were the main Windows cold-start regression in v1.4
1097
1275
  // (~30 ms per file × 14 files dominates on NTFS + Defender).
@@ -1213,6 +1391,7 @@ async function seedWikiFiles(projectRoot) {
1213
1391
 
1214
1392
  async function createSkillSymlinks(projectRoot, skillRows, existingManifest, reLink, colors, t = null) {
1215
1393
  const strategies = {};
1394
+ const errors = [];
1216
1395
 
1217
1396
  for (const skill of skillRows) {
1218
1397
  const target = resolve(projectRoot, skill.relative_path);
@@ -1227,6 +1406,7 @@ async function createSkillSymlinks(projectRoot, skillRows, existingManifest, reL
1227
1406
  console.log(colors.yellow(` [warn] ${result.message}`));
1228
1407
  }
1229
1408
  } catch (err) {
1409
+ errors.push({ skill: skill.canonical_id, error: err });
1230
1410
  const msg = t
1231
1411
  ? t('error.symlink', { skill: skill.canonical_id, message: err.message })
1232
1412
  : ` [error] Failed to link ${skill.canonical_id}: ${err.message}`;
@@ -1234,7 +1414,7 @@ async function createSkillSymlinks(projectRoot, skillRows, existingManifest, reL
1234
1414
  }
1235
1415
  }
1236
1416
 
1237
- return { strategies };
1417
+ return { strategies, errors };
1238
1418
  }
1239
1419
 
1240
1420
  async function buildFilesManifest(projectRoot, packs, pkgVersion) {
@@ -29,6 +29,7 @@ import {
29
29
  stat,
30
30
  readdir,
31
31
  symlink,
32
+ realpath,
32
33
  lstat,
33
34
  rm,
34
35
  copyFile,
@@ -186,6 +187,18 @@ export function fileHash(filePath) {
186
187
  // linkDirectory — symlink ladder
187
188
  // ---------------------------------------------------------------------------
188
189
 
190
+ async function linkPointsToTarget(linkPath, target) {
191
+ try {
192
+ const [currentTarget, expectedTarget] = await Promise.all([
193
+ realpath(linkPath),
194
+ realpath(target),
195
+ ]);
196
+ return currentTarget === expectedTarget;
197
+ } catch (_) {
198
+ return false;
199
+ }
200
+ }
201
+
189
202
  /**
190
203
  * @typedef {'symlink'|'junction'|'copy'} LinkStrategy
191
204
  */
@@ -206,9 +219,10 @@ export function fileHash(filePath) {
206
219
  * 2. fs.symlink(target, linkPath, 'junction') — Windows directory junctions
207
220
  * 3. copyDir(target, linkPath) + warn — final fallback
208
221
  *
209
- * When `linkPath` already exists and matches the same strategy recorded in
210
- * `existingStrategy` (from manifest), the function returns early (idempotent).
211
- * When `linkPath` already exists with a different strategy, it is removed first.
222
+ * When `linkPath` already exists and points to `target` with the same link
223
+ * strategy recorded in `existingStrategy`, the function returns early.
224
+ * Stale links are removed and recreated. Copy fallbacks are refreshed because
225
+ * their source target cannot be verified from filesystem metadata.
212
226
  *
213
227
  * @param {string} target - Real directory the link should point to.
214
228
  * @param {string} linkPath - Where the link/copy will be created.
@@ -220,20 +234,28 @@ export async function linkDirectory(target, linkPath, existingStrategy = null) {
220
234
  let existingStat = null;
221
235
  try {
222
236
  existingStat = await lstat(linkPath);
223
- } catch (_) {
224
- // Does not exist proceed with creation
237
+ } catch (err) {
238
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err;
225
239
  }
226
240
 
227
241
  if (existingStat) {
228
242
  // If it is a symlink or junction pointing to the right target, keep it
229
- if (existingStat.isSymbolicLink() && existingStrategy === 'symlink') {
230
- return { strategy: 'symlink', message: `symlink already exists: ${linkPath}`, warning: false };
231
- }
232
- if (existingStat.isSymbolicLink() && existingStrategy === 'junction') {
233
- return { strategy: 'junction', message: `junction already exists: ${linkPath}`, warning: false };
243
+ if (
244
+ existingStat.isSymbolicLink()
245
+ && (existingStrategy === 'symlink' || existingStrategy === 'junction')
246
+ ) {
247
+ if (await linkPointsToTarget(linkPath, target)) {
248
+ return {
249
+ strategy: existingStrategy,
250
+ message: `${existingStrategy} already exists: ${linkPath}`,
251
+ warning: false,
252
+ };
253
+ }
234
254
  }
235
255
  if (existingStat.isDirectory() && existingStrategy === 'copy') {
236
- return { strategy: 'copy', message: `copy already exists: ${linkPath}`, warning: false };
256
+ await rm(linkPath, { recursive: true, force: true });
257
+ await copyDir(target, linkPath);
258
+ return { strategy: 'copy', message: `copy refreshed: ${linkPath}`, warning: false };
237
259
  }
238
260
  // Stale or mismatched — remove and recreate
239
261
  if (existingStat.isSymbolicLink()) {
@@ -249,8 +271,11 @@ export async function linkDirectory(target, linkPath, existingStrategy = null) {
249
271
 
250
272
  // Attempt 1: native symlink (macOS / Linux / Windows with Developer Mode)
251
273
  try {
252
- await symlink(target, linkPath);
253
- return { strategy: 'symlink', message: `symlink created: ${linkPath} -> ${target}`, warning: false };
274
+ const symlinkTarget = process.platform === 'win32'
275
+ ? resolve(target)
276
+ : (relative(dirname(linkPath), target) || '.');
277
+ await symlink(symlinkTarget, linkPath);
278
+ return { strategy: 'symlink', message: `symlink created: ${linkPath} -> ${symlinkTarget}`, warning: false };
254
279
  } catch (err1) {
255
280
  // EPERM on Windows without Developer Mode, or other platform restriction
256
281
  const isPermError = err1.code === 'EPERM' || err1.code === 'EACCES';
@@ -259,7 +284,7 @@ export async function linkDirectory(target, linkPath, existingStrategy = null) {
259
284
 
260
285
  // Attempt 2: Windows directory junction
261
286
  try {
262
- await symlink(target, linkPath, 'junction');
287
+ await symlink(resolve(target), linkPath, 'junction');
263
288
  return { strategy: 'junction', message: `junction created: ${linkPath} -> ${target}`, warning: false };
264
289
  } catch (err2) {
265
290
  // Junction also failed — fall through to copy
@@ -75,6 +75,8 @@ export default {
75
75
  // ── Warnings ───────────────────────────────────────────────────────────────
76
76
  'warn.manifest_read': '[warn] Could not read existing manifest: {message}. Treating as fresh install.',
77
77
  'warn.copied_skills': ' [warn] Some skills were copied instead of symlinked. Run "lumina install --re-link" after enabling Windows Developer Mode.',
78
+ 'warn.relocated': ' [warn] Workspace moved from {from} to {to}; managed links will be refreshed.',
79
+ 'warn.preserved_modified_file': ' [warn] Kept modified file that is no longer selected: {path}',
78
80
  'warn.upgrade_header': '[warn] Lumina upgraded v{from} -> v{to} — schema gap detected:',
79
81
  'warn.upgrade_errors': ' {errors} error(s), {warnings} warning(s) across legacy entries.',
80
82
  'warn.upgrade_fix_quick': ' Quick fix (deterministic):',
@@ -74,6 +74,8 @@ export default {
74
74
  // ── Warnings ───────────────────────────────────────────────────────────────
75
75
  'warn.manifest_read': '[cảnh báo] Không đọc được manifest hiện tại: {message}. Coi như cài đặt mới.',
76
76
  'warn.copied_skills': ' [cảnh báo] Một số kỹ năng được sao chép thay vì symlink. Chạy "lumina install --re-link" sau khi bật Windows Developer Mode.',
77
+ 'warn.relocated': ' [cảnh báo] Workspace đã chuyển từ {from} sang {to}; các liên kết do Lumina quản lý sẽ được làm mới.',
78
+ 'warn.preserved_modified_file': ' [cảnh báo] Giữ lại tệp đã được chỉnh sửa dù không còn được chọn: {path}',
77
79
  'warn.upgrade_header': '[cảnh báo] Lumina đã nâng cấp v{from} -> v{to} — phát hiện chênh lệch schema:',
78
80
  'warn.upgrade_errors': ' {errors} lỗi, {warnings} cảnh báo trên các mục cũ.',
79
81
  'warn.upgrade_fix_quick': ' Sửa nhanh (xác định):',
@@ -80,6 +80,8 @@ export default {
80
80
  // ── Warnings ───────────────────────────────────────────────────────────────
81
81
  'warn.manifest_read': '[警告] 无法读取现有 manifest: {message}。视为全新安装。',
82
82
  'warn.copied_skills': ' [警告] 部分技能采用复制而非软链接。启用 Windows 开发者模式后请运行 "lumina install --re-link"。',
83
+ 'warn.relocated': ' [警告] 工作区已从 {from} 移动到 {to};Lumina 管理的链接将被刷新。',
84
+ 'warn.preserved_modified_file': ' [警告] 已保留不再选用但被修改过的文件:{path}',
83
85
  'warn.upgrade_header': '[警告] Lumina 已从 v{from} 升级到 v{to} — 检测到 schema 差异:',
84
86
  'warn.upgrade_errors': ' 旧条目共有 {errors} 个错误、{warnings} 个警告。',
85
87
  'warn.upgrade_fix_quick': ' 快速修复(确定性):',
@@ -149,9 +149,17 @@ export function buildPromptList(existingManifest, defaultLocale = 'en') {
149
149
  * @param {boolean} [opts.acceptDefaults=false] - Skip prompts; return defaults.
150
150
  * @param {string} [opts.cwd] - Project root for defaults.
151
151
  * @param {Function} [opts.t] - Locale translator function.
152
+ * @param {Function} [opts.resolveDestination] - Detect an existing install after directory selection.
152
153
  * @returns {Promise<InstallAnswers>}
153
154
  */
154
- export async function runInstallPrompts({ acceptDefaults = false, cwd = process.cwd(), existingManifest = null, defaultLocale = 'en', t: initialT = null } = {}) {
155
+ export async function runInstallPrompts({
156
+ acceptDefaults = false,
157
+ cwd = process.cwd(),
158
+ existingManifest = null,
159
+ defaultLocale = 'en',
160
+ t: initialT = null,
161
+ resolveDestination = null,
162
+ } = {}) {
155
163
  if (acceptDefaults) {
156
164
  const loc = existingManifest?.locale ?? defaultLocale;
157
165
  return defaultAnswers(cwd, loc);
@@ -224,6 +232,31 @@ export async function runInstallPrompts({ acceptDefaults = false, cwd = process.
224
232
  const directory = expandUserPath(directoryRaw, cwdAbs);
225
233
  const projectName = defaultProjectName(directory);
226
234
 
235
+ if (!existingManifest && resolveDestination) {
236
+ const detected = await resolveDestination(directory);
237
+ if (detected?.existingManifest && detected?.answers) {
238
+ const installedLocale = detected.existingManifest.locale ?? 'en';
239
+ let localeSwitchConfirmedFor = null;
240
+ if (installedLocale !== locale) {
241
+ const proceed = await confirm({
242
+ message: `Locale change ${installedLocale} -> ${locale} will rewrite README.md and IDE stubs in the new locale. Outside-schema edits are preserved. Continue?`,
243
+ initialValue: false,
244
+ });
245
+ if (isCancel(proceed) || !proceed) {
246
+ cancel(t ? t('prompt.cancelled') : 'Installation cancelled.');
247
+ process.exit(4);
248
+ }
249
+ localeSwitchConfirmedFor = locale;
250
+ }
251
+ return {
252
+ ...detected.answers,
253
+ directory,
254
+ selectedLocale: locale,
255
+ localeSwitchConfirmedFor,
256
+ };
257
+ }
258
+ }
259
+
227
260
  // ── Prompt 2: Research purpose (multi-line free-form, optional) ─────────
228
261
  const researchPurposeRaw = await text({
229
262
  message: t ? t('prompt.purpose.message') : 'Research purpose (optional — describe what this wiki is for)',