diff --git a/README.md b/README.md index 38b09bd7..83894678 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ skilld # Add skills for specific package(s) — npm: prefix for registry packages skilld add npm:vue npm:nuxt npm:pinia +# The same prefixes work in the interactive wizard's package prompt + # Add a pre-authored skill from a GitHub repo skilld add gh:vercel-labs/agent-skills diff --git a/src/cli.ts b/src/cli.ts index 4a687a1b..7a5eb93a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { isInteractive, isRunningInsideAgent } from './cli/env.ts' import { formatStatus, getRepoHint, relativeTime } from './cli/intro.ts' import { guard, menuLoop } from './cli/menu.ts' import { hasPrepareHook, suggestPrepareHook } from './cli/prepare-hook.ts' +import { parseSkillInput } from './core/prefix.ts' import { configCommand, configCommandDef } from './commands/config.ts' import { removeCommand, removeCommandDef } from './commands/remove.ts' import { infoCommandDef, statusCommand } from './commands/status.ts' @@ -59,6 +60,23 @@ function deprecatedForwarder( // ── Subcommands (lazy-loaded) ── +/** + * Normalize manually-entered packages. Accepts `npm:` prefixed and bare names; + * other sources are only installable through `skilld add`. + */ +function toPackageNames(tokens: string[]): string[] | null { + const names: string[] = [] + for (const token of tokens) { + const source = parseSkillInput(token) + if (source.type !== 'npm' && source.type !== 'bare') { + p.log.error(`${token} is not an npm package. Install it with \`skilld add ${token}\`.`) + return null + } + names.push(source.package) + } + return names +} + const SUBCOMMAND_NAMES = ['add', 'eject', 'update', 'info', 'list', 'config', 'remove', 'install', 'uninstall', 'search', 'cache', 'validate', 'assemble', 'setup', 'prepare', 'author', 'publish', 'upload', 'login', 'logout', 'whoami', 'pull'] // ── Main command ── @@ -277,7 +295,7 @@ const main = defineCommand({ if (source === 'manual') { const input = await p.text({ message: 'Enter package names (space or comma-separated)', - placeholder: 'vue nuxt pinia', + placeholder: 'vue npm:nuxt pinia', }) if (p.isCancel(input)) { if (!hasPkgJson) { @@ -290,7 +308,10 @@ const main = defineCommand({ p.log.warn('No packages entered') continue } - selected = input.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean) + const names = toPackageNames(input.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean)) + if (!names) + continue + selected = names if (selected.length === 0) { p.log.warn('No valid packages entered') continue @@ -534,11 +555,14 @@ const main = defineCommand({ if (source === 'manual') { const input = guard(await p.text({ message: 'Enter package names (space or comma-separated)', - placeholder: 'vue nuxt pinia', + placeholder: 'vue npm:nuxt pinia', })) if (!input) return - selected = input.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean) + const names = toPackageNames(input.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean)) + if (!names) + return + selected = names if (selected.length === 0) return } diff --git a/src/core/prepare.ts b/src/core/prepare.ts index 27d77fd7..4282d319 100644 --- a/src/core/prepare.ts +++ b/src/core/prepare.ts @@ -29,7 +29,14 @@ export function resolvePkgDir(name: string, cwd: string, version?: string): stri return nodeModulesPath if (version) { - const cachedPkgDir = join(getCacheDir(name, version), 'pkg') + // getCacheDir rejects malformed names; this path probes, so treat that as a miss. + let cachedPkgDir: string + try { + cachedPkgDir = join(getCacheDir(name, version), 'pkg') + } + catch { + return null + } if (existsSync(join(cachedPkgDir, 'package.json'))) return cachedPkgDir } diff --git a/test/unit/pkg-dir-probe.test.ts b/test/unit/pkg-dir-probe.test.ts new file mode 100644 index 00000000..75b5ae90 --- /dev/null +++ b/test/unit/pkg-dir-probe.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { getShippedSkills, resolvePkgDir } from '../../src/core/prepare.ts' + +// `getCacheDir` rejects malformed names to block path traversal. These callers +// probe for an optional cache hit, so a rejection is a miss, not a crash: an +// unparsed spec used to abort the whole sync with an uncaught error. +describe('package dir probing', () => { + it.each(['npm:vue', 'gh:owner/repo', '../escape'])('returns null for %j', (name) => { + expect(resolvePkgDir(name, process.cwd(), '1.0.0')).toBeNull() + }) + + it.each(['npm:vue', '../escape'])('reports no shipped skills for %j', (name) => { + expect(getShippedSkills(name, process.cwd(), '1.0.0')).toEqual([]) + }) +})