Skip to content

Commit fb18517

Browse files
fix(search): search installed skills across all agents (#87)
Co-authored-by: Harlan Wilton <harlan@harlanzw.com>
1 parent 5687ca7 commit fb18517

7 files changed

Lines changed: 178 additions & 36 deletions

File tree

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ skilld update tailwindcss
159159
# Search docs across installed skills
160160
skilld search "useFetch options" -p nuxt
161161
skilld search "error" -p nuxt --filter '{"type":"issue"}'
162+
skilld search "routing" --agents claude-code
162163
skilld search --guide -p nuxt
163164

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

242243
### Embedding Model
243244

245+
Search covers skills installed for every agent in the project, deduplicated. Restrict it with `--agents`:
246+
247+
```bash
248+
skilld search "routing" --agents claude-code
249+
skilld search "routing" --agents claude-code,codex
250+
```
251+
244252
`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`:
245253

246254
| Model | Dimensions | Notes |

src/commands/search-helpers.ts

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
1+
import type { AgentType } from '../agent/index.ts'
2+
import type { readLock } from '../core/index.ts'
13
import type { SearchFilter } from '../retriv/index.ts'
24
import { existsSync, readdirSync } from 'node:fs'
35
import * as p from '@clack/prompts'
46
import { join } from 'pathe'
5-
import { agents, detectTargetAgent } from '../agent/index.ts'
67
import { getPackageDbPath, REFERENCES_DIR } from '../cache/index.ts'
7-
import { readLock } from '../core/index.ts'
8-
import { getSharedSkillsDir } from '../core/paths.ts'
98
import { toStoragePackageName } from '../core/prefix.ts'
9+
import { readProjectLock } from '../core/skills.ts'
1010

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

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

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

36-
/** Read the project's skilld-lock.yaml (shared dir or agent skills dir) */
37-
function readProjectLock(cwd: string): ReturnType<typeof readLock> {
38-
const shared = getSharedSkillsDir(cwd)
39-
if (shared) {
40-
const lock = readLock(shared)
41-
if (lock)
42-
return lock
43-
}
44-
const agent = detectTargetAgent()
45-
if (!agent)
46-
return null
47-
return readLock(`${cwd}/${agents[agent].skillsDir}`)
48-
}
49-
5036
/** List installed packages with versions from the project lockfile */
51-
export function listLockPackages(cwd: string = process.cwd()): string[] {
52-
const lock = readProjectLock(cwd)
37+
export function listLockPackages(cwd: string = process.cwd(), agentTypes?: AgentType[]): string[] {
38+
const lock = readProjectLock(cwd, agentTypes)
5339
if (!lock)
5440
return []
5541
const seen = new Map<string, string>()

src/commands/search-interactive.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { AgentType } from '../agent/index.ts'
12
import type { SearchFilter, SearchSnippet } from '../retriv/index.ts'
23
import { styleText } from 'node:util'
34
import { createLogUpdate } from 'log-update'
@@ -20,13 +21,13 @@ function filterToSearchFilter(label: FilterLabel): SearchFilter | undefined {
2021

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

23-
export async function interactiveSearch(packageFilter?: string): Promise<void> {
24-
const dbs = findPackageDbs(packageFilter)
25-
const versions = getPackageVersions()
24+
export async function interactiveSearch(packageFilter?: string, agentTypes?: AgentType[]): Promise<void> {
25+
const dbs = findPackageDbs(packageFilter, agentTypes)
26+
const versions = getPackageVersions(process.cwd(), agentTypes)
2627
if (dbs.length === 0) {
2728
let msg: string
2829
if (packageFilter) {
29-
const available = listLockPackages()
30+
const available = listLockPackages(process.cwd(), agentTypes)
3031
msg = available.length > 0
3132
? `No docs indexed for "${packageFilter}". Available: ${available.join(', ')}`
3233
: `No docs indexed for "${packageFilter}". Run \`skilld add ${packageFilter}\` first.`

src/commands/search.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import type { AgentType } from '../agent/index.ts'
12
import type { SearchFilter } from '../retriv/index.ts'
23
import * as p from '@clack/prompts'
34
import { defineCommand } from 'citty'
45
import { detectCurrentAgent } from 'unagent/env'
6+
import { agents } from '../agent/index.ts'
57
import { isInteractive } from '../cli/env.ts'
68
import { formatSnippet, normalizeScores, sanitizeMarkdown } from '../core/index.ts'
79
import { resolveSkilldCommand } from '../core/skilld-command.ts'
@@ -54,19 +56,20 @@ function mergeFilters(prefix?: SearchFilter, json?: SearchFilter): SearchFilter
5456
}
5557

5658
export interface SearchCommandOptions {
59+
agents?: AgentType[]
5760
packageFilter?: string
5861
filter?: SearchFilter
5962
limit?: number
6063
}
6164

6265
export async function searchCommand(rawQuery: string, opts: SearchCommandOptions = {}): Promise<void> {
6366
const { packageFilter, limit: userLimit } = opts
64-
const dbs = findPackageDbs(packageFilter)
65-
const versions = getPackageVersions()
67+
const dbs = findPackageDbs(packageFilter, opts.agents)
68+
const versions = getPackageVersions(process.cwd(), opts.agents)
6669

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

186+
export type AgentFilterParseResult
187+
= | { _tag: 'All' }
188+
| { _tag: 'Selected', agents: AgentType[] }
189+
| { _tag: 'Invalid', values: string[] }
190+
191+
export function parseAgentFilter(raw?: string): AgentFilterParseResult {
192+
if (raw === undefined)
193+
return { _tag: 'All' }
194+
const ids = raw.split(',').map(s => s.trim()).filter(Boolean)
195+
const unknown = ids.filter(id => !Object.hasOwn(agents, id))
196+
if (ids.length === 0 || unknown.length > 0)
197+
return { _tag: 'Invalid', values: unknown }
198+
return { _tag: 'Selected', agents: ids as AgentType[] }
199+
}
200+
183201
export const searchCommandDef = defineCommand({
184202
meta: { name: 'search', description: 'Search indexed docs' },
185203
args: {
@@ -194,6 +212,11 @@ export const searchCommandDef = defineCommand({
194212
description: 'Filter by package name',
195213
valueHint: 'name',
196214
},
215+
agents: {
216+
type: 'string',
217+
description: 'Only search skills installed for these agents (comma-separated)',
218+
valueHint: 'names',
219+
},
197220
filter: {
198221
type: 'string',
199222
alias: 'f',
@@ -229,6 +252,16 @@ export const searchCommandDef = defineCommand({
229252
filter = parsed
230253
}
231254

255+
const agentFilter = parseAgentFilter(args.agents as string | undefined)
256+
if (agentFilter._tag === 'Invalid') {
257+
const reason = agentFilter.values.length > 0
258+
? `Unknown agent: ${agentFilter.values.join(', ')}`
259+
: 'Agent filter is empty'
260+
p.log.error(`${reason}. Available: ${Object.keys(agents).join(', ')}`)
261+
return
262+
}
263+
const agentTypes = agentFilter._tag === 'Selected' ? agentFilter.agents : undefined
264+
232265
let limit: number | undefined
233266
if (args.limit !== undefined) {
234267
const parsed = Number(args.limit)
@@ -240,7 +273,7 @@ export const searchCommandDef = defineCommand({
240273
}
241274

242275
if (args.query)
243-
return searchCommand(args.query, { packageFilter, filter, limit })
276+
return searchCommand(args.query, { packageFilter, filter, limit, agents: agentTypes })
244277

245278
if (filter || limit)
246279
p.log.warn('--filter and --limit are ignored in interactive mode. Provide a query to use them.')
@@ -250,6 +283,6 @@ export const searchCommandDef = defineCommand({
250283
process.exit(1)
251284
}
252285
const { interactiveSearch } = await import('./search-interactive.ts')
253-
return interactiveSearch(packageFilter)
286+
return interactiveSearch(packageFilter, agentTypes)
254287
},
255288
})

src/core/skills.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import type { AgentType } from '../agent/index.ts'
2-
import type { SkillInfo } from './lockfile.ts'
2+
import type { SkilldLock, SkillInfo } from './lockfile.ts'
33
import type { ShippedSkill } from './prepare.ts'
44
import { existsSync, readdirSync } from 'node:fs'
55
import { join } from 'pathe'
66
import { agents } from '../agent/index.ts'
77
import { readLocalDependencies } from '../sources/index.ts'
8-
import { parsePackages, parseSkillFrontmatter, readLock } from './lockfile.ts'
8+
import { mergeLocks, parsePackages, parseSkillFrontmatter, readLock } from './lockfile.ts'
99
import { getSharedSkillsDir, LOCK_FILENAME, skillInternalFile } from './paths.ts'
1010
import { getShippedSkills } from './prepare.ts'
1111
import { NPM_SCOPE_PREFIX_RE, VERSION_RANGE_PREFIX_RE } from './regex.ts'
@@ -124,6 +124,25 @@ export function* iterateSkills(opts: IterateSkillsOptions = {}): Generator<Skill
124124
}
125125
}
126126

127+
export function readProjectLock(cwd: string, agentTypes?: AgentType[]): SkilldLock | null {
128+
const shared = getSharedSkillsDir(cwd)
129+
if (shared) {
130+
const lock = readLock(shared)
131+
if (lock)
132+
return lock
133+
}
134+
135+
const targets = agentTypes?.length
136+
? agentTypes.map(id => agents[id]).filter(Boolean)
137+
: Object.values(agents)
138+
139+
const locks = targets
140+
.map(target => readLock(join(cwd, target.skillsDir)))
141+
.filter((lock): lock is SkilldLock => !!lock)
142+
143+
return locks.length ? mergeLocks(locks) : null
144+
}
145+
127146
export function isOutdated(skill: SkillEntry, depVersion: string): boolean {
128147
if (!skill.info?.version)
129148
return true

test/unit/project-lock.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import { invalidateLockCache } from '../../src/core/lockfile.ts'
6+
import { readProjectLock } from '../../src/core/skills.ts'
7+
8+
let cwd: string
9+
10+
function writeLockfile(dir: string, skills: Record<string, { packageName: string, version: string, syncedAt?: string }>): void {
11+
mkdirSync(join(cwd, dir), { recursive: true })
12+
let yaml = 'skills:\n'
13+
for (const [name, info] of Object.entries(skills)) {
14+
yaml += ` ${name}:\n`
15+
yaml += ` packageName: ${info.packageName}\n`
16+
yaml += ` version: ${info.version}\n`
17+
if (info.syncedAt)
18+
yaml += ` syncedAt: ${info.syncedAt}\n`
19+
}
20+
writeFileSync(join(cwd, dir, 'skilld-lock.yaml'), yaml)
21+
}
22+
23+
function packages(lock: ReturnType<typeof readProjectLock>): string[] {
24+
return Object.values(lock?.skills ?? {}).map(s => `${s.packageName}@${s.version}`).sort()
25+
}
26+
27+
beforeEach(() => {
28+
cwd = mkdtempSync(join(tmpdir(), 'skilld-lock-'))
29+
invalidateLockCache()
30+
})
31+
32+
afterEach(() => {
33+
rmSync(cwd, { recursive: true, force: true })
34+
invalidateLockCache()
35+
})
36+
37+
describe('readProjectLock', () => {
38+
it('returns null when no agent has a lockfile', () => {
39+
expect(readProjectLock(cwd)).toBeNull()
40+
})
41+
42+
it('reads a single agent dir', () => {
43+
writeLockfile('.claude/skills', { vue: { packageName: 'vue', version: '3.5.0' } })
44+
expect(packages(readProjectLock(cwd))).toEqual(['vue@3.5.0'])
45+
})
46+
47+
it('merges every agent dir', () => {
48+
writeLockfile('.claude/skills', { vue: { packageName: 'vue', version: '3.5.0' } })
49+
writeLockfile('.agents/skills', { zod: { packageName: 'zod', version: '3.23.0' } })
50+
expect(packages(readProjectLock(cwd))).toEqual(['vue@3.5.0', 'zod@3.23.0'])
51+
})
52+
53+
it('dedupes a skill present in several agent dirs, preferring the newest sync', () => {
54+
writeLockfile('.claude/skills', { vue: { packageName: 'vue', version: '3.4.0', syncedAt: '2026-01-01' } })
55+
writeLockfile('.cursor/skills', { vue: { packageName: 'vue', version: '3.5.0', syncedAt: '2026-06-01' } })
56+
expect(packages(readProjectLock(cwd))).toEqual(['vue@3.5.0'])
57+
})
58+
59+
it('restricts to the requested agents', () => {
60+
writeLockfile('.claude/skills', { vue: { packageName: 'vue', version: '3.5.0' } })
61+
writeLockfile('.agents/skills', { zod: { packageName: 'zod', version: '3.23.0' } })
62+
63+
expect(packages(readProjectLock(cwd, ['claude-code']))).toEqual(['vue@3.5.0'])
64+
expect(packages(readProjectLock(cwd, ['codex']))).toEqual(['zod@3.23.0'])
65+
expect(packages(readProjectLock(cwd, ['claude-code', 'codex']))).toEqual(['vue@3.5.0', 'zod@3.23.0'])
66+
})
67+
68+
it('returns null when the requested agent has no lockfile', () => {
69+
writeLockfile('.claude/skills', { vue: { packageName: 'vue', version: '3.5.0' } })
70+
expect(readProjectLock(cwd, ['cursor'])).toBeNull()
71+
})
72+
73+
it('prefers a shared skills dir over agent dirs', () => {
74+
writeLockfile('.skills', { vue: { packageName: 'vue', version: '3.5.0' } })
75+
writeLockfile('.claude/skills', { zod: { packageName: 'zod', version: '3.23.0' } })
76+
expect(packages(readProjectLock(cwd))).toEqual(['vue@3.5.0'])
77+
})
78+
})

test/unit/search.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { SearchSnippet } from '../../src/retriv/types'
22
import { describe, expect, it } from 'vitest'
3-
import { generateSearchGuide, parseFilterPrefix, parseJsonFilter } from '../../src/commands/search'
3+
import { generateSearchGuide, parseAgentFilter, parseFilterPrefix, parseJsonFilter } from '../../src/commands/search'
44
import { normalizeScores, scoreLabel } from '../../src/core/formatting'
55

66
function snippet(overrides: Partial<SearchSnippet> = {}): SearchSnippet {
@@ -134,6 +134,23 @@ describe('parseJsonFilter', () => {
134134
})
135135
})
136136

137+
describe('parseAgentFilter', () => {
138+
it('parses known agents', () => {
139+
expect(parseAgentFilter('claude-code, codex')).toEqual({
140+
_tag: 'Selected',
141+
agents: ['claude-code', 'codex'],
142+
})
143+
})
144+
145+
it('rejects an empty selection', () => {
146+
expect(parseAgentFilter(',')).toEqual({ _tag: 'Invalid', values: [] })
147+
})
148+
149+
it('rejects inherited object properties', () => {
150+
expect(parseAgentFilter('toString')).toEqual({ _tag: 'Invalid', values: ['toString'] })
151+
})
152+
})
153+
137154
describe('generateSearchGuide', () => {
138155
it('generates generic guide without package', () => {
139156
const guide = generateSearchGuide()

0 commit comments

Comments
 (0)