Skip to content
113 changes: 112 additions & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,114 @@ skillx import --dry-run

---

### `skillx add <source>`

Install skills straight from GitHub, an https git URL or a local directory.
`add` is a thin front end to the [installer](#installer): skills land in the
same runtime skills directory (`--runtime`, default `claude-code`) with the same
receipts, install lock, conflicts and backups as `skillx install`. Unlike a
registry release, a skill added this way is **not scanned, signed or pinned in
`skillx.lock.json`**; review it before use.

**Sources:**
| Source | Meaning |
|--------|---------|
| `owner/repo` | A GitHub repository (default branch) |
| `owner/repo/skill` | One skill of it, by name or directory name (same as `--skill skill`) |
| `github:owner/repo[/path][@ref]` | A directory of the repository, at a branch or tag |
| `https://github.com/owner/repo[/tree/<ref>[/<path>]]` | Read as `github:` |
| `https://host/repo.git` | Any https git repository (default branch) |
| `./dir`, `../dir`, an absolute path | A local directory |

Only `https` is accepted. `http`, `ssh`, `git@host:` (scp style), `file://`,
`ext::` and other `::` transport helpers, a URL with a query or fragment, and
anything starting with `-` are refused as `usage_error`; git is also run with
every other protocol disabled. A path or ref with `..`, `.`, empty, `\` or `:`
segments is refused. `@publisher/package`, `kit:` and `collection:` belong to
`skillx install`; `@agentkit/*` is reserved.

**Options:**
| Flag | Description | Default |
|------|-------------|---------|
| `-s, --skill <names...>` | Skills to install, by front matter name or directory name (`*` for all) | ask; the only skill; all with `-y` |
| `-l, --list` | List the skills in the source; install nothing | `false` |
| `-y, --yes` | Do not ask (no selection prompt, no confirmation); required to install in human mode when stdin is not a terminal | `false` |
| `--force` | Replace a skill added before, or install over a directory nothing owns (edited files are backed up, unknown files kept) | `false` |
| `--runtime <id>` | `claude-code` or `codex` | `claude-code` |
| `--json` | Envelope with `status` (`listed`, `installed` or `cancelled`); never asks, so several skills need `--skill` | `false` |

**How a skill is found and read.** A skill is a directory with a regular
`SKILL.md` whose front matter (between a first `---` line and the next `---`
line) is plain YAML with a non-empty `name` and `description`. The front matter
is parsed as data only (YAML 1.2 core schema): a `---js`/`---javascript` fence
is not front matter, and tags or duplicate keys reject the file, so nothing in
a source ever runs. When the source (or `github:` path) is itself a skill, it
is the only one; otherwise every skill up to 6 directories deep counts,
outside `.git`, other VCS and cache directories and `node_modules`, and a
skill's own subdirectories are its content. Skills that cannot be installed are
listed under `skipped` with the reason.

- **Names.** `name` is the installed directory name and is never rewritten:
it must be a valid alias (up to 64 lowercase letters, digits, `.`, `_`, `-`,
not ending in `.`, not a Windows device name). Names starting with `ak-` or `ak:` are reserved for
AgentKit and refused. Naming a skipped skill with `--skill` is a
`usage_error`; two chosen skills with one name are a `usage_error`.
- **Content.** Each chosen skill is checked with the release tree rules
(`agentkit.skill-tree/v1`): a symlink, junction, hardlink, git submodule or
other special entry, an unsafe or colliding path, or a tree over the release
limits (4,096 files, 32 MiB per file, 256 MiB in all) refuses the skill as
`incompatible`. Links are never followed or copied, neither while searching
nor while reading.
- **Git.** The clone is shallow, single-branch and without a checkout, into a
temporary directory removed afterwards: files are read from git objects, so
no repository file is written, and no hook, filter or LFS download runs. git
runs without a shell, with `--` before the URL and all credential prompts
off (`core.askPass=`, `GIT_TERMINAL_PROMPT=0`, `GCM_INTERACTIVE=never`), so
a private repository works only with credentials git already has (a
credential helper, or a token in the URL).
- **Credentials.** A token in the URL is used to clone and nothing else: it is
removed from the recorded source and masked (`https://***@host`) in every
message and envelope.

**Where it is recorded.** Each added skill is its own installer source,
`add:<name>`, recorded with the source it came from (no credentials): its
receipt's `package` is the skill's origin (for example
`github:owner/repo/skills/pdf`) and `version` the commit read, or `local`.
`skillx list` shows it, and `skillx uninstall add:<name>` removes it with
ownership proof. `skillx update` skips added skills (naming one is a
`usage_error`); refresh one with `skillx add <source> --force`. `sync --locked`
does not restore them.

**Overwrites.** Nothing installed is replaced without `--force`: adding a name
that `add` installed before from a different tree is a `conflict`, and so is a
directory no source owns (with `--force` its unknown files are kept). A skill
that another source (a registry package, kit or collection) installed at
other content is a `conflict` even with `--force`; the same content just adds
`add:<name>` as a second owner.

**Exit codes.** `0` installed, listed, or cancelled by an empty answer at a
prompt; `2` `usage_error` (bad source, refused transport or path, reserved or
invalid name, several skills under `--json` without `--skill`, or stdin not a
terminal without `--yes` or `--json`: pass `--yes`, with `--skill` to choose,
since human mode cannot ask); `3` `not_found` (no skill,
no such `--skill`, path, repository or branch); `4` `incompatible` (a link,
special file or limit in a chosen skill); `5` `auth_error` (git asked for
credentials); `6` `network_error` (clone failed or timed out after 120 s);
`8` `conflict` (see Overwrites). Nothing is installed when any chosen skill
fails.

**Examples:**
```bash
skillx add anthropics/skills --list
skillx add anthropics/skills/pdf
skillx add github:acme/agent-skills/skills@v1.2 --skill lint review --yes
skillx add https://git.example.com/team/skills.git --skill '*' --json
skillx add ./my-skills --force
skillx uninstall add:pdf
```

---

### Release commands: `validate`, `pack`, `publish`, `promote`

These commands work on immutable, digest-pinned releases. Like every other
Expand Down Expand Up @@ -505,7 +613,10 @@ is sent only over `https`; plain `http` is refused except for `localhost`,
- **update** re-resolves installed sources (all, or those named) and applies the
difference, then refreshes the lock file when its scope was updated. With
nothing installed it prints `Nothing to update.` (`--json`: empty `outcomes`).
- **uninstall** drops a kit, a collection, or one or more packages; each
Skills installed by [`skillx add`](#skillx-add-source) (`add:<name>`) are
skipped; naming one is a `usage_error`.
- **uninstall** drops a kit, a collection, one or more packages, or skills
installed by `skillx add` (`add:<name>`, named on their own); each
explicit package is its own source (`packages:@scope/name`), whether it was
installed alone, with others, or by `sync --locked` from a packages lock.
Each package is dropped from a packages lock file as soon as it is
Expand Down
52 changes: 51 additions & 1 deletion packages/cli/src/built-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { execFile, execFileSync, execSync, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -357,6 +357,56 @@ describe("built cli", { timeout: 30_000 }, () => {
return (await Promise.all(names.map((name) => leafCommands([...path, name])))).flat();
}

it("add installs a local skill through the installer as one envelope, with installer exit codes", async () => {
const home = join(root, "add-home");
const env = { ...childEnv, HOME: home, USERPROFILE: home, SKILLX_HOME: join(home, ".skillx"), CLAUDE_CONFIG_DIR: join(home, ".claude") };
const run = async (...args: string[]) => {
try {
return { code: 0, stdout: (await execFileAsync(process.execPath, [BIN, ...args], { cwd: root, env, encoding: "utf8" })).stdout };
} catch (error) {
const failed = error as { code?: number; stdout?: string };
return { code: failed.code ?? -1, stdout: failed.stdout ?? "" };
}
};
const src = join(root, "add-src");
mkdirSync(join(src, "references"), { recursive: true });
writeFileSync(join(src, "SKILL.md"), "---\nname: smoke-skill\ndescription: Built CLI smoke\n---\n# Smoke\n");
writeFileSync(join(src, "references", "notes.md"), "notes\n");
const listed = JSON.parse((await run("add", src, "--list", "--json")).stdout) as { ok: true; data: { status: string; skills: Array<{ name: string }> } };
expect(listed.data).toMatchObject({ status: "listed", skills: [{ name: "smoke-skill" }] });

const installed = await run("add", src, "--json");
expect(installed.code).toBe(0);
expect(JSON.parse(installed.stdout)).toMatchObject({ ok: true, data: { status: "installed", skills: [{ alias: "smoke-skill" }] } });
expect(readFileSync(join(home, ".claude", "skills", "smoke-skill", "references", "notes.md"), "utf8")).toBe("notes\n");
const owners = JSON.parse((await run("list", "--json")).stdout) as { data: { sources: Array<{ owner: string }> } };
expect(owners.data.sources.map((s) => s.owner)).toEqual(["add:smoke-skill"]);

// The same name from a changed tree is a conflict (exit 8) until --force.
writeFileSync(join(src, "SKILL.md"), "---\nname: smoke-skill\ndescription: Built CLI smoke v2\n---\n");
const conflict = await run("add", src, "--json");
expect(conflict.code).toBe(8);
expect(JSON.parse(conflict.stdout)).toMatchObject({ ok: false, error: { code: "conflict" } });
expect((await run("add", src, "--force", "--json")).code).toBe(0);
expect((await run("uninstall", "add:smoke-skill", "--json")).code).toBe(0);

// Human mode on a piped stdin cannot ask: a usage error, never a silent exit-0 cancel.
const piped = await run("add", src);
expect(piped.code).toBe(2);
expect(piped.stdout).not.toContain("Cancelled");
expect(existsSync(join(home, ".claude", "skills", "smoke-skill"))).toBe(false);

const missing = await run("add", join(root, "no-such-dir"), "--json");
expect(missing.code).toBe(2);
expect(JSON.parse(missing.stdout)).toMatchObject({ ok: false, error: { code: "usage_error" } });
// Human mode fails and exits too; nothing keeps the process alive.
expect((await run("add", "ext::sh -c id")).code).toBe(2);
const token = "ghp_SECRETTOKEN123";
const leaked = await run("add", `http://user:${token}@127.0.0.1:9/x.git`, "--json");
expect(leaked.code).toBe(2);
expect(leaked.stdout).not.toContain(token);
});

it("offers --json on every command but the stdio MCP server", async () => {
const leaves = await leafCommands();
expect(leaves.length).toBeGreaterThan(20);
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/src/commands/add-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import chalk from 'chalk';
import type { ApplyResult } from '../lib/install/install-engine.js';
import type { FoundSkill, SkippedSkill } from '../lib/add/skill-discovery.js';

/** What `skillx add` reports; the same data backs the `--json` envelope. */
export type AddResult =
| { status: 'cancelled' }
| { status: 'listed'; source: string; commit?: string; skills: FoundSkill[]; skipped: SkippedSkill[] }
| {
status: 'installed';
source: string;
commit?: string;
runtime: string;
skillsDir: string;
skills: Array<{ alias: string; origin: string; digest: string }>;
skipped: SkippedSkill[];
planDigest: string;
applied: ApplyResult[];
recovered: { rolledBack: string[]; rolledForward: string[] };
};

/** Refused skills go to stderr, so piped stdout stays clean. */
export function printSkipped(skipped: readonly SkippedSkill[]): void {
for (const s of skipped) console.error(chalk.yellow(` ! skipped ${s.dir || '(root)'}: ${s.reason}`));
}

export function printAdd(result: AddResult): void {
if (result.status === 'cancelled') {
console.log(chalk.dim('Cancelled; nothing was installed.'));
return;
}
const at = result.commit ? chalk.dim(` @ ${result.commit.slice(0, 12)}`) : '';
if (result.status === 'listed') {
console.log(chalk.bold(`\n${result.source}`) + at);
for (const s of result.skills) console.log(` ${chalk.cyan(s.name.padEnd(28))} ${chalk.dim(s.dir || '.')} ${s.description.slice(0, 80)}`);
if (result.skills.length === 0) console.log(chalk.dim(' no installable skill'));
printSkipped(result.skipped);
return;
}
console.log(chalk.bold(`\n${result.source}`) + at + chalk.dim(` → ${result.skillsDir}`));
for (const a of result.applied) {
for (const c of a.changed) {
console.log(` ${chalk.green(c.action.padEnd(9))} ${c.alias}`);
if (c.backupDir) console.log(chalk.yellow(` local changes saved to ${c.backupDir}`));
if (c.preserved.length > 0) console.log(chalk.yellow(` kept files not in the skill: ${c.preserved.slice(0, 5).join(', ')}`));
// Stderr, like the installer: an executable file is reported, never run.
if (c.executable) console.error(chalk.yellow(` ! ${c.alias}: ${c.executable.length} executable file(s) written (${c.executable.slice(0, 5).join(', ')}); skillx never runs them, review before use`));
}
if (a.changed.length === 0) console.log(` ${chalk.dim('unchanged'.padEnd(9))} ${a.owner.slice(a.owner.indexOf(':') + 1)}`);
}
printSkipped(result.skipped);
console.log(chalk.green('\n✓ done') + chalk.dim(' Skills from git or a local path are not scanned or signed; review them before use.'));
}
85 changes: 85 additions & 0 deletions packages/cli/src/commands/add-prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import chalk from 'chalk';
import { InstallError, usageError } from '../lib/install/install-errors.js';
import type { FoundSkill, SkippedSkill } from '../lib/add/skill-discovery.js';
import { promptLine } from '../lib/prompt-line.js';

/**
* Which skills `skillx add` installs. Names match a skill's front matter name
* or its directory name, case-insensitively; `*` takes every installable
* skill. A name that matches a refused skill (reserved, invalid front matter)
* fails with the reason instead of being skipped quietly. Without names, one
* skill is taken as is; several are all taken with `--yes`, chosen at a
* prompt in human mode, and must be named under `--json`.
*/

const baseName = (dir: string) => dir.slice(dir.lastIndexOf('/') + 1);
const matches = (name: string, skill: { name?: string; dir: string }) => {
const n = name.toLowerCase();
return skill.name?.toLowerCase() === n || (skill.dir !== '' && baseName(skill.dir).toLowerCase() === n);
};

export function pickByName(found: readonly FoundSkill[], skipped: readonly SkippedSkill[], names: readonly string[]): FoundSkill[] {
const picked = new Set<FoundSkill>();
for (const name of names) {
const hits = found.filter((s) => matches(name, s));
if (hits.length > 0) {
hits.forEach((s) => picked.add(s));
continue;
}
const refused = skipped.find((s) => matches(name, s));
if (refused) throw usageError(`cannot install ${name}: ${refused.reason}`, { skill: name, dir: refused.dir });
throw new InstallError('not_found', `no skill named ${name} in this source`, { skill: name, available: found.map((s) => s.name) });
}
return [...picked];
}

/** Parses `1,3`, `2-4` or `all` against a list of `count` items; null when the answer is not one of those. */
export function parseChoice(answer: string, count: number): number[] | null {
const text = answer.trim().toLowerCase();
if (text === 'all' || text === '*') return Array.from({ length: count }, (_, i) => i);
const picked = new Set<number>();
for (const part of text.split(/[\s,]+/).filter(Boolean)) {
const m = /^(\d+)(?:-(\d+))?$/.exec(part);
if (!m) return null;
const [from, to] = [Number(m[1]), Number(m[2] ?? m[1])];
if (from < 1 || to > count || from > to) return null;
for (let i = from; i <= to; i++) picked.add(i - 1);
}
return picked.size > 0 ? [...picked].sort((a, b) => a - b) : null;
}

/**
* Human mode asks before it installs, so it needs a terminal. A pipe, a closed
* stdin or CI is a usage error, never a silent "cancelled" with exit 0.
*/
export function assertCanPrompt(mode: { yes?: boolean; json?: boolean }, isTTY = process.stdin.isTTY === true): void {
if (mode.yes || mode.json || isTTY) return;
throw usageError('stdin is not a terminal, so skillx add cannot ask; pass --yes to install without a prompt (with --skill <name...> to choose skills)');
}

export async function selectSkills(
found: readonly FoundSkill[],
skipped: readonly SkippedSkill[],
names: readonly string[],
mode: { yes?: boolean; json?: boolean },
): Promise<FoundSkill[] | null> {
if (names.length > 0 && !names.includes('*')) return pickByName(found, skipped, names);
if (found.length === 0) {
throw new InstallError('not_found', 'no installable skill found (a skill is a directory with a SKILL.md that has a name and a description)', { skipped });
}
if (names.length > 0 || found.length === 1 || mode.yes) return [...found];
if (mode.json) throw usageError(`${found.length} skills found; name them with --skill <name...> or --skill '*'`, { available: found.map((s) => s.name) });
found.forEach((s, i) => console.log(` ${chalk.dim(String(i + 1).padStart(2))} ${chalk.cyan(s.name)} ${chalk.dim(s.description.slice(0, 70))}`));
for (;;) {
const answer = await promptLine('Install which skills? (e.g. 1,3 or 2-4 or all; empty to cancel) ', process.stdout);
if (answer === null || answer.trim() === '') return null;
const picked = parseChoice(answer, found.length);
if (picked) return picked.map((i) => found[i]!);
console.log(chalk.yellow(` answer with numbers from 1 to ${found.length}, ranges, or all`));
}
}

export async function confirmInstall(): Promise<boolean> {
const answer = await promptLine('Install? [y/N] ', process.stdout);
return answer !== null && /^y(es)?$/i.test(answer.trim());
}
Loading
Loading