claude-attribution 1.8.0 → 1.9.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/README.md +67 -37
- package/package.json +2 -2
- package/src/__tests__/claude-projects.test.ts +42 -0
- package/src/__tests__/copilot-session.test.ts +141 -0
- package/src/__tests__/differ.test.ts +14 -2
- package/src/__tests__/git-notes.test.ts +28 -0
- package/src/__tests__/installed-repos.test.ts +2 -1
- package/src/__tests__/integration-helpers.ts +12 -1
- package/src/__tests__/integration.test.ts +140 -1
- package/src/__tests__/runtime.test.ts +12 -1
- package/src/__tests__/transcript.test.ts +61 -0
- package/src/attribution/commit.ts +68 -39
- package/src/attribution/differ.ts +76 -20
- package/src/attribution/git-notes.ts +26 -0
- package/src/attribution/runtime.ts +8 -7
- package/src/metrics/claude-projects.ts +48 -0
- package/src/metrics/collect.ts +113 -57
- package/src/metrics/copilot-session.ts +383 -0
- package/src/metrics/local-session.ts +12 -0
- package/src/metrics/session-metrics.ts +22 -0
- package/src/metrics/transcript.ts +56 -17
- package/src/setup/installed-repos.ts +4 -4
- package/src/setup/uninstall.ts +7 -2
|
@@ -15,11 +15,22 @@ describe("detectAssistantRuntime", () => {
|
|
|
15
15
|
});
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
test("
|
|
18
|
+
test("detects Claude runtime without treating alias defaults as the active model", () => {
|
|
19
19
|
expect(
|
|
20
20
|
detectAssistantRuntime({
|
|
21
21
|
ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-4-6",
|
|
22
22
|
}),
|
|
23
|
+
).toEqual({
|
|
24
|
+
vendor: "claude",
|
|
25
|
+
client: "Claude Code",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("uses ANTHROPIC_MODEL when the active model is explicit", () => {
|
|
30
|
+
expect(
|
|
31
|
+
detectAssistantRuntime({
|
|
32
|
+
ANTHROPIC_MODEL: "claude-sonnet-4-6",
|
|
33
|
+
}),
|
|
23
34
|
).toEqual({
|
|
24
35
|
vendor: "claude",
|
|
25
36
|
client: "Claude Code",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createTempContext, writeTranscript } from "./integration-helpers.ts";
|
|
3
|
+
import { parseTranscript } from "../metrics/transcript.ts";
|
|
4
|
+
|
|
5
|
+
describe("parseTranscript", () => {
|
|
6
|
+
test("counts real user prompts and ignores tool_result rows", async () => {
|
|
7
|
+
const ctx = await createTempContext("transcript-parser");
|
|
8
|
+
const originalHome = process.env.HOME;
|
|
9
|
+
try {
|
|
10
|
+
process.env.HOME = ctx.home;
|
|
11
|
+
const repo = "/Users/alice.smith/Code/my-repo";
|
|
12
|
+
const sessionId = "session-1";
|
|
13
|
+
await writeTranscript(ctx.home, repo, sessionId, [
|
|
14
|
+
{
|
|
15
|
+
type: "user",
|
|
16
|
+
timestamp: "2026-04-01T10:00:00.000Z",
|
|
17
|
+
message: { role: "user", content: "Add a helper" },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
type: "assistant",
|
|
21
|
+
timestamp: "2026-04-01T10:00:05.000Z",
|
|
22
|
+
message: {
|
|
23
|
+
role: "assistant",
|
|
24
|
+
model: "claude-sonnet-4-6",
|
|
25
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
26
|
+
content: [{ type: "tool_use", name: "WebSearch", input: {} }],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
type: "user",
|
|
31
|
+
timestamp: "2026-04-01T10:00:07.000Z",
|
|
32
|
+
toolUseResult: { tool: "WebSearch" },
|
|
33
|
+
message: {
|
|
34
|
+
role: "user",
|
|
35
|
+
content: [{ type: "tool_result" }],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: "assistant",
|
|
40
|
+
timestamp: "2026-04-01T10:00:12.000Z",
|
|
41
|
+
message: {
|
|
42
|
+
role: "assistant",
|
|
43
|
+
model: "claude-sonnet-4-6",
|
|
44
|
+
usage: { input_tokens: 12, output_tokens: 8 },
|
|
45
|
+
content: [{ type: "text" }],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const result = await parseTranscript(sessionId, repo);
|
|
51
|
+
expect(result).not.toBeNull();
|
|
52
|
+
expect(result?.humanPromptCount).toBe(1);
|
|
53
|
+
expect(result?.toolCounts).toEqual({ WebSearch: 1 });
|
|
54
|
+
expect(result?.totals.totalCalls).toBe(2);
|
|
55
|
+
expect(result?.aiMinutes).toBe(0);
|
|
56
|
+
} finally {
|
|
57
|
+
process.env.HOME = originalHome;
|
|
58
|
+
await ctx.cleanup();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -31,11 +31,13 @@ import {
|
|
|
31
31
|
attributeLines,
|
|
32
32
|
aggregateTotals,
|
|
33
33
|
type AttributionResult,
|
|
34
|
+
type AssistantRuntimeInfo,
|
|
34
35
|
type FileAttribution,
|
|
35
36
|
type LineAttribution,
|
|
36
37
|
hashLine,
|
|
37
38
|
} from "./differ.ts";
|
|
38
|
-
import {
|
|
39
|
+
import { resolveCopilotSessionId } from "../metrics/copilot-session.ts";
|
|
40
|
+
import { parseLocalSession } from "../metrics/local-session.ts";
|
|
39
41
|
import { detectAssistantRuntime } from "./runtime.ts";
|
|
40
42
|
import {
|
|
41
43
|
buildSessionMetrics,
|
|
@@ -88,47 +90,66 @@ import {
|
|
|
88
90
|
async function resolveSessionForCommit(
|
|
89
91
|
repoRoot: string,
|
|
90
92
|
changedFiles: string[],
|
|
93
|
+
branch: string | null,
|
|
91
94
|
): Promise<string | null> {
|
|
92
95
|
const fromCurrentSession = await readCurrentSession(repoRoot);
|
|
93
96
|
if (fromCurrentSession) return fromCurrentSession;
|
|
94
97
|
|
|
95
98
|
const toolLog = join(repoRoot, ".claude", "logs", "tool-usage.jsonl");
|
|
96
|
-
if (
|
|
99
|
+
if (existsSync(toolLog)) {
|
|
100
|
+
try {
|
|
101
|
+
const raw = await readFile(toolLog, "utf8");
|
|
102
|
+
const EIGHT_HOURS = 8 * 60 * 60 * 1000;
|
|
103
|
+
const recentSessions: string[] = [];
|
|
104
|
+
const seen = new Set<string>();
|
|
105
|
+
for (const line of raw.trim().split("\n").filter(Boolean).reverse()) {
|
|
106
|
+
const entry = JSON.parse(line) as { session?: string; timestamp?: string };
|
|
107
|
+
if (!entry.session || !entry.timestamp || seen.has(entry.session)) continue;
|
|
108
|
+
const age = Date.now() - new Date(entry.timestamp).getTime();
|
|
109
|
+
if (age >= EIGHT_HOURS) continue;
|
|
110
|
+
seen.add(entry.session);
|
|
111
|
+
recentSessions.push(entry.session);
|
|
112
|
+
}
|
|
113
|
+
if (recentSessions.length > 0) {
|
|
114
|
+
if (changedFiles.length > 0) {
|
|
115
|
+
const changedAbsPaths = changedFiles.map((relPath) => join(repoRoot, relPath));
|
|
116
|
+
for (const candidate of recentSessions) {
|
|
117
|
+
const hasMatchingCheckpoint = await Promise.all(
|
|
118
|
+
changedAbsPaths.map((absPath) =>
|
|
119
|
+
loadCheckpoint(candidate, absPath, "after")
|
|
120
|
+
.then((checkpoint) => checkpoint !== null)
|
|
121
|
+
.catch(() => false),
|
|
122
|
+
),
|
|
123
|
+
).then((matches) => matches.some(Boolean));
|
|
124
|
+
if (hasMatchingCheckpoint) return candidate;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
97
127
|
|
|
98
|
-
|
|
99
|
-
const raw = await readFile(toolLog, "utf8");
|
|
100
|
-
const EIGHT_HOURS = 8 * 60 * 60 * 1000;
|
|
101
|
-
const recentSessions: string[] = [];
|
|
102
|
-
const seen = new Set<string>();
|
|
103
|
-
for (const line of raw.trim().split("\n").filter(Boolean).reverse()) {
|
|
104
|
-
const entry = JSON.parse(line) as { session?: string; timestamp?: string };
|
|
105
|
-
if (!entry.session || !entry.timestamp || seen.has(entry.session)) continue;
|
|
106
|
-
const age = Date.now() - new Date(entry.timestamp).getTime();
|
|
107
|
-
if (age >= EIGHT_HOURS) continue;
|
|
108
|
-
seen.add(entry.session);
|
|
109
|
-
recentSessions.push(entry.session);
|
|
110
|
-
}
|
|
111
|
-
if (recentSessions.length === 0) return null;
|
|
112
|
-
|
|
113
|
-
if (changedFiles.length > 0) {
|
|
114
|
-
const changedAbsPaths = changedFiles.map((relPath) => join(repoRoot, relPath));
|
|
115
|
-
for (const candidate of recentSessions) {
|
|
116
|
-
const hasMatchingCheckpoint = await Promise.all(
|
|
117
|
-
changedAbsPaths.map((absPath) =>
|
|
118
|
-
loadCheckpoint(candidate, absPath, "after")
|
|
119
|
-
.then((checkpoint) => checkpoint !== null)
|
|
120
|
-
.catch(() => false),
|
|
121
|
-
),
|
|
122
|
-
).then((matches) => matches.some(Boolean));
|
|
123
|
-
if (hasMatchingCheckpoint) return candidate;
|
|
128
|
+
return recentSessions[0] ?? null;
|
|
124
129
|
}
|
|
130
|
+
} catch {
|
|
131
|
+
// Non-fatal
|
|
125
132
|
}
|
|
133
|
+
}
|
|
126
134
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
return await resolveCopilotSessionId(repoRoot, branch).catch(() => null);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function mergeAssistantRuntime(
|
|
139
|
+
runtime: AssistantRuntimeInfo | undefined,
|
|
140
|
+
provider: "claude" | "copilot" | undefined,
|
|
141
|
+
transcriptModels: { modelFull: string }[],
|
|
142
|
+
): AssistantRuntimeInfo | undefined {
|
|
143
|
+
if (provider === "copilot") {
|
|
144
|
+
return {
|
|
145
|
+
vendor: "copilot",
|
|
146
|
+
client: runtime?.client ?? "GitHub Copilot CLI",
|
|
147
|
+
clientVersion: runtime?.clientVersion,
|
|
148
|
+
modelFamily: transcriptModels[0]?.modelFull ?? runtime?.modelFamily,
|
|
149
|
+
};
|
|
130
150
|
}
|
|
131
|
-
|
|
151
|
+
|
|
152
|
+
return runtime;
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
async function main() {
|
|
@@ -143,7 +164,7 @@ async function main() {
|
|
|
143
164
|
.catch(() => null as string | null),
|
|
144
165
|
renamedFilesInCommit(repoRoot),
|
|
145
166
|
]);
|
|
146
|
-
const sessionId = await resolveSessionForCommit(repoRoot, changedFiles);
|
|
167
|
+
const sessionId = await resolveSessionForCommit(repoRoot, changedFiles, branch);
|
|
147
168
|
|
|
148
169
|
// Process files in parallel — each file attribution is independent.
|
|
149
170
|
// Return type includes attribution[] so the minimap block can build currentAiHashes.
|
|
@@ -227,16 +248,14 @@ async function main() {
|
|
|
227
248
|
files: fileResults,
|
|
228
249
|
totals: aggregateTotals(fileResults),
|
|
229
250
|
};
|
|
230
|
-
const assistantRuntime = detectAssistantRuntime();
|
|
231
|
-
if (assistantRuntime) {
|
|
232
|
-
result.assistantRuntime = assistantRuntime;
|
|
233
|
-
}
|
|
234
251
|
const notesRefsToSync = [NOTES_REF];
|
|
235
252
|
|
|
236
|
-
|
|
253
|
+
const detectedRuntime = detectAssistantRuntime();
|
|
254
|
+
|
|
255
|
+
// Attach session usage metadata (non-fatal if unavailable)
|
|
237
256
|
if (sessionId) {
|
|
238
257
|
const [tx, toolEntries, agentEntries] = await Promise.all([
|
|
239
|
-
|
|
258
|
+
parseLocalSession(sessionId, repoRoot).catch(() => null),
|
|
240
259
|
readJsonlForSession<ToolLogEntry>(
|
|
241
260
|
join(repoRoot, ".claude", "logs", "tool-usage.jsonl"),
|
|
242
261
|
sessionId,
|
|
@@ -246,6 +265,14 @@ async function main() {
|
|
|
246
265
|
sessionId,
|
|
247
266
|
).catch(() => []),
|
|
248
267
|
]);
|
|
268
|
+
const mergedRuntime = mergeAssistantRuntime(
|
|
269
|
+
detectedRuntime ?? undefined,
|
|
270
|
+
tx?.provider,
|
|
271
|
+
tx?.byModel ?? [],
|
|
272
|
+
);
|
|
273
|
+
if (mergedRuntime) {
|
|
274
|
+
result.assistantRuntime = mergedRuntime;
|
|
275
|
+
}
|
|
249
276
|
if (tx && tx.byModel.length > 0) {
|
|
250
277
|
result.modelUsage = tx.byModel.map((m) => ({
|
|
251
278
|
modelFull: m.modelFull,
|
|
@@ -261,6 +288,8 @@ async function main() {
|
|
|
261
288
|
if (hasSessionMetrics(sessionMetrics)) {
|
|
262
289
|
result.sessionMetrics = sessionMetrics;
|
|
263
290
|
}
|
|
291
|
+
} else if (detectedRuntime) {
|
|
292
|
+
result.assistantRuntime = detectedRuntime;
|
|
264
293
|
}
|
|
265
294
|
|
|
266
295
|
// Write git note
|
|
@@ -85,8 +85,9 @@ export function hashLine(line: string): string {
|
|
|
85
85
|
* Rules:
|
|
86
86
|
* - AI: hash present in afterHashes but NOT in beforeHashes
|
|
87
87
|
* → Claude wrote this line and it survived to the commit unchanged
|
|
88
|
-
* - MIXED:
|
|
89
|
-
*
|
|
88
|
+
* - MIXED: committed content sits in a one-for-one replacement span anchored
|
|
89
|
+
* by exact surrounding matches, and the replaced after-snapshot line
|
|
90
|
+
* was AI-authored
|
|
90
91
|
* → Claude wrote it, human modified it before committing
|
|
91
92
|
* - HUMAN: everything else (existed before Claude, or written after Claude
|
|
92
93
|
* finished without a checkpoint)
|
|
@@ -100,13 +101,11 @@ export function hashLine(line: string): string {
|
|
|
100
101
|
* This is conservative toward AI for identical content — an acceptable trade-off
|
|
101
102
|
* given that truly identical lines are indistinguishable without positional history.
|
|
102
103
|
*
|
|
103
|
-
* **MIXED detection limitation**: MIXED
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* small in-place tweaks (e.g., changing a value on a line Claude wrote) rather
|
|
109
|
-
* than bulk insertions or deletions that shift line numbers.
|
|
104
|
+
* **MIXED detection limitation**: MIXED is only inferred for replacement spans
|
|
105
|
+
* where we can align the after-snapshot and committed file with exact matching
|
|
106
|
+
* anchors around the changed region. Insertions/deletions without stable
|
|
107
|
+
* anchors are treated as HUMAN to avoid false positives that would reduce the
|
|
108
|
+
* reported AI percentage for otherwise all-Claude changes.
|
|
110
109
|
*/
|
|
111
110
|
export function attributeLines(
|
|
112
111
|
beforeLines: string[],
|
|
@@ -115,10 +114,10 @@ export function attributeLines(
|
|
|
115
114
|
): { attribution: LineAttribution[]; stats: FileAttribution } {
|
|
116
115
|
const beforeHashes = new Set(beforeLines.map(hashLine));
|
|
117
116
|
const afterHashes = new Set(afterLines.map(hashLine));
|
|
118
|
-
// Positional index: line index → hash (for MIXED detection)
|
|
119
117
|
const afterByIndex = afterLines.map(hashLine);
|
|
118
|
+
const committedByIndex = committedLines.map(hashLine);
|
|
120
119
|
|
|
121
|
-
const attribution: LineAttribution[] = committedLines.map((line
|
|
120
|
+
const attribution: LineAttribution[] = committedLines.map((line) => {
|
|
122
121
|
const hash = hashLine(line);
|
|
123
122
|
|
|
124
123
|
// Blank lines carry no signal
|
|
@@ -128,18 +127,75 @@ export function attributeLines(
|
|
|
128
127
|
return "AI";
|
|
129
128
|
}
|
|
130
129
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
130
|
+
return "HUMAN";
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
function markMixedRange(
|
|
134
|
+
afterStart: number,
|
|
135
|
+
afterEnd: number,
|
|
136
|
+
committedStart: number,
|
|
137
|
+
committedEnd: number,
|
|
138
|
+
): void {
|
|
139
|
+
while (
|
|
140
|
+
afterStart < afterEnd &&
|
|
141
|
+
committedStart < committedEnd &&
|
|
142
|
+
afterByIndex[afterStart] === committedByIndex[committedStart]
|
|
137
143
|
) {
|
|
138
|
-
|
|
144
|
+
afterStart++;
|
|
145
|
+
committedStart++;
|
|
139
146
|
}
|
|
140
147
|
|
|
141
|
-
|
|
142
|
-
|
|
148
|
+
while (
|
|
149
|
+
afterStart < afterEnd &&
|
|
150
|
+
committedStart < committedEnd &&
|
|
151
|
+
afterByIndex[afterEnd - 1] === committedByIndex[committedEnd - 1]
|
|
152
|
+
) {
|
|
153
|
+
afterEnd--;
|
|
154
|
+
committedEnd--;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (afterStart >= afterEnd || committedStart >= committedEnd) return;
|
|
158
|
+
|
|
159
|
+
const nextAfterByHash = new Map<string, number>();
|
|
160
|
+
for (let i = afterStart; i < afterEnd; i++) {
|
|
161
|
+
const hash = afterByIndex[i];
|
|
162
|
+
if (hash !== undefined && !nextAfterByHash.has(hash)) {
|
|
163
|
+
nextAfterByHash.set(hash, i);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (let i = committedStart; i < committedEnd; i++) {
|
|
168
|
+
const hash = committedByIndex[i];
|
|
169
|
+
if (hash === undefined) continue;
|
|
170
|
+
const afterMatch = nextAfterByHash.get(hash);
|
|
171
|
+
if (afterMatch === undefined) continue;
|
|
172
|
+
|
|
173
|
+
markMixedRange(afterStart, afterMatch, committedStart, i);
|
|
174
|
+
markMixedRange(afterMatch + 1, afterEnd, i + 1, committedEnd);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (afterEnd - afterStart !== committedEnd - committedStart) return;
|
|
179
|
+
|
|
180
|
+
for (let offset = 0; offset < committedEnd - committedStart; offset++) {
|
|
181
|
+
const committedIndex = committedStart + offset;
|
|
182
|
+
const afterHash = afterByIndex[afterStart + offset];
|
|
183
|
+
const committedLine = committedLines[committedIndex];
|
|
184
|
+
if (
|
|
185
|
+
afterHash === undefined ||
|
|
186
|
+
committedLine === undefined ||
|
|
187
|
+
committedLine.trim() === "" ||
|
|
188
|
+
attribution[committedIndex] === "AI" ||
|
|
189
|
+
beforeHashes.has(afterHash)
|
|
190
|
+
) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
attribution[committedIndex] = "MIXED";
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
markMixedRange(0, afterLines.length, 0, committedLines.length);
|
|
143
199
|
|
|
144
200
|
const ai = attribution.filter((a) => a === "AI").length;
|
|
145
201
|
const human = attribution.filter((a) => a === "HUMAN").length;
|
|
@@ -15,6 +15,7 @@ import { execFile } from "child_process";
|
|
|
15
15
|
import { promisify } from "util";
|
|
16
16
|
import {
|
|
17
17
|
aggregateTotals,
|
|
18
|
+
type AssistantRuntimeInfo,
|
|
18
19
|
type AttributionResult,
|
|
19
20
|
type FileAttribution,
|
|
20
21
|
} from "./differ.ts";
|
|
@@ -292,6 +293,30 @@ export function isKnownAiActorCommit(meta: CommitMeta): boolean {
|
|
|
292
293
|
return false;
|
|
293
294
|
}
|
|
294
295
|
|
|
296
|
+
function inferAiActorRuntime(meta: CommitMeta): AssistantRuntimeInfo | undefined {
|
|
297
|
+
const { authorName, authorEmail, message } = meta;
|
|
298
|
+
const combined = `${authorName}\n${authorEmail}\n${message}`.toLowerCase();
|
|
299
|
+
if (combined.includes("copilot")) {
|
|
300
|
+
return {
|
|
301
|
+
vendor: "copilot",
|
|
302
|
+
client: "GitHub Copilot",
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (combined.includes("claude")) {
|
|
306
|
+
return {
|
|
307
|
+
vendor: "claude",
|
|
308
|
+
client: "Claude Code",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (authorEmail.includes("[bot]") || authorName.includes("[bot]")) {
|
|
312
|
+
return {
|
|
313
|
+
vendor: "unknown",
|
|
314
|
+
client: authorName || "AI agent",
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
295
320
|
/**
|
|
296
321
|
* Build a 100% AI AttributionResult for a commit without running the
|
|
297
322
|
* checkpoint-based differ. All non-blank committed lines are marked AI.
|
|
@@ -336,5 +361,6 @@ export async function buildAllAiResult(
|
|
|
336
361
|
timestamp: meta.timestamp,
|
|
337
362
|
files: fileResults,
|
|
338
363
|
totals: aggregateTotals(fileResults),
|
|
364
|
+
assistantRuntime: inferAiActorRuntime(meta),
|
|
339
365
|
};
|
|
340
366
|
}
|
|
@@ -17,16 +17,17 @@ export function detectAssistantRuntime(
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
const anthropicModel =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
firstNonEmpty(env["
|
|
24
|
-
firstNonEmpty(env["
|
|
25
|
-
|
|
20
|
+
const anthropicModel = firstNonEmpty(env["ANTHROPIC_MODEL"]);
|
|
21
|
+
const hasClaudeRuntimeHint =
|
|
22
|
+
anthropicModel !== undefined ||
|
|
23
|
+
firstNonEmpty(env["ANTHROPIC_DEFAULT_OPUS_MODEL"]) !== undefined ||
|
|
24
|
+
firstNonEmpty(env["ANTHROPIC_DEFAULT_SONNET_MODEL"]) !== undefined ||
|
|
25
|
+
firstNonEmpty(env["ANTHROPIC_DEFAULT_HAIKU_MODEL"]) !== undefined;
|
|
26
|
+
if (hasClaudeRuntimeHint) {
|
|
26
27
|
return {
|
|
27
28
|
vendor: "claude",
|
|
28
29
|
client: "Claude Code",
|
|
29
|
-
modelFamily: anthropicModel,
|
|
30
|
+
...(anthropicModel ? { modelFamily: anthropicModel } : {}),
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { readdir } from "fs/promises";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join, resolve } from "path";
|
|
5
|
+
|
|
6
|
+
function legacyProjectKey(repoRoot: string): string {
|
|
7
|
+
return resolve(repoRoot).replace(/\//g, "-");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function sanitizeProjectKey(repoRoot: string): string {
|
|
11
|
+
return resolve(repoRoot).replace(/[^A-Za-z0-9-]/g, "-");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function canonicalProjectKey(value: string): string {
|
|
15
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function claudeProjectKey(repoRoot: string): string {
|
|
19
|
+
return sanitizeProjectKey(repoRoot);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function claudeProjectsRoot(
|
|
23
|
+
homeDir = process.env["HOME"] ?? homedir(),
|
|
24
|
+
): string {
|
|
25
|
+
return join(homeDir, ".claude", "projects");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function resolveClaudeProjectDir(
|
|
29
|
+
repoRoot: string,
|
|
30
|
+
projectsRoot = claudeProjectsRoot(),
|
|
31
|
+
): Promise<string | null> {
|
|
32
|
+
for (const key of new Set([claudeProjectKey(repoRoot), legacyProjectKey(repoRoot)])) {
|
|
33
|
+
const dir = join(projectsRoot, key);
|
|
34
|
+
if (existsSync(dir)) return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!existsSync(projectsRoot)) return null;
|
|
38
|
+
|
|
39
|
+
const expected = canonicalProjectKey(resolve(repoRoot));
|
|
40
|
+
for (const entry of await readdir(projectsRoot, { withFileTypes: true })) {
|
|
41
|
+
if (!entry.isDirectory()) continue;
|
|
42
|
+
if (canonicalProjectKey(entry.name) === expected) {
|
|
43
|
+
return join(projectsRoot, entry.name);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
}
|