reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
0
vendor-new/rev-skills/bin/.gitkeep
Normal file
0
vendor-new/rev-skills/bin/.gitkeep
Normal file
37
vendor-new/rev-skills/bin/auditstate.mjs
Normal file
37
vendor-new/rev-skills/bin/auditstate.mjs
Normal 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);
|
||||
}
|
||||
22
vendor-new/rev-skills/bin/capindex.mjs
Normal file
22
vendor-new/rev-skills/bin/capindex.mjs
Normal 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}`);
|
||||
}
|
||||
63
vendor-new/rev-skills/bin/convert.mjs
Normal file
63
vendor-new/rev-skills/bin/convert.mjs
Normal 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();
|
||||
687
vendor-new/rev-skills/bin/dsh.mjs
Normal file
687
vendor-new/rev-skills/bin/dsh.mjs
Normal 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; }
|
||||
}
|
||||
30
vendor-new/rev-skills/bin/examplecheck.mjs
Normal file
30
vendor-new/rev-skills/bin/examplecheck.mjs
Normal 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);
|
||||
130
vendor-new/rev-skills/bin/install.mjs
Normal file
130
vendor-new/rev-skills/bin/install.mjs
Normal 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); });
|
||||
}
|
||||
21
vendor-new/rev-skills/bin/probelist.mjs
Normal file
21
vendor-new/rev-skills/bin/probelist.mjs
Normal 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} 项)`);
|
||||
}
|
||||
63
vendor-new/rev-skills/bin/toollife.mjs
Normal file
63
vendor-new/rev-skills/bin/toollife.mjs
Normal 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);
|
||||
}
|
||||
224
vendor-new/rev-skills/bin/wxsource.mjs
Normal file
224
vendor-new/rev-skills/bin/wxsource.mjs
Normal 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(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user