Skip to content
Merged
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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 83 additions & 5 deletions src/commands/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -75,17 +76,94 @@ export async function cacheCleanCommand(): Promise<void> {
}
}

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')
},
})
59 changes: 57 additions & 2 deletions test/unit/cache-clean.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('node:fs')>('node:fs')
Expand All @@ -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', () => ({
Expand Down Expand Up @@ -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')
})
})
Loading