reverse-skills-max: initial public release (restored, redacted, hardened)

This commit is contained in:
baradok609hecl1pq6qi
2026-10-20 02:17:15 +03:00
commit da24c2bd20
2227 changed files with 443931 additions and 0 deletions

View File

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env node
// bin/auditstate.mjs — 增量审查状态
// node bin/auditstate.mjs status 列出「内容变了待复核 / 从未复核 / 已复核未变」
// node bin/auditstate.mjs update <技能...> 回填复核记录(写入当前 hash + 日期)
// node bin/auditstate.mjs update --all 全部标记为已复核(仅用于机制初始化)
//
// 审查波工作流:status → 只审列出的技能 → update。成本从 O(技能总数) 降到 O(变更数)。
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { computeStatus, markReviewed, todayISO, STATE_REL } from '../lib/review-state.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SKILLS = join(ROOT, '.claude', 'skills');
const [mode, ...rest] = process.argv.slice(2);
if (mode === 'status') {
const { changed, unreviewed, ok, unknown } = computeStatus(ROOT, SKILLS);
console.log(`已复核未变 ${ok.length}|待复核(内容已变)${changed.length}|从未复核 ${unreviewed.length}`);
if (changed.length) {
console.log('\n待复核(内容在复核之后又变了)——这就是下一轮审查波的工作集:');
for (const c of changed) console.log(` ${c.name} 上次复核 ${c.last_reviewed ?? '—'}`);
}
if (unreviewed.length) {
console.log(`\n从未复核(${unreviewed.length}):${unreviewed.map((u) => u.name).join(' ')}`);
}
if (unknown.length) console.log(`\nWARN: 状态里有已不存在的技能:${unknown.join(' ')}`);
} else if (mode === 'update') {
if (!rest.length) { console.error('用法:auditstate.mjs update <技能...> | --all'); process.exit(2); }
const names = rest[0] === '--all'
? [...computeStatus(ROOT, SKILLS).changed, ...computeStatus(ROOT, SKILLS).unreviewed].map((s) => s.name)
: rest;
const n = markReviewed(ROOT, SKILLS, names, { date: todayISO() });
console.log(`已回填 ${n} 项 → ${STATE_REL}`);
} else {
console.error(`未知子命令:${mode ?? '(空)'}(可用 status / update)`);
process.exit(2);
}

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
// bin/capindex.mjs — 生成 / 校验「能力 → 技能」索引
// node bin/capindex.mjs 重新生成 re-analyze/references/capability-index.md
// node bin/capindex.mjs --check 只校验是否过期(npm test 已含此检查)
import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { INDEX_REL, renderCapabilityIndex, checkCapabilityIndex } from '../lib/capability-index.mjs';
const SKILLS = join(dirname(fileURLToPath(import.meta.url)), '..', '.claude', 'skills');
if (process.argv.includes('--check')) {
const errors = checkCapabilityIndex(SKILLS);
if (errors.length) {
for (const e of errors) console.error(`FAIL: ${e}`);
process.exit(1);
}
console.log('OK: capability index up to date');
} else {
writeFileSync(join(SKILLS, INDEX_REL), renderCapabilityIndex(SKILLS));
console.log(`generated: .claude/skills/${INDEX_REL}`);
}

View File

@@ -0,0 +1,63 @@
// bin/convert.mjs — SKILL.md → 规则文件转换器
import { readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseFrontmatterDocument } from '../lib/frontmatter.mjs';
export function skillDirs(skillsRoot) {
return readdirSync(skillsRoot, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('re-'))
.map(e => join(skillsRoot, e.name));
}
export function readSkill(dir) {
const md = readFileSync(join(dir, 'SKILL.md'), 'utf8');
const { fm, body } = parseFrontmatterDocument(md); // 与 validate.mjs 同一解析器(lib/frontmatter.mjs)
return { name: fm.name, description: fm.description, body };
}
// parseSkill:readSkill 的别名,保持调用方兼容
export function parseSkill(dir) { return readSkill(dir); }
const HERE = dirname(fileURLToPath(import.meta.url));
export function convertAll(type, destDir) {
// 初版:cursor/windsurf 每技能一文件;copilot 聚合单文件
const skills = skillDirs(join(HERE, '..', '.claude', 'skills'))
.map(readSkill);
mkdirSync(destDir, { recursive: true });
const files = [];
if (type === 'cursor') {
for (const s of skills) {
const p = join(destDir, `${s.name}.mdc`);
writeFileSync(p, `---\ndescription: ${JSON.stringify(s.description)}\nglobs: **/*\n---\n\n${s.body}`);
files.push(p);
}
} else if (type === 'windsurf') {
for (const s of skills) {
const p = join(destDir, `${s.name}.md`);
writeFileSync(p, `# ${s.name}\n\n${s.description}\n\n${s.body}`);
files.push(p);
}
} else if (type === 'copilot') {
const p = join(destDir, 'copilot-instructions.md');
const head = '# 逆向工程技能库(AI 助手指令)\n\n本文件由技能库自动聚合生成,作为分析时的知识参考。\n\n';
writeFileSync(p, head + skills.map(s =>
`## ${s.name}\n\n${s.description}\n\n${s.body}`).join('\n\n---\n\n'));
files.push(p);
} else {
throw new Error(`unknown rule type: ${type}`);
}
return files;
}
// —— 追加到 convert.mjs 末尾 ——
function cli() {
const args = process.argv.slice(2);
const type = args[args.indexOf('--target') + 1];
const out = args[args.indexOf('--out') + 1];
if (!type || !out) { console.error('usage: node bin/convert.mjs --target cursor|copilot|windsurf --out <dir>'); process.exit(1); }
const files = convertAll(type, out);
console.log(`generated ${files.length} file(s) in ${out}`);
}
if (process.argv[1] && process.argv[1].endsWith('convert.mjs')) cli();

View File

@@ -0,0 +1,687 @@
#!/usr/bin/env node
// bin/dsh.mjs — rev-skills 的 DSH(DeepSeek Harness)适配 CLI
//
// 为什么单独做一层:DSH 的技能发现与 Claude Code 有一处关键差别——**不合法的技能文件会被
// 静默跳过**(只写一条日志 warning)。技能目录里一个坏 frontmatter 不会让会话报错,只会让
// 该技能从目录里消失,因此需要一个独立闸口把这类文件点名。
//
// 子命令(零依赖,只用 node: 内置模块,ESM,Node >=22):
// check [--dir <skillsRoot>] [--json] 按 DSH 发现规则校验技能根
// preset spec 打印 DSH 预设 bundle 的安装输入(只打印,不安装)
// preset status [--profile-dir <dir>] [--json] 只读检查 profile 是否已启用该 bundle
// preset install [--apply] [--profile-dir <dir>] [--force]
// preset uninstall [--apply] [--profile-dir <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 = [
'布局:只扫一层,识别 <root>/<name>/SKILL.md(目录 bundle)与 <root>/<name>.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-<id>)与 config.id(<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 → <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('仓库技能根 <repo>/.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 <skillsRoot>] [--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 <dir>] [--json]
只读检查 profile 是否已启用该 bundle(默认 $DSH_PROFILE_DIR → $DSH_HOME/profiles/desktop → ~/.dsh/profiles/desktop)
node bin/dsh.mjs preset install [--profile-dir <dir>] [--force] [--apply]
默认 dry-run 只打印将要做的编辑;--apply 才写文件(先备份 ${PROFILE_BACKUP})
node bin/dsh.mjs preset uninstall [--profile-dir <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; }
}

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env node
// bin/examplecheck.mjs — 技能示例代码块语法检查(opt-in,不进 npm test)
// node bin/examplecheck.mjs 检查 python / shell 块的语法
// node bin/examplecheck.mjs --json 机器可读输出
//
// 说明:需要 python3 / bash;缺失时对应部分跳过而非失败(见 lib/skill-examples.mjs 顶部注释)。
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { checkExamples, available } from '../lib/skill-examples.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SKILLS = join(ROOT, '.claude', 'skills');
const asJson = process.argv.includes('--json');
const interpreters = available();
const { checked, errors, skipped, total } = checkExamples(SKILLS, { interpreters });
if (asJson) {
console.log(JSON.stringify({ checked, total, skipped, errors }, null, 2));
} else {
console.log(`代码块 ${total} 个|已检查 ${checked} 个(python3=${interpreters.python ? '有' : '无'} bash=${interpreters.shell ? '有' : '无'})`);
for (const s of skipped) console.log(` 跳过:${s}`);
if (errors.length) {
console.log('');
for (const e of errors) console.log(`FAIL: ${e.file}:${e.line} [${e.tag}] ${e.msg}`);
} else {
console.log('OK: 示例语法检查通过');
}
}
if (errors.length) process.exit(1);

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
// bin/install.mjs — rev-skills 多工具安装器
import { cpSync, rmSync, symlinkSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createInterface } from 'node:readline';
import { convertAll } from './convert.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const SKILLS_SRC = join(__dirname, '..', '.claude', 'skills');
const TARGETS = {
claude: { mode: 'native', places: { global: '~/.claude/skills', project: '.claude/skills' } },
gemini: { mode: 'native', places: { global: '~/.gemini/skills', project: '.gemini/skills' } },
cline: { mode: 'native', places: { project: '.claude/skills' } },
codex: { mode: 'native', places: { global: '~/.codex/skills', project: '.codex/skills' } },
dsh: { mode: 'native', places: { global: '~/.dsh/skills', project: '.dsh/skills' } },
cursor: { mode: 'rule', places: { project: '.cursor/rules' }, ruleType: 'cursor' },
copilot: { mode: 'rule', places: { project: '.github' }, ruleType: 'copilot' },
windsurf: { mode: 'rule', places: { project: '.windsurf/rules' }, ruleType: 'windsurf' },
};
// 每个 native 目标的自检提示:技能根路径 + 该运行时里「看到技能」的方式
const VERIFY = {
claude: d => `Claude Code 中运行 /skills 查看,或 ls ${d}`,
gemini: d => `Gemini CLI 中查看技能目录,或 ls ${d}`,
cline: d => `Cline 中查看技能目录,或 ls ${d}`,
codex: d => `Codex 中查看技能目录,或 ls ${d}`,
dsh: d => `技能根 ${d};新建会话后 \`skill\` 工具目录可见(也可 ls ${d} 核对技能数)`,
};
function expandHome(p) { return p.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? '.'); }
function ask(q) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise(res => rl.question(q, a => { rl.close(); res(a.trim()); }));
}
export function planFor(target, scope, opts) {
const t = TARGETS[target];
if (!t) throw new Error(`unknown target: ${target} (valid: ${Object.keys(TARGETS).join(', ')}, all)`);
if (t.mode === 'native') {
if (!t.places[scope]) throw new Error(`target ${target} does not support --${scope}`);
const dest = expandHome(t.places[scope]);
const names = readdirSync(SKILLS_SRC);
return { mode: 'native', dest, names, link: opts.link };
}
if (t.mode === 'rule') {
if (scope !== 'project') throw new Error(`target ${target} only supports --project`);
return { mode: 'rule', dest: expandHome(t.places.project), ruleType: t.ruleType };
}
}
async function installTarget(target, scope, opts) {
const plan = planFor(target, scope, opts);
if (plan.mode === 'native') {
const conflicts = plan.names.filter(n => existsSync(join(plan.dest, n)));
const selfDestructive = resolve(plan.dest) === resolve(SKILLS_SRC);
if (!opts.dryRun) {
mkdirSync(plan.dest, { recursive: true });
for (const n of plan.names) {
const src = join(SKILLS_SRC, n), dst = join(plan.dest, n);
if (existsSync(dst)) {
if (opts.force && selfDestructive) {
console.log(`WARN: dest == source, skipping destructive overwrite for ${n}`);
continue;
}
if (opts.force) rmSync(dst, { recursive: true, force: true });
else { console.log(`SKIP ${n}: exists at ${dst} (use --force to overwrite)`); continue; }
}
if (plan.link && !opts.dryRun) {
try { symlinkSync(src, dst, 'dir'); }
catch { cpSync(src, dst, { recursive: true }); console.log(`LINK failed for ${n}, copied instead`); }
} else cpSync(src, dst, { recursive: true });
}
}
console.log(`[${target}] plan: ${plan.names.length} skills → ${plan.dest}${opts.link ? ' (symlink)' : ''}${conflicts.length ? `, ${conflicts.length} conflict(s): ${conflicts.join(', ')}` : ''}`);
console.log(`[${target}] verify: ${(VERIFY[target] ?? (d => `技能根 ${d}`))(plan.dest)}`);
} else {
const files = opts.dryRun ? [] : convertAll(plan.ruleType, plan.dest);
console.log(`[${target}] plan: convert ${readdirSync(SKILLS_SRC).length} skills → ${plan.dest}`);
if (files.length) console.log(`[${target}] generated: ${files.join(', ')}`);
}
}
async function main() {
const args = process.argv.slice(2);
const opts = {
target: 'claude', scope: null, dryRun: false, link: false, force: false,
yes: false, uninstall: args[0] === 'uninstall',
};
for (let i = opts.uninstall ? 1 : 0; i < args.length; i++) {
const a = args[i];
if (a === '--global') opts.scope = 'global';
else if (a === '--project') opts.scope = 'project';
else if (a === '--target') opts.target = args[++i] ?? 'claude';
else if (a === '--dry-run') opts.dryRun = true;
else if (a === '--link') opts.link = true;
else if (a === '--force') opts.force = true;
else if (a === '--yes') opts.yes = true;
}
const targets = opts.target === 'all' ? Object.keys(TARGETS) : [opts.target];
if (opts.uninstall) {
for (const t of targets) {
const scope = opts.scope ?? 'project';
const plan = planFor(t, scope, opts);
if (plan.mode === 'rule') { console.log(`[${t}] uninstall: remove ${plan.dest} manually (converted rules are plain files)`); continue; }
if (!opts.dryRun) { for (const n of readdirSync(SKILLS_SRC)) rmSync(join(plan.dest, n), { recursive: true, force: true }); }
console.log(`[${t}] removed skills from ${plan.dest}`);
}
return;
}
for (const t of targets) {
if (!TARGETS[t]) throw new Error(`unknown target: ${t} (valid: ${Object.keys(TARGETS).join(', ')}, all)`);
if (opts.scope === 'global' && !('global' in TARGETS[t].places)) {
console.log(`[${t}] skip: target 仅支持 --project,已跳过(--target all --global 组合)`);
continue;
}
let scope = opts.scope;
if (!scope) {
const tDef = TARGETS[t];
const hasGlobal = 'global' in tDef.places, hasProject = 'project' in tDef.places;
scope = hasGlobal && hasProject ? (opts.yes ? 'global' : await ask(`[${t}] 安装到全局还是项目?(global/project) `)) : (hasGlobal ? 'global' : 'project');
}
await installTarget(t, scope, opts);
}
}
if (process.argv[1] && process.argv[1].endsWith('install.mjs')) {
main().catch(e => { console.error(`ERROR: ${e.message}`); process.exit(1); });
}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
// bin/probelist.mjs — 由登记表生成 probe.sh 的工具清单
// node bin/probelist.mjs 重新生成(写入 probe.sh 的 GENERATED-TOOLS 块)
// node bin/probelist.mjs --check 只校验是否与登记表一致(npm test 已含)
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { writeProbeBlock, checkProbeBlock, probeToolNames, PROBE_REL } from '../lib/probe-tools.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
if (process.argv.includes('--check')) {
const errors = checkProbeBlock(ROOT);
if (errors.length) {
for (const e of errors) console.error(`FAIL: ${e}`);
process.exit(1);
}
console.log(`OK: probe 工具清单与登记表一致(${probeToolNames(ROOT).length} 项)`);
} else {
const changed = writeProbeBlock(ROOT);
console.log(`${changed ? 'updated' : 'unchanged'}: ${PROBE_REL}(${probeToolNames(ROOT).length} 项)`);
}

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env node
// bin/toollife.mjs — 第三方命令/API 生命周期检查
// node bin/toollife.mjs check 一致性 + 时效报告(CI 用,死条目即失败)
// node bin/toollife.mjs candidates 列出技能里出现但未登记的命令候选
// node bin/toollife.mjs stale 列出距上次核验超期的登记项
// node bin/toollife.mjs smoke 本机探测(仅报告,CI 机器未必装这些工具)
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { checkRegister, REGISTER_REL, smokeProbe, daysSince, deriveRisk } from '../lib/tool-register.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SKILLS = join(ROOT, '.claude', 'skills');
const mode = process.argv[2] ?? 'check';
if (mode === 'check') {
const { errors, warnings, stale, pending, candidates, register } = checkRegister(ROOT, SKILLS);
for (const e of errors) console.error(`FAIL: ${e}`);
const verified = register.tools.filter((t) => t.last_verified).length;
console.log(`登记 ${register.tools.length} 项|已核验 ${verified}|超期 ${stale.length}|待核验积压 ${pending.length}`);
if (stale.length) {
console.log(`\n超期(≥180 天未复核,${stale.length} 项)——进入下一轮审查波:`);
for (const s of stale.slice(0, 20)) console.log(` ${s.name} 上次核验 ${s.last_verified}(${s.days} 天前)`);
if (stale.length > 20) console.log(` …另有 ${stale.length - 20} 项`);
}
if (pending.length) {
// 风险分层:技能里写死了层级路径/版本号的更容易随上游漂移(volatility 的教训)
const risk = deriveRisk(SKILLS, register.tools.map((t) => t.name));
const high = pending.filter((n) => risk[n]?.level === 'high')
.sort((a, b) => risk[b].spread - risk[a].spread);
console.log(`\n待核验积压 ${pending.length} 项|高风险 ${high.length}|低风险 ${pending.length - high.length}`);
console.log('高风险项(按影响面排序,= 下一轮审查波的建议顺序):');
for (const n of high.slice(0, 15)) console.log(` 面=${String(risk[n].spread).padStart(3)} ${n}`);
if (high.length > 15) console.log(` …另有 ${high.length - 15} 项(node bin/toollife.mjs stale 看全量)`);
}
if (candidates.length) {
console.log(`\n未登记候选(${candidates.length} 项,确认后加入 ${REGISTER_REL} 或加入 ignore):`);
console.log(` ${candidates.join(' ')}`);
}
for (const w of warnings) console.warn(`WARN: ${w}`);
if (errors.length) process.exit(1);
console.log('\nOK: tool register consistent');
} else if (mode === 'candidates') {
const { candidates } = checkRegister(ROOT, SKILLS);
console.log(candidates.join('\n'));
} else if (mode === 'stale') {
const { register } = checkRegister(ROOT, SKILLS);
const rows = register.tools
.map((t) => ({ name: t.name, v: t.last_verified, d: t.last_verified ? daysSince(t.last_verified) : null }))
.filter((r) => r.v === null || r.d >= 180)
.sort((a, b) => (b.d ?? Infinity) - (a.d ?? Infinity));
for (const r of rows) console.log(`${r.name}\t${r.v ?? '未核验'}\t${r.d ?? '-'}`);
console.log(`\n共 ${rows.length} 项待核验/超期`);
} else if (mode === 'smoke') {
const { register } = checkRegister(ROOT, SKILLS);
const names = register.tools.filter((t) => t.kind === 'cli').map((t) => t.name);
const res = smokeProbe(names);
const miss = res.filter((r) => !r.present).map((r) => r.name);
console.log(`本机存在 ${res.length - miss.length}/${res.length}|缺失:${miss.join(' ') || '无'}`);
console.log('(仅报告:CI 机器不必装这些工具,本模式不参与判定)');
} else {
console.error(`未知子命令:${mode}(可用 check / candidates / stale / smoke)`);
process.exit(2);
}

View File

@@ -0,0 +1,224 @@
#!/usr/bin/env node
// 看雪论坛 / 微信公众号技术文章源获取工具(方案1:直接抓取官方论坛源)
//
// 背景:看雪学苑公众号文章均为看雪论坛(bbs.kanxue.com)帖子转载,微信侧无官方
// API 可枚举他人公众号文章,因此直接抓论坛列表即等价于公众号选文源。
//
// 子命令:
// kanxue list [--board 名|--id N] [--pages N] [--md] 论坛文章列表(默认全站)
// kanxue thread <帖子ID> [--md] 单篇帖子标题/作者/正文
// wechat <文章URL> [--md] 微信文章抓取(备用渠道)
//
// 输出默认 JSON(结构化,便于管道/喂给 AI);--md 输出 markdown(便于直接入库)。
// 零依赖(Node >=22 内置 fetch)。导出纯函数供 tests/wxsource.test.mjs 测试。
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
// 版块名(中文/英文别名)→ forum id(首页导航实测确认;未知版块用 --id 指定)
const BOARDS = {
home: null, // 全站首页
ai: 170, 'ai-help': 170, 'ai助力安全': 170, 'ai 助力安全': 170,
'ai-self': 168, 'ai自身安全': 168, 'ai 自身安全': 168,
'ai-platform': 123, 'ai平台与数据安全': 123,
re: 4, 逆向工程: 4, 软件逆向: 4,
ctf: 37, 'ctf对抗': 37,
crypto: 132, 密码应用: 132, 密码算法: 132,
tools: 10, 安全工具: 10,
harmony: 178, 鸿蒙: 178,
android: 161, 'android安全': 161,
iot: 128, 'iot安全': 128,
ios: 166, 'ios安全': 166,
coding: 41, 编程技术: 41,
unpack: 88, 加壳脱壳: 88,
vuln: 150, 二进制漏洞: 150,
pwn: 171,
web: 151, 'web安全': 151,
translate: 32, 外文翻译: 32,
};
export const BASE = 'https://bbs.kanxue.com';
// ---------- 网络 ----------
export async function fetchText(url, { retries = 1 } = {}) {
for (let i = 0; ; i++) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 20000);
try {
const res = await fetch(url, {
headers: { 'user-agent': UA, 'accept-language': 'zh-CN,zh;q=0.9' },
signal: ctrl.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`);
return await res.text();
} catch (e) {
if (i >= retries) throw e;
} finally {
clearTimeout(timer);
}
}
}
// ---------- HTML → 简化 markdown 文本 ----------
export function htmlToText(html) {
let s = html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|section|li|h[1-6]|pre|tr|blockquote)>/gi, '\n')
.replace(/<img[^>]*>/gi, '[图片]')
.replace(/<code[^>]*>/gi, '`')
.replace(/<\/code>/gi, '`')
.replace(/<li[^>]*>/gi, '- ')
.replace(/<[^>]+>/g, '');
s = s
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
return s.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+\n/g, '\n').trim();
}
// ---------- 看雪论坛列表解析 ----------
// 首页聚合列表:<a class="bbs_home_page_list_title" href="thread-292402.htm" title="...">标题</a>
export function parseKanxueHomeList(html) {
const items = [];
const re =
/<a[^>]*class="[^"]*bbs_home_page_list_title[^"]*"[^>]*href="(thread-\d+\.htm)"[^>]*title="([^"]*)"[^>]*>[\s\S]*?<\/a>/g;
for (const m of html.matchAll(re)) {
items.push({ url: `${BASE}/${m[1]}`, title: m[2].trim() || htmlToText(m[0]) });
}
return items;
}
// 分类页(xiuno):<tr class="thread ..." data-tid="292346"> 行内标题 + 作者
export function parseForumList(html) {
const items = [];
const re = /<tr[^>]*class="thread[^"]*"[^>]*data-tid="(\d+)"[\s\S]*?<\/tr>/g;
for (const m of html.matchAll(re)) {
const tid = m[1];
const t = m[0].match(/<a href="thread-\d+\.htm"[^>]*>([^<]{4,120})<\/a>/);
const au = [...m[0].matchAll(/<a href="user-home-\d+\.htm"[^>]*>([^<]{2,40})<\/a>/g)].at(-1);
if (!t) continue;
items.push({
url: `${BASE}/thread-${tid}.htm`,
title: htmlToText(t[1]),
author: au ? htmlToText(au[1]) : undefined,
});
}
return items;
}
// ---------- 看雪帖子正文解析 ----------
export function parseThread(html) {
const title = html.match(/<title>([^<]*)</)?.[1]?.replace(/-[^-]*-看雪安全社区.*$/, '').trim();
const author = html.match(/<a href="user-home-\d+\.htm"[^>]*title="([^"]+)"[^>]*>/)?.[1] ||
html.match(/user-home-\d+\.htm"[^>]*>([^<]{2,40})<\/a>/)?.[1];
// 正文:优先取第一条消息(isfirst=1,markdown 或富文本类型均可),终止于下一条消息
const first =
html.match(/<div class="message[^"]*"[^>]*isfirst="1"[^>]*>([\s\S]*?)<\/div>\s*<div class="message/) ||
html.match(/<div class="message[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<div class="message/) ||
html.match(/<div class="message[^"]*"[^>]*isfirst="1"[^>]*>([\s\S]*)/) ||
html.match(/<div class="message[^"]*"[^>]*>([\s\S]*)/);
return { title: title || undefined, author, body: htmlToText(first ? first[1] : '') };
}
// ---------- 微信文章解析(备用渠道) ----------
export function parseWechat(html) {
const meta = (prop) => html.match(new RegExp(`<meta property="${prop}" content="([^"]*)"`))?.[1];
const title = meta('og:title');
const author = meta('og:article:author') || meta('og:description');
const body = html.match(/<div[^>]*id="js_content"[^>]*>([\s\S]*?)(?:<\/div>\s*<script|$)/)?.[1] || '';
return { title, author, body: htmlToText(body) };
}
// ---------- CLI ----------
function usage() {
console.log(`看雪/微信技术文章源获取工具
用法:
wxsource.mjs kanxue list [--board 名|--id N] [--pages N] [--md]
论坛文章列表(默认全站首页;--md 输出 markdown 列表)
内置版块: ${Object.keys(BOARDS).filter((k) => BOARDS[k]).join(' / ')}
wxsource.mjs kanxue thread <帖子ID> [--md]
单篇帖子标题/作者/正文(帖子ID 如 292402)
wxsource.mjs wechat <文章URL> [--md]
微信文章抓取(备用渠道,公众号内容 = 论坛转载)
输出默认 JSON({url,title,author,body} 结构),--md 输出 markdown。`);
}
function argVal(args, name, fallback) {
const i = args.indexOf(name);
return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : fallback;
}
function has(args, name) {
return args.includes(name);
}
async function main(argv) {
const args = argv.slice(2);
if (args.length === 0 || has(args, '-h') || has(args, '--help')) return usage();
const md = has(args, '--md');
const cmd = args[0];
if (cmd === 'kanxue' && args[1] === 'list') {
const board = argVal(args, '--board', 'home');
const boardId = argVal(args, '--id', null) ?? BOARDS[board];
const pages = parseInt(argVal(args, '--pages', '1'), 10);
if (boardId === undefined && board !== 'home')
throw new Error(`未知版块 "${board}",用 --id 指定 forum 编号,或见 -h 的版块列表`);
const urls = [];
for (let p = 1; p <= pages; p++) {
const url = boardId ? `${BASE}/forum-${boardId}${p > 1 ? '-' + p : ''}.htm` : `${BASE}/`;
const html = await fetchText(url);
const items = boardId ? parseForumList(html) : parseKanxueHomeList(html);
urls.push(...items);
}
if (md) {
console.log(`## 看雪 ${board} 文章列表\n`);
for (const it of urls) console.log(`- [${it.title}](${it.url})` + (it.author ? ` — ${it.author}` : ''));
} else {
console.log(JSON.stringify(urls, null, 2));
}
} else if (cmd === 'kanxue' && args[1] === 'thread') {
const id = args[2];
if (!id) throw new Error('缺少帖子ID,如: kanxue thread 292402');
const html = await fetchText(`${BASE}/thread-${id}.htm`);
const t = parseThread(html);
if (md) {
console.log(`# ${t.title}\n\n作者: ${t.author ?? '未知'}\n\n${t.body}`);
} else {
console.log(JSON.stringify({ url: `${BASE}/thread-${id}.htm`, ...t }, null, 2));
}
} else if (cmd === 'wechat') {
const url = args[1];
if (!url) throw new Error('缺少微信文章 URL');
const html = await fetchText(url);
const a = parseWechat(html);
if (md) {
console.log(`# ${a.title ?? '未知'}\n\n作者: ${a.author ?? '未知'}\n\n${a.body}`);
} else {
console.log(JSON.stringify({ url, ...a }, null, 2));
}
} else {
usage();
process.exitCode = 1;
}
}
if (process.argv[1]?.endsWith('wxsource.mjs')) {
main(process.argv).catch((e) => {
console.error(`错误: ${e.message}`);
process.exitCode = 1;
});
}