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 +20 -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 +3 -2
- package/src/installer/commands.js +210 -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/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.
|
|
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/
|
|
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
|
-
|
|
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).
|
|
@@ -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) {
|
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):',
|
|
@@ -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': ' 快速修复(确定性):',
|
package/src/installer/prompts.js
CHANGED
|
@@ -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({
|
|
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)',
|