#!/usr/bin/env node // bin/dsh.mjs — rev-skills 的 DSH(DeepSeek Harness)适配 CLI // // 为什么单独做一层:DSH 的技能发现与 Claude Code 有一处关键差别——**不合法的技能文件会被 // 静默跳过**(只写一条日志 warning)。技能目录里一个坏 frontmatter 不会让会话报错,只会让 // 该技能从目录里消失,因此需要一个独立闸口把这类文件点名。 // // 子命令(零依赖,只用 node: 内置模块,ESM,Node >=22): // check [--dir ] [--json] 按 DSH 发现规则校验技能根 // preset spec 打印 DSH 预设 bundle 的安装输入(只打印,不安装) // preset status [--profile-dir ] [--json] 只读检查 profile 是否已启用该 bundle // preset install [--apply] [--profile-dir ] [--force] // preset uninstall [--apply] [--profile-dir ] // // 约定:所有会改文件的逻辑都写成纯函数(applyProfileEdits / removeProfileEdits), // 写入只在 CLI 层发生,且默认 dry-run。本工具**不启动任何子进程**(不调用 pnpm)。 // // 诚实边界:本文件只按下方 DSH_CHECKS 列出的规则做**行级**判定,不做完整 YAML 校验 // (DSH 内部用完整 YAML 解析器)。`--json` 的 checks 字段逐条列出实际做了哪些检查。 import { readFileSync, writeFileSync, copyFileSync, existsSync, readdirSync, renameSync, statSync, rmSync } from 'node:fs'; import { join, dirname, resolve, isAbsolute, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import { parseFrontmatterFields } from '../lib/frontmatter.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); export const REPO_ROOT = resolve(__dirname, '..'); export const BUNDLE_DIR = REPO_ROOT; export const DEFAULT_SKILLS_ROOT = join(REPO_ROOT, '.claude', 'skills'); export const BUNDLE_PACKAGE = 'rev-skills'; export const PRESET_ID = 'rev-skills'; export const PROFILE_BACKUP = 'package.json.rev-skills.bak'; // DSH 的技能名文法(@deepseek-ai/dsh-skill 的 SKILL_NAME) export const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; // 标准预设的工具结果裁剪阈值(thresholdChars)——超过只告警,不是错误 export const PRUNE_THRESHOLD_CHARS = 8192; // 实际执行的检查项清单:`--json` 逐条透出,避免让人以为做了完整 YAML 校验 export const DSH_CHECKS = [ '布局:只扫一层,识别 //SKILL.md(目录 bundle)与 /.md(平铺文件),嵌套 **/SKILL.md 不识别', '目录 bundle 必须含 SKILL.md', 'frontmatter 首行是裸 "---"(允许行尾 CR;BOM 开头会失败)', 'frontmatter 有闭合的 "---" 行', 'frontmatter 不含 tab 字符', 'frontmatter 顶层是 key: value 映射(优先用仓库解析子集;它只认 \\w+ 键,遇到 DSH 的连字符键时回落放宽键文法的行级复核。完整 YAML 语义未校验)', 'name 存在且非空', 'name 等于目录名(平铺文件等于去扩展名的文件名)', 'name 匹配 ^[a-z0-9]+(?:-[a-z0-9]+)*$', 'description 存在且非空', '闭合行之后的正文非空', 'disable-model-invocation / user-invocable 只接受 DSH 布尔文法(驼峰 legacy 键报错)', `SKILL.md 超过 ${PRUNE_THRESHOLD_CHARS} 字符时告警(工具结果裁剪阈值,不阻塞发现;计数按检出文件字符数,CRLF 结账会多算换行前的 CR)`, ]; const INVOCATION_BOOL_KEYS = ['disable-model-invocation', 'user-invocable']; const LEGACY_INVOCATION_KEYS = ['disableModelInvocation', 'modelInvocable', 'userInvocable']; const TRUE_WORDS = new Set(['true', 'yes', 'on', '1']); const FALSE_WORDS = new Set(['false', 'no', 'off', '0']); // 放宽键文法的行级解析(只在本文件内用)。仓库的 lib/frontmatter.mjs 只认 \w+ 键, // 而 DSH 的 disable-model-invocation / user-invocable 带连字符:那几个键在那里会被判成 // 「顶格游离行」,若直接采信就会把完全合法的技能误报成非法。DSH 用的是完整 YAML 解析器, // 连字符键是合法 YAML,所以这里回落到与仓库解析器同语义、但键文法放宽到 [A-Za-z0-9_-]+ 的复核。 const LOOSE_KEY = /^([A-Za-z0-9_-]+):[ \t]?(.*)$/; function parseLooseFrontmatter(raw) { const fm = {}; const lines = raw.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.trim() === '' || line.trimStart().startsWith('#')) continue; if (/^\s/.test(line)) continue; // 缩进续行由块标量消费,游离的缩进行按仓库解析器语义忽略 const kv = LOOSE_KEY.exec(line); if (!kv) throw new Error(`顶层第 ${i + 1} 行不是 "key: value" 映射行:${line.slice(0, 60)}`); const value = kv[2]; if (/^[>|][+-]?$/.test(value.trim())) { const folded = []; while (lines[i + 1] !== undefined && /^\s+/.test(lines[i + 1])) { const cont = lines[++i].replace(/^\s+/, ''); if (cont.trim() !== '') folded.push(cont); } fm[kv[1]] = folded.join(value.trim()[0] === '>' ? ' ' : '\n').trim(); } else { fm[kv[1]] = value.trim(); } } return fm; } // 先交仓库解析器(与 validate.mjs 同一套语义),失败时用放宽键文法的复核兜底 function parseFrontmatterForDsh(raw) { try { return { fm: parseFrontmatterFields(raw), fallback: false }; } catch (repoError) { try { return { fm: parseLooseFrontmatter(raw), fallback: true, repoError }; } catch { throw repoError; } } } // ---------- 技能检查 ---------- function entryKind(dir, dirent) { if (dirent.isSymbolicLink()) { // install --target dsh --link 会产生符号链接/目录联接,按目标类型判定 try { const st = statSync(join(dir, dirent.name)); return st.isDirectory() ? 'dir' : st.isFile() ? 'file' : 'other'; } catch { return 'other'; } } return dirent.isDirectory() ? 'dir' : dirent.isFile() ? 'file' : 'other'; } // 单文件检查 → { errors, warnings, chars, bodyChars } export function checkSkillFile(file, expectedName) { const errors = []; const warnings = []; const push = (code, message) => errors.push({ file, name: expectedName ?? null, code, message }); const text = readFileSync(file, 'utf8'); const chars = text.length; // DSH 的分界行判定容忍行尾 CR,YAML 解析器也不在乎 CRLF;解析与正文计数统一在 LF 归一化后的 // 文本上做,这样检查结果与结账方式(Windows 上 core.autocrlf=true 会写出 CRLF)无关。 const src = text.replace(/\r\n?/g, '\n'); const bail = () => ({ errors, warnings, chars, bodyChars: 0 }); const lines = src.split('\n'); const opening = lines[0] ?? ''; if (opening !== '---') { const bom = text.startsWith('\uFEFF') ? '(文件以 BOM 开头,DSH 的首行判定会失败)' : ''; push('opening-delimiter', `首行不是裸 "---"${bom},实际是 ${JSON.stringify(opening.slice(0, 40))}`); return bail(); } let close = -1; for (let i = 1; i < lines.length; i++) { if (lines[i] === '---') { close = i; break; } } if (close < 0) { push('closing-delimiter', 'frontmatter 缺少闭合的 "---" 行'); return bail(); } const raw = lines.slice(1, close).join('\n'); const bodyChars = lines.slice(close + 1).join('\n').trim().length; if (raw.includes('\t')) push('tab-in-frontmatter', 'frontmatter 含 tab 字符(YAML 不允许用 tab 缩进)'); let fm = null; if (raw.trim() === '') { push('empty-frontmatter', 'frontmatter 块为空,YAML 解析结果是 null 而不是映射'); } else { try { fm = parseFrontmatterForDsh(raw).fm; } catch (e) { push('frontmatter-not-mapping', `${e.message}(按仓库解析子集判定;完整 YAML 语义未校验)`); } } const name = typeof fm?.name === 'string' ? fm.name.trim() : ''; const description = typeof fm?.description === 'string' ? fm.description.trim() : ''; if (!name) push('name-missing', 'frontmatter 缺少非空的 name'); else { if (!SKILL_NAME_PATTERN.test(name)) push('name-invalid', `name "${name}" 不符合 ^[a-z0-9]+(?:-[a-z0-9]+)*$`); if (expectedName !== undefined && expectedName !== null && name !== expectedName) { push('name-mismatch', `name "${name}" 与目录/文件名 "${expectedName}" 不一致`); } } if (!description) push('description-missing', 'frontmatter 缺少非空的 description'); if (bodyChars === 0) push('empty-body', '闭合 "---" 之后的正文为空'); for (const key of LEGACY_INVOCATION_KEYS) { if (fm && Object.hasOwn(fm, key)) { const canonical = key === 'userInvocable' ? 'user-invocable' : 'disable-model-invocation'; push('legacy-invocation-key', `frontmatter 字段 "${key}" 不被支持,DSH 要求写成 "${canonical}"`); } } for (const key of INVOCATION_BOOL_KEYS) { if (!fm || !Object.hasOwn(fm, key)) continue; const v = String(fm[key]).trim().toLowerCase(); if (v !== '' && !TRUE_WORDS.has(v) && !FALSE_WORDS.has(v)) { push('invalid-invocation-value', `frontmatter 字段 "${key}" 的值 ${JSON.stringify(fm[key])} 不是 DSH 接受的布尔写法`); } } if (chars > PRUNE_THRESHOLD_CHARS) { warnings.push({ file, name: expectedName ?? name ?? null, code: 'prune-threshold', chars, bodyChars, message: `SKILL.md ${chars} 字符 > ${PRUNE_THRESHOLD_CHARS},被标准预设加载时可能被裁剪(正文 ${bodyChars} 字符)`, }); } return { errors, warnings, chars, bodyChars }; } // 校验整个技能根 → { ok, root, total, errors[], warnings[], skills[], skipped[], checks[] } export function checkSkills(root) { const errors = []; const warnings = []; const skills = []; const skipped = []; const base = { root, checks: DSH_CHECKS }; if (!existsSync(root)) { return { ...base, ok: false, total: 0, errors: [{ file: root, name: null, code: 'root-missing', message: `技能根不存在:${root}` }], warnings: [], skills: [], skipped, }; } const entries = readdirSync(root, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); for (const e of entries) { if (e.name.startsWith('.')) { skipped.push(e.name); continue; } const full = join(root, e.name); const kind = entryKind(root, e); let file = null; let name = null; let layout = null; if (kind === 'dir') { layout = 'bundle'; name = e.name; file = join(full, 'SKILL.md'); if (!existsSync(file)) { errors.push({ file: full, name, code: 'missing-skill-md', message: `目录 bundle 缺 SKILL.md:${full}` }); skills.push({ name, layout, file: null, chars: 0, bodyChars: 0 }); continue; } } else if (kind === 'file' && e.name.endsWith('.md')) { layout = 'flat'; name = e.name.slice(0, -3); file = full; } else { skipped.push(e.name); continue; } const r = checkSkillFile(file, name); errors.push(...r.errors); warnings.push(...r.warnings); skills.push({ name, layout, file, chars: r.chars, bodyChars: r.bodyChars }); } return { ...base, ok: errors.length === 0, total: skills.length, errors, warnings, skills, skipped }; } // ---------- profile 状态 ---------- // 默认 profile 目录:$DSH_PROFILE_DIR → $DSH_HOME/profiles/desktop → ~/.dsh/profiles/desktop export function resolveProfileDir(explicit, env = process.env) { if (explicit) return resolve(explicit); if (env.DSH_PROFILE_DIR) return env.DSH_PROFILE_DIR; const home = env.DSH_HOME || join(homedir(), '.dsh'); return join(home, 'profiles', 'desktop'); } export function linkSpec(bundleDir) { return `link:${resolve(bundleDir).split(sep).join('/')}`; } function normalizeSpecPath(spec) { if (typeof spec !== 'string' || spec.trim() === '') return null; const m = /^(?:link|file):(.*)$/i.exec(spec.trim()); const p = (m ? m[1] : spec).trim(); if (!p) return null; const abs = isAbsolute(p) ? p : resolve(p); const norm = abs.split(/[\\/]+/).join('/').replace(/\/+$/, ''); return process.platform === 'win32' ? norm.toLowerCase() : norm; } export function dependencyMatchesBundle(spec, bundleDir) { const a = normalizeSpecPath(spec); if (a === null) return false; return a === normalizeSpecPath(resolve(bundleDir)); } // 在 Cordis 补丁文本里找预设行:行形如 `id: <名字>`(可带 `- ` 前缀、可带引号) // 同时接受 Loader 行 id(preset-)与 config.id() export function findPresetRow(text, id = PRESET_ID) { const lines = String(text).split('\n'); for (let i = 0; i < lines.length; i++) { const m = /^\s*(?:-\s*)?id:\s*['"]?([^'"\s#]+)['"]?\s*$/.exec(lines[i]); if (!m) continue; if (m[1] === id || m[1] === `preset-${id}`) { return { line: i + 1, id: m[1], text: lines[i].trim() }; } } return null; } // 只读:profile 与 bundle 的一致性快照(测试直接用它,不需要真实 profile) export function readProfileState(profileDir = resolveProfileDir(), bundleDir = BUNDLE_DIR) { const profileFile = join(profileDir, 'package.json'); const bundleFile = join(bundleDir, 'package.json'); const state = { profileDir, profileFile, profileExists: existsSync(profileFile), profileError: null, pkg: null, bundleDir, bundleFile, bundleExists: existsSync(bundleFile), bundleError: null, bundleName: BUNDLE_PACKAGE, patchFile: null, patchExists: false, presetRowFound: false, presetRow: null, bundles: [], listedInBundles: false, dependencyEntry: null, dependencyMatchesBundle: false, ok: false, }; if (!state.profileExists) state.profileError = `profile package.json 不存在:${profileFile}`; else { try { state.pkg = JSON.parse(readFileSync(profileFile, 'utf8')); } catch (e) { state.profileError = `profile package.json 不是合法 JSON:${e.message}`; } } if (state.bundleExists) { let bp = null; try { bp = JSON.parse(readFileSync(bundleFile, 'utf8')); } catch (e) { state.bundleError = `bundle package.json 不是合法 JSON:${e.message}`; } const rel = bp?.dsh?.bundle?.patch; state.patchFile = join(bundleDir, typeof rel === 'string' && rel.trim() !== '' ? rel : 'cordis.patch.yml'); } else { state.bundleError = `bundle 不存在:${bundleFile}`; state.patchFile = join(bundleDir, 'cordis.patch.yml'); } state.patchExists = existsSync(state.patchFile); if (state.patchExists) { state.presetRow = findPresetRow(readFileSync(state.patchFile, 'utf8'), PRESET_ID); state.presetRowFound = state.presetRow !== null; } const bundles = state.pkg?.dsh?.profile?.bundles; state.bundles = Array.isArray(bundles) ? bundles : []; state.listedInBundles = state.bundles.includes(BUNDLE_PACKAGE); const dep = state.pkg?.dependencies?.[BUNDLE_PACKAGE]; state.dependencyEntry = typeof dep === 'string' ? dep : null; state.dependencyMatchesBundle = dependencyMatchesBundle(state.dependencyEntry, bundleDir); state.ok = state.bundleExists && state.listedInBundles && state.dependencyEntry !== null && state.presetRowFound && state.dependencyMatchesBundle; return state; } // ---------- profile 编辑(纯函数,不碰文件) ---------- function clone(v) { return JSON.parse(JSON.stringify(v)); } function snapshot(pkg, bundleName) { return { bundles: Array.isArray(pkg?.dsh?.profile?.bundles) ? [...pkg.dsh.profile.bundles] : [], dependency: typeof pkg?.dependencies?.[bundleName] === 'string' ? pkg.dependencies[bundleName] : null, }; } // 追加 bundle:bundles 去重追加、依赖设为 link:<绝对路径>。幂等。 export function applyProfileEdits(pkg, bundleDir = BUNDLE_DIR, bundleName = BUNDLE_PACKAGE) { const before = snapshot(pkg, bundleName); const next = clone(pkg ?? {}); if (next.dsh === undefined || next.dsh === null || typeof next.dsh !== 'object') next.dsh = {}; if (next.dsh.profile === undefined || next.dsh.profile === null || typeof next.dsh.profile !== 'object') next.dsh.profile = {}; const bundles = Array.isArray(next.dsh.profile.bundles) ? next.dsh.profile.bundles : []; if (!bundles.includes(bundleName)) bundles.push(bundleName); next.dsh.profile.bundles = bundles; if (next.dependencies === undefined || next.dependencies === null || typeof next.dependencies !== 'object') next.dependencies = {}; next.dependencies[bundleName] = linkSpec(bundleDir); const after = snapshot(next, bundleName); return { next, before, after, changed: before.bundles.join('\n') !== after.bundles.join('\n') || before.dependency !== after.dependency }; } // 移除 bundle:从 bundles 删名、删依赖键。幂等。不改 dsh.profile 的其它键。 export function removeProfileEdits(pkg, bundleName = BUNDLE_PACKAGE) { const before = snapshot(pkg, bundleName); const next = clone(pkg ?? {}); if (Array.isArray(next.dsh?.profile?.bundles)) { next.dsh.profile.bundles = next.dsh.profile.bundles.filter((x) => x !== bundleName); } if (next.dependencies && typeof next.dependencies === 'object') delete next.dependencies[bundleName]; const after = snapshot(next, bundleName); return { next, before, after, changed: before.bundles.join('\n') !== after.bundles.join('\n') || before.dependency !== after.dependency }; } function detectIndent(text) { const m = /^([ \t]+)"/m.exec(String(text)); return m ? m[1] : ' '; } // 原子写:先写临时文件再改名,避免半截文件 function writeFileAtomic(file, text) { const tmp = `${file}.rev-skills.tmp`; try { writeFileSync(tmp, text); renameSync(tmp, file); } catch (e) { try { rmSync(tmp, { force: true }); } catch { /* 临时文件清理失败不掩盖原错误 */ } throw e; } } export function serializeProfile(pkg, originalText) { return `${JSON.stringify(pkg, null, detectIndent(originalText))}\n`; } // ---------- 预设 bundle 的本地解析 ---------- export function readBundleManifest(bundleDir = BUNDLE_DIR) { const file = join(bundleDir, 'package.json'); if (!existsSync(file)) return { file, exists: false, manifest: null, error: `bundle 不存在:${file}` }; try { return { file, exists: true, manifest: JSON.parse(readFileSync(file, 'utf8')), error: null }; } catch (e) { return { file, exists: true, manifest: null, error: `bundle package.json 不是合法 JSON:${e.message}` }; } } // 本地解析预设实际挂载的技能根。补丁里的 `!!js` 表达式按「REV_SKILLS_DIR → DSH 用户根 → // 包解析」顺序取第一个「存在且至少含一个 re- 条目」的候选;这里复现可在本机判定的那几项 // (包解析那两项需要先安装依赖,标记为不可本地判定),并按同一判据选出生效项。 export function resolveLocalSkillRoot(env = process.env, repoRoot = REPO_ROOT) { const candidates = []; const add = (source, root, resolvable = true) => { if (!root) return; const exists = existsSync(root); candidates.push({ source, root, exists, skills: exists ? countSkills(root) : null, resolvable }); }; const rev = env.REV_SKILLS_DIR; add('REV_SKILLS_DIR → /.claude/skills', rev ? join(rev, '.claude', 'skills') : null); add('REV_SKILLS_DIR(直接指向技能根)', rev || null); const dshHome = env.DSH_HOME || join(homedir(), '.dsh'); add('$DSH_HOME/skills(无环境变量时的最终回落)', join(dshHome, 'skills')); add('仓库技能根 /.claude/skills(补丁里由包解析得到)', join(repoRoot, '.claude', 'skills')); candidates.push({ source: '从 rev-skills 的 package.json 反解(本包即 bundle)', root: null, exists: null, skills: null, resolvable: false }); const active = candidates.find((c) => c.resolvable && c.exists && (c.skills ?? 0) > 0) ?? null; return { candidates, active }; } function countSkills(root) { if (!existsSync(root)) return null; try { return readdirSync(root).filter((n) => n.startsWith('re-')).length; } catch { return null; } } // ---------- CLI ---------- const USAGE = `用法: node bin/dsh.mjs check [--dir ] [--json] 按 DSH 技能发现规则校验技能根(默认 ${DEFAULT_SKILLS_ROOT}) node bin/dsh.mjs preset spec 打印 DSH 预设 bundle(${BUNDLE_DIR})的安装输入:bundle 绝对路径、profile package.json 编辑、等价 pnpm 命令 node bin/dsh.mjs preset status [--profile-dir ] [--json] 只读检查 profile 是否已启用该 bundle(默认 $DSH_PROFILE_DIR → $DSH_HOME/profiles/desktop → ~/.dsh/profiles/desktop) node bin/dsh.mjs preset install [--profile-dir ] [--force] [--apply] 默认 dry-run 只打印将要做的编辑;--apply 才写文件(先备份 ${PROFILE_BACKUP}) node bin/dsh.mjs preset uninstall [--profile-dir ] [--apply] 默认 dry-run;--apply 删除 bundles 与依赖项,存在备份时从备份还原 本工具只读写 JSON 文件,不启动任何子进程(不调用 pnpm),也不会改动技能目录。`; function parseArgs(argv) { const opts = { dir: null, profileDir: null, json: false, apply: false, force: false, help: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--dir') opts.dir = argv[++i]; else if (a === '--profile-dir') opts.profileDir = argv[++i]; else if (a === '--json') opts.json = true; else if (a === '--apply') opts.apply = true; else if (a === '--force') opts.force = true; else if (a === '--help' || a === '-h') opts.help = true; else return { error: `未知参数:${a}` }; } return opts; } function runCheck(opts) { const root = opts.dir ? resolve(opts.dir) : DEFAULT_SKILLS_ROOT; const result = checkSkills(root); if (opts.json) { console.log(JSON.stringify(result, null, 2)); return result.ok ? 0 : 1; } console.log(`skills root: ${result.root}`); for (const w of result.warnings) console.log(`WARN: ${w.file} — ${w.message}`); if (!result.ok) { for (const e of result.errors) console.error(`FAIL: ${e.file} — ${e.message}`); console.error(`FAIL: ${result.errors.length} error(s) / ${result.total} skills checked, ${result.warnings.length} warning(s)`); return 1; } console.log(`OK: ${result.total} skills DSH-compatible (${result.warnings.length} warning(s))`); return 0; } function printEdits(before, after) { console.log(`dsh.profile.bundles:`); console.log(` before: ${JSON.stringify(before.bundles)}`); console.log(` after: ${JSON.stringify(after.bundles)}`); console.log(`dependencies["${BUNDLE_PACKAGE}"]:`); console.log(` before: ${JSON.stringify(before.dependency)}`); console.log(` after: ${JSON.stringify(after.dependency)}`); } function runSpec() { const bundle = readBundleManifest(); const patchRel = typeof bundle.manifest?.dsh?.bundle?.patch === 'string' ? bundle.manifest.dsh.bundle.patch : './cordis.patch.yml'; const patchFile = resolve(BUNDLE_DIR, patchRel); const spec = linkSpec(BUNDLE_DIR); const local = resolveLocalSkillRoot(); console.log('## DSH 预设安装输入'); console.log(`bundle 绝对路径(DSH 插件管理器「安装 bundle」字段填这一行):`); console.log(` ${resolve(BUNDLE_DIR)}`); console.log(`bundle package.json: ${bundle.file}(${bundle.exists ? '存在' : '缺失'})`); if (bundle.error) console.log(` note: ${bundle.error}`); console.log(`包名: ${BUNDLE_PACKAGE}`); console.log(`补丁文件: ${patchFile}(${existsSync(patchFile) ? '存在' : '缺失'})`); console.log(`预设行: Loader id preset-${PRESET_ID} / config.id ${PRESET_ID}`); console.log(''); console.log('profile package.json 需要追加的编辑(等价 JSON 片段):'); console.log(JSON.stringify({ dsh: { profile: { bundles: [`...(保留现有项)`, BUNDLE_PACKAGE] } }, dependencies: { [BUNDLE_PACKAGE]: spec }, }, null, 2)); console.log(''); console.log('等价的 pnpm 命令(在 profile 目录执行;bundles 清单仍需上面的编辑或插件管理器 toggle):'); console.log(` pnpm add "${spec}"`); console.log(''); console.log(`REV_SKILLS_DIR: ${process.env.REV_SKILLS_DIR || '未设置(设置后是补丁里的第一优先候选)'}`); console.log('预设技能根候选(补丁 !!js 的取用顺序;生效项 = 第一个存在且含 re- 条目的候选):'); for (const c of local.candidates) { if (!c.resolvable) { console.log(` - ${c.source}(需要先安装依赖,本工具不代跑 pnpm)`); continue; } const mark = local.active && local.active.root === c.root ? ' ← 生效' : ''; const state = c.exists ? `存在,re- 技能 ${c.skills}` : '不存在'; console.log(` - ${c.source} → ${c.root}(${state})${mark}`); } if (local.active) console.log(`生效技能根:${local.active.root}`); else console.log('生效技能根:无可判定候选(补丁会把该预设的技能目录解析为空,DSH 会回落到 $DSH_HOME/skills)'); console.log('本命令只打印,不安装、不写任何文件。'); return 0; } function printStatus(state) { console.log(`[preset status] profile: ${state.profileDir}`); console.log(` profile package.json: ${state.profileExists ? '存在' : '缺失'}${state.profileError ? `(${state.profileError})` : ''}`); console.log(` bundle package.json: ${state.bundleExists ? '存在' : '缺失'}${state.bundleError ? `(${state.bundleError})` : ''}`); console.log(` 补丁文件: ${state.patchFile}(${state.patchExists ? '存在' : '缺失'})`); console.log(` 预设行 id: ${PRESET_ID}: ${state.presetRowFound ? `存在(第 ${state.presetRow.line} 行:${state.presetRow.id})` : '缺失'}`); console.log(` dsh.profile.bundles 含 ${BUNDLE_PACKAGE}: ${state.listedInBundles ? '是' : '否'}(现有 ${state.bundles.length} 项)`); console.log(` dependencies["${BUNDLE_PACKAGE}"]: ${state.dependencyEntry === null ? '缺失' : JSON.stringify(state.dependencyEntry)}`); console.log(` 依赖指向本 bundle: ${state.dependencyMatchesBundle ? '是' : '否'}`); if (state.ok) console.log('OK: 已安装且一致'); else console.log(`未安装或不一致:${[!state.bundleExists && 'bundle 缺失', !state.presetRowFound && '补丁缺预设行', !state.listedInBundles && 'bundles 未列出', state.dependencyEntry === null && '依赖缺失', state.dependencyEntry !== null && !state.dependencyMatchesBundle && '依赖不指向本 bundle', state.profileError && 'profile 不可读'].filter(Boolean).join(' / ')}`); } function runStatus(opts) { const state = readProfileState(resolveProfileDir(opts.profileDir)); if (opts.json) { console.log(JSON.stringify(state, null, 2)); return state.ok ? 0 : 1; } printStatus(state); return state.ok ? 0 : 1; } function loadProfileForWrite(profileDir) { const profileFile = join(profileDir, 'package.json'); if (!existsSync(profileFile)) return { error: `profile package.json 不存在:${profileFile}` }; const text = readFileSync(profileFile, 'utf8'); try { return { profileFile, text, pkg: JSON.parse(text) }; } catch (e) { return { error: `profile package.json 不是合法 JSON:${e.message}` }; } } function runInstall(opts) { const profileDir = resolveProfileDir(opts.profileDir); const loaded = loadProfileForWrite(profileDir); if (loaded.error) { console.error(`ERROR: ${loaded.error}`); return 1; } const bundleFile = join(BUNDLE_DIR, 'package.json'); if (!existsSync(bundleFile)) { console.error(`ERROR: bundle 不存在:${bundleFile}`); return 1; } const edits = applyProfileEdits(loaded.pkg, BUNDLE_DIR); const backupFile = join(profileDir, PROFILE_BACKUP); const manualStep = `在 profile 目录(${profileDir})执行 pnpm install(或改用 GUI 插件管理器安装依赖),然后重启 DSH`; if (!opts.apply) { if (opts.json) { console.log(JSON.stringify({ applied: false, dryRun: true, profileFile: loaded.profileFile, backupFile, before: edits.before, after: edits.after, manualStep }, null, 2)); return 0; } console.log('[preset install] dry-run:未写任何文件'); console.log(`profile: ${loaded.profileFile}`); printEdits(edits.before, edits.after); console.log(`加 --apply 才会写入(写入前备份到 ${PROFILE_BACKUP})`); return 0; } const backupExisted = existsSync(backupFile); if (backupExisted && !opts.force) { console.error(`ERROR: 备份已存在,拒绝覆盖:${backupFile}(确认后可加 --force)`); return 1; } try { if (!backupExisted) copyFileSync(loaded.profileFile, backupFile); writeFileAtomic(loaded.profileFile, serializeProfile(edits.next, loaded.text)); } catch (e) { console.error(`ERROR: 写入失败,package.json 未被修改:${e.message}`); return 1; } if (opts.json) { console.log(JSON.stringify({ applied: true, profileFile: loaded.profileFile, backupFile, backupExisted, before: edits.before, after: edits.after, manualStep }, null, 2)); return 0; } console.log(`[preset install] profile: ${loaded.profileFile}`); printEdits(edits.before, edits.after); console.log(`已写入 ${loaded.profileFile};备份 ${backupFile}`); console.log(`后续手动步骤:${manualStep}。`); return 0; } function runUninstall(opts) { const profileDir = resolveProfileDir(opts.profileDir); const loaded = loadProfileForWrite(profileDir); if (loaded.error) { console.error(`ERROR: ${loaded.error}`); return 1; } const backupFile = join(profileDir, PROFILE_BACKUP); const backupExists = existsSync(backupFile); let backupText = null; if (backupExists) { backupText = readFileSync(backupFile, 'utf8'); try { JSON.parse(backupText); } catch (e) { console.error(`ERROR: 备份不是合法 JSON,未改动任何文件:${backupFile}(${e.message})`); return 1; } } const edits = removeProfileEdits(loaded.pkg); const manualStep = `在 profile 目录(${profileDir})执行 pnpm install(或改用 GUI 插件管理器)并重启 DSH,使移除生效`; const plan = { applied: opts.apply, profileFile: loaded.profileFile, backupFile, restoreFromBackup: backupExists, before: edits.before, after: edits.after, manualStep }; if (!opts.apply) { if (opts.json) { console.log(JSON.stringify({ ...plan, dryRun: true }, null, 2)); return 0; } console.log('[preset uninstall] dry-run:未写任何文件'); console.log(`profile: ${loaded.profileFile}`); printEdits(edits.before, edits.after); console.log(backupExists ? `把 ${PROFILE_BACKUP} 的内容还原回 package.json(备份保留)` : '没有备份,按上面的 after 写回'); console.log('加 --apply 才会写入'); return 0; } try { writeFileAtomic(loaded.profileFile, backupExists ? backupText : serializeProfile(edits.next, loaded.text)); } catch (e) { console.error(`ERROR: 写入失败,package.json 未被修改:${e.message}`); return 1; } if (opts.json) { console.log(JSON.stringify(plan, null, 2)); return 0; } console.log(`[preset uninstall] profile: ${loaded.profileFile}`); printEdits(edits.before, edits.after); console.log(backupExists ? `还原来源:${backupFile}(备份保留)` : '没有备份,按上面的 after 写回'); console.log(`已写入 ${loaded.profileFile}`); console.log(`后续手动步骤:${manualStep}。`); return 0; } function main(argv) { const cmd = argv[0]; if (cmd === undefined) { console.error(USAGE); return 1; } if (cmd === '--help' || cmd === '-h') { console.log(USAGE); return 0; } if (cmd === 'check') { const opts = parseArgs(argv.slice(1)); if (opts.error) { console.error(`ERROR: ${opts.error}`); console.error(USAGE); return 1; } return runCheck(opts); } if (cmd === 'preset') { const sub = argv[1]; if (!['spec', 'status', 'install', 'uninstall'].includes(sub)) { console.error(`ERROR: 未知子命令:${`preset ${sub ?? ''}`.trim()}`); console.error(USAGE); return 1; } const opts = parseArgs(argv.slice(2)); if (opts.error) { console.error(`ERROR: ${opts.error}`); console.error(USAGE); return 1; } if (sub === 'spec') return runSpec(); if (sub === 'status') return runStatus(opts); if (sub === 'install') return runInstall(opts); return runUninstall(opts); } console.error(`ERROR: 未知子命令:${cmd}`); console.error(USAGE); return 1; } if (process.argv[1] && process.argv[1].endsWith('dsh.mjs')) { try { process.exitCode = main(process.argv.slice(2)); } catch (e) { console.error(`ERROR: ${e.message}`); process.exitCode = 1; } }