diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 183172c..a8ba24b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Thanks for your interest in contributing! Here's how you can help. git clone https://github.com/sametcelikbicak/rolecraft.git cd rolecraft npm link # now `rolecraft` runs from your local checkout -npm test # 676+ tests should pass +npm test # 721+ tests should pass ``` **Requirements:** Node.js >= 20, no other dependencies. diff --git a/README.md b/README.md index 95f608a..03f574f 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,7 @@ cd rolecraft npm link # rolecraft CLI runs from local checkout npm install # for docs site (VitePress) npm run docs:dev # local docs preview -npm test # 678 tests, 0 fails expected +npm test # 721+ tests, 0 fails expected ``` [→ Contributing guide](CONTRIBUTING.md) diff --git a/bin/rolecraft.js b/bin/rolecraft.js index 3a44f41..c047045 100755 --- a/bin/rolecraft.js +++ b/bin/rolecraft.js @@ -81,7 +81,8 @@ ${agentFlags.join('\n')} --frozen-lockfile Fail if skill already installed --symlink Install as symlink instead of copy --copy Install as copy (default) - --interactive Choose and install a skill from search results + --list List available skills from a source without installing + --skill Install specific skills by name (comma-separated, e.g. "skill1,skill2") Examples: rolecraft install ./my-skill @@ -128,6 +129,12 @@ export async function main() { options.dryRun = flags.includes('--dry-run') options.yes = flags.includes('--yes') || flags.includes('-y') options.noMcp = flags.includes('--no-mcp') + options.list = flags.includes('--list') + + const skillIndex = flags.indexOf('--skill') + if (skillIndex !== -1 && flags[skillIndex + 1] && !flags[skillIndex + 1].startsWith('-')) { + options.skill = flags[skillIndex + 1].split(',').map(s => s.trim()) + } await installCommand(source, options) break diff --git a/docs/commands/install.md b/docs/commands/install.md index 3ecb0f2..fa7dd15 100644 --- a/docs/commands/install.md +++ b/docs/commands/install.md @@ -2,6 +2,10 @@ Install a skill from a local path, GitHub repository, or npm package. +Supports **single-skill** and **multi-skill** repositories. If a source contains +multiple `SKILL.md` files (e.g. under `skills/`), you will be prompted to +select which ones to install. + ## Usage ```bash @@ -27,9 +31,11 @@ Shorthand `owner/repo`: ```bash rolecraft install sametcelikbicak/task-decomposer rolecraft install sametcelikbicak/coverage-guard +rolecraft install mattpocock/skills ``` -The CLI clones with `--depth 1`, finds `SKILL.md` recursively, installs it, and cleans up. +The CLI clones with `--depth 1`, discovers all `SKILL.md` files (including +those under `skills/`, `.agents/skills/`, etc.), and lets you choose. ### npm package @@ -42,7 +48,18 @@ rolecraft install npm:package@1.0.0 rolecraft install npm:@scope/package@latest ``` -The CLI fetches package metadata from the npm registry, downloads and extracts the tarball, finds `SKILL.md` recursively, installs it, and cleans up. +The CLI fetches package metadata from the npm registry, downloads and extracts +the tarball, finds `SKILL.md` recursively, installs it, and cleans up. + +## Selection flags + +| Flag | Description | +| ------------------------- | ------------------------------------------------------ | +| `--list` | List available skills from the source without installing | +| `--skill ` | Install specific skills by name (comma-separated) | + +Without these flags and with more than one skill found, you will be prompted +interactively to select which skills to install. ## Scope flags @@ -74,6 +91,15 @@ rolecraft install ./my-skill # Install from GitHub rolecraft install sametcelikbicak/task-decomposer +# Install from a multi-skill repo (interactive selection) +rolecraft install mattpocock/skills + +# List skills in a repo without installing +rolecraft install mattpocock/skills --list + +# Install specific skills by name (comma-separated) +rolecraft install mattpocock/skills --skill "grill-me,tdd" + # Install from npm rolecraft install npm:some-skill-package rolecraft install npm:@org/skill-package@1.0.0 @@ -96,3 +122,81 @@ rolecraft install ./my-skill --dry-run # Fail if already installed rolecraft install ./my-skill --frozen-lockfile ``` + +## Multi-skill repositories + +When a source contains multiple `SKILL.md` files (e.g., `mattpocock/skills` has +15+ skills under `skills/engineering/` and `skills/productivity/`), the CLI: + +1. Discovers all skills by scanning `skills/`, `.agents/skills/`, and other + known container directories, plus a recursive fallback search (max depth 3). +2. If `--list` is passed, prints all available skills and exits. +3. If `--skill` is passed, installs only the matching skills. +4. If `--yes` is passed, installs all skills without prompting. +5. Otherwise, shows an interactive numbered list to select skills. + +Each skill is installed to its own subdirectory (slug-based name) under the +target agent's skills directory. + +### Interactive selection example + +```text +$ rolecraft install mattpocock/skills + +Resolving skills... +Found 15 skill(s) + + 1. ask-matt + slug: ask-matt + 2. domain-modeling + slug: domain-modeling + 3. diagnosing-bugs + slug: diagnosing-bugs + 4. grill-me + slug: grill-me + 5. grill-with-docs + slug: grill-with-docs + 6. grilling + slug: grilling + ... + +Enter numbers (space-separated) to select, "all" for all, or press Enter to confirm selection: 4 5 + + grill-me selected + grill-with-docs selected +``` + +### `--list` output example + +```text +$ rolecraft install mattpocock/skills --list + +Found 15 skill(s) + + ask-matt + Slug: ask-matt + Owner: mattpocock + Description: Ask which skill or flow fits your situation + Files: SKILL.md + + grill-me + Slug: grill-me + Owner: mattpocock + Description: Get relentlessly interviewed about a plan or design + Files: SKILL.md + + ... +``` + +### `--skill` usage example + +```bash +# Install only specific skills by name (comma-separated) +rolecraft install mattpocock/skills --skill "grill-me,tdd" + +# Install a single skill +rolecraft install mattpocock/skills --skill diagnose-bugs + +# Non-interactive: install all skills with --yes +rolecraft install mattpocock/skills --yes --global +``` diff --git a/src/commands/install.js b/src/commands/install.js index dd77bf4..bacfc8f 100644 --- a/src/commands/install.js +++ b/src/commands/install.js @@ -1,6 +1,6 @@ import { createInterface as defaultCreateInterface } from 'node:readline' import { stdin as input, stdout as output } from 'node:process' -import { resolveSource } from '../utils/resolver.js' +import { resolveSource, resolveSkills } from '../utils/resolver.js' import { installSkill } from '../utils/installer.js' import { scanSkill, formatSecurityReport } from '../utils/security.js' import { parseMcpServersFromSkill, resolveMcpSource, addMcpServer, getSupportedMcpAgents } from '../utils/mcp.js' @@ -47,17 +47,81 @@ async function askScope() { } } +async function selectSkillsInteractive(skills) { + const choices = skills.map((s, i) => ({ + index: i, + label: s.name, + description: s.description || `slug: ${s.slug}`, + selected: false, + })) + + console.log() + for (let i = 0; i < choices.length; i++) { + console.log(` ${i + 1}. ${choices[i].label}`) + if (choices[i].description) { + console.log(` ${choices[i].description}`) + } + } + + while (true) { + console.log() + const answer = await askQuestion('Enter numbers (space-separated) to select, "all" for all, or press Enter to confirm selection: ') + + if (answer === '' || answer === null || answer === undefined) { + const selected = choices.filter(c => c.selected).map(c => skills[c.index]) + if (selected.length === 0) { + const retry = await askQuestion('No skills selected. Try again? [Y/n] ') + if (retry === 'n' || retry === 'no') return null + continue + } + return selected + } + + if (answer === 'all') { + for (const c of choices) c.selected = true + const all = skills.slice() + console.log(` Selected all ${all.length} skills.`) + return all + } + + const parts = answer.split(/\s+/).map(p => parseInt(p, 10)) + for (const p of parts) { + if (!isNaN(p) && p >= 1 && p <= choices.length) { + choices[p - 1].selected = !choices[p - 1].selected + const status = choices[p - 1].selected ? 'selected' : 'deselected' + console.log(` ${choices[p - 1].label} ${status}`) + } + } + } +} + export async function installCommand(source, options) { const hasScopeFlags = options.global || options.project || agents.some(a => options[a.flag]) const scope = hasScopeFlags ? options : options.yes ? { global: false, project: true } : await askScope() + if (options.list) { + const spinner = createSpinner('Resolving skills...') + spinner.start() + const skills = await resolveSkills(source) + spinner.succeed(`Found ${skills.length} skill(s)`) + console.log() + for (const s of skills) { + console.log(` ${s.name}`) + console.log(` Slug: ${s.slug}`) + console.log(` Owner: ${s.owner}`) + if (s.description) console.log(` Description: ${s.description}`) + console.log(` Files: ${s.files.join(', ')}`) + console.log() + } + return + } + if (options.frozenLockfile) { const { readLock, getProjectLockPath } = await import('../utils/lockfile.js') const [globalLock, projectLock] = await Promise.all([ readLock(), readLock(getProjectLockPath(process.cwd())).catch(() => ({ skills: {} })), ]) - const resolveSource = (await import('../utils/resolver.js')).resolveSource const { slug } = await resolveSource(source) const existing = globalLock.skills[slug] || projectLock.skills[slug] if (existing) { @@ -65,32 +129,34 @@ export async function installCommand(source, options) { } } - const spinner = createSpinner(`šŸ” Resolving ${source}...`) + const spinner = createSpinner('Resolving skills...') spinner.start() - const resolved = await resolveSource(source) - spinner.succeed(`šŸ“¦ Found: ${resolved.name}`) - console.log(` Slug: ${resolved.slug}`) - console.log(` Owner: ${resolved.owner}`) - console.log(` Files: ${resolved.files.join(', ')}`) - - const security = scanSkill(resolved) - console.log(formatSecurityReport(security)) - const level = security.score >= 90 ? 'safe' : security.score >= 70 ? 'review' : 'danger' - - if (level === 'danger' && !options.yes) { - throw new Error('Install blocked by security scan. Use --yes to force install.') - } - - if (level === 'review' && !options.yes) { - const answer = await askQuestion('\nāš ļø Continue with installation? [y/N] ') - if (answer !== 'y' && answer !== 'yes') { + const allSkills = await resolveSkills(source) + spinner.succeed(`Found ${allSkills.length} skill(s)`) + + let selectedSkills + if (options.skill && options.skill.length > 0) { + const skillNames = options.skill.map(n => n.toLowerCase()) + selectedSkills = allSkills.filter(s => + skillNames.includes(s.name.toLowerCase()) || skillNames.includes(s.slug.toLowerCase()) + ) + if (selectedSkills.length === 0) { + throw new Error(`No matching skills found for: ${options.skill.join(', ')}. Available: ${allSkills.map(s => s.name).join(', ')}`) + } + } else if (allSkills.length === 1) { + selectedSkills = allSkills + } else if (options.yes) { + selectedSkills = allSkills + console.log(` Installing all ${allSkills.length} skills`) + } else { + const result = await selectSkillsInteractive(allSkills) + if (!result) { console.log('Install cancelled.') return } + selectedSkills = result } - console.log() - const targets = [] if (scope.global) targets.push('agents') if (scope.project) targets.push('project') @@ -100,36 +166,69 @@ export async function installCommand(source, options) { if (options.dryRun) { const mode = options.symlink ? 'symlink' : 'copy' - console.log(`\nšŸ“‹ [dry-run] Would install skill:\n`) - console.log(` Skill: ${resolved.name} (${resolved.slug})`) - console.log(` Source: ${source}`) - console.log(` Mode: ${mode}`) - console.log(` Files: ${resolved.files.join(', ')}`) - console.log(` Targets: ${targets.join(', ')}\n`) + console.log(`\n[dry-run] Would install ${selectedSkills.length} skill(s):\n`) + for (const skill of selectedSkills) { + console.log(` Skill: ${skill.name} (${skill.slug})`) + console.log(` Source: ${source}`) + console.log(` Mode: ${mode}`) + console.log(` Files: ${skill.files.join(', ')}`) + console.log(` Targets: ${targets.join(', ')}`) + console.log() + } return } - const results = await installSkill(resolved, targets, options.symlink ? 'symlink' : 'copy') + for (const skill of selectedSkills) { + const resolved = { + ...skill, + sourcePath: skill.sourcePath || source, + sourceType: skill.sourceType || 'local', + } - console.log('āœ… Installed successfully:\n') - for (const r of results) { - console.log(` ${r.label} → ${r.path}`) - } + console.log() + console.log(` Skill: ${resolved.name}`) + console.log(` Slug: ${resolved.slug}`) + console.log(` Owner: ${resolved.owner}`) + console.log(` Files: ${resolved.files.join(', ')}`) + + const security = scanSkill(resolved) + console.log(formatSecurityReport(security)) + const level = security.score >= 90 ? 'safe' : security.score >= 70 ? 'review' : 'danger' + + if (level === 'danger' && !options.yes) { + throw new Error(`Install of "${resolved.name}" blocked by security scan. Use --yes to force install.`) + } + + if (level === 'review' && !options.yes) { + const answer = await askQuestion(`\n "${resolved.name}" requires review. Continue? [y/N] `) + if (answer !== 'y' && answer !== 'yes') { + console.log(` Skipping "${resolved.name}".`) + continue + } + } + + const results = await installSkill(resolved, targets, options.symlink ? 'symlink' : 'copy') + + console.log(`\n Installed "${resolved.name}":`) + for (const r of results) { + console.log(` ${r.label} -> ${r.path}`) + } - if (resolved.content && !options.noMcp) { - const mcpServers = parseMcpServersFromSkill(resolved.content) - if (mcpServers.length > 0) { - console.log(`\nšŸ”§ Skill includes ${mcpServers.length} MCP server(s). Installing...`) - const supportedAgents = getSupportedMcpAgents() - const mcpTargets = targets.filter(t => t !== 'project' && supportedAgents.includes(t)) - for (const server of mcpServers) { - const resolvedMcp = resolveMcpSource(server.source) - let installedCount = 0 - for (const agent of mcpTargets) { - const ok = await addMcpServer(agent, server.name, resolvedMcp) - if (ok) installedCount++ + if (resolved.content && !options.noMcp) { + const mcpServers = parseMcpServersFromSkill(resolved.content) + if (mcpServers.length > 0) { + console.log(`\n Skill includes ${mcpServers.length} MCP server(s). Installing...`) + const supportedAgents = getSupportedMcpAgents() + const mcpTargets = targets.filter(t => t !== 'project' && supportedAgents.includes(t)) + for (const server of mcpServers) { + const resolvedMcp = resolveMcpSource(server.source) + let installedCount = 0 + for (const agent of mcpTargets) { + const ok = await addMcpServer(agent, server.name, resolvedMcp) + if (ok) installedCount++ + } + console.log(` ${installedCount}/${mcpTargets.length} agents: MCP server "${server.name}" installed`) } - console.log(` ${installedCount}/${mcpTargets.length} agents: MCP server "${server.name}" installed`) } } } diff --git a/src/commands/install.test.js b/src/commands/install.test.js index 0082489..d744b5d 100644 --- a/src/commands/install.test.js +++ b/src/commands/install.test.js @@ -186,13 +186,13 @@ describe('askScope', () => { try { await assert.rejects( () => installModule.installCommand(dangerDir, { global: true }), - /Install blocked by security scan/, + /blocked by security scan/, ) } finally { restore() } - assert.ok(!logs.some(l => l.includes('Installed successfully'))) + assert.ok(!logs.some(l => l.includes('Installed'))) assert.equal(existsSync(join(tempDir, '.agents', 'skills', 'test-danger')), false) }) @@ -235,8 +235,8 @@ describe('askScope', () => { installModule.resetAskQuestion() } - assert.ok(logs.some(l => l.includes('Install cancelled'))) - assert.ok(!logs.some(l => l.includes('Installed successfully'))) + assert.ok(logs.some(l => l.includes('Skipping'))) + assert.ok(!logs.some(l => l.includes('Installed'))) assert.equal(existsSync(join(tempDir, '.agents', 'skills', 'test-review-cancel')), false) }) @@ -272,6 +272,63 @@ describe('askScope', () => { assert.ok(logs.some(l => l.includes('my-test-mcp'))) }) + it('installs with --list flag shows skills without installing', async () => { + const listDir = join(tempDir, 'list-skill') + mkdirSync(listDir, { recursive: true }) + writeFileSync(join(listDir, 'SKILL.md'), '---\nname: list-me\ndescription: For listing\n---\nContent') + + const { logs, restore } = capture('log') + await installModule.installCommand(listDir, { list: true, global: true }) + restore() + + assert.ok(logs.some(l => l.includes('list-me'))) + assert.ok(logs.some(l => l.includes('Found'))) + assert.ok(!logs.some(l => l.includes('Installed'))) + }) + + it('--skill flag selects specific skill from multi-skill source', async () => { + const multiDir = join(tempDir, 'multi-select-skill') + mkdirSync(join(multiDir, 'skills', 'alpha'), { recursive: true }) + mkdirSync(join(multiDir, 'skills', 'beta'), { recursive: true }) + writeFileSync(join(multiDir, 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\nslug: multi/alpha\ndescription: A\n---\nContent') + writeFileSync(join(multiDir, 'skills', 'beta', 'SKILL.md'), '---\nname: beta\nslug: multi/beta\ndescription: B\n---\nContent') + + const { logs, restore } = capture('log') + await installModule.installCommand(multiDir, { skill: ['alpha'], global: true }) + restore() + + assert.ok(logs.some(l => l.includes('Installed'))) + assert.ok(logs.some(l => l.includes('alpha'))) + assert.ok(!logs.some(l => l.includes('beta'))) + }) + + it('--skill flag throws for non-matching names', async () => { + const multiDir = join(tempDir, 'multi-no-match') + mkdirSync(join(multiDir, 'skills', 'only-one'), { recursive: true }) + writeFileSync(join(multiDir, 'skills', 'only-one', 'SKILL.md'), '---\nname: only-one\ndescription: Lonely\n---\nC') + + await assert.rejects( + () => installModule.installCommand(multiDir, { skill: ['nonexistent'], global: true }), + /No matching skills found/, + ) + }) + + it('installs all skills with --yes from multi-skill source', async () => { + const multiDir = join(tempDir, 'multi-yes-skill') + mkdirSync(join(multiDir, 'skills', 's1'), { recursive: true }) + mkdirSync(join(multiDir, 'skills', 's2'), { recursive: true }) + writeFileSync(join(multiDir, 'skills', 's1', 'SKILL.md'), '---\nname: s1\ndescription: First\n---\nA') + writeFileSync(join(multiDir, 'skills', 's2', 'SKILL.md'), '---\nname: s2\ndescription: Second\n---\nB') + + const { logs, restore } = capture('log') + await installModule.installCommand(multiDir, { global: true, yes: true }) + restore() + + assert.ok(logs.some(l => l.includes('s1'))) + assert.ok(logs.some(l => l.includes('s2'))) + assert.ok(logs.some(l => l.includes('Installed'))) + }) + it('--no-mcp skips MCP server installation', async () => { const noMcpSkill = join(tempDir, 'no-mcp-skill') mkdirSync(noMcpSkill, { recursive: true }) diff --git a/src/utils/resolver.js b/src/utils/resolver.js index 76ae2db..904b233 100644 --- a/src/utils/resolver.js +++ b/src/utils/resolver.js @@ -88,8 +88,63 @@ async function readFileContents(skillDir) { return fileContents } +async function enrichSkill(found) { + const fileContents = await readFileContents(found.dir) + const files = Object.keys(fileContents) + return { + name: found.name, + slug: found.slug, + owner: found.owner, + description: found.description, + content: found.content, + files, + fileContents, + contentSha: computeContentHash(fileContents), + skillDir: found.dir, + } +} + async function scanForSkill(dir, maxDepth = 3) { const results = [] + const seenDirs = new Set() + + async function tryAddSkill(skillDir) { + if (seenDirs.has(skillDir)) return + const skillPath = join(skillDir, 'SKILL.md') + try { + const c = await readFile(skillPath, 'utf-8') + const meta = parseMetadata(c) + seenDirs.add(skillDir) + results.push({ dir: skillDir, ...meta, content: c }) + } catch { + // skip unreadable or missing files + } + } + + const containerCandidates = [ + join(dir, 'skills'), + join(dir, '.agents', 'skills'), + join(dir, '.claude', 'skills'), + join(dir, '.cursor', 'skills'), + ] + + for (const containerDir of containerCandidates) { + let entries + try { entries = await readdir(containerDir, { withFileTypes: true }) } catch { continue } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === '.git') continue + const skillDir = join(containerDir, entry.name) + await tryAddSkill(skillDir) + if (!seenDirs.has(skillDir)) { + let subEntries + try { subEntries = await readdir(skillDir, { withFileTypes: true }) } catch { continue } + for (const sub of subEntries) { + if (!sub.isDirectory() || sub.name === '.git') continue + await tryAddSkill(join(skillDir, sub.name)) + } + } + } + } async function scan(currentDir, depth = 0) { if (depth > maxDepth) return @@ -105,12 +160,16 @@ async function scanForSkill(dir, maxDepth = 3) { if (entry.isDirectory()) { await scan(fullPath, depth + 1) } else if (entry.name === 'SKILL.md') { - try { - const c = await readFile(fullPath, 'utf-8') - const meta = parseMetadata(c) - results.push({ dir: dirname(fullPath), ...meta, content: c }) - } catch { - // skip unreadable files + const skillDir = dirname(fullPath) + if (!seenDirs.has(skillDir)) { + try { + const c = await readFile(fullPath, 'utf-8') + const meta = parseMetadata(c) + seenDirs.add(skillDir) + results.push({ dir: skillDir, ...meta, content: c }) + } catch { + // skip unreadable files + } } } } @@ -120,7 +179,7 @@ async function scanForSkill(dir, maxDepth = 3) { return results } -async function resolveLocal(source) { +async function resolveLocalInternal(source) { const expanded = source.replace(/^~/, homedir()) let skillDir @@ -144,7 +203,11 @@ async function resolveLocal(source) { const meta = parseMetadata(content) const fileContents = await readFileContents(skillDir) const files = Object.keys(fileContents) - return { ...meta, content, files, fileContents, contentSha: computeContentHash(fileContents), skillDir, sourcePath: source, sourceType: 'local' } + return { + skills: [{ ...meta, content, files, fileContents, contentSha: computeContentHash(fileContents), skillDir }], + sourcePath: source, + sourceType: 'local', + } } catch { // direct SKILL.md not found, scan recursively } @@ -154,14 +217,11 @@ async function resolveLocal(source) { throw new Error(`No SKILL.md found in ${skillDir}`) } - const skill = found[0] - const fileContents = await readFileContents(skill.dir) - const files = Object.keys(fileContents) - - return { ...skill, files, fileContents, contentSha: computeContentHash(fileContents), skillDir: skill.dir, sourcePath: source, sourceType: 'local' } + const enriched = await Promise.all(found.map(enrichSkill)) + return { skills: enriched, sourcePath: source, sourceType: 'local' } } -async function resolveGitHub(source) { +async function resolveGitHubInternal(source) { const tmpDir = mkdtempSync(join(tmpdir(), 'rolecraft-gh-')) const url = `https://github.com/${source}.git` @@ -179,21 +239,16 @@ async function resolveGitHub(source) { throw new Error(`No SKILL.md found in GitHub repo ${source}`) } - const skill = found[0] - const fileContents = await readFileContents(skill.dir) - const files = Object.keys(fileContents) - const owner = source.split('/')[0] - return { - ...skill, - owner: skill.owner === 'local' ? owner : skill.owner, - slug: skill.slug === 'unknown' || skill.slug === skill.name ? `${owner}/${skill.name}` : skill.slug, - files, - fileContents, - contentSha: computeContentHash(fileContents), - sourcePath: source, - sourceType: 'github', - } + const enriched = await Promise.all(found.map(async (f) => { + const e = await enrichSkill(f) + return { + ...e, + owner: e.owner === 'local' ? owner : e.owner, + slug: e.slug === 'unknown' || e.slug === e.name ? `${owner}/${e.name}` : e.slug, + } + })) + return { skills: enriched, sourcePath: source, sourceType: 'github' } } finally { await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) } @@ -213,7 +268,7 @@ function normalizeGitUrl(source) { return source } -async function resolveGitUrl(source) { +async function resolveGitUrlInternal(source) { const tmpDir = mkdtempSync(join(tmpdir(), 'rolecraft-git-')) const url = normalizeGitUrl(source) @@ -230,22 +285,18 @@ async function resolveGitUrl(source) { throw new Error(`No SKILL.md found in repository ${source}`) } - const skill = found[0] - const fileContents = await readFileContents(skill.dir) - const files = Object.keys(fileContents) + const enriched = await Promise.all(found.map(async (f) => { + const e = await enrichSkill(f) + return { + ...e, + owner: e.owner === 'local' ? 'remote' : e.owner, + slug: e.slug === 'unknown' || e.slug === e.name ? `remote/${e.name}` : e.slug, + } + })) await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) - return { - ...skill, - owner: skill.owner === 'local' ? 'remote' : skill.owner, - slug: skill.slug === 'unknown' || skill.slug === skill.name ? `remote/${skill.name}` : skill.slug, - files, - fileContents, - contentSha: computeContentHash(fileContents), - sourcePath: source, - sourceType: 'git', - } + return { skills: enriched, sourcePath: source, sourceType: 'git' } } function isNpmRef(source) { @@ -321,7 +372,7 @@ async function downloadFile(url, dest) { await response.body.pipeTo(writable) } -async function resolveNpm(source) { +async function resolveNpmInternal(source) { const { pkgName, version } = parseNpmRef(source) const encodedName = pkgName.replace(/\//g, '%2F') @@ -363,37 +414,49 @@ async function resolveNpm(source) { throw new Error(`No SKILL.md found in npm package ${pkgName}@${ver}`) } - const skill = found[0] - const fileContents = await readFileContents(skill.dir) - const files = Object.keys(fileContents) - - return { - ...skill, - owner: skill.owner === 'local' ? pkgName : skill.owner, - slug: skill.slug === 'unknown' || skill.slug === skill.name ? `${pkgName}/${skill.name}` : skill.slug, - files, - fileContents, - contentSha: computeContentHash(fileContents), - sourcePath: source, - sourceType: 'npm', - } + const enriched = await Promise.all(found.map(async (f) => { + const e = await enrichSkill(f) + return { + ...e, + owner: e.owner === 'local' ? pkgName : e.owner, + slug: e.slug === 'unknown' || e.slug === e.name ? `${pkgName}/${e.name}` : e.slug, + } + })) + return { skills: enriched, sourcePath: source, sourceType: 'npm' } } finally { await rm(tmpDir, { recursive: true, force: true }).catch(() => {}) } } -export async function resolveSource(source) { +function pickFirst({ skills, sourcePath, sourceType }) { + if (skills.length === 0) { + throw new Error('No skills found') + } + return { ...skills[0], sourcePath, sourceType } +} + +async function resolveAll(source) { if (isNpmRef(source)) { - return await resolveNpm(source) + return await resolveNpmInternal(source) } if (isGitHubRef(source)) { - return await resolveGitHub(source) + return await resolveGitHubInternal(source) } if (isLocalPath(source)) { - return await resolveLocal(source) + return await resolveLocalInternal(source) } if (isGitUrl(source)) { - return await resolveGitUrl(source) + return await resolveGitUrlInternal(source) } throw new Error(`Invalid source: "${source}". Use a local path (./, /, ~), GitHub ref (owner/repo), git URL, or npm package (npm:package)`) } + +export async function resolveSource(source) { + const result = await resolveAll(source) + return pickFirst(result) +} + +export async function resolveSkills(source) { + const result = await resolveAll(source) + return result.skills.map(s => ({ ...s, sourcePath: result.sourcePath, sourceType: result.sourceType })) +} diff --git a/src/utils/resolver.test.js b/src/utils/resolver.test.js index 40bca6d..a2debd8 100644 --- a/src/utils/resolver.test.js +++ b/src/utils/resolver.test.js @@ -303,6 +303,66 @@ Just content }) }) + describe('resolveSkills', () => { + it('returns all skills from a multi-skill local source', async () => { + const multiDir = join(tempDir, 'multi-skill') + const engDir = join(multiDir, 'skills', 'engineering', 'skill-a') + const prodDir = join(multiDir, 'skills', 'productivity', 'skill-b') + mkdirSync(engDir, { recursive: true }) + mkdirSync(prodDir, { recursive: true }) + writeFileSync(join(engDir, 'SKILL.md'), '---\nname: skill-a\nslug: eng/skill-a\nowner: tester\ndescription: First skill\n---\nContent A') + writeFileSync(join(prodDir, 'SKILL.md'), '---\nname: skill-b\nslug: prod/skill-b\nowner: tester\ndescription: Second skill\n---\nContent B') + writeFileSync(join(engDir, 'helper.js'), 'x') + + const result = await resolverModule.resolveSkills(multiDir) + + assert.equal(result.length, 2) + assert.ok(result.some(s => s.name === 'skill-a')) + assert.ok(result.some(s => s.name === 'skill-b')) + const skillA = result.find(s => s.name === 'skill-a') + assert.ok(skillA.files.includes('SKILL.md')) + assert.ok(skillA.files.includes('helper.js')) + assert.equal(skillA.owner, 'tester') + assert.equal(skillA.sourceType, 'local') + }) + + it('resolveSkills from single-skill source returns array of 1', async () => { + const singleDir = join(tempDir, 'single-resolve-skills') + mkdirSync(singleDir, { recursive: true }) + writeFileSync(join(singleDir, 'SKILL.md'), '---\nname: solo\ndescription: A lone skill\n---\nContent') + + const result = await resolverModule.resolveSkills(singleDir) + + assert.equal(result.length, 1) + assert.equal(result[0].name, 'solo') + assert.equal(result[0].sourceType, 'local') + }) + + it('resolveSkills from GitHub returns all skills', async () => { + await freshImport() + resolverModule.setSpawnSync((cmd, args) => { + if (cmd === 'git' && args[0] === 'clone') { + const d = args[4] + mkdirSync(join(d, 'skills', 'alpha'), { recursive: true }) + mkdirSync(join(d, 'skills', 'beta'), { recursive: true }) + writeFileSync(join(d, 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\nslug: gh/alpha\ndescription: Alpha\n---\nA') + writeFileSync(join(d, 'skills', 'beta', 'SKILL.md'), '---\nname: beta\nslug: gh/beta\ndescription: Beta\n---\nB') + } + return { status: 0, stdout: '', stderr: '' } + }) + + const result = await resolverModule.resolveSkills('user/multi-repo') + + assert.equal(result.length, 2) + assert.ok(result.some(s => s.name === 'alpha')) + assert.ok(result.some(s => s.name === 'beta')) + for (const s of result) { + assert.equal(s.owner, 'user') + assert.equal(s.sourceType, 'github') + } + }) + }) + describe('resolveGitHub', () => { it('throws for invalid GitHub ref', async () => {