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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ skilld update tailwindcss
# Search docs across installed skills
skilld search "useFetch options" -p nuxt
skilld search "error" -p nuxt --filter '{"type":"issue"}'
skilld search "routing" --agents claude-code
skilld search --guide -p nuxt

# Target a specific agent
Expand Down Expand Up @@ -188,7 +189,7 @@ skilld config
| `skilld` | Interactive wizard (first run) or status menu (existing skills) |
| `skilld add <source...>` | Add skills. Sources: `npm:<pkg>`, `crate:<name>`, `gh:<owner/repo>`, or bare names (deprecated) |
| `skilld update [pkg]` | Update outdated skills (all or specific) |
| `skilld search [query]` | Search indexed docs (`-p` package, `--filter` JSON, `--limit`, `--guide`) |
| `skilld search [query]` | Search indexed docs (`-p` package, `--agents` filter, `--filter` JSON, `--limit`, `--guide`) |
| `skilld list` | List installed skills (`--json` for machine-readable output) |
| `skilld info` | Show skill info and config |
| `skilld config` | Configure agent, model, preferences |
Expand Down Expand Up @@ -241,6 +242,13 @@ The large default context can exceed memory for big models on constrained hardwa

### Embedding Model

Search covers skills installed for every agent in the project, deduplicated. Restrict it with `--agents`:

```bash
skilld search "routing" --agents claude-code
skilld search "routing" --agents claude-code,codex
```

`skilld search` is powered by a local embedding model. It runs offline through transformers.js. It needs no API key or network traffic after the first download. Pick one under **Embedding model** in `skilld config`:

| Model | Dimensions | Notes |
Expand Down
32 changes: 9 additions & 23 deletions src/commands/search-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import type { AgentType } from '../agent/index.ts'
import type { readLock } from '../core/index.ts'
import type { SearchFilter } from '../retriv/index.ts'
import { existsSync, readdirSync } from 'node:fs'
import * as p from '@clack/prompts'
import { join } from 'pathe'
import { agents, detectTargetAgent } from '../agent/index.ts'
import { getPackageDbPath, REFERENCES_DIR } from '../cache/index.ts'
import { readLock } from '../core/index.ts'
import { getSharedSkillsDir } from '../core/paths.ts'
import { toStoragePackageName } from '../core/prefix.ts'
import { readProjectLock } from '../core/skills.ts'

const STATIC_REGEX_1 = /[-_/]+/
const STATIC_REGEX_2 = /^(issues?|docs?|releases?):(.+)$/i

/** Collect search.db paths for packages installed in the current project (from skilld-lock.yaml) */
export function findPackageDbs(packageFilter?: string): string[] {
export function findPackageDbs(packageFilter?: string, agentTypes?: AgentType[]): string[] {
const cwd = process.cwd()
const lock = readProjectLock(cwd)
const lock = readProjectLock(cwd, agentTypes)
if (!lock)
return []
return filterLockDbs(lock, packageFilter)
}

/** Build package name → version map from the project lockfile */
export function getPackageVersions(cwd: string = process.cwd()): Map<string, string> {
const lock = readProjectLock(cwd)
export function getPackageVersions(cwd: string = process.cwd(), agentTypes?: AgentType[]): Map<string, string> {
const lock = readProjectLock(cwd, agentTypes)
const map = new Map<string, string>()
if (!lock)
return map
Expand All @@ -33,23 +33,9 @@ export function getPackageVersions(cwd: string = process.cwd()): Map<string, str
return map
}

/** Read the project's skilld-lock.yaml (shared dir or agent skills dir) */
function readProjectLock(cwd: string): ReturnType<typeof readLock> {
const shared = getSharedSkillsDir(cwd)
if (shared) {
const lock = readLock(shared)
if (lock)
return lock
}
const agent = detectTargetAgent()
if (!agent)
return null
return readLock(`${cwd}/${agents[agent].skillsDir}`)
}

/** List installed packages with versions from the project lockfile */
export function listLockPackages(cwd: string = process.cwd()): string[] {
const lock = readProjectLock(cwd)
export function listLockPackages(cwd: string = process.cwd(), agentTypes?: AgentType[]): string[] {
const lock = readProjectLock(cwd, agentTypes)
if (!lock)
return []
const seen = new Map<string, string>()
Expand Down
9 changes: 5 additions & 4 deletions src/commands/search-interactive.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AgentType } from '../agent/index.ts'
import type { SearchFilter, SearchSnippet } from '../retriv/index.ts'
import { styleText } from 'node:util'
import { createLogUpdate } from 'log-update'
Expand All @@ -20,13 +21,13 @@ function filterToSearchFilter(label: FilterLabel): SearchFilter | undefined {

const SPINNER_FRAMES = ['◐', '◓', '◑', '◒']

export async function interactiveSearch(packageFilter?: string): Promise<void> {
const dbs = findPackageDbs(packageFilter)
const versions = getPackageVersions()
export async function interactiveSearch(packageFilter?: string, agentTypes?: AgentType[]): Promise<void> {
const dbs = findPackageDbs(packageFilter, agentTypes)
const versions = getPackageVersions(process.cwd(), agentTypes)
if (dbs.length === 0) {
let msg: string
if (packageFilter) {
const available = listLockPackages()
const available = listLockPackages(process.cwd(), agentTypes)
msg = available.length > 0
? `No docs indexed for "${packageFilter}". Available: ${available.join(', ')}`
: `No docs indexed for "${packageFilter}". Run \`skilld add ${packageFilter}\` first.`
Expand Down
43 changes: 38 additions & 5 deletions src/commands/search.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { AgentType } from '../agent/index.ts'
import type { SearchFilter } from '../retriv/index.ts'
import * as p from '@clack/prompts'
import { defineCommand } from 'citty'
import { detectCurrentAgent } from 'unagent/env'
import { agents } from '../agent/index.ts'
import { isInteractive } from '../cli/env.ts'
import { formatSnippet, normalizeScores, sanitizeMarkdown } from '../core/index.ts'
import { resolveSkilldCommand } from '../core/skilld-command.ts'
Expand Down Expand Up @@ -54,19 +56,20 @@ function mergeFilters(prefix?: SearchFilter, json?: SearchFilter): SearchFilter
}

export interface SearchCommandOptions {
agents?: AgentType[]
packageFilter?: string
filter?: SearchFilter
limit?: number
}

export async function searchCommand(rawQuery: string, opts: SearchCommandOptions = {}): Promise<void> {
const { packageFilter, limit: userLimit } = opts
const dbs = findPackageDbs(packageFilter)
const versions = getPackageVersions()
const dbs = findPackageDbs(packageFilter, opts.agents)
const versions = getPackageVersions(process.cwd(), opts.agents)

if (dbs.length === 0) {
if (packageFilter) {
const available = listLockPackages()
const available = listLockPackages(process.cwd(), opts.agents)
if (available.length > 0)
p.log.warn(`No docs indexed for "${packageFilter}". Available: ${available.join(', ')}`)
else
Expand Down Expand Up @@ -180,6 +183,21 @@ Without -p, searches all installed packages.
Omit the query for interactive mode with live results.`
}

export type AgentFilterParseResult
= | { _tag: 'All' }
| { _tag: 'Selected', agents: AgentType[] }
| { _tag: 'Invalid', values: string[] }

export function parseAgentFilter(raw?: string): AgentFilterParseResult {
if (raw === undefined)
return { _tag: 'All' }
const ids = raw.split(',').map(s => s.trim()).filter(Boolean)
const unknown = ids.filter(id => !Object.hasOwn(agents, id))
if (ids.length === 0 || unknown.length > 0)
return { _tag: 'Invalid', values: unknown }
return { _tag: 'Selected', agents: ids as AgentType[] }
}

export const searchCommandDef = defineCommand({
meta: { name: 'search', description: 'Search indexed docs' },
args: {
Expand All @@ -194,6 +212,11 @@ export const searchCommandDef = defineCommand({
description: 'Filter by package name',
valueHint: 'name',
},
agents: {
type: 'string',
description: 'Only search skills installed for these agents (comma-separated)',
valueHint: 'names',
},
filter: {
type: 'string',
alias: 'f',
Expand Down Expand Up @@ -229,6 +252,16 @@ export const searchCommandDef = defineCommand({
filter = parsed
}

const agentFilter = parseAgentFilter(args.agents as string | undefined)
if (agentFilter._tag === 'Invalid') {
const reason = agentFilter.values.length > 0
? `Unknown agent: ${agentFilter.values.join(', ')}`
: 'Agent filter is empty'
p.log.error(`${reason}. Available: ${Object.keys(agents).join(', ')}`)
return
}
const agentTypes = agentFilter._tag === 'Selected' ? agentFilter.agents : undefined

let limit: number | undefined
if (args.limit !== undefined) {
const parsed = Number(args.limit)
Expand All @@ -240,7 +273,7 @@ export const searchCommandDef = defineCommand({
}

if (args.query)
return searchCommand(args.query, { packageFilter, filter, limit })
return searchCommand(args.query, { packageFilter, filter, limit, agents: agentTypes })

if (filter || limit)
p.log.warn('--filter and --limit are ignored in interactive mode. Provide a query to use them.')
Expand All @@ -250,6 +283,6 @@ export const searchCommandDef = defineCommand({
process.exit(1)
}
const { interactiveSearch } = await import('./search-interactive.ts')
return interactiveSearch(packageFilter)
return interactiveSearch(packageFilter, agentTypes)
},
})
23 changes: 21 additions & 2 deletions src/core/skills.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { AgentType } from '../agent/index.ts'
import type { SkillInfo } from './lockfile.ts'
import type { SkilldLock, SkillInfo } from './lockfile.ts'
import type { ShippedSkill } from './prepare.ts'
import { existsSync, readdirSync } from 'node:fs'
import { join } from 'pathe'
import { agents } from '../agent/index.ts'
import { readLocalDependencies } from '../sources/index.ts'
import { parsePackages, parseSkillFrontmatter, readLock } from './lockfile.ts'
import { mergeLocks, parsePackages, parseSkillFrontmatter, readLock } from './lockfile.ts'
import { getSharedSkillsDir, LOCK_FILENAME, skillInternalFile } from './paths.ts'
import { getShippedSkills } from './prepare.ts'
import { NPM_SCOPE_PREFIX_RE, VERSION_RANGE_PREFIX_RE } from './regex.ts'
Expand Down Expand Up @@ -124,6 +124,25 @@ export function* iterateSkills(opts: IterateSkillsOptions = {}): Generator<Skill
}
}

export function readProjectLock(cwd: string, agentTypes?: AgentType[]): SkilldLock | null {
const shared = getSharedSkillsDir(cwd)
if (shared) {
const lock = readLock(shared)
if (lock)
return lock
}

const targets = agentTypes?.length
? agentTypes.map(id => agents[id]).filter(Boolean)
: Object.values(agents)

const locks = targets
.map(target => readLock(join(cwd, target.skillsDir)))
.filter((lock): lock is SkilldLock => !!lock)

return locks.length ? mergeLocks(locks) : null
}

export function isOutdated(skill: SkillEntry, depVersion: string): boolean {
if (!skill.info?.version)
return true
Expand Down
78 changes: 78 additions & 0 deletions test/unit/project-lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { invalidateLockCache } from '../../src/core/lockfile.ts'
import { readProjectLock } from '../../src/core/skills.ts'

let cwd: string

function writeLockfile(dir: string, skills: Record<string, { packageName: string, version: string, syncedAt?: string }>): void {
mkdirSync(join(cwd, dir), { recursive: true })
let yaml = 'skills:\n'
for (const [name, info] of Object.entries(skills)) {
yaml += ` ${name}:\n`
yaml += ` packageName: ${info.packageName}\n`
yaml += ` version: ${info.version}\n`
if (info.syncedAt)
yaml += ` syncedAt: ${info.syncedAt}\n`
}
writeFileSync(join(cwd, dir, 'skilld-lock.yaml'), yaml)
}

function packages(lock: ReturnType<typeof readProjectLock>): string[] {
return Object.values(lock?.skills ?? {}).map(s => `${s.packageName}@${s.version}`).sort()
}

beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'skilld-lock-'))
invalidateLockCache()
})

afterEach(() => {
rmSync(cwd, { recursive: true, force: true })
invalidateLockCache()
})

describe('readProjectLock', () => {
it('returns null when no agent has a lockfile', () => {
expect(readProjectLock(cwd)).toBeNull()
})

it('reads a single agent dir', () => {
writeLockfile('.claude/skills', { vue: { packageName: 'vue', version: '3.5.0' } })
expect(packages(readProjectLock(cwd))).toEqual(['vue@3.5.0'])
})

it('merges every agent dir', () => {
writeLockfile('.claude/skills', { vue: { packageName: 'vue', version: '3.5.0' } })
writeLockfile('.agents/skills', { zod: { packageName: 'zod', version: '3.23.0' } })
expect(packages(readProjectLock(cwd))).toEqual(['vue@3.5.0', 'zod@3.23.0'])
})

it('dedupes a skill present in several agent dirs, preferring the newest sync', () => {
writeLockfile('.claude/skills', { vue: { packageName: 'vue', version: '3.4.0', syncedAt: '2026-01-01' } })
writeLockfile('.cursor/skills', { vue: { packageName: 'vue', version: '3.5.0', syncedAt: '2026-06-01' } })
expect(packages(readProjectLock(cwd))).toEqual(['vue@3.5.0'])
})

it('restricts to the requested agents', () => {
writeLockfile('.claude/skills', { vue: { packageName: 'vue', version: '3.5.0' } })
writeLockfile('.agents/skills', { zod: { packageName: 'zod', version: '3.23.0' } })

expect(packages(readProjectLock(cwd, ['claude-code']))).toEqual(['vue@3.5.0'])
expect(packages(readProjectLock(cwd, ['codex']))).toEqual(['zod@3.23.0'])
expect(packages(readProjectLock(cwd, ['claude-code', 'codex']))).toEqual(['vue@3.5.0', 'zod@3.23.0'])
})

it('returns null when the requested agent has no lockfile', () => {
writeLockfile('.claude/skills', { vue: { packageName: 'vue', version: '3.5.0' } })
expect(readProjectLock(cwd, ['cursor'])).toBeNull()
})

it('prefers a shared skills dir over agent dirs', () => {
writeLockfile('.skills', { vue: { packageName: 'vue', version: '3.5.0' } })
writeLockfile('.claude/skills', { zod: { packageName: 'zod', version: '3.23.0' } })
expect(packages(readProjectLock(cwd))).toEqual(['vue@3.5.0'])
})
})
19 changes: 18 additions & 1 deletion test/unit/search.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { SearchSnippet } from '../../src/retriv/types'
import { describe, expect, it } from 'vitest'
import { generateSearchGuide, parseFilterPrefix, parseJsonFilter } from '../../src/commands/search'
import { generateSearchGuide, parseAgentFilter, parseFilterPrefix, parseJsonFilter } from '../../src/commands/search'
import { normalizeScores, scoreLabel } from '../../src/core/formatting'

function snippet(overrides: Partial<SearchSnippet> = {}): SearchSnippet {
Expand Down Expand Up @@ -134,6 +134,23 @@ describe('parseJsonFilter', () => {
})
})

describe('parseAgentFilter', () => {
it('parses known agents', () => {
expect(parseAgentFilter('claude-code, codex')).toEqual({
_tag: 'Selected',
agents: ['claude-code', 'codex'],
})
})

it('rejects an empty selection', () => {
expect(parseAgentFilter(',')).toEqual({ _tag: 'Invalid', values: [] })
})

it('rejects inherited object properties', () => {
expect(parseAgentFilter('toString')).toEqual({ _tag: 'Invalid', values: ['toString'] })
})
})

describe('generateSearchGuide', () => {
it('generates generic guide without package', () => {
const guide = generateSearchGuide()
Expand Down