@codehourra/llm-iwiki 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts ADDED
@@ -0,0 +1,485 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
3
+
4
+ import { parseExperiencesYaml, parseSummariesYaml } from './ai-yaml'
5
+ import { readConfig, setConfigValue } from './config'
6
+ import { openDatabase, runMigrations } from './db'
7
+ import {
8
+ acceptExperience,
9
+ type ExperienceScope,
10
+ listCandidates,
11
+ prepareExperiencesTask,
12
+ proposeExperiences,
13
+ rejectExperience,
14
+ } from './experiences'
15
+ import { checkVault, exportProject } from './obsidian'
16
+ import { getAppPaths, getProjectTaskDir } from './paths'
17
+ import { getProject, listProjects, renameProject, resolveProject } from './projects'
18
+ import { inspectProject } from './sessions'
19
+ import { initSkills, SKILL_TARGETS, type SkillTarget } from './skills'
20
+ import { applySummaries, prepareSummariesTask } from './summarize'
21
+ import { runSync } from './sync'
22
+
23
+ export interface CliRuntime {
24
+ cwd: string
25
+ homeDir?: string
26
+ stdout: (message: string) => void
27
+ stderr: (message: string) => void
28
+ }
29
+
30
+ const HELP = `llm-iwiki
31
+
32
+ Usage:
33
+ llm-iwiki init
34
+ llm-iwiki doctor
35
+ llm-iwiki sync [--project <path>]
36
+ llm-iwiki projects list
37
+ llm-iwiki projects resolve <path>
38
+ llm-iwiki projects inspect <path-or-project-id>
39
+ llm-iwiki projects rename <path-or-project-id> <display-name>
40
+ llm-iwiki summarize prepare [changed|all] --project <path> [--out <file>]
41
+ llm-iwiki summarize apply --project <path> --file <summaries.yaml>
42
+ llm-iwiki experiences prepare --project <path> [--from changed-summaries|all-recent] [--out <file>]
43
+ llm-iwiki experiences propose --project <path> --file <experiences.yaml>
44
+ llm-iwiki experiences candidates [--project <path>]
45
+ llm-iwiki experiences accept <candidate-id>
46
+ llm-iwiki experiences reject <candidate-id>
47
+ llm-iwiki obsidian export [--project <path>] [--vault <dir>] [--force]
48
+ llm-iwiki obsidian check
49
+ llm-iwiki config show
50
+ llm-iwiki config set <key> <value>
51
+ llm-iwiki skills init [--target codex|claude-code|cursor] [--force] [--dry-run]
52
+ `
53
+
54
+ function resolveCliPath(cwd: string, targetPath: string): string {
55
+ return isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath)
56
+ }
57
+
58
+ function readFlag(args: string[], name: string): string | null {
59
+ const index = args.indexOf(name)
60
+ if (index === -1) return null
61
+ const value = args[index + 1]
62
+ if (!value || value.startsWith('--')) return null
63
+ return value
64
+ }
65
+
66
+ export async function runCli(args: string[], runtime: CliRuntime): Promise<number> {
67
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
68
+ runtime.stdout(HELP)
69
+ return 0
70
+ }
71
+
72
+ if (args[0] === 'init') {
73
+ const paths = getAppPaths(runtime.homeDir)
74
+ mkdirSync(paths.configDir, { recursive: true })
75
+ if (!existsSync(paths.configFile)) {
76
+ writeFileSync(paths.configFile, 'obsidian_vault = ""\n')
77
+ }
78
+ const db = openDatabase(paths.databaseFile)
79
+ try {
80
+ runMigrations(db)
81
+ } finally {
82
+ db.close()
83
+ }
84
+ runtime.stdout(`Initialized llm-iwiki at ${paths.configDir}`)
85
+ return 0
86
+ }
87
+
88
+ if (args[0] === 'doctor') {
89
+ const paths = getAppPaths(runtime.homeDir)
90
+ if (!existsSync(paths.configFile)) {
91
+ runtime.stderr('llm-iwiki is not initialized. Run: llm-iwiki init')
92
+ return 1
93
+ }
94
+ if (!existsSync(paths.databaseFile)) {
95
+ runtime.stderr('llm-iwiki database is missing. Run: llm-iwiki init')
96
+ return 1
97
+ }
98
+ const db = openDatabase(paths.databaseFile)
99
+ try {
100
+ runMigrations(db)
101
+ } finally {
102
+ db.close()
103
+ }
104
+ runtime.stdout(`config: ${paths.configFile}`)
105
+ runtime.stdout(`database: ${paths.databaseFile}`)
106
+ runtime.stdout('status: ok')
107
+ return 0
108
+ }
109
+
110
+ if (args[0] === 'sync') {
111
+ const projectFlag = readFlag(args, '--project')
112
+ const paths = getAppPaths(runtime.homeDir)
113
+ const db = openDatabase(paths.databaseFile)
114
+ try {
115
+ runMigrations(db)
116
+ const report = runSync(db, {
117
+ homeDir: runtime.homeDir ?? paths.homeDir,
118
+ projectFilter: projectFlag ? resolveCliPath(runtime.cwd, projectFlag) : null,
119
+ })
120
+ if (report.bySource.length === 0) {
121
+ runtime.stdout('No collectors detected on this machine.')
122
+ return 0
123
+ }
124
+ for (const source of report.bySource) {
125
+ runtime.stdout(
126
+ `${source.source}: ${source.total} sessions (new ${source.new}, changed ${source.changed}, unchanged ${source.unchanged}, missing ${source.sourceMissing})`,
127
+ )
128
+ }
129
+ return 0
130
+ } catch (error) {
131
+ runtime.stderr(error instanceof Error ? error.message : String(error))
132
+ return 1
133
+ } finally {
134
+ db.close()
135
+ }
136
+ }
137
+
138
+ if (args[0] === 'projects' && args[1] === 'list') {
139
+ const paths = getAppPaths(runtime.homeDir)
140
+ const db = openDatabase(paths.databaseFile)
141
+ try {
142
+ runMigrations(db)
143
+ const projects = listProjects(db)
144
+ if (projects.length === 0) {
145
+ runtime.stdout('No projects yet. Run: llm-iwiki sync')
146
+ return 0
147
+ }
148
+ for (const project of projects) {
149
+ const name = project.displayName ?? project.canonicalName
150
+ runtime.stdout(`${project.id} ${project.sessionCount} sessions ${name}`)
151
+ }
152
+ return 0
153
+ } finally {
154
+ db.close()
155
+ }
156
+ }
157
+
158
+ if (args[0] === 'projects' && args[1] === 'inspect') {
159
+ const target = args[2]
160
+ if (!target) {
161
+ runtime.stderr('Usage: llm-iwiki projects inspect <path-or-project-id>')
162
+ return 1
163
+ }
164
+ const paths = getAppPaths(runtime.homeDir)
165
+ const db = openDatabase(paths.databaseFile)
166
+ try {
167
+ runMigrations(db)
168
+ const project = target.startsWith('proj_')
169
+ ? getProject(db, target)
170
+ : resolveProject(db, resolveCliPath(runtime.cwd, target))
171
+ const inspection = inspectProject(db, project.id)
172
+ runtime.stdout(`project: ${project.displayName ?? project.canonicalName}`)
173
+ runtime.stdout(`id: ${project.id}`)
174
+ if (project.canonicalRepoUrl) runtime.stdout(`repo: ${project.canonicalRepoUrl}`)
175
+ runtime.stdout(`sources: ${inspection.sources.map((s) => `${s.source}(${s.sessionCount})`).join(', ') || 'none'}`)
176
+ runtime.stdout(`sessions: ${inspection.sessions.length}`)
177
+ for (const session of inspection.sessions) {
178
+ runtime.stdout(` [${session.sourceId}] ${session.title ?? session.sourceSessionId} (${session.messageCount} msgs, ${session.status})`)
179
+ }
180
+ return 0
181
+ } catch (error) {
182
+ runtime.stderr(error instanceof Error ? error.message : String(error))
183
+ return 1
184
+ } finally {
185
+ db.close()
186
+ }
187
+ }
188
+
189
+ if (args[0] === 'projects' && args[1] === 'resolve') {
190
+ const targetPath = args[2] ?? runtime.cwd
191
+ const paths = getAppPaths(runtime.homeDir)
192
+ const db = openDatabase(paths.databaseFile)
193
+ try {
194
+ runMigrations(db)
195
+ const project = resolveProject(db, resolveCliPath(runtime.cwd, targetPath))
196
+ runtime.stdout(JSON.stringify(project, null, 2))
197
+ return 0
198
+ } catch (error) {
199
+ runtime.stderr(error instanceof Error ? error.message : String(error))
200
+ return 1
201
+ } finally {
202
+ db.close()
203
+ }
204
+ }
205
+
206
+ if (args[0] === 'projects' && args[1] === 'rename') {
207
+ const target = args[2]
208
+ const displayName = args[3]
209
+ if (!target || !displayName) {
210
+ runtime.stderr('Usage: llm-iwiki projects rename <path-or-project-id> <display-name>')
211
+ return 1
212
+ }
213
+ const paths = getAppPaths(runtime.homeDir)
214
+ const db = openDatabase(paths.databaseFile)
215
+ try {
216
+ runMigrations(db)
217
+ const project = target.startsWith('proj_')
218
+ ? renameProject(db, target, displayName)
219
+ : renameProject(db, resolveProject(db, resolveCliPath(runtime.cwd, target)).id, displayName)
220
+ runtime.stdout(JSON.stringify(project, null, 2))
221
+ return 0
222
+ } catch (error) {
223
+ runtime.stderr(error instanceof Error ? error.message : String(error))
224
+ return 1
225
+ } finally {
226
+ db.close()
227
+ }
228
+ }
229
+
230
+ if (args[0] === 'summarize' && args[1] === 'prepare') {
231
+ const scopeArg = args[2] && !args[2].startsWith('--') ? args[2] : 'changed'
232
+ if (scopeArg !== 'changed' && scopeArg !== 'all') {
233
+ runtime.stderr('Usage: llm-iwiki summarize prepare [changed|all] --project <path> [--out <file>]')
234
+ return 1
235
+ }
236
+ const projectPath = resolveCliPath(runtime.cwd, readFlag(args, '--project') ?? runtime.cwd)
237
+ const outFile = readFlag(args, '--out') ?? join(getProjectTaskDir(projectPath), 'summaries-task.md')
238
+ const paths = getAppPaths(runtime.homeDir)
239
+ const db = openDatabase(paths.databaseFile)
240
+ try {
241
+ runMigrations(db)
242
+ const project = resolveProject(db, projectPath)
243
+ const result = prepareSummariesTask(db, project.id, scopeArg)
244
+ mkdirSync(dirname(resolveCliPath(runtime.cwd, outFile)), { recursive: true })
245
+ writeFileSync(resolveCliPath(runtime.cwd, outFile), result.markdown)
246
+ runtime.stdout(`prepared summaries task: ${result.sessionCount} sessions -> ${outFile}`)
247
+ return 0
248
+ } catch (error) {
249
+ runtime.stderr(error instanceof Error ? error.message : String(error))
250
+ return 1
251
+ } finally {
252
+ db.close()
253
+ }
254
+ }
255
+
256
+ if (args[0] === 'summarize' && args[1] === 'apply') {
257
+ const file = readFlag(args, '--file')
258
+ if (!file) {
259
+ runtime.stderr('Usage: llm-iwiki summarize apply --project <path> --file <summaries.yaml>')
260
+ return 1
261
+ }
262
+ const paths = getAppPaths(runtime.homeDir)
263
+ const db = openDatabase(paths.databaseFile)
264
+ try {
265
+ runMigrations(db)
266
+ const parsed = parseSummariesYaml(readFileSync(resolveCliPath(runtime.cwd, file), 'utf8'))
267
+ const result = applySummaries(db, parsed)
268
+ runtime.stdout(`applied summaries: ${result.written}`)
269
+ if (result.skipped.length > 0) {
270
+ runtime.stdout(`skipped (unknown session): ${result.skipped.length}`)
271
+ }
272
+ return 0
273
+ } catch (error) {
274
+ runtime.stderr(error instanceof Error ? error.message : String(error))
275
+ return 1
276
+ } finally {
277
+ db.close()
278
+ }
279
+ }
280
+
281
+ if (args[0] === 'experiences' && args[1] === 'prepare') {
282
+ const fromArg = (readFlag(args, '--from') ?? 'changed-summaries') as ExperienceScope
283
+ if (fromArg !== 'changed-summaries' && fromArg !== 'all-recent') {
284
+ runtime.stderr('Invalid --from. Use changed-summaries or all-recent.')
285
+ return 1
286
+ }
287
+ const projectPath = resolveCliPath(runtime.cwd, readFlag(args, '--project') ?? runtime.cwd)
288
+ const outFile = readFlag(args, '--out') ?? join(getProjectTaskDir(projectPath), 'experiences-task.md')
289
+ const paths = getAppPaths(runtime.homeDir)
290
+ const db = openDatabase(paths.databaseFile)
291
+ try {
292
+ runMigrations(db)
293
+ const project = resolveProject(db, projectPath)
294
+ const result = prepareExperiencesTask(db, project.id, fromArg)
295
+ mkdirSync(dirname(resolveCliPath(runtime.cwd, outFile)), { recursive: true })
296
+ writeFileSync(resolveCliPath(runtime.cwd, outFile), result.markdown)
297
+ runtime.stdout(`prepared experiences task: ${result.summaryCount} summaries -> ${outFile}`)
298
+ return 0
299
+ } catch (error) {
300
+ runtime.stderr(error instanceof Error ? error.message : String(error))
301
+ return 1
302
+ } finally {
303
+ db.close()
304
+ }
305
+ }
306
+
307
+ if (args[0] === 'experiences' && args[1] === 'propose') {
308
+ const file = readFlag(args, '--file')
309
+ if (!file) {
310
+ runtime.stderr('Usage: llm-iwiki experiences propose --project <path> --file <experiences.yaml>')
311
+ return 1
312
+ }
313
+ const paths = getAppPaths(runtime.homeDir)
314
+ const db = openDatabase(paths.databaseFile)
315
+ try {
316
+ runMigrations(db)
317
+ const parsed = parseExperiencesYaml(readFileSync(resolveCliPath(runtime.cwd, file), 'utf8'))
318
+ const result = proposeExperiences(db, parsed)
319
+ runtime.stdout(`proposed experiences: ${result.written}`)
320
+ return 0
321
+ } catch (error) {
322
+ runtime.stderr(error instanceof Error ? error.message : String(error))
323
+ return 1
324
+ } finally {
325
+ db.close()
326
+ }
327
+ }
328
+
329
+ if (args[0] === 'experiences' && args[1] === 'candidates') {
330
+ const projectFlag = readFlag(args, '--project')
331
+ const paths = getAppPaths(runtime.homeDir)
332
+ const db = openDatabase(paths.databaseFile)
333
+ try {
334
+ runMigrations(db)
335
+ const projectId = projectFlag ? resolveProject(db, resolveCliPath(runtime.cwd, projectFlag)).id : null
336
+ const candidates = listCandidates(db, projectId)
337
+ if (candidates.length === 0) {
338
+ runtime.stdout('No experience candidates. Run: llm-iwiki experiences propose')
339
+ return 0
340
+ }
341
+ for (const candidate of candidates) {
342
+ runtime.stdout(
343
+ `${candidate.id} [${candidate.status}] ${candidate.confidence ?? '-'} ${candidate.proposed_title}`,
344
+ )
345
+ }
346
+ return 0
347
+ } catch (error) {
348
+ runtime.stderr(error instanceof Error ? error.message : String(error))
349
+ return 1
350
+ } finally {
351
+ db.close()
352
+ }
353
+ }
354
+
355
+ if (args[0] === 'experiences' && (args[1] === 'accept' || args[1] === 'reject')) {
356
+ const candidateId = args[2]
357
+ if (!candidateId) {
358
+ runtime.stderr(`Usage: llm-iwiki experiences ${args[1]} <candidate-id>`)
359
+ return 1
360
+ }
361
+ const paths = getAppPaths(runtime.homeDir)
362
+ const db = openDatabase(paths.databaseFile)
363
+ try {
364
+ runMigrations(db)
365
+ if (args[1] === 'accept') {
366
+ const result = acceptExperience(db, candidateId)
367
+ runtime.stdout(`accepted: ${result.experienceId} (${result.slug}), linked sessions: ${result.linkedSessions}`)
368
+ } else {
369
+ rejectExperience(db, candidateId)
370
+ runtime.stdout(`rejected: ${candidateId}`)
371
+ }
372
+ return 0
373
+ } catch (error) {
374
+ runtime.stderr(error instanceof Error ? error.message : String(error))
375
+ return 1
376
+ } finally {
377
+ db.close()
378
+ }
379
+ }
380
+
381
+ if (args[0] === 'config' && args[1] === 'show') {
382
+ const paths = getAppPaths(runtime.homeDir)
383
+ const config = readConfig(paths.configFile)
384
+ runtime.stdout(`config: ${paths.configFile}`)
385
+ runtime.stdout(`obsidian.vault: ${config.obsidianVault ?? '(unset)'}`)
386
+ return 0
387
+ }
388
+
389
+ if (args[0] === 'config' && args[1] === 'set') {
390
+ const key = args[2]
391
+ const value = args[3]
392
+ if (!key || value === undefined) {
393
+ runtime.stderr('Usage: llm-iwiki config set <key> <value>')
394
+ return 1
395
+ }
396
+ const paths = getAppPaths(runtime.homeDir)
397
+ mkdirSync(paths.configDir, { recursive: true })
398
+ try {
399
+ const normalizedKey = setConfigValue(paths.configFile, key, value)
400
+ runtime.stdout(`set ${normalizedKey} = ${value}`)
401
+ return 0
402
+ } catch (error) {
403
+ runtime.stderr(error instanceof Error ? error.message : String(error))
404
+ return 1
405
+ }
406
+ }
407
+
408
+ if (args[0] === 'obsidian' && args[1] === 'export') {
409
+ const paths = getAppPaths(runtime.homeDir)
410
+ const vaultFlag = readFlag(args, '--vault')
411
+ const vault = vaultFlag
412
+ ? resolveCliPath(runtime.cwd, vaultFlag)
413
+ : readConfig(paths.configFile).obsidianVault
414
+ if (!vault) {
415
+ runtime.stderr('No Obsidian vault configured. Run: llm-iwiki config set obsidian.vault <dir>')
416
+ return 1
417
+ }
418
+ const projectPath = resolveCliPath(runtime.cwd, readFlag(args, '--project') ?? runtime.cwd)
419
+ const force = args.includes('--force')
420
+ const db = openDatabase(paths.databaseFile)
421
+ try {
422
+ runMigrations(db)
423
+ const project = resolveProject(db, projectPath)
424
+ const report = exportProject(db, vault, project, { force })
425
+ runtime.stdout(`vault: ${vault}`)
426
+ runtime.stdout(
427
+ `exported: created ${report.created}, updated ${report.updated}, forced ${report.forced}, conflicts ${report.conflicts.length}`,
428
+ )
429
+ for (const conflict of report.conflicts) {
430
+ runtime.stdout(` conflict (skipped): ${conflict}`)
431
+ }
432
+ if (report.conflicts.length > 0 && !force) {
433
+ runtime.stdout('Re-run with --force to overwrite managed blocks of conflicting notes.')
434
+ }
435
+ return 0
436
+ } catch (error) {
437
+ runtime.stderr(error instanceof Error ? error.message : String(error))
438
+ return 1
439
+ } finally {
440
+ db.close()
441
+ }
442
+ }
443
+
444
+ if (args[0] === 'obsidian' && args[1] === 'check') {
445
+ const paths = getAppPaths(runtime.homeDir)
446
+ const db = openDatabase(paths.databaseFile)
447
+ try {
448
+ runMigrations(db)
449
+ const report = checkVault(db)
450
+ runtime.stdout(`notes: ${report.total}, clean: ${report.clean}, needs attention: ${report.entries.length}`)
451
+ for (const entry of report.entries) {
452
+ runtime.stdout(` ${entry.status}: ${entry.filePath}`)
453
+ }
454
+ return 0
455
+ } finally {
456
+ db.close()
457
+ }
458
+ }
459
+
460
+ if (args[0] === 'skills' && args[1] === 'init') {
461
+ const targetFlag = readFlag(args, '--target')
462
+ if (args.includes('--target') && !targetFlag) {
463
+ runtime.stderr('Invalid --target. Use codex, claude-code, or cursor.')
464
+ return 1
465
+ }
466
+ if (targetFlag && !(SKILL_TARGETS as readonly string[]).includes(targetFlag)) {
467
+ runtime.stderr('Invalid --target. Use codex, claude-code, or cursor.')
468
+ return 1
469
+ }
470
+ const target = targetFlag as SkillTarget | null
471
+ const result = initSkills({
472
+ cwd: runtime.cwd,
473
+ target,
474
+ force: args.includes('--force'),
475
+ dryRun: args.includes('--dry-run'),
476
+ })
477
+ runtime.stdout(`skills written: ${result.written.length}`)
478
+ runtime.stdout(`skills skipped: ${result.skipped.length}`)
479
+ return 0
480
+ }
481
+
482
+ runtime.stderr(`Unknown command: ${args.join(' ')}`)
483
+ runtime.stderr(HELP)
484
+ return 1
485
+ }
@@ -0,0 +1,120 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+
4
+ import type { Collector, RawMessage, RawSession } from './types'
5
+ import { deriveTitle, isEphemeralPath, normalizeContentParts } from './util'
6
+
7
+ const PROJECT_ROOTS = ['.claude/projects', '.claude-internal/projects']
8
+
9
+ function parseSessionFile(filePath: string): RawSession | null {
10
+ const raw = readFileSync(filePath, 'utf8')
11
+ const lines = raw.split('\n')
12
+
13
+ const messages: RawMessage[] = []
14
+ let rawProjectPath: string | null = null
15
+ let sourceSessionId: string | null = null
16
+
17
+ for (const line of lines) {
18
+ const trimmed = line.trim()
19
+ if (trimmed === '') continue
20
+
21
+ let parsed: Record<string, unknown>
22
+ try {
23
+ parsed = JSON.parse(trimmed) as Record<string, unknown>
24
+ } catch {
25
+ continue
26
+ }
27
+
28
+ if (typeof parsed.sessionId === 'string' && !sourceSessionId) {
29
+ sourceSessionId = parsed.sessionId
30
+ }
31
+ if (typeof parsed.cwd === 'string' && !rawProjectPath) {
32
+ rawProjectPath = parsed.cwd
33
+ }
34
+
35
+ const type = parsed.type
36
+ if (type !== 'user' && type !== 'assistant') continue
37
+ if (parsed.isSidechain === true) continue
38
+
39
+ const message = parsed.message
40
+ if (!message || typeof message !== 'object') continue
41
+ const messageRecord = message as Record<string, unknown>
42
+ const role = typeof messageRecord.role === 'string' ? messageRecord.role : type
43
+ const content = normalizeContentParts(messageRecord.content)
44
+ if (content.trim() === '') continue
45
+
46
+ messages.push({
47
+ role,
48
+ content,
49
+ timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : null,
50
+ })
51
+ }
52
+
53
+ if (messages.length === 0) return null
54
+ if (isEphemeralPath(rawProjectPath)) return null
55
+
56
+ sourceSessionId ??= basename(filePath).replace(/\.jsonl$/, '')
57
+ const timestamps = messages.map((message) => message.timestamp).filter((value): value is string => value != null)
58
+
59
+ return {
60
+ sourceSessionId,
61
+ rawPath: filePath,
62
+ rawProjectPath,
63
+ title: deriveTitle(messages),
64
+ createdAt: timestamps[0] ?? null,
65
+ updatedAt: timestamps[timestamps.length - 1] ?? null,
66
+ messages,
67
+ }
68
+ }
69
+
70
+ function listSessionFiles(homeDir: string): string[] {
71
+ const files: string[] = []
72
+ const seenRoots = new Set<string>()
73
+ for (const root of PROJECT_ROOTS) {
74
+ const projectsDir = join(homeDir, root)
75
+ if (!existsSync(projectsDir)) continue
76
+
77
+ let canonicalRoot: string
78
+ try {
79
+ canonicalRoot = realpathSync(projectsDir)
80
+ } catch {
81
+ canonicalRoot = projectsDir
82
+ }
83
+ if (seenRoots.has(canonicalRoot)) continue
84
+ seenRoots.add(canonicalRoot)
85
+
86
+ for (const projectEntry of readdirSync(projectsDir, { withFileTypes: true })) {
87
+ if (!projectEntry.isDirectory()) continue
88
+ const projectDir = join(projectsDir, projectEntry.name)
89
+ for (const fileEntry of readdirSync(projectDir, { withFileTypes: true })) {
90
+ if (fileEntry.isFile() && fileEntry.name.endsWith('.jsonl')) {
91
+ files.push(join(projectDir, fileEntry.name))
92
+ }
93
+ }
94
+ }
95
+ }
96
+ return files
97
+ }
98
+
99
+ export const claudeCodeCollector: Collector = {
100
+ id: 'claude-code',
101
+ name: 'Claude Code',
102
+
103
+ detect(homeDir: string): boolean {
104
+ return PROJECT_ROOTS.some((root) => existsSync(join(homeDir, root)))
105
+ },
106
+
107
+ collect(homeDir: string): RawSession[] {
108
+ const sessions: RawSession[] = []
109
+ for (const filePath of listSessionFiles(homeDir)) {
110
+ try {
111
+ if (!statSync(filePath).isFile()) continue
112
+ const session = parseSessionFile(filePath)
113
+ if (session) sessions.push(session)
114
+ } catch {
115
+ continue
116
+ }
117
+ }
118
+ return sessions
119
+ },
120
+ }