Skip to content
Closed
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
30 changes: 8 additions & 22 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 { 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'

Check failure on line 7 in src/commands/search-helpers.ts

View workflow job for this annotation

GitHub Actions / test

All imports in the declaration are only used as types. Use `import type`
import { getSharedSkillsDir } from '../core/paths.ts'
import { readProjectLock } from '../core/skills.ts'
import { toStoragePackageName } from '../core/prefix.ts'

Check failure on line 9 in src/commands/search-helpers.ts

View workflow job for this annotation

GitHub Actions / test

Expected "../core/prefix.ts" to come before "../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, 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<string, string> {
const lock = readProjectLock(cwd)
export function getPackageVersions(cwd: string = process.cwd(), agentFilter?: AgentType[]): Map<string, string> {
const lock = readProjectLock(cwd, agentFilter)
const map = new Map<string, string>()
if (!lock)
return map
Expand All @@ -33,23 +33,9 @@
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(), agentFilter?: AgentType[]): string[] {
const lock = readProjectLock(cwd, agentFilter)
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, agentFilter?: AgentType[]): Promise<void> {
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.`
Expand Down
36 changes: 31 additions & 5 deletions src/commands/search.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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'

Check failure on line 5 in src/commands/search.ts

View workflow job for this annotation

GitHub Actions / test

Expected "citty" (value-external) to come before "../agent/index.ts" (value-parent)
import { detectCurrentAgent } from 'unagent/env'
import { isInteractive } from '../cli/env.ts'
import { formatSnippet, normalizeScores, sanitizeMarkdown } from '../core/index.ts'
Expand Down Expand Up @@ -54,19 +56,21 @@
}

export interface SearchCommandOptions {
/** Restrict to specific agents' skills dirs (default: all) */
agentFilter?: 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.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
Expand Down Expand Up @@ -180,6 +184,19 @@
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: {
Expand All @@ -194,6 +211,11 @@
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 +251,10 @@
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)
Expand All @@ -240,7 +266,7 @@
}

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.')
Expand All @@ -250,6 +276,6 @@
process.exit(1)
}
const { interactiveSearch } = await import('./search-interactive.ts')
return interactiveSearch(packageFilter)
return interactiveSearch(packageFilter, agentFilter)
},
})
30 changes: 28 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,32 @@ export function* iterateSkills(opts: IterateSkillsOptions = {}): Generator<Skill
}
}

/**
* Read the project lockfile, merging every agent's skills dir.
*
* Indexes are keyed by package and version, not by agent, so restricting to one
* agent would hide skills the project actually has. Pass `agentFilter` to
* narrow to specific agents.
*/
export function readProjectLock(cwd: string, agentFilter?: AgentType[]): SkilldLock | null {
const shared = getSharedSkillsDir(cwd)
if (shared) {
const lock = readLock(shared)
if (lock)
return lock
}

const targets = agentFilter?.length
? agentFilter.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
81 changes: 81 additions & 0 deletions test/unit/project-lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'

Check failure on line 1 in test/unit/project-lock.test.ts

View workflow job for this annotation

GitHub Actions / test

Expected "mkdirSync" to come before "mkdtempSync"
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'])
})

// 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'])
})
})
Loading