lumina-wiki 1.6.0 → 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,60 @@ 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
+
28
+ ## [1.6.1] - 2026-05-18
29
+
30
+ ### Fixed
31
+
32
+ - Restored v1.6 research tool scripts to the npm package allowlist so
33
+ upgrades include OpenAlex, Unpaywall, CORE, RSS, and PDF resolution tools
34
+ (fixes #20).
35
+ - Expanded the package-readiness check (`scripts/ci-package.mjs`) to require
36
+ every Python tool copied by the installer, preventing future research-pack
37
+ tarball omissions.
38
+
39
+ ### Changed
40
+
41
+ - OpenAlex research tooling now authenticates via `OPENALEX_API_KEY` /
42
+ `api_key` query parameter instead of the deprecated `OPENALEX_MAILTO`
43
+ polite-pool flow. The new key enables OpenAlex's free daily API budget and
44
+ usage tracking. Existing users should rename `OPENALEX_MAILTO` to
45
+ `OPENALEX_API_KEY` in their local `.env` — the old variable is ignored.
46
+ - `fetch_openalex.py` search `per_page` is now clamped to 100 (the OpenAlex
47
+ documented maximum) and explicit 401/403 handling surfaces a clear error
48
+ message when the key is rejected.
49
+
50
+ ### CI
51
+
52
+ - Dropped Node 22 from the test matrix across all OSes due to an upstream
53
+ `node:test` IPC bug (`ERR_TEST_FAILURE` / structured-clone deserialization)
54
+ that surfaced intermittently on Windows, macOS, and Linux runners.
55
+ - Cold-start budget gate now runs only on `ubuntu-latest`; Windows and macOS
56
+ hosted runners have filesystem latency that makes the 350 ms threshold
57
+ infeasible regardless of code changes.
58
+ - `Node 20 / windows-latest` marked `continue-on-error: true` to surface a
59
+ remaining Windows-only `node:test` flake as a warning instead of blocking
60
+ the build (tracked in #23).
61
+
8
62
  ## [1.6.0] - 2026-05-15
9
63
 
10
64
  ### Added — Multi-provider PDF resolution + RSS / Atom feeds (research pack)
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.0",
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",
@@ -64,6 +64,11 @@
64
64
  "src/tools/fetch_wikipedia.py",
65
65
  "src/tools/fetch_s2.py",
66
66
  "src/tools/fetch_deepxiv.py",
67
+ "src/tools/fetch_openalex.py",
68
+ "src/tools/fetch_unpaywall.py",
69
+ "src/tools/fetch_core.py",
70
+ "src/tools/resolve_pdf.py",
71
+ "src/tools/fetch_rss.py",
67
72
  "src/tools/id_utils.py",
68
73
  "src/tools/requirements.txt",
69
74
  "CHANGELOG.md",
@@ -83,7 +88,8 @@
83
88
  "devDependencies": {},
84
89
  "scripts": {
85
90
  "test": "npm run test:installer",
86
- "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",
87
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",
88
94
  "test:python": "node scripts/run-pytest.mjs",
89
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).
@@ -1140,6 +1318,8 @@ async function renderEnvExample(projectRoot) {
1140
1318
  `# Copy to .env and fill in your values. Never commit .env.\n\n` +
1141
1319
  `# Semantic Scholar API key (optional; improves rate limits)\n` +
1142
1320
  `SEMANTIC_SCHOLAR_API_KEY=\n\n` +
1321
+ `# OpenAlex API key (optional; enables free daily API budget and usage tracking)\n` +
1322
+ `OPENALEX_API_KEY=\n\n` +
1143
1323
  `# DeepXiv token (optional; enables full-text PDF access)\n` +
1144
1324
  `DEEPXIV_TOKEN=\n\n` +
1145
1325
  `# arXiv does not require an API key in v0.1\n`;
@@ -1211,6 +1391,7 @@ async function seedWikiFiles(projectRoot) {
1211
1391
 
1212
1392
  async function createSkillSymlinks(projectRoot, skillRows, existingManifest, reLink, colors, t = null) {
1213
1393
  const strategies = {};
1394
+ const errors = [];
1214
1395
 
1215
1396
  for (const skill of skillRows) {
1216
1397
  const target = resolve(projectRoot, skill.relative_path);
@@ -1225,6 +1406,7 @@ async function createSkillSymlinks(projectRoot, skillRows, existingManifest, reL
1225
1406
  console.log(colors.yellow(` [warn] ${result.message}`));
1226
1407
  }
1227
1408
  } catch (err) {
1409
+ errors.push({ skill: skill.canonical_id, error: err });
1228
1410
  const msg = t
1229
1411
  ? t('error.symlink', { skill: skill.canonical_id, message: err.message })
1230
1412
  : ` [error] Failed to link ${skill.canonical_id}: ${err.message}`;
@@ -1232,7 +1414,7 @@ async function createSkillSymlinks(projectRoot, skillRows, existingManifest, reL
1232
1414
  }
1233
1415
  }
1234
1416
 
1235
- return { strategies };
1417
+ return { strategies, errors };
1236
1418
  }
1237
1419
 
1238
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):',