2949984428_claude-skill-sync/main.js

1162 lines
44 KiB
JavaScript
Raw Normal View History

2026-04-23 08:16:00 +00:00
'use strict';
const obsidian = require('obsidian');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const os = require('os');
const VIEW_TYPE = 'claude-skill-sync-view';
const PRESET_PLATFORMS = [
{ name: 'Claude Code', target: '~/.claude/skills' },
{ name: 'Codex / Agents', target: '~/.agents/skills' },
{ name: 'Cursor', target: '~/.cursor/skills' },
{ name: 'Gemini CLI', target: '~/.gemini/skills' },
{ name: 'Qwen Code', target: '~/.qwen/skills' },
{ name: 'Windsurf', target: '~/.windsurf/skills' },
{ name: 'Trae', target: '~/.trae/skills' },
{ name: 'Trae CN', target: '~/.trae-cn/skills' },
{ name: 'Junie', target: '~/.junie/skills' },
{ name: 'Factory Droid', target: '~/.factory/skills' },
{ name: 'Augment', target: '~/.augment/skills' },
{ name: 'OpenCode', target: '~/.opencode/skills' },
{ name: 'KiloCode', target: '~/.kilocode/skills' },
{ name: 'Qoder', target: '~/.qoder/skills' },
{ name: 'OB1', target: '~/.ob1/skills' },
{ name: 'Amp', target: '~/.amp/skills' },
{ name: 'Kiro', target: '~/.kiro/skills' },
{ name: 'CodeBuddy', target: '~/.codebuddy/skills' }
];
const DEFAULT_SETTINGS = {
skillRoot: 'Skills',
platforms: [
{ name: 'Claude Code', target: '~/.claude/skills', enabled: true },
{ name: 'Codex / Agents', target: '~/.agents/skills', enabled: true }
],
notifyOnStartup: true,
autoScanIntervalMin: 5,
autoInstall: true,
autoImport: false,
usageRange: 'all'
2026-04-23 08:16:00 +00:00
};
function expandHome(p) {
if (!p) return p;
if (p === '~') return os.homedir();
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
return p;
}
function vaultBasePath(app) {
const a = app.vault.adapter;
return a.basePath || (typeof a.getBasePath === 'function' ? a.getBasePath() : '');
}
async function lstatSafe(p) {
try { return await fsp.lstat(p); } catch { return null; }
}
async function exists(p) {
return (await lstatSafe(p)) !== null;
}
async function isSymlink(p) {
const st = await lstatSafe(p);
return st ? st.isSymbolicLink() : false;
}
async function readlinkSafe(p) {
try { return await fsp.readlink(p); } catch { return null; }
}
class SkillSyncPlugin extends obsidian.Plugin {
async onload() {
await this.loadSettings();
this.addSettingTab(new SkillSyncSettingTab(this.app, this));
this.registerView(VIEW_TYPE, (leaf) => new SkillSyncView(leaf, this));
this.addRibbonIcon('plug', 'Skill Sync', () => this.activateView());
this.addCommand({
id: 'open-skill-sync',
name: '打开 Skill Sync 侧边栏',
callback: () => this.activateView()
});
this.addCommand({
id: 'refresh-skills',
name: '刷新 Skill 列表',
callback: () => this.refreshAllViews()
});
this.addCommand({
id: 'install-all',
name: '把所有 Skill 安装到所有 Coding 工具',
callback: () => this.installAll()
});
this.addCommand({
id: 'import-existing',
name: '扫描并导入 Coding 工具里已有的 Skill',
callback: () => new ImportModal(this.app, this).open()
});
this.addCommand({
id: 'cleanup-dangling',
name: '清理失效链接',
callback: () => this.cleanupDangling()
});
this.addCommand({
id: 'repair-mislinked',
name: '修复指向错误的链接',
callback: () => this.repairAllMislinked()
});
this.app.workspace.onLayoutReady(() => {
setTimeout(() => this.checkAndNotifyOnStartup(), 1500);
this.startAutoScan();
});
}
onunload() {
this.stopAutoScan();
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
}
startAutoScan() {
this.stopAutoScan();
const min = Number(this.settings.autoScanIntervalMin) || 0;
if (min <= 0) return;
this._scanTimer = setInterval(() => this.autoScanTick(), min * 60 * 1000);
}
stopAutoScan() {
if (this._scanTimer) {
clearInterval(this._scanTimer);
this._scanTimer = null;
}
}
async autoScanTick() {
let installedN = 0, importedN = 0;
if (this.settings.autoInstall) {
const skills = await this.listSkills();
for (const s of skills) {
for (const p of this.settings.platforms) {
if (!p.enabled) continue;
const st = await this.getStatus(s, p);
if (st.state === 'missing') {
if (await this.install(s, p)) installedN++;
}
}
}
}
if (this.settings.autoImport) {
const items = await this.listImportable();
for (const it of items) {
if (await this.importSkill(it)) importedN++;
}
}
if (installedN > 0 || importedN > 0) {
const parts = [];
if (installedN > 0) parts.push(`安装 ${installedN}`);
if (importedN > 0) parts.push(`导入 ${importedN}`);
new obsidian.Notice(`Skill Sync 自动同步:${parts.join(' / ')}`, 6000);
this.refreshAllViews();
} else if (this.settings.notifyOnStartup) {
const s = await this.getSyncSummary();
if (s.hasIssue) {
const parts = [];
if (s.notInstalled > 0) parts.push(`${s.notInstalled} 未安装`);
if (s.partiallySynced > 0) parts.push(`${s.partiallySynced} 部分同步`);
if (s.importableCount > 0) parts.push(`${s.importableCount} 待导入`);
if (parts.length > 0) {
new obsidian.Notice(`Skill Sync 状态:${parts.join(' / ')}`, 5000);
}
}
}
}
async loadSettings() {
const data = (await this.loadData()) || {};
this.settings = {
skillRoot: data.skillRoot || DEFAULT_SETTINGS.skillRoot,
platforms: Array.isArray(data.platforms) && data.platforms.length
? data.platforms
: DEFAULT_SETTINGS.platforms.map(p => ({ ...p })),
notifyOnStartup: data.notifyOnStartup !== false,
autoScanIntervalMin: typeof data.autoScanIntervalMin === 'number' ? data.autoScanIntervalMin : DEFAULT_SETTINGS.autoScanIntervalMin,
autoInstall: data.autoInstall !== false,
autoImport: data.autoImport === true
};
}
async saveSettings() {
await this.saveData(this.settings);
this.startAutoScan();
this.refreshAllViews();
}
async activateView() {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(VIEW_TYPE)[0];
if (!leaf) {
leaf = workspace.getRightLeaf(false);
await leaf.setViewState({ type: VIEW_TYPE, active: true });
}
workspace.revealLeaf(leaf);
}
refreshAllViews() {
this.app.workspace.getLeavesOfType(VIEW_TYPE).forEach(l => {
if (l.view && typeof l.view.render === 'function') l.view.render();
});
}
skillRootAbs() {
return path.join(vaultBasePath(this.app), this.settings.skillRoot);
}
async listSkills() {
const root = this.skillRootAbs();
if (!(await exists(root))) return [];
let entries;
try {
entries = await fsp.readdir(root, { withFileTypes: true });
} catch {
return [];
}
const skills = [];
for (const e of entries) {
if (!e.isDirectory()) continue;
if (e.name.startsWith('.')) continue;
const dir = path.join(root, e.name);
let description = '';
const dirSt = await lstatSafe(dir);
let mtime = dirSt ? dirSt.mtimeMs : 0;
for (const fileName of ['SKILL.md', 'skill.md']) {
const skillMd = path.join(dir, fileName);
const st = await lstatSafe(skillMd);
if (!st) continue;
if (st.mtimeMs > mtime) mtime = st.mtimeMs;
2026-04-23 08:16:00 +00:00
try {
const txt = await fsp.readFile(skillMd, 'utf8');
const m = txt.match(/^---\n([\s\S]*?)\n---/);
if (m) {
const d = m[1].match(/^description:\s*(.+)$/m);
if (d) description = d[1].trim();
}
} catch {}
break;
2026-04-23 08:16:00 +00:00
}
skills.push({ name: e.name, abs: dir, description, mtime });
2026-04-23 08:16:00 +00:00
}
skills.sort((a, b) => b.mtime - a.mtime || a.name.localeCompare(b.name));
2026-04-23 08:16:00 +00:00
return skills;
}
// 统计每个 skill 在 Claude Code 里的历史调用次数。
// 数据源:~/.claude/projects/**/*.jsonl 会话日志,每次调用落一行
// "name":"Skill","input":{"skill":"xxx"}。range: 'all' | '7' | '30'(天)。
async getSkillUsage(range) {
range = range || 'all';
if (this._usageCache && this._usageCache.range === range &&
(Date.now() - this._usageCache.at) < 60000) {
return this._usageCache.map;
}
const map = new Map();
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
if (!(await exists(projectsDir))) {
this._usageCache = { range, at: Date.now(), map };
return map;
}
let cutoff = 0;
if (range !== 'all') {
const days = parseInt(range, 10);
if (days > 0) cutoff = Date.now() - days * 86400000;
}
const files = [];
const walk = async (dir) => {
let entries;
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) await walk(full);
else if (e.isFile() && e.name.endsWith('.jsonl')) files.push(full);
}
};
await walk(projectsDir);
const reSkill = /"name":"Skill","input":\{"skill":"([^"]+)"/;
const reTime = /"timestamp":"([^"]+)"/;
for (const f of files) {
let txt;
try { txt = await fsp.readFile(f, 'utf8'); } catch { continue; }
const lines = txt.split('\n');
for (const line of lines) {
if (line.indexOf('"name":"Skill"') === -1) continue;
const m = reSkill.exec(line);
if (!m) continue;
if (cutoff) {
const tm = reTime.exec(line);
if (tm) {
const t = Date.parse(tm[1]);
if (t && t < cutoff) continue;
}
}
// 去掉插件命名空间前缀superpowers:brainstorming -> brainstorming
// 以便和 vault 里的裸目录名对上。
let name = m[1];
const colon = name.lastIndexOf(':');
if (colon !== -1) name = name.slice(colon + 1);
map.set(name, (map.get(name) || 0) + 1);
}
}
this._usageCache = { range, at: Date.now(), map };
return map;
}
2026-04-23 08:16:00 +00:00
async getStatus(skill, platform) {
const target = path.join(expandHome(platform.target), skill.name);
if (!(await exists(target))) return { state: 'missing', target };
const link = await readlinkSafe(target);
if (link == null) return { state: 'conflict', target };
const linkAbs = path.isAbsolute(link) ? link : path.resolve(path.dirname(target), link);
if (path.resolve(linkAbs) === path.resolve(skill.abs)) return { state: 'linked', target };
return { state: 'mislinked', target, current: linkAbs };
}
async install(skill, platform) {
const targetDir = expandHome(platform.target);
try {
await fsp.mkdir(targetDir, { recursive: true });
} catch (e) {
new obsidian.Notice(`无法创建 Coding 工具的 Skill 目录 ${targetDir}: ${e.message}`);
return false;
}
const target = path.join(targetDir, skill.name);
if (await exists(target)) {
const link = await readlinkSafe(target);
if (link == null) {
new obsidian.Notice(`冲突:${target} 已存在且不是链接,未操作`);
return false;
}
await fsp.unlink(target);
}
try {
await fsp.symlink(skill.abs, target, 'dir');
} catch (e) {
new obsidian.Notice(`创建链接失败:${e.message}`);
return false;
}
new obsidian.Notice(`✓ 已安装 ${skill.name}${platform.name}`);
return true;
}
async uninstall(skill, platform) {
const target = path.join(expandHome(platform.target), skill.name);
if (!(await exists(target))) return false;
if (!(await isSymlink(target))) {
new obsidian.Notice(`安全保护:这是 Coding 工具自己的 Skill不是从 Obsidian 链接过去的),插件不敢删`);
return false;
}
try {
await fsp.unlink(target);
} catch (e) {
new obsidian.Notice(`移除失败:${e.message}`);
return false;
}
new obsidian.Notice(`✓ 已移除 ${skill.name}${platform.name}`);
return true;
}
async listImportable() {
const seen = new Map();
const vaultSkills = new Set((await this.listSkills()).map(s => s.name));
for (const p of this.settings.platforms) {
if (!p.enabled) continue;
const dir = expandHome(p.target);
if (!(await exists(dir))) continue;
let entries;
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
catch { continue; }
for (const e of entries) {
if (!e.isDirectory()) continue;
if (e.name.startsWith('.')) continue;
const full = path.join(dir, e.name);
const st = await lstatSafe(full);
if (!st || st.isSymbolicLink()) continue;
if (vaultSkills.has(e.name)) continue;
if (seen.has(e.name)) continue;
seen.set(e.name, { name: e.name, abs: full, sourcePlatform: p.name });
}
}
return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
}
async importSkill(item) {
const root = this.skillRootAbs();
await fsp.mkdir(root, { recursive: true });
const dest = path.join(root, item.name);
if (await exists(dest)) {
new obsidian.Notice(`Obsidian 里已存在 ${item.name},跳过`);
return false;
}
try {
await fsp.rename(item.abs, dest);
} catch (e) {
new obsidian.Notice(`移动失败 ${item.name}: ${e.message}`);
return false;
}
const skill = { name: item.name, abs: dest, description: '' };
for (const p of this.settings.platforms) {
if (!p.enabled) continue;
const target = path.join(expandHome(p.target), item.name);
if (await exists(target)) {
const link = await readlinkSafe(target);
if (link != null) {
try { await fsp.unlink(target); } catch {}
} else {
continue;
}
}
try { await fsp.symlink(skill.abs, target, 'dir'); } catch {}
}
return true;
}
async installAll() {
const skills = await this.listSkills();
if (skills.length === 0) {
new obsidian.Notice('未发现任何 skill');
return;
}
let added = 0, skipped = 0, conflicts = 0;
for (const s of skills) {
for (const p of this.settings.platforms) {
if (!p.enabled) continue;
const st = await this.getStatus(s, p);
if (st.state === 'linked') { skipped++; continue; }
if (st.state === 'conflict') { conflicts++; continue; }
if (await this.install(s, p)) added++;
}
}
new obsidian.Notice(`批量完成:新增 ${added},已存在 ${skipped},冲突 ${conflicts}`);
this.refreshAllViews();
}
async listDangling() {
const dangling = [];
for (const p of this.settings.platforms) {
if (!p.enabled) continue;
const dir = expandHome(p.target);
if (!(await exists(dir))) continue;
let entries;
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
catch { continue; }
for (const e of entries) {
const full = path.join(dir, e.name);
const st = await lstatSafe(full);
if (!st || !st.isSymbolicLink()) continue;
try {
await fsp.stat(full); // 跟随 symlink失败说明 dangling
} catch {
const link = await readlinkSafe(full);
dangling.push({ platform: p.name, name: e.name, target: full, points: link });
}
}
}
return dangling;
}
async cleanupDangling() {
const list = await this.listDangling();
let count = 0;
for (const d of list) {
try { await fsp.unlink(d.target); count++; } catch {}
}
new obsidian.Notice(`已清理 ${count} 个失效链接`);
this.refreshAllViews();
return count;
}
async repairAllMislinked() {
const skills = await this.listSkills();
let count = 0;
for (const s of skills) {
for (const p of this.settings.platforms) {
if (!p.enabled) continue;
const st = await this.getStatus(s, p);
if (st.state === 'mislinked') {
if (await this.install(s, p)) count++;
}
}
}
new obsidian.Notice(`已修复 ${count} 个指向错误的链接`);
this.refreshAllViews();
return count;
}
async getSyncSummary() {
const skills = await this.listSkills();
const importable = await this.listImportable();
const dangling = await this.listDangling();
const enabledPlatforms = this.settings.platforms.filter(p => p.enabled);
let fullySynced = 0, partiallySynced = 0, notInstalled = 0;
let mislinkedSkills = 0, conflictSkills = 0;
for (const s of skills) {
let linkedN = 0, mislinkedN = 0, conflictN = 0;
for (const p of enabledPlatforms) {
const st = await this.getStatus(s, p);
if (st.state === 'linked') linkedN++;
else if (st.state === 'mislinked') mislinkedN++;
else if (st.state === 'conflict') conflictN++;
}
if (mislinkedN > 0) mislinkedSkills++;
if (conflictN > 0) conflictSkills++;
if (enabledPlatforms.length > 0 && linkedN === enabledPlatforms.length) fullySynced++;
else if (linkedN > 0) partiallySynced++;
else notInstalled++;
}
return {
vaultSkillCount: skills.length,
platformCount: enabledPlatforms.length,
fullySynced, partiallySynced, notInstalled,
mislinkedSkills, conflictSkills,
importableCount: importable.length,
danglingCount: dangling.length,
hasIssue: notInstalled > 0 || partiallySynced > 0 || mislinkedSkills > 0 || conflictSkills > 0 || importable.length > 0 || dangling.length > 0
};
}
async checkAndNotifyOnStartup() {
if (!this.settings.notifyOnStartup) return;
const s = await this.getSyncSummary();
if (!s.hasIssue) return;
const parts = [];
if (s.notInstalled > 0) parts.push(`${s.notInstalled} 未安装`);
if (s.partiallySynced > 0) parts.push(`${s.partiallySynced} 部分同步`);
if (s.importableCount > 0) parts.push(`${s.importableCount} 待导入`);
if (s.mislinkedSkills > 0) parts.push(`${s.mislinkedSkills} 指向错误`);
if (s.danglingCount > 0) parts.push(`${s.danglingCount} 失效`);
if (s.conflictSkills > 0) parts.push(`${s.conflictSkills} 冲突`);
const notice = new obsidian.Notice(`Skill Sync: ${parts.join(' / ')}(点击查看)`, 8000);
notice.noticeEl.addClass('css-skill-sync-notice');
notice.noticeEl.onclick = () => {
notice.hide();
this.activateView();
};
2026-04-23 08:16:00 +00:00
}
}
class SkillSyncView extends obsidian.ItemView {
constructor(leaf, plugin) {
super(leaf);
this.plugin = plugin;
this.filterText = '';
}
getViewType() { return VIEW_TYPE; }
getDisplayText() { return 'Skill Sync'; }
getIcon() { return 'plug'; }
async onOpen() { await this.render(); }
async onClose() {}
async renderStatusCard(c) {
const card = c.createDiv({ cls: 'css-status-card' });
const loading = card.createDiv({ cls: 'css-status-loading', text: '检查同步状态...' });
const s = await this.plugin.getSyncSummary();
loading.remove();
const head = card.createDiv({ cls: 'css-status-head' });
head.createEl('span', { cls: 'css-status-title', text: '同步状态' });
head.createEl('span', {
cls: 'css-status-overall ' + (s.hasIssue ? 'has-issue' : 'all-good'),
text: s.hasIssue ? '需要操作' : '全部同步'
});
const grid = card.createDiv({ cls: 'css-status-grid' });
const items = [
{
label: `Obsidian Skill (×${s.platformCount} 工具)`, value: s.vaultSkillCount, kind: 'info',
tip: `Obsidian 里收藏的 Skill 总数。这些 Skill 会同步到 ${s.platformCount} 个 Coding 工具。`
},
{
label: '已同步', value: s.fullySynced, kind: s.fullySynced > 0 ? 'good' : 'mute',
tip: '所有启用的 Coding 工具都能用的 Skill 数。'
},
{
label: '部分同步', value: s.partiallySynced, kind: s.partiallySynced > 0 ? 'warn' : 'mute', action: 'install',
tip: '只在某些 Coding 工具能用、其他工具还没装的 Skill 数。点击一键补齐。'
},
{
label: '未安装', value: s.notInstalled, kind: s.notInstalled > 0 ? 'warn' : 'mute', action: 'install',
tip: '还没装到任何 Coding 工具的 Skill 数。常见于刚加了新 Skill 或换了新电脑。点击一键安装。'
},
{
label: '待导入', value: s.importableCount, kind: s.importableCount > 0 ? 'warn' : 'mute', action: 'import',
tip: 'Coding 工具那边自带的、还没收进 Obsidian 管理的 Skill 数。点击挑选哪些要纳入。'
},
{
label: '指向错误', value: s.mislinkedSkills, kind: s.mislinkedSkills > 0 ? 'warn' : 'mute', action: 'repair',
tip: '链接到了错误位置的 Skill 数(比如换电脑后 Obsidian 路径变了)。点击一键修正。'
},
{
label: '失效 link', value: s.danglingCount, kind: s.danglingCount > 0 ? 'warn' : 'mute', action: 'cleanup',
tip: '你已经删了源 Skill但 Coding 工具那边还残留着空链接。点击清理。'
},
{
label: '冲突', value: s.conflictSkills, kind: s.conflictSkills > 0 ? 'bad' : 'mute',
tip: 'Coding 工具那边已经有同名 Skill不是从 Obsidian 来的),插件不敢覆盖,需要你手动处理。'
}
];
items.forEach(it => {
const cell = grid.createDiv({ cls: `css-status-cell css-${it.kind}` });
cell.setAttr('aria-label', it.tip);
cell.title = it.tip;
cell.createDiv({ cls: 'css-status-num', text: String(it.value) });
cell.createDiv({ cls: 'css-status-label', text: it.label });
if (it.action && it.value > 0) {
cell.addClass('css-clickable');
cell.onclick = async () => {
if (it.action === 'install') { await this.plugin.installAll(); }
else if (it.action === 'import') { new ImportModal(this.app, this.plugin).open(); return; }
else if (it.action === 'repair') { await this.plugin.repairAllMislinked(); }
else if (it.action === 'cleanup') { await this.plugin.cleanupDangling(); }
await this.render();
};
}
});
}
async render() {
const c = this.containerEl.children[1];
// 整个视图先建进游离容器 wrap末尾用代际守卫一次性 swap 进 c。
// 这样多次 render 重叠时各自写各自的 wrap只有最新一次能落地
// 不会再往同一容器重复 append之前会叠出两个搜索框
const myGen = (this._renderGen = (this._renderGen || 0) + 1);
const wrap = createDiv({ cls: 'css-skill-sync' });
const commit = () => {
if (myGen !== this._renderGen) return false;
c.empty();
c.addClass('css-skill-sync');
c.appendChild(wrap);
return true;
};
2026-04-23 08:16:00 +00:00
const header = wrap.createDiv({ cls: 'css-header' });
2026-04-23 08:16:00 +00:00
header.createEl('h3', { text: 'Claude Skill Sync' });
const btnRow = wrap.createDiv({ cls: 'css-btn-row' });
2026-04-23 08:16:00 +00:00
const refreshBtn = btnRow.createEl('button', { text: '刷新' });
refreshBtn.onclick = () => { this.plugin._usageCache = null; this.render(); };
2026-04-23 08:16:00 +00:00
const installAllBtn = btnRow.createEl('button', { text: '全部安装', cls: 'mod-cta' });
installAllBtn.onclick = async () => { await this.plugin.installAll(); await this.render(); };
const importBtn = btnRow.createEl('button', { text: '导入现有' });
importBtn.onclick = () => new ImportModal(this.app, this.plugin).open();
const root = this.plugin.skillRootAbs();
wrap.createDiv({ cls: 'css-meta', text: `Skill 文件夹:${root}` });
2026-04-23 08:16:00 +00:00
await this.renderStatusCard(wrap);
2026-04-23 08:16:00 +00:00
const skills = await this.plugin.listSkills();
if (skills.length === 0) {
const empty = wrap.createDiv({ cls: 'css-empty' });
2026-04-23 08:16:00 +00:00
empty.createEl('div', { text: `还没有任何 Skill。` });
empty.createEl('div', { text: `在 Obsidian 里创建:${this.plugin.settings.skillRoot}/<skill-name>/SKILL.md` });
commit();
2026-04-23 08:16:00 +00:00
return;
}
const searchWrap = wrap.createDiv({ cls: 'css-search-wrap' });
2026-04-23 08:16:00 +00:00
const searchInput = searchWrap.createEl('input', {
type: 'search',
cls: 'css-search-input',
attr: { placeholder: '搜索 Skill 名或描述...' }
});
searchInput.value = this.filterText || '';
const countEl = searchWrap.createEl('span', { cls: 'css-search-count' });
// 调用次数统计条:时间段控(全部/7天/30天 + 按次数排序
const usageRange = this.plugin.settings.usageRange || 'all';
const usage = await this.plugin.getSkillUsage(usageRange);
const rangeText = (r) => r === 'all' ? '全部历史' : `${r}`;
let maxCount = 0;
usage.forEach(v => { if (v > maxCount) maxCount = v; });
const usageBar = wrap.createDiv({ cls: 'css-usage-bar' });
const seg = usageBar.createDiv({ cls: 'css-seg' });
[['all', '全部'], ['7', '7天'], ['30', '30天']].forEach(([v, t]) => {
const b = seg.createEl('button', {
cls: 'css-seg-btn' + (v === usageRange ? ' is-active' : ''),
text: t
});
b.onclick = async () => {
if (this.plugin.settings.usageRange === v) return;
this.plugin.settings.usageRange = v;
await this.plugin.saveSettings();
await this.render();
};
});
const sortBtn = usageBar.createEl('button', {
cls: 'css-sort-btn' + (this.sortByUsage ? ' is-active' : ''),
text: '↕ 按次数'
});
sortBtn.setAttribute('title', '按调用次数从高到低排序(再点恢复按修改时间)');
sortBtn.onclick = () => { this.sortByUsage = !this.sortByUsage; this.render(); };
const orderedSkills = this.sortByUsage
? skills.slice().sort((a, b) =>
(usage.get(b.name) || 0) - (usage.get(a.name) || 0) || a.name.localeCompare(b.name))
: skills;
2026-04-23 08:16:00 +00:00
const listEl = wrap.createDiv({ cls: 'css-skill-list' });
for (const s of orderedSkills) {
2026-04-23 08:16:00 +00:00
const card = listEl.createDiv({ cls: 'css-skill' });
card.dataset.name = s.name.toLowerCase();
card.dataset.desc = (s.description || '').toLowerCase();
const nameRow = card.createDiv({ cls: 'css-name-row' });
const toggle = nameRow.createSpan({ cls: 'css-toggle', text: s.description ? '▸' : ' ' });
const nameEl = nameRow.createSpan({ cls: 'css-name css-name-link', text: s.name });
nameEl.setAttribute('aria-label', '打开 SKILL.md');
nameEl.onclick = (e) => {
e.stopPropagation();
this.openSkillMd(s);
};
if (s.mtime) {
const timeEl = nameRow.createSpan({ cls: 'css-mtime', text: obsidian.moment(s.mtime).fromNow() });
timeEl.setAttribute('title', obsidian.moment(s.mtime).format('YYYY-MM-DD HH:mm'));
}
const useCount = usage.get(s.name) || 0;
const tier = useCount === 0 ? 'zero'
: (maxCount && useCount >= maxCount * 0.66) ? 'hot'
: (maxCount && useCount >= maxCount * 0.33) ? 'warm' : 'cool';
const useEl = nameRow.createSpan({
cls: `css-uc css-uc-${tier}`,
text: useCount === 0 ? '○ 0' : `${useCount}`
});
useEl.setAttribute('title', `被调用 ${useCount}Claude Code · ${rangeText(usageRange)}`);
2026-04-23 08:16:00 +00:00
let descEl = null;
if (s.description) {
descEl = card.createEl('div', { cls: 'css-desc', text: s.description });
descEl.style.display = 'none';
const toggleDesc = (e) => {
if (e) e.stopPropagation();
const open = descEl.style.display === 'none';
descEl.style.display = open ? '' : 'none';
toggle.setText(open ? '▾' : '▸');
};
toggle.style.cursor = 'pointer';
toggle.onclick = toggleDesc;
nameRow.style.cursor = 'pointer';
nameRow.onclick = toggleDesc;
}
for (const p of this.plugin.settings.platforms) {
if (!p.enabled) continue;
const row = card.createDiv({ cls: 'css-row' });
row.createSpan({ cls: 'css-plat', text: p.name });
const status = await this.plugin.getStatus(s, p);
const labels = { linked: '已安装', missing: '未安装', conflict: '冲突', mislinked: '指向其他' };
row.createSpan({ cls: `css-badge css-${status.state}`, text: labels[status.state] || status.state });
if (status.state === 'linked') {
const btn = row.createEl('button', { text: '移除' });
btn.onclick = async () => { await this.plugin.uninstall(s, p); await this.render(); };
} else if (status.state === 'missing') {
const btn = row.createEl('button', { text: '安装', cls: 'mod-cta' });
btn.onclick = async () => { await this.plugin.install(s, p); await this.render(); };
} else if (status.state === 'mislinked') {
row.createSpan({ cls: 'css-warn', text: `${status.current}` });
const btn = row.createEl('button', { text: '改指向' });
btn.onclick = async () => { await this.plugin.install(s, p); await this.render(); };
} else if (status.state === 'conflict') {
row.createSpan({ cls: 'css-warn', text: 'Coding 工具里已有同名 Skill不是链接请手动处理' });
}
}
}
// 内容全部建好,最新一次 render 才落地(旧的重叠 render 在此作废)
if (!commit()) return;
2026-04-23 08:16:00 +00:00
const filter = (q) => {
this.filterText = q;
const ql = (q || '').toLowerCase().trim();
let visible = 0;
listEl.querySelectorAll('.css-skill').forEach(card => {
const match = !ql || card.dataset.name.includes(ql) || card.dataset.desc.includes(ql);
card.style.display = match ? '' : 'none';
if (match) visible++;
});
countEl.setText(ql ? `${visible} / ${skills.length}` : `${skills.length}`);
};
searchInput.oninput = (e) => filter(e.target.value);
filter(this.filterText);
}
async openSkillMd(skill) {
const root = this.plugin.settings.skillRoot;
for (const fileName of ['SKILL.md', 'skill.md']) {
const rel = obsidian.normalizePath(`${root}/${skill.name}/${fileName}`);
const file = this.app.vault.getAbstractFileByPath(rel);
if (file instanceof obsidian.TFile) {
await this.app.workspace.getLeaf(false).openFile(file);
return;
}
}
new obsidian.Notice(`${skill.name} 没有 SKILL.md`);
}
2026-04-23 08:16:00 +00:00
}
class SkillSyncSettingTab extends obsidian.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Claude Skill Sync' });
new obsidian.Setting(containerEl)
.setName('启动时提醒同步状态')
.setDesc('打开 Obsidian 后扫描一次,如发现待安装/待导入/失效等问题弹通知')
.addToggle(tg => tg
.setValue(this.plugin.settings.notifyOnStartup)
.onChange(async v => {
this.plugin.settings.notifyOnStartup = v;
await this.plugin.saveSettings();
}));
containerEl.createEl('h3', { text: '自动同步' });
new obsidian.Setting(containerEl)
.setName('定时扫描间隔(分钟)')
.setDesc('后台每隔多少分钟自动检查一次。0 = 关闭定时扫描。建议 5-30。')
.addText(t => t
.setPlaceholder('5')
.setValue(String(this.plugin.settings.autoScanIntervalMin))
.onChange(async v => {
const n = parseInt(v, 10);
this.plugin.settings.autoScanIntervalMin = isNaN(n) ? 0 : Math.max(0, n);
await this.plugin.saveSettings();
}));
new obsidian.Setting(containerEl)
.setName('自动安装新 Skill 到 Coding 工具')
.setDesc('扫描时如发现 Obsidian 有新 Skill 还没装到 Coding 工具,自动建链接。安全(链接可逆)。建议开启。')
.addToggle(tg => tg
.setValue(this.plugin.settings.autoInstall)
.onChange(async v => {
this.plugin.settings.autoInstall = v;
await this.plugin.saveSettings();
}));
const importSetting = new obsidian.Setting(containerEl)
.setName('自动收 Coding 工具里的新 Skill 到 Obsidian')
.setDesc('⚠️ 危险:扫描时如发现 Coding 工具里有新的实体 Skill 文件夹,自动移到 Obsidian。移动不可逆。仅当你确定 Coding 工具的 Skill 目录不会被其他工具临时使用时再开。')
.addToggle(tg => tg
.setValue(this.plugin.settings.autoImport)
.onChange(async v => {
this.plugin.settings.autoImport = v;
await this.plugin.saveSettings();
}));
importSetting.descEl.style.color = 'var(--text-warning, #eab308)';
new obsidian.Setting(containerEl)
.setName('Obsidian 中的 Skill 文件夹')
.setDesc('相对 Obsidian 根的路径。下面每个子文件夹视为一个 Skill。')
.addText(t => t
.setPlaceholder('Skills')
.setValue(this.plugin.settings.skillRoot)
.onChange(async v => {
this.plugin.settings.skillRoot = (v || '').trim() || 'Skills';
await this.plugin.saveSettings();
}));
containerEl.createEl('h3', { text: 'Coding 工具' });
containerEl.createDiv({ cls: 'setting-item-description', text: '安装 = 让 Coding 工具能读到 Obsidian 里的 Skill。路径里 ~ 会自动展开成你的 home 目录。' });
this.plugin.settings.platforms.forEach((p, idx) => {
const wrap = containerEl.createDiv({ cls: 'css-platform-edit' });
new obsidian.Setting(wrap)
.setName(p.name || '(未命名)')
.addText(t => t
.setPlaceholder('名称')
.setValue(p.name)
.onChange(async v => {
this.plugin.settings.platforms[idx].name = v;
await this.plugin.saveSettings();
}))
.addText(t => t
.setPlaceholder('~/.claude/skills')
.setValue(p.target)
.onChange(async v => {
this.plugin.settings.platforms[idx].target = v.trim();
await this.plugin.saveSettings();
}))
.addToggle(tg => tg
.setValue(p.enabled)
.setTooltip('启用')
.onChange(async v => {
this.plugin.settings.platforms[idx].enabled = v;
await this.plugin.saveSettings();
}))
.addExtraButton(b => b
.setIcon('trash')
.setTooltip('删除该平台')
.onClick(async () => {
this.plugin.settings.platforms.splice(idx, 1);
await this.plugin.saveSettings();
this.display();
}));
});
new obsidian.Setting(containerEl)
.addButton(b => b
.setButtonText('+ 添加 Coding 工具')
.setCta()
.onClick(() => {
new AddPlatformModal(this.app, this.plugin, () => this.display()).open();
}));
containerEl.createEl('h3', { text: '说明' });
const ul = containerEl.createEl('ul');
[
'只在桌面端工作(移动端不支持链接)',
'安装 = 在 Coding 工具的 Skill 目录里建一个链接,指回 Obsidian 里的源 Skill',
'移除只断开链接,不会删除 Obsidian 里的原 Skill安全保护',
'若 Coding 工具里已经有同名 Skill不是从 Obsidian 链接过来的),状态会显示为「冲突」,需手动迁移或重命名',
'跨电脑同步交给 Obsidian 自身Obsidian Sync / iCloud / Git / Remotely Save'
].forEach(t => ul.createEl('li', { text: t }));
}
}
class ImportModal extends obsidian.Modal {
constructor(app, plugin) {
super(app);
this.plugin = plugin;
this.selected = new Set();
this.items = [];
}
async onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('css-skill-sync-modal');
contentEl.createEl('h2', { text: '导入 Coding 工具里已有的 Skill 到 Obsidian' });
contentEl.createEl('p', {
cls: 'css-import-hint',
text: '把 Coding 工具里已有的 Skill 收进 Obsidian 统一管理,之后所有 Coding 工具都能用同一份。已经链接好的或 Obsidian 里已有同名的会被自动过滤。'
});
const loading = contentEl.createDiv({ text: '扫描中...' });
this.items = await this.plugin.listImportable();
loading.remove();
if (this.items.length === 0) {
contentEl.createDiv({ cls: 'css-empty', text: '没有可导入的 Skill' });
return;
}
const ctrl = contentEl.createDiv({ cls: 'css-import-ctrl' });
const selAll = ctrl.createEl('button', { text: '全选' });
const selNone = ctrl.createEl('button', { text: '清空' });
const goBtn = ctrl.createEl('button', { text: '导入选中', cls: 'mod-cta' });
ctrl.createSpan({ cls: 'css-import-count', text: `${this.items.length} 个候选` });
const listEl = contentEl.createDiv({ cls: 'css-import-list' });
const checkboxes = [];
this.items.forEach(item => {
const row = listEl.createDiv({ cls: 'css-import-row' });
const cb = row.createEl('input', { type: 'checkbox' });
cb.dataset.name = item.name;
cb.onchange = () => {
if (cb.checked) this.selected.add(item.name);
else this.selected.delete(item.name);
};
checkboxes.push(cb);
const label = row.createEl('label');
label.createEl('strong', { text: item.name });
label.createEl('span', { cls: 'css-source', text: `${item.sourcePlatform}` });
label.createEl('div', { cls: 'css-source-path', text: item.abs });
const delBtn = row.createEl('button', { cls: 'css-del-btn', text: '删除' });
delBtn.title = '删除 Coding 工具里这份实体 Skill 文件夹(不可恢复)';
let confirmTimer = null;
delBtn.onclick = async (e) => {
e.stopPropagation();
if (delBtn.dataset.confirm === '1') {
if (confirmTimer) clearTimeout(confirmTimer);
try {
await fsp.rm(item.abs, { recursive: true, force: true });
new obsidian.Notice(`已删除 ${item.name}`);
this.selected.delete(item.name);
row.remove();
this.items = this.items.filter(x => x.name !== item.name);
ctrl.querySelector('.css-import-count').setText(`${this.items.length} 个候选`);
if (this.items.length === 0) {
listEl.remove();
contentEl.createDiv({ cls: 'css-empty', text: '没有可导入的 Skill' });
}
this.plugin.refreshAllViews();
} catch (err) {
new obsidian.Notice(`删除失败:${err.message}`);
}
return;
}
delBtn.dataset.confirm = '1';
delBtn.setText('再点确认');
delBtn.addClass('mod-warning');
confirmTimer = setTimeout(() => {
delBtn.dataset.confirm = '0';
delBtn.setText('删除');
delBtn.removeClass('mod-warning');
}, 3000);
};
});
selAll.onclick = () => {
checkboxes.forEach(cb => {
cb.checked = true;
this.selected.add(cb.dataset.name);
});
};
selNone.onclick = () => {
checkboxes.forEach(cb => { cb.checked = false; });
this.selected.clear();
};
goBtn.onclick = async () => {
if (this.selected.size === 0) {
new obsidian.Notice('未选中任何 Skill');
return;
}
goBtn.disabled = true;
goBtn.setText('导入中...');
let count = 0;
for (const it of this.items) {
if (!this.selected.has(it.name)) continue;
if (await this.plugin.importSkill(it)) count++;
}
new obsidian.Notice(`已导入 ${count} 个 Skill 到 Obsidian`);
this.close();
this.plugin.refreshAllViews();
};
}
onClose() { this.contentEl.empty(); }
}
class AddPlatformModal extends obsidian.Modal {
constructor(app, plugin, onComplete) {
super(app);
this.plugin = plugin;
this.onComplete = onComplete;
this.selected = new Set();
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('css-skill-sync-modal');
contentEl.addClass('css-add-platform-modal');
contentEl.createEl('h2', { text: '添加 Coding 工具' });
const searchWrap = contentEl.createDiv({ cls: 'css-search-wrap' });
const searchInput = searchWrap.createEl('input', {
type: 'search',
cls: 'css-search-input',
attr: { placeholder: '搜索预设(如 cursor、gemini...' }
});
const countEl = searchWrap.createEl('span', { cls: 'css-search-count' });
const existingTargets = new Set(
this.plugin.settings.platforms.map(p => (p.target || '').replace(/\/$/, ''))
);
const existingNames = new Set(this.plugin.settings.platforms.map(p => p.name));
const listEl = contentEl.createDiv({ cls: 'css-preset-list' });
PRESET_PLATFORMS.forEach(preset => {
const exists = existingNames.has(preset.name) || existingTargets.has(preset.target);
const row = listEl.createDiv({ cls: 'css-preset-row' });
row.dataset.search = (preset.name + ' ' + preset.target).toLowerCase();
const cb = row.createEl('input', { type: 'checkbox' });
if (exists) {
cb.disabled = true;
cb.checked = true;
row.addClass('css-preset-exists');
}
cb.onchange = () => {
if (cb.checked) this.selected.add(preset.name);
else this.selected.delete(preset.name);
};
const label = row.createEl('label');
label.createSpan({ cls: 'css-preset-name', text: preset.name });
label.createSpan({ cls: 'css-preset-path', text: preset.target });
if (exists) label.createSpan({ cls: 'css-preset-tag', text: '已添加' });
});
const filter = (q) => {
const ql = (q || '').toLowerCase().trim();
let visible = 0;
listEl.querySelectorAll('.css-preset-row').forEach(row => {
const match = !ql || row.dataset.search.includes(ql);
row.style.display = match ? '' : 'none';
if (match) visible++;
});
countEl.setText(ql ? `${visible} / ${PRESET_PLATFORMS.length}` : `${PRESET_PLATFORMS.length}`);
};
searchInput.oninput = (e) => filter(e.target.value);
filter('');
const customToggle = contentEl.createEl('button', {
cls: 'css-custom-toggle',
text: '+ 自定义 Coding 工具'
});
const customWrap = contentEl.createDiv({ cls: 'css-custom-wrap' });
customWrap.style.display = 'none';
const customRow = customWrap.createDiv({ cls: 'css-custom-row' });
const nameInput = customRow.createEl('input', { type: 'text' });
nameInput.placeholder = '名称(如 My Tool';
const pathInput = customRow.createEl('input', { type: 'text' });
pathInput.placeholder = '~/.your-tool/skills';
customToggle.onclick = () => {
const open = customWrap.style.display === 'none';
customWrap.style.display = open ? '' : 'none';
customToggle.setText(open ? ' 收起自定义' : '+ 自定义 Coding 工具');
if (open) nameInput.focus();
};
const ctrl = contentEl.createDiv({ cls: 'css-import-ctrl' });
const goBtn = ctrl.createEl('button', { text: '添加', cls: 'mod-cta' });
goBtn.onclick = async () => {
let added = 0;
for (const preset of PRESET_PLATFORMS) {
if (!this.selected.has(preset.name)) continue;
if (existingNames.has(preset.name) || existingTargets.has(preset.target)) continue;
this.plugin.settings.platforms.push({ name: preset.name, target: preset.target, enabled: true });
added++;
}
const cn = (nameInput.value || '').trim();
const cp = (pathInput.value || '').trim();
if (cn && cp) {
this.plugin.settings.platforms.push({ name: cn, target: cp, enabled: true });
added++;
} else if (cn || cp) {
new obsidian.Notice('自定义 Coding 工具需要同时填名称和路径');
return;
}
if (added === 0) {
new obsidian.Notice('未选中任何预设也未填自定义');
return;
}
await this.plugin.saveSettings();
new obsidian.Notice(`已添加 ${added} 个 Coding 工具`);
this.close();
if (this.onComplete) this.onComplete();
};
}
onClose() { this.contentEl.empty(); }
}
module.exports = SkillSyncPlugin;