Skip to content

Commit deb56dc

Browse files
fix(cli): accept source prefixes in the wizard package prompt (#88)
Co-authored-by: Harlan Wilton <harlan@harlanzw.com>
1 parent fb18517 commit deb56dc

8 files changed

Lines changed: 114 additions & 11 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ skilld
146146
# Add skills for specific package(s) — npm: prefix for registry packages
147147
skilld add npm:vue npm:nuxt npm:pinia
148148

149+
# The same prefixes work in the interactive wizard's package prompt
150+
149151
# Add a pre-authored skill from a GitHub repo
150152
skilld add gh:vercel-labs/agent-skills
151153

src/cache/internal/version.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ const VALID_PKG_NAME = /^(?:@[a-z0-9][-a-z0-9._]*\/)?[a-z0-9][-a-z0-9._]*$/
1111
/** Validate version string (semver-ish, no path separators) */
1212
const VALID_VERSION = /^[a-z0-9][-\w.+]*$/i
1313

14+
export function isValidCachePackageName(name: string): boolean {
15+
return VALID_PKG_NAME.test(name)
16+
}
17+
18+
export function isValidCacheVersion(version: string): boolean {
19+
return VALID_VERSION.test(version)
20+
}
21+
1422
/**
1523
* Get exact version key for cache keying
1624
*/
@@ -30,9 +38,9 @@ export function getCacheKey(name: string, version: string): string {
3038
* Validates name/version to prevent path traversal.
3139
*/
3240
export function getCacheDir(name: string, version: string): string {
33-
if (!VALID_PKG_NAME.test(name))
41+
if (!isValidCachePackageName(name))
3442
throw new Error(`Invalid package name: ${name}`)
35-
if (!VALID_VERSION.test(version))
43+
if (!isValidCacheVersion(version))
3644
throw new Error(`Invalid version: ${version}`)
3745

3846
const dir = resolve(REFERENCES_DIR, getCacheKey(name, version))

src/cli.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { runWizard } from './commands/wizard.ts'
2121
import { timedSpinner } from './core/formatting.ts'
2222
import { getProjectState, hasCompletedWizard, isOutdated, readConfig, semverGt } from './core/index.ts'
2323
import { readPackageJsonSafe } from './core/package-json.ts'
24+
import { parseNpmPackageInputs } from './core/prefix.ts'
2425
import { COMMA_OR_WHITESPACE_RE, VERSION_RANGE_PREFIX_RE } from './core/regex.ts'
2526
import { iterateSkills } from './core/skills.ts'
2627
import { fetchLatestVersion, fetchNpmRegistryMeta } from './sources/index.ts'
@@ -277,7 +278,7 @@ const main = defineCommand({
277278
if (source === 'manual') {
278279
const input = await p.text({
279280
message: 'Enter package names (space or comma-separated)',
280-
placeholder: 'vue nuxt pinia',
281+
placeholder: 'vue npm:nuxt pinia',
281282
})
282283
if (p.isCancel(input)) {
283284
if (!hasPkgJson) {
@@ -290,7 +291,12 @@ const main = defineCommand({
290291
p.log.warn('No packages entered')
291292
continue
292293
}
293-
selected = input.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean)
294+
const parsed = parseNpmPackageInputs(input.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean))
295+
if (parsed._tag === 'Err') {
296+
p.log.error(`${parsed.input} is not an npm package. Install it with \`skilld add ${parsed.input}\`.`)
297+
continue
298+
}
299+
selected = parsed.packageSpecs
294300
if (selected.length === 0) {
295301
p.log.warn('No valid packages entered')
296302
continue
@@ -534,11 +540,16 @@ const main = defineCommand({
534540
if (source === 'manual') {
535541
const input = guard(await p.text({
536542
message: 'Enter package names (space or comma-separated)',
537-
placeholder: 'vue nuxt pinia',
543+
placeholder: 'vue npm:nuxt pinia',
538544
}))
539545
if (!input)
540546
return
541-
selected = input.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean)
547+
const parsed = parseNpmPackageInputs(input.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean))
548+
if (parsed._tag === 'Err') {
549+
p.log.error(`${parsed.input} is not an npm package. Install it with \`skilld add ${parsed.input}\`.`)
550+
return
551+
}
552+
selected = parsed.packageSpecs
542553
if (selected.length === 0)
543554
return
544555
}

src/core/prefix.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { GitSkillSource } from '../sources/git-skills.ts'
1616
import { parseGitSkillInput } from '../sources/git-skills.ts'
1717

1818
const STATIC_REGEX_1 = /^[\w.-]+\/[\w.-]+/
19+
const EXPLICIT_NON_NPM_PREFIX_RE = /^(?:crate|gh|github):/
1920

2021
export type SkillSource
2122
= | { type: 'npm', package: string, tag?: string }
@@ -25,6 +26,25 @@ export type SkillSource
2526
| { type: 'collection', handle: string, name: string }
2627
| { type: 'bare', package: string, tag?: string }
2728

29+
export type NpmPackageInputResult
30+
= | { _tag: 'Ok', packageSpecs: string[] }
31+
| { _tag: 'Err', input: string }
32+
33+
export function parseNpmPackageInputs(inputs: string[]): NpmPackageInputResult {
34+
const packageSpecs: string[] = []
35+
36+
for (const input of inputs) {
37+
const source = parseSkillInput(input)
38+
const isMalformedExplicitSource = source.type === 'bare' && EXPLICIT_NON_NPM_PREFIX_RE.test(input)
39+
if ((source.type !== 'npm' && source.type !== 'bare') || isMalformedExplicitSource || !source.package)
40+
return { _tag: 'Err', input }
41+
42+
packageSpecs.push(source.tag ? `${source.package}@${source.tag}` : source.package)
43+
}
44+
45+
return { _tag: 'Ok', packageSpecs }
46+
}
47+
2848
/**
2949
* Parse a single CLI input token into a typed SkillSource.
3050
*

src/core/prepare.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import type { SkillInfo } from './lockfile.ts'
1010
import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync, symlinkSync, unlinkSync } from 'node:fs'
1111
import { basename, join } from 'pathe'
12-
import { getCacheDir } from '../cache/internal/version.ts'
12+
import { getCacheDir, isValidCachePackageName, isValidCacheVersion } from '../cache/internal/version.ts'
1313
import { readPackageJsonSafe } from './package-json.ts'
1414
import { README_FILENAME_RE } from './regex.ts'
1515

@@ -24,11 +24,16 @@ function toStorageName(name: string): string {
2424

2525
/** Resolve package directory: node_modules first, then global cache */
2626
export function resolvePkgDir(name: string, cwd: string, version?: string): string | null {
27+
if (!isValidCachePackageName(name))
28+
return null
29+
2730
const nodeModulesPath = join(cwd, 'node_modules', name)
2831
if (existsSync(nodeModulesPath))
2932
return nodeModulesPath
3033

3134
if (version) {
35+
if (!isValidCacheVersion(version))
36+
return null
3237
const cachedPkgDir = join(getCacheDir(name, version), 'pkg')
3338
if (existsSync(join(cachedPkgDir, 'package.json')))
3439
return cachedPkgDir

test/unit/pkg-dir-probe.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'pathe'
4+
import { afterEach, describe, expect, it } from 'vitest'
5+
import { getShippedSkills, resolvePkgDir } from '../../src/core/prepare.ts'
6+
7+
describe('package dir probing', () => {
8+
const fixtureDirs: string[] = []
9+
10+
afterEach(() => {
11+
for (const dir of fixtureDirs)
12+
rmSync(dir, { recursive: true, force: true })
13+
fixtureDirs.length = 0
14+
})
15+
16+
it.each(['npm:vue', 'gh:owner/repo', ''])('returns null for %j', (name) => {
17+
expect(resolvePkgDir(name, process.cwd(), '1.0.0')).toBeNull()
18+
})
19+
20+
it('rejects traversal when the escaped directory exists', () => {
21+
const cwd = mkdtempSync(join(tmpdir(), 'skilld-pkg-probe-'))
22+
fixtureDirs.push(cwd)
23+
mkdirSync(join(cwd, 'escape'))
24+
25+
expect(resolvePkgDir('../escape', cwd, '1.0.0')).toBeNull()
26+
})
27+
28+
it('returns an installed package before validating the cache version', () => {
29+
const cwd = mkdtempSync(join(tmpdir(), 'skilld-pkg-probe-'))
30+
fixtureDirs.push(cwd)
31+
const packageDir = join(cwd, 'node_modules', 'vue')
32+
mkdirSync(packageDir, { recursive: true })
33+
34+
expect(resolvePkgDir('vue', cwd, '../invalid')).toBe(packageDir)
35+
})
36+
37+
it.each(['npm:vue', '../escape'])('reports no shipped skills for %j', (name) => {
38+
expect(getShippedSkills(name, process.cwd(), '1.0.0')).toEqual([])
39+
})
40+
})

test/unit/prefix.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
import { describe, expect, it } from 'vitest'
2-
import { parseSkillInput, resolveSkillName } from '../../src/core/prefix'
2+
import { parseNpmPackageInputs, parseSkillInput, resolveSkillName } from '../../src/core/prefix'
33

44
describe('prefix parser', () => {
5+
describe('wizard npm inputs', () => {
6+
it('normalizes prefixes without dropping npm tags', () => {
7+
expect(parseNpmPackageInputs(['npm:vue@beta', '@nuxt/ui@3.0.0', 'pinia'])).toEqual({
8+
_tag: 'Ok',
9+
packageSpecs: ['vue@beta', '@nuxt/ui@3.0.0', 'pinia'],
10+
})
11+
})
12+
13+
it.each(['gh:owner/repo', 'gh:not-a-repo', 'crate:serde', '@curator'])('rejects non-npm input %s', (input) => {
14+
expect(parseNpmPackageInputs([input])).toEqual({ _tag: 'Err', input })
15+
})
16+
})
17+
518
describe('npm: prefix', () => {
619
it('parses simple package name', () => {
720
expect(parseSkillInput('npm:vue')).toEqual({

test/unit/prepare-restore.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ vi.mock('node:fs', async () => {
1212
}
1313
})
1414

15-
vi.mock('../../src/cache/internal/version', () => ({
16-
getCacheDir: (name: string, version: string) => `/home/.skilld/references/${name}@${version}`,
17-
}))
15+
vi.mock('../../src/cache/internal/version', async (importOriginal) => {
16+
const actual = await importOriginal<typeof import('../../src/cache/internal/version')>()
17+
return {
18+
...actual,
19+
getCacheDir: (name: string, version: string) => `/home/.skilld/references/${name}@${version}`,
20+
}
21+
})
1822

1923
describe('restorePkgSymlink', () => {
2024
beforeEach(() => vi.resetAllMocks())

0 commit comments

Comments
 (0)