From 9789f21c583462c69876f84d29ea829a6b2bc758 Mon Sep 17 00:00:00 2001 From: 2949984428 Date: Thu, 25 Jun 2026 02:13:41 +0800 Subject: [PATCH] =?UTF-8?q?Release=20v0.2.0:=20=E6=8C=89=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=20Skill=20=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 解析 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) --- CHANGELOG.md | 13 ++++ README.md | 1 + main.js | 187 +++++++++++++++++++++++++++++++++++++++++++++----- manifest.json | 2 +- styles.css | 104 ++++++++++++++++++++++++++-- versions.json | 3 +- 6 files changed, 284 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b4aab..3344945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this plugin will be documented here. +## [0.2.0] - 2026-06-25 + +### 新增 + +- **按用量排序 Skill 列表**:解析 Claude Code 会话日志(`~/.claude/projects/**/*.jsonl`),统计每个 Skill 的实际调用次数,侧边栏可按调用次数从高到低排序,一眼看出哪些 Skill 真正在用、哪些只是占位 +- **用量时间范围**:可选统计全部历史或最近 N 天(设置项 `usageRange`),结果缓存 60 秒避免重复扫描 +- Skill 名自动去掉插件命名空间前缀(如 `superpowers:brainstorming` → `brainstorming`),与 vault 内裸目录名对齐 + +### 优化 + +- 点击 Skill 名打开 SKILL.md 的逻辑更稳健(兼容小写 `skill.md`) +- 侧边栏与状态卡样式打磨 + ## [0.1.0] - 2026-04-23 ### 首发版本 diff --git a/README.md b/README.md index 985a9ff..12151c4 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ git clone https://github.com//claude-skill-sync.git \ - **指向错误一键修复**:vault 路径变了,所有 symlink 一键改指 - **定时自动同步**(默认 5 分钟):vault 新 skill 自动安装;平台新真目录默认仅提醒、可选自动导入 - **启动状态提醒**:打开 Obsidian 弹通知摘要 +- **按用量排序**(v0.2.0):读 Claude Code 会话日志统计每个 Skill 的真实调用次数,侧边栏按热度排序,可选全部历史或最近 N 天 ## 工作机制 diff --git a/main.js b/main.js index ff2d796..cba26e3 100644 --- a/main.js +++ b/main.js @@ -38,7 +38,8 @@ const DEFAULT_SETTINGS = { notifyOnStartup: true, autoScanIntervalMin: 5, autoInstall: true, - autoImport: false + autoImport: false, + usageRange: 'all' }; function expandHome(p) { @@ -224,9 +225,14 @@ class SkillSyncPlugin extends obsidian.Plugin { if (!e.isDirectory()) continue; if (e.name.startsWith('.')) continue; const dir = path.join(root, e.name); - const skillMd = path.join(dir, 'SKILL.md'); let description = ''; - if (await exists(skillMd)) { + 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---/); @@ -235,13 +241,74 @@ class SkillSyncPlugin extends obsidian.Plugin { if (d) description = d[1].trim(); } } catch {} + break; } - skills.push({ name: e.name, abs: dir, description }); + skills.push({ name: e.name, abs: dir, description, mtime }); } - skills.sort((a, b) => a.name.localeCompare(b.name)); + 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 }; @@ -466,7 +533,12 @@ class SkillSyncPlugin extends obsidian.Plugin { if (s.mislinkedSkills > 0) parts.push(`${s.mislinkedSkills} 指向错误`); if (s.danglingCount > 0) parts.push(`${s.danglingCount} 失效`); if (s.conflictSkills > 0) parts.push(`${s.conflictSkills} 冲突`); - new obsidian.Notice(`Skill Sync: ${parts.join(' / ')}(点击查看)`, 8000); + const notice = new obsidian.Notice(`Skill Sync: ${parts.join(' / ')}(点击查看)`, 8000); + notice.noticeEl.addClass('css-skill-sync-notice'); + notice.noticeEl.onclick = () => { + notice.hide(); + this.activateView(); + }; } } @@ -551,33 +623,44 @@ class SkillSyncView extends obsidian.ItemView { async render() { const c = this.containerEl.children[1]; - c.empty(); - c.addClass('css-skill-sync'); + // 整个视图先建进游离容器 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 = c.createDiv({ cls: 'css-header' }); + const header = wrap.createDiv({ cls: 'css-header' }); header.createEl('h3', { text: 'Claude Skill Sync' }); - const btnRow = c.createDiv({ cls: 'css-btn-row' }); + const btnRow = wrap.createDiv({ cls: 'css-btn-row' }); const refreshBtn = btnRow.createEl('button', { text: '刷新' }); - refreshBtn.onclick = () => this.render(); + 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(); - c.createDiv({ cls: 'css-meta', text: `Skill 文件夹:${root}` }); + wrap.createDiv({ cls: 'css-meta', text: `Skill 文件夹:${root}` }); - await this.renderStatusCard(c); + await this.renderStatusCard(wrap); const skills = await this.plugin.listSkills(); if (skills.length === 0) { - const empty = c.createDiv({ cls: 'css-empty' }); + const empty = wrap.createDiv({ cls: 'css-empty' }); empty.createEl('div', { text: `还没有任何 Skill。` }); empty.createEl('div', { text: `在 Obsidian 里创建:${this.plugin.settings.skillRoot}//SKILL.md` }); + commit(); return; } - const searchWrap = c.createDiv({ cls: 'css-search-wrap' }); + const searchWrap = wrap.createDiv({ cls: 'css-search-wrap' }); const searchInput = searchWrap.createEl('input', { type: 'search', cls: 'css-search-input', @@ -586,16 +669,68 @@ class SkillSyncView extends obsidian.ItemView { searchInput.value = this.filterText || ''; const countEl = searchWrap.createEl('span', { cls: 'css-search-count' }); - const listEl = c.createDiv({ cls: 'css-skill-list' }); + // 调用次数统计条:时间段控(全部/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; }); - for (const s of skills) { + 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 ? '▸' : ' ' }); - nameRow.createSpan({ cls: 'css-name', text: s.name }); + 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) { @@ -638,6 +773,9 @@ class SkillSyncView extends obsidian.ItemView { } } + // 内容全部建好,最新一次 render 才落地(旧的重叠 render 在此作废) + if (!commit()) return; + const filter = (q) => { this.filterText = q; const ql = (q || '').toLowerCase().trim(); @@ -652,6 +790,19 @@ class SkillSyncView extends obsidian.ItemView { 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 { diff --git a/manifest.json b/manifest.json index ccedcd2..99a8ed4 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "claude-skill-sync", "name": "Claude Skill Sync", - "version": "0.1.0", + "version": "0.2.0", "minAppVersion": "1.4.0", "description": "Centralize AI coding agent Skills (Claude Code, Codex, Cursor, Gemini, and 14+ more) in one folder, symlinked to each tool local directory. Bi-directional sync, status dashboard, 18+ presets.", "author": "Chocolae", diff --git a/styles.css b/styles.css index f089683..177aff4 100644 --- a/styles.css +++ b/styles.css @@ -101,25 +101,32 @@ border-radius: 6px; } .css-skill-sync .css-search-wrap { + position: relative; display: flex; align-items: center; - gap: 6px; margin-bottom: 8px; } .css-skill-sync .css-search-input { flex: 1; font-size: 12px; - padding: 4px 8px; + padding: 4px 56px 4px 8px; border: 1px solid var(--background-modifier-border); border-radius: 4px; background: var(--background-primary); } .css-skill-sync .css-search-count { - font-size: 10px; - color: var(--text-muted); + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + font-size: 11px; + font-weight: 600; + color: var(--text-normal); + background: var(--background-modifier-border); + padding: 1px 8px; + border-radius: 9px; white-space: nowrap; - min-width: 30px; - text-align: right; + pointer-events: none; } .css-skill-sync .css-skill-list { display: flex; @@ -354,3 +361,88 @@ .css-add-platform-modal .css-custom-wrap { margin-bottom: 10px; } +.css-skill-sync .css-name-link { + cursor: pointer; +} +.css-skill-sync .css-name-link:hover { + color: var(--text-accent); + text-decoration: underline; +} +.css-skill-sync .css-mtime { + font-size: 10px; + color: var(--text-faint); + flex-shrink: 0; + margin-left: auto; +} +.css-skill-sync-notice { + cursor: pointer; +} + +/* 调用次数统计条:时间段控 + 排序 */ +.css-skill-sync .css-usage-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +/* 段控:全部 / 7天 / 30天 */ +.css-skill-sync .css-seg { + display: inline-flex; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + overflow: hidden; +} +.css-skill-sync .css-seg-btn { + font-size: 11px; + padding: 2px 9px; + border: none; + border-radius: 0; + background: var(--background-primary); + color: var(--text-muted); + box-shadow: none; + cursor: pointer; +} +.css-skill-sync .css-seg-btn:not(:last-child) { + border-right: 1px solid var(--background-modifier-border); +} +.css-skill-sync .css-seg-btn:hover { + background: var(--background-modifier-hover); +} +.css-skill-sync .css-seg-btn.is-active { + background: var(--interactive-accent); + color: var(--text-on-accent); +} +/* 按次数排序 */ +.css-skill-sync .css-sort-btn { + margin-left: auto; + font-size: 11px; + padding: 2px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-primary); + color: var(--text-muted); + box-shadow: none; + cursor: pointer; + white-space: nowrap; +} +.css-skill-sync .css-sort-btn:hover { + background: var(--background-modifier-hover); +} +.css-skill-sync .css-sort-btn.is-active { + background: var(--interactive-accent); + color: var(--text-on-accent); + border-color: var(--interactive-accent); +} +/* 每个 skill 名字行后的次数徽标(热度四档) */ +.css-skill-sync .css-uc { + flex-shrink: 0; + margin-left: 6px; + font-size: 10px; + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.css-skill-sync .css-uc-hot { color: var(--color-red, #e5534b); } +.css-skill-sync .css-uc-warm { color: var(--color-orange, #d2904a); } +.css-skill-sync .css-uc-cool { color: var(--text-accent); } +.css-skill-sync .css-uc-zero { color: var(--text-faint); font-weight: 400; } diff --git a/versions.json b/versions.json index cdffaed..9a9de87 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,4 @@ { - "0.1.0": "1.4.0" + "0.1.0": "1.4.0", + "0.2.0": "1.4.0" }