@coworker-jp/aidr 0.0.1

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/src/cli.mjs ADDED
@@ -0,0 +1,494 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ import fs from "node:fs/promises";
4
+ import readline from "node:readline";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { fileURLToPath } from "node:url";
8
+ import { Command } from "commander";
9
+
10
+ const execFileP = promisify(execFile);
11
+ import { AGENT_ORDER, AGENT_REGISTRY, loadAgent } from "./agents/index.mjs";
12
+ import { verifyKey, maskKey } from "./verify.mjs";
13
+ import { fetchBinary, fetchOpengrep } from "./binary-fetcher.mjs";
14
+ import { detectInstalled } from "./detect.mjs";
15
+ import { installBrowserExtension } from "./browser-extension.mjs";
16
+
17
+ // Package identity — read at module load from the bundled package.json.
18
+ // The PROD publish workflow renames the package to `@coworker-jp/aidr`;
19
+ // DEV keeps `@coworker-jp/aidr-dev`. We key the default environment
20
+ // (verify endpoint + S3 bucket) off the package name so users don't need to
21
+ // remember `--env dev` when they explicitly `npx` the `-dev` package. The
22
+ // PROD publish also pins version to the Cargo.toml release so scanner binary
23
+ // URLs resolve to `v{X.Y.Z}/`; DEV uses a `-dev.<run_number>` prerelease that
24
+ // never matches, so DEV falls back to the latest S3 prefix.
25
+ const __pkgDir = path.dirname(fileURLToPath(import.meta.url));
26
+ const PKG = JSON.parse(
27
+ await fs.readFile(path.resolve(__pkgDir, "..", "package.json"), "utf8"),
28
+ );
29
+ const IS_PROD_PACKAGE = PKG.name === "@coworker-jp/aidr";
30
+ const PINNED_VERSION = IS_PROD_PACKAGE ? PKG.version : undefined;
31
+ const DEFAULT_ENV = IS_PROD_PACKAGE ? "prod" : "dev";
32
+
33
+ const ACCESS_KEY_RE = /^ak_[A-Za-z0-9_-]{43}$/;
34
+
35
+ function validateKey(key) {
36
+ if (!ACCESS_KEY_RE.test(key)) {
37
+ throw new Error(`invalid access key format (expected ak_<43-char urlsafe base64>)`);
38
+ }
39
+ }
40
+
41
+ function parseAgentList(val) {
42
+ return String(val).split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
43
+ }
44
+
45
+ // Shared settings.json paths per agent — only claude and gemini write into a
46
+ // config file that users may have hand-authored. Other agents' hook files are
47
+ // entirely owned by aidr, so no confirmation is needed for them.
48
+ function sharedSettingsPath(agentName, base) {
49
+ if (agentName === "claude") return path.join(base, ".claude", "settings.json");
50
+ if (agentName === "gemini") return path.join(base, ".gemini", "settings.json");
51
+ return null;
52
+ }
53
+
54
+ async function fileExists(p) {
55
+ try { await fs.access(p); return true; } catch { return false; }
56
+ }
57
+
58
+ async function promptYesNo(message) {
59
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
60
+ try {
61
+ const answer = await new Promise((resolve) => {
62
+ rl.question(`${message} [y/N] `, resolve);
63
+ });
64
+ return /^y(es)?$/i.test(String(answer).trim());
65
+ } finally {
66
+ rl.close();
67
+ }
68
+ }
69
+
70
+ async function cmdInstall(opts) {
71
+ // `install --uninstall` is a convenience alias so users can toggle
72
+ // between install/uninstall with a single memorised command. It forwards
73
+ // the relevant options (agent, all, scope, yes, dry-run) to the uninstall
74
+ // flow. --key isn't needed but commander requires it via requiredOption,
75
+ // so we accept and ignore it.
76
+ if (opts.uninstall) {
77
+ return cmdUninstall({
78
+ agent: opts.agent,
79
+ all: opts.all,
80
+ scope: opts.scope,
81
+ yes: opts.yes,
82
+ dryRun: opts.dryRun,
83
+ });
84
+ }
85
+
86
+ const home = process.env.HOME || os.homedir();
87
+ const scope = opts.scope || "user";
88
+ const base = scope === "user" ? home : process.cwd();
89
+ validateKey(opts.key);
90
+
91
+ if (opts.verify !== false) {
92
+ try {
93
+ const info = await verifyKey(opts.key, opts.env || DEFAULT_ENV);
94
+ console.log(`verified: plan=${info.plan} expires_at=${info.expires_at || "n/a"}`);
95
+ } catch (e) {
96
+ console.error(`verify failed: ${e.message}`);
97
+ process.exit(1);
98
+ }
99
+ }
100
+
101
+ let agents;
102
+ if (opts.all) {
103
+ agents = detectInstalled(home);
104
+ if (agents.length === 0) {
105
+ console.error("no agents detected under $HOME; specify --agent explicitly");
106
+ process.exit(1);
107
+ }
108
+ } else if (opts.agent) {
109
+ agents = parseAgentList(opts.agent);
110
+ } else {
111
+ console.error("--agent <list> or --all is required");
112
+ process.exit(1);
113
+ }
114
+
115
+ for (const name of agents) {
116
+ if (!AGENT_REGISTRY[name]) {
117
+ console.error(`unknown agent: ${name} (see 'aidr list-agents')`);
118
+ process.exit(1);
119
+ }
120
+ }
121
+
122
+ // Standalone has no hook scripts and no settings.json — scheduled
123
+ // endpoint-info collection is the whole reason to pick it, so turn
124
+ // --scheduled on by default when standalone is selected. --binary-only
125
+ // still short-circuits later.
126
+ if (agents.includes("standalone")) {
127
+ opts.scheduled = true;
128
+ }
129
+
130
+ // --scheduled installs a cron / systemd / launchd entry that runs
131
+ // `ai-scanner scan endpoint-info` periodically against an agent-independent
132
+ // binary at ~/.aidr/bin/ai-scanner. Auto-add the standalone install so the
133
+ // binary fetch loop drops a copy there even when the user only listed AI
134
+ // agents (claude / cursor / ...) on the command line.
135
+ if (opts.scheduled && !agents.includes("standalone")) {
136
+ agents.push("standalone");
137
+ }
138
+
139
+ const intervalHours = parseInt(opts.interval ?? "4", 10);
140
+ if (opts.scheduled && (isNaN(intervalHours) || intervalHours < 1)) {
141
+ console.error("--interval must be an integer >= 1");
142
+ process.exit(1);
143
+ }
144
+
145
+ // Confirmation for existing shared settings.json (claude / gemini).
146
+ // --yes bypasses; --dry-run doesn't write so skip; otherwise prompt on TTY,
147
+ // error if non-interactive (safer than silently merging in CI).
148
+ if (!opts.yes && !opts.dryRun) {
149
+ const conflicts = (await Promise.all(
150
+ agents.map(async (name) => {
151
+ const p = sharedSettingsPath(name, base);
152
+ return (p && await fileExists(p)) ? p : null;
153
+ })
154
+ )).filter(Boolean);
155
+ if (conflicts.length > 0) {
156
+ const list = conflicts.map(p => ` - ${p}`).join("\n");
157
+ if (!process.stdin.isTTY) {
158
+ console.error(`Existing settings.json detected:\n${list}`);
159
+ console.error(`aidr will merge into these files and create timestamped backups.`);
160
+ console.error(`Re-run with -y/--yes to proceed non-interactively, or run in an interactive terminal.`);
161
+ process.exit(1);
162
+ }
163
+ console.error(`Existing settings.json detected:\n${list}`);
164
+ console.error(`aidr will merge ai-scanner env/hooks into each file and back up the original.`);
165
+ const ok = await promptYesNo("Proceed?");
166
+ if (!ok) {
167
+ console.error("aborted");
168
+ process.exit(1);
169
+ }
170
+ }
171
+ }
172
+
173
+ if (!opts.skipBinary && !opts.dryRun) {
174
+ const agentBinDirs = [];
175
+ for (const name of agents) {
176
+ const mod = await loadAgent(name);
177
+ const agentDir = mod.meta.agentDir;
178
+ if (!agentDir) continue;
179
+ agentBinDirs.push(path.join(home, agentDir, "bin"));
180
+ }
181
+ if (agentBinDirs.length === 0) {
182
+ console.error("no agents with binary support selected; skipping binary fetch");
183
+ } else {
184
+ try {
185
+ const [primaryDir, ...restDirs] = agentBinDirs;
186
+ const fetchOpts = {
187
+ env: opts.env || DEFAULT_ENV,
188
+ accessKey: opts.key,
189
+ version: PINNED_VERSION,
190
+ };
191
+
192
+ // ai-scanner + bundled Opengrep (LGPL-2.1). Both land in the same bin
193
+ // dir so ai-scanner's sibling-lookup finds opengrep without relying on
194
+ // PATH. Users can point AI_SCANNER_OPENGREP elsewhere to substitute
195
+ // their own LGPL-2.1 build.
196
+ const scannerRes = await fetchBinary(path.join(primaryDir, "ai-scanner"), fetchOpts);
197
+ console.log(`binary: ${scannerRes.path} (${scannerRes.platform}${scannerRes.verified ? ", sha256 verified" : ""})`);
198
+ // Opengrep is fetched unmodified from its public upstream GitHub
199
+ // release (LGPL-2.1); no access-key gating is needed here.
200
+ const opengrepRes = await fetchOpengrep(path.join(primaryDir, "opengrep"));
201
+ console.log(`binary: ${opengrepRes.path} (opengrep ${opengrepRes.platform}${opengrepRes.verified ? ", sha256 verified" : ""})`);
202
+
203
+ const copyOps = restDirs.flatMap(restDir =>
204
+ ["ai-scanner", "opengrep"].map(async (file) => {
205
+ const src = path.join(primaryDir, file);
206
+ const dest = path.join(restDir, file);
207
+ await fs.mkdir(restDir, { recursive: true });
208
+ await fs.copyFile(src, dest);
209
+ await fs.chmod(dest, 0o755);
210
+ if (process.platform === "darwin") {
211
+ try { await execFileP("xattr", ["-d", "com.apple.quarantine", dest]); } catch {}
212
+ }
213
+ console.log(`binary: ${dest} (copied)`);
214
+ })
215
+ );
216
+ await Promise.all(copyOps);
217
+
218
+ console.log("Includes Opengrep (LGPL-2.1).");
219
+ } catch (e) {
220
+ console.error(`binary fetch failed: ${e.message}`);
221
+ if (!opts.binaryOnly) console.error("continuing with hook install (use --skip-binary to suppress)");
222
+ else process.exit(1);
223
+ }
224
+ }
225
+ }
226
+
227
+ if (opts.binaryOnly) return;
228
+
229
+ const installResults = (await Promise.all(agents.map(async (name) => {
230
+ const mod = await loadAgent(name);
231
+ try {
232
+ const res = await mod.install(base, opts.key, { dryRun: opts.dryRun, force: opts.force, env: opts.env || DEFAULT_ENV, scope, home });
233
+ console.log(`[${name}] installed (key=${maskKey(opts.key)})`);
234
+ // Prefer the aggregated backupPaths list (settings + scripts); fall back
235
+ // to the legacy single backupPath for agents that don't report scripts yet.
236
+ const backups = (res && res.backupPaths && res.backupPaths.length)
237
+ ? res.backupPaths
238
+ : (res && res.backupPath ? [res.backupPath] : []);
239
+ for (const bp of backups) {
240
+ console.log(`[${name}] existing file backed up to ${bp}`);
241
+ }
242
+ return { name, displayName: mod.meta.displayName || name };
243
+ } catch (e) {
244
+ console.error(`[${name}] install failed: ${e.message}`);
245
+ process.exit(1);
246
+ }
247
+ }))).filter(Boolean);
248
+ // Standalone has no hooks to restart — exclude it from the restart notice.
249
+ const restartAgents = installResults
250
+ .filter((r) => r.name !== "standalone")
251
+ .map((r) => r.displayName);
252
+
253
+ if (opts.scheduled && !opts.dryRun) {
254
+ const { installScheduled } = await import("./scheduled.mjs");
255
+ // Always point the cron / systemd / launchd entry at the standalone
256
+ // binary (~/.aidr/bin/ai-scanner). It is agent-independent, self-updates
257
+ // on every endpoint-info run, and survives an individual agent's removal.
258
+ const standalone = await loadAgent("standalone");
259
+ const binaryPath = path.join(home, standalone.meta.agentDir, "bin", "ai-scanner");
260
+ try {
261
+ await installScheduled({
262
+ home,
263
+ accessKey: opts.key,
264
+ binaryPath,
265
+ dryRun: opts.dryRun,
266
+ interval: intervalHours,
267
+ });
268
+ } catch (e) {
269
+ console.error(`scheduled install failed: ${e.message}`);
270
+ process.exit(1);
271
+ }
272
+ }
273
+
274
+ if (opts.browserExtension && !opts.dryRun) {
275
+ const firstAgentName = agents[0];
276
+ const firstAgent = await loadAgent(firstAgentName);
277
+ const binaryPath = path.join(home, firstAgent.meta.agentDir, "bin", "ai-scanner");
278
+ try {
279
+ await installBrowserExtension(home, binaryPath);
280
+ } catch (e) {
281
+ console.error(`browser extension install warning: ${e.message}`);
282
+ }
283
+ }
284
+
285
+ if (restartAgents.length > 0 && !opts.dryRun) {
286
+ const list = restartAgents.join(", ");
287
+ console.log("");
288
+ console.log(`Next: restart ${list} so hooks take effect.`);
289
+ if (process.platform === "darwin") {
290
+ console.log("On macOS, the first hook run may prompt: \"<your terminal> wants to control System Events\".");
291
+ console.log("Click Allow — the scanner uses this to collect endpoint info (login items).");
292
+ }
293
+ }
294
+ }
295
+
296
+ async function cmdUninstall(opts) {
297
+ const home = process.env.HOME || os.homedir();
298
+ const scope = opts.scope || "user";
299
+ const base = scope === "user" ? home : process.cwd();
300
+
301
+ let agents;
302
+ if (opts.all) {
303
+ agents = detectInstalled(home);
304
+ } else if (opts.agent) {
305
+ agents = parseAgentList(opts.agent);
306
+ } else {
307
+ agents = detectInstalled(home);
308
+ }
309
+
310
+ if (agents.length === 0) {
311
+ console.error("no agents detected; specify --agent explicitly");
312
+ process.exit(1);
313
+ }
314
+
315
+ for (const name of agents) {
316
+ if (!AGENT_REGISTRY[name]) {
317
+ console.error(`unknown agent: ${name} (see 'aidr list-agents')`);
318
+ process.exit(1);
319
+ }
320
+ }
321
+
322
+ // Phase 1: collect plans (dry-run each agent)
323
+ const plans = await Promise.all(agents.map(async (name) => {
324
+ const mod = await loadAgent(name);
325
+ try {
326
+ const plan = await mod.uninstall(base, { dryRun: true });
327
+ return { name, mod, plan };
328
+ } catch (e) {
329
+ console.error(`[${name}] cannot plan uninstall: ${e.message}`);
330
+ process.exit(1);
331
+ }
332
+ }));
333
+
334
+ // Decide whether there's any work to confirm. Includes nested unmerge
335
+ // sub-actions (codex: { action: "unmerge", hooksJson, configToml }).
336
+ const settingsHasWork = (settings) => {
337
+ if (!settings || settings.action === "none") return false;
338
+ if (settings.action === "unmerge") {
339
+ return ["hooksJson", "configToml"].some((k) => {
340
+ const sub = settings[k];
341
+ return sub && sub.action !== "none" && sub.action !== "noop";
342
+ });
343
+ }
344
+ return settings.action === "delete" || settings.action === "modify";
345
+ };
346
+
347
+ const hasWork = plans.some(({ plan }) =>
348
+ plan && (plan.removedFiles?.length > 0 || settingsHasWork(plan.settings))
349
+ );
350
+ if (!hasWork) {
351
+ console.log("nothing to uninstall");
352
+ return;
353
+ }
354
+
355
+ // Print plan
356
+ console.error("aidr uninstall — the following will be removed/modified:");
357
+ for (const { name, plan } of plans) {
358
+ if (!plan) continue;
359
+ for (const f of plan.removedFiles || []) {
360
+ console.error(` [${name}] remove ${f}`);
361
+ }
362
+ if (plan.settings) {
363
+ if (plan.settings.action === "delete") {
364
+ console.error(` [${name}] delete ${plan.settings.path} (no ai-scanner entries left)`);
365
+ } else if (plan.settings.action === "modify") {
366
+ console.error(` [${name}] modify ${plan.settings.path} (strip ai-scanner env/hooks, backup created)`);
367
+ } else if (plan.settings.action === "unmerge") {
368
+ // Nested plan (codex): surface each sub-action so `-y` users can see
369
+ // hooks.json / config.toml will be touched.
370
+ for (const sub of [plan.settings.hooksJson, plan.settings.configToml]) {
371
+ if (!sub || sub.action === "none" || sub.action === "noop") continue;
372
+ if (sub.action === "delete") {
373
+ console.error(` [${name}] delete ${sub.path} (no ai-scanner entries left)`);
374
+ } else {
375
+ console.error(` [${name}] modify ${sub.path} (strip ai-scanner entries, backup created)`);
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ if (opts.dryRun) return;
383
+
384
+ // Phase 2: confirm
385
+ if (!opts.yes) {
386
+ if (!process.stdin.isTTY) {
387
+ console.error("");
388
+ console.error("Re-run with -y/--yes to proceed non-interactively.");
389
+ process.exit(1);
390
+ }
391
+ const ok = await promptYesNo("Proceed?");
392
+ if (!ok) {
393
+ console.error("aborted");
394
+ process.exit(1);
395
+ }
396
+ }
397
+
398
+ // Phase 3: execute
399
+ await Promise.all(plans.map(async ({ name, mod }) => {
400
+ try {
401
+ const res = await mod.uninstall(base, { dryRun: false });
402
+ console.log(`[${name}] uninstalled`);
403
+ if (res?.settings?.backupPath) {
404
+ console.log(`[${name}] existing settings backed up to ${res.settings.backupPath}`);
405
+ }
406
+ } catch (e) {
407
+ console.error(`[${name}] uninstall failed: ${e.message}`);
408
+ process.exit(1);
409
+ }
410
+ }));
411
+
412
+ try {
413
+ const { uninstallScheduled } = await import("./scheduled.mjs");
414
+ await uninstallScheduled({ home, dryRun: opts.dryRun });
415
+ } catch (e) {
416
+ console.error(`scheduled uninstall warning: ${e.message}`);
417
+ }
418
+ }
419
+
420
+ async function cmdDoctor() {
421
+ const home = process.env.HOME || os.homedir();
422
+ console.log(`home: ${home}`);
423
+ console.log(`node: ${process.version}`);
424
+ console.log(`platform: ${process.platform}/${process.arch}`);
425
+ const installed = detectInstalled(home);
426
+ console.log(`detected agents: ${installed.join(", ") || "(none)"}`);
427
+ const lines = await Promise.all(installed.map(async (name) => {
428
+ const mod = await loadAgent(name);
429
+ const agentDir = mod.meta.agentDir;
430
+ if (!agentDir) return null;
431
+ const bin = path.join(home, agentDir, "bin", "ai-scanner");
432
+ let present = false;
433
+ try {
434
+ await fs.access(bin, fs.constants.F_OK);
435
+ present = true;
436
+ } catch {}
437
+ return `binary: ${bin} ${present ? "[present]" : "[missing]"}`;
438
+ }));
439
+ for (const line of lines) {
440
+ if (line) console.log(line);
441
+ }
442
+ }
443
+
444
+ async function cmdListAgents() {
445
+ for (const name of AGENT_ORDER) {
446
+ const mod = await loadAgent(name);
447
+ const d = mod.meta.displayName || name;
448
+ const stub = mod.meta.stub ? " (stub)" : "";
449
+ console.log(`${name}\t${d}${stub}`);
450
+ }
451
+ }
452
+
453
+ export async function run(argv) {
454
+ const program = new Command();
455
+ program
456
+ .name("aidr")
457
+ .description("AIDR setup CLI — installs ai-scanner hooks for AI coding agents")
458
+ .version("0.0.1");
459
+
460
+ program
461
+ .command("install")
462
+ .description("Install ai-scanner hooks for one or more agents")
463
+ .option("--agent <list>", "comma-separated agent names")
464
+ .option("--all", "install for all detected agents")
465
+ .requiredOption("--key <ak_xxx>", "access key (ak_<43-char>)")
466
+ .option("--env <env>", "target environment: dev|prod (default: auto-detected from package name — `-dev` suffix ⇒ dev, otherwise prod)")
467
+ .option("--dry-run", "print planned writes, do not touch disk", false)
468
+ .option("--force", "overwrite existing hook scripts (settings.json is always merged, never overwritten)", false)
469
+ .option("--binary-only", "fetch binary and exit", false)
470
+ .option("--no-verify", "skip /verify call")
471
+ .option("--skip-binary", "skip binary download", false)
472
+ .option("--scope <project|user>", "installation scope: user ($HOME) or project (cwd)", "user")
473
+ .option("-y, --yes", "proceed without confirmation when existing settings.json is detected", false)
474
+ .option("--uninstall", "remove all ai-scanner hooks/scripts/binaries for the selected agents (see 'uninstall' command)", false)
475
+ .option("--scheduled", "enable scheduled endpoint-info collection (creates a cron/systemd/launchd entry; without this flag endpoint-info is never collected)", false)
476
+ .option("--interval <hours>", "scheduled scan interval in hours (min 1, default 4; requires --scheduled)", "4")
477
+ .option("--browser-extension", "install browser extension native messaging host for Shadow IT network monitoring (default: disabled)", false)
478
+ .action(cmdInstall);
479
+
480
+ program
481
+ .command("uninstall")
482
+ .description("Remove ai-scanner hooks, scripts, and binaries for one or more agents")
483
+ .option("--agent <list>", "comma-separated agent names (default: all detected)")
484
+ .option("--all", "uninstall for all detected agents")
485
+ .option("--scope <project|user>", "scope: user ($HOME) or project (cwd)", "user")
486
+ .option("-y, --yes", "proceed without confirmation prompt", false)
487
+ .option("--dry-run", "print what would be removed without touching disk", false)
488
+ .action(cmdUninstall);
489
+
490
+ program.command("doctor").action(cmdDoctor);
491
+ program.command("list-agents").action(cmdListAgents);
492
+
493
+ await program.parseAsync(argv);
494
+ }
package/src/detect.mjs ADDED
@@ -0,0 +1,39 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const ENTRIES = [
5
+ ["claude", ".claude"],
6
+ ["cursor", ".cursor"],
7
+ ["windsurf", ".windsurf"],
8
+ ["kiro", ".kiro"],
9
+ ["gemini", ".gemini"],
10
+ ["codex", ".codex"],
11
+ ["cline", ".cline"],
12
+ ["roo", ".roo"],
13
+ ["copilot", ".github/copilot"],
14
+ ["qwen", ".qwen"],
15
+ ["trae", ".trae"],
16
+ ["amazonq", ".aws/amazonq"],
17
+ ["jetbrains", null], // IDE-managed, skip home detection
18
+ ["opencode", ".opencode"],
19
+ ["aider", ".aider"],
20
+ ["amp", ".amp"],
21
+ ["crush", ".crush"],
22
+ ["antigravity", ".antigravity"],
23
+ ["continue", ".continue"],
24
+ ];
25
+
26
+ export function detectInstalled(home = process.env.HOME || "") {
27
+ const out = [];
28
+ for (const [name, rel] of ENTRIES) {
29
+ if (!rel) continue;
30
+ const p = path.join(home, rel);
31
+ try {
32
+ const st = fs.statSync(p);
33
+ if (st.isDirectory()) out.push(name);
34
+ } catch {
35
+ // not present
36
+ }
37
+ }
38
+ return out;
39
+ }