2949984428_claude-skill-sync/main.js
2949984428 9789f21c58 Release v0.2.0: 按用量排序 Skill 列表
- 解析 Claude Code 会话日志统计 Skill 调用次数,侧边栏按热度排序
- 新增 usageRange 设置(全部历史 / 最近 N 天),结果缓存 60s
- Skill 名自动去插件命名空间前缀,与 vault 裸目录名对齐
- 更新 manifest 0.1.0 → 0.2.0、versions.json、CHANGELOG、README

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:13:41 +08:00

1161 lines
44 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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'
};
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;
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;
}
skills.push({ name: e.name, abs: dir, description, mtime });
}
skills.sort((a, b) => b.mtime - a.mtime || a.name.localeCompare(b.name));
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;
}
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();
};
}
}
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;
};
const header = wrap.createDiv({ cls: 'css-header' });
header.createEl('h3', { text: 'Claude Skill Sync' });
const btnRow = wrap.createDiv({ cls: 'css-btn-row' });
const refreshBtn = btnRow.createEl('button', { text: '刷新' });
refreshBtn.onclick = () => { this.plugin._usageCache = null; this.render(); };
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}` });
await this.renderStatusCard(wrap);
const skills = await this.plugin.listSkills();
if (skills.length === 0) {
const empty = wrap.createDiv({ cls: 'css-empty' });
empty.createEl('div', { text: `还没有任何 Skill。` });
empty.createEl('div', { text: `在 Obsidian 里创建:${this.plugin.settings.skillRoot}/<skill-name>/SKILL.md` });
commit();
return;
}
const searchWrap = wrap.createDiv({ cls: 'css-search-wrap' });
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;
const listEl = wrap.createDiv({ cls: 'css-skill-list' });
for (const s of orderedSkills) {
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)}`);
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;
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`);
}
}
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;