diff --git a/README.md b/README.md index a678e3d0..defd01d9 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,10 @@ sh1pt automation stagehand setup AI browser automation (Brows sh1pt login (auxiliary) sh1pt secret set|get|list|rm (auxiliary — credentials vault) +sh1pt actions list|search|info discover built-in GitHub Actions packs +sh1pt actions plan|install|audit preview, install, and audit workflow packs +sh1pt skills list|search|info|retrieve discover built-in agent skills +sh1pt skills install [--target T] install guidance into AGENTS.md / CLAUDE.md / Copilot sh1pt skills new|create create sh1pt.skill.json from SKILL.md sh1pt skills publish --all [--dry-run] promote agent skills to uGig/ClawHub/etc. sh1pt skills marketplaces list supported skill marketplaces @@ -336,13 +340,18 @@ sh1pt skills marketplaces list supported skill marketp ### skills ```bash +sh1pt skills list +sh1pt skills info modern-web +sh1pt skills retrieve modern-web +sh1pt skills install modern-web --target agents-md +sh1pt skills install modern-web --target copilot --yes sh1pt skills new --skill-file ./SKILL.md --source-url https://raw.example/SKILL.md --price 0 sh1pt skills publish --all --dry-run sh1pt skills publish --marketplace ugig clawhub goose sh1pt skills marketplaces ``` -`sh1pt skills new` creates a `sh1pt.skill.json` promotion manifest from a local `SKILL.md`. `sh1pt skills publish --all --dry-run` prints the exact uGig/ClawHub/Goose/LobeHub/Kilo/Skillstore/etc. commands or manual steps so agents can register, verify credentials, and promote without guessing. +`sh1pt skills list|info|retrieve|install` works against the CLI's built-in skill catalog, starting with the `modern-web` guidance pack. Install uses a managed block so sh1pt can append or update agent instructions in `AGENTS.md`, `CLAUDE.md`, `.github/copilot-instructions.md`, and similar target files without overwriting unrelated content. `sh1pt skills new` still creates a `sh1pt.skill.json` promotion manifest from a local `SKILL.md`, and `sh1pt skills publish --all --dry-run` prints the exact uGig/ClawHub/Goose/LobeHub/Kilo/Skillstore/etc. commands or manual steps so agents can register, verify credentials, and promote without guessing. Example dry-run shape: diff --git a/packages/cli/package.json b/packages/cli/package.json index 02b37b79..87ea6292 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -26,7 +26,8 @@ "types": "./dist/index.d.ts", "files": [ "bin", - "dist" + "dist", + "skills" ], "publishConfig": { "access": "public" diff --git a/packages/cli/skills/modern-web/guides/modern-web.md b/packages/cli/skills/modern-web/guides/modern-web.md new file mode 100644 index 00000000..2fdd7b1d --- /dev/null +++ b/packages/cli/skills/modern-web/guides/modern-web.md @@ -0,0 +1,8 @@ +Prefer reviewable, framework-native changes over custom glue code. + +- Keep changes small and focused. +- Preserve existing build, test, and lint commands. +- Prefer least-privilege GitHub Actions permissions. +- Pin GitHub Actions to immutable versions when practical. +- Never commit secrets or `.env` files. +- Update user-facing docs when behavior changes. diff --git a/packages/cli/skills/modern-web/sh1pt.skill.json b/packages/cli/skills/modern-web/sh1pt.skill.json new file mode 100644 index 00000000..80f2e124 --- /dev/null +++ b/packages/cli/skills/modern-web/sh1pt.skill.json @@ -0,0 +1,19 @@ +{ + "name": "modern-web", + "publisher": "profullstack", + "type": "skill", + "version": "0.1.0", + "title": "Modern Web Guidance", + "description": "Install reviewable coding-agent guidance for safe, modern web app changes.", + "trustLevel": "verified", + "guide": "guides/modern-web.md", + "targets": [ + "agents-md", + "claude", + "copilot", + "cursor", + "codex", + "openclaw", + "goose" + ] +} diff --git a/packages/cli/src/commands/build-actions.test.ts b/packages/cli/src/commands/build-actions.test.ts index 4f20c6ce..f07df8e7 100644 --- a/packages/cli/src/commands/build-actions.test.ts +++ b/packages/cli/src/commands/build-actions.test.ts @@ -1,12 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { actionsCmd, auditWorkflowContent } from './build-actions.js'; +import { auditWorkflowContent, createActionsCmd } from './build-actions.js'; describe('actions command aliases', () => { + const actionsCmd = createActionsCmd(); it('supports `info` as an alias for `show`', () => { const showCmd = actionsCmd.commands.find((c) => c.name() === 'show'); expect(showCmd).toBeDefined(); expect(showCmd?.aliases()).toContain('info'); }); + + it('supports a search subcommand for pack discovery', () => { + const searchCmd = actionsCmd.commands.find((c) => c.name() === 'search'); + expect(searchCmd).toBeDefined(); + }); }); describe('auditWorkflowContent', () => { diff --git a/packages/cli/src/commands/build-actions.ts b/packages/cli/src/commands/build-actions.ts index f67ad819..df9af6c9 100644 --- a/packages/cli/src/commands/build-actions.ts +++ b/packages/cli/src/commands/build-actions.ts @@ -15,9 +15,6 @@ import { } from '@profullstack/sh1pt-actions-fleet-core'; import { loadBuiltinPacks } from '@profullstack/sh1pt-action-packs'; -export const actionsCmd = new Command('actions') - .description('Install and manage GitHub Actions workflow packs from the sh1pt Actions Store.'); - export interface WorkflowAuditFinding { file: string; rule: string; @@ -61,89 +58,50 @@ function printStatusLine(destination: string, statusKind: string, reason?: strin console.log(` ${tag} ${destination}${suffix}`); } -actionsCmd - .command('list') - .description('List built-in action packs.') - .option('--json', 'emit machine-readable JSON') - .action(async (opts: { json?: boolean }) => { - const catalog = await loadBuiltinPacks(); - const rows = [...catalog.values()] - .map((e) => ({ - id: e.manifest.id, - name: e.manifest.name, - version: e.manifest.version, - categories: e.manifest.categories, - description: e.manifest.description, - })) - .sort((a, b) => a.id.localeCompare(b.id)); - - if (opts.json) { - console.log(JSON.stringify(rows, null, 2)); - return; - } - - if (rows.length === 0) { - console.log(kleur.dim('(no built-in packs)')); - return; - } - - for (const row of rows) { - console.log(`${kleur.bold(row.id)} ${kleur.dim(`v${row.version}`)}`); - console.log(` ${row.name} — ${row.description}`); - console.log(` ${kleur.dim('categories:')} ${row.categories.join(', ')}`); - console.log(); - } - }); - -actionsCmd - .command('show') - .alias('info') - .description('Show details of a single action pack.') - .argument('', 'pack id, e.g. node-pnpm-ci') - .option('--json', 'emit machine-readable JSON') - .action(async (packId: string, opts: { json?: boolean }) => { - const { manifest } = await getCatalogEntry(packId); - if (opts.json) { - console.log(JSON.stringify(manifest, null, 2)); - return; - } +function printCatalogRows(rows: Array<{ + id: string; + name: string; + version: string; + categories: string[]; + description: string; +}>): void { + if (rows.length === 0) { + console.log(kleur.dim('(no built-in packs)')); + return; + } - console.log(kleur.bold(`${manifest.name} (${manifest.id}@${manifest.version})`)); - console.log(manifest.description); + for (const row of rows) { + console.log(`${kleur.bold(row.id)} ${kleur.dim(`v${row.version}`)}`); + console.log(` ${row.name} — ${row.description}`); + console.log(` ${kleur.dim('categories:')} ${row.categories.join(', ')}`); console.log(); - console.log(`${kleur.dim('publisher:')} ${manifest.publisher}`); - console.log(`${kleur.dim('visibility:')} ${manifest.visibility}`); - console.log(`${kleur.dim('license:')} ${manifest.license}`); - console.log(`${kleur.dim('categories:')} ${manifest.categories.join(', ')}`); - console.log(`${kleur.dim('pricing:')} ${manifest.pricing.type}`); - - if (Object.keys(manifest.inputs).length > 0) { - console.log(); - console.log(kleur.bold('Inputs')); - for (const [name, def] of Object.entries(manifest.inputs)) { - const required = def.required ? kleur.yellow(' (required)') : ''; - const dflt = def.default !== undefined ? kleur.dim(` [default: ${def.default}]`) : ''; - const desc = def.description ? ` — ${def.description}` : ''; - console.log(` ${name}${required}${dflt}${desc}`); - } - } - - if (manifest.secrets.length > 0) { - console.log(); - console.log(kleur.bold('Secrets')); - for (const s of manifest.secrets) { - const required = (s.required ?? false) ? kleur.yellow(' (required)') : ''; - const desc = s.description ? ` — ${s.description}` : ''; - console.log(` ${s.name}${required}${desc}`); - } - } + } +} - console.log(); - console.log(kleur.bold('Files')); - for (const f of manifest.files) { - console.log(` ${f.destination} ${kleur.dim(`← ${f.source} · ${f.mergeStrategy}`)}`); - } - }); +async function catalogRows(query?: string): Promise> { + const catalog = await loadBuiltinPacks(); + const needle = query?.trim().toLowerCase(); + return [...catalog.values()] + .map((e) => ({ + id: e.manifest.id, + name: e.manifest.name, + version: e.manifest.version, + categories: e.manifest.categories, + description: e.manifest.description, + })) + .filter((row) => { + if (!needle) return true; + return [row.id, row.name, row.description, ...row.categories] + .some((value) => value.toLowerCase().includes(needle)); + }) + .sort((a, b) => a.id.localeCompare(b.id)); +} export function auditWorkflowContent(file: string, content: string): WorkflowAuditFinding[] { const findings: WorkflowAuditFinding[] = []; @@ -199,59 +157,6 @@ async function findWorkflowFiles(repoDir: string): Promise { } } -actionsCmd - .command('audit') - .description('Audit GitHub workflow files for common security risks.') - .option('-r, --repo ', 'target repo directory', '.') - .option('--strict', 'exit with non-zero code when findings are present') - .option('--json', 'emit machine-readable JSON') - .action(async (opts: { repo: string; strict?: boolean; json?: boolean }) => { - const repoDir = resolve(opts.repo); - const files = await findWorkflowFiles(repoDir); - const findings: WorkflowAuditFinding[] = []; - - for (const file of files) { - const content = await readFile(file, 'utf8'); - findings.push(...auditWorkflowContent(file, content)); - } - - const result = { - repoDir, - filesScanned: files.length, - findings, - riskLevel: findings.some((f) => f.severity === 'high') - ? 'high' - : findings.some((f) => f.severity === 'medium') - ? 'medium' - : findings.length > 0 - ? 'low' - : 'none', - } as const; - - if (opts.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(kleur.bold(`Audit: ${repoDir}`)); - console.log(kleur.dim(`Scanned ${files.length} workflow file(s)`)); - if (findings.length === 0) { - console.log(kleur.green('✔ No findings')); - } else { - console.log(); - for (const finding of findings) { - const color = finding.severity === 'high' ? kleur.red : finding.severity === 'medium' ? kleur.yellow : kleur.cyan; - console.log(`${color(finding.severity.toUpperCase().padEnd(6))} ${finding.file}`); - console.log(` ${finding.rule}: ${finding.message}`); - } - console.log(); - console.log(`${kleur.bold('Risk level:')} ${result.riskLevel}`); - } - } - - if (opts.strict && findings.length > 0) { - process.exitCode = 1; - } - }); - async function buildPlan(packId: string, repoOpt: string, inputs: RenderInputs): Promise { const entry = await getCatalogEntry(packId); const repoDir = resolve(repoOpt); @@ -263,97 +168,238 @@ async function buildPlan(packId: string, repoOpt: string, inputs: RenderInputs): return planDiff({ repoDir, render }); } -actionsCmd - .command('plan') - .description('Render a pack and show planned file changes vs the target repo (no writes).') - .argument('', 'pack id') - .option('-r, --repo ', 'target repo directory', '.') - .option('-i, --input ', 'pack input as key=value (repeatable)') - .option('--json', 'emit machine-readable JSON') - .action(async (packId: string, opts: { repo: string; input?: string[]; json?: boolean }) => { - const inputs = parseInputPairs(opts.input); - const plan = await buildPlan(packId, opts.repo, inputs); - - if (opts.json) { - console.log(JSON.stringify(plan, null, 2)); - return; - } - - console.log(kleur.bold(`Plan: ${plan.packId}@${plan.packVersion} → ${plan.repoDir}`)); - console.log(); - for (const file of plan.files) { - printStatusLine(file.destination, file.status.kind); - } - }); - const OWNER_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.-]+$/; function looksLikeOwnerRepo(repo: string): boolean { return OWNER_REPO_RE.test(repo); } -actionsCmd - .command('install') - .description( - 'Render and install pack files. Local mode (default): writes to a directory unless --dry-run. ' + - 'Remote mode: pass --repo owner/name --pr to open a pull request via the gh CLI.', - ) - .argument('', 'pack id') - .option('-r, --repo ', 'local repo directory or owner/name on GitHub', '.') - .option('-i, --input ', 'pack input as key=value (repeatable)') - .option('--dry-run', 'show planned changes without writing (default unless --yes)') - .option('-y, --yes', 'actually write files (local mode)') - .option('--pr', 'open a pull request against the remote repo (requires --repo owner/name)') - .option('--base ', 'base branch when opening a PR (defaults to the repo default branch)') - .option('--draft', 'open the PR as a draft') - .option('--force', 'overwrite existing unmanaged or other-pack files') - .option('--json', 'emit machine-readable JSON') - .action(async ( - packId: string, - opts: { - repo: string; - input?: string[]; - dryRun?: boolean; - yes?: boolean; - pr?: boolean; - base?: string; - draft?: boolean; - force?: boolean; - json?: boolean; - }, - ) => { - const inputs = parseInputPairs(opts.input); - - if (opts.pr || looksLikeOwnerRepo(opts.repo)) { - if (!looksLikeOwnerRepo(opts.repo)) { - throw new Error(`--pr requires --repo owner/name, got "${opts.repo}"`); +export function createActionsCmd(): Command { + const actionsCmd = new Command('actions') + .description('Install and manage GitHub Actions workflow packs from the sh1pt Actions Store.'); + + actionsCmd + .command('list') + .description('List built-in action packs.') + .option('--json', 'emit machine-readable JSON') + .action(async (opts: { json?: boolean }) => { + const rows = await catalogRows(); + + if (opts.json) { + console.log(JSON.stringify(rows, null, 2)); + return; } - await runRemoteInstall(packId, opts.repo, inputs, opts); - return; - } - const plan = await buildPlan(packId, opts.repo, inputs); - const dryRun = !opts.yes; - const result = await installPlan(plan, { dryRun, force: opts.force ?? false }); + printCatalogRows(rows); + }); - if (opts.json) { - console.log(JSON.stringify(result, null, 2)); - return; - } + actionsCmd + .command('search') + .description('Search built-in action packs.') + .argument('', 'search text') + .option('--json', 'emit machine-readable JSON') + .action(async (query: string, opts: { json?: boolean }) => { + const rows = await catalogRows(query); - const header = dryRun - ? kleur.yellow(`Dry-run: ${plan.packId}@${plan.packVersion} → ${plan.repoDir}`) - : kleur.bold(`Install: ${plan.packId}@${plan.packVersion} → ${plan.repoDir}`); - console.log(header); - console.log(); - for (const file of result.files) { - printStatusLine(file.destination, file.action, file.reason); - } - if (dryRun) { + if (opts.json) { + console.log(JSON.stringify(rows, null, 2)); + return; + } + + printCatalogRows(rows); + }); + + actionsCmd + .command('show') + .alias('info') + .description('Show details of a single action pack.') + .argument('', 'pack id, e.g. node-pnpm-ci') + .option('--json', 'emit machine-readable JSON') + .action(async (packId: string, opts: { json?: boolean }) => { + const { manifest } = await getCatalogEntry(packId); + if (opts.json) { + console.log(JSON.stringify(manifest, null, 2)); + return; + } + + console.log(kleur.bold(`${manifest.name} (${manifest.id}@${manifest.version})`)); + console.log(manifest.description); console.log(); - console.log(kleur.dim('Re-run with --yes to write changes.')); - } - }); + console.log(`${kleur.dim('publisher:')} ${manifest.publisher}`); + console.log(`${kleur.dim('visibility:')} ${manifest.visibility}`); + console.log(`${kleur.dim('license:')} ${manifest.license}`); + console.log(`${kleur.dim('categories:')} ${manifest.categories.join(', ')}`); + console.log(`${kleur.dim('pricing:')} ${manifest.pricing.type}`); + + if (Object.keys(manifest.inputs).length > 0) { + console.log(); + console.log(kleur.bold('Inputs')); + for (const [name, def] of Object.entries(manifest.inputs)) { + const required = def.required ? kleur.yellow(' (required)') : ''; + const dflt = def.default !== undefined ? kleur.dim(` [default: ${def.default}]`) : ''; + const desc = def.description ? ` — ${def.description}` : ''; + console.log(` ${name}${required}${dflt}${desc}`); + } + } + + if (manifest.secrets.length > 0) { + console.log(); + console.log(kleur.bold('Secrets')); + for (const s of manifest.secrets) { + const required = (s.required ?? false) ? kleur.yellow(' (required)') : ''; + const desc = s.description ? ` — ${s.description}` : ''; + console.log(` ${s.name}${required}${desc}`); + } + } + + console.log(); + console.log(kleur.bold('Files')); + for (const f of manifest.files) { + console.log(` ${f.destination} ${kleur.dim(`← ${f.source} · ${f.mergeStrategy}`)}`); + } + }); + + actionsCmd + .command('audit') + .description('Audit GitHub workflow files for common security risks.') + .option('-r, --repo ', 'target repo directory', '.') + .option('--strict', 'exit with non-zero code when findings are present') + .option('--json', 'emit machine-readable JSON') + .action(async (opts: { repo: string; strict?: boolean; json?: boolean }) => { + const repoDir = resolve(opts.repo); + const files = await findWorkflowFiles(repoDir); + const findings: WorkflowAuditFinding[] = []; + + for (const file of files) { + const content = await readFile(file, 'utf8'); + findings.push(...auditWorkflowContent(file, content)); + } + + const result = { + repoDir, + filesScanned: files.length, + findings, + riskLevel: findings.some((f) => f.severity === 'high') + ? 'high' + : findings.some((f) => f.severity === 'medium') + ? 'medium' + : findings.length > 0 + ? 'low' + : 'none', + } as const; + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(kleur.bold(`Audit: ${repoDir}`)); + console.log(kleur.dim(`Scanned ${files.length} workflow file(s)`)); + if (findings.length === 0) { + console.log(kleur.green('✔ No findings')); + } else { + console.log(); + for (const finding of findings) { + const color = finding.severity === 'high' ? kleur.red : finding.severity === 'medium' ? kleur.yellow : kleur.cyan; + console.log(`${color(finding.severity.toUpperCase().padEnd(6))} ${finding.file}`); + console.log(` ${finding.rule}: ${finding.message}`); + } + console.log(); + console.log(`${kleur.bold('Risk level:')} ${result.riskLevel}`); + } + } + + if (opts.strict && findings.length > 0) { + process.exitCode = 1; + } + }); + + actionsCmd + .command('plan') + .description('Render a pack and show planned file changes vs the target repo (no writes).') + .argument('', 'pack id') + .option('-r, --repo ', 'target repo directory', '.') + .option('-i, --input ', 'pack input as key=value (repeatable)') + .option('--json', 'emit machine-readable JSON') + .action(async (packId: string, opts: { repo: string; input?: string[]; json?: boolean }) => { + const inputs = parseInputPairs(opts.input); + const plan = await buildPlan(packId, opts.repo, inputs); + + if (opts.json) { + console.log(JSON.stringify(plan, null, 2)); + return; + } + + console.log(kleur.bold(`Plan: ${plan.packId}@${plan.packVersion} → ${plan.repoDir}`)); + console.log(); + for (const file of plan.files) { + printStatusLine(file.destination, file.status.kind); + } + }); + + actionsCmd + .command('install') + .description( + 'Render and install pack files. Local mode (default): writes to a directory unless --dry-run. ' + + 'Remote mode: pass --repo owner/name --pr to open a pull request via the gh CLI.', + ) + .argument('', 'pack id') + .option('-r, --repo ', 'local repo directory or owner/name on GitHub', '.') + .option('-i, --input ', 'pack input as key=value (repeatable)') + .option('--dry-run', 'show planned changes without writing (default unless --yes)') + .option('-y, --yes', 'actually write files (local mode)') + .option('--pr', 'open a pull request against the remote repo (requires --repo owner/name)') + .option('--base ', 'base branch when opening a PR (defaults to the repo default branch)') + .option('--draft', 'open the PR as a draft') + .option('--force', 'overwrite existing unmanaged or other-pack files') + .option('--json', 'emit machine-readable JSON') + .action(async ( + packId: string, + opts: { + repo: string; + input?: string[]; + dryRun?: boolean; + yes?: boolean; + pr?: boolean; + base?: string; + draft?: boolean; + force?: boolean; + json?: boolean; + }, + ) => { + const inputs = parseInputPairs(opts.input); + + if (opts.pr || looksLikeOwnerRepo(opts.repo)) { + if (!looksLikeOwnerRepo(opts.repo)) { + throw new Error(`--pr requires --repo owner/name, got "${opts.repo}"`); + } + await runRemoteInstall(packId, opts.repo, inputs, opts); + return; + } + + const plan = await buildPlan(packId, opts.repo, inputs); + const dryRun = !opts.yes; + const result = await installPlan(plan, { dryRun, force: opts.force ?? false }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + const header = dryRun + ? kleur.yellow(`Dry-run: ${plan.packId}@${plan.packVersion} → ${plan.repoDir}`) + : kleur.bold(`Install: ${plan.packId}@${plan.packVersion} → ${plan.repoDir}`); + console.log(header); + console.log(); + for (const file of result.files) { + printStatusLine(file.destination, file.action, file.reason); + } + if (dryRun) { + console.log(); + console.log(kleur.dim('Re-run with --yes to write changes.')); + } + }); + + return actionsCmd; +} async function runRemoteInstall( packId: string, diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 1411de2c..42ff02c2 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -8,7 +8,7 @@ import kleur from 'kleur'; import { describeInput, resolveInput } from '../input.js'; import type { ResolvedInput } from '../input.js'; import { entityCmd } from './entity.js'; -import { actionsCmd } from './build-actions.js'; +import { createActionsCmd } from './build-actions.js'; function run(argv: string[], env?: Record): number { console.log(kleur.cyan(`→ ${argv.join(' ')}`)); @@ -185,7 +185,7 @@ buildCmd.addCommand(entityCmd); // Actions Store / Actions Fleet — install GitHub Actions workflow packs. // See docs/prd/actions-fleet.md. -buildCmd.addCommand(actionsCmd); +buildCmd.addCommand(createActionsCmd()); // Maintainer ops — lockstep version bump for the three published sh1pt // packages (core / policy / cli). Wraps the root-level `pnpm version:*` diff --git a/packages/cli/src/commands/skills.test.ts b/packages/cli/src/commands/skills.test.ts index 634a5982..0f23e4ca 100644 --- a/packages/cli/src/commands/skills.test.ts +++ b/packages/cli/src/commands/skills.test.ts @@ -1,5 +1,107 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { skillsCmd } from './skills.js'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + loadBuiltinSkills, + planSkillInstall, + resolveSkillTargetPath, + skillsCmd, +} from './skills.js'; + +describe('builtin skills', () => { + it('loads the modern-web skill', async () => { + const catalog = await loadBuiltinSkills(); + const entry = catalog.get('modern-web'); + expect(entry).toBeDefined(); + expect(entry?.manifest.title).toBe('Modern Web Guidance'); + expect(entry?.content).toContain('Prefer reviewable, framework-native changes'); + }); + + it('maps supported targets to expected paths', () => { + expect(resolveSkillTargetPath('agents-md', 'modern-web')).toBe('AGENTS.md'); + expect(resolveSkillTargetPath('claude', 'modern-web')).toBe('CLAUDE.md'); + expect(resolveSkillTargetPath('copilot', 'modern-web')).toBe('.github/copilot-instructions.md'); + expect(resolveSkillTargetPath('cursor', 'modern-web')).toBe('.cursor/rules/modern-web.mdc'); + }); + + it('appends a managed block to existing content', async () => { + const catalog = await loadBuiltinSkills(); + const entry = catalog.get('modern-web'); + if (!entry) throw new Error('modern-web missing'); + + const plan = planSkillInstall(entry, 'agents-md', '# Existing\n'); + expect(plan.action).toBe('append'); + expect(plan.content).toContain('# Existing'); + expect(plan.content).toContain(''); + }); + + it('updates an existing managed block in place', async () => { + const catalog = await loadBuiltinSkills(); + const entry = catalog.get('modern-web'); + if (!entry) throw new Error('modern-web missing'); + + const existing = [ + '# Existing', + '', + '', + 'old content', + '', + '', + ].join('\n'); + + const plan = planSkillInstall(entry, 'agents-md', existing); + expect(plan.action).toBe('update-managed'); + expect(plan.content).not.toContain('old content'); + expect(plan.content).toContain('Modern Web Guidance'); + }); +}); + +describe('skills install command', () => { + let stdout: string[]; + let tempDir: string; + + beforeEach(() => { + stdout = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + stdout.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + }); + + it('supports dry-run install without writing files', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'sh1pt-skills-')); + const installCmd = skillsCmd.commands.find((c) => c.name() === 'install'); + expect(installCmd).toBeDefined(); + + await installCmd?.parseAsync(['modern-web', '--repo', tempDir, '--target', 'copilot'], { from: 'user' }); + + expect(stdout.join('\n')).toContain('Dry-run'); + expect(existsSync(join(tempDir, '.github', 'copilot-instructions.md'))).toBe(false); + }); + + it('writes the selected target file with managed markers when --yes is used', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'sh1pt-skills-')); + const installCmd = skillsCmd.commands.find((c) => c.name() === 'install'); + expect(installCmd).toBeDefined(); + + await installCmd?.parseAsync(['modern-web', '--repo', tempDir, '--target', 'copilot', '--yes'], { from: 'user' }); + + const file = join(tempDir, '.github', 'copilot-instructions.md'); + expect(existsSync(file)).toBe(true); + const content = readFileSync(file, 'utf8'); + expect(content).toContain(''); + expect(content).toContain('Modern Web Guidance'); + expect(content).toContain('Prefer least-privilege GitHub Actions permissions.'); + }); +}); describe('skills marketplaces --json', () => { let stdout: string[]; diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index 2cb4f5ed..d7ecaf82 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; -import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; -import { basename, dirname, resolve } from 'node:path'; +import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import kleur from 'kleur'; type SkillManifest = { @@ -16,6 +17,34 @@ type SkillManifest = { marketplaces: Record; }; +export type BuiltinSkillManifest = { + name: string; + publisher: string; + type: 'skill'; + version: string; + title: string; + description: string; + trustLevel: 'official' | 'verified' | 'community' | 'experimental' | 'untrusted'; + guide: string; + targets: string[]; +}; + +export type BuiltinSkillEntry = { + manifest: BuiltinSkillManifest; + skillDir: string; + guidePath: string; + content: string; +}; + +export type SkillInstallAction = 'create' | 'append' | 'update-managed'; + +export type SkillInstallPlan = { + destination: string; + target: string; + action: SkillInstallAction; + content: string; +}; + const MARKETPLACES = [ { id: 'ugig', name: 'uGig', method: 'CLI/API', readiness: 'live', command: (m: SkillManifest) => `ugig skills new --title ${q(m.title)} --description ${q(m.description)} --category ${q(m.category ?? 'Automation')} --price ${m.price} --tags ${q(m.tags.join(','))}${m.sourceUrl ? ` --source-url ${q(m.sourceUrl)}` : ''}` }, { id: 'clawhub', name: 'ClawHub', method: 'CLI', readiness: 'live', command: (m: SkillManifest) => `npm exec --package=clawhub@latest -- clawhub skill publish . --slug ${q(m.name)} --name ${q(m.title)} --version 1.0.0 --tags latest,automation` }, @@ -32,6 +61,18 @@ const MARKETPLACES = [ { id: 'agenthub', name: 'AgentHub / agentskillsmarket.space', method: 'Account import', readiness: 'manual', note: 'Requires account email confirmation, then import the public GitHub skill repo from the submit page.' }, ] as const; +const BUILTIN_SKILLS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills'); + +const SKILL_TARGETS = { + 'agents-md': 'AGENTS.md', + claude: 'CLAUDE.md', + copilot: '.github/copilot-instructions.md', + cursor: '.cursor/rules/{{name}}.mdc', + codex: '.codex/{{name}}.md', + openclaw: '.openclaw/{{name}}.md', + goose: '.goose/{{name}}.md', +} as const; + function slugify(s: string): string { return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 64) || 'my-skill'; } @@ -57,8 +98,236 @@ async function saveManifest(path: string, manifest: SkillManifest): Promise> { + const catalog = new Map(); + const entries = await readdir(BUILTIN_SKILLS_DIR, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillDir = join(BUILTIN_SKILLS_DIR, entry.name); + const manifestPath = join(skillDir, 'sh1pt.skill.json'); + if (!(await exists(manifestPath))) continue; + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as BuiltinSkillManifest; + const guidePath = join(skillDir, manifest.guide); + const content = normalizeText(await readFile(guidePath, 'utf8')); + catalog.set(manifest.name, { manifest, skillDir, guidePath, content }); + } + return catalog; +} + +async function getBuiltinSkill(name: string): Promise { + const catalog = await loadBuiltinSkills(); + const entry = catalog.get(name); + if (!entry) { + const available = [...catalog.keys()].sort().join(', ') || '(none)'; + throw new Error(`skill "${name}" not found. Available: ${available}`); + } + return entry; +} + +function formatSkillRows(entries: BuiltinSkillEntry[], json?: boolean): void { + const rows = entries + .map(({ manifest }) => ({ + name: manifest.name, + title: manifest.title, + version: manifest.version, + trustLevel: manifest.trustLevel, + description: manifest.description, + targets: manifest.targets, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + if (json) { + console.log(JSON.stringify(rows, null, 2)); + return; + } + + if (rows.length === 0) { + console.log(kleur.dim('(no built-in skills)')); + return; + } + + for (const row of rows) { + console.log(`${kleur.bold(row.name)} ${kleur.dim(`v${row.version}`)} ${kleur.cyan(`[${row.trustLevel}]`)}`); + console.log(` ${row.title} — ${row.description}`); + console.log(` ${kleur.dim('targets:')} ${row.targets.join(', ')}`); + console.log(); + } +} + +function skillMarkers(name: string): { start: string; end: string } { + return { + start: ``, + end: ``, + }; +} + +export function resolveSkillTargetPath(target: string, skillName: string): string { + const template = SKILL_TARGETS[target as keyof typeof SKILL_TARGETS]; + if (!template) { + const available = Object.keys(SKILL_TARGETS).sort().join(', '); + throw new Error(`unknown skill target "${target}". Available: ${available}`); + } + return template.replaceAll('{{name}}', skillName); +} + +function renderSkillBlock(entry: BuiltinSkillEntry): string { + const { manifest, content } = entry; + const { start, end } = skillMarkers(manifest.name); + return normalizeText([ + start, + `## sh1pt skill: ${manifest.title}`, + '', + `_Installed from ${manifest.publisher}/${manifest.name}@${manifest.version} · trust: ${manifest.trustLevel}_`, + '', + content.trim(), + end, + '', + ].join('\n')); +} + +export function planSkillInstall(entry: BuiltinSkillEntry, target: string, existingContent?: string): SkillInstallPlan { + const destination = resolveSkillTargetPath(target, entry.manifest.name); + const block = renderSkillBlock(entry); + const existing = existingContent === undefined ? undefined : normalizeText(existingContent); + const { start, end } = skillMarkers(entry.manifest.name); + + if (existing === undefined) { + return { destination, target, action: 'create', content: block }; + } + + if (existing.includes(start) && existing.includes(end)) { + const pattern = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, 'm'); + return { + destination, + target, + action: 'update-managed', + content: normalizeText(existing.replace(pattern, block)), + }; + } + + const separator = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n'; + return { + destination, + target, + action: 'append', + content: normalizeText(`${existing}${separator}${block}`), + }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function printSkillStatusLine(destination: string, action: SkillInstallAction): void { + const colorize = action === 'create' ? kleur.green : action === 'append' ? kleur.yellow : kleur.cyan; + console.log(` ${colorize(action.padEnd(20))} ${destination}`); +} + export const skillsCmd = new Command('skills') - .description('Package and promote SKILL.md agent skills across marketplaces'); + .description('Package, install, and promote SKILL.md agent skills across marketplaces'); + +skillsCmd + .command('list') + .description('List built-in skill packages available for installation') + .option('--json', 'output as JSON') + .action(async (opts: { json?: boolean }) => { + const catalog = await loadBuiltinSkills(); + formatSkillRows([...catalog.values()], opts.json); + }); + +skillsCmd + .command('search') + .description('Search built-in skill packages') + .argument('', 'search text') + .option('--json', 'output as JSON') + .action(async (query: string, opts: { json?: boolean }) => { + const needle = query.trim().toLowerCase(); + const catalog = await loadBuiltinSkills(); + const matches = [...catalog.values()].filter(({ manifest }) => + [manifest.name, manifest.title, manifest.description, manifest.trustLevel, ...manifest.targets] + .some((value) => value.toLowerCase().includes(needle))); + formatSkillRows(matches, opts.json); + }); + +skillsCmd + .command('info') + .description('Show details for a built-in skill package') + .argument('', 'skill name') + .option('--json', 'output as JSON') + .action(async (name: string, opts: { json?: boolean }) => { + const { manifest } = await getBuiltinSkill(name); + if (opts.json) { + console.log(JSON.stringify(manifest, null, 2)); + return; + } + + console.log(kleur.bold(`${manifest.title} (${manifest.name}@${manifest.version})`)); + console.log(manifest.description); + console.log(); + console.log(`${kleur.dim('publisher:')} ${manifest.publisher}`); + console.log(`${kleur.dim('trust level:')} ${manifest.trustLevel}`); + console.log(`${kleur.dim('targets:')} ${manifest.targets.join(', ')}`); + }); + +skillsCmd + .command('retrieve') + .description('Print the contents of a built-in skill guide') + .argument('', 'skill name') + .option('--json', 'output as JSON') + .action(async (name: string, opts: { json?: boolean }) => { + const entry = await getBuiltinSkill(name); + if (opts.json) { + console.log(JSON.stringify({ manifest: entry.manifest, content: entry.content }, null, 2)); + return; + } + process.stdout.write(entry.content); + }); + +skillsCmd + .command('install') + .description('Install a built-in skill into an agent instruction file') + .argument('', 'skill name') + .option('-r, --repo ', 'target repo directory', '.') + .option('--target ', 'install target (agents-md, claude, copilot, cursor, codex, openclaw, goose)', 'agents-md') + .option('--dry-run', 'show planned changes without writing (default unless --yes)') + .option('-y, --yes', 'actually write files') + .option('--json', 'output as JSON') + .action(async (name: string, opts: { repo: string; target: string; yes?: boolean; json?: boolean }) => { + const entry = await getBuiltinSkill(name); + const repoDir = resolve(opts.repo); + const destination = join(repoDir, resolveSkillTargetPath(opts.target, entry.manifest.name)); + const existingContent = await exists(destination) ? await readFile(destination, 'utf8') : undefined; + const plan = planSkillInstall(entry, opts.target, existingContent); + const dryRun = !opts.yes; + + if (!dryRun) { + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, plan.content, 'utf8'); + } + + const result = { repoDir, ...plan, dryRun }; + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + const header = dryRun + ? kleur.yellow(`Dry-run: ${entry.manifest.name}@${entry.manifest.version} → ${repoDir}`) + : kleur.bold(`Install: ${entry.manifest.name}@${entry.manifest.version} → ${repoDir}`); + console.log(header); + console.log(); + printSkillStatusLine(plan.destination, plan.action); + console.log(kleur.dim(` target: ${plan.target}`)); + if (dryRun) { + console.log(); + console.log(kleur.dim('Re-run with --yes to write changes.')); + } + }); skillsCmd .command('new') diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8a16aacf..0f14db17 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import kleur from 'kleur'; import { createRequire } from 'node:module'; import { buildCmd } from './commands/build.js'; +import { createActionsCmd } from './commands/build-actions.js'; import { promoteCmd } from './commands/promote.js'; import { scaleCmd } from './commands/scale.js'; import { iterateCmd } from './commands/iterate.js'; @@ -48,7 +49,8 @@ program.addCommand(loginCmd); program.addCommand(logoutCmd); program.addCommand(secretsCmd); program.addCommand(configCmd); -program.addCommand(skillsCmd); // skills · package/promote SKILL.md agent skills across marketplaces +program.addCommand(createActionsCmd()); // actions · install/audit GitHub Actions workflow packs +program.addCommand(skillsCmd); // skills · package/promote SKILL.md agent skills across marketplaces program.addCommand(agentsCmd); // agents · generate/run/talk with AI coding CLIs program.addCommand(deployCmd); // deploy · provision cloud infrastructure program.addCommand(openapiCmd); // openapi · spec → SDK + MCP server + docs site (Stainless-style)