claude-attribution 1.2.2 → 1.2.4

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.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "AI code attribution tracking for Claude Code sessions — checkpoint-based line diff approach",
5
5
  "type": "module",
6
6
  "bin": {
@@ -78,23 +78,34 @@ async function main() {
78
78
  const hookResult = await installGitHook(targetRepo);
79
79
  if (hookResult === "noop") {
80
80
  console.log("");
81
- console.log(" ⚠️ Lefthook detected. Add this to lefthook.yml manually:");
82
- console.log(" post-commit:");
81
+ console.log(
82
+ " ⚠️ Could not auto-edit lefthook config (unusual structure).",
83
+ );
84
+ console.log(
85
+ " Add this to your lefthook config under post-commit manually:",
86
+ );
83
87
  console.log(" commands:");
84
88
  console.log(" claude-attribution:");
85
89
  console.log(" run: claude-attribution hook post-commit || true");
86
90
  console.log("");
87
- console.log(
88
- " (skipped automatic hook install — Lefthook manages .git/hooks/)",
89
- );
90
91
  } else if (hookResult === "created") {
91
92
  const hookPath =
92
93
  manager === "husky" ? ".husky/post-commit" : ".git/hooks/post-commit";
93
94
  console.log(`✓ Created ${hookPath}`);
94
95
  } else if (hookResult === "appended") {
95
- const hookPath =
96
- manager === "husky" ? ".husky/post-commit" : ".git/hooks/post-commit";
97
- console.log(`✓ Appended to existing ${hookPath}`);
96
+ const lefthookConfigFile =
97
+ manager === "lefthook"
98
+ ? (["lefthook.yml", "lefthook.yaml", ".lefthook.yml"].find((name) =>
99
+ existsSync(join(targetRepo, name)),
100
+ ) ?? "lefthook.yml")
101
+ : undefined;
102
+ const hookFile =
103
+ manager === "husky"
104
+ ? ".husky/post-commit"
105
+ : manager === "lefthook"
106
+ ? lefthookConfigFile!
107
+ : ".git/hooks/post-commit";
108
+ console.log(`✓ Added post-commit entry to ${hookFile}`);
98
109
  } else {
99
110
  // unchanged
100
111
  console.log("✓ post-commit hook already up to date");
@@ -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"; // Lefthook detected — manual config required, nothing written
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
- * Note: for Lefthook repos this is a no-op (manual config required).
90
- * Returns a GitHookInstallResult describing what was done.
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
- // Cannot safely auto-edit YAML caller should inform the user
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 (!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";
252
+ if (existing.includes("claude-attribution")) {
253
+ // Already present — do not overwrite other hooks in the file.
254
+ return "unchanged";
141
255
  }
142
- // Already ours — replace with latest template
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);