mirror of
https://github.com/2949984428/claude-skill-sync.git
synced 2026-07-22 06:55:06 +00:00
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:
parent
0f9e49861c
commit
9789f21c58
6 changed files with 284 additions and 26 deletions
13
CHANGELOG.md
13
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
|
||||
|
||||
### 首发版本
|
||||
|
|
|
|||
|
|
@ -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
187
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-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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
104
styles.css
104
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; }
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"0.1.0": "1.4.0"
|
||||
"0.1.0": "1.4.0",
|
||||
"0.2.0": "1.4.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue