diff --git a/CLAUDE.md b/CLAUDE.md index 5483cdc2..dfa238e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,8 @@ skilld prepare # Hook for package.json "prepare" (restore refs, sync ship skilld uninstall # Remove skilld data skilld search "query" # Search indexed docs skilld search "query" -p nuxt # Search filtered by package -skilld cache # Clean expired LLM cache entries +skilld cache --clean # Clean expired LLM cache entries +skilld cache --stats # Show cache disk usage breakdown skilld add owner/repo # Add pre-authored skills from git repo skilld eject vue # Eject skill (portable, no symlinks) skilld eject vue --name vue # Eject with custom skill dir name diff --git a/src/commands/cache.ts b/src/commands/cache.ts index 51aa68f0..6ec240d2 100644 --- a/src/commands/cache.ts +++ b/src/commands/cache.ts @@ -2,11 +2,12 @@ * Cache management commands */ +import type { Dirent } from 'node:fs' import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs' import * as p from '@clack/prompts' import { defineCommand } from 'citty' import { join } from 'pathe' -import { CACHE_DIR } from '../cache/index.ts' +import { CACHE_DIR, REFERENCES_DIR, REPOS_DIR } from '../cache/index.ts' import { clearEmbeddingCache } from '../retriv/embedding-cache.ts' const LLM_CACHE_DIR = join(CACHE_DIR, 'llm-cache') @@ -75,17 +76,94 @@ export async function cacheCleanCommand(): Promise { } } +function dirEntries(dir: string): Dirent[] { + if (!existsSync(dir)) + return [] + return readdirSync(dir, { withFileTypes: true, recursive: true }) +} + +function sumFileBytes(entries: Dirent[]): number { + return entries + .filter(e => e.isFile()) + .reduce((sum, e) => { + try { + return sum + statSync(join(e.parentPath, e.name)).size + } + catch { return sum } + }, 0) +} + +function fmtBytes(n: number): string { + const units = ['B', 'KB', 'MB', 'GB'] as const + let i = 0 + while (n >= 1024 && i < units.length - 1) { + n /= 1024 + i++ + } + return i === 0 ? `${n}${units[i]}` : `${n.toFixed(1)}${units[i]}` +} + +export function cacheStatsCommand(): void { + const dim = (s: string) => `\x1B[90m${s}\x1B[0m` + + const refs = dirEntries(REFERENCES_DIR) + const repos = dirEntries(REPOS_DIR) + const llm = dirEntries(LLM_CACHE_DIR) + const embPath = join(CACHE_DIR, 'embeddings.db') + const embSize = existsSync(embPath) ? statSync(embPath).size : 0 + + // Count packages: top-level non-scoped dirs + dirs inside @scope/ dirs + const packages = refs.filter(e => + e.isDirectory() + && (e.parentPath === REFERENCES_DIR + ? !e.name.startsWith('@') + : e.parentPath.startsWith(REFERENCES_DIR)), + ).length + + const llmFiles = llm.filter(e => e.isFile()) + const sizes = { refs: sumFileBytes(refs), repos: sumFileBytes(repos), llm: sumFileBytes(llmFiles), emb: embSize } + const total = sizes.refs + sizes.repos + sizes.llm + sizes.emb + + const lines = [ + `References ${fmtBytes(sizes.refs)} ${dim(`${packages} packages`)}`, + ...(sizes.repos > 0 ? [`Repos ${fmtBytes(sizes.repos)}`] : []), + `LLM cache ${fmtBytes(sizes.llm)} ${dim(`${llmFiles.length} entries`)}`, + ...(sizes.emb > 0 ? [`Embeddings ${fmtBytes(sizes.emb)}`] : []), + '', + `Total ${fmtBytes(total)} ${dim(CACHE_DIR)}`, + ] + p.log.message(lines.join('\n')) +} + export const cacheCommandDef = defineCommand({ meta: { name: 'cache', description: 'Cache management', hidden: true }, args: { clean: { type: 'boolean', + alias: 'c', description: 'Remove expired enhancement cache entries', - default: true, + default: false, + }, + stats: { + type: 'boolean', + alias: 's', + description: 'Show cache disk usage', + default: false, }, }, - async run() { - p.intro(`\x1B[1m\x1B[35mskilld\x1B[0m cache clean`) - await cacheCleanCommand() + async run({ args }) { + if (args.stats) { + p.intro(`\x1B[1m\x1B[35mskilld\x1B[0m cache stats`) + cacheStatsCommand() + return + } + if (args.clean) { + p.intro(`\x1B[1m\x1B[35mskilld\x1B[0m cache clean`) + await cacheCleanCommand() + return + } + // No flag: show usage + p.intro(`\x1B[1m\x1B[35mskilld\x1B[0m cache`) + p.log.message('Usage:\n skilld cache --clean Remove expired cache entries\n skilld cache --stats Show cache disk usage') }, }) diff --git a/test/unit/cache-clean.test.ts b/test/unit/cache-clean.test.ts index 3ee1b817..70d2b140 100644 --- a/test/unit/cache-clean.test.ts +++ b/test/unit/cache-clean.test.ts @@ -1,6 +1,6 @@ import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { cacheCleanCommand } from '../../src/commands/cache.ts' +import { cacheCleanCommand, cacheStatsCommand } from '../../src/commands/cache.ts' vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') @@ -15,7 +15,7 @@ vi.mock('node:fs', async () => { }) vi.mock('@clack/prompts', () => ({ - log: { success: vi.fn(), info: vi.fn() }, + log: { success: vi.fn(), info: vi.fn(), message: vi.fn() }, })) vi.mock('../../src/retriv/embedding-cache.ts', () => ({ @@ -71,3 +71,58 @@ describe('cacheCleanCommand', () => { expect(rmSync).toHaveBeenCalledWith(expect.stringContaining('bad.json')) }) }) + +describe('cacheStatsCommand', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('runs without error on empty cache', () => { + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(readdirSync).mockReturnValue([] as any) + + expect(() => cacheStatsCommand()).not.toThrow() + }) + + it('reports total and sections in output', async () => { + const { log } = await import('@clack/prompts') + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readdirSync).mockReturnValue([] as any) + vi.mocked(statSync).mockReturnValue({ size: 0 } as any) + + cacheStatsCommand() + + const output = vi.mocked(log.message).mock.calls[0]![0] as string + expect(output).toContain('References') + expect(output).toContain('LLM cache') + expect(output).toContain('Total') + expect(output).toContain('0 packages') + }) + + it('counts scoped packages correctly', async () => { + const { log } = await import('@clack/prompts') + + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(statSync).mockReturnValue({ size: 100 } as any) + + // Simulate: references/ contains vue@3.5.0 (unscoped) and @vue/ scope dir + // @vue/ contains runtime-core@3.5.0 and shared@3.5.0 + // Total packages = 3 (vue, runtime-core, shared) + vi.mocked(readdirSync).mockImplementation(((dir: string, _opts?: any) => { + if (dir.includes('references')) { + return [ + { name: 'vue@3.5.0', isFile: () => false, isDirectory: () => true, parentPath: dir }, + { name: '@vue', isFile: () => false, isDirectory: () => true, parentPath: dir }, + { name: 'runtime-core@3.5.0', isFile: () => false, isDirectory: () => true, parentPath: `${dir}/@vue` }, + { name: 'shared@3.5.0', isFile: () => false, isDirectory: () => true, parentPath: `${dir}/@vue` }, + ] as any + } + return [] as any + }) as any) + + cacheStatsCommand() + + const output = vi.mocked(log.message).mock.calls[0]![0] as string + expect(output).toContain('3 packages') + }) +})