@rachel_rotenberg/ai-contribution-tracker 1.0.18 → 1.0.19

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.
Files changed (2) hide show
  1. package/cli.js +360 -0
  2. package/package.json +9 -5
package/cli.js ADDED
@@ -0,0 +1,360 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AI Contribution Tracker — Standalone CLI Installer
4
+ *
5
+ * Installs the git commit-msg hook and registers the OpenCode plugin
6
+ * without requiring VS Code. Works on macOS, Linux, and Windows.
7
+ *
8
+ * Usage:
9
+ * npx @rachel_rotenberg/ai-contribution-tracker init # Install everything
10
+ * npx @rachel_rotenberg/ai-contribution-tracker status # Show what's installed
11
+ * npx @rachel_rotenberg/ai-contribution-tracker remove # Uninstall hooks + plugin
12
+ */
13
+ "use strict";
14
+
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+ const os = require("os");
18
+ const { execSync } = require("child_process");
19
+
20
+ // ─── Constants ──────────────────────────────────────────────
21
+ const PLUGIN_NAME = "@rachel_rotenberg/ai-contribution-tracker";
22
+ const HOOK_MARKER = "AI_IMPACT_PENDING";
23
+
24
+ // ─── Logging helpers ────────────────────────────────────────
25
+ function ok(msg) { console.log(` \u2713 ${msg}`); }
26
+ function skip(msg) { console.log(` - ${msg}`); }
27
+ function warn(msg) { console.log(` ! ${msg}`); }
28
+ function fail(msg) { console.error(` \u2717 ${msg}`); }
29
+
30
+ // ─── Git hook body (shell script, runs on all platforms via Git Bash) ───
31
+ const HOOK_BODY = [
32
+ "",
33
+ "# AI Contribution Tracker \u2014 reads AI_IMPACT_PENDING flag",
34
+ 'IMPACT_FLAG=$(git rev-parse --git-path AI_IMPACT_PENDING)',
35
+ 'STATE_FILE=$(git rev-parse --git-path ai-tracker-state.json)',
36
+ 'if [ -f "$IMPACT_FLAG" ]; then',
37
+ ' MARKER=$(cat "$IMPACT_FLAG")',
38
+ ' if [ -z "$MARKER" ]; then MARKER="Impacted by AI"; fi',
39
+ ' if ! grep -qF "Impacted by AI" "$1"; then',
40
+ ' echo "" >> "$1"',
41
+ ' echo "$MARKER" >> "$1"',
42
+ ' fi',
43
+ ' rm "$IMPACT_FLAG"',
44
+ 'fi',
45
+ 'if [ -f "$STATE_FILE" ]; then rm "$STATE_FILE"; fi',
46
+ ].join("\n");
47
+
48
+ // ─── Git hook installation ──────────────────────────────────
49
+
50
+ /** Append our hook snippet to an existing commit-msg hook or create a new one. */
51
+ function appendOrCreateHook(hooksDir) {
52
+ const hookPath = path.join(hooksDir, "commit-msg");
53
+
54
+ if (fs.existsSync(hookPath)) {
55
+ const existing = fs.readFileSync(hookPath, "utf8");
56
+ if (existing.includes(HOOK_MARKER)) {
57
+ skip(`commit-msg hook already has AI tracker snippet: ${hookPath}`);
58
+ return;
59
+ }
60
+ fs.appendFileSync(hookPath, "\n" + HOOK_BODY + "\n");
61
+ ok(`Appended AI tracker snippet to existing hook: ${hookPath}`);
62
+ } else {
63
+ const content = ("#!/bin/sh\n" + HOOK_BODY + "\n").replace(/\r\n/g, "\n");
64
+ fs.writeFileSync(hookPath, content);
65
+ ok(`Created commit-msg hook: ${hookPath}`);
66
+ }
67
+
68
+ // Make executable (no-op on Windows; Git for Windows handles this via Git Bash)
69
+ try { fs.chmodSync(hookPath, "755"); } catch { /* Windows */ }
70
+ }
71
+
72
+ /** Install the global git commit-msg hook. */
73
+ function installGitHook() {
74
+ console.log("\nGit commit-msg hook:");
75
+
76
+ // Check if core.hooksPath is already set
77
+ let existingPath = "";
78
+ try {
79
+ existingPath = execSync("git config --global core.hooksPath", {
80
+ encoding: "utf8",
81
+ stdio: ["pipe", "pipe", "pipe"],
82
+ }).trim();
83
+ } catch {
84
+ // Not set — we'll create our own
85
+ }
86
+
87
+ if (existingPath) {
88
+ // Use existing hooksPath directory
89
+ fs.mkdirSync(existingPath, { recursive: true });
90
+ appendOrCreateHook(existingPath);
91
+ } else {
92
+ const hooksDir = path.join(os.homedir(), ".config", "ai-contribution-tracker", "git-hooks");
93
+ fs.mkdirSync(hooksDir, { recursive: true });
94
+ appendOrCreateHook(hooksDir);
95
+
96
+ try {
97
+ const gitPath = hooksDir.replace(/\\/g, "/");
98
+ execSync(`git config --global core.hooksPath "${gitPath}"`, {
99
+ encoding: "utf8",
100
+ stdio: ["pipe", "pipe", "pipe"],
101
+ });
102
+ ok(`Set git global core.hooksPath: ${gitPath}`);
103
+ } catch (e) {
104
+ fail(`Could not set core.hooksPath: ${e.message}`);
105
+ }
106
+ }
107
+ }
108
+
109
+ // ─── OpenCode plugin registration ───────────────────────────
110
+
111
+ /** Add the plugin to the "plugin" array in an opencode.json file. */
112
+ function addPluginToConfig(configDir) {
113
+ try {
114
+ fs.mkdirSync(configDir, { recursive: true });
115
+ const configPath = path.join(configDir, "opencode.json");
116
+
117
+ let config = {};
118
+ if (fs.existsSync(configPath)) {
119
+ try {
120
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
121
+ } catch {
122
+ // Malformed JSON — back up and start fresh
123
+ fs.copyFileSync(configPath, configPath + ".bak");
124
+ warn(`Backed up malformed ${configPath} to ${configPath}.bak`);
125
+ config = {};
126
+ }
127
+ }
128
+
129
+ let plugins = config.plugin;
130
+ if (!Array.isArray(plugins)) {
131
+ plugins = [];
132
+ }
133
+
134
+ // Check if already registered (plain string or [name, options] tuple)
135
+ const alreadyRegistered = plugins.some(
136
+ (p) => (typeof p === "string" ? p : p[0]) === PLUGIN_NAME
137
+ );
138
+
139
+ if (alreadyRegistered) {
140
+ skip(`Plugin already in: ${configPath}`);
141
+ return;
142
+ }
143
+
144
+ plugins.push(PLUGIN_NAME);
145
+ config.plugin = plugins;
146
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
147
+ ok(`Added plugin to: ${configPath}`);
148
+ } catch (e) {
149
+ fail(`Could not update ${configDir}: ${e.message}`);
150
+ }
151
+ }
152
+
153
+ function installOpenCodePlugin() {
154
+ console.log("\nOpenCode plugin:");
155
+ const configDir = path.join(os.homedir(), ".config", "opencode");
156
+ addPluginToConfig(configDir);
157
+ }
158
+
159
+ // ─── Status check ───────────────────────────────────────────
160
+
161
+ function showStatus() {
162
+ console.log("\nAI Contribution Tracker \u2014 Status\n");
163
+
164
+ // Git hook
165
+ let hooksPath = "";
166
+ try {
167
+ hooksPath = execSync("git config --global core.hooksPath", {
168
+ encoding: "utf8",
169
+ stdio: ["pipe", "pipe", "pipe"],
170
+ }).trim();
171
+ } catch { /* not set */ }
172
+
173
+ if (hooksPath) {
174
+ const hookFile = path.join(hooksPath, "commit-msg");
175
+ if (fs.existsSync(hookFile) && fs.readFileSync(hookFile, "utf8").includes(HOOK_MARKER)) {
176
+ ok(`Git hook installed: ${hookFile}`);
177
+ } else {
178
+ warn(`core.hooksPath set to ${hooksPath} but no AI tracker snippet found`);
179
+ }
180
+ } else {
181
+ warn("No global core.hooksPath set \u2014 git hook not installed");
182
+ }
183
+
184
+ // OpenCode plugin
185
+ const configPath = path.join(os.homedir(), ".config", "opencode", "opencode.json");
186
+ if (fs.existsSync(configPath)) {
187
+ try {
188
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
189
+ const plugins = Array.isArray(config.plugin) ? config.plugin : [];
190
+ const found = plugins.some(
191
+ (p) => (typeof p === "string" ? p : p[0]) === PLUGIN_NAME
192
+ );
193
+ if (found) {
194
+ ok(`OpenCode plugin registered: ${configPath}`);
195
+ } else {
196
+ warn(`opencode.json exists but plugin not in plugin array: ${configPath}`);
197
+ }
198
+ } catch {
199
+ warn(`Could not parse: ${configPath}`);
200
+ }
201
+ } else {
202
+ skip(`No opencode.json found at: ${configPath}`);
203
+ }
204
+ }
205
+
206
+ // ─── Uninstall ──────────────────────────────────────────────
207
+
208
+ function removeGitHook() {
209
+ console.log("\nGit commit-msg hook:");
210
+
211
+ let hooksPath = "";
212
+ try {
213
+ hooksPath = execSync("git config --global core.hooksPath", {
214
+ encoding: "utf8",
215
+ stdio: ["pipe", "pipe", "pipe"],
216
+ }).trim();
217
+ } catch { /* not set */ }
218
+
219
+ if (!hooksPath) {
220
+ skip("No global core.hooksPath set \u2014 nothing to remove");
221
+ return;
222
+ }
223
+
224
+ const hookFile = path.join(hooksPath, "commit-msg");
225
+ if (!fs.existsSync(hookFile)) {
226
+ skip(`No commit-msg hook at: ${hookFile}`);
227
+ return;
228
+ }
229
+
230
+ const content = fs.readFileSync(hookFile, "utf8");
231
+ if (!content.includes(HOOK_MARKER)) {
232
+ skip("commit-msg hook does not contain AI tracker snippet");
233
+ return;
234
+ }
235
+
236
+ const lines = content.split("\n");
237
+ const filtered = [];
238
+ let skipping = false;
239
+
240
+ for (let i = 0; i < lines.length; i++) {
241
+ if (lines[i].includes("AI Contribution Tracker")) {
242
+ skipping = true;
243
+ continue;
244
+ }
245
+ if (skipping) {
246
+ if (lines[i].includes('rm "$STATE_FILE"')) {
247
+ // consume the closing `fi` on the next non-empty line, then stop skipping
248
+ for (let j = i + 1; j < lines.length; j++) {
249
+ if (lines[j].trim() === "fi") { i = j; break; }
250
+ if (lines[j].trim() !== "") { i = j - 1; break; }
251
+ }
252
+ skipping = false;
253
+ continue;
254
+ }
255
+ continue;
256
+ }
257
+ filtered.push(lines[i]);
258
+ }
259
+
260
+ const remaining = filtered.join("\n").trim();
261
+ if (remaining === "#!/bin/sh" || remaining === "") {
262
+ fs.unlinkSync(hookFile);
263
+ ok(`Removed commit-msg hook: ${hookFile}`);
264
+ } else {
265
+ fs.writeFileSync(hookFile, remaining + "\n");
266
+ ok(`Removed AI tracker snippet from: ${hookFile}`);
267
+ }
268
+ }
269
+
270
+ function removeOpenCodePlugin() {
271
+ console.log("\nOpenCode plugin:");
272
+
273
+ const configPath = path.join(os.homedir(), ".config", "opencode", "opencode.json");
274
+ if (!fs.existsSync(configPath)) {
275
+ skip("No opencode.json found");
276
+ return;
277
+ }
278
+
279
+ try {
280
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
281
+ if (!Array.isArray(config.plugin)) {
282
+ skip("No plugin array in opencode.json");
283
+ return;
284
+ }
285
+
286
+ const before = config.plugin.length;
287
+ config.plugin = config.plugin.filter(
288
+ (p) => (typeof p === "string" ? p : p[0]) !== PLUGIN_NAME
289
+ );
290
+
291
+ if (config.plugin.length === before) {
292
+ skip("Plugin was not registered");
293
+ return;
294
+ }
295
+
296
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
297
+ ok(`Removed plugin from: ${configPath}`);
298
+ } catch (e) {
299
+ fail(`Could not update ${configPath}: ${e.message}`);
300
+ }
301
+ }
302
+
303
+ // ─── Main ───────────────────────────────────────────────────
304
+
305
+ function printUsage() {
306
+ console.log(`
307
+ AI Contribution Tracker \u2014 CLI Installer
308
+
309
+ Installs git hooks and OpenCode plugin without requiring VS Code.
310
+ Works on macOS, Linux, and Windows.
311
+
312
+ Usage:
313
+ npx ${PLUGIN_NAME} init Install git hook + register OpenCode plugin
314
+ npx ${PLUGIN_NAME} status Show installation status
315
+ npx ${PLUGIN_NAME} remove Uninstall git hook + unregister plugin
316
+ npx ${PLUGIN_NAME} --help Show this help message
317
+
318
+ What gets installed:
319
+ 1. Global git commit-msg hook (tags every AI-assisted commit)
320
+ 2. OpenCode plugin entry in ~/.config/opencode/opencode.json
321
+ `);
322
+ }
323
+
324
+ const command = process.argv[2];
325
+
326
+ switch (command) {
327
+ case "init":
328
+ case "install":
329
+ console.log("\nAI Contribution Tracker \u2014 Installing...\n");
330
+ console.log(`Platform: ${process.platform} (${os.arch()})`);
331
+ installGitHook();
332
+ installOpenCodePlugin();
333
+ console.log("\nDone. All future AI-assisted commits will be tagged automatically.\n");
334
+ break;
335
+
336
+ case "status":
337
+ showStatus();
338
+ break;
339
+
340
+ case "remove":
341
+ case "uninstall":
342
+ console.log("\nAI Contribution Tracker \u2014 Removing...");
343
+ removeGitHook();
344
+ removeOpenCodePlugin();
345
+ console.log("\nDone.\n");
346
+ break;
347
+
348
+ case "--help":
349
+ case "-h":
350
+ case "help":
351
+ printUsage();
352
+ break;
353
+
354
+ default:
355
+ if (command) {
356
+ console.error(`Unknown command: ${command}\n`);
357
+ }
358
+ printUsage();
359
+ process.exit(command ? 1 : 0);
360
+ }
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@rachel_rotenberg/ai-contribution-tracker",
3
- "version": "1.0.18",
4
- "description": "OpenCode plugin \u2014 tracks AI coding sessions and tags git commits with Impacted by AI markers",
3
+ "version": "1.0.19",
4
+ "description": "OpenCode plugin tracks AI coding sessions and tags git commits with Impacted by AI markers",
5
5
  "main": "index.ts",
6
6
  "types": "index.ts",
7
+ "bin": {
8
+ "ai-contribution-tracker": "cli.js"
9
+ },
7
10
  "files": [
8
- "index.ts"
11
+ "index.ts",
12
+ "cli.js"
9
13
  ],
10
14
  "exports": {
11
15
  ".": {
@@ -24,9 +28,9 @@
24
28
  "license": "MIT",
25
29
  "repository": {
26
30
  "type": "git",
27
- "url": "https://github.com/YoavLax/AI-Contribution-Tracker"
31
+ "url": "git+https://github.com/YoavLax/AI-Contribution-Tracker.git"
28
32
  },
29
33
  "peerDependencies": {
30
34
  "@opencode-ai/plugin": ">=1.0.0"
31
35
  }
32
- }
36
+ }