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 +54 -0
- package/README.md +2 -0
- package/README.vi.md +2 -0
- package/README.zh.md +2 -0
- package/bin/lumina.js +7 -0
- package/package.json +8 -2
- package/src/installer/commands.js +212 -30
- package/src/installer/fs.js +39 -14
- package/src/installer/locales/en.mjs +2 -0
- package/src/installer/locales/vi.mjs +2 -0
- package/src/installer/locales/zh.mjs +2 -0
- package/src/installer/prompts.js +34 -1
- package/src/skills/packs/research/discover/references/source-modes.md +2 -2
- package/src/skills/packs/research/setup/SKILL.md +1 -1
- package/src/skills/packs/research/watch-run/SKILL.md +1 -1
- package/src/skills/packs/research/watchlist/SKILL.md +3 -2
- package/src/templates/.env.example +4 -0
- package/src/tools/_cache.py +5 -5
- package/src/tools/fetch_core.py +293 -0
- package/src/tools/fetch_openalex.py +460 -0
- package/src/tools/fetch_rss.py +482 -0
- package/src/tools/fetch_unpaywall.py +221 -0
- package/src/tools/init_discovery.py +3 -3
- package/src/tools/resolve_pdf.py +439 -0
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.
|
|
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/
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
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
|
|
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) {
|
package/src/installer/fs.js
CHANGED
|
@@ -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
|
|
210
|
-
* `existingStrategy
|
|
211
|
-
*
|
|
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
|
-
|
|
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 (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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):',
|