Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## 0.40.0 — 2026-11-10

Validation: 286 tests and TypeScript checks.
Validation: 323 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`.
Expand All @@ -13,6 +13,17 @@ Validation: 286 tests and TypeScript checks.
- **Secret keys written in a skill are reported** (medium), cut to their first characters; placeholders such as `sk-xxxxxxxx` are not.
- **Look-alike names.** The audit, `show` and the web preview name the far more installed skill a skill could be taken for: a name, or an owner's name, one character away.
- **The update diff covers the whole folder.** The files an update adds, changes or removes are listed and shown next to the `SKILL.md`, and their new warning signs are reported with their file; `outdated_skills` returns them as `files_changed`.
- **Project skills get updates too.** `outdated`, `update` and `u` in the tree check the project's skills as well as global ones: the installed files are compared blob by blob with the repository's HEAD. A copy edited by hand (its files no longer match the lock's `computedHash`) is reported as modified and never overwritten; a pinned one is left to `lock`. `-p`/`-g` limit either command to one scope; `outdated --json` entries carry their `scope`.
- **`skills-atlas suggest`** finds what a project is built with (manifests, workspaces and telltale files, about 45 technologies) and suggests catalog skills for each, publisher's own first, without what is installed; `--json` and `-n` available.
- **💡 For this project** opens the tree and the web page when the current directory uses known technologies: the suggested skills, in rank order, each tagged with its technology.
- **MCP:** new `suggest_skills`, `update_skills`, `rollback_skill`, `doctor` and `sync_project` tools, audited like installs; `install_skills` takes `packs`; `outdated_skills` covers both scopes and returns `modified`.
- **Context cost.** The preview, the web page's Details, `show` and MCP `show_skill` estimate what a skill costs in context: its description at every session start, its `SKILL.md` when used. `installed --size` adds each skill's tokens and size on disk and each agent's startup load; an agent loading more than 5,000 tokens of descriptions is flagged there and above the tree.
- **Profiles.** `skills-atlas profile use <pack>` (and **Use this pack as a profile…** on the web page) keeps only a pack's skills in a scope: the others, home-made ones included, are removed after the whole scope is saved byte for byte. `profile restore` (and **Backups** on the web page) puts a scope back exactly, offline, saving the current state first; `--initial` returns to the configuration before any profile. `profile create`, `list` and `backups` manage the rest.
- **Manage personal packs everywhere.** In the tree, `N` saves the ticked skills as a pack (name and description asked), `E` edits a personal pack by ticking its skills, `D` deletes it, `P` activates any pack as a profile and `B` restores a backup. `profile edit` and `profile delete` do the same on the command line; the web page gets **Edit this pack…** (name, description, skills) and **Delete this pack**, and its Save as pack fills the id from the name. Shipped packs stay read-only; backups are never touched by pack changes.
- **Shipped packs always show.** `packs.json` now holds only personal packs, added after the shipped ones; it no longer replaces them, and `[]` no longer hides them. Copies of shipped packs an earlier version wrote there are ignored.
- **Tree: warnings on their own page.** Heavy context loads and incomplete update checks no longer stack above the tree: the status line shows `⚠ N`, `!` lists them, Esc goes back. The logo now shows on every screen when the terminal is tall enough, and every list sizes itself under it.
- **Fix:** the questions the tree's `N`, `E`, `P` and `B` keys ask (pack name, backup number…) no longer end the program with *Detected unsettled top-level await*: stdin is held while a question waits.
- **Packs, profiles and backups without leaving the tree.** `N`, `E`, `D`, `P` and `B` open the tree's own windows (name and description, confirmation, backups list, profile scope and plan); the pack themes update in place, a restore reads what is installed again, and only a confirmed profile leaves the tree to install, like any change. Warnings are spaced, and agents with the same load share one line.

## 0.30.0 — 2026-10-23

Expand Down
57 changes: 50 additions & 7 deletions README.md

Large diffs are not rendered by default.

81 changes: 69 additions & 12 deletions src/agent-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import { auditSkills, withLookalikes } from './audit.js';
import { fetchSkillFiles, fetchSkillMarkdown, githubUrl } from './github.js';
import { parseSkillId } from './install/plan.js';
import { listInstalled } from './install/run.js';
import { findUpdates, type UpdateCheck } from './install/updates.js';
import { checkUpdates, type UpdateCheck } from './install/updates.js';
import { folderCost, markdownCost, type SkillCost } from './context.js';
import { projectRevisionDrift } from './revisions.js';
import type { Risk } from './risks.js';
import type { Catalog, Scope, Skill } from './types.js';
import { missingSkills, missingSkillsByAgent } from './selection.js';
import { detectStack, suggestSkills } from './suggest.js';
import { SCOPES, type Catalog, type Scope, type Skill } from './types.js';

/**
* What an agent (the CLI's non-interactive commands, the MCP server) can ask
Expand Down Expand Up @@ -61,12 +65,12 @@ export function findSkill(catalog: Catalog, id: string): Skill {
}

/** A skill's SKILL.md, from its GitHub repository (escape sequences removed), and the warning signs of its folder. */
export async function showSkill(catalog: Catalog, id: string): Promise<{ skill: SkillSummary; markdown: string; risks: Risk[] }> {
export async function showSkill(catalog: Catalog, id: string): Promise<{ skill: SkillSummary; markdown: string; risks: Risk[]; cost: SkillCost }> {
const skill = findSkill(catalog, id);
const markdown = await fetchSkillMarkdown(skill);
// The audit's view: the other files of the folder too, the SKILL.md read once.
const [audit] = withLookalikes(await auditSkills([skill], async () => markdown, fetchSkillFiles), catalog.skills);
return { skill: summarize(skill), markdown, risks: audit!.risks };
return { skill: summarize(skill), markdown, risks: audit!.risks, cost: markdownCost(markdown, skill.name) };
}

/** An installed skill, with the agent ids an install command needs. */
Expand All @@ -76,11 +80,16 @@ export interface InstalledSummary {
source?: string;
scope: Scope;
agents: string[];
/** Estimated context cost, when asked for: tokens at startup and when used, files and bytes on disk. */
cost?: SkillCost;
}

export async function installedSkills(scopes: Scope[]): Promise<InstalledSummary[]> {
export async function installedSkills(scopes: Scope[], opts: { cost?: boolean } = {}): Promise<InstalledSummary[]> {
const found = (await Promise.all(scopes.map((sc) => listInstalled(sc)))).flat();
return found.map((s) => ({ id: `${s.source ?? 'local'}@${s.name}`, name: s.name, source: s.source, scope: s.scope, agents: agentIds(s.agents) }));
return found.map((s) => {
const cost = opts.cost && s.path ? folderCost(s.path, s.name) : undefined;
return { id: `${s.source ?? 'local'}@${s.name}`, name: s.name, source: s.source, scope: s.scope, agents: agentIds(s.agents), ...(cost ? { cost } : {}) };
});
}

/**
Expand All @@ -96,11 +105,59 @@ export function skillsByAgent(installed: InstalledSummary[]): { agent: string; n
}

/**
* Global skills with a newer version on GitHub. Updating one is installing it
* again: `skills-atlas install <id> -g -a <its agents> -y`. `unchecked` lists what
* could not be checked: the check is complete only when it is empty.
* Installed skills with a newer version on GitHub, in the project and global
* scopes (or the ones asked). Updating one is installing it again in its scope:
* `skills-atlas install <id> -g|-p -a <its agents> -y`. `unchecked` lists what
* could not be checked: the check is complete only when it is empty. `modified`
* names the project skills edited by hand, which are left out of the updates.
*/
export async function outdatedSkills(): Promise<{ skills: InstalledSummary[]; unchecked: UpdateCheck['unchecked'] }> {
const [found, installed] = await Promise.all([findUpdates(), installedSkills(['global'])]);
return { skills: installed.filter((s) => s.source && found.outdated.has(`${s.source}@${s.name}`.toLowerCase())), unchecked: found.unchecked };
export async function outdatedSkills(scopes: Scope[] = ['project', 'global']): Promise<{ skills: InstalledSummary[]; unchecked: UpdateCheck['unchecked']; modified: string[] }> {
const [found, installed] = await Promise.all([checkUpdates(scopes), installedSkills(scopes)]);
return {
skills: installed.filter((s) => s.source && found.outdated[s.scope].has(`${s.source}@${s.name}`.toLowerCase())),
unchecked: found.unchecked,
modified: found.modified,
};
}

/** A technology found in the project and the skills suggested for it, as an agent reads them. */
export interface StackSummary {
id: string;
label: string;
evidence: string[];
skills: SkillSummary[];
}

/**
* Skills that fit the project in `dir`: what its manifests and files say it uses,
* then catalog skills for each of those, without the ones installed there or globally.
* Reads files only; nothing is installed or sent anywhere.
*/
export async function suggestForProject(catalog: Catalog, dir = process.cwd(), opts: { perStack?: number } = {}): Promise<{ stack: StackSummary[]; packs: string[] }> {
const installed = (await Promise.all(SCOPES.map((sc) => listInstalled(sc, dir)))).flat();
const found = suggestSkills(catalog, detectStack(dir), { installed: installed.map((s) => `${s.source ?? 'local'}@${s.name}`), perStack: opts.perStack });
return { stack: found.stack.map((s) => ({ id: s.id, label: s.label, evidence: s.evidence, skills: s.skills.map(summarize) })), packs: found.packs };
}

/** What `sync` would install: groups of ids, each for its agents (all of the file's agents, or project-wide presence). */
export interface SyncPlan {
ids: string[];
agents?: string[];
revisions?: Record<string, string>;
groups: { agents: string[] | undefined; ids: string[] }[];
}

/**
* The skills of a project's `skills-atlas.json` that are missing in the project: per
* target agent when agents are given (or the file names some), else by presence. Pinned
* skills whose installed files drifted from their revision are reinstalled too.
*/
export async function projectSyncPlan(file: { ids: string[]; agents?: string[]; revisions?: Record<string, string> }, targets: string[] | undefined, cwd = process.cwd()): Promise<SyncPlan> {
const installed = await listInstalled('project', cwd);
const groups: SyncPlan['groups'] = targets
? missingSkillsByAgent(file.ids, installed, targets)
: [{ agents: undefined, ids: missingSkills(file.ids, installed.map((s) => `${s.source ?? 'local'}@${s.name}`)) }];
const drift = (await projectRevisionDrift(file.ids, file.revisions ?? {}, installed, cwd)).filter((id) => !groups.some((g) => g.ids.includes(id)));
if (drift.length) groups.push({ agents: targets, ids: drift });
return { ids: file.ids, agents: targets, revisions: file.revisions, groups: groups.filter((g) => g.ids.length) };
}
6 changes: 5 additions & 1 deletion src/catalog/installed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface InstalledEntry {
scopes: Scope[];
/** Display names of the agents it is installed for, all scopes together. */
agents: string[];
/** The same, scope by scope: an update reinstalls in one scope for that scope's agents. */
agentsByScope?: Partial<Record<Scope, string[]>>;
}

export interface TreeData {
Expand Down Expand Up @@ -47,9 +49,11 @@ export function mergeInstalled(catalogSkills: Skill[], visible: Skill[], install
if (entry) {
if (!entry.scopes.includes(inst.scope)) entry.scopes.push(inst.scope);
for (const a of inst.agents) if (!entry.agents.includes(a)) entry.agents.push(a);
const scoped = (entry.agentsByScope ??= {});
scoped[inst.scope] = [...new Set([...(scoped[inst.scope] ?? []), ...inst.agents])];
continue;
}
entries.set(base.id, { name: inst.name, scopes: [inst.scope], agents: [...inst.agents] });
entries.set(base.id, { name: inst.name, scopes: [inst.scope], agents: [...inst.agents], agentsByScope: { [inst.scope]: [...inst.agents] } });
shown.set(base.id, { ...base, themes: [INSTALLED_THEME.id, ...base.themes] });
}
for (const e of entries.values()) e.scopes.sort((a, b) => (a === 'project' ? -1 : b === 'project' ? 1 : 0));
Expand Down
6 changes: 3 additions & 3 deletions src/catalog/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function cleanDescription(text: string | null | undefined, max = 400): st
* Reads `description` from a SKILL.md frontmatter, including the folded (`>`)
* and literal (`|`) block forms. Not a full YAML parser.
*/
export function frontmatterDescription(markdown: string): string | undefined {
export function frontmatterDescription(markdown: string, max = 400): string | undefined {
const fm = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(markdown)?.[1];
if (!fm) return undefined;
const lines = fm.split(/\r?\n/);
Expand All @@ -79,12 +79,12 @@ export function frontmatterDescription(markdown: string): string | undefined {
const inline = lines[i]!.replace(/^description\s*:\s*/, '').trim();
if (inline && !BROKEN.test(inline)) {
const unquoted = /^(["'])([\s\S]*)\1$/.exec(inline);
return cleanDescription(unquoted ? unquoted[2]!.replace(/\\"/g, '"').replace(/''/g, "'") : inline);
return cleanDescription(unquoted ? unquoted[2]!.replace(/\\"/g, '"').replace(/''/g, "'") : inline, max);
}
const block: string[] = [];
for (const line of lines.slice(i + 1)) {
if (line.trim() && !/^\s/.test(line)) break;
block.push(line.trim());
}
return cleanDescription(block.join(' '));
return cleanDescription(block.join(' '), max);
}
Loading
Loading