claude-attribution 1.1.1 → 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 +1 -1
- package/src/commands/pr.ts +18 -4
- package/src/hooks/pre-tool-use.ts +10 -1
- package/src/lib/auto-upgrade.ts +110 -0
- package/src/setup/install.ts +49 -164
- package/src/setup/shared.ts +189 -0
- package/src/setup/uninstall.ts +11 -0
package/package.json
CHANGED
package/src/commands/pr.ts
CHANGED
|
@@ -133,11 +133,25 @@ async function main() {
|
|
|
133
133
|
body = template.trimEnd() + "\n\n" + metricsBlock;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
// Push branch to remote
|
|
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.
|
|
137
138
|
try {
|
|
138
|
-
await execFileAsync(
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
|
141
155
|
} catch (err) {
|
|
142
156
|
const msg = err instanceof Error ? err.message : String(err);
|
|
143
157
|
console.error(`Error pushing branch: ${msg}`);
|
|
@@ -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
|
+
}
|
package/src/setup/install.ts
CHANGED
|
@@ -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,
|
|
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
|
|
13
|
-
import {
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
215
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/setup/uninstall.ts
CHANGED
|
@@ -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.",
|