diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 86ab59c..a9881d1 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "skills-atlas", - "version": "0.30.0", + "version": "0.40.0", "description": "Find, read, install and update public Agent Skills from a catalog of about 75,000: a skill, MCP tools and a /skills-atlas command.", "author": { "name": "DevOhMyCode", diff --git a/CHANGELOG.md b/CHANGELOG.md index 784404f..221b7ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +## 0.40.0 — 2026-11-10 + +Validation: 286 tests and TypeScript checks. + +- **`skills-atlas unused`** lists the installed skills Claude Code has not used in the last 30 days, read from its session transcripts on this machine, and `--remove` uninstalls them. Skills installed during the window and skills of other agents are listed apart, never as unused. Also as the MCP tool `unused_skills`. +- **`skills-atlas overlaps`** lists the installed skills whose descriptions are close enough for an agent to take one for the other, with a score and the words they share. Also as the MCP tool `overlapping_skills`. - **A source that fails no longer drops its skills.** The crawl keeps what that source listed in the previous snapshot. The snapshot of 2026-11-10, built while claude-plugins.dev answered HTTP 402, had lost 35,750 skills; the one of 2026-11-04 is restored. - **Content read from a URL is a warning sign.** A line where the skill fetches, reads or downloads something at a URL is reported (medium): what is served there can change after the review, and pinning the skill to a commit does not pin it. - **Secret keys written in a skill are reported** (medium), cut to their first characters; placeholders such as `sk-xxxxxxxx` are not. diff --git a/README.md b/README.md index 82c38f2..01b0c31 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Copies do not count. A repository that shares at least three growing skill names Three ways to let an agent find and install skills for you, from the lightest: - **The skill.** `npx skills add devohmycode/skills-atlas -s skills-atlas` teaches any agent the commands above (`list --json`, `show`, `install … -y`, `outdated --json`). Ask "find me a skill to draw diagrams": the agent searches, reads the candidates' `SKILL.md`, then installs the one you pick. -- **The MCP server.** `skills-atlas mcp` serves the same catalog as tools: `search_skills`, `list_themes`, `show_skill` (with its risk check), `list_packs`, `list_installed`, `outdated_skills` (`{ skills, unchecked }`), `install_skills` (with `dry_run`; it refuses to replace a skill of the same name unless `replace: true`) and `remove_skills`. Installs and removals run one at a time per server, each re-checking what is installed; reads and dry runs do not wait. In Claude Code: +- **The MCP server.** `skills-atlas mcp` serves the same catalog as tools: `search_skills`, `list_themes`, `show_skill` (with its risk check), `list_packs`, `list_installed`, `unused_skills` (what Claude Code has not used lately), `overlapping_skills` (descriptions an agent may confuse), `outdated_skills` (`{ skills, unchecked }`), `install_skills` (with `dry_run`; it refuses to replace a skill of the same name unless `replace: true`) and `remove_skills`. Installs and removals run one at a time per server, each re-checking what is installed; reads and dry runs do not wait. In Claude Code: ```bash claude mcp add skills-atlas -- npx -y skills-atlas@latest mcp @@ -268,6 +268,28 @@ Only `repository` (a GitHub repository URL) and `reason` are required. Without ` *Agents step: every agent the skills CLI supports, with those detected on this machine flagged `· detected`.* +### Unused skills + +Every installed skill costs its name and description in each session, used or not. `skills-atlas unused` reads Claude Code's session transcripts on this machine (`~/.claude/projects`, or `CLAUDE_CONFIG_DIR`) and lists the installed skills it has not used in the last 30 days (`--days `): a skill counts as used when the agent called it with the Skill tool, when you typed it as a slash command, or when the agent read its `SKILL.md`, subagents included. Nothing leaves the machine and nothing read is kept. + +- **Too early to tell**: a skill installed (or updated, which reinstalls it) during the window is listed apart, not as unused. +- **Usage unknown**: skills not installed for Claude Code. Only its transcripts are read; the other agents' usage is not measured. +- **No transcript in the window** is an error, never "everything is unused". +- `--remove` uninstalls the unused skills listed, from the scopes they are in (`--dry-run` prints the commands); `-g` or `-p` keeps to one scope, `--json` prints `{ since, transcripts_read, transcripts_skipped, unused, used, too_recent, unknown }`, each used skill with its `count` and the day of its `last` use. + +It reads only the transcripts changed in the window, and in them only the lines that can hold a call; with several gigabytes of transcripts it still takes tens of seconds. Transcripts above 1 GB are skipped and counted. + +### Skills an agent may confuse + +An agent chooses a skill from its name and description alone: two installed skills that describe the same job compete for the same requests. `skills-atlas overlaps` compares the descriptions of the installed skills a same agent sees and lists the close pairs, the closest first, with a score from 0 to 1 and the words they share: + +``` + 0.89 review (global) ~ review-bugbot (global) + shared words: bugbot, review, subagent, chang, code · Codex, Cursor, Droid +9 +``` + +Words are weighed by how rare they are among your installed skills, so that “use when the user asks” counts for little. `--threshold ` moves the bar (0.5 by default: lower lists more pairs), `-g` or `-p` keeps to one scope, `--json` adds both descriptions. It is a word comparison, in English and French stop words only: it points at pairs worth reading side by side, it does not prove they conflict, and skills of one family (`storage-get`, `storage-delete`) match by design. + ### Installation diagnostics `skills-atlas doctor` checks project and global installations without changing any files. Use `-p` or `-g` for one scope and `--json` for a machine-readable report. It reports broken links, missing or unreadable `SKILL.md` files, invalid lock files and lock entries without a readable installed skill. Missing ordinary agent directories and absent lock files are normal. It exits with 1 when it finds issues and 0 otherwise; it never repairs or removes anything automatically. diff --git a/package-lock.json b/package-lock.json index 596ea23..f3f023a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "skills-atlas", - "version": "0.30.0", + "version": "0.40.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "skills-atlas", - "version": "0.30.0", + "version": "0.40.0", "license": "MIT", "dependencies": { "commander": "^14.0.0", diff --git a/package.json b/package.json index 3f4e870..ca44582 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skills-atlas", - "version": "0.30.0", + "version": "0.40.0", "description": "Browse every public Agent Skill grouped by theme, multi-select and install them through `npx skills add`.", "type": "module", "bin": { diff --git a/src/cli.tsx b/src/cli.tsx index 753b911..f6c70ae 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -20,7 +20,7 @@ import { diagnoseInstalled } from './install/doctor.js'; import { isSuccess, listInstalled, runAll, type CommandOutcome } from './install/run.js'; import { SCOPES, type Catalog, type InstallMethod, type InstallOptions, type Removal, type Scope } from './types.js'; import { readFileSync } from 'node:fs'; -import { installedSkills, outdatedSkills, showSkill, skillsByAgent } from './agent-api.js'; +import { agentIds, installedSkills, outdatedSkills, showSkill, skillsByAgent } from './agent-api.js'; import { serveMcp } from './mcp.js'; import { complete, completionScript, type CompletionContext } from './completion.js'; import { auditEnabled, auditSkills, auditTarget, failsAudit, withLookalikes } from './audit.js'; @@ -29,6 +29,8 @@ import { fetchSkillFiles, fetchSkillMarkdown } from './github.js'; import { riskNote, riskWhere } from './risks.js'; import { fileSummary, updateDiff } from './install/diff.js'; import { browseLoop } from './browse.js'; +import { describeInstalled, findOverlaps, OVERLAP_THRESHOLD } from './overlaps.js'; +import { scanUsage, transcriptsDir, usageReport } from './usage.js'; import { missingSkills, missingSkillsByAgent, parseSelection, parseSelectionFile, toSelection } from './selection.js'; /** The skills a project expects, at its root, versioned with its code. */ @@ -697,6 +699,76 @@ program for (const issue of issues) console.log(` ${t().tree.where[issue.scope]} · ${issue.message}: ${issue.path}${issue.name ? ` (${issue.name})` : ''}`); }); +program + .command('unused') + .description(c.unusedCmd) + .option('--days ', c.daysOption, '30') + .option('-g, --global', c.global) + .option('-p, --project', c.project) + .option('--remove', c.unusedRemove) + .option('--dry-run', c.dryRun) + .option('--json', c.json) + .action(async (opts: { days: string; global?: boolean; project?: boolean; remove?: boolean; dryRun?: boolean; json?: boolean }) => { + const days = Number(opts.days); + if (!Number.isInteger(days) || days < 1) fail(c.badNumber('--days')); + const scopes: Scope[] = opts.global && !opts.project ? ['global'] : opts.project && !opts.global ? ['project'] : SCOPES; + const since = new Date(Date.now() - days * 86_400_000); + const dir = transcriptsDir(); + if (process.stderr.isTTY && !opts.json) console.error(paint('dim', c.usageReading(dir))); + const scan = scanUsage(dir, since); + const report = usageReport((await Promise.all(scopes.map((sc) => listInstalled(sc)))).flat(), scan, since); + const id = (s: { source?: string; name: string }) => `${s.source ?? 'local'}@${s.name}`; + if (opts.json) { + return void process.stdout.write(`${JSON.stringify({ + since: since.toISOString().slice(0, 10), transcripts_read: scan.files, transcripts_skipped: scan.skipped, + unused: report.unused.map((s) => ({ id: id(s), name: s.name, scope: s.scope })), + used: report.used.map((s) => ({ id: id(s), name: s.name, scope: s.scope, count: s.count, last: s.last })), + too_recent: report.recent.map((s) => ({ id: id(s), name: s.name, scope: s.scope })), + unknown: report.unknown.map((s) => ({ id: id(s), name: s.name, scope: s.scope })), + }, null, 2)}\n`); + } + // Without a transcript nothing is known: every skill would read as unused. + if (!scan.files) return fail(c.usageNoTranscripts(dir)); + console.log(paint('dim', c.usageRead(formatNumber(scan.files), since.toISOString().slice(0, 10)))); + if (!report.unused.length) console.log(c.unusedNone); + else { + console.log(paint('bold', c.unusedTitle(report.unused.length, days))); + for (const s of report.unused) console.log(` ${padEnd(s.name, 34)} ${paint('dim', `${padEnd(s.source ?? 'local', 36)} ${t().tree.where[s.scope]}`)}`); + } + if (report.recent.length) console.log(paint('dim', c.unusedRecent(report.recent.map((s) => s.name).join(', ')))); + if (report.unknown.length) console.log(paint('dim', c.unusedUnknown(report.unknown.length))); + if (!report.unused.length) return; + if (!opts.remove) return console.log(paint('dim', c.unusedHint)); + const removals = new Map(); + for (const s of report.unused) removals.set(s.name, { name: s.name, scopes: [...(removals.get(s.name)?.scopes ?? []), s.scope] }); + await apply([], [...removals.values()], { agents: [], scope: 'global', method: 'symlink', skillsVersion: SKILLS_VERSION }, { dryRun: opts.dryRun, mouse: false, skillsVersion: SKILLS_VERSION }); + }); + +program + .command('overlaps') + .description(c.overlapsCmd) + .option('--threshold ', c.thresholdOption, String(OVERLAP_THRESHOLD)) + .option('-g, --global', c.global) + .option('-p, --project', c.project) + .option('--json', c.json) + .action(async (opts: { threshold: string; global?: boolean; project?: boolean; json?: boolean }) => { + const threshold = Number(opts.threshold); + if (!(threshold > 0 && threshold <= 1)) fail(c.badNumber('--threshold')); + const scopes: Scope[] = opts.global && !opts.project ? ['global'] : opts.project && !opts.global ? ['project'] : SCOPES; + const found = findOverlaps(describeInstalled((await Promise.all(scopes.map((sc) => listInstalled(sc)))).flat()), threshold); + if (opts.json) { + const ref = (s: (typeof found)[number]['a']) => ({ id: `${s.source ?? 'local'}@${s.name}`, name: s.name, scope: s.scope, description: s.description }); + return void process.stdout.write(`${JSON.stringify(found.map((o) => ({ score: o.score, a: ref(o.a), b: ref(o.b), shared: o.shared, agents: agentIds(o.agents) })), null, 2)}\n`); + } + if (!found.length) return console.log(paint('dim', c.overlapsNone)); + console.log(paint('bold', c.overlapsTitle(found.length))); + for (const o of found) { + console.log(`\n ${paint('yellow', o.score.toFixed(2))} ${o.a.name} ${paint('dim', `(${t().tree.where[o.a.scope]})`)} ~ ${o.b.name} ${paint('dim', `(${t().tree.where[o.b.scope]})`)}`); + console.log(paint('dim', ` ${c.overlapsShared}: ${o.shared.join(', ')} · ${o.agents.slice(0, 3).join(', ')}${o.agents.length > 3 ? ` +${o.agents.length - 3}` : ''}`)); + } + console.log(paint('dim', `\n${c.overlapsHint}`)); + }); + program .command('installed') .description(c.installedCmd) diff --git a/src/i18n/de.ts b/src/i18n/de.ts index b6dbd20..2c465cc 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -263,6 +263,24 @@ export const de: Messages = { showCmd: "gibt die SKILL.md eines Skills aus, gelesen aus seinem GitHub-Repository", mcpCmd: "stellt Katalog und Installationen als MCP-Tools über stdio bereit, für Claude und andere Agenten", installedCmd: "listet installierte Skills nach Agent: was jeder Agent sieht", + unusedCmd: "listet installierte Skills, die Claude Code zuletzt nicht benutzt hat, laut seinen Sitzungsprotokollen auf diesem Rechner", + daysOption: "wie viele Tage zurückgeschaut wird (Standard 30)", + unusedRemove: "deinstalliert die aufgelisteten unbenutzten Skills", + overlapsCmd: "listet installierte Skills, deren Beschreibungen so ähnlich sind, dass ein Agent sie verwechseln kann", + thresholdOption: "Ähnlichkeit, ab der ein Paar gelistet wird, 0 bis 1 (Standard 0.5)", + badNumber: (opt) => `${opt}: keine gültige Zahl`, + usageRead: (files, since) => `${files} Claude-Code-Protokolle gelesen, seit ${since}.`, + usageReading: (dir) => `Claude-Code-Protokolle in ${dir} werden gelesen…`, + usageNoTranscripts: (dir) => `Kein Claude-Code-Protokoll aus diesem Zeitraum in ${dir}: Die Nutzung lässt sich nicht messen.`, + unusedNone: "Alle Skills, die Claude Code so lange sieht, wurden benutzt.", + unusedTitle: (n, days) => `Seit ${days} Tagen nicht benutzt: ${n}`, + unusedRecent: (names) => `Seitdem installiert, zu früh für ein Urteil: ${names}`, + unusedUnknown: (n) => `Nicht für Claude Code installiert, Nutzung unbekannt: ${n}`, + unusedHint: "Deinstallieren Sie sie mit `skills-atlas unused --remove` oder wählen Sie sie im Baum ab.", + overlapsNone: "Kein Paar installierter Skills hat ähnliche Beschreibungen.", + overlapsTitle: (n) => `Skill-Paare, die ein Agent verwechseln kann: ${n}`, + overlapsShared: "gemeinsame Wörter", + overlapsHint: "Lesen Sie beide Beschreibungen: Behalten Sie einen Skill, oder lassen Sie jede sagen, wann sie gilt und wann die andere.", completionCmd: "gibt das Shell-Vervollständigungsskript aus (bash, zsh, fish, powershell)", packOption: "installiert auch die Skills dieser Pakete (siehe `skills-atlas packs`)", packsCmd: "listet die Pakete: Skills zum gemeinsamen Installieren", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c70e229..2cb074f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -261,6 +261,24 @@ export const en = { showCmd: "print a skill's SKILL.md, read from its GitHub repository", mcpCmd: "serve the catalog and installs as MCP tools on stdio, for Claude and other agents", installedCmd: "list installed skills by agent: what each agent sees", + unusedCmd: "list installed skills Claude Code has not used lately, read from its session transcripts on this machine", + daysOption: "how many days back to look (default 30)", + unusedRemove: "uninstall the unused skills listed", + overlapsCmd: "list installed skills whose descriptions are close enough for an agent to confuse them", + thresholdOption: "similarity from which a pair is listed, 0 to 1 (default 0.5)", + badNumber: (opt: string) => `${opt}: not a valid number`, + usageRead: (files: string, since: string) => `${files} Claude Code transcripts read, since ${since}.`, + usageReading: (dir: string) => `Reading the Claude Code transcripts in ${dir}…`, + usageNoTranscripts: (dir: string) => `No Claude Code transcript in that period in ${dir}: usage cannot be measured.`, + unusedNone: "Every skill Claude Code has seen for that long was used.", + unusedTitle: (n: number, days: number) => `Not used in ${days} days: ${n}`, + unusedRecent: (names: string) => `Installed since, too early to tell: ${names}`, + unusedUnknown: (n: number) => `Not installed for Claude Code, usage unknown: ${n}`, + unusedHint: "Uninstall them with `skills-atlas unused --remove`, or untick them in the tree.", + overlapsNone: "No two installed skills have close descriptions.", + overlapsTitle: (n: number) => `Pairs of skills an agent may confuse: ${n}`, + overlapsShared: "shared words", + overlapsHint: "Read both descriptions: keep one skill, or make each say when it applies and when the other does.", completionCmd: "print the shell completion script (bash, zsh, fish, powershell)", packOption: "install the skills of these packs too (see `skills-atlas packs`)", packsCmd: "list the curated packs: skills to install together", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 90e0a3d..fb7ca75 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -263,6 +263,24 @@ export const es: Messages = { showCmd: "muestra el SKILL.md de una skill, leído de su repositorio GitHub", mcpCmd: "sirve el catálogo y las instalaciones como herramientas MCP por stdio, para Claude y otros agentes", installedCmd: "lista las skills instaladas por agente: lo que ve cada agente", + unusedCmd: "lista las skills instaladas que Claude Code no ha usado últimamente, según sus transcripciones de sesión en esta máquina", + daysOption: "cuántos días atrás mirar (30 por defecto)", + unusedRemove: "desinstala las skills sin usar listadas", + overlapsCmd: "lista las skills instaladas cuyas descripciones son tan parecidas que un agente puede confundirlas", + thresholdOption: "similitud a partir de la cual se lista un par, de 0 a 1 (0.5 por defecto)", + badNumber: (opt) => `${opt}: número no válido`, + usageRead: (files, since) => `${files} transcripciones de Claude Code leídas, desde el ${since}.`, + usageReading: (dir) => `Leyendo las transcripciones de Claude Code en ${dir}…`, + usageNoTranscripts: (dir) => `Ninguna transcripción de Claude Code en ese periodo en ${dir}: no se puede medir el uso.`, + unusedNone: "Todas las skills que Claude Code ve desde hace ese tiempo se han usado.", + unusedTitle: (n, days) => `Sin usar en ${days} días: ${n}`, + unusedRecent: (names) => `Instaladas desde entonces, demasiado pronto para saberlo: ${names}`, + unusedUnknown: (n) => `No instaladas para Claude Code, uso desconocido: ${n}`, + unusedHint: "Desinstálelas con `skills-atlas unused --remove`, o desmárquelas en el árbol.", + overlapsNone: "Ningún par de skills instaladas tiene descripciones parecidas.", + overlapsTitle: (n) => `Pares de skills que un agente puede confundir: ${n}`, + overlapsShared: "palabras comunes", + overlapsHint: "Lea las dos descripciones: conserve una sola skill, o haga que cada una diga cuándo se aplica y cuándo la otra.", completionCmd: "muestra el script de autocompletado del shell (bash, zsh, fish, powershell)", packOption: "instala también las skills de estos packs (ver `skills-atlas packs`)", packsCmd: "lista los packs: skills para instalar juntas", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index a608c17..bc56541 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -263,6 +263,24 @@ export const fr: Messages = { showCmd: "affiche le SKILL.md d'un skill, lu dans son dépôt GitHub", mcpCmd: "sert le catalogue et les installations comme outils MCP sur stdio, pour Claude et d’autres agents", installedCmd: "liste les skills installés par agent : ce que voit chaque agent", + unusedCmd: "liste les skills installés que Claude Code n’a pas utilisés récemment, d’après ses transcriptions de session sur cette machine", + daysOption: "nombre de jours à remonter (30 par défaut)", + unusedRemove: "désinstalle les skills inutilisés listés", + overlapsCmd: "liste les skills installés dont les descriptions sont assez proches pour qu’un agent les confonde", + thresholdOption: "similarité à partir de laquelle une paire est listée, de 0 à 1 (0.5 par défaut)", + badNumber: (opt) => `${opt} : nombre invalide`, + usageRead: (files, since) => `${files} transcriptions Claude Code lues, depuis le ${since}.`, + usageReading: (dir) => `Lecture des transcriptions Claude Code dans ${dir}…`, + usageNoTranscripts: (dir) => `Aucune transcription Claude Code sur cette période dans ${dir} : l’usage ne peut pas être mesuré.`, + unusedNone: "Tous les skills que Claude Code voit depuis aussi longtemps ont servi.", + unusedTitle: (n, days) => `Non utilisés depuis ${days} jours : ${n}`, + unusedRecent: (names) => `Installés depuis, trop tôt pour le dire : ${names}`, + unusedUnknown: (n) => `Non installés pour Claude Code, usage inconnu : ${n}`, + unusedHint: "Désinstallez-les avec `skills-atlas unused --remove`, ou décochez-les dans l’arbre.", + overlapsNone: "Aucune paire de skills installés n’a de descriptions proches.", + overlapsTitle: (n) => `Paires de skills qu’un agent peut confondre : ${n}`, + overlapsShared: "mots communs", + overlapsHint: "Lisez les deux descriptions : gardez un seul skill, ou faites dire à chacune quand elle s’applique et quand c’est l’autre.", completionCmd: "affiche le script de complétion du shell (bash, zsh, fish, powershell)", packOption: "installe aussi les skills de ces packs (voir `skills-atlas packs`)", packsCmd: "liste les packs : des skills à installer ensemble", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 3520843..c1143e7 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -263,6 +263,24 @@ export const it: Messages = { showCmd: "mostra lo SKILL.md di una skill, letto dal suo repository GitHub", mcpCmd: "espone catalogo e installazioni come strumenti MCP su stdio, per Claude e altri agenti", installedCmd: "elenca le skill installate per agente: ciò che vede ogni agente", + unusedCmd: "elenca le skill installate che Claude Code non ha usato di recente, in base alle trascrizioni di sessione su questa macchina", + daysOption: "quanti giorni indietro guardare (30 per impostazione predefinita)", + unusedRemove: "disinstalla le skill inutilizzate elencate", + overlapsCmd: "elenca le skill installate con descrizioni così simili che un agente può confonderle", + thresholdOption: "somiglianza da cui una coppia viene elencata, da 0 a 1 (0.5 per impostazione predefinita)", + badNumber: (opt) => `${opt}: numero non valido`, + usageRead: (files, since) => `${files} trascrizioni di Claude Code lette, dal ${since}.`, + usageReading: (dir) => `Lettura delle trascrizioni di Claude Code in ${dir}…`, + usageNoTranscripts: (dir) => `Nessuna trascrizione di Claude Code in quel periodo in ${dir}: l’uso non può essere misurato.`, + unusedNone: "Tutte le skill che Claude Code vede da così tanto sono state usate.", + unusedTitle: (n, days) => `Non usate da ${days} giorni: ${n}`, + unusedRecent: (names) => `Installate da allora, troppo presto per dirlo: ${names}`, + unusedUnknown: (n) => `Non installate per Claude Code, uso sconosciuto: ${n}`, + unusedHint: "Disinstallale con `skills-atlas unused --remove`, o deselezionale nell’albero.", + overlapsNone: "Nessuna coppia di skill installate ha descrizioni simili.", + overlapsTitle: (n) => `Coppie di skill che un agente può confondere: ${n}`, + overlapsShared: "parole comuni", + overlapsHint: "Leggi le due descrizioni: tieni una sola skill, o fai dire a ciascuna quando si applica e quando l’altra.", completionCmd: "stampa lo script di completamento della shell (bash, zsh, fish, powershell)", packOption: "installa anche le skill di questi pacchetti (vedi `skills-atlas packs`)", packsCmd: "elenca i pacchetti: skill da installare insieme", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 04c02bb..fe2dc52 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -261,6 +261,24 @@ export const ja: Messages = { showCmd: "スキルの SKILL.md を GitHub リポジトリから読んで表示", mcpCmd: "カタログとインストールを MCP ツールとして stdio で提供(Claude などのエージェント向け)", installedCmd: "インストール済みスキルをエージェント別に一覧表示", + unusedCmd: "このマシンのセッション記録をもとに、Claude Code が最近使っていないインストール済みスキルを一覧表示", + daysOption: "さかのぼる日数(既定 30)", + unusedRemove: "一覧の未使用スキルをアンインストール", + overlapsCmd: "説明が似ていてエージェントが混同しうるインストール済みスキルを一覧表示", + thresholdOption: "ペアを一覧に載せる類似度、0〜1(既定 0.5)", + badNumber: (opt) => `${opt}: 有効な数値ではありません`, + usageRead: (files, since) => `Claude Code のセッション記録 ${files} 件を読み込みました(${since} 以降)。`, + usageReading: (dir) => `${dir} の Claude Code セッション記録を読み込み中…`, + usageNoTranscripts: (dir) => `${dir} にこの期間の Claude Code セッション記録がないため、使用状況を測れません。`, + unusedNone: "この期間ずっと Claude Code から見えていたスキルはすべて使われました。", + unusedTitle: (n, days) => `${days} 日間未使用: ${n}`, + unusedRecent: (names) => `期間中にインストール、まだ判断できません: ${names}`, + unusedUnknown: (n) => `Claude Code 向けにインストールされておらず使用状況不明: ${n}`, + unusedHint: "`skills-atlas unused --remove` でアンインストールするか、ツリーでチェックを外してください。", + overlapsNone: "説明が似ているインストール済みスキルの組はありません。", + overlapsTitle: (n) => `エージェントが混同しうるスキルの組: ${n}`, + overlapsShared: "共通の語", + overlapsHint: "両方の説明を読み、一方だけ残すか、それぞれがいつ使われるかを書き分けてください。", completionCmd: "シェル補完スクリプトを出力(bash, zsh, fish, powershell)", packOption: "これらのパックのスキルもインストール(`skills-atlas packs` を参照)", packsCmd: "パック一覧: まとめてインストールするスキル", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 62858fa..ace9982 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -261,6 +261,24 @@ export const ko: Messages = { showCmd: "스킬의 SKILL.md를 GitHub 저장소에서 읽어 출력", mcpCmd: "카탈로그와 설치를 stdio MCP 도구로 제공 (Claude 등 에이전트용)", installedCmd: "설치된 스킬을 에이전트별로 표시", + unusedCmd: "이 컴퓨터의 세션 기록을 바탕으로 Claude Code가 최근 사용하지 않은 설치된 스킬을 표시", + daysOption: "며칠 전까지 볼지(기본 30)", + unusedRemove: "표시된 미사용 스킬을 제거", + overlapsCmd: "설명이 비슷해 에이전트가 혼동할 수 있는 설치된 스킬을 표시", + thresholdOption: "쌍을 표시할 유사도, 0~1(기본 0.5)", + badNumber: (opt) => `${opt}: 올바른 숫자가 아닙니다`, + usageRead: (files, since) => `Claude Code 세션 기록 ${files}개를 읽었습니다(${since} 이후).`, + usageReading: (dir) => `${dir}의 Claude Code 세션 기록을 읽는 중…`, + usageNoTranscripts: (dir) => `${dir}에 이 기간의 Claude Code 세션 기록이 없어 사용량을 측정할 수 없습니다.`, + unusedNone: "이 기간 내내 Claude Code에 보인 스킬은 모두 사용되었습니다.", + unusedTitle: (n, days) => `${days}일 동안 미사용: ${n}`, + unusedRecent: (names) => `기간 중 설치되어 아직 판단할 수 없음: ${names}`, + unusedUnknown: (n) => `Claude Code용으로 설치되지 않아 사용량 알 수 없음: ${n}`, + unusedHint: "`skills-atlas unused --remove`로 제거하거나 트리에서 선택을 해제하세요.", + overlapsNone: "설명이 비슷한 설치된 스킬 쌍이 없습니다.", + overlapsTitle: (n) => `에이전트가 혼동할 수 있는 스킬 쌍: ${n}`, + overlapsShared: "공통 단어", + overlapsHint: "두 설명을 읽고 하나만 남기거나, 각각 언제 쓰이는지 구분해 적으세요.", completionCmd: "셸 자동 완성 스크립트 출력 (bash, zsh, fish, powershell)", packOption: "이 팩의 스킬도 설치 (`skills-atlas packs` 참고)", packsCmd: "팩 목록: 함께 설치할 스킬", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index 63238d8..aa13cc7 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -263,6 +263,24 @@ export const pt: Messages = { showCmd: "mostra o SKILL.md de uma skill, lido do seu repositório GitHub", mcpCmd: "serve o catálogo e as instalações como ferramentas MCP via stdio, para o Claude e outros agentes", installedCmd: "lista as skills instaladas por agente: o que cada agente vê", + unusedCmd: "lista as skills instaladas que o Claude Code não usou recentemente, segundo as transcrições de sessão nesta máquina", + daysOption: "quantos dias recuar (30 por omissão)", + unusedRemove: "desinstala as skills não usadas listadas", + overlapsCmd: "lista as skills instaladas cujas descrições são próximas o bastante para um agente as confundir", + thresholdOption: "semelhança a partir da qual um par é listado, de 0 a 1 (0.5 por omissão)", + badNumber: (opt) => `${opt}: número inválido`, + usageRead: (files, since) => `${files} transcrições do Claude Code lidas, desde ${since}.`, + usageReading: (dir) => `A ler as transcrições do Claude Code em ${dir}…`, + usageNoTranscripts: (dir) => `Nenhuma transcrição do Claude Code nesse período em ${dir}: não é possível medir o uso.`, + unusedNone: "Todas as skills que o Claude Code vê há esse tempo foram usadas.", + unusedTitle: (n, days) => `Não usadas em ${days} dias: ${n}`, + unusedRecent: (names) => `Instaladas desde então, cedo demais para dizer: ${names}`, + unusedUnknown: (n) => `Não instaladas para o Claude Code, uso desconhecido: ${n}`, + unusedHint: "Desinstale-as com `skills-atlas unused --remove`, ou desmarque-as na árvore.", + overlapsNone: "Nenhum par de skills instaladas tem descrições próximas.", + overlapsTitle: (n) => `Pares de skills que um agente pode confundir: ${n}`, + overlapsShared: "palavras comuns", + overlapsHint: "Leia as duas descrições: mantenha uma só skill, ou faça cada uma dizer quando se aplica e quando é a outra.", completionCmd: "mostra o script de autocompletar do shell (bash, zsh, fish, powershell)", packOption: "instala também as skills destes pacotes (veja `skills-atlas packs`)", packsCmd: "lista os pacotes: skills para instalar juntas", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 6a2c6a3..187ae9c 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -261,6 +261,24 @@ export const zh: Messages = { showCmd: "显示技能的 SKILL.md(从其 GitHub 仓库读取)", mcpCmd: "通过 stdio 以 MCP 工具提供目录与安装(供 Claude 等代理使用)", installedCmd: "按代理列出已安装的技能:每个代理能看到什么", + unusedCmd: "根据本机的会话记录,列出 Claude Code 近期未使用的已安装技能", + daysOption: "回溯的天数(默认 30)", + unusedRemove: "卸载列出的未使用技能", + overlapsCmd: "列出描述相近、代理可能混淆的已安装技能", + thresholdOption: "列出一对技能的相似度阈值,0 到 1(默认 0.5)", + badNumber: (opt) => `${opt}:不是有效的数字`, + usageRead: (files, since) => `已读取 ${files} 份 Claude Code 会话记录,自 ${since} 起。`, + usageReading: (dir) => `正在读取 ${dir} 中的 Claude Code 会话记录…`, + usageNoTranscripts: (dir) => `${dir} 中没有该时段的 Claude Code 会话记录:无法衡量使用情况。`, + unusedNone: "Claude Code 在此期间可见的技能都被使用过。", + unusedTitle: (n, days) => `${days} 天内未使用:${n}`, + unusedRecent: (names) => `在此期间安装,尚无法判断:${names}`, + unusedUnknown: (n) => `未为 Claude Code 安装,使用情况未知:${n}`, + unusedHint: "用 `skills-atlas unused --remove` 卸载,或在树中取消勾选。", + overlapsNone: "没有描述相近的已安装技能。", + overlapsTitle: (n) => `代理可能混淆的技能对:${n}`, + overlapsShared: "共同词", + overlapsHint: "请阅读两段描述:只保留一个技能,或让每段说明各自何时适用。", completionCmd: "输出 shell 补全脚本(bash、zsh、fish、powershell)", packOption: "同时安装这些包中的技能(见 `skills-atlas packs`)", packsCmd: "列出技能包:一起安装的技能", diff --git a/src/mcp.ts b/src/mcp.ts index 915e21b..d3d3882 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -1,6 +1,6 @@ import { createInterface } from 'node:readline'; import { z } from 'zod'; -import { installedSkills, outdatedSkills, searchSkills, showSkill } from './agent-api.js'; +import { agentIds, installedSkills, outdatedSkills, searchSkills, showSkill } from './agent-api.js'; import { unknownAgents } from './agents.js'; import { auditSkills, auditTarget, failsAudit } from './audit.js'; import { updateDiff } from './install/diff.js'; @@ -9,6 +9,8 @@ import { nameCollisions } from './install/collisions.js'; import { formatCommand, parseSkillId, planInstall, planRemove } from './install/plan.js'; import { isSuccess, listInstalled, runAll } from './install/run.js'; import { riskReport, riskWhere } from './risks.js'; +import { describeInstalled, findOverlaps, OVERLAP_THRESHOLD } from './overlaps.js'; +import { scanUsage, transcriptsDir, usageReport } from './usage.js'; import { SCOPES, type Catalog, type Scope, type Skill } from './types.js'; /** @@ -76,6 +78,18 @@ const TOOLS = { inputSchema: { type: 'object', properties: { scope: { type: 'string', enum: ['project', 'global'], description: 'Default: both.' } } }, annotations: { readOnlyHint: true }, }, + unused_skills: { + description: 'List the installed skills Claude Code has not used in the last days, read from its session transcripts on this machine, as { since, transcripts_read, unused, used, too_recent, unknown }. too_recent were installed during the window; unknown are not installed for Claude Code, so nothing is known of them. With transcripts_read at 0 nothing is known at all. Propose removing the unused ones, and remove only what the user confirms.', + schema: z.object({ days: z.number().int().min(1).max(3650).optional() }), + inputSchema: { type: 'object', properties: { days: { type: 'integer', minimum: 1, description: 'How many days back to look. Default: 30.' } } }, + annotations: { readOnlyHint: true }, + }, + overlapping_skills: { + description: 'List pairs of installed skills, seen by a same agent, whose descriptions are close enough to be taken for one another, the closest first: score (0 to 1), the two skills with their descriptions, and the words they share. A word comparison, not a verdict: read both descriptions before advising to keep one or to reword them.', + schema: z.object({ threshold: z.number().gt(0).max(1).optional() }), + inputSchema: { type: 'object', properties: { threshold: { type: 'number', description: 'Similarity from which a pair is listed. Default: 0.5.' } } }, + annotations: { readOnlyHint: true }, + }, outdated_skills: { description: 'List global skills with a newer version on GitHub, as { skills, unchecked }: each skill with the lines the update changes in SKILL.md and the warning signs it adds (new_risks); unchecked lists the repositories that could not be checked (source, reason), so the check is complete only when it is empty. Tell the user about new_risks and unchecked before updating. Update one with install_skills, scope global, with its own agents.', schema: z.object({}), @@ -132,6 +146,22 @@ async function callTool(name: ToolName, args: unknown, opts: McpOptions, catalog return json(readPacks()); case 'list_installed': return json(await (opts.installed ?? installedSkills)(input.scope ? [input.scope as Scope] : SCOPES)); + case 'unused_skills': { + const since = new Date(Date.now() - (input.days ?? 30) * 86_400_000); + const scan = scanUsage(transcriptsDir(), since); + const report = usageReport((await Promise.all(SCOPES.map((sc) => listInstalled(sc)))).flat(), scan, since); + const ref = (s: { source?: string; name: string; scope: Scope }) => ({ id: `${s.source ?? 'local'}@${s.name}`, name: s.name, scope: s.scope }); + return json({ + since: since.toISOString().slice(0, 10), transcripts_read: scan.files, transcripts_skipped: scan.skipped, + unused: report.unused.map(ref), used: report.used.map((s) => ({ ...ref(s), count: s.count, last: s.last })), + too_recent: report.recent.map(ref), unknown: report.unknown.map(ref), + }); + } + case 'overlapping_skills': { + const found = findOverlaps(describeInstalled((await Promise.all(SCOPES.map((sc) => listInstalled(sc)))).flat()), input.threshold ?? OVERLAP_THRESHOLD); + const ref = (s: (typeof found)[number]['a']) => ({ id: `${s.source ?? 'local'}@${s.name}`, name: s.name, scope: s.scope, description: s.description }); + return json(found.map((o) => ({ score: o.score, a: ref(o.a), b: ref(o.b), shared: o.shared, agents: agentIds(o.agents) }))); + } case 'outdated_skills': { // Each update with what it changes: line counts and the warning signs the new version adds. const { skills: outdated, unchecked } = await outdatedSkills(); diff --git a/src/overlaps.ts b/src/overlaps.ts new file mode 100644 index 0000000..1fa75c8 --- /dev/null +++ b/src/overlaps.ts @@ -0,0 +1,105 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { frontmatterDescription } from './catalog/normalize.js'; +import type { InstalledSkill } from './install/run.js'; + +/** + * Installed skills an agent may take for one another. An agent picks a skill from its + * name and description alone: two skills that describe the same job compete for the same + * requests, and which one runs is not predictable. Descriptions are compared by their + * words, each weighed by how rare it is among the installed skills (TF-IDF, cosine): + * it points at pairs worth reading side by side, it does not prove they conflict. + */ + +/** Similarity from which two descriptions are reported, between 0 and 1. */ +export const OVERLAP_THRESHOLD = 0.5; + +const STOP = new Set( + ('a an and are as at be but by can do does for from has have how i if in into is it its may must no not of on or should so than that the their them then there these this those to up use used uses using user users want wants when whenever where which who why will with without you your also any all ask asks asked about after before even only such via etc skill skills task tasks need needs ' + + 'le la les de des du un une et ou a au aux pour dans que qui en est sur par avec sans ce cette ces son sa ses plus pas ne se').split(' '), +); + +/** The words of a text that say what it is about: lower case, plurals and common endings folded. */ +export function words(text: string): string[] { + return (text.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').match(/[a-z][a-z0-9+#]*/g) ?? []) + .filter((w) => w.length > 2 && !STOP.has(w)) + .map((w) => (w.length > 5 ? w.replace(/(ing|ed|es|s)$/, '') : w.length > 3 ? w.replace(/s$/, '') : w)); +} + +export interface Described { + name: string; + description: string; + scope: InstalledSkill['scope']; + source?: string; + agents: string[]; +} + +export interface Overlap { + a: Described; + b: Described; + /** 0 to 1, two decimals. */ + score: number; + /** The words that weigh most in the match. */ + shared: string[]; + /** The agents that see both skills. */ + agents: string[]; +} + +/** Pairs of skills seen by a same agent whose descriptions are close, the closest first. */ +export function findOverlaps(skills: Described[], threshold = OVERLAP_THRESHOLD): Overlap[] { + // The name is part of what the agent reads, and says the job in two or three words. + const bags = skills.map((s) => { + const counts = new Map(); + for (const w of words(`${s.name.replace(/[-_:]/g, ' ')} ${s.description}`)) counts.set(w, (counts.get(w) ?? 0) + 1); + return counts; + }); + const df = new Map(); + for (const bag of bags) for (const w of bag.keys()) df.set(w, (df.get(w) ?? 0) + 1); + const idf = (w: string) => Math.log((skills.length + 1) / ((df.get(w) ?? 0) + 1)) + 1; + const vectors = bags.map((bag) => { + const v = new Map(); + let norm = 0; + for (const [w, n] of bag) { + const x = (1 + Math.log(n)) * idf(w); + v.set(w, x); + norm += x * x; + } + return { v, norm: Math.sqrt(norm) }; + }); + const out: Overlap[] = []; + for (let i = 0; i < skills.length; i++) { + for (let j = i + 1; j < skills.length; j++) { + const agents = skills[i]!.agents.filter((a) => skills[j]!.agents.includes(a)); + const [x, y] = [vectors[i]!, vectors[j]!]; + if (!agents.length || !x.norm || !y.norm) continue; + const parts: [string, number][] = []; + let dot = 0; + for (const [w, value] of x.v) { + const other = y.v.get(w); + if (other === undefined) continue; + dot += value * other; + parts.push([w, value * other]); + } + const score = dot / (x.norm * y.norm); + if (score < threshold) continue; + out.push({ a: skills[i]!, b: skills[j]!, score: Math.round(score * 100) / 100, shared: parts.sort((p, q) => q[1] - p[1]).slice(0, 6).map(([w]) => w), agents }); + } + } + return out.sort((p, q) => q.score - p.score || p.a.name.localeCompare(q.a.name)); +} + +/** The installed skills with the description their SKILL.md gives; one without a readable description is left out. */ +export function describeInstalled(installed: InstalledSkill[], read: (path: string) => string = (p) => readFileSync(join(p, 'SKILL.md'), 'utf8')): Described[] { + const out: Described[] = []; + for (const s of installed) { + if (!s.path) continue; + let description: string | undefined; + try { + description = frontmatterDescription(read(s.path)); + } catch { + description = undefined; + } + if (description) out.push({ name: s.name, description, scope: s.scope, source: s.source, agents: s.agents }); + } + return out; +} diff --git a/src/usage.ts b/src/usage.ts new file mode 100644 index 0000000..d789ba2 --- /dev/null +++ b/src/usage.ts @@ -0,0 +1,195 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, join } from 'node:path'; +import type { InstalledSkill } from './install/run.js'; +import { frontmatterName } from './install/scan.js'; + +/** + * Which installed skills an agent actually used, read from its session transcripts on + * this machine. Only Claude Code keeps transcripts this can read (`~/.claude/projects`, + * one JSON line per message): a skill counts as used when the agent called the Skill + * tool with its name, when the user typed it as a slash command, or when the agent read + * its SKILL.md. Nothing leaves the machine, and nothing is kept of what is read. + */ + +/** The agent whose transcripts are read, as the scan of installed skills names it. */ +export const USAGE_AGENT = 'Claude Code'; + +/** Where Claude Code keeps its transcripts: `CLAUDE_CONFIG_DIR`, else `~/.claude`. */ +export const transcriptsDir = (env: NodeJS.ProcessEnv = process.env): string => join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects'); + +export interface SkillUse { + count: number; + /** Day of the last use (YYYY-MM-DD). */ + last: string; +} + +export interface UsageScan { + /** Transcripts read: those changed since `since`. */ + files: number; + /** Transcripts left unread: too large, or unreadable. */ + skipped: number; + /** Uses by skill name, lower case. */ + uses: Map; +} + +/** Transcripts above this size are not read: one buffer per file. */ +const MAX_BYTES = 1024 * 1024 * 1024; +/** What a line that may hold a use contains; the other lines are never parsed. */ +const MARKERS = ['"name":"Skill"', '', 'SKILL.md'].map((m) => Buffer.from(m)); +/** A line longer than this is a tool result or a request copy, not a call. */ +const MAX_LINE = 256 * 1024; + +/** `plugin:skill`, `apps/web:skill` and `/skill` all name `skill`. */ +const skillKey = (name: string): string => name.trim().replace(/^\//, '').split(':').pop()!.toLowerCase(); + +/** The skill names one transcript line uses. */ +export function usesInLine(line: string): string[] { + let entry: { type?: string; message?: { content?: unknown } }; + try { + entry = JSON.parse(line) as typeof entry; + } catch { + return []; + } + const content = entry.message?.content; + const out: string[] = []; + const command = (text: string) => { + for (const m of text.matchAll(/\/?([^<\s]+)<\/command-name>/g)) out.push(skillKey(m[1]!)); + }; + if (entry.type === 'user' && typeof content === 'string') command(content); + if (!Array.isArray(content)) return out; + for (const item of content as { type?: string; name?: string; text?: string; input?: { skill?: unknown; file_path?: unknown } }[]) { + if (entry.type === 'user' && item.type === 'text' && typeof item.text === 'string') command(item.text); + if (entry.type !== 'assistant' || item.type !== 'tool_use') continue; + if (item.name === 'Skill' && typeof item.input?.skill === 'string') out.push(skillKey(item.input.skill)); + // Reading a skill's SKILL.md is using it too: `…/skills//SKILL.md`. + const read = item.name === 'Read' && typeof item.input?.file_path === 'string' ? /([^\\/]+)[\\/]SKILL\.md$/i.exec(item.input.file_path) : null; + if (read) out.push(read[1]!.toLowerCase()); + } + return out; +} + +/** The lines of a transcript that hold a marker, without decoding the others. */ +function* candidateLines(buf: Buffer): Generator { + let from = 0; + // Each marker's next position is kept: searching again from every line would read the file once per hit. + const next = MARKERS.map((m) => buf.indexOf(m)); + while (from < buf.length) { + let hit = -1; + MARKERS.forEach((m, k) => { + if (next[k]! >= 0 && next[k]! < from) next[k] = buf.indexOf(m, from); + if (next[k]! >= 0 && (hit < 0 || next[k]! < hit)) hit = next[k]!; + }); + if (hit < 0) return; + const start = buf.lastIndexOf(10, hit) + 1; + let end = buf.indexOf(10, hit); + if (end < 0) end = buf.length; + if (end - start <= MAX_LINE) yield buf.toString('utf8', start, end); + from = end + 1; + } +} + +function transcripts(dir: string, since: number, out: string[] = []): string[] { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + const path = join(dir, e.name); + if (e.isDirectory()) transcripts(path, since, out); + else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(path); + } + return out; +} + +/** Reads the transcripts changed since `since` and counts the uses dated since then. */ +export function scanUsage(dir: string, since: Date): UsageScan { + const scan: UsageScan = { files: 0, skipped: 0, uses: new Map() }; + const from = since.toISOString(); + for (const path of transcripts(dir, since.getTime())) { + let buf: Buffer; + try { + const stat = statSync(path); + if (stat.mtimeMs < since.getTime()) continue; + if (stat.size > MAX_BYTES) { + scan.skipped++; + continue; + } + buf = readFileSync(path); + } catch { + scan.skipped++; + continue; + } + scan.files++; + for (const line of candidateLines(buf)) { + // The timestamp is read from the text: parsing waits until the line is known to be in the window. + const at = /"timestamp":"([^"]+)"/.exec(line)?.[1]; + if (!at || at < from) continue; + for (const name of usesInLine(line)) { + const use = scan.uses.get(name) ?? { count: 0, last: '' }; + use.count++; + if (at.slice(0, 10) > use.last) use.last = at.slice(0, 10); + scan.uses.set(name, use); + } + } + } + return scan; +} + +export interface UsageReport { + /** Seen by the agent for the whole window and never used in it. */ + unused: InstalledSkill[]; + /** Used in the window, most used first. */ + used: (InstalledSkill & SkillUse)[]; + /** Installed during the window and not used yet: too early to tell. */ + recent: InstalledSkill[]; + /** Not installed for the agent whose transcripts are read: nothing is known. */ + unknown: InstalledSkill[]; +} + +/** When a skill folder appeared; a reinstall (an update) starts it again. */ +function installedAt(path: string): number | undefined { + try { + const stat = statSync(path); + return stat.birthtimeMs || stat.mtimeMs; + } catch { + return undefined; + } +} + +/** The names a skill can be called by: the one the scan gives, its folder's, and its SKILL.md's. */ +function namesOf(s: InstalledSkill): string[] { + const names = [s.name]; + if (!s.path) return names; + names.push(basename(s.path)); + try { + const own = frontmatterName(readFileSync(join(s.path, 'SKILL.md'), 'utf8')); + if (own) names.push(own); + } catch { + // An unreadable SKILL.md: the other names still count. + } + return names; +} + +/** Sorts the installed skills by what the transcripts say of them. */ +export function usageReport( + installed: InstalledSkill[], + scan: UsageScan, + since: Date, + born: (path: string) => number | undefined = installedAt, + names: (s: InstalledSkill) => string[] = namesOf, +): UsageReport { + const report: UsageReport = { unused: [], used: [], recent: [], unknown: [] }; + for (const s of installed) { + const found = names(s).map((n) => scan.uses.get(n.toLowerCase())).filter((u) => u !== undefined); + const use = found.length ? { count: found.reduce((n, u) => Math.max(n, u.count), 0), last: found.map((u) => u.last).sort().at(-1)! } : undefined; + if (!s.agents.includes(USAGE_AGENT)) report.unknown.push(s); + else if (use) report.used.push({ ...s, ...use }); + else if (((s.path ? born(s.path) : undefined) ?? 0) > since.getTime()) report.recent.push(s); + else report.unused.push(s); + } + report.used.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + return report; +} diff --git a/test/mcp.test.ts b/test/mcp.test.ts index b78cd84..4a247af 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -39,7 +39,7 @@ describe('MCP server', () => { expect(init.result).toMatchObject({ protocolVersion: '2025-06-18', serverInfo: { name: 'skills-atlas', version: '9.9.9' }, capabilities: { tools: {} } }); const list = (await handle({ jsonrpc: '2.0', id: 2, method: 'tools/list' })) as any; expect(list.result.tools.map((t: { name: string }) => t.name)).toEqual( - ['search_skills', 'list_themes', 'show_skill', 'list_packs', 'list_installed', 'outdated_skills', 'install_skills', 'remove_skills'], + ['search_skills', 'list_themes', 'show_skill', 'list_packs', 'list_installed', 'unused_skills', 'overlapping_skills', 'outdated_skills', 'install_skills', 'remove_skills'], ); // Notifications get no answer; unknown methods a JSON-RPC error. expect(await handle({ jsonrpc: '2.0', method: 'notifications/initialized' })).toBeUndefined(); diff --git a/test/usage-overlaps.test.ts b/test/usage-overlaps.test.ts new file mode 100644 index 0000000..b2f47f1 --- /dev/null +++ b/test/usage-overlaps.test.ts @@ -0,0 +1,96 @@ +import { mkdirSync, mkdtempSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { describeInstalled, findOverlaps, words } from '../src/overlaps.js'; +import { scanUsage, transcriptsDir, usageReport, usesInLine } from '../src/usage.js'; + +const line = (type: string, timestamp: string, content: unknown) => JSON.stringify({ type, timestamp, message: { content } }); +const skillCall = (skill: string) => [{ type: 'tool_use', name: 'Skill', input: { skill } }]; + +describe('skill usage in transcripts', () => { + it('reads a Skill call, a slash command and a SKILL.md read, whatever prefix names the skill', () => { + expect(usesInLine(line('assistant', 't', skillCall('claude-seo:seo-audit')))).toEqual(['seo-audit']); + expect(usesInLine(line('user', 't', '/brainstorming '))).toEqual(['brainstorming']); + expect(usesInLine(line('user', 't', [{ type: 'text', text: '/Report' }]))).toEqual(['report']); + expect(usesInLine(line('assistant', 't', [{ type: 'tool_use', name: 'Read', input: { file_path: 'C:\\Users\\me\\.claude\\skills\\pdf\\SKILL.md' } }]))).toEqual(['pdf']); + }); + + it('ignores what only mentions a skill: a tool result, an assistant quoting a command, a broken line', () => { + expect(usesInLine(line('user', 't', [{ type: 'tool_result', content: '"name":"Skill" docs/SKILL.md' }]))).toEqual([]); + expect(usesInLine(line('assistant', 't', [{ type: 'text', text: '/pdf' }]))).toEqual([]); + expect(usesInLine(line('assistant', 't', [{ type: 'tool_use', name: 'Read', input: { file_path: 'docs/SKILL.md.bak' } }]))).toEqual([]); + expect(usesInLine('{"type":"assistant","name":"Skill"')).toEqual([]); + }); + + const transcripts = () => { + const dir = mkdtempSync(join(tmpdir(), 'atlas-usage-')); + mkdirSync(join(dir, 'proj', 'session', 'subagents'), { recursive: true }); + writeFileSync(join(dir, 'proj', 'a.jsonl'), [ + line('assistant', '2026-11-01T10:00:00.000Z', skillCall('pdf')), + line('assistant', '2026-11-05T10:00:00.000Z', skillCall('pdf')), + line('assistant', '2026-09-01T10:00:00.000Z', skillCall('old-one')), + line('user', '2026-11-02T10:00:00.000Z', 'plain text'), + // A copy of the request, without a date: the same call must not count twice. + JSON.stringify({ type: 'api-request-blob', message: { content: skillCall('pdf') } }), + ].join('\n')); + writeFileSync(join(dir, 'proj', 'session', 'subagents', 'b.jsonl'), `${line('assistant', '2026-11-03T10:00:00.000Z', skillCall('plugin:docx'))}\n`); + writeFileSync(join(dir, 'proj', 'stale.jsonl'), line('assistant', '2026-11-04T10:00:00.000Z', skillCall('never-read'))); + utimesSync(join(dir, 'proj', 'stale.jsonl'), new Date('2026-01-01'), new Date('2026-01-01')); + writeFileSync(join(dir, 'proj', 'notes.txt'), line('assistant', '2026-11-04T10:00:00.000Z', skillCall('not-a-transcript'))); + return dir; + }; + const since = new Date('2026-10-15T00:00:00.000Z'); + + it('counts the uses dated in the window, in transcripts changed in it, subagents included', () => { + const scan = scanUsage(transcripts(), since); + expect(scan.files).toBe(2); + expect([...scan.uses]).toEqual([['pdf', { count: 2, last: '2026-11-05' }], ['docx', { count: 1, last: '2026-11-03' }]]); + expect(scanUsage(join(tmpdir(), 'no-such-transcripts'), since)).toEqual({ files: 0, skipped: 0, uses: new Map() }); + expect(transcriptsDir({ CLAUDE_CONFIG_DIR: join('x', 'y') })).toBe(join('x', 'y', 'projects')); + }); + + it('sorts installed skills into used, unused, too recent and unknown', () => { + const skill = (name: string, agents: string[], path = name) => ({ name, scope: 'global' as const, agents, path }); + const installed = [skill('pdf', ['Claude Code']), skill('xlsx', ['Claude Code']), skill('fresh', ['Claude Code']), skill('codex-only', ['Codex']), skill('renamed', ['Claude Code'], 'docx')]; + const born = (path: string) => (path === 'fresh' ? since.getTime() + 1 : since.getTime() - 1); + const report = usageReport(installed, scanUsage(transcripts(), since), since, born, (s) => [s.name, s.path!]); + expect(report.used.map((s) => [s.name, s.count, s.last])).toEqual([['pdf', 2, '2026-11-05'], ['renamed', 1, '2026-11-03']]); + expect(report.unused.map((s) => s.name)).toEqual(['xlsx']); + expect(report.recent.map((s) => s.name)).toEqual(['fresh']); + expect(report.unknown.map((s) => s.name)).toEqual(['codex-only']); + }); +}); + +describe('overlapping descriptions', () => { + const skill = (name: string, description: string, agents = ['Claude Code']) => ({ name, description, scope: 'global' as const, agents }); + const installed = [ + skill('review', 'Review code changes for bugs and security issues before merging a pull request.'), + skill('review-bugbot', 'Review the code changes of a pull request for bugs before merging.'), + skill('pdf', 'Read, fill and merge PDF forms and documents.'), + skill('xlsx', 'Create and edit spreadsheets with formulas and charts.'), + skill('review-codex', 'Review code changes for bugs and security issues before merging a pull request.', ['Codex']), + ]; + + it('keeps the words that say what a description is about', () => { + expect(words('Use this skill when the user wants to review Pull-Requests, reviewing changes.')).toEqual(['review', 'pull', 'request', 'review', 'chang']); + expect(words('Crée des rapports d’audit')).toEqual(['cree', 'rapport', 'audit']); + }); + + it('reports the pairs a same agent sees with close descriptions, the closest first, and the words they share', () => { + const found = findOverlaps(installed); + expect(found.map((o) => [o.a.name, o.b.name])).toEqual([['review', 'review-bugbot']]); + expect(found[0]!.score).toBeGreaterThan(0.5); + expect(found[0]!.shared).toEqual(expect.arrayContaining(['review', 'bug'])); + expect(found[0]!.agents).toEqual(['Claude Code']); + expect(findOverlaps(installed, 0.99)).toEqual([]); + expect(findOverlaps([])).toEqual([]); + }); + + it('reads each description from its SKILL.md and leaves out a skill without one', () => { + const files: Record = { a: '---\nname: a\ndescription: Fill PDF forms.\n---\nbody', b: 'no frontmatter' }; + const read = (path: string) => { if (!(path in files)) throw new Error('gone'); return files[path]!; }; + const all = ['a', 'b', 'c'].map((name) => ({ name, scope: 'project' as const, agents: ['Codex'], path: name })); + expect(describeInstalled([...all, { name: 'd', scope: 'project', agents: [] }], read)).toEqual([{ name: 'a', description: 'Fill PDF forms.', scope: 'project', source: undefined, agents: ['Codex'] }]); + }); +});