claude-attribution 1.1.0 → 1.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-attribution",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "AI code attribution tracking for Claude Code sessions — checkpoint-based line diff approach",
5
5
  "type": "module",
6
6
  "bin": {
@@ -133,6 +133,31 @@ async function main() {
133
133
  body = template.trimEnd() + "\n\n" + metricsBlock;
134
134
  }
135
135
 
136
+ // Push branch to remote. If the branch already has a remote tracking ref,
137
+ // push any new commits (no-op if up-to-date). Otherwise set tracking with -u.
138
+ try {
139
+ const hasUpstream = await execFileAsync(
140
+ "git",
141
+ ["rev-parse", "--abbrev-ref", "@{upstream}"],
142
+ { cwd: repoRoot },
143
+ ).then(
144
+ () => true,
145
+ () => false,
146
+ );
147
+ if (hasUpstream) {
148
+ // Branch already pushed — push any new commits, ignore "already up-to-date"
149
+ await execFileAsync("git", ["push"], { cwd: repoRoot }).catch(() => {});
150
+ } else {
151
+ await execFileAsync("git", ["push", "-u", "origin", "HEAD"], {
152
+ cwd: repoRoot,
153
+ });
154
+ }
155
+ } catch (err) {
156
+ const msg = err instanceof Error ? err.message : String(err);
157
+ console.error(`Error pushing branch: ${msg}`);
158
+ process.exit(1);
159
+ }
160
+
136
161
  // Write body to a temp file (avoids shell quoting issues)
137
162
  const tmpDir = await mkdtemp(join(tmpdir(), "claude-attribution-pr-"));
138
163
  const bodyFile = join(tmpDir, "pr-body.md");
@@ -17,6 +17,7 @@ import {
17
17
  writeCurrentSession,
18
18
  } from "../attribution/checkpoint.ts";
19
19
  import { readStdin, WRITE_TOOLS, getFilePath } from "../lib/hooks.ts";
20
+ import { maybeAutoUpgrade } from "../lib/auto-upgrade.ts";
20
21
  import {
21
22
  otelEndpoint,
22
23
  otelHeaders,
@@ -44,13 +45,21 @@ async function main() {
44
45
  }
45
46
 
46
47
  const { session_id, tool_name, tool_input } = payload;
48
+ const repoRoot = resolve(process.cwd());
49
+
50
+ // Auto-upgrade: runs at most once per session, non-fatal
51
+ try {
52
+ await maybeAutoUpgrade(repoRoot, session_id);
53
+ } catch {
54
+ // Never block Claude
55
+ }
56
+
47
57
  if (!WRITE_TOOLS.has(tool_name)) process.exit(0);
48
58
 
49
59
  const filePath = getFilePath(tool_input);
50
60
  if (!filePath) process.exit(0);
51
61
 
52
62
  const absPath = resolve(filePath);
53
- const repoRoot = resolve(process.cwd());
54
63
 
55
64
  try {
56
65
  // Only save before-checkpoint if one doesn't already exist for this session+file.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Auto-upgrade — transparently applies structural hook/command updates when
3
+ * the installed CLI version in a repo is older than the running CLI version.
4
+ *
5
+ * Called from pre-tool-use.ts on the first tool invocation of each session.
6
+ * Never throws — all errors are swallowed silently.
7
+ */
8
+ import { readFile, writeFile, mkdir } from "fs/promises";
9
+ import { existsSync } from "fs";
10
+ import { join, dirname } from "path";
11
+ import { SESSION_ID_RE } from "../attribution/checkpoint.ts";
12
+ import {
13
+ ATTRIBUTION_ROOT,
14
+ mergeHooks,
15
+ installGitHook,
16
+ installSlashCommands,
17
+ installCiWorkflow,
18
+ type HooksConfig,
19
+ } from "../setup/shared.ts";
20
+
21
+ const CHECKPOINT_BASE = "/tmp/claude-attribution";
22
+
23
+ /** Returns true if semver string `a` is strictly greater than `b`. */
24
+ function newerThan(a: string, b: string): boolean {
25
+ const pa = a.split(".").map(Number);
26
+ const pb = b.split(".").map(Number);
27
+ for (let i = 0; i < 3; i++) {
28
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
29
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ /**
35
+ * Check whether the repo's installed claude-attribution version is older than
36
+ * the running CLI, and if so apply structural updates (hooks, slash commands,
37
+ * git hook, CI workflow). Runs at most once per session via a tmp flag file.
38
+ */
39
+ export async function maybeAutoUpgrade(
40
+ repoRoot: string,
41
+ sessionId: string,
42
+ ): Promise<void> {
43
+ try {
44
+ // Validate sessionId before use as a filesystem path component
45
+ if (!SESSION_ID_RE.test(sessionId)) return;
46
+
47
+ // Session-scoped dedup: only run once per session
48
+ const sessionTmpDir = join(CHECKPOINT_BASE, sessionId);
49
+ const flagFile = join(sessionTmpDir, "upgrade-checked");
50
+ if (existsSync(flagFile)) return;
51
+
52
+ await mkdir(CHECKPOINT_BASE, { recursive: true });
53
+ await mkdir(sessionTmpDir, { recursive: true, mode: 0o700 });
54
+ await writeFile(flagFile, "");
55
+
56
+ const versionFile = join(
57
+ repoRoot,
58
+ ".claude",
59
+ "attribution-state",
60
+ "installed-version",
61
+ );
62
+
63
+ // Read CLI version
64
+ const pkg = JSON.parse(
65
+ await readFile(join(ATTRIBUTION_ROOT, "package.json"), "utf8"),
66
+ ) as { version: string };
67
+ const cliVersion = pkg.version;
68
+
69
+ if (!existsSync(versionFile)) {
70
+ // Pre-existing install that predates version tracking. Bootstrap the
71
+ // file so future upgrades work automatically — no structural changes yet.
72
+ await mkdir(dirname(versionFile), { recursive: true });
73
+ await writeFile(versionFile, cliVersion);
74
+ return;
75
+ }
76
+
77
+ const installedVersion = (await readFile(versionFile, "utf8")).trim();
78
+ if (!installedVersion || !newerThan(cliVersion, installedVersion)) return;
79
+
80
+ // Apply structural updates — each step is best-effort
81
+ const settingsPath = join(repoRoot, ".claude", "settings.json");
82
+ const hooksConfig = JSON.parse(
83
+ await readFile(
84
+ join(ATTRIBUTION_ROOT, "src", "setup", "templates", "hooks.json"),
85
+ "utf8",
86
+ ),
87
+ ) as HooksConfig;
88
+
89
+ try {
90
+ await mergeHooks(settingsPath, hooksConfig);
91
+ } catch {}
92
+ try {
93
+ await installSlashCommands(repoRoot);
94
+ } catch {}
95
+ try {
96
+ await installGitHook(repoRoot);
97
+ } catch {}
98
+ try {
99
+ await installCiWorkflow(repoRoot);
100
+ } catch {}
101
+
102
+ await mkdir(dirname(versionFile), { recursive: true });
103
+ await writeFile(versionFile, cliVersion);
104
+ process.stderr.write(
105
+ `claude-attribution: auto-upgraded ${installedVersion} → ${cliVersion}\n`,
106
+ );
107
+ } catch {
108
+ // Never block Claude
109
+ }
110
+ }
@@ -5,140 +5,25 @@
5
5
  *
6
6
  * If no path is given, installs into the current working directory.
7
7
  */
8
- import { readFile, writeFile, appendFile, chmod, mkdir } from "fs/promises";
8
+ import { readFile, writeFile, appendFile, mkdir } from "fs/promises";
9
9
  import { existsSync } from "fs";
10
10
  import { execFile } from "child_process";
11
11
  import { promisify } from "util";
12
- import { resolve, join, dirname } from "path";
13
- import { fileURLToPath } from "url";
12
+ import { resolve, join } from "path";
13
+ import {
14
+ ATTRIBUTION_ROOT,
15
+ mergeHooks,
16
+ installGitHook,
17
+ installSlashCommands,
18
+ installCiWorkflow,
19
+ detectHookManager,
20
+ type HooksConfig,
21
+ } from "./shared.ts";
14
22
 
15
23
  const execFileAsync = promisify(execFile);
16
24
 
17
- const ATTRIBUTION_ROOT = resolve(
18
- dirname(fileURLToPath(import.meta.url)),
19
- "..",
20
- "..",
21
- );
22
25
  const CLI_BIN = resolve(ATTRIBUTION_ROOT, "bin", "claude-attribution");
23
26
 
24
- interface HookEntry {
25
- matcher: string;
26
- hooks: Array<{ type: string; command: string }>;
27
- }
28
-
29
- type HooksConfig = Record<string, HookEntry[]>;
30
-
31
- interface ClaudeSettings {
32
- hooks?: HooksConfig;
33
- [key: string]: unknown;
34
- }
35
-
36
- async function mergeHooks(
37
- settingsPath: string,
38
- newHooks: HooksConfig,
39
- ): Promise<void> {
40
- let settings: ClaudeSettings = {};
41
- if (existsSync(settingsPath)) {
42
- const raw = await readFile(settingsPath, "utf8");
43
- settings = JSON.parse(raw) as ClaudeSettings;
44
- }
45
-
46
- const existing = settings.hooks ?? {};
47
-
48
- for (const [event, entries] of Object.entries(newHooks)) {
49
- const current = existing[event] ?? [];
50
- // Remove any existing claude-attribution entries, then push all new ones
51
- const filtered = current.filter(
52
- (e) => !e.hooks.some((h) => h.command.includes("claude-attribution")),
53
- );
54
- filtered.push(...entries);
55
- existing[event] = filtered;
56
- }
57
-
58
- settings.hooks = existing;
59
- await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
60
- }
61
-
62
- /**
63
- * Detect whether the repo uses Husky or Lefthook to manage git hooks.
64
- * These tools own .git/hooks/ themselves — we must not write there directly.
65
- * Instead we register via their config files.
66
- */
67
- function detectHookManager(repoRoot: string): "husky" | "lefthook" | "none" {
68
- if (existsSync(join(repoRoot, ".husky"))) return "husky";
69
- if (
70
- existsSync(join(repoRoot, "lefthook.yml")) ||
71
- existsSync(join(repoRoot, "lefthook.yaml")) ||
72
- existsSync(join(repoRoot, "lefthook.json")) ||
73
- existsSync(join(repoRoot, ".lefthook.yml")) ||
74
- existsSync(join(repoRoot, ".lefthook.json"))
75
- )
76
- return "lefthook";
77
- return "none";
78
- }
79
-
80
- async function installGitHook(repoRoot: string): Promise<void> {
81
- const manager = detectHookManager(repoRoot);
82
- const runLine = `claude-attribution hook post-commit || true`;
83
-
84
- if (manager === "husky") {
85
- // Husky: add post-commit file to .husky/ directory
86
- const huskyHook = join(repoRoot, ".husky", "post-commit");
87
- if (existsSync(huskyHook)) {
88
- const existing = await readFile(huskyHook, "utf8");
89
- if (!existing.includes("claude-attribution")) {
90
- await writeFile(
91
- huskyHook,
92
- existing.trimEnd() + "\n\n# claude-attribution\n" + runLine + "\n",
93
- );
94
- console.log(" (appended to existing .husky/post-commit)");
95
- }
96
- } else {
97
- await writeFile(
98
- huskyHook,
99
- `#!/bin/sh\n# claude-attribution\n${runLine}\n`,
100
- );
101
- await chmod(huskyHook, 0o755);
102
- }
103
- return;
104
- }
105
-
106
- if (manager === "lefthook") {
107
- // Lefthook: instruct the user — auto-editing YAML is fragile
108
- console.log("");
109
- console.log(" ⚠️ Lefthook detected. Add this to lefthook.yml manually:");
110
- console.log(" post-commit:");
111
- console.log(" commands:");
112
- console.log(" claude-attribution:");
113
- console.log(` run: ${runLine}`);
114
- console.log("");
115
- return;
116
- }
117
-
118
- // Plain git hooks
119
- const hookDest = join(repoRoot, ".git", "hooks", "post-commit");
120
- const template = await readFile(
121
- join(ATTRIBUTION_ROOT, "src", "setup", "templates", "post-commit.sh"),
122
- "utf8",
123
- );
124
-
125
- if (existsSync(hookDest)) {
126
- const existing = await readFile(hookDest, "utf8");
127
- if (!existing.includes("claude-attribution")) {
128
- await writeFile(
129
- hookDest,
130
- existing.trimEnd() + "\n\n# claude-attribution\n" + template,
131
- );
132
- await chmod(hookDest, 0o755);
133
- return;
134
- }
135
- // Already ours — replace
136
- }
137
-
138
- await writeFile(hookDest, template);
139
- await chmod(hookDest, 0o755);
140
- }
141
-
142
27
  async function main() {
143
28
  const targetRepo = resolve(process.argv[2] ?? process.cwd());
144
29
 
@@ -182,8 +67,31 @@ async function main() {
182
67
  console.log("✓ Merged hooks into .claude/settings.json");
183
68
 
184
69
  // 2. Install post-commit git hook
185
- await installGitHook(targetRepo);
186
- console.log("✓ Installed .git/hooks/post-commit");
70
+ const manager = detectHookManager(targetRepo);
71
+ const hookResult = await installGitHook(targetRepo);
72
+ if (hookResult === "noop") {
73
+ console.log("");
74
+ console.log(" ⚠️ Lefthook detected. Add this to lefthook.yml manually:");
75
+ console.log(" post-commit:");
76
+ console.log(" commands:");
77
+ console.log(" claude-attribution:");
78
+ console.log(" run: claude-attribution hook post-commit || true");
79
+ console.log("");
80
+ console.log(
81
+ " (skipped automatic hook install — Lefthook manages .git/hooks/)",
82
+ );
83
+ } else if (hookResult === "created") {
84
+ const hookPath =
85
+ manager === "husky" ? ".husky/post-commit" : ".git/hooks/post-commit";
86
+ console.log(`✓ Created ${hookPath}`);
87
+ } else if (hookResult === "appended") {
88
+ const hookPath =
89
+ manager === "husky" ? ".husky/post-commit" : ".git/hooks/post-commit";
90
+ console.log(`✓ Appended to existing ${hookPath}`);
91
+ } else {
92
+ // unchanged
93
+ console.log("✓ post-commit hook already up to date");
94
+ }
187
95
 
188
96
  // Configure git push to also push notes by default (avoids needing a separate push).
189
97
  // Use --unset-all first so re-running install doesn't accumulate duplicate refspecs,
@@ -211,51 +119,28 @@ async function main() {
211
119
  );
212
120
  }
213
121
 
214
- // 3. Install /metrics slash command
215
- const commandsDir = join(claudeDir, "commands");
216
- await mkdir(commandsDir, { recursive: true });
217
- const metricsTemplate = await readFile(
218
- join(ATTRIBUTION_ROOT, "src", "setup", "templates", "metrics-command.md"),
219
- "utf8",
220
- );
221
- await writeFile(join(commandsDir, "metrics.md"), metricsTemplate);
122
+ // 3. Install slash commands
123
+ await installSlashCommands(targetRepo);
222
124
  console.log("✓ Installed .claude/commands/metrics.md (/metrics command)");
223
-
224
- const startTemplate = await readFile(
225
- join(ATTRIBUTION_ROOT, "src", "setup", "templates", "start-command.md"),
226
- "utf8",
227
- );
228
- await writeFile(join(commandsDir, "start.md"), startTemplate);
229
125
  console.log("✓ Installed .claude/commands/start.md (/start command)");
230
-
231
- const prTemplate = await readFile(
232
- join(ATTRIBUTION_ROOT, "src", "setup", "templates", "pr-command.md"),
233
- "utf8",
234
- );
235
- await writeFile(join(commandsDir, "pr.md"), prTemplate);
236
126
  console.log("✓ Installed .claude/commands/pr.md (/pr command)");
237
127
 
238
128
  // 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
- );
129
+ await installCiWorkflow(targetRepo);
255
130
  console.log(
256
131
  "✓ Installed .github/workflows/claude-attribution-pr.yml (auto-injects metrics on PR open)",
257
132
  );
258
133
 
134
+ // 5. Record installed version for auto-upgrade tracking
135
+ const pkg = JSON.parse(
136
+ await readFile(join(ATTRIBUTION_ROOT, "package.json"), "utf8"),
137
+ ) as { version: string };
138
+ await writeFile(
139
+ join(claudeDir, "attribution-state", "installed-version"),
140
+ pkg.version,
141
+ );
142
+ console.log(`✓ Recorded installed version: ${pkg.version}`);
143
+
259
144
  console.log("\nDone! claude-attribution is active for this repo.");
260
145
  console.log(
261
146
  "Commit .claude/settings.json and .github/workflows/claude-attribution-pr.yml to share with the team.",
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Shared setup utilities — used by both install.ts and auto-upgrade.ts.
3
+ *
4
+ * No top-level side effects. No console.log — logging is the caller's
5
+ * responsibility.
6
+ */
7
+ import { readFile, writeFile, chmod, mkdir } from "fs/promises";
8
+ import { existsSync } from "fs";
9
+ import { resolve, join, dirname } from "path";
10
+ import { fileURLToPath } from "url";
11
+
12
+ export const ATTRIBUTION_ROOT = resolve(
13
+ dirname(fileURLToPath(import.meta.url)),
14
+ "..",
15
+ "..",
16
+ );
17
+
18
+ export interface HookEntry {
19
+ matcher: string;
20
+ hooks: Array<{ type: string; command: string }>;
21
+ }
22
+
23
+ export type HooksConfig = Record<string, HookEntry[]>;
24
+
25
+ export interface ClaudeSettings {
26
+ hooks?: HooksConfig;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ /**
31
+ * Merge new hook entries into .claude/settings.json.
32
+ * Removes any existing claude-attribution entries for each event, then
33
+ * appends the new ones — safe to call repeatedly.
34
+ */
35
+ export async function mergeHooks(
36
+ settingsPath: string,
37
+ newHooks: HooksConfig,
38
+ ): Promise<void> {
39
+ let settings: ClaudeSettings = {};
40
+ if (existsSync(settingsPath)) {
41
+ const raw = await readFile(settingsPath, "utf8");
42
+ settings = JSON.parse(raw) as ClaudeSettings;
43
+ }
44
+
45
+ const existing = settings.hooks ?? {};
46
+
47
+ for (const [event, entries] of Object.entries(newHooks)) {
48
+ const current = existing[event] ?? [];
49
+ const filtered = current.filter(
50
+ (e) => !e.hooks.some((h) => h.command.includes("claude-attribution")),
51
+ );
52
+ filtered.push(...entries);
53
+ existing[event] = filtered;
54
+ }
55
+
56
+ settings.hooks = existing;
57
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
58
+ }
59
+
60
+ /**
61
+ * Detect whether the repo uses Husky or Lefthook to manage git hooks.
62
+ * These tools own .git/hooks/ themselves — we must not write there directly.
63
+ */
64
+ export function detectHookManager(
65
+ repoRoot: string,
66
+ ): "husky" | "lefthook" | "none" {
67
+ if (existsSync(join(repoRoot, ".husky"))) return "husky";
68
+ if (
69
+ existsSync(join(repoRoot, "lefthook.yml")) ||
70
+ existsSync(join(repoRoot, "lefthook.yaml")) ||
71
+ existsSync(join(repoRoot, "lefthook.json")) ||
72
+ existsSync(join(repoRoot, ".lefthook.yml")) ||
73
+ existsSync(join(repoRoot, ".lefthook.json"))
74
+ )
75
+ return "lefthook";
76
+ return "none";
77
+ }
78
+
79
+ /** Result returned by installGitHook describing what action was taken. */
80
+ export type GitHookInstallResult =
81
+ | "created" // wrote a new hook file
82
+ | "appended" // appended to an existing hook file
83
+ | "unchanged" // hook already contained our entry; nothing written
84
+ | "noop"; // Lefthook detected — manual config required, nothing written
85
+
86
+ /**
87
+ * Install the post-commit git hook, respecting husky/lefthook if present.
88
+ * Idempotent — safe to call on already-installed repos.
89
+ * Note: for Lefthook repos this is a no-op (manual config required).
90
+ * Returns a GitHookInstallResult describing what was done.
91
+ */
92
+ export async function installGitHook(
93
+ repoRoot: string,
94
+ ): Promise<GitHookInstallResult> {
95
+ const manager = detectHookManager(repoRoot);
96
+ const runLine = `claude-attribution hook post-commit || true`;
97
+
98
+ if (manager === "husky") {
99
+ const huskyHook = join(repoRoot, ".husky", "post-commit");
100
+ if (existsSync(huskyHook)) {
101
+ const existing = await readFile(huskyHook, "utf8");
102
+ if (!existing.includes("claude-attribution")) {
103
+ await writeFile(
104
+ huskyHook,
105
+ existing.trimEnd() + "\n\n# claude-attribution\n" + runLine + "\n",
106
+ );
107
+ return "appended";
108
+ }
109
+ return "unchanged";
110
+ } else {
111
+ await writeFile(
112
+ huskyHook,
113
+ `#!/bin/sh\n# claude-attribution\n${runLine}\n`,
114
+ );
115
+ await chmod(huskyHook, 0o755);
116
+ return "created";
117
+ }
118
+ }
119
+
120
+ if (manager === "lefthook") {
121
+ // Cannot safely auto-edit YAML — caller should inform the user
122
+ return "noop";
123
+ }
124
+
125
+ // Plain git hooks
126
+ const hookDest = join(repoRoot, ".git", "hooks", "post-commit");
127
+ const template = await readFile(
128
+ join(ATTRIBUTION_ROOT, "src", "setup", "templates", "post-commit.sh"),
129
+ "utf8",
130
+ );
131
+
132
+ if (existsSync(hookDest)) {
133
+ const existing = await readFile(hookDest, "utf8");
134
+ if (!existing.includes("claude-attribution")) {
135
+ await writeFile(
136
+ hookDest,
137
+ existing.trimEnd() + "\n\n# claude-attribution\n" + template,
138
+ );
139
+ await chmod(hookDest, 0o755);
140
+ return "appended";
141
+ }
142
+ // Already ours — replace with latest template
143
+ }
144
+
145
+ await writeFile(hookDest, template);
146
+ await chmod(hookDest, 0o755);
147
+ return "created";
148
+ }
149
+
150
+ /**
151
+ * Install (or overwrite) the slash command files into .claude/commands/.
152
+ */
153
+ export async function installSlashCommands(repoRoot: string): Promise<void> {
154
+ const commandsDir = join(repoRoot, ".claude", "commands");
155
+ await mkdir(commandsDir, { recursive: true });
156
+
157
+ const commands = [
158
+ { template: "metrics-command.md", dest: "metrics.md" },
159
+ { template: "start-command.md", dest: "start.md" },
160
+ { template: "pr-command.md", dest: "pr.md" },
161
+ ];
162
+
163
+ for (const { template, dest } of commands) {
164
+ const content = await readFile(
165
+ join(ATTRIBUTION_ROOT, "src", "setup", "templates", template),
166
+ "utf8",
167
+ );
168
+ await writeFile(join(commandsDir, dest), content);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Install (or overwrite) the GitHub Actions PR metrics workflow.
174
+ */
175
+ export async function installCiWorkflow(repoRoot: string): Promise<void> {
176
+ const workflowsDir = join(repoRoot, ".github", "workflows");
177
+ await mkdir(workflowsDir, { recursive: true });
178
+ const content = await readFile(
179
+ join(
180
+ ATTRIBUTION_ROOT,
181
+ "src",
182
+ "setup",
183
+ "templates",
184
+ "pr-metrics-workflow.yml",
185
+ ),
186
+ "utf8",
187
+ );
188
+ await writeFile(join(workflowsDir, "claude-attribution-pr.yml"), content);
189
+ }
@@ -174,6 +174,17 @@ async function main() {
174
174
  console.log("✓ Removed .github/workflows/claude-attribution-pr.yml");
175
175
  }
176
176
 
177
+ // 7. Remove installed-version tracking file
178
+ const versionFile = join(
179
+ targetRepo,
180
+ ".claude",
181
+ "attribution-state",
182
+ "installed-version",
183
+ );
184
+ if (await removeFile(versionFile)) {
185
+ console.log("✓ Removed .claude/attribution-state/installed-version");
186
+ }
187
+
177
188
  console.log("\nDone! claude-attribution has been removed from this repo.");
178
189
  console.log(
179
190
  "Note: .claude/logs/ and .claude/attribution-state/ are preserved.",