From f39684339ba0e6178c693540fcaa7a2ba689f9e2 Mon Sep 17 00:00:00 2001 From: "Robert Kent Jr." Date: Wed, 12 Aug 2026 11:13:41 -0400 Subject: [PATCH] fix(search): search across all agents, add --agents filter `readProjectLock` resolved the lockfile via `detectTargetAgent()`, which returns null when a project has more than one agent directory. Search then reported nothing indexed even with a populated index on disk. Reproduces in any project with, say, `.claude/` and `.agents/` when run from a shell with no agent env var set, which is the normal case for a human in a terminal. Indexes are keyed by package and version, never by agent, so scoping the lockfile to one agent only hid skills the project has. Merge every agent dir instead, deduplicated by `mergeLocks`, and add `--agents` to narrow when that is wanted. Moves `readProjectLock` from `commands/search-helpers.ts` to `core/skills.ts`, alongside the other agent-aware project resolution. --- README.md | 10 +++- src/commands/search-helpers.ts | 30 +++-------- src/commands/search-interactive.ts | 9 ++-- src/commands/search.ts | 36 +++++++++++-- src/core/skills.ts | 30 ++++++++++- test/unit/project-lock.test.ts | 81 ++++++++++++++++++++++++++++++ 6 files changed, 162 insertions(+), 34 deletions(-) create mode 100644 test/unit/project-lock.test.ts 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..8d20114a 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 { 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 { readProjectLock } from '../core/skills.ts' import { toStoragePackageName } from '../core/prefix.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, agentFilter?: AgentType[]): string[] { const cwd = process.cwd() - const lock = readProjectLock(cwd) + const lock = readProjectLock(cwd, agentFilter) 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(), agentFilter?: AgentType[]): Map { + const lock = readProjectLock(cwd, agentFilter) 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(), agentFilter?: AgentType[]): string[] { + const lock = readProjectLock(cwd, agentFilter) if (!lock) return [] const seen = new Map() diff --git a/src/commands/search-interactive.ts b/src/commands/search-interactive.ts index 74099164..f4413a82 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, agentFilter?: AgentType[]): Promise { + const dbs = findPackageDbs(packageFilter, agentFilter) + const versions = getPackageVersions(process.cwd(), agentFilter) if (dbs.length === 0) { let msg: string if (packageFilter) { - const available = listLockPackages() + const available = listLockPackages(process.cwd(), agentFilter) 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..6859188c 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,5 +1,7 @@ +import type { AgentType } from '../agent/index.ts' import type { SearchFilter } from '../retriv/index.ts' import * as p from '@clack/prompts' +import { agents } from '../agent/index.ts' import { defineCommand } from 'citty' import { detectCurrentAgent } from 'unagent/env' import { isInteractive } from '../cli/env.ts' @@ -54,6 +56,8 @@ function mergeFilters(prefix?: SearchFilter, json?: SearchFilter): SearchFilter } export interface SearchCommandOptions { + /** Restrict to specific agents' skills dirs (default: all) */ + agentFilter?: AgentType[] packageFilter?: string filter?: SearchFilter limit?: number @@ -61,12 +65,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.agentFilter) + const versions = getPackageVersions(process.cwd(), opts.agentFilter) if (dbs.length === 0) { if (packageFilter) { - const available = listLockPackages() + const available = listLockPackages(process.cwd(), opts.agentFilter) if (available.length > 0) p.log.warn(`No docs indexed for "${packageFilter}". Available: ${available.join(', ')}`) else @@ -180,6 +184,19 @@ Without -p, searches all installed packages. Omit the query for interactive mode with live results.` } +/** Parse `--agents a,b` into agent ids. Returns null after reporting an unknown id. */ +function parseAgentFilter(raw?: string): AgentType[] | undefined | null { + if (!raw) + return undefined + const ids = raw.split(',').map(s => s.trim()).filter(Boolean) + const unknown = ids.filter(id => !(id in agents)) + if (unknown.length) { + p.log.error(`Unknown agent: ${unknown.join(', ')}. Available: ${Object.keys(agents).join(', ')}`) + return null + } + return ids as AgentType[] +} + export const searchCommandDef = defineCommand({ meta: { name: 'search', description: 'Search indexed docs' }, args: { @@ -194,6 +211,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 +251,10 @@ export const searchCommandDef = defineCommand({ filter = parsed } + const agentFilter = parseAgentFilter(args.agents as string | undefined) + if (agentFilter === null) + return + let limit: number | undefined if (args.limit !== undefined) { const parsed = Number(args.limit) @@ -240,7 +266,7 @@ export const searchCommandDef = defineCommand({ } if (args.query) - return searchCommand(args.query, { packageFilter, filter, limit }) + return searchCommand(args.query, { packageFilter, filter, limit, agentFilter }) if (filter || limit) p.log.warn('--filter and --limit are ignored in interactive mode. Provide a query to use them.') @@ -250,6 +276,6 @@ export const searchCommandDef = defineCommand({ process.exit(1) } const { interactiveSearch } = await import('./search-interactive.ts') - return interactiveSearch(packageFilter) + return interactiveSearch(packageFilter, agentFilter) }, }) diff --git a/src/core/skills.ts b/src/core/skills.ts index bb436230..e322402a 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,32 @@ 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..e6d0346b --- /dev/null +++ b/test/unit/project-lock.test.ts @@ -0,0 +1,81 @@ +import { mkdtempSync, mkdirSync, 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']) + }) + + // Regression: two agent dirs made detectTargetAgent() ambiguous, so search + // reported nothing indexed despite a populated index. + 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() + }) + + // A shared .skills dir is the project-wide store; per-agent dirs are symlinks into it. + 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']) + }) +})