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
20 changes: 20 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Validate Skills

on:
push:
pull_request:

jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Validate skills
run: npm run validate
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
.idea/
.DS_Store
.env
.env.*
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2026 extra
Copyright (c) 2026 extra-org

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Extra Skills

Claude Code Agent Skills for the [`extra`](https://github.com/extra-org/extra)
agent framework.

Two repositories, two jobs:

```
extra = the main framework / runtime / CLI (agentctl, the engine, Docker image)
extra-skills = Claude Code Agent Skills for setup, debugging, and onboarding
```

This repo does **not** contain the `extra` source — only skills, their
reference docs and templates, a validation script, and CI.

> **Official documentation:** <https://extra-c586718a.mintlify.site/docs/introduction>
> Go there for conceptual documentation (architecture, YAML reference, CLI,
> plugins). Use `/extra-setup` in Claude Code for hands-on project setup.

## Install

List the skills in this repo:

```bash
npx skills add extra-org/extra-skills --list
```

Install all skills into the **current project** (for Claude Code):

```bash
npx skills add extra-org/extra-skills --skill '*' -a claude-code
```

Install all skills **globally** for Claude Code (available in every project):

```bash
npx skills add extra-org/extra-skills --skill '*' -a claude-code -g
```

- **without `-g`** → installs into the current project only.
- **with `-g`** → installs globally for Claude Code.

## Usage

Open Claude Code in any project that uses (or will use) `extra`, then run:

```
/extra-setup
/extra-setup simple banking demo
/extra-setup repair my agents.yml
/extra-setup configure MCP tools and resolvers
```

## What the skill does

`/extra-setup` turns Claude Code into a setup agent for `extra`. It:

1. **Inspects** the project (looks for `agents.yml`/`agents.yaml`, `plugins/`,
`prompts/`, package manager, Docker, whether `extra` is installed).
2. **Understands the goal** from your argument, or infers a safe minimal demo.
3. **Creates or repairs `agents.yml`** — one root orchestrator plus focused
agents — using a minimal, valid template, without destructive rewrites.
4. **Configures** agents, MCP servers (URL-based), tools, resolvers (with
`shared`/`agent` scope), and access control (`protected` + `plugins/access.py`).
5. **Discovers the real commands** from the project (never guesses) and runs
`agentctl generate` / `agentctl validate` (or their Docker equivalents),
plus any tests/lint/typecheck the project defines.
6. **Summarizes** what it detected, created, changed, ran, and what to do next.

It ships with reference docs it reads on demand:
[extra-concepts](skills/extra-setup/references/extra-concepts.md),
[agents-yml-guide](skills/extra-setup/references/agents-yml-guide.md), and
[troubleshooting](skills/extra-setup/references/troubleshooting.md), plus a
[minimal `agents.yml` template](skills/extra-setup/assets/minimal-agents.yml).

## Development

```bash
npm install # no runtime deps; sets up npm scripts
npm run validate # validate every skill in skills/
```

`npm run validate` runs [`scripts/validate-skills.mjs`](scripts/validate-skills.mjs)
(Node built-ins only), which checks that each skill has a `SKILL.md` with valid
frontmatter, a `name` matching its folder, kebab-case naming, resolvable
reference links, and no committed secrets or private paths. CI runs the same
check on every push and pull request.

## License

[MIT](LICENSE) © extra-org
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "extra-skills",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Claude Code Agent Skills for the extra agent framework.",
"license": "MIT",
"scripts": {
"validate": "node scripts/validate-skills.mjs"
}
}
222 changes: 222 additions & 0 deletions scripts/validate-skills.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
#!/usr/bin/env node
// Validate the extra-skills repository structure.
//
// Dependency-free (Node.js built-ins only). Checks that every skill under
// `skills/` is well-formed enough to be installed by `npx skills add` and
// loaded by Claude Code:
//
// 1. `skills/` exists.
// 2. Each direct child folder has a SKILL.md.
// 3. Each SKILL.md starts with YAML frontmatter.
// 4. Frontmatter includes `name`.
// 5. Frontmatter includes `description`.
// 6. `name` exactly matches the skill folder name.
// 7. Skill names are safe lowercase kebab-case.
// 8. Markdown links to reference files resolve to existing files.
// 9. No obvious private paths or secrets are committed.
// 10. README and each SKILL.md link the official docs; README carries the
// canonical `extra-org/extra-skills` install slug.
//
// Exits non-zero on any error so it can gate CI.

import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
import { join, dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const SKILLS_DIR = join(ROOT, "skills");

const errors = [];
const warnings = [];
const skillsChecked = [];

const KEBAB_CASE = /^[a-z0-9]+(-[a-z0-9]+)*$/;

// The official public docs must be discoverable from the README and every
// skill should point Claude Code at them as the public source of truth.
const OFFICIAL_DOCS = "https://extra-c586718a.mintlify.site/docs";

// Patterns that must never appear in committed skill content.
const SECRET_PATTERNS = [
{ label: "macOS/Linux home path", re: /\/Users\/[A-Za-z0-9._-]+\//g },
{ label: "Linux home path", re: /\/home\/[A-Za-z0-9._-]+\//g },
{ label: "Windows user path", re: /[A-Za-z]:\\Users\\[A-Za-z0-9._-]+/g },
{ label: "AWS access key id", re: /\bAKIA[0-9A-Z]{16}\b/g },
{ label: "Anthropic-style secret key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
{ label: "generic secret assignment", re: /\b(?:api[_-]?key|secret|password|token)\s*[:=]\s*['"][^'"\s]{12,}['"]/gi },
];

function fail(msg) {
errors.push(msg);
}

function parseFrontmatter(content) {
// Frontmatter must be the very first thing in the file.
if (!content.startsWith("---")) {
return { ok: false, reason: "file does not start with '---' frontmatter" };
}
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) {
return { ok: false, reason: "frontmatter block is not closed with '---'" };
}
const body = match[1];
const data = {};
for (const rawLine of body.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (!line.trim() || line.trimStart().startsWith("#")) continue;
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!m) continue; // tolerate nested/multiline values we don't need
const key = m[1];
let value = m[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (!(key in data)) data[key] = value;
}
return { ok: true, data };
}

function scanForSecrets(relPath, content) {
for (const { label, re } of SECRET_PATTERNS) {
const found = content.match(re);
if (found) {
fail(`${relPath}: contains a ${label} (${JSON.stringify(found[0])})`);
}
}
}

function checkReferenceLinks(skillDir, relPath, content) {
// Markdown links like [text](references/foo.md) or [text](assets/bar.yml).
const linkRe = /\]\(([^)]+)\)/g;
let m;
while ((m = linkRe.exec(content)) !== null) {
let target = m[1].trim();
// Skip external links, anchors, and mailto.
if (/^([a-z]+:)?\/\//i.test(target) || target.startsWith("#") || target.startsWith("mailto:")) {
continue;
}
// Strip anchor / query suffixes.
target = target.split("#")[0].split("?")[0].trim();
if (!target) continue;
const resolved = resolve(skillDir, target);
if (!existsSync(resolved)) {
fail(`${relPath}: link target does not exist -> ${target}`);
}
}
}

function main() {
if (!existsSync(SKILLS_DIR) || !statSync(SKILLS_DIR).isDirectory()) {
fail("skills/ directory does not exist");
return report();
}

// README must link the official docs and use the canonical install slug.
const readmePath = join(ROOT, "README.md");
if (!existsSync(readmePath)) {
fail("README.md is missing");
} else {
const readme = readFileSync(readmePath, "utf8");
if (!readme.includes(OFFICIAL_DOCS)) {
fail(`README.md: does not link the official docs (${OFFICIAL_DOCS}...)`);
}
if (!readme.includes("extra-org/extra-skills")) {
fail("README.md: missing the 'extra-org/extra-skills' install slug");
}
scanForSecrets(relative(ROOT, readmePath), readme);
}

const entries = readdirSync(SKILLS_DIR, { withFileTypes: true }).filter((e) =>
e.isDirectory()
);

if (entries.length === 0) {
fail("skills/ contains no skill folders");
return report();
}

for (const entry of entries) {
const skillName = entry.name;
const skillDir = join(SKILLS_DIR, skillName);
const skillMdPath = join(skillDir, "SKILL.md");
const relSkillMd = relative(ROOT, skillMdPath);

if (!existsSync(skillMdPath)) {
fail(`skills/${skillName}: missing SKILL.md`);
continue;
}

const content = readFileSync(skillMdPath, "utf8");
const fm = parseFrontmatter(content);
if (!fm.ok) {
fail(`${relSkillMd}: ${fm.reason}`);
continue;
}

if (!fm.data.name) fail(`${relSkillMd}: frontmatter missing 'name'`);
if (!fm.data.description) fail(`${relSkillMd}: frontmatter missing 'description'`);

if (fm.data.name && fm.data.name !== skillName) {
fail(
`${relSkillMd}: frontmatter name '${fm.data.name}' does not match folder '${skillName}'`
);
}

// Each skill should reference the official docs as the public source of truth.
if (!content.includes(OFFICIAL_DOCS)) {
fail(`${relSkillMd}: does not link the official docs (${OFFICIAL_DOCS}...)`);
}

if (!KEBAB_CASE.test(skillName)) {
fail(`skills/${skillName}: folder name is not safe lowercase kebab-case`);
}
if (fm.data.name && !KEBAB_CASE.test(fm.data.name)) {
fail(`${relSkillMd}: name '${fm.data.name}' is not safe lowercase kebab-case`);
}

checkReferenceLinks(skillDir, relSkillMd, content);

// Scan the whole skill tree for secrets / private paths.
for (const file of walk(skillDir)) {
const rel = relative(ROOT, file);
const raw = readFileSync(file, "utf8");
scanForSecrets(rel, raw);
}

skillsChecked.push(skillName);
}

return report();
}

function* walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
} else if (entry.isFile()) {
// Only scan text-like files.
if (/\.(md|mdx|ya?ml|toml|txt|json|py|mjs|js|ts)$/i.test(entry.name)) {
yield full;
}
}
}
}

function report() {
for (const w of warnings) console.warn(`⚠ ${w}`);
if (errors.length > 0) {
console.error(`\n✖ Validation failed with ${errors.length} error(s):\n`);
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
console.log(
`✔ Validated ${skillsChecked.length} skill(s): ${skillsChecked.join(", ") || "(none)"}`
);
process.exit(0);
}

main();
Loading
Loading