Skip to content
Draft
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
2 changes: 1 addition & 1 deletion cli/src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export async function runSync(options: SyncOptions = {}): Promise<SyncResult> {
try {
providers = [getProvider(options.source)];
} catch {
throw new Error(`Unknown source: ${options.source}. Available: ${getAllProviders().map(p => p.getProviderName()).join(', ')}`);
throw new Error(`Unknown source: ${options.source}. Available: ${getAllProviders(true).map(p => p.getProviderName()).join(', ')}`);
}
} else {
providers = getAllProviders();
Expand Down
10 changes: 5 additions & 5 deletions cli/src/parser/jsonl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export function classifyUserMessage(msg: ClaudeMessage): UserMessageClass {
/**
* Parse a single JSONL file and extract session data
*/
export async function parseJsonlFile(filePath: string): Promise<ParsedSession | null> {
export async function parseJsonlFile(filePath: string, sessionIdOverride?: string): Promise<ParsedSession | null> {
const entries: JsonlEntry[] = [];

const fileStream = fs.createReadStream(filePath);
Expand All @@ -147,15 +147,15 @@ export async function parseJsonlFile(filePath: string): Promise<ParsedSession |
return null;
}

return buildSession(filePath, entries);
return buildSession(filePath, entries, sessionIdOverride);
}

/**
* Build a ParsedSession from JSONL entries
*/
function buildSession(filePath: string, entries: JsonlEntry[]): ParsedSession | null {
// Extract session ID from filename
const sessionId = extractSessionId(filePath);
function buildSession(filePath: string, entries: JsonlEntry[], sessionIdOverride?: string): ParsedSession | null {
// Special providers may use audit.jsonl: provide a stable namespaced ID.
const sessionId = sessionIdOverride ?? extractSessionId(filePath);
if (!sessionId) return null;

// Find summary entries
Expand Down
53 changes: 53 additions & 0 deletions cli/src/providers/__tests__/claude-desktop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ClaudeDesktopProvider, getClaudeDesktopDir } from '../claude-desktop.js';

const provider = new ClaudeDesktopProvider();
let home: string;

beforeEach(() => { home = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-claude-desktop-')); });
afterEach(() => { fs.rmSync(home, { recursive: true, force: true }); });

function writeSession() {
const projectDir = path.join(getClaudeDesktopDir(home), 'account-1', 'project-1');
const sessionDir = path.join(projectDir, 'session-1');
fs.mkdirSync(sessionDir, { recursive: true });
const audit = path.join(sessionDir, 'audit.jsonl');
const rows = [
{ type: 'user', uuid: 'u1', sessionId: 'original', timestamp: '2026-09-01T10:00:00Z', message: { role: 'user', content: 'Investigate API latency' } },
{ type: 'assistant', uuid: 'a1', sessionId: 'original', timestamp: '2026-09-01T10:00:05Z', parentUuid: 'u1', message: { role: 'assistant', content: 'I will inspect the traces', model: 'claude-sonnet-4-5', usage: { input_tokens: 100, output_tokens: 30 } } },
{ type: 'user', uuid: 'u2', sessionId: 'original', timestamp: '2026-09-01T10:00:15Z', parentUuid: 'a1', message: { role: 'user', content: 'Check the slow endpoint too' } },
];
fs.writeFileSync(audit, rows.map(x => JSON.stringify(x)).join('\n'));
fs.writeFileSync(path.join(projectDir, 'session-1.json'), JSON.stringify({ title: 'Investigate latency', userSelectedFolders: ['/tmp/my-app'] }));
return audit;
}

describe('ClaudeDesktopProvider', () => {
it('is manual opt-in rather than included in a regular sync', async () => {
expect(provider.getProviderName()).toBe('claude-desktop');
expect(await provider.discover({ homeRoot: home })).toEqual([]);
});
it('discovers local audit.jsonl from a selected home and parses non-UUID filename', async () => {
const audit = writeSession();
expect(await provider.discover({ homeRoot: home })).toEqual([audit]);
expect(await provider.discover({ homeRoot: home, projectFilter: 'other' })).toEqual([]);
const parsed = await provider.parse(audit);
expect(parsed).not.toBeNull();
expect(parsed!.sourceTool).toBe('claude-desktop');
expect(parsed!.id).toMatch(/^claude-desktop-/);
expect(parsed!.messages).toHaveLength(3);
expect(parsed!.messages.every(m => m.sessionId === parsed!.id && m.id.startsWith(parsed!.id + ':'))).toBe(true);
expect(parsed!.messages[1].parentId).toBe(`${parsed!.id}:u1`);
expect(parsed!.projectPath).toBe('/tmp/my-app');
expect(parsed!.customTitle).toBe('Investigate latency');
expect((await provider.parse(audit))!.id).toBe(parsed!.id);
});
it('does not import an empty audit session', async () => {
const audit = writeSession();
fs.writeFileSync(audit, '');
expect(await provider.parse(audit)).toBeNull();
});
});
60 changes: 60 additions & 0 deletions cli/src/providers/__tests__/pi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { PiProvider } from '../pi.js';
import { getProvider, getAllProviders } from '../registry.js';

const provider = new PiProvider();
let home: string;

beforeEach(() => { home = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-pi-')); });
afterEach(() => { fs.rmSync(home, { recursive: true, force: true }); });

function writeSession() {
const dir = path.join(home, '.pi', 'agent', 'sessions', '--tmp-project--');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'sample.jsonl');
const rows = [
{ type: 'session', cwd: '/tmp/project' },
{ type: 'message', id: '1', timestamp: '2026-09-01T10:00:00Z', message: { role: 'user', content: [{ type: 'text', text: 'Review this PR' }] } },
{ type: 'message', id: '2', parentId: '1', timestamp: '2026-09-01T10:00:05Z', message: { role: 'assistant', content: [{ type: 'text', text: 'I found a bug' }], model: 'gpt-4o-mini', usage: { input: 120, output: 40, cacheRead: 10 } } },
{ type: 'compaction' },
{ type: 'message', id: '3', parentId: '2', timestamp: '2026-09-01T10:00:15Z', message: { role: 'user', content: 'Please fix it' } },
];
fs.writeFileSync(file, rows.map(x => JSON.stringify(x)).join('\n'));
return file;
}

describe('PiProvider', () => {
it('is registered for explicit --source but excluded from default all-provider scans', () => {
expect(getProvider('pi').getProviderName()).toBe('pi');
expect(getProvider('claude-desktop').getProviderName()).toBe('claude-desktop');
const enabled = getAllProviders().map(p => p.getProviderName());
expect(enabled).not.toContain('pi');
expect(enabled).not.toContain('claude-desktop');
expect(enabled).toContain('kilo');
expect(getAllProviders(true).map(p => p.getProviderName())).toContain('pi');
});
it('discovers and parses Pi session JSONL under a selected home', async () => {
const file = writeSession();
expect(await provider.discover({ homeRoot: home })).toEqual([file]);
expect(await provider.discover({ homeRoot: home, projectFilter: 'unmatched' })).toEqual([]);
const parsed = await provider.parse(file);
expect(parsed).not.toBeNull();
expect(parsed!.sourceTool).toBe('pi');
expect(parsed!.id).toMatch(/^pi-/);
expect(parsed!.messages).toHaveLength(3);
expect(parsed!.messages.every(m => m.id.startsWith(parsed!.id + ':'))).toBe(true);
expect(parsed!.messages[1].parentId).toBe(`${parsed!.id}:1`);
expect(parsed!.usage?.totalInputTokens).toBe(120);
expect(parsed!.usage?.cacheReadTokens).toBe(10);
expect(parsed!.compactCount).toBe(1);
expect((await provider.parse(file))!.id).toBe(parsed!.id);
});
it('skips malformed and empty sessions', async () => {
const file = writeSession();
fs.writeFileSync(file, '{not-json}\n');
expect(await provider.parse(file)).toBeNull();
});
});
110 changes: 110 additions & 0 deletions cli/src/providers/claude-desktop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createHash } from 'crypto';
import type { SessionProvider } from './types.js';
import type { ParsedSession } from '../types.js';
import { parseJsonlFile } from '../parser/jsonl.js';
/** Claude Desktop stores local-agent audit files under an OS-specific per-home directory. */
export function getClaudeDesktopDir(homeRoot?: string): string {
const home = homeRoot ?? os.homedir();
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions');
if (process.platform === 'win32') return path.join(homeRoot ?? process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'Claude', 'local-agent-mode-sessions');
return path.join(home, '.config', 'Claude', 'local-agent-mode-sessions');
}

/**
* Claude Desktop Provider.
* Discovers and parses local agent mode sessions from Claude Desktop app.
*/
export class ClaudeDesktopProvider implements SessionProvider {
getProviderName(): string {
return 'claude-desktop';
}

async discover(options?: { projectFilter?: string; homeRoot?: string }): Promise<string[]> {
const baseDir = getClaudeDesktopDir(options?.homeRoot);

if (!fs.existsSync(baseDir)) {
return [];
}

const files: string[] = [];

// Directory structure: <account_uuid>/<project_uuid>/<session_id>/audit.jsonl
const accounts = fs.readdirSync(baseDir);
for (const account of accounts) {
if (account.startsWith('.')) continue;
const accountPath = path.join(baseDir, account);
if (!fs.statSync(accountPath).isDirectory()) continue;

const projects = fs.readdirSync(accountPath);
for (const project of projects) {
if (project.startsWith('.')) continue;
const projectPath = path.join(accountPath, project);
if (!fs.statSync(projectPath).isDirectory()) continue;

// Optional project filter (matches against project UUID for now)
if (options?.projectFilter && !project.includes(options.projectFilter)) {
continue;
}

const sessions = fs.readdirSync(projectPath);
for (const session of sessions) {
if (session.startsWith('.')) continue;
const sessionPath = path.join(projectPath, session);
if (!fs.statSync(sessionPath).isDirectory()) continue;

const auditPath = path.join(sessionPath, 'audit.jsonl');
if (fs.existsSync(auditPath)) {
files.push(auditPath);
}
}
}
}

return files;
}

async parse(filePath: string): Promise<ParsedSession | null> {
// Filename audit.jsonl does not match the shared Claude UUID-based parser.
const sessionId = 'claude-desktop-' + createHash('sha256').update(path.resolve(filePath)).digest('hex').slice(0, 24);
const session = await parseJsonlFile(filePath, sessionId);
if (!session) return null;

session.sourceTool = 'claude-desktop';
session.messages = session.messages.map(message => ({
...message,
id: `${sessionId}:${message.id}`,
sessionId,
parentId: message.parentId ? `${sessionId}:${message.parentId}` : null,
}));

// Enrich with metadata from <session_id>.json
try {
const parentDir = path.dirname(filePath);
const sessionId = path.basename(parentDir);
const projectDir = path.dirname(parentDir);
const metaPath = path.join(projectDir, `${sessionId}.json`);

if (fs.existsSync(metaPath)) {
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
if (meta.title) session.customTitle = meta.title;

// Enhance project path extraction: audit.jsonl messages lack cwd
if (meta.userSelectedFolders && meta.userSelectedFolders.length > 0) {
session.projectPath = meta.userSelectedFolders[0];
session.projectName = path.basename(meta.userSelectedFolders[0]) || 'unknown';
} else if (meta.cwd) {
session.projectPath = meta.cwd;
session.projectName = path.basename(meta.cwd) || 'unknown';
}
}
} catch (err) {
// Ignore errors reading metadata
console.warn(`[claude-desktop] Error reading metadata for ${filePath}: ${err}`);
}

return session;
}
}
Loading
Loading