diff --git a/README.md b/README.md index acda7285..8fd37d30 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,10 @@ skilld add crate:serde skilld update skilld update tailwindcss +# Build a searchable skill from the current project +skilld self +skilld search "how is authentication handled" -p self + # Search docs across installed skills skilld search "useFetch options" -p nuxt skilld search "error" -p nuxt --filter '{"type":"issue"}' @@ -191,6 +195,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 self` | Build a searchable skill from the current project source and docs | | `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 | diff --git a/src/cli.ts b/src/cli.ts index 8b6f5888..f1670c27 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -60,7 +60,7 @@ function deprecatedForwarder( // ── Subcommands (lazy-loaded) ── -const SUBCOMMAND_NAMES = ['add', 'eject', 'update', 'changes', 'watch', 'unwatch', 'info', 'list', 'config', 'remove', 'install', 'uninstall', 'search', 'cache', 'validate', 'assemble', 'setup', 'prepare', 'author', 'publish', 'upload', 'login', 'logout', 'whoami', 'pull'] +const SUBCOMMAND_NAMES = ['add', 'eject', 'update', 'changes', 'watch', 'unwatch', 'info', 'list', 'config', 'remove', 'install', 'uninstall', 'search', 'self', 'cache', 'validate', 'assemble', 'setup', 'prepare', 'author', 'publish', 'upload', 'login', 'logout', 'whoami', 'pull'] // ── Main command ── @@ -86,6 +86,7 @@ const main = defineCommand({ prepare: () => import('./commands/prepare.ts').then(m => m.prepareCommandDef), uninstall: () => import('./commands/uninstall.ts').then(m => m.uninstallCommandDef), search: () => import('./commands/search.ts').then(m => m.searchCommandDef), + self: () => import('./commands/self.ts').then(m => m.selfCommandDef), cache: () => import('./commands/cache.ts').then(m => m.cacheCommandDef), setup: () => import('./commands/wizard.ts').then(m => m.setupCommandDef), login: () => import('./commands/login.ts').then(m => m.loginCommandDef), diff --git a/src/commands/search-helpers.ts b/src/commands/search-helpers.ts index 0ddf1b08..fa7bac2c 100644 --- a/src/commands/search-helpers.ts +++ b/src/commands/search-helpers.ts @@ -5,19 +5,21 @@ import { existsSync, readdirSync } from 'node:fs' import * as p from '@clack/prompts' import { join } from 'pathe' import { getPackageDbPath, REFERENCES_DIR } from '../cache/index.ts' +import { selfIndexDbPath } 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) */ +/** Collect project-local and installed-package search databases. */ export function findPackageDbs(packageFilter?: string, agentTypes?: AgentType[]): string[] { const cwd = process.cwd() const lock = readProjectLock(cwd, agentTypes) - if (!lock) - return [] - return filterLockDbs(lock, packageFilter) + const packageDbs = lock ? filterLockDbs(lock, packageFilter) : [] + const selfDb = selfIndexDbPath(cwd) + const includeSelf = existsSync(selfDb) && (!packageFilter || packageFilter.toLowerCase() === 'self') + return includeSelf ? [selfDb, ...packageDbs] : packageDbs } /** Build package name → version map from the project lockfile */ diff --git a/src/commands/search.ts b/src/commands/search.ts index e8939299..904546b6 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,5 +1,5 @@ import type { AgentType } from '../agent/index.ts' -import type { SearchFilter } from '../retriv/index.ts' +import type { SearchFilter, SearchSnippet } from '../retriv/index.ts' import * as p from '@clack/prompts' import { defineCommand } from 'citty' import { detectCurrentAgent } from 'unagent/env' @@ -62,6 +62,20 @@ export interface SearchCommandOptions { limit?: number } +export function mergeSearchResults(results: SearchSnippet[][], limit: number): SearchSnippet[] { + const seen = new Set() + return results.flat() + .sort((a, b) => b.score - a.score) + .filter((result) => { + const key = `${result.package}:${result.referenceRoot ?? ''}:${result.source}:${result.lineStart}-${result.lineEnd}` + if (seen.has(key)) + return false + seen.add(key) + return true + }) + .slice(0, limit) +} + export async function searchCommand(rawQuery: string, opts: SearchCommandOptions = {}): Promise { const { packageFilter, limit: userLimit } = opts const dbs = findPackageDbs(packageFilter, opts.agents) @@ -69,11 +83,16 @@ export async function searchCommand(rawQuery: string, opts: SearchCommandOptions if (dbs.length === 0) { if (packageFilter) { - const available = listLockPackages(process.cwd(), opts.agents) - if (available.length > 0) - p.log.warn(`No docs indexed for "${packageFilter}". Available: ${available.join(', ')}`) - else - p.log.warn(`No docs indexed for "${packageFilter}". Run \`skilld add ${packageFilter}\` first.`) + if (packageFilter.toLowerCase() === 'self') { + p.log.warn('No project index found. Run `skilld self` first.') + } + else { + const available = listLockPackages(process.cwd(), opts.agents) + if (available.length > 0) + p.log.warn(`No docs indexed for "${packageFilter}". Available: ${available.join(', ')}`) + else + p.log.warn(`No docs indexed for "${packageFilter}". Run \`skilld add ${packageFilter}\` first.`) + } } else { p.log.warn('No docs indexed yet. Run `skilld add ` first.') @@ -103,18 +122,8 @@ export async function searchCommand(rawQuery: string, opts: SearchCommandOptions throw err } - // Merge, deduplicate by source+lineRange, and sort by score - const seen = new Set() - const merged = allResults.flat() - .sort((a, b) => b.score - a.score) - .filter((r) => { - const key = `${r.source}:${r.lineStart}-${r.lineEnd}` - if (seen.has(key)) - return false - seen.add(key) - return true - }) - .slice(0, resultLimit) + // Merge, deduplicate within each package, and sort by score + const merged = mergeSearchResults(allResults, resultLimit) const elapsed = ((performance.now() - start) / 1000).toFixed(2) diff --git a/src/commands/self.ts b/src/commands/self.ts new file mode 100644 index 00000000..828d5885 --- /dev/null +++ b/src/commands/self.ts @@ -0,0 +1,305 @@ +import type { AgentType } from '../agent/index.ts' +import type { Document, IndexConfig } from '../retriv/index.ts' +import { copyFileSync, existsSync, lstatSync, mkdirSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { glob, lstat, readFile } from 'node:fs/promises' +import * as p from '@clack/prompts' +import { defineCommand } from 'citty' +import { basename, dirname, join, relative } from 'pathe' +import { agents, linkSkillToAgents, sanitizeName } from '../agent/index.ts' +import { ensureProjectFiles } from '../agent/skill-installer.ts' +import { resolveAgent } from '../cli/agent-prompt.ts' +import { timedSpinner } from '../core/formatting.ts' +import { readPackageJsonSafe } from '../core/package-json.ts' +import { getSharedSkillsDir, selfIndexDbPath, skillInternalDir } from '../core/paths.ts' +import { yamlEscape } from '../core/yaml.ts' +import { MAX_INDEX_DOCS } from '../retriv/index-pipeline.ts' +import { createIndex, SearchDepsUnavailableError } from '../retriv/index.ts' +import { shutdownWorker } from '../retriv/pool.ts' + +const PROJECT_PATTERNS = [ + 'README*', + 'package.json', + 'docs/**/*.{md,mdx,txt}', + '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,vue,svelte,astro,py,rs,go,java,kt,kts,rb,php,swift,cs,css,scss,html}', +] + +const DOC_FILE_RE = /^(?:README|docs\/)|\.(?:md|mdx|txt)$/i +const KEY_FILE_RE = /^(?:README[^/]*|package\.json)$/i +const INDEX_FILE_RE = /(?:^|\/)index\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/ + +const IGNORED_DIRS: Record = { + '.git': true, + '.skilld': true, + '.nuxt': true, + '.output': true, + '.next': true, + 'build': true, + 'coverage': true, + 'dist': true, + 'node_modules': true, + 'target': true, +} + +export const MAX_SELF_FILE_BYTES = 512 * 1024 + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +function assertSafeSkillDestination(cwd: string, skillDir: string): void { + const relativeDir = relative(cwd, skillDir) + if (relativeDir === '..' || relativeDir.startsWith('../')) + throw new Error(`Skill destination escapes the project: ${skillDir}`) + + let current = cwd + for (const segment of relativeDir.split('/')) { + current = join(current, segment) + try { + if (lstatSync(current).isSymbolicLink()) + throw new Error(`Refusing to write through symlink: ${current}`) + } + catch (error) { + if (!isMissingPathError(error)) + throw error + } + } +} + +function assertReplaceableProjectLink(projectLink: string): boolean { + try { + if (!lstatSync(projectLink).isSymbolicLink()) + throw new Error(`Cannot replace non-symlink project reference: ${projectLink}`) + return true + } + catch (error) { + if (isMissingPathError(error)) + return false + throw error + } +} + +export interface SelfProject { + name: string + description?: string + files: string[] + documents: Document[] + keyFiles: string[] + directories: string[] +} + +export interface CreateSelfSkillOptions { + cwd: string + agent: AgentType + onProgress?: (message: string) => void + index?: (documents: Document[], config: IndexConfig & { removeIds?: string[] }) => Promise + renameFile?: (from: string, to: string) => void +} + +export interface CreateSelfSkillResult { + dbPath: string + skillDir: string + skillName: string + documentCount: number +} + +/** Scan the current project into documents suitable for the search index. */ +export async function scanSelfProject(cwd: string): Promise { + const packageJson = readPackageJsonSafe(join(cwd, 'package.json'))?.parsed + const rawName = typeof packageJson?.name === 'string' ? packageJson.name : basename(cwd) + const description = typeof packageJson?.description === 'string' ? packageJson.description : undefined + const files: string[] = [] + + for await (const file of glob(PROJECT_PATTERNS, { + cwd, + exclude: (path: string) => path.split('/').some(segment => IGNORED_DIRS[segment]), + })) { + files.push(file) + } + + files.sort((a, b) => { + const aDoc = DOC_FILE_RE.test(a) + const bDoc = DOC_FILE_RE.test(b) + return Number(bDoc) - Number(aDoc) || a.localeCompare(b) + }) + const uniqueFiles = [...new Set(files)] + const indexableFiles: string[] = [] + for (const file of uniqueFiles) { + if (indexableFiles.length === MAX_INDEX_DOCS) + break + const info = await lstat(join(cwd, file)) + if (info.isFile() && info.size <= MAX_SELF_FILE_BYTES) + indexableFiles.push(file) + } + + const documents = await Promise.all(indexableFiles.map(async (file) => { + const content = await readFile(join(cwd, file), 'utf8') + const type = DOC_FILE_RE.test(file) ? 'doc' : 'source' + return { + id: file, + content, + metadata: { package: rawName, source: `project/${file}`, type }, + } satisfies Document + })) + + const entryFields = ['main', 'module', 'types'] + const declaredEntries = entryFields.flatMap((field) => { + const value = packageJson?.[field] + return typeof value === 'string' ? [value] : [] + }) + const keyFiles = [...new Set([ + ...indexableFiles.filter(file => KEY_FILE_RE.test(file)), + ...declaredEntries.filter(file => indexableFiles.includes(file)), + ...indexableFiles.filter(file => INDEX_FILE_RE.test(file)).slice(0, 8), + ])] + const directories = [...new Set(indexableFiles.map(file => dirname(file)).filter(dir => dir !== '.'))].slice(0, 12) + + return { name: rawName, description, files: indexableFiles, documents, keyFiles, directories } +} + +/** Render the project skill that directs agents to the local self index. */ +export function renderSelfSkill(project: SelfProject, skillName: string): string { + const description = project.description + ? `Project context for ${project.name}: ${project.description}` + : `Project context for ${project.name}` + const keyFiles = project.keyFiles.length > 0 + ? project.keyFiles.map(file => `- \`${file}\``).join('\n') + : '- No conventional entry files detected' + const directories = project.directories.length > 0 + ? project.directories.map(dir => `- \`${dir}/\``).join('\n') + : '- Project root only' + + return `---\nname: ${skillName}\ndescription: ${yamlEscape(description)}\n---\n\n# ${project.name}\n\nUse this skill for questions and changes specific to this project.\n\n## Search\n\nRun \`skilld search "query" -p self\` to search the current project source and docs. Re-run \`skilld self\` after the project changes to rebuild the index.\n\n## Key files\n\n${keyFiles}\n\n## Directories\n\n${directories}\n` +} + +/** Build a fresh project-local index and install its generated skill. */ +export async function createSelfSkill(opts: CreateSelfSkillOptions): Promise { + const project = await scanSelfProject(opts.cwd) + if (project.documents.length === 0) + throw new Error('No project source or documentation files found') + + const skillName = `${sanitizeName(project.name)}-project` + const shared = getSharedSkillsDir(opts.cwd) + const baseDir = shared || join(opts.cwd, agents[opts.agent].skillsDir) + const skillDir = join(baseDir, skillName) + const internalDir = skillInternalDir(skillDir) + const projectLink = join(internalDir, 'project') + assertSafeSkillDestination(opts.cwd, internalDir) + assertReplaceableProjectLink(projectLink) + const dbPath = selfIndexDbPath(opts.cwd) + const referenceRoot = `${relative(opts.cwd, skillDir)}/.skilld` + for (const document of project.documents) + document.metadata = { ...document.metadata, package: skillName, referenceRoot } + const nextDbPath = `${dbPath}.next` + const index = opts.index ?? createIndex + const renameFile = opts.renameFile ?? renameSync + + mkdirSync(dirname(dbPath), { recursive: true }) + for (const suffix of ['', '-shm', '-wal']) + rmSync(`${nextDbPath}${suffix}`, { force: true }) + + try { + await index(project.documents, { + dbPath: nextDbPath, + onProgress: ({ phase, current, total }) => opts.onProgress?.(`${phase} (${current}/${total})`), + }) + } + catch (error) { + for (const suffix of ['', '-shm', '-wal']) + rmSync(`${nextDbPath}${suffix}`, { force: true }) + throw error + } + + if (!existsSync(nextDbPath)) + throw new Error('Search index build did not produce a database') + + const backupDbPath = `${dbPath}.previous` + try { + for (const suffix of ['', '-shm', '-wal']) { + rmSync(`${backupDbPath}${suffix}`, { force: true }) + if (existsSync(`${dbPath}${suffix}`)) + copyFileSync(`${dbPath}${suffix}`, `${backupDbPath}${suffix}`) + } + } + catch (error) { + for (const suffix of ['', '-shm', '-wal']) + rmSync(`${backupDbPath}${suffix}`, { force: true }) + throw error + } + + try { + for (const suffix of ['', '-shm', '-wal']) + rmSync(`${dbPath}${suffix}`, { force: true }) + for (const suffix of ['', '-shm', '-wal']) { + if (existsSync(`${nextDbPath}${suffix}`)) + renameFile(`${nextDbPath}${suffix}`, `${dbPath}${suffix}`) + } + } + catch (error) { + for (const suffix of ['', '-shm', '-wal']) { + rmSync(`${dbPath}${suffix}`, { force: true }) + if (existsSync(`${backupDbPath}${suffix}`)) + copyFileSync(`${backupDbPath}${suffix}`, `${dbPath}${suffix}`) + rmSync(`${nextDbPath}${suffix}`, { force: true }) + } + throw error + } + finally { + for (const suffix of ['', '-shm', '-wal']) + rmSync(`${backupDbPath}${suffix}`, { force: true }) + } + + mkdirSync(internalDir, { recursive: true }) + if (assertReplaceableProjectLink(projectLink)) + unlinkSync(projectLink) + symlinkSync(relative(internalDir, opts.cwd), projectLink, 'dir') + const skillContent = renderSelfSkill(project, skillName) + writeFileSync(join(skillDir, 'SKILL.md'), skillContent) + writeFileSync(join(skillInternalDir(skillDir), '_SKILL.md'), skillContent) + + if (shared) + linkSkillToAgents(skillName, shared, opts.cwd, opts.agent) + + return { dbPath, skillDir, skillName, documentCount: project.documents.length } +} + +export const selfCommandDef = defineCommand({ + meta: { name: 'self', description: 'Build a searchable skill from the current project' }, + args: { + agent: { + type: 'enum' as const, + options: Object.keys(agents), + alias: 'a', + description: 'Target agent', + }, + }, + async run({ args }) { + const cwd = process.cwd() + const agent = resolveAgent(args.agent) + if (!agent || agent === 'none') + return + + const spin = timedSpinner() + spin.start('Scanning project') + try { + const result = await createSelfSkill({ + cwd, + agent, + onProgress: message => spin.message(message), + }) + await ensureProjectFiles({ cwd, agent, global: false }) + spin.stop(`Indexed ${result.documentCount} files`) + p.outro(`Self skill written to ${relative(cwd, result.skillDir)}`) + } + catch (error) { + spin.stop('Self indexing failed') + if (error instanceof SearchDepsUnavailableError) + p.log.error('Search requires the optional native dependencies') + else + p.log.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + finally { + await shutdownWorker() + } + }, +}) diff --git a/src/core/formatting.ts b/src/core/formatting.ts index 95b9aa11..22045a60 100644 --- a/src/core/formatting.ts +++ b/src/core/formatting.ts @@ -86,8 +86,13 @@ export function normalizeScores(results: SearchSnippet[]): Map, pct?: number): string { - const refPath = `.claude/skills/${r.package}/.skilld/${r.source}` + const refPath = snippetRefPath(r) const lineRange = r.lineStart === r.lineEnd ? `L${r.lineStart}` : `L${r.lineStart}-${r.lineEnd}` const score = pct != null ? scoreLabel(pct) : styleText('gray', r.score.toFixed(2)) const version = versions?.get(r.package) @@ -112,7 +117,7 @@ export function formatCompactSnippet(r: SearchSnippet, cols: number): { title: s const scopeStr = r.scope?.length ? `${r.scope.map(e => e.name).join('.')} → ` : '' const title = entityStr ? `${scopeStr}${entityStr}` : r.source.split('/').pop() || r.source - const refPath = `.claude/skills/${r.package}/.skilld/${r.source}` + const refPath = snippetRefPath(r) const lineRange = r.lineStart === r.lineEnd ? `L${r.lineStart}` : `L${r.lineStart}-${r.lineEnd}` const path = `${refPath}:${lineRange}` diff --git a/src/core/paths.ts b/src/core/paths.ts index 35970826..ccec35a7 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -105,3 +105,8 @@ export function getRepoCacheDir(owner: string, repo: string): string { export function getPackageDbPath(name: string, version: string): string { return join(getReferencesDir(name, version), 'search.db') } + +/** Project-local search database for the live self skill */ +export function selfIndexDbPath(cwd: string = process.cwd()): string { + return join(cwd, '.skilld', 'self', 'search.db') +} diff --git a/src/retriv/index.ts b/src/retriv/index.ts index 6510b692..31da8939 100644 --- a/src/retriv/index.ts +++ b/src/retriv/index.ts @@ -198,6 +198,7 @@ function toSnippets(results: SearchResult[]): SearchSnippet[] { return { package: r.metadata.package || 'unknown', source, + referenceRoot: typeof r.metadata.referenceRoot === 'string' ? r.metadata.referenceRoot : undefined, lineStart: r.lineRange?.[0] ?? 1, lineEnd: r.lineRange?.[1] ?? lines, content, diff --git a/src/retriv/types.ts b/src/retriv/types.ts index 38819cf9..5e343737 100644 --- a/src/retriv/types.ts +++ b/src/retriv/types.ts @@ -70,6 +70,8 @@ export interface SearchSnippet { package: string /** Source file path */ source: string + /** Project-relative directory containing the referenced source tree */ + referenceRoot?: string /** Start line number */ lineStart: number /** End line number */ diff --git a/test/unit/search.test.ts b/test/unit/search.test.ts index f11411e8..ecc1fcee 100644 --- a/test/unit/search.test.ts +++ b/test/unit/search.test.ts @@ -1,7 +1,7 @@ import type { SearchSnippet } from '../../src/retriv/types' import { describe, expect, it } from 'vitest' -import { generateSearchGuide, parseAgentFilter, parseFilterPrefix, parseJsonFilter } from '../../src/commands/search' -import { normalizeScores, scoreLabel } from '../../src/core/formatting' +import { generateSearchGuide, mergeSearchResults, parseAgentFilter, parseFilterPrefix, parseJsonFilter } from '../../src/commands/search' +import { formatCompactSnippet, formatSnippet, normalizeScores, scoreLabel } from '../../src/core/formatting' function snippet(overrides: Partial = {}): SearchSnippet { return { @@ -185,6 +185,46 @@ describe('generateSearchGuide', () => { }) }) +describe('mergeSearchResults', () => { + it('keeps matching source locations from different indexes', () => { + const projectResult = snippet({ + package: 'foo-project', + referenceRoot: '.agents/skills/foo-project/.skilld', + source: 'src/index.ts', + score: 0.9, + }) + const dependencyResult = snippet({ + package: 'foo-project', + referenceRoot: '.claude/skills/foo-project/.skilld', + source: 'src/index.ts', + score: 0.8, + }) + + expect(mergeSearchResults([[projectResult], [dependencyResult]], 5)).toEqual([ + projectResult, + dependencyResult, + ]) + }) + + it('deduplicates matching source locations within a package', () => { + const result = snippet({ source: 'src/index.ts' }) + + expect(mergeSearchResults([[result], [{ ...result }]], 5)).toEqual([result]) + }) +}) + +describe('search result paths', () => { + it('uses an indexed project reference root when provided', () => { + const result = snippet({ + source: 'project/src/index.ts', + referenceRoot: '.agents/skills/demo-project/.skilld', + }) + + expect(formatSnippet(result)).toContain('.agents/skills/demo-project/.skilld/project/src/index.ts:L1-10') + expect(formatCompactSnippet(result, 120).path).toBe('.agents/skills/demo-project/.skilld/project/src/index.ts:L1-10') + }) +}) + describe('normalizeScores', () => { it('normalizes best result to 100', () => { const results = [snippet({ score: 0.08 }), snippet({ score: 0.04 }), snippet({ score: 0.02 })] diff --git a/test/unit/self.test.ts b/test/unit/self.test.ts new file mode 100644 index 00000000..4ca549cc --- /dev/null +++ b/test/unit/self.test.ts @@ -0,0 +1,198 @@ +import type { Document, IndexConfig } from '../../src/retriv/index.ts' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { afterEach, describe, expect, it } from 'vitest' +import { findPackageDbs } from '../../src/commands/search-helpers.ts' +import { createSelfSkill, MAX_SELF_FILE_BYTES, scanSelfProject } from '../../src/commands/self.ts' +import { selfIndexDbPath } from '../../src/core/paths.ts' +import { MAX_INDEX_DOCS } from '../../src/retriv/index-pipeline.ts' + +const fixtures: string[] = [] +const originalCwd = process.cwd() + +function makeProject(): string { + const cwd = mkdtempSync(join(tmpdir(), 'skilld-self-')) + fixtures.push(cwd) + writeFileSync(join(cwd, 'package.json'), JSON.stringify({ + name: '@demo/project', + description: 'Demo project', + main: 'src/index.ts', + })) + mkdirSync(join(cwd, 'src')) + writeFileSync(join(cwd, 'src/index.ts'), 'export const answer = 42\n') + writeFileSync(join(cwd, 'README.md'), '# Demo\n') + return cwd +} + +afterEach(() => { + process.chdir(originalCwd) + for (const fixture of fixtures.splice(0)) + rmSync(fixture, { recursive: true, force: true }) +}) + +describe('scanSelfProject', () => { + it('indexes project text without following symlinks or oversized files', async () => { + const cwd = makeProject() + mkdirSync(join(cwd, 'docs')) + const outside = join(tmpdir(), `skilld-self-secret-${Date.now()}.md`) + writeFileSync(outside, 'secret outside project') + fixtures.push(outside) + symlinkSync(outside, join(cwd, 'docs/linked.md')) + writeFileSync(join(cwd, 'docs/oversized.md'), Buffer.alloc(MAX_SELF_FILE_BYTES + 1, 97)) + + const project = await scanSelfProject(cwd) + + expect(project.files).toContain('README.md') + expect(project.files).toContain('src/index.ts') + expect(project.files).not.toContain('docs/linked.md') + expect(project.files).not.toContain('docs/oversized.md') + expect(project.documents.some(doc => doc.content.includes('secret outside project'))).toBe(false) + }) + + it('caps the project before reading source files', async () => { + const cwd = makeProject() + for (let i = 0; i < MAX_INDEX_DOCS; i++) + writeFileSync(join(cwd, `source-${String(i).padStart(3, '0')}.ts`), `export const n = ${i}\n`) + + const project = await scanSelfProject(cwd) + + expect(project.documents).toHaveLength(MAX_INDEX_DOCS) + expect(project.files[0]).toBe('README.md') + }) +}) + +describe('createSelfSkill', () => { + it('rebuilds edited files and exposes the self database to search', async () => { + const cwd = makeProject() + const dbPath = selfIndexDbPath(cwd) + mkdirSync(join(cwd, '.skilld/self'), { recursive: true }) + writeFileSync(dbPath, 'stale') + const indexed: Document[][] = [] + + const index = async (documents: Document[], config: IndexConfig & { removeIds?: string[] }) => { + expect(existsSync(config.dbPath)).toBe(false) + expect(readFileSync(dbPath, 'utf8')).toBe(indexed.length === 0 ? 'stale' : `build-${indexed.length}`) + indexed.push(documents) + writeFileSync(config.dbPath, `build-${indexed.length}`) + } + + const first = await createSelfSkill({ cwd, agent: 'codex', index }) + writeFileSync(join(cwd, 'src/index.ts'), 'export const answer = 43\n') + const second = await createSelfSkill({ cwd, agent: 'codex', index }) + + expect(indexed).toHaveLength(2) + expect(indexed[1]?.find(doc => doc.id === 'src/index.ts')?.content).toContain('43') + expect(indexed[1]?.find(doc => doc.id === 'src/index.ts')?.metadata?.package).toBe('demo-project-project') + expect(indexed[1]?.find(doc => doc.id === 'src/index.ts')?.metadata?.referenceRoot).toBe('.agents/skills/demo-project-project/.skilld') + expect(readFileSync(dbPath, 'utf8')).toBe('build-2') + expect(readFileSync(join(first.skillDir, 'SKILL.md'), 'utf8')).toContain('skilld search "query" -p self') + expect(second.skillName).toBe('demo-project-project') + expect(realpathSync(join(first.skillDir, '.skilld/project'))).toBe(realpathSync(cwd)) + + process.chdir(cwd) + expect(findPackageDbs('self')).toEqual([dbPath]) + expect(findPackageDbs()).toEqual([dbPath]) + expect(findPackageDbs('vue')).toEqual([]) + }) + + it('keeps the last usable index when rebuilding fails', async () => { + const cwd = makeProject() + const dbPath = selfIndexDbPath(cwd) + mkdirSync(join(cwd, '.skilld/self'), { recursive: true }) + writeFileSync(dbPath, 'working') + + await expect(createSelfSkill({ + cwd, + agent: 'codex', + index: async (_documents, config) => { + writeFileSync(config.dbPath, 'partial') + throw new Error('embedding failed') + }, + })).rejects.toThrow('embedding failed') + + expect(readFileSync(dbPath, 'utf8')).toBe('working') + expect(existsSync(`${dbPath}.next`)).toBe(false) + }) + + it('restores the previous index when promotion fails', async () => { + const cwd = makeProject() + const dbPath = selfIndexDbPath(cwd) + mkdirSync(join(cwd, '.skilld/self'), { recursive: true }) + writeFileSync(dbPath, 'working') + + await expect(createSelfSkill({ + cwd, + agent: 'codex', + index: async (_documents, config) => { + writeFileSync(config.dbPath, 'complete') + }, + renameFile: () => { + throw new Error('rename failed') + }, + })).rejects.toThrow('rename failed') + + expect(readFileSync(dbPath, 'utf8')).toBe('working') + expect(existsSync(`${dbPath}.previous`)).toBe(false) + }) + + it('does not replace the index when the project reference is not replaceable', async () => { + const cwd = makeProject() + const dbPath = selfIndexDbPath(cwd) + mkdirSync(join(cwd, '.skilld/self'), { recursive: true }) + writeFileSync(dbPath, 'working') + const projectLink = join(cwd, '.agents/skills/demo-project-project/.skilld/project') + mkdirSync(join(projectLink, '..'), { recursive: true }) + writeFileSync(projectLink, 'not a symlink') + + await expect(createSelfSkill({ + cwd, + agent: 'codex', + index: async (_documents, config) => { + writeFileSync(config.dbPath, 'complete') + }, + })).rejects.toThrow('Cannot replace non-symlink project reference') + + expect(readFileSync(dbPath, 'utf8')).toBe('working') + }) + + it('rejects a symlinked skill destination', async () => { + const cwd = makeProject() + const outside = mkdtempSync(join(tmpdir(), 'skilld-self-outside-')) + fixtures.push(outside) + const skillsDir = join(cwd, '.agents/skills') + mkdirSync(skillsDir, { recursive: true }) + symlinkSync(outside, join(skillsDir, 'demo-project-project'), 'dir') + + await expect(createSelfSkill({ + cwd, + agent: 'codex', + index: async (_documents, config) => { + writeFileSync(config.dbPath, 'complete') + }, + })).rejects.toThrow('Refusing to write through symlink') + + expect(existsSync(join(outside, 'SKILL.md'))).toBe(false) + expect(existsSync(join(outside, '_SKILL.md'))).toBe(false) + }) + + it('rejects a symlinked internal skill directory', async () => { + const cwd = makeProject() + const outside = mkdtempSync(join(tmpdir(), 'skilld-self-internal-outside-')) + fixtures.push(outside) + const skillDir = join(cwd, '.agents/skills/demo-project-project') + mkdirSync(skillDir, { recursive: true }) + symlinkSync(outside, join(skillDir, '.skilld'), 'dir') + + await expect(createSelfSkill({ + cwd, + agent: 'codex', + index: async (_documents, config) => { + writeFileSync(config.dbPath, 'complete') + }, + })).rejects.toThrow('Refusing to write through symlink') + + expect(existsSync(join(outside, '_SKILL.md'))).toBe(false) + expect(existsSync(join(outside, 'project'))).toBe(false) + }) +})