Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
147e926
feat(cli): add skdd add/push/drops — the Commons verbs
zakelfassi Jul 1, 2026
bbf5fb4
feat(plugin): opt-in enforcement hooks — finish-the-loop gate + freez…
zakelfassi Jul 1, 2026
3f3a333
docs: Commons integration — README section, docs/commons.md, site pag…
zakelfassi Jul 1, 2026
7426234
docs(site): wire commons.md through the sync-docs manifest
zakelfassi Jul 1, 2026
17fde65
fix(cli): treat Commons manifests as hostile input; allowlist push pa…
zakelfassi Jul 2, 2026
68e49fd
Merge branch 'feat/commons-cli' into feat/plugin-hooks
zakelfassi Jul 2, 2026
bf91b01
Merge branch 'feat/plugin-hooks' into docs/commons-integration
zakelfassi Jul 2, 2026
c190833
fix(cli): refuse symlinked skill dirs and SKILL.md in push
zakelfassi Jul 2, 2026
5279ac0
Merge branch 'feat/commons-cli' into feat/plugin-hooks
zakelfassi Jul 2, 2026
50a3a70
Merge branch 'feat/plugin-hooks' into docs/commons-integration
zakelfassi Jul 2, 2026
cc2fbf1
fix(cli): guard symlinked skills at push discovery, not just the item…
zakelfassi Jul 2, 2026
163436f
Merge branch 'feat/commons-cli' into feat/plugin-hooks
zakelfassi Jul 2, 2026
c8d343d
Merge branch 'feat/plugin-hooks' into docs/commons-integration
zakelfassi Jul 2, 2026
e220320
fix(cli): address Commons review findings in add/push/commons/config
zakelfassi Jul 2, 2026
e718cb6
Merge branch 'feat/commons-cli' into feat/plugin-hooks
zakelfassi Jul 2, 2026
885b1e4
fix(plugin): address hook review comments (finish-loop + freeze)
zakelfassi Jul 2, 2026
dcd28d9
Merge branch 'feat/plugin-hooks' into docs/commons-integration
zakelfassi Jul 2, 2026
1d0b5fe
fix(plugin): finish-loop detects edits to files dirty before the session
zakelfassi Jul 2, 2026
96330fd
Merge branch 'feat/plugin-hooks' into docs/commons-integration
zakelfassi Jul 2, 2026
66c0465
fix(plugin): anchor hook repo snapshots to the repo root (subdir-safe)
zakelfassi Jul 2, 2026
5cc1d49
Merge branch 'feat/plugin-hooks' into docs/commons-integration
zakelfassi Jul 2, 2026
86e9913
fix(cli): address second-round PR review comments on add/push
zakelfassi Jul 2, 2026
d3aa1ad
Merge branch 'feat/commons-cli' into feat/plugin-hooks
zakelfassi Jul 2, 2026
029eabf
fix(plugin): address second-round hook review comments
zakelfassi Jul 2, 2026
9bde3fd
Merge branch 'feat/plugin-hooks' into docs/commons-integration
zakelfassi Jul 2, 2026
2acc1a5
Merge remote-tracking branch 'origin/main' into docs/commons-integration
zakelfassi Jul 2, 2026
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,23 @@ Every loop through the diagram *improves* the colony. Archiving is reversible; n

---

## SkDD Commons — skills that evolve in public

Community skills live in **[SkDD Commons](https://github.com/zakelfassi/skdd-commons)**, released as curated, dated **drops**. Unlike static skill lists, every Commons skill carries lifecycle metadata (`forged-by`, `forged-from`, `forged-reason`) and an evolution loop: hit an edge case in the wild, fix your local copy, and `skdd push` ships the diff upstream as a PR. A skill that has evolved across many codebases carries a trust signal no static collection can fake.

```bash
# Install the current drop — six skills forged by claude-fable-5 on 2026-07-01
pnpm dlx @zakelfassi/skdd add zakelfassi/skdd-commons 2026-07-frontier

# See what's on offer / push your evolution back
skdd drops
skdd push what-would-you-cut
```

Current drop: [`2026-07-frontier` — July 2026 Frontier, the Fable Festival drop](https://github.com/zakelfassi/skdd-commons/tree/main/packs/2026-07-frontier). See [`packs/README.md`](packs/README.md) for the pack concept and [`docs/commons.md`](docs/commons.md) for the full add/push flow.

---

## Hub & Global Colony

*This is the 1.0 headline.*
Expand Down
10 changes: 10 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ SkDD is being built in named tranches (P0 → P5). Each tranche ships as a batch
| **G — Community scaffolding** | SECURITY, CoC, CODEOWNERS, issue/PR templates, FUNDING, CHANGELOG, ROADMAP | ✅ Shipped (v0.3.0) |
| **H — CLI depth + quality bar** | `skdd stats`, `search`, `migrate`, `compose`, `link --watch`, `completion`, ESLint/Prettier, Changesets, e2e tests, `SkddError` class, man pages | ⏸ Deferred |
| **P4 — Strategy / Ecosystem** | Marketplace submissions, RFC upstream for lifecycle metadata, security doc, SkDD rename debate, governance | ⏸ Deferred |
| **Commons** ([plan](docs/plans/2026-07-skdd-commons.md)) | Phase 1: [`skdd-commons`](https://github.com/zakelfassi/skdd-commons) repo + `2026-07-frontier` drop + safety-lint CI · Phase 2: `skdd add`/`push`/`drops` + `.skdd-lock.json` provenance · Phase 3: opt-in plugin hooks (`finish-the-loop` gate, `freeze-the-session` reminder) · Phase 4: docs + launch | 🚧 Shipped, launch pending |

### Commons v2 (deferred)

Explicitly cut from Commons v1 — candidates for a follow-up tranche:

- **`skdd update`** — three-way evolution merge against the upstream drop; highest-value item, because a local/upstream divergence *is a PR waiting to be pushed* (`.skdd-lock.json` already records the base sha)
- **Hub "Commons" pane** — list drops with an install action inside `skdd hub`
- **npm drops** (`@skdd/drop-*`) — git transport is enough for v1
- **`attack-the-plan` as a plan-approval gate** — deferred for nag risk; stays a skill

## Manual steps (not automatable — GitHub settings)

Expand Down
31 changes: 31 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ skdd list [--format=table|json] [-g]
skdd link [--mode=symlink|copy|auto] [--harness=<list>] [--force] [--quiet] [-g]
skdd doctor [--json] [-g]
skdd import [target] [--json] [--apply] [--canonical=<dir>] [--skip-link] [-g]
skdd add <source> [selector] [--rename=<name>] [--dry-run] [--json] [--non-interactive] [-g]
skdd push <skill|pack> [--to=<owner/repo>] [--drop=<id>] [--dry-run] [-g]
skdd drops [--from=<source>] [--format=table|json]
skdd hub
skdd mcp <subcommand>
```
Expand Down Expand Up @@ -118,6 +121,34 @@ skdd import --apply # migrate + link
skdd import ../some-other-project --apply # operate on a different root
```

### `skdd add`

Install skills from a **Commons repo** — a git repo with a `drops.json` manifest and `packs/<drop-id>/<skill>/` directories (see [SkDD Commons](https://github.com/zakelfassi/skdd-commons)). Sources: GitHub shorthand (`owner/repo`), a full git URL, or a local path, each with an optional `#ref`. Selector: a drop id, `drop/skill` for a single skill, or omitted for an interactive pick.

Every skill is validated with `--strict` before install (refused on failure), checked for name collisions against the target colony (`--rename` resolves single-skill collisions), registered with provenance (`owner/repo@shortsha (drop-id)` in the Source column, full sha in `.skdd-lock.json`), and mirrored via the same **safe, never-forced** link path as `skdd link`.

```bash
skdd add zakelfassi/skdd-commons 2026-07-frontier # whole drop
skdd add zakelfassi/skdd-commons 2026-07-frontier/finish-the-loop -g # one skill, global colony
skdd add ../my-commons 2026-01-test --dry-run # local source, plan only
```

### `skdd push`

Ship a skill (or every local skill sharing a `metadata.pack` id) upstream to a Commons as a PR. Needs the [GitHub CLI](https://cli.github.com) authenticated. The default target repo comes from `~/.skdd/config.toml` (`commons = "owner/repo"`).

Machine-local state is stripped before travel (`usage-count` resets to `"0"`, `last-used` is dropped); `forged-*` provenance is preserved. Skills that already exist upstream branch as `evolve/<name>` with a diff summary; new skills branch as `skill/<name>` and land in `incoming/` for maintainer triage, or in an existing drop with `--drop <id>`. `--dry-run` prints the full would-be PR without network writes.

```bash
skdd push what-would-you-cut --dry-run # inspect the PR before sending it
skdd push what-would-you-cut # fork, branch, PR
skdd push my-new-skill --drop 2026-07-frontier
```

### `skdd drops`

List the drops a Commons offers (id, title, date, skill count, story link). `--from` accepts the same source forms as `add`; defaults to the configured commons.

## Development

```bash
Expand Down
307 changes: 307 additions & 0 deletions cli/src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { select } from "@inquirer/prompts";
import {
type DropsManifest,
fetchCommons,
parseSource,
provenanceLabel,
readDropsManifest,
resolveSelector,
} from "../lib/commons.js";
import { ensureGlobalColony, skddHome } from "../lib/global.js";
import { upsertLockEntry } from "../lib/lock.js";
import { logger } from "../lib/logger.js";
import { addRegistryEntry } from "../lib/registry.js";
import { parseSkill } from "../lib/skill.js";
import { NAME_MAX_LENGTH, NAME_REGEX } from "../lib/spec.js";
import { runLink } from "./link.js";
import { validateSkill } from "./validate.js";

export interface AddOptions {
cwd?: string;
global?: boolean;
rename?: string;
dryRun?: boolean;
json?: boolean;
/** Skip the interactive drop picker (CI / agent-driven use). */
nonInteractive?: boolean;
}

interface InstalledSkill {
name: string;
sourceName: string; // upstream name (differs from `name` when --rename is used)
path: string;
provenance: string;
description: string;
}

export async function runAdd(
sourceArg: string,
selector: string | undefined,
opts: AddOptions = {},
): Promise<number> {
const cwd = resolve(opts.cwd ?? process.cwd());
const colonyRoot = opts.global ? skddHome() : cwd;
if (opts.global) ensureGlobalColony();
const canonicalDir = opts.global
? join(skddHome(), "skills")
: join(cwd, detectCanonical(cwd) ?? "skills");

if (opts.rename) {
const err = validateRename(opts.rename);
if (err) {
logger.error(err);
return 1;
}
}

// ── fetch the commons ──────────────────────────────────────────────────────
let fetched: ReturnType<typeof fetchCommons>;
try {
fetched = fetchCommons(parseSource(sourceArg, cwd));
} catch (err) {
logger.error((err as Error).message);
return 1;
}

try {
let manifest: DropsManifest;
try {
manifest = readDropsManifest(fetched.dir);
} catch (err) {
logger.error((err as Error).message);
return 1;
}

// ── resolve the selection ────────────────────────────────────────────────
let effectiveSelector = selector;
if (!effectiveSelector) {
if (opts.nonInteractive || opts.json || !process.stdin.isTTY) {
logger.error(
`No selector given. Available drops: ${manifest.drops.map((d) => d.id).join(", ") || "(none)"}`,
);
logger.dim(`Usage: skdd add ${sourceArg} <drop-id>[/<skill>]`);
return 1;
}
effectiveSelector = await select({
message: "Pick a drop to install:",
choices: manifest.drops.map((d) => ({
name: `${d.id} — ${d.title} (${d.skills.length} skills)`,
value: d.id,
})),
});
}

let selection: ReturnType<typeof resolveSelector>;
try {
selection = resolveSelector(manifest, effectiveSelector);
} catch (err) {
logger.error((err as Error).message);
return 1;
}

if (opts.rename && selection.skills.length !== 1) {
logger.error(
`--rename applies to a single skill; the selection '${effectiveSelector}' contains ${selection.skills.length}.`,
);
return 1;
}

// ── validate every selected skill (refuse on any strict failure) ─────────
const dropDir = join(fetched.dir, "packs", selection.drop.id);
let validationFailed = false;
const parsedSkills: Array<{ sourceName: string; dir: string; description: string }> = [];
for (const skillName of selection.skills) {
const skillMd = join(dropDir, skillName, "SKILL.md");
if (!existsSync(skillMd)) {
logger.error(`${selection.drop.id}/${skillName}: SKILL.md missing in the source repo.`);
validationFailed = true;
continue;
}
try {
const parsed = parseSkill(skillMd);
const errors = validateSkill(parsed, { strict: true }).filter(
(i) => i.severity === "error",
);
if (errors.length > 0) {
logger.error(`${selection.drop.id}/${skillName}: fails skdd validate --strict:`);
for (const e of errors) logger.dim(` ${e.field ? `[${e.field}] ` : ""}${e.message}`);
validationFailed = true;
continue;
}
parsedSkills.push({
sourceName: skillName,
dir: join(dropDir, skillName),
description: String(parsed.frontmatter.description ?? ""),
});
} catch (err) {
logger.error(`${selection.drop.id}/${skillName}: ${(err as Error).message}`);
validationFailed = true;
}
}
if (validationFailed) {
logger.error("Refusing to install: one or more skills failed validation.");
return 1;
}

// ── collision check against the target colony ────────────────────────────
const collisions: string[] = [];
for (const s of parsedSkills) {
const targetName = opts.rename ?? s.sourceName;
if (existsSync(join(canonicalDir, targetName))) {
collisions.push(targetName);
}
}
if (collisions.length > 0) {
for (const name of collisions) {
logger.error(
`Collision: '${name}' already exists in ${relative(cwd, canonicalDir) || canonicalDir}.`,
);
}
logger.dim(
collisions.length === 1 && parsedSkills.length === 1
? `Re-run with --rename <new-name> to install it under a different name.`
: `Remove or rename the existing skills, or add skills one at a time with --rename.`,
);
return 1;
}

// ── dry run: report the plan and stop ────────────────────────────────────
const planned: InstalledSkill[] = parsedSkills.map((s) => {
const name = opts.rename ?? s.sourceName;
return {
name,
sourceName: s.sourceName,
path: join(relative(colonyRoot, canonicalDir) || canonicalDir, name, "SKILL.md"),
provenance: provenanceLabel(fetched.source, fetched.sha, selection.drop.id),
description: s.description,
};
});

if (opts.dryRun) {
if (opts.json) {
console.log(
JSON.stringify(
{
dryRun: true,
source: fetched.source.label,
sha: fetched.sha,
drop: selection.drop.id,
skills: planned,
},
null,
2,
),
);
} else {
logger.heading(`skdd add — dry run`);
logger.dim(
`source: ${fetched.source.label}${fetched.sha ? ` @ ${fetched.sha.slice(0, 7)}` : ""}`,
);
logger.dim(`drop: ${selection.drop.id} — ${selection.drop.title}`);
console.log("");
for (const p of planned) {
logger.info(
` would install ${p.sourceName}${p.name !== p.sourceName ? ` as ${p.name}` : ""} → ${p.path}`,
);
}
logger.dim("\nNo files written (--dry-run).");
}
return 0;
}

// ── install: copy, rename, register, lock ───────────────────────────────
for (let i = 0; i < parsedSkills.length; i++) {
const s = parsedSkills[i]!;
const p = planned[i]!;
const dest = join(canonicalDir, p.name);
cpSync(s.dir, dest, { recursive: true });
if (p.name !== s.sourceName) {
rewriteSkillName(join(dest, "SKILL.md"), p.name);
}
addRegistryEntry(colonyRoot, {
name: p.name,
source: p.provenance,
path: p.path,
lastUsed: new Date().toISOString().slice(0, 10),
uses: 0,
description: p.description,
status: "active",
});
upsertLockEntry(colonyRoot, p.name, {
source: fetched.source.label,
drop: selection.drop.id,
sha: fetched.sha,
addedAt: new Date().toISOString(),
});
if (!opts.json) logger.success(`installed ${p.name} (${p.provenance})`);
}

// ── refresh mirrors through the existing SAFE link path (never forced) ───
const linkCode = opts.global
? await runLink({ global: true, quiet: true })
: await runLink({ cwd, quiet: true });

if (opts.json) {
console.log(
JSON.stringify(
{
source: fetched.source.label,
sha: fetched.sha,
drop: selection.drop.id,
installed: planned,
mirrors: linkCode === 0 ? "refreshed" : "blocked",
},
null,
2,
),
);
}

if (linkCode !== 0) {
logger.warn(
`Skills are installed in the canonical dir, but at least one harness mirror was NOT refreshed\n` +
` (a populated directory sits at the mirror path — skdd never replaces it silently).\n` +
` Review the paths above, then run '${opts.global ? "skdd link -g" : "skdd link"}' (add --force only if you're sure).`,
);
return 1;
}

if (!opts.json) {
logger.success(
`${planned.length} skill(s) added from ${fetched.source.label} — mirrors refreshed.`,
);
}
return 0;
} finally {
fetched.cleanup();
}
}

function detectCanonical(root: string): string | null {
const p = join(root, ".colony.json");
if (!existsSync(p)) return null;
try {
const manifest = JSON.parse(readFileSync(p, "utf8")) as { canonicalSkillsDir?: string };
if (typeof manifest.canonicalSkillsDir === "string" && manifest.canonicalSkillsDir.length > 0) {
return manifest.canonicalSkillsDir;
}
} catch {
// malformed .colony.json is doctor's concern, not add's
}
return null;
}

function validateRename(name: string): string | null {
if (name.length > NAME_MAX_LENGTH) return `--rename must be ≤${NAME_MAX_LENGTH} characters`;
if (!NAME_REGEX.test(name)) return `--rename must be lowercase kebab-case (${NAME_REGEX})`;
return null;
}

/** Rewrite the frontmatter `name:` line so it matches the renamed directory. */
function rewriteSkillName(skillMdPath: string, newName: string): void {
const raw = readFileSync(skillMdPath, "utf8");
const updated = raw.replace(/^name:\s*.+$/m, `name: ${newName}`);
writeFileSync(skillMdPath, updated);
}
Loading