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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "skills-atlas",
"version": "0.20.0",
"version": "0.30.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",
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: ci

# Types, tests and build on every pull request, whatever it touches (a catalog refresh too).
on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npm run build
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

## Unreleased

## 0.30.0 — 2026-10-23

- **Update checks say when they are incomplete.** A repository GitHub does not answer for (HTTP 403 rate limit, timeout, invalid answer), a truncated tree or a skill folder it no longer has is reported instead of read as up to date: the tree shows a warning, `outdated` and `update` name each on stderr and exit with 1, and `update` still runs the updates it confirmed. `outdated --json` keeps its array on stdout. A skill installed at the repository root is now checked too.
- **MCP `outdated_skills` returns `{ skills, unchecked }`** instead of an array: the skills as before, with their diff, and the repositories that could not be checked. Agents reading the old array must read `skills`.
- **Rollback records only a proven version.** The commit kept before an update must have the lock's folder hash: a commit found by date alone is checked against GitHub, and after `rollback` the exact commit is remembered (`revisions.json`, next to `history.json`) while the lock keeps that hash. A rollback to an old commit followed by an update now goes back to that commit, not to the latest one on the branch. When no version can be proven, `update` says the skill has no way back.
- **Skill names are checked wherever an id is read.** `install o/r@--global` and every other entry point (selection files, packs, MCP, the web page, removals) refuse a name that is empty, starts with `-`, holds a quote or a control character, or is longer than 200 characters, before any command is built.
- **MCP installs and removals run one at a time**, each re-checking what is installed, so two calls cannot write the lock together or miss each other's name collisions. Reads and dry runs do not wait.
- **Catalogs are checked when they load.** A snapshot with missing or mistyped fields is rejected with the field's path; a bad download never replaces a valid cache. Entries `install` would refuse (59 in the current snapshot, such as names starting with `-`) are left out. Older snapshots and unknown fields still load.
- **An empty crawl fails** instead of replacing the snapshot with zero skills.
- **Web page:** a failed "Show more" keeps the rows already loaded and shows the error under them; the next try loads from the same place. After a change in the side panel, the focus stays on the same agent, scope or method control (or the panel's first control when it went away).
- **CI:** types, tests and build run on every pull request.

Validation: 268 tests and TypeScript checks.

## 0.20.0 — 2026-10-21

- **The audit reads the whole skill folder.** Besides the `SKILL.md`, the skill's other text files (scripts, references, configs) are checked: a clean `SKILL.md` that runs a `curl | sh` script now fails. Findings name their file (`scripts/setup.sh:12`) on the review screen, with `install -y`, `show`, the web page and MCP. Binaries, large files and unreadable ones are reported as not audited.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,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`, `install_skills` (with `dry_run`; it refuses to replace a skill of the same name unless `replace: true`) and `remove_skills`. 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`, `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
Expand Down Expand Up @@ -258,7 +258,7 @@ Only `repository` (a GitHub repository URL) and `reason` are required. Without `
- **Pre-ticked**: installed skills are ticked and marked with their scope, `● project`, `● global` or `● project + global`, under their theme and in an **Already installed** group at the top. That group also holds skills the catalog does not know, such as home-made ones.
- **Uninstall**: unticking an installed skill marks it `✗ will be uninstalled`. On confirmation it is removed from every agent of every scope it was found in, with one `npx skills remove -s … -y [-g]` per scope, then checked on disk.
- **Same name, one folder**: the skills CLI installs by name, so a skill from another repository replaces an installed one of the same name. The review screen lists such replacements before anything runs; `install` and `import` print them.
- **Updates**: globally installed skills with a newer version on GitHub read `↑ update available`, and the status line counts them. Press `u` to mark them: they are reinstalled at their latest version for the agents that already have them. The check runs in the background, one GitHub request per repository, with `GITHUB_TOKEN`, `GH_TOKEN` or the GitHub CLI's token when there is one (without a token GitHub allows 60 requests an hour). Project skills are not checked: their lock records a hash GitHub cannot be compared with. Before you update, `p` on a skill marked for update shows what changes in its `SKILL.md` (added lines green, removed red) and only the warning signs the new version brings. `skills-atlas update` prints each skill's `+added −removed` lines and those new warning signs before running anything, `--dry-run` included; the MCP server's `outdated_skills` returns them as `lines_added`, `lines_removed` and `new_risks`. Before each update, skills-atlas records the commit each skill was installed from (the last one touching its folder before the lock's install date, in `~/.config/skills-atlas/history.json`, five per skill). `skills-atlas rollback <id>` reinstalls the latest of them for the skill's own agents, audited at that revision; `--list` shows them and `--to <sha>` picks one. Skills updated before this version have no way back.
- **Updates**: globally installed skills with a newer version on GitHub read `↑ update available`, and the status line counts them. Press `u` to mark them: they are reinstalled at their latest version for the agents that already have them. The check runs in the background, one GitHub request per repository, with `GITHUB_TOKEN`, `GH_TOKEN` or the GitHub CLI's token when there is one (without a token GitHub allows 60 requests an hour). Project skills are not checked: their lock records a hash GitHub cannot be compared with. A repository GitHub does not answer for (rate limit, timeout, private or deleted), a truncated tree or a skill folder it no longer has makes the check incomplete, never "up to date": the tree shows a warning, and `outdated` and `update` name each one on stderr and exit with 1 (`outdated --json` keeps its array on stdout; `update` still runs the confirmed updates). Before you update, `p` on a skill marked for update shows what changes in its `SKILL.md` (added lines green, removed red) and only the warning signs the new version brings. `skills-atlas update` prints each skill's `+added −removed` lines and those new warning signs before running anything, `--dry-run` included; the MCP server's `outdated_skills` returns `{ skills, unchecked }`, each skill with `lines_added`, `lines_removed` and `new_risks`, and `unchecked` listing what could not be checked (`source`, `reason`). Before each update, skills-atlas records the commit each skill was installed from, in `~/.config/skills-atlas/history.json`, five per skill, but only a commit it can prove: one a rollback installed while the lock still records the same folder hash (kept in `revisions.json` next to it), the installer's own ref, or the last commit touching the folder before the lock's install date whose folder hash on GitHub matches the lock's. A version that cannot be proven (offline, a folder hash that matches no such commit) is not recorded, and `update` says that skill has no way back. `skills-atlas rollback <id>` reinstalls the latest of them for the skill's own agents, audited at that revision; `--list` shows them and `--to <sha>` picks one. Skills updated before this version have no way back.
- **Install**: ticking a skill that is not installed installs it. The scope is asked after the tree, pre-filled with your last choice, unless `-p`/`-g` sets it.
- **Questions**: the status line shows the balance `+N −M`. Scope, agents and method are only asked when there is something to install.

Expand Down Expand Up @@ -334,7 +334,7 @@ In a terminal, progress is shown uv-style: one line per task (uninstall, then on

## The catalog

`data/catalog.json.gz` is rebuilt every other day by GitHub Actions (`npm run crawl`). The CLI downloads that snapshot from GitHub, keeps it in `~/.cache/skills-atlas/` (or `$XDG_CACHE_HOME/skills-atlas/`) and checks for a new one at most every 12 hours, with an ETag, so an unchanged catalog is not downloaded again. It uses the snapshot bundled in the npm package when that one is newer, when GitHub cannot be reached (it then waits an hour before trying again), or with `--offline`. `--refresh` skips the 12-hour wait; failing that, deleting `~/.cache/skills-atlas/` has the same effect on older versions. Changes to the catalog format must stay readable by released versions, which download it too.
`data/catalog.json.gz` is rebuilt every other day by GitHub Actions (`npm run crawl`). The CLI downloads that snapshot from GitHub, keeps it in `~/.cache/skills-atlas/` (or `$XDG_CACHE_HOME/skills-atlas/`) and checks for a new one at most every 12 hours, with an ETag, so an unchanged catalog is not downloaded again. It uses the snapshot bundled in the npm package when that one is newer, when GitHub cannot be reached (it then waits an hour before trying again), or with `--offline`. `--refresh` skips the 12-hour wait; failing that, deleting `~/.cache/skills-atlas/` has the same effect on older versions. Changes to the catalog format must stay readable by released versions, which download it too. A catalog is checked when it loads: a download with the wrong shape is rejected before it reaches the cache, and the cache or the bundled snapshot is used instead; entries `install` would refuse (a name starting with `-`, for one) are left out. A crawl that finds no skill fails without replacing the snapshot.

The snapshot is built from:

Expand Down
2 changes: 1 addition & 1 deletion commands/skills-atlas.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Find Agent Skills for: $ARGUMENTS

Use the skills-atlas MCP tools (or the skills-atlas skill's commands when the tools are not available):

1. Search with `search_skills`, trying two or three wordings of the need. If nothing was asked, call `outdated_skills` and `list_installed` and report what is installed and what can be updated.
1. Search with `search_skills`, trying two or three wordings of the need. If nothing was asked, call `outdated_skills` and `list_installed` and report what is installed and what can be updated. `outdated_skills` returns `{ skills, unchecked }`: when `unchecked` is not empty, say which repositories could not be checked instead of calling the rest up to date.
2. Keep the two or three best matches: what the description says it does for this need first, then installs, then whether it is official. Read each one with `show_skill` and say in one line what it does, and anything it runs or reaches beyond its description.
3. Ask which to install, for which agents (default: claude-code) and in which scope (this project or global). Install only after a yes, with `install_skills`.

Expand Down
40 changes: 36 additions & 4 deletions docs/demo/index.html

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "skills-atlas",
"version": "0.20.0",
"version": "0.30.0",
"description": "Browse every public Agent Skill grouped by theme, multi-select and install them through `npx skills add`.",
"type": "module",
"bin": {
Expand Down
2 changes: 2 additions & 0 deletions skills/skills-atlas/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Updates (global skills only):
npx -y skills-atlas@latest outdated --json # [{ id, name, source, scope, agents }]
```

Exit code 1 means the check is incomplete: the repositories it could not check are named on stderr, and their skills may be outdated too. Tell the user rather than saying everything is up to date.

To update one, install it again with its own agents: `install <id> -g -a <its agents> -y`. Skills installed for the same agents can share one command.

There is no non-interactive uninstall in skills-atlas: use `npx -y skills remove -s <name> -y` (add `-g` for a global skill).
Expand Down
9 changes: 5 additions & 4 deletions src/agent-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { auditSkills } from './audit.js';
import { fetchSkillFiles, fetchSkillMarkdown, githubUrl } from './github.js';
import { parseSkillId } from './install/plan.js';
import { listInstalled } from './install/run.js';
import { findUpdates } from './install/updates.js';
import { findUpdates, type UpdateCheck } from './install/updates.js';
import type { Risk } from './risks.js';
import type { Catalog, Scope, Skill } from './types.js';

Expand Down Expand Up @@ -97,9 +97,10 @@ 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`.
* 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.
*/
export async function outdatedSkills(): Promise<InstalledSummary[]> {
export async function outdatedSkills(): Promise<{ skills: InstalledSummary[]; unchecked: UpdateCheck['unchecked'] }> {
const [found, installed] = await Promise.all([findUpdates(), installedSkills(['global'])]);
return installed.filter((s) => s.source && found.has(`${s.source}@${s.name}`.toLowerCase()));
return { skills: installed.filter((s) => s.source && found.outdated.has(`${s.source}@${s.name}`.toLowerCase())), unchecked: found.unchecked };
}
7 changes: 5 additions & 2 deletions src/catalog/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function assemble(

/**
* Crawls every source. A failing source is logged and skipped so that one
* registry being down does not block the snapshot; all of them failing does.
* registry being down does not block the snapshot; all of them failing, or no skill at all, does.
*/
export async function buildCatalog(opts: BuildOptions = {}): Promise<Catalog> {
const log = opts.log ?? (() => {});
Expand Down Expand Up @@ -90,5 +90,8 @@ export async function buildCatalog(opts: BuildOptions = {}): Promise<Catalog> {
merged = merged.filter((s) => !missing.has(s));
log(`enrich: ${filled} descriptions filled, ${missing.size} skills gone from their repository dropped`);
}
return assemble(raw, merged, await officialOwners, sources);
const catalog = assemble(raw, merged, await officialOwners, sources);
// Sources that answer nothing (or skills all dropped) must not replace the previous snapshot with an empty one.
if (!catalog.skills.length) throw new Error('the crawl found no skill');
return catalog;
}
53 changes: 50 additions & 3 deletions src/catalog/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,51 @@ import { readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gunzipSync, gzipSync } from 'node:zlib';
import { z } from 'zod';
import { parseSkillId } from '../install/plan.js';
import type { Catalog } from '../types.js';

// The shape of `Catalog` (src/types.ts). Unknown fields are kept, so that newer snapshots still load.
const str = z.string();
const num = z.number();
const Skill = z.looseObject({
id: str, name: str, source: str, labels: z.array(str), origins: z.array(z.enum(['claude-plugins.dev', 'smithery', 'skills.sh'])), themes: z.array(str),
path: str.optional(), description: str.optional(), installs: num.optional(), stars: num.optional(), rank: num.optional(), growth: num.optional(),
requires: z.looseObject({ bins: z.array(str).optional(), compatibility: str.optional() }).optional(),
updatedAt: str.optional(), growthSuspect: num.optional(),
repo: z.looseObject({ license: str.optional(), archived: z.boolean().optional(), pushedAt: str.optional() }).optional(),
official: z.boolean().optional(),
recommendation: z.looseObject({ repository: str, reason: str, order: num }).optional(),
});

/** An entry `install` would refuse (a name read as a flag, a malformed source) or whose id is not its source@name. */
function installable(s: { id: string; source: string; name: string }): boolean {
try {
const { source, name } = parseSkillId(s.id);
return source === s.source && name === s.name;
} catch {
return false;
}
}
const Theme = z.looseObject({
id: str, label: str, description: str.optional(),
pack: z.looseObject({ available: num, total: num, missing: z.array(str) }).optional(),
});
const CatalogSchema = z.looseObject({
version: z.literal(1),
generatedAt: str.min(1),
themes: z.array(Theme),
skills: z.array(Skill),
stats: z.looseObject({
sources: z.record(str, z.looseObject({ status: z.enum(['ok', 'error']), count: num, error: str.optional() })).optional(),
raw: z.record(str, num),
merged: num,
official: num.optional(),
growthSince: str.optional(),
byTheme: z.record(str, num),
}),
});

/** Default snapshot: `data/catalog.json.gz`, found next to `dist/` or `src/catalog/`. */
export function defaultCatalogPath(): string {
let dir = dirname(fileURLToPath(import.meta.url));
Expand All @@ -19,9 +62,13 @@ export function defaultCatalogPath(): string {
export function decodeCatalog(buf: Buffer): Catalog {
// gzip magic number: 1f 8b
const text = buf[0] === 0x1f && buf[1] === 0x8b ? gunzipSync(buf).toString('utf8') : buf.toString('utf8');
const catalog = JSON.parse(text) as Catalog;
if (catalog.version !== 1 || !Array.isArray(catalog.skills)) throw new Error('unsupported catalog format');
return catalog;
const parsed = CatalogSchema.safeParse(JSON.parse(text));
if (!parsed.success) {
const issues = parsed.error.issues.slice(0, 3).map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
throw new Error(`invalid catalog: ${issues.join('; ')}${parsed.error.issues.length > 3 ? ` (+${parsed.error.issues.length - 3} more)` : ''}`);
}
// Entries that could never install are left out rather than failing the whole catalog.
return { ...parsed.data, skills: parsed.data.skills.filter(installable) } as Catalog;
}

/** Loads the catalog from a local path (`.json` or `.json.gz`) or an http(s) URL. */
Expand Down
Loading
Loading