Skip to content

Commit 33e81de

Browse files
committed
feat: add configurable Claude profile discovery
1 parent 1fef41a commit 33e81de

11 files changed

Lines changed: 349 additions & 18 deletions

README.extension.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ The extension is organized into three sections: **Observe**, **Measure**, and **
6161
| **OpenCode** | macOS/Linux: `~/.local/share/opencode/`<br>Windows: `%USERPROFILE%\.local\share\opencode\` |
6262
| **GitHub Copilot CLI** | `~/.copilot/session-state/` and `~/.copilot/history-session-state/` |
6363

64+
Claude auto-detects `CLAUDE_CONFIG_DIR` and `~/.claude` by default. To scan only specific Claude profiles, set `aiEngineerCoach.claudeConfigDirs` in VS Code settings, for example `["~/.claude-home", "~/.claude-work"]`.
65+
6466
## Getting Started
6567

6668
1. Open the command palette (`Cmd+Shift+P` / `Ctrl+Shift+P`).

docs/content/getting-started/installation.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,13 @@ You can also click the AI Engineer Coach icon in the Activity Bar (sidebar) if i
4646
## Configuration
4747

4848
AI Engineer Coach works out of the box with sensible defaults. Optional settings are available under `aiEngineerCoach.*` in VS Code settings to control cache behavior, date ranges, and workspace filtering.
49+
50+
By default, Claude session discovery scans `CLAUDE_CONFIG_DIR` and `~/.claude`. To scan only specific Claude profiles, add config roots to `aiEngineerCoach.claudeConfigDirs`:
51+
52+
```json
53+
{
54+
"aiEngineerCoach.claudeConfigDirs": ["~/.claude-home", "~/.claude-work"]
55+
}
56+
```
57+
58+
When this setting is non-empty, it replaces automatic Claude discovery.

package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,19 @@
8181
"type": "webview"
8282
}
8383
]
84+
},
85+
"configuration": {
86+
"title": "AI Engineer Coach",
87+
"properties": {
88+
"aiEngineerCoach.claudeConfigDirs": {
89+
"type": "array",
90+
"default": [],
91+
"items": {
92+
"type": "string"
93+
},
94+
"markdownDescription": "Claude configuration directories to scan for session logs. Leave empty to auto-detect `CLAUDE_CONFIG_DIR` and the default `~/.claude`; set one or more entries to explicitly choose Claude profiles such as `~/.claude-home` and `~/.claude-work`. Each entry should point to a Claude config directory; `projects/` is appended automatically unless the entry already ends with `projects`."
95+
}
96+
}
8497
}
8598
},
8699
"scripts": {

src/core/harness-config-roots.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See LICENSE in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
/* Harness-specific configuration root discovery. */
7+
8+
import * as os from 'os';
9+
import * as fs from 'fs';
10+
import * as path from 'path';
11+
12+
export const CLAUDE_CONFIG_DIRS_ENV = 'AI_ENGINEER_COACH_CLAUDE_CONFIG_DIRS';
13+
14+
function expandHomeDir(dir: string, home: string): string {
15+
if (dir === '~') return home || dir;
16+
if (dir.startsWith(`~${path.sep}`) || dir.startsWith('~/') || dir.startsWith('~\\')) {
17+
return home ? path.join(home, dir.slice(2)) : dir;
18+
}
19+
return dir;
20+
}
21+
22+
function parseDirList(raw: string | undefined): string[] {
23+
if (!raw || raw.trim().length === 0) return [];
24+
const trimmed = raw.trim();
25+
if (trimmed.startsWith('[')) {
26+
try {
27+
const parsed: unknown = JSON.parse(trimmed);
28+
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [];
29+
} catch {
30+
return [];
31+
}
32+
}
33+
return trimmed.split(path.delimiter).map(part => part.trim()).filter(Boolean);
34+
}
35+
36+
function uniqueResolvedDirs(dirs: string[], home: string): string[] {
37+
const seen = new Set<string>();
38+
const roots: string[] = [];
39+
40+
for (const dir of dirs) {
41+
if (!dir || dir.trim().length === 0) continue;
42+
const resolved = path.resolve(expandHomeDir(dir.trim(), home));
43+
let canonical = resolved;
44+
try {
45+
canonical = fs.realpathSync(resolved);
46+
} catch {
47+
/* Keep the unresolved path so future dirs can still be configured. */
48+
}
49+
const key = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
50+
if (seen.has(key)) continue;
51+
seen.add(key);
52+
roots.push(resolved);
53+
}
54+
55+
return roots;
56+
}
57+
58+
export function getClaudeConfigRoots(): string[] {
59+
const home = os.homedir() || process.env.HOME || process.env.USERPROFILE || '';
60+
const configuredDirs = parseDirList(process.env[CLAUDE_CONFIG_DIRS_ENV]);
61+
if (configuredDirs.length > 0) return uniqueResolvedDirs(configuredDirs, home);
62+
63+
const dirs = [
64+
process.env.CLAUDE_CONFIG_DIR || '',
65+
];
66+
if (home) dirs.push(path.join(home, '.claude'));
67+
return uniqueResolvedDirs(dirs, home);
68+
}

src/core/parser-claude.test.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import * as os from 'os';
1212
import * as path from 'path';
1313
import { execSync } from 'child_process';
1414
import { describe, it, expect } from 'vitest';
15-
import { parseClaudeSessions } from './parser-claude';
15+
import { CLAUDE_CONFIG_DIRS_ENV } from './harness-config-roots';
16+
import { findClaudeDirs, parseClaudeSessions } from './parser-claude';
1617

1718
/** os.tmpdir() on Windows often returns 8.3 short names (e.g. TAMASB~1)
1819
* that don't match readdirSync output. Resolve to the long form so
@@ -74,7 +75,109 @@ function withProjectsDir(filename: string, lines: object[], run: (projectsDir: s
7475
try { run(projectsDir); } finally { fs.rmSync(root, { recursive: true, force: true }); }
7576
}
7677

78+
function withClaudeEnv(run: () => void): void {
79+
const original = {
80+
HOME: process.env.HOME,
81+
USERPROFILE: process.env.USERPROFILE,
82+
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
83+
[CLAUDE_CONFIG_DIRS_ENV]: process.env[CLAUDE_CONFIG_DIRS_ENV],
84+
};
85+
try {
86+
run();
87+
} finally {
88+
for (const [key, value] of Object.entries(original)) {
89+
if (value === undefined) delete process.env[key];
90+
else process.env[key] = value;
91+
}
92+
}
93+
}
94+
7795
describe('parseClaudeSessions', () => {
96+
it('uses configured Claude config directories as an explicit override', () => {
97+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-dirs-test-'));
98+
const home = path.join(root, 'home');
99+
const work = path.join(root, '.claude-work');
100+
const active = path.join(root, '.claude-active');
101+
const directProjects = path.join(root, '.claude-home', 'projects');
102+
103+
try {
104+
withClaudeEnv(() => {
105+
for (const dir of [
106+
path.join(home, '.claude', 'projects'),
107+
path.join(work, 'projects'),
108+
path.join(active, 'projects'),
109+
directProjects,
110+
]) {
111+
fs.mkdirSync(dir, { recursive: true });
112+
}
113+
114+
process.env.HOME = home;
115+
delete process.env.USERPROFILE;
116+
process.env.CLAUDE_CONFIG_DIR = active;
117+
process.env[CLAUDE_CONFIG_DIRS_ENV] = JSON.stringify([work, directProjects]);
118+
119+
expect(findClaudeDirs()).toEqual([
120+
path.join(work, 'projects'),
121+
directProjects,
122+
]);
123+
});
124+
} finally {
125+
fs.rmSync(root, { recursive: true, force: true });
126+
}
127+
});
128+
129+
it('falls back to CLAUDE_CONFIG_DIR and default home when no Claude dirs are configured', () => {
130+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-auto-dirs-test-'));
131+
const home = path.join(root, 'home');
132+
const active = path.join(root, '.claude-active');
133+
134+
try {
135+
withClaudeEnv(() => {
136+
fs.mkdirSync(path.join(home, '.claude', 'projects'), { recursive: true });
137+
fs.mkdirSync(path.join(active, 'projects'), { recursive: true });
138+
139+
process.env.HOME = home;
140+
delete process.env.USERPROFILE;
141+
process.env.CLAUDE_CONFIG_DIR = active;
142+
delete process.env[CLAUDE_CONFIG_DIRS_ENV];
143+
144+
expect(findClaudeDirs()).toEqual([
145+
path.join(active, 'projects'),
146+
path.join(home, '.claude', 'projects'),
147+
]);
148+
});
149+
} finally {
150+
fs.rmSync(root, { recursive: true, force: true });
151+
}
152+
});
153+
154+
it('deduplicates Claude config directories that resolve to the same symlink target', () => {
155+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-symlink-dirs-test-'));
156+
const home = path.join(root, 'home');
157+
const target = path.join(root, '.claude-home');
158+
159+
try {
160+
withClaudeEnv(() => {
161+
fs.mkdirSync(path.join(home), { recursive: true });
162+
fs.mkdirSync(path.join(target, 'projects'), { recursive: true });
163+
try {
164+
fs.symlinkSync(target, path.join(home, '.claude'), 'dir');
165+
} catch {
166+
return;
167+
}
168+
169+
process.env.HOME = home;
170+
delete process.env.USERPROFILE;
171+
delete process.env.CLAUDE_CONFIG_DIR;
172+
process.env[CLAUDE_CONFIG_DIRS_ENV] = JSON.stringify([target]);
173+
174+
expect(findClaudeDirs()).toEqual([path.join(target, 'projects')]);
175+
});
176+
} finally {
177+
fs.rmSync(root, { recursive: true, force: true });
178+
}
179+
});
180+
78181
it('skips tool_result-only user records and merges following assistant into prior real user request', () => {
79182
withProjectsDir('s.jsonl', [
80183
makeUser('write a file please'),

src/core/parser-claude.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import * as fs from 'fs';
1919
import * as path from 'path';
2020
import { Session, SessionRequest } from './types';
21+
import { getClaudeConfigRoots } from './harness-config-roots';
2122
import { assertTrustedPath, readFileSafe, createRequest, createSession, detectDevcontainerFromRequests, extractSkillNameFromPath } from './parser-shared';
2223
import { extractReasoningEffortFromModelId } from './helpers';
2324
import { warnCore } from './log';
@@ -311,10 +312,25 @@ function collectClaudeAssistantData(lines: ClaudeLine[], startIndex: number, las
311312
}
312313

313314
export function findClaudeDirs(): string[] {
314-
const home = process.env.HOME || process.env.USERPROFILE || '';
315315
const dirs: string[] = [];
316-
const projectsDir = path.join(home, '.claude', 'projects');
317-
if (fs.existsSync(projectsDir)) dirs.push(projectsDir);
316+
const seen = new Set<string>();
317+
318+
for (const root of getClaudeConfigRoots()) {
319+
const projectsDir = path.basename(root) === 'projects' ? root : path.join(root, 'projects');
320+
const resolved = path.resolve(projectsDir);
321+
let canonical = resolved;
322+
try {
323+
canonical = fs.realpathSync(resolved);
324+
} catch {
325+
/* Keep the unresolved path so future dirs can still be configured. */
326+
}
327+
const key = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
328+
if (seen.has(key)) continue;
329+
if (fs.existsSync(resolved)) {
330+
seen.add(key);
331+
dirs.push(resolved);
332+
}
333+
}
318334
return dirs;
319335
}
320336

src/core/parser-harnesses.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See LICENSE in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as fs from 'fs';
7+
import * as os from 'os';
8+
import * as path from 'path';
9+
import { describe, expect, it } from 'vitest';
10+
import { CLAUDE_CONFIG_DIRS_ENV } from './harness-config-roots';
11+
import { collectExternalHarnessesSync } from './parser-harnesses';
12+
import { Session, Workspace } from './types';
13+
14+
function writeClaudeSession(projectsDir: string, sessionId: string): void {
15+
const projectDir = path.join(projectsDir, '-Users-me-story-book');
16+
fs.mkdirSync(projectDir, { recursive: true });
17+
const lines = [
18+
{
19+
type: 'user',
20+
timestamp: '2026-05-26T10:00:00Z',
21+
sessionId,
22+
cwd: '/Users/me/story-book',
23+
entrypoint: 'cli',
24+
message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
25+
},
26+
{
27+
type: 'assistant',
28+
timestamp: '2026-05-26T10:00:01Z',
29+
sessionId,
30+
message: {
31+
role: 'assistant',
32+
model: 'claude-sonnet-4',
33+
content: [{ type: 'text', text: 'hi' }],
34+
usage: { input_tokens: 10, output_tokens: 5 },
35+
},
36+
},
37+
];
38+
fs.writeFileSync(path.join(projectDir, `${sessionId}.jsonl`), lines.map(line => JSON.stringify(line)).join('\n'), 'utf-8');
39+
}
40+
41+
function withClaudeEnv(run: () => void): void {
42+
const original = {
43+
HOME: process.env.HOME,
44+
USERPROFILE: process.env.USERPROFILE,
45+
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
46+
[CLAUDE_CONFIG_DIRS_ENV]: process.env[CLAUDE_CONFIG_DIRS_ENV],
47+
};
48+
try {
49+
run();
50+
} finally {
51+
for (const [key, value] of Object.entries(original)) {
52+
if (value === undefined) delete process.env[key];
53+
else process.env[key] = value;
54+
}
55+
}
56+
}
57+
58+
describe('collectExternalHarnessesSync', () => {
59+
it('deduplicates Claude sessions copied across multiple config roots', () => {
60+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-harness-dedupe-'));
61+
const home = path.join(root, 'home');
62+
const defaultProjects = path.join(home, '.claude', 'projects');
63+
const workRoot = path.join(root, '.claude-work');
64+
const workProjects = path.join(workRoot, 'projects');
65+
const sessionId = '11111111-1111-4111-8111-111111111111';
66+
67+
try {
68+
withClaudeEnv(() => {
69+
writeClaudeSession(defaultProjects, sessionId);
70+
writeClaudeSession(workProjects, sessionId);
71+
72+
process.env.HOME = home;
73+
delete process.env.USERPROFILE;
74+
delete process.env.CLAUDE_CONFIG_DIR;
75+
process.env[CLAUDE_CONFIG_DIRS_ENV] = JSON.stringify([workRoot]);
76+
77+
const workspaces = new Map<string, Workspace>();
78+
const sessions: Session[] = [];
79+
collectExternalHarnessesSync(workspaces, sessions);
80+
81+
const claudeSessions = sessions.filter(session => session.harness === 'Claude');
82+
expect(claudeSessions).toHaveLength(1);
83+
expect(claudeSessions[0].sessionId).toBe(sessionId);
84+
});
85+
} finally {
86+
fs.rmSync(root, { recursive: true, force: true });
87+
}
88+
});
89+
});

src/core/parser-harnesses.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,37 @@ function addSession(workspaces: WorkspaceMap, sessions: Session[], session: Sess
3232
}
3333
}
3434

35+
function addClaudeSession(
36+
workspaces: WorkspaceMap,
37+
sessions: Session[],
38+
seenSessionIds: Set<string>,
39+
session: Session,
40+
rootPath: string,
41+
): void {
42+
if (seenSessionIds.has(session.sessionId)) return;
43+
seenSessionIds.add(session.sessionId);
44+
addSession(workspaces, sessions, session, rootPath);
45+
}
46+
3547
const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [
3648
{
3749
name: 'Claude Code',
3850
collectSync(ctx) {
51+
const seenSessionIds = new Set<string>();
3952
for (const claudeDir of findClaudeDirs()) {
4053
for (const { sessions } of parseClaudeSessions(claudeDir)) {
41-
for (const session of sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir);
54+
for (const session of sessions) addClaudeSession(ctx.workspaces, ctx.sessions, seenSessionIds, session, claudeDir);
4255
}
4356
}
4457
},
4558
async collectAsync(ctx, reportDetail) {
59+
const seenSessionIds = new Set<string>();
4660
for (const claudeDir of findClaudeDirs()) {
4761
const results = await parseClaudeSessionsAsync(claudeDir, (idx, total, name) => {
4862
reportDetail?.(`${idx}/${total}: ${name}`);
4963
});
5064
for (const { sessions } of results) {
51-
for (const session of sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir);
65+
for (const session of sessions) addClaudeSession(ctx.workspaces, ctx.sessions, seenSessionIds, session, claudeDir);
5266
}
5367
}
5468
},

0 commit comments

Comments
 (0)