claude-attribution 1.0.2 → 1.1.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 +47 -19
- package/package.json +1 -1
- package/src/cli.ts +7 -35
- package/src/hooks/post-bash.ts +125 -0
- package/src/metrics/collect.ts +33 -9
- package/src/setup/install.ts +41 -83
- package/src/setup/templates/hooks.json +9 -0
- package/src/setup/templates/pr-metrics-workflow.yml +84 -0
- package/src/setup/uninstall.ts +11 -0
- package/src/setup/templates/pre-push.sh +0 -4
package/README.md
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
> ```bash
|
|
7
7
|
> npm install -g claude-attribution
|
|
8
8
|
> claude-attribution install ~/Code/your-repo
|
|
9
|
-
> git add .claude/settings.json .gitignore && git commit -m "chore: install claude-attribution hooks"
|
|
9
|
+
> git add .claude/settings.json .github/workflows/claude-attribution-pr.yml .gitignore && git commit -m "chore: install claude-attribution hooks"
|
|
10
10
|
> ```
|
|
11
|
-
> From then on, just work normally. After each `git commit` you'll see a one-line attribution summary in your terminal. When you
|
|
11
|
+
> From then on, just work normally. After each `git commit` you'll see a one-line attribution summary in your terminal. When you open a PR — whether Claude creates it or you run `gh pr create` yourself — metrics are injected into the PR body automatically, no command needed.
|
|
12
12
|
>
|
|
13
|
-
> **Using Copilot?** The tool still works for tracking Claude usage alongside Copilot. Copilot line-level attribution isn't supported yet — for Copilot-specific stats, use the
|
|
13
|
+
> **Using Copilot?** The tool still works for tracking Claude usage alongside Copilot. Copilot line-level attribution isn't supported yet — for Copilot-specific stats, use the GitHub Copilot usage dashboard under your organization's Settings → Copilot. Both tools' org-level data flows into the VP Datadog dashboard automatically on every PR merge.
|
|
14
14
|
>
|
|
15
|
-
> **Requirements:** [Bun](https://bun.sh) (preferred) or Node 18+, and `gh` (GitHub CLI) authenticated
|
|
15
|
+
> **Requirements:** [Bun](https://bun.sh) (preferred) or Node 18+, and `gh` (GitHub CLI) authenticated.
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
@@ -56,20 +56,23 @@ claude-attribution install ~/Code/your-repo
|
|
|
56
56
|
bun ~/Code/claude-attribution/src/setup/install.ts ~/Code/your-repo
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
The installer makes
|
|
59
|
+
The installer makes six changes to the target repo:
|
|
60
60
|
|
|
61
|
-
**`.claude/settings.json`** — merges
|
|
61
|
+
**`.claude/settings.json`** — merges six Claude Code hooks:
|
|
62
62
|
|
|
63
63
|
| Event | Hook | What it does |
|
|
64
64
|
|-------|------|--------------|
|
|
65
65
|
| PreToolUse (Edit/Write/MultiEdit/NotebookEdit) | `pre-tool-use.ts` | Snapshot file content before Claude touches it |
|
|
66
66
|
| PostToolUse (all tools) | `post-tool-use.ts` | Snapshot file after Claude writes + log tool call |
|
|
67
|
+
| PostToolUse (Bash) | `post-bash.ts` | Detect `gh pr create` and inject metrics into the new PR |
|
|
67
68
|
| SubagentStart / SubagentStop | `subagent.ts` | Log subagent activity |
|
|
68
69
|
| Stop | `stop.ts` | No-op; registered for future use |
|
|
69
70
|
|
|
70
71
|
**`.git/hooks/post-commit`** — runs attribution after every commit. If the repo already has a `post-commit` hook from Husky or another tool, the call is appended rather than replacing it. For Lefthook repos, the installer prints the config snippet to add manually.
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
**`remote.origin.push` refspec** — the installer runs `git config --add remote.origin.push refs/notes/claude-attribution:refs/notes/claude-attribution` so that `git push` (without an explicit refspec) automatically includes attribution notes. No pre-push hook is installed — a hook that pushes notes concurrently with the main push causes SSH connection conflicts on GitHub.
|
|
74
|
+
|
|
75
|
+
**`.github/workflows/claude-attribution-pr.yml`** — GitHub Actions workflow that fires on every PR open and push. Injects metrics into the PR body automatically for PRs created outside Claude (Copilot, manual `gh pr create`, GitHub UI). Skips injection if the local `post-bash` hook already injected metrics on `opened`; always updates on `synchronize` (new commits).
|
|
73
76
|
|
|
74
77
|
**`.claude/commands/`** — installs two slash commands:
|
|
75
78
|
- `/metrics` — generate a PR metrics report
|
|
@@ -79,11 +82,11 @@ The installer makes four changes to the target repo:
|
|
|
79
82
|
|
|
80
83
|
### Committing the settings change
|
|
81
84
|
|
|
82
|
-
The `.claude/settings.json`
|
|
85
|
+
The `.claude/settings.json` and workflow changes should be committed so all developers get the hooks and all PRs get metrics automatically.
|
|
83
86
|
|
|
84
87
|
```bash
|
|
85
88
|
# After running the installer:
|
|
86
|
-
git add .claude/settings.json .gitignore
|
|
89
|
+
git add .claude/settings.json .github/workflows/claude-attribution-pr.yml .gitignore
|
|
87
90
|
git commit -m "chore: install claude-attribution hooks"
|
|
88
91
|
```
|
|
89
92
|
|
|
@@ -111,7 +114,7 @@ claude-attribution uninstall ~/Code/your-repo
|
|
|
111
114
|
bun ~/Code/claude-attribution/src/setup/uninstall.ts ~/Code/your-repo
|
|
112
115
|
```
|
|
113
116
|
|
|
114
|
-
This removes hooks from `.claude/settings.json`, removes `.git/hooks/post-commit
|
|
117
|
+
This removes hooks from `.claude/settings.json`, removes `.git/hooks/post-commit`, removes the slash commands, removes `.github/workflows/claude-attribution-pr.yml`, and removes any legacy `pre-push` hooks (for example `.husky/pre-push` or `.git/hooks/pre-push`) if present. Attribution state (`.claude/attribution-state/`) and logs (`.claude/logs/`) are left in place. The `remote.origin.push` refspec is also removed from git config.
|
|
115
118
|
|
|
116
119
|
---
|
|
117
120
|
|
|
@@ -137,24 +140,49 @@ When you check out a new branch for a new ticket, run `/start` in Claude Code:
|
|
|
137
140
|
|
|
138
141
|
This writes a timestamp to `.claude/attribution-state/session-start`. The `/metrics` command uses this to scope tool counts, token usage, and attribution data to only the activity after this marker — so a long-running Claude Code session doesn't inflate metrics across multiple tickets.
|
|
139
142
|
|
|
140
|
-
###
|
|
143
|
+
### Getting metrics into your PR
|
|
141
144
|
|
|
142
|
-
|
|
145
|
+
Metrics are injected automatically — no command needed.
|
|
143
146
|
|
|
147
|
+
**When Claude creates the PR** (asks Claude to open a PR, uses `/pr`): the `post-bash` hook fires immediately after `gh pr create` succeeds, injects full local metrics (token usage, tool counts, attribution) into the PR body before Claude continues.
|
|
148
|
+
|
|
149
|
+
**When you create the PR yourself** (`gh pr create`, GitHub UI, Copilot): the `claude-attribution-pr.yml` GitHub Actions workflow fires on `opened` and injects attribution-only metrics (token counts aren't available in CI since logs are local).
|
|
150
|
+
|
|
151
|
+
**On every new push to an open PR**: the workflow fires on `synchronize` and updates the attribution percentages to reflect new commits.
|
|
152
|
+
|
|
153
|
+
The metrics block looks like:
|
|
154
|
+
|
|
155
|
+
```markdown
|
|
156
|
+
## Claude Code Metrics
|
|
157
|
+
|
|
158
|
+
**AI contribution: ~77%** (142 of 184 committed lines) · Active: 8m
|
|
159
|
+
|
|
160
|
+
| Model | Calls | Input | Output | Cache |
|
|
161
|
+
|-------|-------|-------|--------|-------|
|
|
162
|
+
| Sonnet | 45 | 120K | 35K | 10K |
|
|
163
|
+
| **Total** | 45 | 120K | 35K | 10K |
|
|
164
|
+
|
|
165
|
+
**Human prompts (steering effort):** 12
|
|
166
|
+
|
|
167
|
+
<details>
|
|
168
|
+
<summary>Tools · Agents · Files</summary>
|
|
169
|
+
|
|
170
|
+
**Tools:** Edit ×47, Read ×31, Bash ×12
|
|
171
|
+
...
|
|
172
|
+
</details>
|
|
144
173
|
```
|
|
145
|
-
/pr "feat: COMM-1234 add user authentication"
|
|
146
|
-
```
|
|
147
174
|
|
|
148
|
-
|
|
175
|
+
#### Manual option
|
|
176
|
+
|
|
177
|
+
If you need to create a PR with metrics outside of Claude, use the `/pr` slash command or CLI directly:
|
|
149
178
|
|
|
150
179
|
```bash
|
|
151
180
|
claude-attribution pr "feat: COMM-1234 add user authentication"
|
|
152
|
-
claude-attribution pr "feat: my feature" --draft
|
|
153
|
-
claude-attribution pr "feat: my feature" --base develop
|
|
154
|
-
claude-attribution pr # Title from branch name
|
|
181
|
+
claude-attribution pr "feat: my feature" --draft
|
|
182
|
+
claude-attribution pr "feat: my feature" --base develop
|
|
155
183
|
```
|
|
156
184
|
|
|
157
|
-
|
|
185
|
+
Requires `gh` to be installed and authenticated (`gh auth status`).
|
|
158
186
|
|
|
159
187
|
### Viewing metrics without creating a PR
|
|
160
188
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -8,10 +8,7 @@
|
|
|
8
8
|
import { resolve, dirname } from "path";
|
|
9
9
|
import { fileURLToPath } from "url";
|
|
10
10
|
import { readFile } from "fs/promises";
|
|
11
|
-
import { execFile } from "child_process";
|
|
12
|
-
import { promisify } from "util";
|
|
13
11
|
|
|
14
|
-
const execFileAsync = promisify(execFile);
|
|
15
12
|
const [, , cmd, ...rest] = process.argv;
|
|
16
13
|
|
|
17
14
|
// Shift argv so sub-modules see their expected args at process.argv[2]
|
|
@@ -47,43 +44,18 @@ switch (cmd) {
|
|
|
47
44
|
case "stop":
|
|
48
45
|
await import("./hooks/stop.ts");
|
|
49
46
|
break;
|
|
47
|
+
case "post-bash":
|
|
48
|
+
await import("./hooks/post-bash.ts");
|
|
49
|
+
break;
|
|
50
50
|
case "post-commit":
|
|
51
51
|
await import("./attribution/commit.ts");
|
|
52
52
|
break;
|
|
53
|
-
case "pre-push":
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
let data = "";
|
|
58
|
-
process.stdin.setEncoding("utf8");
|
|
59
|
-
process.stdin.on("data", (chunk: string) => (data += chunk));
|
|
60
|
-
process.stdin.on("end", () => resolve(data));
|
|
61
|
-
});
|
|
62
|
-
const isBranchPush = stdin
|
|
63
|
-
.trim()
|
|
64
|
-
.split("\n")
|
|
65
|
-
.filter(Boolean)
|
|
66
|
-
.some((line) => {
|
|
67
|
-
const remoteRef = line.split(" ")[2] ?? "";
|
|
68
|
-
return (
|
|
69
|
-
!remoteRef.startsWith("refs/tags/") &&
|
|
70
|
-
!remoteRef.startsWith("refs/notes/")
|
|
71
|
-
);
|
|
72
|
-
});
|
|
73
|
-
if (isBranchPush) {
|
|
74
|
-
try {
|
|
75
|
-
await execFileAsync("git", [
|
|
76
|
-
"push",
|
|
77
|
-
"origin",
|
|
78
|
-
"refs/notes/claude-attribution",
|
|
79
|
-
]);
|
|
80
|
-
} catch {
|
|
81
|
-
// Ignore — notes push failure must not block git push
|
|
82
|
-
}
|
|
83
|
-
}
|
|
53
|
+
case "pre-push":
|
|
54
|
+
// No-op — notes are pushed via the remote.origin.push refspec configured
|
|
55
|
+
// at install time, avoiding concurrent SSH connection conflicts.
|
|
56
|
+
// Kept for backwards compatibility with existing hook installations.
|
|
84
57
|
process.exit(0);
|
|
85
58
|
break;
|
|
86
|
-
}
|
|
87
59
|
default:
|
|
88
60
|
console.error(`Unknown hook: ${rest[0]}`);
|
|
89
61
|
process.exit(1);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostToolUse hook for Bash tool — detects `gh pr create` and injects metrics.
|
|
3
|
+
*
|
|
4
|
+
* Registered in .claude/settings.json for matcher: "Bash"
|
|
5
|
+
* Fires after every Bash tool call; exits immediately if it isn't a PR creation.
|
|
6
|
+
*
|
|
7
|
+
* When Claude runs `gh pr create`, this hook:
|
|
8
|
+
* 1. Parses the PR number from the gh output URL
|
|
9
|
+
* 2. Collects metrics (attribution + tool counts + transcript)
|
|
10
|
+
* 3. Injects the rendered metrics block into the PR body via `gh pr edit`
|
|
11
|
+
*
|
|
12
|
+
* This provides full local metrics (tokens, tools, attribution) automatically,
|
|
13
|
+
* without requiring the developer to run any command. Falls back gracefully if
|
|
14
|
+
* gh isn't installed, the URL can't be parsed, or metrics are empty.
|
|
15
|
+
*/
|
|
16
|
+
import { execFile } from "child_process";
|
|
17
|
+
import { promisify } from "util";
|
|
18
|
+
import { resolve } from "path";
|
|
19
|
+
import { writeFile, unlink } from "fs/promises";
|
|
20
|
+
import { readStdin } from "../lib/hooks.ts";
|
|
21
|
+
import { collectMetrics, renderMetrics } from "../metrics/collect.ts";
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
24
|
+
|
|
25
|
+
interface HookPayload {
|
|
26
|
+
session_id: string;
|
|
27
|
+
tool_name: string;
|
|
28
|
+
tool_input: { command?: string };
|
|
29
|
+
tool_response?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractPrUrl(response: unknown): string | null {
|
|
33
|
+
const text =
|
|
34
|
+
typeof response === "string"
|
|
35
|
+
? response
|
|
36
|
+
: typeof response === "object" &&
|
|
37
|
+
response !== null &&
|
|
38
|
+
"output" in response
|
|
39
|
+
? String((response as { output: unknown }).output)
|
|
40
|
+
: "";
|
|
41
|
+
const match = text.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/);
|
|
42
|
+
return match ? match[0] : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function prNumberFromUrl(url: string): number | null {
|
|
46
|
+
const match = url.match(/\/pull\/(\d+)$/);
|
|
47
|
+
return match ? parseInt(match[1], 10) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function main() {
|
|
51
|
+
const raw = await readStdin();
|
|
52
|
+
if (!raw.trim()) process.exit(0);
|
|
53
|
+
|
|
54
|
+
let payload: HookPayload;
|
|
55
|
+
try {
|
|
56
|
+
payload = JSON.parse(raw) as HookPayload;
|
|
57
|
+
} catch {
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (payload.tool_name !== "Bash") process.exit(0);
|
|
62
|
+
|
|
63
|
+
const command = payload.tool_input?.command ?? "";
|
|
64
|
+
if (!command.includes("gh pr create")) process.exit(0);
|
|
65
|
+
|
|
66
|
+
const prUrl = extractPrUrl(payload.tool_response);
|
|
67
|
+
if (!prUrl) process.exit(0);
|
|
68
|
+
|
|
69
|
+
const prNumber = prNumberFromUrl(prUrl);
|
|
70
|
+
if (!prNumber) process.exit(0);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const repoRoot = resolve(process.cwd());
|
|
74
|
+
const metricsData = await collectMetrics(
|
|
75
|
+
payload.session_id || undefined,
|
|
76
|
+
repoRoot,
|
|
77
|
+
);
|
|
78
|
+
const metricsBlock = renderMetrics(metricsData);
|
|
79
|
+
if (!metricsBlock.trim()) process.exit(0);
|
|
80
|
+
|
|
81
|
+
const { stdout: prJson } = await execFileAsync("gh", [
|
|
82
|
+
"pr",
|
|
83
|
+
"view",
|
|
84
|
+
String(prNumber),
|
|
85
|
+
"--json",
|
|
86
|
+
"body",
|
|
87
|
+
]);
|
|
88
|
+
const { body = "" } = JSON.parse(prJson) as { body?: string };
|
|
89
|
+
|
|
90
|
+
const start = "<!-- claude-attribution metrics -->";
|
|
91
|
+
const end = "<!-- /claude-attribution metrics -->";
|
|
92
|
+
const block = `${start}\n${metricsBlock}\n${end}`;
|
|
93
|
+
|
|
94
|
+
let newBody: string;
|
|
95
|
+
if (body.includes(start) && body.includes(end)) {
|
|
96
|
+
const startIdx = body.indexOf(start);
|
|
97
|
+
const endIdx = body.indexOf(end) + end.length;
|
|
98
|
+
newBody = body.slice(0, startIdx) + block + body.slice(endIdx);
|
|
99
|
+
} else if (body.includes(start)) {
|
|
100
|
+
newBody = body.slice(0, body.indexOf(start)) + block;
|
|
101
|
+
} else {
|
|
102
|
+
newBody = body ? `${body}\n\n${block}` : block;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const tmpFile = `/tmp/claude-attribution-pr-body-${Date.now()}.md`;
|
|
106
|
+
await writeFile(tmpFile, newBody);
|
|
107
|
+
try {
|
|
108
|
+
await execFileAsync("gh", [
|
|
109
|
+
"pr",
|
|
110
|
+
"edit",
|
|
111
|
+
String(prNumber),
|
|
112
|
+
"--body-file",
|
|
113
|
+
tmpFile,
|
|
114
|
+
]);
|
|
115
|
+
} finally {
|
|
116
|
+
await unlink(tmpFile).catch(() => {});
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Non-fatal — never block Claude
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
process.exit(0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
main().catch(() => process.exit(0));
|
package/src/metrics/collect.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { readFile } from "fs/promises";
|
|
9
9
|
import { existsSync } from "fs";
|
|
10
10
|
import { resolve, join, relative } from "path";
|
|
11
|
+
import { execFile } from "child_process";
|
|
12
|
+
import { promisify } from "util";
|
|
11
13
|
import { parseTranscript, type TranscriptResult } from "./transcript.ts";
|
|
12
14
|
import {
|
|
13
15
|
listNotes,
|
|
@@ -21,6 +23,8 @@ import {
|
|
|
21
23
|
} from "../attribution/differ.ts";
|
|
22
24
|
import { SESSION_ID_RE } from "../attribution/checkpoint.ts";
|
|
23
25
|
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
|
|
24
28
|
export interface MetricsData {
|
|
25
29
|
repoRoot: string;
|
|
26
30
|
sessionId: string;
|
|
@@ -49,6 +53,30 @@ async function readSessionStart(repoRoot: string): Promise<Date | null> {
|
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
async function getBranchStartTime(repoRoot: string): Promise<Date | null> {
|
|
57
|
+
for (const ref of ["origin/HEAD", "origin/main", "origin/master"]) {
|
|
58
|
+
try {
|
|
59
|
+
const { stdout: base } = await execFileAsync(
|
|
60
|
+
"git",
|
|
61
|
+
["merge-base", "HEAD", ref],
|
|
62
|
+
{ cwd: repoRoot },
|
|
63
|
+
);
|
|
64
|
+
const forkPoint = base.trim();
|
|
65
|
+
if (!forkPoint) continue;
|
|
66
|
+
const { stdout: log } = await execFileAsync(
|
|
67
|
+
"git",
|
|
68
|
+
["log", "--reverse", "--format=%ct", `${forkPoint}..HEAD`],
|
|
69
|
+
{ cwd: repoRoot },
|
|
70
|
+
);
|
|
71
|
+
const firstTs = log.trim().split("\n").filter(Boolean)[0];
|
|
72
|
+
if (firstTs) return new Date(parseInt(firstTs, 10) * 1000);
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
52
80
|
async function readJsonlForSession(
|
|
53
81
|
filePath: string,
|
|
54
82
|
sessionId: string,
|
|
@@ -130,16 +158,12 @@ export async function collectMetrics(
|
|
|
130
158
|
const root = repoRoot ?? resolve(process.cwd());
|
|
131
159
|
const logDir = join(root, ".claude", "logs");
|
|
132
160
|
|
|
133
|
-
const sessionId = sessionIdArg ?? (await resolveSessionId(root));
|
|
134
|
-
if (!sessionId) {
|
|
135
|
-
console.error(
|
|
136
|
-
"Error: No session ID found. Logs may be empty or not yet generated.",
|
|
137
|
-
);
|
|
138
|
-
console.error("Usage: claude-attribution metrics [session-id]");
|
|
139
|
-
process.exit(1);
|
|
140
|
-
}
|
|
161
|
+
const sessionId = sessionIdArg ?? (await resolveSessionId(root)) ?? "";
|
|
141
162
|
|
|
142
|
-
|
|
163
|
+
// /start marker takes precedence; fall back to earliest commit on the branch
|
|
164
|
+
// so that /start is never required for correct scoping.
|
|
165
|
+
const sessionStart =
|
|
166
|
+
(await readSessionStart(root)) ?? (await getBranchStartTime(root));
|
|
143
167
|
|
|
144
168
|
const [toolEntries, agentEntries, transcript, attributions] =
|
|
145
169
|
await Promise.all([
|
package/src/setup/install.ts
CHANGED
|
@@ -5,14 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* If no path is given, installs into the current working directory.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
readFile,
|
|
10
|
-
writeFile,
|
|
11
|
-
appendFile,
|
|
12
|
-
copyFile,
|
|
13
|
-
chmod,
|
|
14
|
-
mkdir,
|
|
15
|
-
} from "fs/promises";
|
|
8
|
+
import { readFile, writeFile, appendFile, chmod, mkdir } from "fs/promises";
|
|
16
9
|
import { existsSync } from "fs";
|
|
17
10
|
import { execFile } from "child_process";
|
|
18
11
|
import { promisify } from "util";
|
|
@@ -84,65 +77,6 @@ function detectHookManager(repoRoot: string): "husky" | "lefthook" | "none" {
|
|
|
84
77
|
return "none";
|
|
85
78
|
}
|
|
86
79
|
|
|
87
|
-
async function installPrePushHook(repoRoot: string): Promise<void> {
|
|
88
|
-
const manager = detectHookManager(repoRoot);
|
|
89
|
-
const runLine = `claude-attribution hook pre-push || true`;
|
|
90
|
-
|
|
91
|
-
if (manager === "husky") {
|
|
92
|
-
const huskyHook = join(repoRoot, ".husky", "pre-push");
|
|
93
|
-
if (existsSync(huskyHook)) {
|
|
94
|
-
const existing = await readFile(huskyHook, "utf8");
|
|
95
|
-
if (!existing.includes("claude-attribution")) {
|
|
96
|
-
await writeFile(
|
|
97
|
-
huskyHook,
|
|
98
|
-
existing.trimEnd() + "\n\n# claude-attribution\n" + runLine + "\n",
|
|
99
|
-
);
|
|
100
|
-
console.log(" (appended to existing .husky/pre-push)");
|
|
101
|
-
}
|
|
102
|
-
} else {
|
|
103
|
-
await writeFile(
|
|
104
|
-
huskyHook,
|
|
105
|
-
`#!/bin/sh\n# claude-attribution\n${runLine}\n`,
|
|
106
|
-
);
|
|
107
|
-
await chmod(huskyHook, 0o755);
|
|
108
|
-
}
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (manager === "lefthook") {
|
|
113
|
-
console.log("");
|
|
114
|
-
console.log(" ⚠️ Lefthook detected. Add this to lefthook.yml manually:");
|
|
115
|
-
console.log(" pre-push:");
|
|
116
|
-
console.log(" commands:");
|
|
117
|
-
console.log(" claude-attribution:");
|
|
118
|
-
console.log(` run: ${runLine}`);
|
|
119
|
-
console.log("");
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Plain git hooks
|
|
124
|
-
const hookDest = join(repoRoot, ".git", "hooks", "pre-push");
|
|
125
|
-
const rawTemplate = await readFile(
|
|
126
|
-
join(ATTRIBUTION_ROOT, "src", "setup", "templates", "pre-push.sh"),
|
|
127
|
-
"utf8",
|
|
128
|
-
);
|
|
129
|
-
if (existsSync(hookDest)) {
|
|
130
|
-
const existing = await readFile(hookDest, "utf8");
|
|
131
|
-
if (!existing.includes("claude-attribution")) {
|
|
132
|
-
await writeFile(
|
|
133
|
-
hookDest,
|
|
134
|
-
existing.trimEnd() + "\n\n# claude-attribution\n" + rawTemplate,
|
|
135
|
-
);
|
|
136
|
-
await chmod(hookDest, 0o755);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
// Already ours — replace
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
await writeFile(hookDest, rawTemplate);
|
|
143
|
-
await chmod(hookDest, 0o755);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
80
|
async function installGitHook(repoRoot: string): Promise<void> {
|
|
147
81
|
const manager = detectHookManager(repoRoot);
|
|
148
82
|
const runLine = `claude-attribution hook post-commit || true`;
|
|
@@ -247,29 +181,30 @@ async function main() {
|
|
|
247
181
|
await mergeHooks(settingsPath, hooksConfig);
|
|
248
182
|
console.log("✓ Merged hooks into .claude/settings.json");
|
|
249
183
|
|
|
250
|
-
// 2. Install post-commit
|
|
184
|
+
// 2. Install post-commit git hook
|
|
251
185
|
await installGitHook(targetRepo);
|
|
252
186
|
console.log("✓ Installed .git/hooks/post-commit");
|
|
253
|
-
await installPrePushHook(targetRepo);
|
|
254
|
-
console.log(
|
|
255
|
-
"✓ Installed .git/hooks/pre-push (pushes attribution notes to origin)",
|
|
256
|
-
);
|
|
257
187
|
|
|
258
|
-
//
|
|
188
|
+
// Configure git push to also push notes by default (avoids needing a separate push).
|
|
189
|
+
// Use --unset-all first so re-running install doesn't accumulate duplicate refspecs,
|
|
190
|
+
// then --add to append alongside any other user-configured push refspecs.
|
|
191
|
+
const notesRefspec =
|
|
192
|
+
"refs/notes/claude-attribution:refs/notes/claude-attribution";
|
|
259
193
|
try {
|
|
194
|
+
// Remove any existing entries for this refspec (idempotent — exits non-zero if missing, that's fine)
|
|
260
195
|
await execFileAsync(
|
|
261
196
|
"git",
|
|
262
|
-
[
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
197
|
+
["config", "--unset-all", "remote.origin.push", notesRefspec],
|
|
198
|
+
{ cwd: targetRepo },
|
|
199
|
+
).catch(() => {
|
|
200
|
+
// exit 5 = key not found; safe to ignore
|
|
201
|
+
});
|
|
202
|
+
await execFileAsync(
|
|
203
|
+
"git",
|
|
204
|
+
["config", "--add", "remote.origin.push", notesRefspec],
|
|
268
205
|
{ cwd: targetRepo },
|
|
269
206
|
);
|
|
270
|
-
console.log(
|
|
271
|
-
"✓ Configured remote.origin.push for attribution notes refspec",
|
|
272
|
-
);
|
|
207
|
+
console.log("✓ Configured remote.origin.push to include attribution notes");
|
|
273
208
|
} catch {
|
|
274
209
|
console.log(
|
|
275
210
|
" (skipped git config remote.origin.push — no origin remote or git unavailable)",
|
|
@@ -300,8 +235,31 @@ async function main() {
|
|
|
300
235
|
await writeFile(join(commandsDir, "pr.md"), prTemplate);
|
|
301
236
|
console.log("✓ Installed .claude/commands/pr.md (/pr command)");
|
|
302
237
|
|
|
238
|
+
// 4. Install GitHub Actions workflow for automatic PR metrics injection
|
|
239
|
+
const workflowsDir = join(targetRepo, ".github", "workflows");
|
|
240
|
+
await mkdir(workflowsDir, { recursive: true });
|
|
241
|
+
const workflowTemplate = await readFile(
|
|
242
|
+
join(
|
|
243
|
+
ATTRIBUTION_ROOT,
|
|
244
|
+
"src",
|
|
245
|
+
"setup",
|
|
246
|
+
"templates",
|
|
247
|
+
"pr-metrics-workflow.yml",
|
|
248
|
+
),
|
|
249
|
+
"utf8",
|
|
250
|
+
);
|
|
251
|
+
await writeFile(
|
|
252
|
+
join(workflowsDir, "claude-attribution-pr.yml"),
|
|
253
|
+
workflowTemplate,
|
|
254
|
+
);
|
|
255
|
+
console.log(
|
|
256
|
+
"✓ Installed .github/workflows/claude-attribution-pr.yml (auto-injects metrics on PR open)",
|
|
257
|
+
);
|
|
258
|
+
|
|
303
259
|
console.log("\nDone! claude-attribution is active for this repo.");
|
|
304
|
-
console.log(
|
|
260
|
+
console.log(
|
|
261
|
+
"Commit .claude/settings.json and .github/workflows/claude-attribution-pr.yml to share with the team.",
|
|
262
|
+
);
|
|
305
263
|
}
|
|
306
264
|
|
|
307
265
|
main().catch((err) => {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
name: Claude Code Attribution Metrics
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [opened, synchronize]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
pull-requests: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
metrics:
|
|
13
|
+
name: Claude Code Attribution Metrics
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
with:
|
|
18
|
+
fetch-depth: 0
|
|
19
|
+
|
|
20
|
+
- name: Fetch attribution notes
|
|
21
|
+
run: git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
|
|
22
|
+
|
|
23
|
+
- name: Install claude-attribution
|
|
24
|
+
run: npm install -g claude-attribution
|
|
25
|
+
|
|
26
|
+
- name: Generate metrics
|
|
27
|
+
run: claude-attribution metrics > /tmp/claude-attribution-metrics.md || true
|
|
28
|
+
|
|
29
|
+
- name: Inject metrics into PR body
|
|
30
|
+
uses: actions/github-script@v7
|
|
31
|
+
with:
|
|
32
|
+
script: |
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
let metrics;
|
|
35
|
+
try {
|
|
36
|
+
metrics = fs.readFileSync('/tmp/claude-attribution-metrics.md', 'utf8').trim();
|
|
37
|
+
} catch {
|
|
38
|
+
console.log('No metrics output, skipping.');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!metrics) {
|
|
42
|
+
console.log('Empty metrics output, skipping.');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { data: pr } = await github.rest.pulls.get({
|
|
47
|
+
owner: context.repo.owner,
|
|
48
|
+
repo: context.repo.repo,
|
|
49
|
+
pull_number: context.payload.pull_request.number,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const body = pr.body || '';
|
|
53
|
+
const start = '<!-- claude-attribution metrics -->';
|
|
54
|
+
const end = '<!-- /claude-attribution metrics -->';
|
|
55
|
+
|
|
56
|
+
// On 'opened': skip if the local post-bash hook already injected metrics.
|
|
57
|
+
// On 'synchronize': always update to reflect new commits.
|
|
58
|
+
if (context.payload.action === 'opened' && body.includes(start) && body.includes(end)) {
|
|
59
|
+
const existing = body.slice(body.indexOf(start) + start.length, body.indexOf(end)).trim();
|
|
60
|
+
if (existing) {
|
|
61
|
+
console.log('Metrics already injected by local hook, skipping.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const block = `${start}\n${metrics}\n${end}`;
|
|
66
|
+
|
|
67
|
+
let newBody;
|
|
68
|
+
if (body.includes(start) && body.includes(end)) {
|
|
69
|
+
const startIdx = body.indexOf(start);
|
|
70
|
+
const endIdx = body.indexOf(end) + end.length;
|
|
71
|
+
newBody = body.slice(0, startIdx) + block + body.slice(endIdx);
|
|
72
|
+
} else if (body.includes(start)) {
|
|
73
|
+
newBody = body.slice(0, body.indexOf(start)) + block;
|
|
74
|
+
} else {
|
|
75
|
+
newBody = body ? `${body}\n\n${block}` : block;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await github.rest.pulls.update({
|
|
79
|
+
owner: context.repo.owner,
|
|
80
|
+
repo: context.repo.repo,
|
|
81
|
+
pull_number: context.payload.pull_request.number,
|
|
82
|
+
body: newBody,
|
|
83
|
+
});
|
|
84
|
+
console.log('Metrics injected into PR body.');
|
package/src/setup/uninstall.ts
CHANGED
|
@@ -163,6 +163,17 @@ async function main() {
|
|
|
163
163
|
console.log(" (no slash commands found)");
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// 6. Remove GitHub Actions workflow
|
|
167
|
+
const workflowPath = join(
|
|
168
|
+
targetRepo,
|
|
169
|
+
".github",
|
|
170
|
+
"workflows",
|
|
171
|
+
"claude-attribution-pr.yml",
|
|
172
|
+
);
|
|
173
|
+
if (await removeFile(workflowPath)) {
|
|
174
|
+
console.log("✓ Removed .github/workflows/claude-attribution-pr.yml");
|
|
175
|
+
}
|
|
176
|
+
|
|
166
177
|
console.log("\nDone! claude-attribution has been removed from this repo.");
|
|
167
178
|
console.log(
|
|
168
179
|
"Note: .claude/logs/ and .claude/attribution-state/ are preserved.",
|