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>
This commit is contained in:
2949984428 2026-06-25 02:13:41 +08:00
parent 0f9e49861c
commit 9789f21c58
6 changed files with 284 additions and 26 deletions

View file

@ -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
### 首发版本

View file

@ -51,6 +51,7 @@ git clone https://github.com/<owner>/claude-skill-sync.git \
- **指向错误一键修复**vault 路径变了,所有 symlink 一键改指
- **定时自动同步**(默认 5 分钟vault 新 skill 自动安装;平台新真目录默认仅提醒、可选自动导入
- **启动状态提醒**:打开 Obsidian 弹通知摘要
- **按用量排序**v0.2.0):读 Claude Code 会话日志统计每个 Skill 的真实调用次数,侧边栏按热度排序,可选全部历史或最近 N 天
## 工作机制

187
main.js
View file

@ -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-name>/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 {

View file

@ -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",

View file

@ -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; }

View file

@ -1,3 +1,4 @@
{
"0.1.0": "1.4.0"
"0.1.0": "1.4.0",
"0.2.0": "1.4.0"
}