Skip to content

Commit 3a9f140

Browse files
authored
feat(cache): add --stats and --clean flags (#61)
1 parent 462d2da commit 3a9f140

File tree

3 files changed

+142
-8
lines changed

3 files changed

+142
-8
lines changed

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ skilld prepare # Hook for package.json "prepare" (restore refs, sync ship
3434
skilld uninstall # Remove skilld data
3535
skilld search "query" # Search indexed docs
3636
skilld search "query" -p nuxt # Search filtered by package
37-
skilld cache # Clean expired LLM cache entries
37+
skilld cache --clean # Clean expired LLM cache entries
38+
skilld cache --stats # Show cache disk usage breakdown
3839
skilld add owner/repo # Add pre-authored skills from git repo
3940
skilld eject vue # Eject skill (portable, no symlinks)
4041
skilld eject vue --name vue # Eject with custom skill dir name

src/commands/cache.ts

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
* Cache management commands
33
*/
44

5+
import type { Dirent } from 'node:fs'
56
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
67
import * as p from '@clack/prompts'
78
import { defineCommand } from 'citty'
89
import { join } from 'pathe'
9-
import { CACHE_DIR } from '../cache/index.ts'
10+
import { CACHE_DIR, REFERENCES_DIR, REPOS_DIR } from '../cache/index.ts'
1011
import { clearEmbeddingCache } from '../retriv/embedding-cache.ts'
1112

1213
const LLM_CACHE_DIR = join(CACHE_DIR, 'llm-cache')
@@ -75,17 +76,94 @@ export async function cacheCleanCommand(): Promise<void> {
7576
}
7677
}
7778

79+
function dirEntries(dir: string): Dirent[] {
80+
if (!existsSync(dir))
81+
return []
82+
return readdirSync(dir, { withFileTypes: true, recursive: true })
83+
}
84+
85+
function sumFileBytes(entries: Dirent[]): number {
86+
return entries
87+
.filter(e => e.isFile())
88+
.reduce((sum, e) => {
89+
try {
90+
return sum + statSync(join(e.parentPath, e.name)).size
91+
}
92+
catch { return sum }
93+
}, 0)
94+
}
95+
96+
function fmtBytes(n: number): string {
97+
const units = ['B', 'KB', 'MB', 'GB'] as const
98+
let i = 0
99+
while (n >= 1024 && i < units.length - 1) {
100+
n /= 1024
101+
i++
102+
}
103+
return i === 0 ? `${n}${units[i]}` : `${n.toFixed(1)}${units[i]}`
104+
}
105+
106+
export function cacheStatsCommand(): void {
107+
const dim = (s: string) => `\x1B[90m${s}\x1B[0m`
108+
109+
const refs = dirEntries(REFERENCES_DIR)
110+
const repos = dirEntries(REPOS_DIR)
111+
const llm = dirEntries(LLM_CACHE_DIR)
112+
const embPath = join(CACHE_DIR, 'embeddings.db')
113+
const embSize = existsSync(embPath) ? statSync(embPath).size : 0
114+
115+
// Count packages: top-level non-scoped dirs + dirs inside @scope/ dirs
116+
const packages = refs.filter(e =>
117+
e.isDirectory()
118+
&& (e.parentPath === REFERENCES_DIR
119+
? !e.name.startsWith('@')
120+
: e.parentPath.startsWith(REFERENCES_DIR)),
121+
).length
122+
123+
const llmFiles = llm.filter(e => e.isFile())
124+
const sizes = { refs: sumFileBytes(refs), repos: sumFileBytes(repos), llm: sumFileBytes(llmFiles), emb: embSize }
125+
const total = sizes.refs + sizes.repos + sizes.llm + sizes.emb
126+
127+
const lines = [
128+
`References ${fmtBytes(sizes.refs)} ${dim(`${packages} packages`)}`,
129+
...(sizes.repos > 0 ? [`Repos ${fmtBytes(sizes.repos)}`] : []),
130+
`LLM cache ${fmtBytes(sizes.llm)} ${dim(`${llmFiles.length} entries`)}`,
131+
...(sizes.emb > 0 ? [`Embeddings ${fmtBytes(sizes.emb)}`] : []),
132+
'',
133+
`Total ${fmtBytes(total)} ${dim(CACHE_DIR)}`,
134+
]
135+
p.log.message(lines.join('\n'))
136+
}
137+
78138
export const cacheCommandDef = defineCommand({
79139
meta: { name: 'cache', description: 'Cache management', hidden: true },
80140
args: {
81141
clean: {
82142
type: 'boolean',
143+
alias: 'c',
83144
description: 'Remove expired enhancement cache entries',
84-
default: true,
145+
default: false,
146+
},
147+
stats: {
148+
type: 'boolean',
149+
alias: 's',
150+
description: 'Show cache disk usage',
151+
default: false,
85152
},
86153
},
87-
async run() {
88-
p.intro(`\x1B[1m\x1B[35mskilld\x1B[0m cache clean`)
89-
await cacheCleanCommand()
154+
async run({ args }) {
155+
if (args.stats) {
156+
p.intro(`\x1B[1m\x1B[35mskilld\x1B[0m cache stats`)
157+
cacheStatsCommand()
158+
return
159+
}
160+
if (args.clean) {
161+
p.intro(`\x1B[1m\x1B[35mskilld\x1B[0m cache clean`)
162+
await cacheCleanCommand()
163+
return
164+
}
165+
// No flag: show usage
166+
p.intro(`\x1B[1m\x1B[35mskilld\x1B[0m cache`)
167+
p.log.message('Usage:\n skilld cache --clean Remove expired cache entries\n skilld cache --stats Show cache disk usage')
90168
},
91169
})

test/unit/cache-clean.test.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
3-
import { cacheCleanCommand } from '../../src/commands/cache.ts'
3+
import { cacheCleanCommand, cacheStatsCommand } from '../../src/commands/cache.ts'
44

55
vi.mock('node:fs', async () => {
66
const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
@@ -15,7 +15,7 @@ vi.mock('node:fs', async () => {
1515
})
1616

1717
vi.mock('@clack/prompts', () => ({
18-
log: { success: vi.fn(), info: vi.fn() },
18+
log: { success: vi.fn(), info: vi.fn(), message: vi.fn() },
1919
}))
2020

2121
vi.mock('../../src/retriv/embedding-cache.ts', () => ({
@@ -71,3 +71,58 @@ describe('cacheCleanCommand', () => {
7171
expect(rmSync).toHaveBeenCalledWith(expect.stringContaining('bad.json'))
7272
})
7373
})
74+
75+
describe('cacheStatsCommand', () => {
76+
beforeEach(() => {
77+
vi.resetAllMocks()
78+
})
79+
80+
it('runs without error on empty cache', () => {
81+
vi.mocked(existsSync).mockReturnValue(false)
82+
vi.mocked(readdirSync).mockReturnValue([] as any)
83+
84+
expect(() => cacheStatsCommand()).not.toThrow()
85+
})
86+
87+
it('reports total and sections in output', async () => {
88+
const { log } = await import('@clack/prompts')
89+
vi.mocked(existsSync).mockReturnValue(true)
90+
vi.mocked(readdirSync).mockReturnValue([] as any)
91+
vi.mocked(statSync).mockReturnValue({ size: 0 } as any)
92+
93+
cacheStatsCommand()
94+
95+
const output = vi.mocked(log.message).mock.calls[0]![0] as string
96+
expect(output).toContain('References')
97+
expect(output).toContain('LLM cache')
98+
expect(output).toContain('Total')
99+
expect(output).toContain('0 packages')
100+
})
101+
102+
it('counts scoped packages correctly', async () => {
103+
const { log } = await import('@clack/prompts')
104+
105+
vi.mocked(existsSync).mockReturnValue(true)
106+
vi.mocked(statSync).mockReturnValue({ size: 100 } as any)
107+
108+
// Simulate: references/ contains vue@3.5.0 (unscoped) and @vue/ scope dir
109+
// @vue/ contains runtime-core@3.5.0 and shared@3.5.0
110+
// Total packages = 3 (vue, runtime-core, shared)
111+
vi.mocked(readdirSync).mockImplementation(((dir: string, _opts?: any) => {
112+
if (dir.includes('references')) {
113+
return [
114+
{ name: 'vue@3.5.0', isFile: () => false, isDirectory: () => true, parentPath: dir },
115+
{ name: '@vue', isFile: () => false, isDirectory: () => true, parentPath: dir },
116+
{ name: 'runtime-core@3.5.0', isFile: () => false, isDirectory: () => true, parentPath: `${dir}/@vue` },
117+
{ name: 'shared@3.5.0', isFile: () => false, isDirectory: () => true, parentPath: `${dir}/@vue` },
118+
] as any
119+
}
120+
return [] as any
121+
}) as any)
122+
123+
cacheStatsCommand()
124+
125+
const output = vi.mocked(log.message).mock.calls[0]![0] as string
126+
expect(output).toContain('3 packages')
127+
})
128+
})

0 commit comments

Comments
 (0)