@cleocode/adapters 2026.4.62 → 2026.4.64
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/dist/index.js +282 -221
- package/dist/index.js.map +3 -3
- package/dist/providers/claude-code/hooks.d.ts +8 -4
- package/dist/providers/claude-code/hooks.d.ts.map +1 -1
- package/dist/providers/claude-sdk/spawn.d.ts +10 -4
- package/dist/providers/claude-sdk/spawn.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/providers/claude-code/__tests__/hooks-get-transcript.test.ts +183 -0
- package/src/providers/claude-code/hooks.ts +69 -32
- package/src/providers/claude-sdk/__tests__/spawn.test.ts +52 -3
- package/src/providers/claude-sdk/spawn.ts +69 -5
|
@@ -143,16 +143,20 @@ export declare class ClaudeCodeHookProvider implements AdapterHookProvider {
|
|
|
143
143
|
/**
|
|
144
144
|
* Extract a plain-text transcript from Claude Code session JSONL files.
|
|
145
145
|
*
|
|
146
|
-
*
|
|
146
|
+
* Claude Code stores session data under `~/.claude/projects/<project-slug>/`:
|
|
147
|
+
* - Root-level session JSONLs: `<sessionId>.jsonl` (primary transcript)
|
|
148
|
+
* - UUID subdirectories contain `subagents/agent-*.jsonl` (subagent turns)
|
|
149
|
+
*
|
|
150
|
+
* Reads the most-recent root-level JSONL plus all subagent JSONLs and
|
|
147
151
|
* extracts user/assistant turn text into a flat string for brain
|
|
148
152
|
* observation extraction.
|
|
149
153
|
*
|
|
150
154
|
* Returns null when no session data is found or on any read error.
|
|
151
155
|
*
|
|
152
|
-
* @param
|
|
156
|
+
* @param sessionId - CLEO session ID (available for future subagent filtering)
|
|
153
157
|
* @param _projectDir - Project directory (unused; Claude Code uses global paths)
|
|
154
|
-
* @task T144 @epic T134
|
|
158
|
+
* @task T729 @task T144 @epic T134
|
|
155
159
|
*/
|
|
156
|
-
getTranscript(
|
|
160
|
+
getTranscript(sessionId: string, _projectDir: string): Promise<string | null>;
|
|
157
161
|
}
|
|
158
162
|
//# sourceMappingURL=hooks.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AA8C/D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,sBAAuB,YAAW,mBAAmB;IAChE,kEAAkE;IAClE,OAAO,CAAC,UAAU,CAAS;IAE3B;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAItD,+DAA+D;IAC/D,OAAO,CAAC,UAAU,CAAuB;IAEzC;;;;;;;;;;;;;;;;OAgBG;IACG,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA6E5D;;;;;;;OAOG;IACG,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IA2C5C;;OAEG;IACH,YAAY,IAAI,OAAO;IAIvB;;;;OAIG;IACH,aAAa,IAAI,MAAM,GAAG,IAAI;IAI9B;;;;;;;;OAQG;IACH,WAAW,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAI/C;;;;;;;;;;OAUG;IACG,2BAA2B,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAStD;;;;;;;;;;OAUG;IACG,kBAAkB,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IASnD;;;;;;;;;;OAUG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAW9D
|
|
1
|
+
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AA8C/D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,sBAAuB,YAAW,mBAAmB;IAChE,kEAAkE;IAClE,OAAO,CAAC,UAAU,CAAS;IAE3B;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAItD,+DAA+D;IAC/D,OAAO,CAAC,UAAU,CAAuB;IAEzC;;;;;;;;;;;;;;;;OAgBG;IACG,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA6E5D;;;;;;;OAOG;IACG,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IA2C5C;;OAEG;IACH,YAAY,IAAI,OAAO;IAIvB;;;;OAIG;IACH,aAAa,IAAI,MAAM,GAAG,IAAI;IAI9B;;;;;;;;OAQG;IACH,WAAW,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAI/C;;;;;;;;;;OAUG;IACG,2BAA2B,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAStD;;;;;;;;;;OAUG;IACG,kBAAkB,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IASnD;;;;;;;;;;OAUG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAW9D;;;;;;;;;;;;;;;;OAgBG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;CA4FpF"}
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
* - Awaits full completion before returning (synchronous output capture)
|
|
10
10
|
* - Session IDs from the SDK enable future multi-turn resumption
|
|
11
11
|
* - No temp files, no OS PIDs — tracking is purely in-memory session IDs
|
|
12
|
-
* - `canSpawn()`
|
|
12
|
+
* - `canSpawn()` uses 3-tier key resolution (env var → stored key → Claude Code OAuth)
|
|
13
13
|
*
|
|
14
14
|
* CANT enrichment is identical to the CLI provider: `buildCantEnrichedPrompt()`
|
|
15
15
|
* is called before `query()` and the result is passed as the SDK prompt string.
|
|
16
16
|
*
|
|
17
17
|
* @task T581
|
|
18
|
+
* @see T752 — canSpawn() OAuth fix
|
|
18
19
|
*/
|
|
19
20
|
import type { AdapterSpawnProvider, SpawnContext, SpawnResult } from '@cleocode/contracts';
|
|
20
21
|
/**
|
|
@@ -38,10 +39,15 @@ export declare class ClaudeSDKSpawnProvider implements AdapterSpawnProvider {
|
|
|
38
39
|
/**
|
|
39
40
|
* Check whether the SDK can be used in the current environment.
|
|
40
41
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
42
|
+
* Uses 3-tier key resolution so the provider works with:
|
|
43
|
+
* - `ANTHROPIC_API_KEY` environment variable (explicit)
|
|
44
|
+
* - `~/.local/share/cleo/anthropic-key` (user-stored via cleo config)
|
|
45
|
+
* - Claude Code OAuth token (zero-config for Claude Code users)
|
|
43
46
|
*
|
|
44
|
-
*
|
|
47
|
+
* No binary check is needed because the SDK manages the Claude Code
|
|
48
|
+
* subprocess internally.
|
|
49
|
+
*
|
|
50
|
+
* @returns `true` when any Anthropic credential is available
|
|
45
51
|
*/
|
|
46
52
|
canSpawn(): Promise<boolean>;
|
|
47
53
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-sdk/spawn.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-sdk/spawn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAKH,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAgE3F;;;;;;;;;;;;;;GAcG;AACH,qBAAa,sBAAuB,YAAW,oBAAoB;IACjE,kCAAkC;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsB;IAE/C;;;;;;;;;;;;OAYG;IACG,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC;IAIlC;;;;;;;;;OASG;IACG,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;IA6IxD;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAU3C;;;;;;;;;OASG;IACG,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGnD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cleocode/adapters",
|
|
3
|
-
"version": "2026.4.
|
|
3
|
+
"version": "2026.4.64",
|
|
4
4
|
"description": "Unified provider adapters for CLEO (Claude Code, OpenCode, Cursor)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@anthropic-ai/claude-agent-sdk": "0.2.108",
|
|
16
16
|
"@openai/agents": "0.8.3",
|
|
17
|
-
"@cleocode/caamp": "2026.4.
|
|
18
|
-
"@cleocode/contracts": "2026.4.
|
|
17
|
+
"@cleocode/caamp": "2026.4.64",
|
|
18
|
+
"@cleocode/contracts": "2026.4.64"
|
|
19
19
|
},
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"engines": {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ClaudeCodeHookProvider.getTranscript() — T729 bug fix.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that getTranscript reads root-level session JSONLs (siblings
|
|
5
|
+
* to UUID subdirectories) instead of walking into UUID subdirs.
|
|
6
|
+
*
|
|
7
|
+
* Claude Code layout under ~/.claude/projects/<project>/:
|
|
8
|
+
* - <sessionId>.jsonl ← root-level session JSONL (the real transcript)
|
|
9
|
+
* - <uuid>/ ← UUID subdir (contains only subagents/ and tool-results/)
|
|
10
|
+
* - <uuid>/subagents/ ← subagent session JSONLs live here
|
|
11
|
+
* - <uuid>/tool-results/ ← not JSONL sessions
|
|
12
|
+
*
|
|
13
|
+
* @task T729
|
|
14
|
+
* @epic T726
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
21
|
+
import { ClaudeCodeHookProvider } from '../hooks.js';
|
|
22
|
+
|
|
23
|
+
let tempDir: string;
|
|
24
|
+
let hooks: ClaudeCodeHookProvider;
|
|
25
|
+
|
|
26
|
+
/** JSONL line for a user turn. */
|
|
27
|
+
function userLine(text: string): string {
|
|
28
|
+
return JSON.stringify({ role: 'user', content: text });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** JSONL line for an assistant turn. */
|
|
32
|
+
function assistantLine(text: string): string {
|
|
33
|
+
return JSON.stringify({ role: 'assistant', content: text });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
tempDir = await mkdtemp(join(tmpdir(), 'cleo-get-transcript-'));
|
|
38
|
+
hooks = new ClaudeCodeHookProvider();
|
|
39
|
+
// Override HOME so getTranscript reads from our temp fixture
|
|
40
|
+
process.env['HOME'] = tempDir;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
delete process.env['HOME'];
|
|
45
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Fixture builders
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create the standard Claude Code directory layout under tempDir.
|
|
54
|
+
*
|
|
55
|
+
* ~/.claude/projects/
|
|
56
|
+
* <projectSlug>/
|
|
57
|
+
* <sessionId>.jsonl ← root-level session JSONL
|
|
58
|
+
* <uuid>/ ← UUID subdir (no JSONLs at root)
|
|
59
|
+
* <uuid>/subagents/
|
|
60
|
+
* agent-<agentId>.jsonl ← subagent JSONL
|
|
61
|
+
* <uuid>/tool-results/ ← not a session JSONL
|
|
62
|
+
*/
|
|
63
|
+
async function createFixture(opts: {
|
|
64
|
+
projectSlug: string;
|
|
65
|
+
sessionId: string;
|
|
66
|
+
rootLines: string[];
|
|
67
|
+
uuid?: string;
|
|
68
|
+
subagentLines?: string[];
|
|
69
|
+
}): Promise<{ projectDir: string }> {
|
|
70
|
+
const projectsDir = join(tempDir, '.claude', 'projects');
|
|
71
|
+
const projectDir = join(projectsDir, opts.projectSlug);
|
|
72
|
+
await mkdir(projectDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
// Root-level session JSONL (the one getTranscript MUST read)
|
|
75
|
+
const rootJsonl = join(projectDir, `${opts.sessionId}.jsonl`);
|
|
76
|
+
await writeFile(rootJsonl, opts.rootLines.join('\n'));
|
|
77
|
+
|
|
78
|
+
if (opts.uuid) {
|
|
79
|
+
// UUID subdir — should NOT be read as a session JSONL source
|
|
80
|
+
const uuidDir = join(projectDir, opts.uuid);
|
|
81
|
+
await mkdir(uuidDir, { recursive: true });
|
|
82
|
+
|
|
83
|
+
if (opts.subagentLines) {
|
|
84
|
+
const subagentsDir = join(uuidDir, 'subagents');
|
|
85
|
+
await mkdir(subagentsDir, { recursive: true });
|
|
86
|
+
const saJsonl = join(subagentsDir, `agent-${opts.sessionId}.jsonl`);
|
|
87
|
+
await writeFile(saJsonl, opts.subagentLines.join('\n'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// tool-results dir should never be iterated
|
|
91
|
+
await mkdir(join(uuidDir, 'tool-results'), { recursive: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { projectDir };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Tests
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
describe('getTranscript — T729 root-level JSONL fix', () => {
|
|
102
|
+
it('GT-1: reads root-level session JSONL, not UUID subdir contents', async () => {
|
|
103
|
+
await createFixture({
|
|
104
|
+
projectSlug: 'test-project',
|
|
105
|
+
sessionId: 'ses_abc123',
|
|
106
|
+
rootLines: [userLine('hello from root'), assistantLine('response from root')],
|
|
107
|
+
uuid: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = await hooks.getTranscript('ses_abc123', '/tmp/test');
|
|
111
|
+
|
|
112
|
+
expect(result).not.toBeNull();
|
|
113
|
+
expect(result).toContain('user: hello from root');
|
|
114
|
+
expect(result).toContain('assistant: response from root');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('GT-2: returns null when projects dir does not exist', async () => {
|
|
118
|
+
// HOME points to tempDir but no .claude/projects inside
|
|
119
|
+
const result = await hooks.getTranscript('ses_abc123', '/tmp/test');
|
|
120
|
+
expect(result).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('GT-3: returns null when root-level JSONL has no recognizable turns', async () => {
|
|
124
|
+
await createFixture({
|
|
125
|
+
projectSlug: 'test-project',
|
|
126
|
+
sessionId: 'ses_empty',
|
|
127
|
+
rootLines: [JSON.stringify({ type: 'system', data: 'some system event' }), 'malformed line'],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const result = await hooks.getTranscript('ses_empty', '/tmp/test');
|
|
131
|
+
expect(result).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('GT-4: picks most-recent root JSONL when multiple projects exist', async () => {
|
|
135
|
+
// Create two project dirs with root JSONLs; lexicographically later name wins
|
|
136
|
+
const projectsDir = join(tempDir, '.claude', 'projects');
|
|
137
|
+
|
|
138
|
+
const proj1 = join(projectsDir, 'project-a');
|
|
139
|
+
await mkdir(proj1, { recursive: true });
|
|
140
|
+
await writeFile(join(proj1, 'ses_older.jsonl'), userLine('older project'));
|
|
141
|
+
|
|
142
|
+
const proj2 = join(projectsDir, 'project-z');
|
|
143
|
+
await mkdir(proj2, { recursive: true });
|
|
144
|
+
await writeFile(join(proj2, 'ses_newer.jsonl'), userLine('newer project'));
|
|
145
|
+
|
|
146
|
+
const result = await hooks.getTranscript('ses_newer', '/tmp/test');
|
|
147
|
+
// project-z sorts after project-a, so project-z/ses_newer.jsonl wins
|
|
148
|
+
expect(result).toContain('user: newer project');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('GT-5: also ingests subagent JSONLs from UUID subdir when present', async () => {
|
|
152
|
+
await createFixture({
|
|
153
|
+
projectSlug: 'test-project',
|
|
154
|
+
sessionId: 'ses_withagent',
|
|
155
|
+
rootLines: [userLine('main session turn')],
|
|
156
|
+
uuid: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
|
157
|
+
subagentLines: [assistantLine('subagent turn')],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const result = await hooks.getTranscript('ses_withagent', '/tmp/test');
|
|
161
|
+
|
|
162
|
+
expect(result).not.toBeNull();
|
|
163
|
+
expect(result).toContain('user: main session turn');
|
|
164
|
+
expect(result).toContain('assistant: subagent turn');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('GT-6: does NOT read JSONL files placed directly inside UUID subdirs (wrong layout)', async () => {
|
|
168
|
+
const projectsDir = join(tempDir, '.claude', 'projects');
|
|
169
|
+
const projectDir = join(projectsDir, 'test-project');
|
|
170
|
+
await mkdir(projectDir, { recursive: true });
|
|
171
|
+
|
|
172
|
+
// Place a JSONL inside a UUID subdir (old/wrong location)
|
|
173
|
+
const uuidDir = join(projectDir, 'f47ac10b-58cc-4372-a567-0e02b2c3d479');
|
|
174
|
+
await mkdir(uuidDir, { recursive: true });
|
|
175
|
+
await writeFile(join(uuidDir, 'wrong-location.jsonl'), userLine('should not be read'));
|
|
176
|
+
|
|
177
|
+
// No root-level JSONL present
|
|
178
|
+
const result = await hooks.getTranscript('ses_abc', '/tmp/test');
|
|
179
|
+
|
|
180
|
+
// Should return null since no root-level JSONLs exist
|
|
181
|
+
expect(result).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -351,67 +351,104 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
|
|
|
351
351
|
/**
|
|
352
352
|
* Extract a plain-text transcript from Claude Code session JSONL files.
|
|
353
353
|
*
|
|
354
|
-
*
|
|
354
|
+
* Claude Code stores session data under `~/.claude/projects/<project-slug>/`:
|
|
355
|
+
* - Root-level session JSONLs: `<sessionId>.jsonl` (primary transcript)
|
|
356
|
+
* - UUID subdirectories contain `subagents/agent-*.jsonl` (subagent turns)
|
|
357
|
+
*
|
|
358
|
+
* Reads the most-recent root-level JSONL plus all subagent JSONLs and
|
|
355
359
|
* extracts user/assistant turn text into a flat string for brain
|
|
356
360
|
* observation extraction.
|
|
357
361
|
*
|
|
358
362
|
* Returns null when no session data is found or on any read error.
|
|
359
363
|
*
|
|
360
|
-
* @param
|
|
364
|
+
* @param sessionId - CLEO session ID (available for future subagent filtering)
|
|
361
365
|
* @param _projectDir - Project directory (unused; Claude Code uses global paths)
|
|
362
|
-
* @task T144 @epic T134
|
|
366
|
+
* @task T729 @task T144 @epic T134
|
|
363
367
|
*/
|
|
364
|
-
async getTranscript(
|
|
368
|
+
async getTranscript(sessionId: string, _projectDir: string): Promise<string | null> {
|
|
365
369
|
try {
|
|
366
370
|
const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? '/root';
|
|
367
371
|
const projectsDir = join(homeDir, '.claude', 'projects');
|
|
368
372
|
|
|
369
|
-
//
|
|
370
|
-
|
|
373
|
+
// Collect root-level session JSONLs (siblings to UUID subdirs).
|
|
374
|
+
// Claude Code layout: ~/.claude/projects/<project>/<sessionId>.jsonl
|
|
375
|
+
// UUID subdirs contain only subagents/ and tool-results/, not JSONLs.
|
|
376
|
+
let rootFiles: Array<{ path: string }> = [];
|
|
377
|
+
const subagentFiles: Array<{ path: string }> = [];
|
|
378
|
+
|
|
371
379
|
try {
|
|
372
380
|
const projectDirs = await readdir(projectsDir, { withFileTypes: true });
|
|
373
|
-
for (const
|
|
374
|
-
if (!
|
|
375
|
-
const
|
|
381
|
+
for (const projectEntry of projectDirs) {
|
|
382
|
+
if (!projectEntry.isDirectory()) continue;
|
|
383
|
+
const projectDir = join(projectsDir, projectEntry.name);
|
|
384
|
+
|
|
376
385
|
try {
|
|
377
|
-
const
|
|
378
|
-
for (const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
386
|
+
const projectContents = await readdir(projectDir, { withFileTypes: true });
|
|
387
|
+
for (const entry of projectContents) {
|
|
388
|
+
// Root-level JSONL: direct child file ending in .jsonl
|
|
389
|
+
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
390
|
+
rootFiles.push({ path: join(projectDir, entry.name) });
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// UUID subdir: check for subagent JSONLs
|
|
395
|
+
if (entry.isDirectory()) {
|
|
396
|
+
const subagentsDir = join(projectDir, entry.name, 'subagents');
|
|
397
|
+
try {
|
|
398
|
+
const subagentEntries = await readdir(subagentsDir);
|
|
399
|
+
for (const sa of subagentEntries) {
|
|
400
|
+
if (sa.startsWith('agent-') && sa.endsWith('.jsonl')) {
|
|
401
|
+
subagentFiles.push({ path: join(subagentsDir, sa) });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
// No subagents dir in this subdir — skip
|
|
406
|
+
}
|
|
407
|
+
}
|
|
383
408
|
}
|
|
384
409
|
} catch {
|
|
385
|
-
// Skip unreadable
|
|
410
|
+
// Skip unreadable project directories
|
|
386
411
|
}
|
|
387
412
|
}
|
|
388
413
|
} catch {
|
|
389
414
|
return null;
|
|
390
415
|
}
|
|
391
416
|
|
|
392
|
-
if (
|
|
417
|
+
if (rootFiles.length === 0 && subagentFiles.length === 0) return null;
|
|
393
418
|
|
|
394
|
-
// Sort by path descending (timestamps in filenames sort naturally)
|
|
395
|
-
|
|
396
|
-
const mostRecent = allFiles[0];
|
|
397
|
-
if (!mostRecent) return null;
|
|
419
|
+
// Sort root files by path descending (timestamps in filenames sort naturally)
|
|
420
|
+
rootFiles = rootFiles.sort((a, b) => b.path.localeCompare(a.path));
|
|
398
421
|
|
|
399
|
-
|
|
400
|
-
const
|
|
422
|
+
// Collect all JSONL paths: most-recent root file first, then subagent files
|
|
423
|
+
const allPaths = [
|
|
424
|
+
...(rootFiles[0] ? [rootFiles[0].path] : []),
|
|
425
|
+
...subagentFiles.map((f) => f.path),
|
|
426
|
+
];
|
|
427
|
+
|
|
428
|
+
// Suppress unused variable warning — sessionId available for future filtering
|
|
429
|
+
void sessionId;
|
|
401
430
|
|
|
402
431
|
const turns: string[] = [];
|
|
403
|
-
for (const
|
|
432
|
+
for (const filePath of allPaths) {
|
|
404
433
|
try {
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
434
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
435
|
+
const lines = raw.split('\n').filter((l) => l.trim());
|
|
436
|
+
for (const line of lines) {
|
|
437
|
+
try {
|
|
438
|
+
const entry = JSON.parse(line) as Record<string, unknown>;
|
|
439
|
+
const role = entry.role as string | undefined;
|
|
440
|
+
const content = entry.content;
|
|
441
|
+
if (role === 'assistant' && typeof content === 'string') {
|
|
442
|
+
turns.push(`assistant: ${content}`);
|
|
443
|
+
} else if (role === 'user' && typeof content === 'string') {
|
|
444
|
+
turns.push(`user: ${content}`);
|
|
445
|
+
}
|
|
446
|
+
} catch {
|
|
447
|
+
// Skip malformed lines
|
|
448
|
+
}
|
|
412
449
|
}
|
|
413
450
|
} catch {
|
|
414
|
-
// Skip
|
|
451
|
+
// Skip unreadable files
|
|
415
452
|
}
|
|
416
453
|
}
|
|
417
454
|
|
|
@@ -61,7 +61,7 @@ describe('ClaudeSDKSpawnProvider', () => {
|
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
// -------------------------------------------------------------------------
|
|
64
|
-
// canSpawn
|
|
64
|
+
// canSpawn — 3-tier key resolution (T752)
|
|
65
65
|
// -------------------------------------------------------------------------
|
|
66
66
|
|
|
67
67
|
describe('canSpawn()', () => {
|
|
@@ -70,12 +70,61 @@ describe('ClaudeSDKSpawnProvider', () => {
|
|
|
70
70
|
expect(await provider.canSpawn()).toBe(true);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
it('returns false when
|
|
73
|
+
it('returns false when no credentials are available', async () => {
|
|
74
|
+
// Mock fs.existsSync to return false for all key paths so no tier
|
|
75
|
+
// resolves (env var, stored key file, or OAuth credentials file).
|
|
76
|
+
const { existsSync } = await import('node:fs');
|
|
77
|
+
const saved = process.env.ANTHROPIC_API_KEY;
|
|
78
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
79
|
+
vi.spyOn({ existsSync }, 'existsSync').mockReturnValue(false);
|
|
80
|
+
// Use vi.mock for node:fs to prevent reading real ~/.claude/.credentials.json
|
|
81
|
+
vi.doMock('node:fs', () => ({
|
|
82
|
+
existsSync: vi.fn().mockReturnValue(false),
|
|
83
|
+
readFileSync: vi.fn().mockImplementation(() => {
|
|
84
|
+
throw new Error('mocked: file not found');
|
|
85
|
+
}),
|
|
86
|
+
}));
|
|
87
|
+
try {
|
|
88
|
+
// Re-import spawn module with mocked fs to get fresh resolver state
|
|
89
|
+
vi.resetModules();
|
|
90
|
+
const { ClaudeSDKSpawnProvider: FreshProvider } = await import('../spawn.js');
|
|
91
|
+
const freshProvider = new FreshProvider();
|
|
92
|
+
expect(await freshProvider.canSpawn()).toBe(false);
|
|
93
|
+
} finally {
|
|
94
|
+
vi.resetModules();
|
|
95
|
+
vi.doUnmock('node:fs');
|
|
96
|
+
if (saved !== undefined) {
|
|
97
|
+
process.env.ANTHROPIC_API_KEY = saved;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns true when OAuth credentials file exists (no API key env var)', async () => {
|
|
103
|
+
const validCreds = JSON.stringify({
|
|
104
|
+
claudeAiOauth: {
|
|
105
|
+
accessToken: 'oauth-token',
|
|
106
|
+
expiresAt: Date.now() + 3_600_000, // 1 hour from now
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
vi.doMock('node:fs', () => ({
|
|
110
|
+
existsSync: vi
|
|
111
|
+
.fn()
|
|
112
|
+
.mockImplementation((p: string) => String(p).endsWith('.credentials.json')),
|
|
113
|
+
readFileSync: vi.fn().mockImplementation((p: string) => {
|
|
114
|
+
if (String(p).endsWith('.credentials.json')) return validCreds;
|
|
115
|
+
throw new Error('mocked: file not found');
|
|
116
|
+
}),
|
|
117
|
+
}));
|
|
74
118
|
const saved = process.env.ANTHROPIC_API_KEY;
|
|
75
119
|
delete process.env.ANTHROPIC_API_KEY;
|
|
76
120
|
try {
|
|
77
|
-
|
|
121
|
+
vi.resetModules();
|
|
122
|
+
const { ClaudeSDKSpawnProvider: FreshProvider } = await import('../spawn.js');
|
|
123
|
+
const freshProvider = new FreshProvider();
|
|
124
|
+
expect(await freshProvider.canSpawn()).toBe(true);
|
|
78
125
|
} finally {
|
|
126
|
+
vi.resetModules();
|
|
127
|
+
vi.doUnmock('node:fs');
|
|
79
128
|
if (saved !== undefined) {
|
|
80
129
|
process.env.ANTHROPIC_API_KEY = saved;
|
|
81
130
|
}
|
|
@@ -9,20 +9,79 @@
|
|
|
9
9
|
* - Awaits full completion before returning (synchronous output capture)
|
|
10
10
|
* - Session IDs from the SDK enable future multi-turn resumption
|
|
11
11
|
* - No temp files, no OS PIDs — tracking is purely in-memory session IDs
|
|
12
|
-
* - `canSpawn()`
|
|
12
|
+
* - `canSpawn()` uses 3-tier key resolution (env var → stored key → Claude Code OAuth)
|
|
13
13
|
*
|
|
14
14
|
* CANT enrichment is identical to the CLI provider: `buildCantEnrichedPrompt()`
|
|
15
15
|
* is called before `query()` and the result is passed as the SDK prompt string.
|
|
16
16
|
*
|
|
17
17
|
* @task T581
|
|
18
|
+
* @see T752 — canSpawn() OAuth fix
|
|
18
19
|
*/
|
|
19
20
|
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
20
24
|
import type { AdapterSpawnProvider, SpawnContext, SpawnResult } from '@cleocode/contracts';
|
|
21
25
|
import { getErrorMessage } from '@cleocode/contracts';
|
|
22
26
|
import { getServers } from './mcp-registry.js';
|
|
23
27
|
import { SessionStore } from './session-store.js';
|
|
24
28
|
import { resolveTools } from './tool-bridge.js';
|
|
25
29
|
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Inline 3-tier Anthropic key resolver
|
|
32
|
+
// NOTE: Cannot import from @cleocode/core — circular dependency
|
|
33
|
+
// (@cleocode/core depends on @cleocode/adapters). This is a deliberate
|
|
34
|
+
// inline copy of the resolution logic from anthropic-key-resolver.ts.
|
|
35
|
+
// Keep in sync with packages/core/src/memory/anthropic-key-resolver.ts.
|
|
36
|
+
// T752 — OAuth fix for canSpawn()
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the Anthropic API key using a 3-tier priority chain:
|
|
41
|
+
* 1. `ANTHROPIC_API_KEY` environment variable
|
|
42
|
+
* 2. `~/.local/share/cleo/anthropic-key` (user-stored via cleo config)
|
|
43
|
+
* 3. `~/.claude/.credentials.json` → claudeAiOauth.accessToken (Claude Code OAuth)
|
|
44
|
+
*
|
|
45
|
+
* @returns The key/token string, or null if unavailable.
|
|
46
|
+
*/
|
|
47
|
+
function resolveAnthropicApiKey(): string | null {
|
|
48
|
+
// 1. Explicit env var
|
|
49
|
+
const envKey = process.env.ANTHROPIC_API_KEY;
|
|
50
|
+
if (envKey?.trim()) return envKey;
|
|
51
|
+
|
|
52
|
+
// 2. CLEO global stored key
|
|
53
|
+
try {
|
|
54
|
+
const xdg = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
|
|
55
|
+
const keyFile = join(xdg, 'cleo', 'anthropic-key');
|
|
56
|
+
if (existsSync(keyFile)) {
|
|
57
|
+
const stored = readFileSync(keyFile, 'utf-8').trim();
|
|
58
|
+
if (stored) return stored;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Not available — continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 3. Claude Code OAuth token (free for Claude Code users)
|
|
65
|
+
try {
|
|
66
|
+
const credPath = join(homedir(), '.claude', '.credentials.json');
|
|
67
|
+
if (!existsSync(credPath)) return null;
|
|
68
|
+
const raw = readFileSync(credPath, 'utf-8');
|
|
69
|
+
const creds = JSON.parse(raw) as {
|
|
70
|
+
claudeAiOauth?: { accessToken?: string; expiresAt?: number };
|
|
71
|
+
};
|
|
72
|
+
const token = creds.claudeAiOauth?.accessToken;
|
|
73
|
+
if (token?.trim()) {
|
|
74
|
+
const expiresAt = creds.claudeAiOauth?.expiresAt;
|
|
75
|
+
if (expiresAt && Date.now() > expiresAt) return null;
|
|
76
|
+
return token;
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Credentials file missing or unreadable — not an error
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
26
85
|
/** Model used when no model is specified in spawn options. */
|
|
27
86
|
const DEFAULT_MODEL = 'claude-sonnet-4-5';
|
|
28
87
|
|
|
@@ -48,13 +107,18 @@ export class ClaudeSDKSpawnProvider implements AdapterSpawnProvider {
|
|
|
48
107
|
/**
|
|
49
108
|
* Check whether the SDK can be used in the current environment.
|
|
50
109
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
110
|
+
* Uses 3-tier key resolution so the provider works with:
|
|
111
|
+
* - `ANTHROPIC_API_KEY` environment variable (explicit)
|
|
112
|
+
* - `~/.local/share/cleo/anthropic-key` (user-stored via cleo config)
|
|
113
|
+
* - Claude Code OAuth token (zero-config for Claude Code users)
|
|
114
|
+
*
|
|
115
|
+
* No binary check is needed because the SDK manages the Claude Code
|
|
116
|
+
* subprocess internally.
|
|
53
117
|
*
|
|
54
|
-
* @returns `true` when
|
|
118
|
+
* @returns `true` when any Anthropic credential is available
|
|
55
119
|
*/
|
|
56
120
|
async canSpawn(): Promise<boolean> {
|
|
57
|
-
return !!
|
|
121
|
+
return !!resolveAnthropicApiKey();
|
|
58
122
|
}
|
|
59
123
|
|
|
60
124
|
/**
|