diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e4d1d9..dc6bb07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,12 @@ ## 0.9.8 - 2026-05-10 ### Added (CLI) +- **Cline provider support.** CodeBurn now reads Cline task usage from both + VS Code globalStorage (`saoudrizwan.claude-dev`) and Cline's + `~/.cline/data` task root. It reuses the existing Cline-family parser for + `ui_messages.json` usage entries, deduplicates migrated tasks by the newest + `ui_messages.json`, and exposes Cline in CLI provider filters, docs, and the + macOS menubar provider tabs. Closes #130. - **Multiple Claude config directories.** Set `CLAUDE_CONFIG_DIRS` to an OS-delimited list of paths (`:`-separated on POSIX, `;`-separated on Windows) to scan more than one Claude data directory in a single run. diff --git a/README.md b/README.md index 9db2a1f..b378248 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr |---|----------|-----------|-----| | | Claude Code | Yes | [claude.md](docs/providers/claude.md) | | | Claude Desktop | Yes | [claude.md](docs/providers/claude.md) | +| | Cline | Yes | [cline.md](docs/providers/cline.md) | | | Codex (OpenAI) | Yes | [codex.md](docs/providers/codex.md) | | | Cursor | Yes | [cursor.md](docs/providers/cursor.md) | | | cursor-agent | Yes | [cursor-agent.md](docs/providers/cursor-agent.md) | @@ -379,9 +380,9 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta **OpenClaw** stores agent sessions as JSONL at `~/.openclaw/agents/*.jsonl`. Also checks legacy paths `.clawdbot`, `.moltbot`, `.moldbot`. Token usage comes from assistant message `usage` blocks; model from `modelId` or `message.model` fields. -**IBM Bob** stores IDE task history in `User/globalStorage/ibm.bob-code/tasks//` under the IBM Bob application data directory. CodeBurn reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model, with support for both GA (`IBM Bob`) and preview (`Bob-IDE`) app data folders. +**Cline / Roo Code / KiloCode** are Cline-family coding agents. CodeBurn reads `ui_messages.json` from each task directory, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. Cline scans both VS Code's `globalStorage/saoudrizwan.claude-dev` and `~/.cline/data`. -**Roo Code / KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory in VS Code's `globalStorage`, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. +**IBM Bob** stores IDE task history in `User/globalStorage/ibm.bob-code/tasks//` under the IBM Bob application data directory. CodeBurn reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model, with support for both GA (`IBM Bob`) and preview (`Bob-IDE`) app data folders. CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP), filters by date range per entry, and classifies each turn. diff --git a/assets/providers/cline.svg b/assets/providers/cline.svg new file mode 100644 index 0000000..d00094b --- /dev/null +++ b/assets/providers/cline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/architecture.md b/docs/architecture.md index c3a8c25..de3f213 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,14 +128,14 @@ type Provider = { } ``` -`src/providers/index.ts` registers nineteen providers across two tiers: +`src/providers/index.ts` registers twenty providers across two tiers: -- **Eager**: `claude`, `codex`, `copilot`, `droid`, `gemini`, `ibm-bob`, `kilo-code`, `kiro`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load. +- **Eager**: `claude`, `cline`, `codex`, `copilot`, `droid`, `gemini`, `ibm-bob`, `kilo-code`, `kiro`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load. - **Lazy**: `antigravity`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf) do not touch users who do not have those tools installed. Both lists hit the same `getAllProviders()` aggregator. A failed lazy import is silent and excludes that provider from the run. -`src/providers/vscode-cline-parser.ts` is a shared helper consumed by `ibm-bob`, `kilo-code`, and `roo-code`. It is not registered as a provider on its own. +`src/providers/vscode-cline-parser.ts` is a shared helper consumed by `cline`, `ibm-bob`, `kilo-code`, and `roo-code`. It is not registered as a provider on its own. For the per-provider data location, storage format, parser quirks, and test coverage, see `docs/providers/`. @@ -181,7 +181,7 @@ The `prepublishOnly` hook in `package.json` runs `npm run build` so `npm publish - `tests/` root (27 files) covers CLI, parser, optimize, cache, format, models, plans. - `tests/security/` (1 file) covers prototype-pollution guards. -- `tests/providers/` (14 files) covers per-provider parsing. +- `tests/providers/` (15 files) covers per-provider parsing. - `tests/fixtures/` holds redacted real-world session data. Five providers ship without dedicated test files today: `antigravity`, `claude`, `gemini`, `goose`, `qwen`. Closing this gap is a standing good-first-issue. diff --git a/docs/providers/README.md b/docs/providers/README.md index 600bd60..02dcc2d 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -11,6 +11,7 @@ For the architectural picture, see `../architecture.md`. | Provider | Storage | Source | Test | |---|---|---|---| | [Claude](claude.md) | JSONL (no parser) | `src/providers/claude.ts` | none (covered indirectly) | +| [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` | | [Codex](codex.md) | JSONL | `src/providers/codex.ts` | `tests/providers/codex.test.ts` | | [Copilot](copilot.md) | JSONL | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` | | [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` | @@ -39,7 +40,7 @@ For the architectural picture, see `../architecture.md`. | Helper | Used by | Source | |---|---|---| -| [vscode-cline-parser](vscode-cline-parser.md) | `ibm-bob`, `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` | +| [vscode-cline-parser](vscode-cline-parser.md) | `cline`, `ibm-bob`, `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` | ## File Format diff --git a/docs/providers/cline.md b/docs/providers/cline.md new file mode 100644 index 0000000..65f27ea --- /dev/null +++ b/docs/providers/cline.md @@ -0,0 +1,50 @@ +# Cline + +Cline VS Code extension and Cline home-data task storage. + +- **Source:** `src/providers/cline.ts` +- **Loading:** eager (`src/providers/index.ts:2`) +- **Test:** `tests/providers/cline.test.ts` + +## Where it reads from + +Two task roots are scanned: + +1. VS Code extension globalStorage for `saoudrizwan.claude-dev`. +2. Cline's home-data root at `~/.cline/data`. + +Both roots are expected to contain a `tasks/` child directory. Discovery is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`, so a task is only included when it has a `ui_messages.json` file. + +## Storage format + +Per-task directories with: + +``` +tasks// + ui_messages.json + api_conversation_history.json + task_metadata.json +``` + +`ui_messages.json` provides the `api_req_started` usage entries. `api_conversation_history.json` is used for model extraction. See [`vscode-cline-parser`](vscode-cline-parser.md) for the full schema description. +`task_metadata.json` is part of Cline's task layout but is not read by CodeBurn today. + +## Caching + +None at the provider level; delegates to the shared helper and normal parser/cache layers. + +## Deduplication + +Discovery deduplicates by task id across the two Cline roots so a migrated task is not scanned twice. If the same task id exists in multiple roots, the one with the newest `ui_messages.json` wins. Parsing still uses the shared per-call key: `::`. + +## Quirks + +- This provider is intentionally a thin wrapper over the shared Cline-family parser. +- Cline can keep data in both VS Code globalStorage and `~/.cline/data`, depending on version and workflow. +- If Cline changes the JSON shape, fix `vscode-cline-parser.ts` only if Roo Code and KiloCode still pass. Branch provider-specific parsing rather than duplicating the whole parser. + +## When fixing a bug here + +1. Reproduce with a minimal task directory containing `ui_messages.json` and `api_conversation_history.json`. +2. Run `tests/providers/cline.test.ts`, plus `tests/providers/roo-code.test.ts` and `tests/providers/kilo-code.test.ts` if the shared parser changes. +3. Keep the provider name `cline`; downstream filters and dedup keys depend on it. diff --git a/docs/providers/kilo-code.md b/docs/providers/kilo-code.md index 188465f..51527ef 100644 --- a/docs/providers/kilo-code.md +++ b/docs/providers/kilo-code.md @@ -25,10 +25,10 @@ Delegated. Per `::` (handled in `vscode-cline-parse ## Quirks - This file is a thin wrapper. Almost every bug for KiloCode actually lives in `vscode-cline-parser.ts`. -- The two providers using the cline parser (KiloCode and Roo Code) differ **only** by extension ID. +- The VS Code extension wrappers using the Cline-family parser differ **only** by extension ID. ## When fixing a bug here -1. If the bug is "KiloCode and Roo Code both broken in the same way", fix it in `vscode-cline-parser.ts`. +1. If the bug is "Cline, KiloCode, and Roo Code all broken in the same way", fix it in `vscode-cline-parser.ts`. 2. If the bug is "KiloCode broken, Roo Code fine", the difference is upstream (KiloCode's emitted JSON differs slightly). Reproduce with a fixture and consider whether the cline parser needs to branch on extension ID. 3. Read [`vscode-cline-parser.md`](vscode-cline-parser.md) before editing. diff --git a/docs/providers/roo-code.md b/docs/providers/roo-code.md index 6f9d16a..e829064 100644 --- a/docs/providers/roo-code.md +++ b/docs/providers/roo-code.md @@ -25,10 +25,10 @@ Delegated. Per `::` (in `vscode-cline-parser.ts:109 ## Quirks - Thin wrapper. Almost every Roo Code bug actually lives in `vscode-cline-parser.ts`. -- The two providers using the cline parser (KiloCode and Roo Code) differ **only** by extension ID. +- The VS Code extension wrappers using the Cline-family parser differ **only** by extension ID. ## When fixing a bug here -1. If the bug also reproduces against KiloCode, fix it in `vscode-cline-parser.ts`. +1. If the bug also reproduces against Cline or KiloCode, fix it in `vscode-cline-parser.ts`. 2. If the bug is Roo Code-specific, the difference is upstream JSON shape. Reproduce with a fixture and consider whether the cline parser needs to branch on extension ID. 3. Read [`vscode-cline-parser.md`](vscode-cline-parser.md) before editing. diff --git a/docs/providers/vscode-cline-parser.md b/docs/providers/vscode-cline-parser.md index ea68eae..3535e63 100644 --- a/docs/providers/vscode-cline-parser.md +++ b/docs/providers/vscode-cline-parser.md @@ -1,25 +1,25 @@ # vscode-cline-parser (Shared Helper) -Shared discovery and parsing for Cline-family task folders. +Shared discovery and parsing for Cline and VS Code extensions descended from Cline. - **Source:** `src/providers/vscode-cline-parser.ts` -- **Loading:** not a provider; imported by `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`. -- **Test:** none directly. Coverage comes from `tests/providers/ibm-bob.test.ts`, `tests/providers/kilo-code.test.ts`, and `tests/providers/roo-code.test.ts`. +- **Loading:** not a provider; imported by `cline.ts`, `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`. +- **Test:** none directly. Coverage comes from `tests/providers/cline.test.ts`, `tests/providers/ibm-bob.test.ts`, `tests/providers/kilo-code.test.ts`, and `tests/providers/roo-code.test.ts`. ## What it does Two responsibilities: -1. `discoverClineTasks(extensionId)` walks VS Code's `globalStorage//tasks/` directories and returns one source per task that has a `ui_messages.json` file. +1. `discoverClineTasks(extensionId)` walks a base directory's `tasks/` child and returns one source per task that has a `ui_messages.json` file (`vscode-cline-parser.ts:25-50`). Without an override directory it uses VS Code's `globalStorage//` path. 2. `discoverClineTasksInBaseDirs(baseDirs)` does the same for non-VS Code apps with compatible task storage, such as IBM Bob. -3. `createClineParser` reads each task's `ui_messages.json` and `api_conversation_history.json`, extracts model and token counts, and yields `ParsedProviderCall` objects. +3. `createClineParser` reads each task's `ui_messages.json` and `api_conversation_history.json`, extracts model, tools, and token counts, and yields `ParsedProviderCall` objects. ## Storage layout Per task directory: ``` -//tasks// +/tasks// ui_messages.json # event stream api_conversation_history.json # full prompt history with model tags ``` @@ -45,6 +45,6 @@ Per `::` where `index` is the position of the `api_ ## When fixing a bug here -1. A change here ripples to IBM Bob, KiloCode, and Roo Code. Run all three provider test files before opening a PR. -2. If you find that one of the two extensions emits a different shape, branch on the extension ID parameter that the discovery function already takes; do not duplicate the parser. -3. If you add support for another Cline-family task store, register it as a thin wrapper file in the same shape as `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`. +1. A change here ripples to Cline, IBM Bob, KiloCode, and Roo Code. Run all four provider test files before opening a PR. +2. If you find that one of the extensions emits a different shape, branch on the extension ID parameter that the discovery function already takes; do not duplicate the parser. +3. If you add support for another Cline-family task store, register it as a thin wrapper file in the same shape as `cline.ts`, `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`. diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 2c287e4..94a576f 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -842,6 +842,7 @@ enum SupportedCurrency: String, CaseIterable, Identifiable { enum ProviderFilter: String, CaseIterable, Identifiable { case all = "All" case claude = "Claude" + case cline = "Cline" case codex = "Codex" case cursor = "Cursor" case cursorAgent = "Cursor Agent" @@ -867,6 +868,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { switch self { case .cursor: ["cursor"] case .cursorAgent: ["cursor-agent", "cursor agent"] + case .cline: ["cline"] case .rooCode: ["roo-code", "roo code"] case .kiloCode: ["kilo-code", "kilocode"] case .ibmBob: ["ibm-bob", "ibm bob"] @@ -881,6 +883,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { switch self { case .all: "all" case .claude: "claude" + case .cline: "cline" case .codex: "codex" case .cursor: "cursor" case .cursorAgent: "cursor-agent" diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index 0d40455..fdeb716 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -479,6 +479,7 @@ extension ProviderFilter { switch self { case .all: return Theme.brandAccent case .claude: return Theme.categoricalClaude + case .cline: return Color(red: 0x23/255.0, green: 0x8A/255.0, blue: 0x7E/255.0) case .codex: return Theme.categoricalCodex case .cursor: return Theme.categoricalCursor case .cursorAgent: return Color(red: 0x4E/255.0, green: 0xC9/255.0, blue: 0xB0/255.0) diff --git a/src/providers/cline.ts b/src/providers/cline.ts new file mode 100644 index 0000000..7317706 --- /dev/null +++ b/src/providers/cline.ts @@ -0,0 +1,73 @@ +import { stat } from 'fs/promises' +import { homedir } from 'os' +import { basename, join } from 'path' + +import { discoverClineTasks, createClineParser, getVSCodeGlobalStoragePath } from './vscode-cline-parser.js' +import type { Provider, SessionSource, SessionParser } from './types.js' + +const EXTENSION_ID = 'saoudrizwan.claude-dev' + +export function getClineDataPath(): string { + return join(homedir(), '.cline', 'data') +} + +function normalizeOverrideDirs(overrideDirs?: string | string[]): string[] | undefined { + if (overrideDirs === undefined) return undefined + // Cline has two default roots, so tests and future callers can override one or both. + return Array.isArray(overrideDirs) ? overrideDirs : [overrideDirs] +} + +async function dedupeTaskSources(sources: SessionSource[]): Promise { + const candidates = await Promise.all(sources.map(async source => ({ + source, + mtimeMs: (await stat(join(source.path, 'ui_messages.json')).catch(() => null))?.mtimeMs ?? 0, + }))) + + const seenTaskIds = new Set() + const deduped: SessionSource[] = [] + + for (const { source } of candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)) { + const taskId = basename(source.path) + if (seenTaskIds.has(taskId)) continue + seenTaskIds.add(taskId) + deduped.push(source) + } + + return deduped +} + +export function createClineProvider(overrideDirs?: string | string[]): Provider { + const configuredDirs = normalizeOverrideDirs(overrideDirs) + + return { + name: 'cline', + displayName: 'Cline', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + const baseDirs = configuredDirs ?? [ + getVSCodeGlobalStoragePath(EXTENSION_ID), + getClineDataPath(), + ] + + const sources = await Promise.all( + baseDirs.map(dir => discoverClineTasks(EXTENSION_ID, 'cline', 'Cline', dir)), + ) + + return dedupeTaskSources(sources.flat()) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createClineParser(source, seenKeys, 'cline') + }, + } +} + +export const cline = createClineProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index 551d3a2..6aa9e68 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,4 +1,5 @@ import { claude } from './claude.js' +import { cline } from './cline.js' import { codex } from './codex.js' import { copilot } from './copilot.js' import { droid } from './droid.js' @@ -102,7 +103,7 @@ async function loadCrush(): Promise { } } -const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode] +const coreProviders: Provider[] = [claude, cline, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode] export async function getAllProviders(): Promise { const [ag, gs, cursor, opencode, cursorAgent, crush] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush()]) diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 2dc1dfc..2df7c4e 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js' describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) }) it('includes sqlite providers after async load', async () => { diff --git a/tests/providers/cline.test.ts b/tests/providers/cline.test.ts new file mode 100644 index 0000000..d739b96 --- /dev/null +++ b/tests/providers/cline.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { cline, createClineProvider } from '../../src/providers/cline.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +async function writeTask(baseDir: string, taskId: string, opts?: { + tokensIn?: number + tokensOut?: number + model?: string + userMessage?: string + cost?: number +}): Promise { + const taskDir = join(baseDir, 'tasks', taskId) + await mkdir(taskDir, { recursive: true }) + + const messages: unknown[] = [] + if (opts?.userMessage) { + messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1700000000000 }) + } + const usage: Record = { + tokensIn: opts?.tokensIn ?? 100, + tokensOut: opts?.tokensOut ?? 50, + } + if (opts?.cost !== undefined) usage.cost = opts.cost + messages.push({ type: 'say', say: 'api_req_started', text: JSON.stringify(usage), ts: 1700000001000 }) + + const modelTag = opts?.model ? `${opts.model}` : '' + const history = [ + { role: 'user', content: [{ type: 'text', text: `hello\n\n${modelTag}\n` }] }, + ] + + await writeFile(join(taskDir, 'ui_messages.json'), JSON.stringify(messages)) + await writeFile(join(taskDir, 'api_conversation_history.json'), JSON.stringify(history)) + + return taskDir +} + +describe('cline provider - discovery', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers Cline tasks from VS Code globalStorage and home data roots', async () => { + const vscodeDir = join(tmpDir, 'globalStorage') + const homeDataDir = join(tmpDir, 'cline-data') + await writeTask(vscodeDir, 'task-vscode') + await writeTask(homeDataDir, 'task-home') + + const provider = createClineProvider([vscodeDir, homeDataDir]) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.map(s => s.provider)).toEqual(['cline', 'cline']) + expect(sessions.map(s => s.project)).toEqual(['Cline', 'Cline']) + expect(sessions.map(s => s.path).sort()).toEqual([ + join(homeDataDir, 'tasks', 'task-home'), + join(vscodeDir, 'tasks', 'task-vscode'), + ].sort()) + }) + + it('deduplicates the same task id across roots by keeping the newest task directory', async () => { + const vscodeDir = join(tmpDir, 'globalStorage') + const homeDataDir = join(tmpDir, 'cline-data') + const oldTask = await writeTask(vscodeDir, 'task-same') + const newTask = await writeTask(homeDataDir, 'task-same') + await utimes(join(oldTask, 'ui_messages.json'), new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z')) + await utimes(join(newTask, 'ui_messages.json'), new Date('2026-02-01T00:00:00Z'), new Date('2026-02-01T00:00:00Z')) + + const provider = createClineProvider([vscodeDir, homeDataDir]) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toBe(newTask) + }) + + it('skips task directories without ui_messages.json', async () => { + const vscodeDir = join(tmpDir, 'globalStorage') + await mkdir(join(vscodeDir, 'tasks', 'task-no-ui'), { recursive: true }) + + const provider = createClineProvider(vscodeDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(0) + }) +}) + +describe('cline provider - parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('parses Cline usage with cline provider identity', async () => { + const taskDir = await writeTask(tmpDir, 'task-parse', { + tokensIn: 200, + tokensOut: 100, + model: 'anthropic/claude-sonnet-4-5', + userMessage: 'build the feature', + cost: 0.07, + }) + + const source = { path: taskDir, project: 'Cline', provider: 'cline' } + const calls: ParsedProviderCall[] = [] + for await (const call of cline.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.provider).toBe('cline') + expect(calls[0]!.model).toBe('claude-sonnet-4-5') + expect(calls[0]!.inputTokens).toBe(200) + expect(calls[0]!.outputTokens).toBe(100) + expect(calls[0]!.costUSD).toBe(0.07) + expect(calls[0]!.userMessage).toBe('build the feature') + expect(calls[0]!.deduplicationKey).toMatch(/^cline:task-parse:/) + }) +}) + +describe('cline provider - metadata', () => { + it('has correct name and displayName', () => { + expect(cline.name).toBe('cline') + expect(cline.displayName).toBe('Cline') + }) + + it('passes through model and tool display names', () => { + expect(cline.modelDisplayName('claude-sonnet-4-5')).toBe('claude-sonnet-4-5') + expect(cline.toolDisplayName('read_file')).toBe('read_file') + }) +})