claude-attribution 1.2.2 → 1.2.5
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/setup/install.ts +236 -10
- package/src/setup/shared.ts +132 -13
package/package.json
CHANGED
package/src/setup/install.ts
CHANGED
|
@@ -5,11 +5,21 @@
|
|
|
5
5
|
*
|
|
6
6
|
* If no path is given, installs into the current working directory.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
readFile,
|
|
10
|
+
writeFile,
|
|
11
|
+
appendFile,
|
|
12
|
+
mkdir,
|
|
13
|
+
mkdtemp,
|
|
14
|
+
unlink,
|
|
15
|
+
rmdir,
|
|
16
|
+
} from "fs/promises";
|
|
9
17
|
import { existsSync } from "fs";
|
|
10
18
|
import { execFile } from "child_process";
|
|
11
19
|
import { promisify } from "util";
|
|
12
20
|
import { resolve, join } from "path";
|
|
21
|
+
import { tmpdir } from "os";
|
|
22
|
+
import { createInterface } from "readline";
|
|
13
23
|
import {
|
|
14
24
|
ATTRIBUTION_ROOT,
|
|
15
25
|
mergeHooks,
|
|
@@ -24,6 +34,208 @@ const execFileAsync = promisify(execFile);
|
|
|
24
34
|
|
|
25
35
|
const CLI_BIN = resolve(ATTRIBUTION_ROOT, "bin", "claude-attribution");
|
|
26
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The exact GitHub Actions job name written into our workflow template.
|
|
39
|
+
* Must match the `name:` field of the `metrics` job in pr-metrics-workflow.yml.
|
|
40
|
+
*/
|
|
41
|
+
const WORKFLOW_CHECK_NAME = "Claude Code Attribution Metrics";
|
|
42
|
+
|
|
43
|
+
/** Extract "owner/repo" from an origin remote URL (SSH or HTTPS). */
|
|
44
|
+
function remoteUrlToSlug(url: string): string | null {
|
|
45
|
+
const m =
|
|
46
|
+
url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/) ??
|
|
47
|
+
url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
48
|
+
return m?.[1] ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Call `gh api <path>` and return parsed JSON, or null on any error. */
|
|
52
|
+
async function ghApiGet(path: string): Promise<unknown> {
|
|
53
|
+
try {
|
|
54
|
+
const { stdout } = (await execFileAsync("gh", [
|
|
55
|
+
"api",
|
|
56
|
+
path,
|
|
57
|
+
])) as unknown as {
|
|
58
|
+
stdout: string;
|
|
59
|
+
};
|
|
60
|
+
return JSON.parse(stdout) as unknown;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Prompt the user for a yes/no answer. Returns false in non-TTY contexts. */
|
|
67
|
+
async function promptYesNo(question: string): Promise<boolean> {
|
|
68
|
+
if (!process.stdin.isTTY) return false;
|
|
69
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
rl.question(question, (answer) => {
|
|
72
|
+
rl.close();
|
|
73
|
+
resolve(answer.trim().toLowerCase() === "y");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function printRequiredCheckNote(branch: string): void {
|
|
79
|
+
console.log(
|
|
80
|
+
`\n ℹ️ To block merges when this workflow fails, add '${WORKFLOW_CHECK_NAME}'`,
|
|
81
|
+
);
|
|
82
|
+
console.log(
|
|
83
|
+
` to required status checks for '${branch}' in Settings → Branches.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* PATCH the required status checks for a branch via the GitHub API.
|
|
89
|
+
* Writes the JSON body to a temp file to avoid arg-length issues.
|
|
90
|
+
*/
|
|
91
|
+
async function patchRequiredChecks(
|
|
92
|
+
slug: string,
|
|
93
|
+
branch: string,
|
|
94
|
+
body: unknown,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "claude-attribution-api-"));
|
|
97
|
+
const tmpFile = join(tmpDir, "body.json");
|
|
98
|
+
try {
|
|
99
|
+
await writeFile(tmpFile, JSON.stringify(body), { flag: "wx" });
|
|
100
|
+
await execFileAsync("gh", [
|
|
101
|
+
"api",
|
|
102
|
+
`repos/${slug}/branches/${branch}/protection/required_status_checks`,
|
|
103
|
+
"--method",
|
|
104
|
+
"PATCH",
|
|
105
|
+
"--input",
|
|
106
|
+
tmpFile,
|
|
107
|
+
]);
|
|
108
|
+
} finally {
|
|
109
|
+
await unlink(tmpFile).catch(() => {});
|
|
110
|
+
await rmdir(tmpDir).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* After installing the workflow, check branch protection / rulesets and offer
|
|
116
|
+
* to add our workflow job as a required status check.
|
|
117
|
+
*
|
|
118
|
+
* - Classic branch protection: fully automatic (detect → prompt → PATCH)
|
|
119
|
+
* - Rulesets: detect only → informational note (ruleset API requires full PUT)
|
|
120
|
+
* - Any error or non-TTY: fall back to informational note
|
|
121
|
+
*/
|
|
122
|
+
async function maybeAddRequiredCheck(repoRoot: string): Promise<void> {
|
|
123
|
+
try {
|
|
124
|
+
// Resolve the GitHub slug from the origin remote
|
|
125
|
+
const { stdout: remoteOut } = (await execFileAsync(
|
|
126
|
+
"git",
|
|
127
|
+
["remote", "get-url", "origin"],
|
|
128
|
+
{ cwd: repoRoot },
|
|
129
|
+
)) as unknown as { stdout: string };
|
|
130
|
+
const slug = remoteUrlToSlug(remoteOut.trim());
|
|
131
|
+
if (!slug) return;
|
|
132
|
+
|
|
133
|
+
// Get the default branch name
|
|
134
|
+
const repoData = await ghApiGet(`repos/${slug}`);
|
|
135
|
+
const branch = (repoData as { default_branch?: string } | null)
|
|
136
|
+
?.default_branch;
|
|
137
|
+
if (!branch) return;
|
|
138
|
+
|
|
139
|
+
// --- Classic branch protection ---
|
|
140
|
+
const protection = await ghApiGet(
|
|
141
|
+
`repos/${slug}/branches/${branch}/protection`,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (!protection) {
|
|
145
|
+
// No classic protection — check rulesets (detect-only)
|
|
146
|
+
const rulesets = await ghApiGet(`repos/${slug}/rulesets`);
|
|
147
|
+
if (Array.isArray(rulesets) && rulesets.length > 0) {
|
|
148
|
+
// Check if any ruleset already requires our check
|
|
149
|
+
const alreadyRequired = (
|
|
150
|
+
rulesets as Array<{
|
|
151
|
+
rules?: Array<{
|
|
152
|
+
type: string;
|
|
153
|
+
parameters?: {
|
|
154
|
+
required_status_checks?: Array<{ context: string }>;
|
|
155
|
+
};
|
|
156
|
+
}>;
|
|
157
|
+
}>
|
|
158
|
+
).some((rs) =>
|
|
159
|
+
rs.rules?.some(
|
|
160
|
+
(r) =>
|
|
161
|
+
r.type === "required_status_checks" &&
|
|
162
|
+
r.parameters?.required_status_checks?.some(
|
|
163
|
+
(c) => c.context === WORKFLOW_CHECK_NAME,
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
if (alreadyRequired) {
|
|
168
|
+
console.log(
|
|
169
|
+
`✓ '${WORKFLOW_CHECK_NAME}' already a required status check`,
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
console.log(
|
|
174
|
+
`\n ℹ️ Ruleset branch protection detected on '${branch}'.`,
|
|
175
|
+
);
|
|
176
|
+
console.log(
|
|
177
|
+
` Add '${WORKFLOW_CHECK_NAME}' to required status checks`,
|
|
178
|
+
);
|
|
179
|
+
console.log(
|
|
180
|
+
` in Settings → Rules to block merges on workflow failure.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Extract current required check names from classic protection
|
|
187
|
+
type Check = { context: string; app_id: number };
|
|
188
|
+
const prot = protection as {
|
|
189
|
+
required_status_checks?: {
|
|
190
|
+
strict: boolean;
|
|
191
|
+
contexts?: string[];
|
|
192
|
+
checks?: Check[];
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
const existingChecks: Check[] =
|
|
196
|
+
prot.required_status_checks?.checks ??
|
|
197
|
+
(prot.required_status_checks?.contexts ?? []).map((c) => ({
|
|
198
|
+
context: c,
|
|
199
|
+
app_id: -1,
|
|
200
|
+
}));
|
|
201
|
+
const strict = prot.required_status_checks?.strict ?? false;
|
|
202
|
+
|
|
203
|
+
if (existingChecks.some((c) => c.context === WORKFLOW_CHECK_NAME)) {
|
|
204
|
+
console.log(`✓ '${WORKFLOW_CHECK_NAME}' already a required status check`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Prompt
|
|
209
|
+
console.log(`\n Branch protection is active on '${branch}'.`);
|
|
210
|
+
const yes = await promptYesNo(
|
|
211
|
+
` Add '${WORKFLOW_CHECK_NAME}' as a required status check? [y/N] `,
|
|
212
|
+
);
|
|
213
|
+
if (!yes) {
|
|
214
|
+
printRequiredCheckNote(branch);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await patchRequiredChecks(slug, branch, {
|
|
219
|
+
strict,
|
|
220
|
+
checks: [...existingChecks, { context: WORKFLOW_CHECK_NAME, app_id: -1 }],
|
|
221
|
+
});
|
|
222
|
+
console.log(
|
|
223
|
+
`✓ Added '${WORKFLOW_CHECK_NAME}' as a required status check on '${branch}'`,
|
|
224
|
+
);
|
|
225
|
+
} catch {
|
|
226
|
+
// Any failure — don't break install, just print the note
|
|
227
|
+
console.log(
|
|
228
|
+
`\n ℹ️ Could not configure required status checks automatically.`,
|
|
229
|
+
);
|
|
230
|
+
console.log(
|
|
231
|
+
` To block merges on workflow failure, add '${WORKFLOW_CHECK_NAME}'`,
|
|
232
|
+
);
|
|
233
|
+
console.log(
|
|
234
|
+
` to required status checks in your branch protection settings.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
27
239
|
async function main() {
|
|
28
240
|
const args = process.argv.slice(2);
|
|
29
241
|
const runnerFlagIdx = args.findIndex((a: string) => a === "--runner");
|
|
@@ -78,23 +290,34 @@ async function main() {
|
|
|
78
290
|
const hookResult = await installGitHook(targetRepo);
|
|
79
291
|
if (hookResult === "noop") {
|
|
80
292
|
console.log("");
|
|
81
|
-
console.log(
|
|
82
|
-
|
|
293
|
+
console.log(
|
|
294
|
+
" ⚠️ Could not auto-edit lefthook config (unusual structure).",
|
|
295
|
+
);
|
|
296
|
+
console.log(
|
|
297
|
+
" Add this to your lefthook config under post-commit manually:",
|
|
298
|
+
);
|
|
83
299
|
console.log(" commands:");
|
|
84
300
|
console.log(" claude-attribution:");
|
|
85
301
|
console.log(" run: claude-attribution hook post-commit || true");
|
|
86
302
|
console.log("");
|
|
87
|
-
console.log(
|
|
88
|
-
" (skipped automatic hook install — Lefthook manages .git/hooks/)",
|
|
89
|
-
);
|
|
90
303
|
} else if (hookResult === "created") {
|
|
91
304
|
const hookPath =
|
|
92
305
|
manager === "husky" ? ".husky/post-commit" : ".git/hooks/post-commit";
|
|
93
306
|
console.log(`✓ Created ${hookPath}`);
|
|
94
307
|
} else if (hookResult === "appended") {
|
|
95
|
-
const
|
|
96
|
-
manager === "
|
|
97
|
-
|
|
308
|
+
const lefthookConfigFile =
|
|
309
|
+
manager === "lefthook"
|
|
310
|
+
? (["lefthook.yml", "lefthook.yaml", ".lefthook.yml"].find((name) =>
|
|
311
|
+
existsSync(join(targetRepo, name)),
|
|
312
|
+
) ?? "lefthook.yml")
|
|
313
|
+
: undefined;
|
|
314
|
+
const hookFile =
|
|
315
|
+
manager === "husky"
|
|
316
|
+
? ".husky/post-commit"
|
|
317
|
+
: manager === "lefthook"
|
|
318
|
+
? lefthookConfigFile!
|
|
319
|
+
: ".git/hooks/post-commit";
|
|
320
|
+
console.log(`✓ Added post-commit entry to ${hookFile}`);
|
|
98
321
|
} else {
|
|
99
322
|
// unchanged
|
|
100
323
|
console.log("✓ post-commit hook already up to date");
|
|
@@ -163,7 +386,10 @@ async function main() {
|
|
|
163
386
|
`✓ Installed .github/workflows/claude-attribution-pr.yml — runner: ${runsOn}${detectedNote}`,
|
|
164
387
|
);
|
|
165
388
|
|
|
166
|
-
// 5.
|
|
389
|
+
// 5. Check branch protection and offer to add required status check
|
|
390
|
+
await maybeAddRequiredCheck(targetRepo);
|
|
391
|
+
|
|
392
|
+
// 6. Record installed version for auto-upgrade tracking
|
|
167
393
|
const pkg = JSON.parse(
|
|
168
394
|
await readFile(join(ATTRIBUTION_ROOT, "package.json"), "utf8"),
|
|
169
395
|
) as { version: string };
|
package/src/setup/shared.ts
CHANGED
|
@@ -81,13 +81,111 @@ export type GitHookInstallResult =
|
|
|
81
81
|
| "created" // wrote a new hook file
|
|
82
82
|
| "appended" // appended to an existing hook file
|
|
83
83
|
| "unchanged" // hook already contained our entry; nothing written
|
|
84
|
-
| "noop"; //
|
|
84
|
+
| "noop"; // could not auto-edit (complex YAML structure) — manual config required
|
|
85
|
+
|
|
86
|
+
/** Lefthook config filenames, in priority order. Only YAML files are auto-edited. */
|
|
87
|
+
const LEFTHOOK_CONFIG_FILES = [
|
|
88
|
+
"lefthook.yml",
|
|
89
|
+
"lefthook.yaml",
|
|
90
|
+
".lefthook.yml",
|
|
91
|
+
"lefthook.json", // JSON files: detect-only, never auto-edited
|
|
92
|
+
".lefthook.json",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Attempt to add our post-commit entry to a lefthook YAML config file.
|
|
97
|
+
*
|
|
98
|
+
* Cases handled automatically:
|
|
99
|
+
* 1. No post-commit section → append our block at end of file
|
|
100
|
+
* 2. post-commit + commands section exists → detect indentation, insert entry
|
|
101
|
+
*
|
|
102
|
+
* Falls back to "noop" only when the post-commit section has an unrecognised
|
|
103
|
+
* structure (e.g. uses `scripts:` instead of `commands:`, or non-standard YAML).
|
|
104
|
+
* Returns "unchanged" if already configured.
|
|
105
|
+
*/
|
|
106
|
+
async function tryInstallLefthookYaml(
|
|
107
|
+
configPath: string,
|
|
108
|
+
): Promise<GitHookInstallResult> {
|
|
109
|
+
const content = await readFile(configPath, "utf8");
|
|
110
|
+
|
|
111
|
+
if (content.includes("claude-attribution")) return "unchanged";
|
|
112
|
+
|
|
113
|
+
const RUN_CMD = "claude-attribution hook post-commit || true";
|
|
114
|
+
|
|
115
|
+
// Case 1: no post-commit section — append entire block at the end.
|
|
116
|
+
if (!/^post-commit:/m.test(content)) {
|
|
117
|
+
await writeFile(
|
|
118
|
+
configPath,
|
|
119
|
+
content.trimEnd() +
|
|
120
|
+
"\n\npost-commit:\n commands:\n claude-attribution:\n run: " +
|
|
121
|
+
RUN_CMD +
|
|
122
|
+
"\n",
|
|
123
|
+
);
|
|
124
|
+
return "appended";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Case 2: post-commit section exists — find the commands: block and insert.
|
|
128
|
+
const lines = content.split("\n");
|
|
129
|
+
const postCommitIdx = lines.findIndex((l) => /^post-commit:/.test(l ?? ""));
|
|
130
|
+
if (postCommitIdx === -1) return "noop";
|
|
131
|
+
|
|
132
|
+
// Find the commands: line within the post-commit block.
|
|
133
|
+
let commandsIdx = -1;
|
|
134
|
+
for (let i = postCommitIdx + 1; i < lines.length; i++) {
|
|
135
|
+
const line = lines[i] ?? "";
|
|
136
|
+
if (!line.trim()) continue;
|
|
137
|
+
if (/^\S/.test(line)) break; // hit another top-level key
|
|
138
|
+
if (/^\s+commands:\s*$/.test(line)) {
|
|
139
|
+
commandsIdx = i;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (commandsIdx === -1) return "noop"; // uses scripts: or unusual structure
|
|
144
|
+
|
|
145
|
+
// Detect indentation from the commands: line itself.
|
|
146
|
+
const commandsLine = lines[commandsIdx] ?? "";
|
|
147
|
+
const baseIndent = commandsLine.match(/^(\s+)/)?.[1] ?? " ";
|
|
148
|
+
const cmdIndent = baseIndent + " ";
|
|
149
|
+
const propIndent = cmdIndent + " ";
|
|
150
|
+
|
|
151
|
+
// Find the last indented line of the commands block.
|
|
152
|
+
// Stop when we reach a top-level key OR when indentation returns to
|
|
153
|
+
// baseIndent.length or less (i.e. a sibling key of commands:, such as
|
|
154
|
+
// parallel: or runner:, that would otherwise be treated as a command entry).
|
|
155
|
+
let insertAfterIdx = commandsIdx;
|
|
156
|
+
for (let i = commandsIdx + 1; i < lines.length; i++) {
|
|
157
|
+
const line = lines[i] ?? "";
|
|
158
|
+
if (!line.trim()) continue;
|
|
159
|
+
if (/^\S/.test(line)) break;
|
|
160
|
+
const indentLength = (line.match(/^(\s*)/) ?? ["", ""])[1]?.length ?? 0;
|
|
161
|
+
if (indentLength <= baseIndent.length) break;
|
|
162
|
+
insertAfterIdx = i;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const entry = [
|
|
166
|
+
`${cmdIndent}claude-attribution:`,
|
|
167
|
+
`${propIndent}run: ${RUN_CMD}`,
|
|
168
|
+
];
|
|
169
|
+
const newLines = [
|
|
170
|
+
...lines.slice(0, insertAfterIdx + 1),
|
|
171
|
+
...entry,
|
|
172
|
+
...lines.slice(insertAfterIdx + 1),
|
|
173
|
+
];
|
|
174
|
+
await writeFile(configPath, newLines.join("\n"));
|
|
175
|
+
return "appended";
|
|
176
|
+
}
|
|
85
177
|
|
|
86
178
|
/**
|
|
87
179
|
* Install the post-commit git hook, respecting husky/lefthook if present.
|
|
88
180
|
* Idempotent — safe to call on already-installed repos.
|
|
89
|
-
*
|
|
90
|
-
*
|
|
181
|
+
*
|
|
182
|
+
* Lefthook: auto-edits the YAML config to add our command entry. Falls back
|
|
183
|
+
* to "noop" (with manual instructions) only for unusual config structures.
|
|
184
|
+
*
|
|
185
|
+
* Husky: appends to .husky/post-commit if present; creates it if not.
|
|
186
|
+
*
|
|
187
|
+
* Plain git hooks: surgically appends our entry. Returns "unchanged" if
|
|
188
|
+
* already present — never clobbers hooks written by other tools.
|
|
91
189
|
*/
|
|
92
190
|
export async function installGitHook(
|
|
93
191
|
repoRoot: string,
|
|
@@ -118,11 +216,31 @@ export async function installGitHook(
|
|
|
118
216
|
}
|
|
119
217
|
|
|
120
218
|
if (manager === "lefthook") {
|
|
121
|
-
//
|
|
219
|
+
// Try to auto-edit the YAML config; fall back to noop for JSON or
|
|
220
|
+
// complex structures that are not safe to modify programmatically.
|
|
221
|
+
for (const configFile of LEFTHOOK_CONFIG_FILES) {
|
|
222
|
+
const configPath = join(repoRoot, configFile);
|
|
223
|
+
if (!existsSync(configPath)) continue;
|
|
224
|
+
if (!configFile.endsWith(".yml") && !configFile.endsWith(".yaml")) {
|
|
225
|
+
// JSON config: detect-only, never auto-edit
|
|
226
|
+
try {
|
|
227
|
+
const content = await readFile(configPath, "utf8");
|
|
228
|
+
if (content.includes("claude-attribution")) return "unchanged";
|
|
229
|
+
} catch {
|
|
230
|
+
// skip
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
return await tryInstallLefthookYaml(configPath);
|
|
236
|
+
} catch {
|
|
237
|
+
return "noop";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
122
240
|
return "noop";
|
|
123
241
|
}
|
|
124
242
|
|
|
125
|
-
// Plain git hooks
|
|
243
|
+
// Plain git hooks — surgical append only, never overwrite the whole file.
|
|
126
244
|
const hookDest = join(repoRoot, ".git", "hooks", "post-commit");
|
|
127
245
|
const template = await readFile(
|
|
128
246
|
join(ATTRIBUTION_ROOT, "src", "setup", "templates", "post-commit.sh"),
|
|
@@ -131,15 +249,16 @@ export async function installGitHook(
|
|
|
131
249
|
|
|
132
250
|
if (existsSync(hookDest)) {
|
|
133
251
|
const existing = await readFile(hookDest, "utf8");
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
existing.trimEnd() + "\n\n# claude-attribution\n" + template,
|
|
138
|
-
);
|
|
139
|
-
await chmod(hookDest, 0o755);
|
|
140
|
-
return "appended";
|
|
252
|
+
if (existing.includes("claude-attribution")) {
|
|
253
|
+
// Already present — do not overwrite other hooks in the file.
|
|
254
|
+
return "unchanged";
|
|
141
255
|
}
|
|
142
|
-
|
|
256
|
+
await writeFile(
|
|
257
|
+
hookDest,
|
|
258
|
+
existing.trimEnd() + "\n\n# claude-attribution\n" + template,
|
|
259
|
+
);
|
|
260
|
+
await chmod(hookDest, 0o755);
|
|
261
|
+
return "appended";
|
|
143
262
|
}
|
|
144
263
|
|
|
145
264
|
await writeFile(hookDest, template);
|