diff --git a/README.md b/README.md index 38b09bd7..9242660e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -188,7 +189,7 @@ skilld config | `skilld` | Interactive wizard (first run) or status menu (existing skills) | | `skilld add ` | Add skills. Sources: `npm:`, `crate:`, `gh:`, 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 | @@ -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 | diff --git a/src/commands/search-helpers.ts b/src/commands/search-helpers.ts index e2f56893..0ddf1b08 100644 --- a/src/commands/search-helpers.ts +++ b/src/commands/search-helpers.ts @@ -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 { - const lock = readProjectLock(cwd) +export function getPackageVersions(cwd: string = process.cwd(), agentTypes?: AgentType[]): Map { + const lock = readProjectLock(cwd, agentTypes) const map = new Map() if (!lock) return map @@ -33,23 +33,9 @@ export function getPackageVersions(cwd: string = process.cwd()): Map { - 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() diff --git a/src/commands/search-interactive.ts b/src/commands/search-interactive.ts index 74099164..b059ff49 100644 --- a/src/commands/search-interactive.ts +++ b/src/commands/search-interactive.ts @@ -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' @@ -20,13 +21,13 @@ function filterToSearchFilter(label: FilterLabel): SearchFilter | undefined { const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'] -export async function interactiveSearch(packageFilter?: string): Promise { - const dbs = findPackageDbs(packageFilter) - const versions = getPackageVersions() +export async function interactiveSearch(packageFilter?: string, agentTypes?: AgentType[]): Promise { + 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.` diff --git a/src/commands/search.ts b/src/commands/search.ts index 766b07d2..e8939299 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -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' @@ -54,6 +56,7 @@ function mergeFilters(prefix?: SearchFilter, json?: SearchFilter): SearchFilter } export interface SearchCommandOptions { + agents?: AgentType[] packageFilter?: string filter?: SearchFilter limit?: number @@ -61,12 +64,12 @@ export interface SearchCommandOptions { export async function searchCommand(rawQuery: string, opts: SearchCommandOptions = {}): Promise { 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 @@ -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: { @@ -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', @@ -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) @@ -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.') @@ -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) }, }) diff --git a/src/core/skills.ts b/src/core/skills.ts index bb436230..4bbf2314 100644 --- a/src/core/skills.ts +++ b/src/core/skills.ts @@ -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' @@ -124,6 +124,25 @@ export function* iterateSkills(opts: IterateSkillsOptions = {}): Generator 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 diff --git a/test/unit/project-lock.test.ts b/test/unit/project-lock.test.ts new file mode 100644 index 00000000..66deb1ca --- /dev/null +++ b/test/unit/project-lock.test.ts @@ -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): 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): 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']) + }) +}) diff --git a/test/unit/search.test.ts b/test/unit/search.test.ts index f10e845d..f11411e8 100644 --- a/test/unit/search.test.ts +++ b/test/unit/search.test.ts @@ -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 { @@ -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()