carmoji 0.2.0 → 0.3.0

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 (3) hide show
  1. package/README.md +43 -14
  2. package/carmoji.js +495 -31
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,10 +1,13 @@
1
1
  # CarMoji Bridge
2
2
 
3
- Forwards coding events from **Claude Code** and **Codex** on your computer
4
- to the CarMoji pet on your phone, over a WebSocket on the same Wi-Fi.
5
- The pet wakes up when a session starts, "types" while the agent edits,
6
- gets nervous while tests run, celebrates finished turns, and pleads with
7
- big puppy eyes when the agent is waiting for your approval.
3
+ Forwards coding events from **Claude Code, Codex, Gemini CLI, Cursor,
4
+ Qoder, Factory, CodeBuddy, Copilot CLI, Kimi Code CLI, Trae, Qwen Code,
5
+ Cline** and friends on your computer to the CarMoji pet on your phone,
6
+ over a WebSocket on the same Wi-Fi. The pet wakes up when a session
7
+ starts, "types" while the agent edits, pores over a document while it
8
+ reads and searches, gets nervous while tests run, celebrates finished
9
+ turns, and pleads with big puppy eyes when the agent is waiting for your
10
+ approval.
8
11
 
9
12
  ## Setup (once)
10
13
 
@@ -50,19 +53,45 @@ already there (a backup is saved to `settings.json.bak` first, reruns are
50
53
  no-ops, and a file that doesn't parse is refused untouched). Prefer to do
51
54
  it by hand? `npx carmoji claude-config` prints the snippet instead.
52
55
 
53
- ## Codex
56
+ ## All the other tools
54
57
 
55
58
  ```sh
56
- npx carmoji install-codex
59
+ npx carmoji tools # what's supported, what's detected
60
+ npx carmoji install codex # one tool…
61
+ npx carmoji install all # …or hooks for everything found on this machine
62
+ npx carmoji uninstall <tool>
57
63
  ```
58
64
 
59
- sets the `notify` program in `~/.codex/config.toml` (backup to
60
- `config.toml.bak`; an existing notify line is replaced with a printed
61
- warning). `npx carmoji codex-config` prints the line if you'd rather paste
62
- it yourself. Codex's notify surface only reports completed turns, so the
63
- pet celebrates finished work but doesn't see individual edits (tail
64
- `~/.codex/sessions/` is a possible future upgrade see
65
- [docs/ROADMAP.md](../docs/ROADMAP.md)).
65
+ | Tool | Hooks written to | Notes |
66
+ | --- | --- | --- |
67
+ | `claude` | `~/.claude/settings.json` | full event coverage |
68
+ | `codex` | `$CODEX_HOME/hooks.json` | full lifecycle via Codex hooks; replaces the old notify line |
69
+ | `gemini` | `~/.gemini/settings.json` | Before/After tool + agent events |
70
+ | `cursor` | `~/.cursor/hooks.json` | shell/read/edit/MCP events |
71
+ | `trae`, `traecn` | `~/.trae{,-cn}/hooks.json` | Trae IDE (Cursor-style events) |
72
+ | `qoder`, `qoderwork` | `~/.qoder{work}/settings.json` | Claude Code forks |
73
+ | `factory` | `~/.factory/settings.json` | sends as source `droid` |
74
+ | `codebuddy` | `~/.codebuddy/settings.json` | Claude Code fork |
75
+ | `qwen` | `~/.qwen/settings.json` | Claude fork, ms timeouts |
76
+ | `copilot` | `~/.copilot/hooks/carmoji.json` | GitHub Copilot CLI |
77
+ | `kimi` | `~/.kimi/config.toml` | TOML `[[hooks]]` blocks |
78
+ | `cline` | `~/Documents/Cline/Hooks/` | one script per event |
79
+
80
+ Everything merges non-destructively: a backup is written next to each
81
+ config, reruns are idempotent, only carmoji-owned entries are ever
82
+ removed, and unparseable files are refused untouched. `install all` skips
83
+ tools that aren't on the machine.
84
+
85
+ All of them funnel through the same `carmoji hook` entry point
86
+ (`--source` names the tool, `--event` supplies the event name for tools
87
+ whose payloads lack one), so the pet reacts identically no matter which
88
+ agent is working — each with its own colored dot on the dashboard.
89
+
90
+ Older Codex versions without hooks support can still use
91
+ `npx carmoji install-codex-notify`, which sets the `notify` program in
92
+ `~/.codex/config.toml` (turn completions only). OpenCode users running the
93
+ community **omo** plugin get events for free — omo fires your Claude Code
94
+ hooks, which already carry carmoji.
66
95
 
67
96
  ## Try it without a coding agent
68
97
 
package/carmoji.js CHANGED
@@ -6,16 +6,20 @@
6
6
  //
7
7
  // Commands:
8
8
  // pair <code> discover the phone on this Wi-Fi and pair with it
9
- // hook Claude Code hook entry point (reads hook JSON on stdin)
10
- // codex-notify Codex notify entry point (event JSON as last argument)
11
- // test <name> send a test event: start|prompt|edit|run|done|error|permission|end
9
+ // install <tool> install hooks for a tool (or `all`); see `tools`
10
+ // uninstall <tool> remove CarMoji hooks from a tool's config
11
+ // tools list supported tools and where their hooks live
12
+ // hook hook entry point, all tools (reads hook JSON on stdin;
13
+ // --source <tool>, --event <name>, --gate)
14
+ // codex-notify legacy Codex notify entry point (event JSON as last arg)
15
+ // test <name> send a test event: start|prompt|edit|read|run|heartbeat|done|error|permission|end
12
16
  // claude-config print the .claude/settings.json hooks snippet
13
- // codex-config print the ~/.codex/config.toml notify line
17
+ // codex-config print the ~/.codex/config.toml notify line (legacy)
14
18
 
15
19
  import {
16
20
  readFileSync, writeFileSync, mkdirSync,
17
21
  statSync, openSync, readSync, closeSync,
18
- copyFileSync, existsSync, readdirSync,
22
+ copyFileSync, existsSync, readdirSync, unlinkSync,
19
23
  } from 'node:fs';
20
24
  import { spawn } from 'node:child_process';
21
25
  import { homedir } from 'node:os';
@@ -282,18 +286,120 @@ function claudeUsage(allowRecompute) {
282
286
  return usage;
283
287
  }
284
288
 
285
- /** Map a Claude Code hook payload to a wire event, or null to ignore. */
286
- function eventFromClaudeHook(payload) {
287
- switch (payload.hook_event_name) {
289
+ /**
290
+ * Raw hook event name → canonical Claude-style name. The supported tools
291
+ * speak several dialects — Cursor/Copilot camelCase, Trae CLI and Hermes
292
+ * snake_case, Gemini's Before/After pairs, Cline's task lifecycle — and
293
+ * they all fold onto one vocabulary before the wire mapping.
294
+ */
295
+ const EVENT_ALIASES = {
296
+ // Cursor / Trae IDE (camelCase). afterFileEdit has no "before" twin, so
297
+ // it maps to a tool START — a burst of edits reads as continuous typing.
298
+ beforeSubmitPrompt: 'UserPromptSubmit',
299
+ beforeShellExecution: 'PreToolUse',
300
+ afterShellExecution: 'PostToolUse',
301
+ beforeReadFile: 'PreToolUse',
302
+ afterFileEdit: 'PreToolUse',
303
+ beforeMCPExecution: 'PreToolUse',
304
+ afterMCPExecution: 'PostToolUse',
305
+ afterAgentThought: 'PostToolUse',
306
+ afterAgentResponse: 'PostToolUse',
307
+ stop: 'Stop',
308
+ // Gemini CLI (its stdin lacks the event name, so these arrive via --event)
309
+ BeforeTool: 'PreToolUse',
310
+ AfterTool: 'PostToolUse',
311
+ BeforeAgent: 'UserPromptSubmit',
312
+ AfterAgent: 'Stop',
313
+ // GitHub Copilot CLI (camelCase)
314
+ sessionStart: 'SessionStart',
315
+ sessionEnd: 'SessionEnd',
316
+ userPromptSubmitted: 'UserPromptSubmit',
317
+ preToolUse: 'PreToolUse',
318
+ postToolUse: 'PostToolUse',
319
+ errorOccurred: 'Error',
320
+ // Cline (task lifecycle)
321
+ TaskStart: 'SessionStart',
322
+ TaskResume: 'UserPromptSubmit',
323
+ TaskComplete: 'Stop',
324
+ TaskCancel: 'Stop',
325
+ // snake_case dialects (Trae CLI, Hermes)
326
+ session_start: 'SessionStart',
327
+ session_end: 'SessionEnd',
328
+ user_prompt_submit: 'UserPromptSubmit',
329
+ pre_tool_use: 'PreToolUse',
330
+ post_tool_use: 'PostToolUse',
331
+ post_tool_use_failure: 'Error',
332
+ permission_request: 'Notification',
333
+ notification: 'Notification',
334
+ pre_tool_call: 'PreToolUse',
335
+ post_tool_call: 'PostToolUse',
336
+ on_session_start: 'SessionStart',
337
+ on_session_end: 'SessionEnd',
338
+ };
339
+
340
+ /** Stand-in tool names for Cursor/Trae events whose payloads name no tool. */
341
+ const EVENT_TOOL_HINTS = {
342
+ beforeShellExecution: 'Bash',
343
+ beforeReadFile: 'Read',
344
+ afterFileEdit: 'Edit',
345
+ beforeMCPExecution: 'MCP',
346
+ };
347
+
348
+ function firstString(...values) {
349
+ for (const value of values) {
350
+ if (typeof value === 'string' && value.trim()) return value;
351
+ }
352
+ return undefined;
353
+ }
354
+
355
+ /**
356
+ * Fold the many stdin dialects onto Claude-style field names
357
+ * (hook_event_name / session_id / tool_name / tool_input) in place.
358
+ */
359
+ function normalizePayload(payload, eventFlag) {
360
+ payload.hook_event_name ??= firstString(
361
+ payload.hookEventName, payload.eventName, payload.event,
362
+ payload.hookName, eventFlag);
363
+ payload.session_id ??= firstString(
364
+ payload.sessionId, payload.conversation_id, payload.conversationId,
365
+ payload.taskId);
366
+ // Copilot: camelCase tool fields, arguments as a JSON string.
367
+ if (!payload.tool_name && firstString(payload.toolName)) {
368
+ payload.tool_name = payload.toolName;
369
+ }
370
+ if (!payload.tool_input && firstString(payload.toolArgs)) {
371
+ try { payload.tool_input = JSON.parse(payload.toolArgs); } catch { /* keep raw */ }
372
+ }
373
+ // Cline nests tool info under preToolUse / postToolUse.
374
+ const nested = payload.preToolUse || payload.postToolUse;
375
+ if (nested && typeof nested === 'object') {
376
+ payload.tool_name ??= firstString(nested.toolName);
377
+ payload.tool_input ??= nested.parameters;
378
+ }
379
+ return payload;
380
+ }
381
+
382
+ /** Map a normalized hook payload to a wire event, or null to ignore. */
383
+ function eventFromHook(payload) {
384
+ const raw = payload.hook_event_name;
385
+ const tool = payload.tool_name ?? EVENT_TOOL_HINTS[raw];
386
+ switch (EVENT_ALIASES[raw] ?? raw) {
288
387
  case 'SessionStart': return { event: 'session_start' };
289
388
  case 'UserPromptSubmit': return { event: 'prompt' };
290
- case 'PreToolUse': return { event: 'tool_start', tool: payload.tool_name };
389
+ case 'PreToolUse': return { event: 'tool_start', tool };
291
390
  // tool_end doesn't animate the face; it's a session heartbeat and a
292
391
  // fresh token sample for the tok/s readout.
293
- case 'PostToolUse': return { event: 'tool_end', tool: payload.tool_name };
392
+ case 'PostToolUse': return { event: 'tool_end', tool };
393
+ case 'PostToolUseFailure':
394
+ case 'Error': return { event: 'error' };
294
395
  case 'Notification': return { event: 'permission' };
295
396
  case 'Stop': return { event: 'turn_done' };
296
397
  case 'SessionEnd': return { event: 'session_end' };
398
+ // Liveness only: keep the session fresh without a face reaction.
399
+ case 'SubagentStart':
400
+ case 'SubagentStop':
401
+ case 'PreCompact':
402
+ case 'PostCompact': return { event: 'tool_end' };
297
403
  default: return null;
298
404
  }
299
405
  }
@@ -342,6 +448,7 @@ const TEST_EVENTS = {
342
448
  start: { event: 'session_start' },
343
449
  prompt: { event: 'prompt' },
344
450
  edit: { event: 'tool_start', tool: 'Edit' },
451
+ read: { event: 'tool_start', tool: 'Read' },
345
452
  run: { event: 'tool_start', tool: 'Bash' },
346
453
  heartbeat: { event: 'tool_end', tool: 'Edit' },
347
454
  done: { event: 'turn_done' },
@@ -360,6 +467,302 @@ function backupFile(path) {
360
467
  const CLAUDE_HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse',
361
468
  'PostToolUse', 'Notification', 'Stop', 'SessionEnd'];
362
469
 
470
+ // ---------------------------------------------------------------------------
471
+ // Multi-tool hook installers (see EVENT_ALIASES for how their events map).
472
+ //
473
+ // Formats, mirroring how each tool reads hooks:
474
+ // claude — {matcher, hooks:[{type,command,timeout}]} per PascalCase event
475
+ // under the config's "hooks" key (Claude Code and its forks)
476
+ // nested — {hooks:[{type,command,timeout}]} (Codex hooks.json, Gemini)
477
+ // flat — {command} per event (Cursor hooks.json, + top-level version)
478
+ // trae — {matcher, loop_limit, hooks:[...]} + top-level version (Trae IDE)
479
+ // copilot — {type:"command", bash, timeoutSec} + top-level version
480
+ // kimi — TOML [[hooks]] blocks appended to config.toml
481
+ // cline — one executable script per event in ~/Documents/Cline/Hooks
482
+ // ---------------------------------------------------------------------------
483
+
484
+ const CURSOR_STYLE_EVENTS = [
485
+ 'beforeSubmitPrompt', 'beforeShellExecution', 'afterShellExecution',
486
+ 'beforeReadFile', 'afterFileEdit', 'beforeMCPExecution', 'afterMCPExecution',
487
+ 'afterAgentThought', 'afterAgentResponse', 'stop',
488
+ ];
489
+
490
+ /**
491
+ * Every tool `carmoji install <name>` knows. `source` defaults to the key;
492
+ * `timeoutMs` marks tools whose hook timeouts are milliseconds; `eventFlag`
493
+ * marks tools whose stdin lacks the event name (the command carries
494
+ * `--event <name>`); `requiresDir` gates installation on the tool actually
495
+ * being present.
496
+ */
497
+ const TOOLS = {
498
+ claude: { name: 'Claude Code', format: 'claude', config: '.claude/settings.json' },
499
+ codex: { name: 'Codex', format: 'codex' },
500
+ gemini: { name: 'Gemini CLI', format: 'nested', config: '.gemini/settings.json',
501
+ eventFlag: true, timeoutMs: true,
502
+ events: ['SessionStart', 'SessionEnd', 'BeforeTool', 'AfterTool',
503
+ 'BeforeAgent', 'AfterAgent'] },
504
+ cursor: { name: 'Cursor', format: 'flat', config: '.cursor/hooks.json',
505
+ versionKey: true, eventFlag: true, events: CURSOR_STYLE_EVENTS },
506
+ trae: { name: 'Trae IDE', format: 'trae', config: '.trae/hooks.json',
507
+ versionKey: true, eventFlag: true, events: CURSOR_STYLE_EVENTS },
508
+ traecn: { name: 'Trae CN', format: 'trae', config: '.trae-cn/hooks.json',
509
+ versionKey: true, eventFlag: true, events: CURSOR_STYLE_EVENTS },
510
+ qoder: { name: 'Qoder', format: 'claude', config: '.qoder/settings.json' },
511
+ qoderwork: { name: 'QoderWork', format: 'claude', config: '.qoderwork/settings.json',
512
+ note: 'Restart QoderWork for the hooks to take effect.' },
513
+ factory: { name: 'Factory (droid)', source: 'droid', format: 'claude',
514
+ config: '.factory/settings.json' },
515
+ codebuddy: { name: 'CodeBuddy', format: 'claude', config: '.codebuddy/settings.json' },
516
+ qwen: { name: 'Qwen Code', format: 'claude', config: '.qwen/settings.json',
517
+ timeoutMs: true },
518
+ copilot: { name: 'GitHub Copilot CLI', format: 'copilot',
519
+ config: '.copilot/hooks/carmoji.json', requiresDir: '.copilot',
520
+ versionKey: true, eventFlag: true,
521
+ events: ['sessionStart', 'sessionEnd', 'userPromptSubmitted',
522
+ 'preToolUse', 'postToolUse', 'errorOccurred'] },
523
+ kimi: { name: 'Kimi Code CLI', format: 'kimi', config: '.kimi/config.toml',
524
+ requiresDir: '.kimi',
525
+ events: ['SessionStart', 'SessionEnd', 'UserPromptSubmit',
526
+ 'PreToolUse', 'PostToolUse', 'PostToolUseFailure',
527
+ 'Notification', 'Stop'] },
528
+ cline: { name: 'Cline', format: 'cline', config: 'Documents/Cline/Hooks',
529
+ requiresDir: 'Documents/Cline',
530
+ events: ['TaskStart', 'TaskResume', 'TaskCancel', 'TaskComplete',
531
+ 'UserPromptSubmit', 'PreToolUse', 'PostToolUse'] },
532
+ };
533
+
534
+ /** Resolve Codex's config dir, honoring $CODEX_HOME like Codex itself does. */
535
+ function codexHome() {
536
+ const raw = (process.env.CODEX_HOME || '').trim();
537
+ if (!raw) return join(homedir(), '.codex');
538
+ if (raw === '~') return homedir();
539
+ if (raw.startsWith('~/')) return join(homedir(), raw.slice(2));
540
+ return raw;
541
+ }
542
+
543
+ function isToolPresent(key, tool) {
544
+ const marker = tool.requiresDir
545
+ ? join(homedir(), tool.requiresDir)
546
+ : key === 'codex' ? codexHome()
547
+ : dirname(join(homedir(), tool.config));
548
+ return existsSync(marker);
549
+ }
550
+
551
+ /** Shared JSON read: refuses to touch files that don't parse. */
552
+ function readJsonConfig(path) {
553
+ if (!existsSync(path)) return {};
554
+ const text = readFileSync(path, 'utf8');
555
+ if (!text.trim()) return {};
556
+ try {
557
+ return JSON.parse(text);
558
+ } catch {
559
+ console.error(`${path} is not valid JSON — fix it first (nothing was changed).`);
560
+ process.exit(1);
561
+ }
562
+ }
563
+
564
+ function writeConfig(path, text) {
565
+ const bak = existsSync(path) ? backupFile(path) : null;
566
+ mkdirSync(dirname(path), { recursive: true });
567
+ writeFileSync(path, text);
568
+ if (bak) console.log(`Backup saved to ${bak}`);
569
+ }
570
+
571
+ /** Generic JSON-config installer covering claude/nested/flat/trae/copilot. */
572
+ function installJsonHooks(key, tool) {
573
+ const path = key === 'codex' ? join(codexHome(), 'hooks.json')
574
+ : join(homedir(), tool.config);
575
+ const root = readJsonConfig(path);
576
+ if (tool.versionKey && root.version === undefined) root.version = 1;
577
+ const hooks = (root.hooks && typeof root.hooks === 'object' && !Array.isArray(root.hooks))
578
+ ? root.hooks : {};
579
+ const source = tool.source ?? key;
580
+ const base = `${HOOK_COMMAND} --source ${source}`;
581
+ const timeout = (seconds) => (tool.timeoutMs ? seconds * 1000 : seconds);
582
+
583
+ for (const event of tool.events ?? CLAUDE_HOOK_EVENTS) {
584
+ const entries = (Array.isArray(hooks[event]) ? hooks[event] : [])
585
+ .filter((entry) => !JSON.stringify(entry).includes('carmoji'));
586
+ const command = tool.eventFlag ? `${base} --event ${event}` : base;
587
+ switch (tool.format) {
588
+ case 'claude':
589
+ entries.push({ matcher: '*',
590
+ hooks: [{ type: 'command', command, timeout: timeout(5) }] });
591
+ break;
592
+ case 'codex':
593
+ case 'nested':
594
+ entries.push({ hooks: [{ type: 'command', command, timeout: timeout(5) }] });
595
+ break;
596
+ case 'flat':
597
+ entries.push({ command });
598
+ break;
599
+ case 'trae':
600
+ entries.push({ matcher: '*', loop_limit: 5,
601
+ hooks: [{ type: 'command', command, timeout: timeout(5) }] });
602
+ break;
603
+ case 'copilot':
604
+ entries.push({ type: 'command', bash: command, timeoutSec: 5 });
605
+ break;
606
+ }
607
+ hooks[event] = entries;
608
+ }
609
+ root.hooks = hooks;
610
+ writeConfig(path, JSON.stringify(root, null, 2) + '\n');
611
+ console.log(`Installed CarMoji hooks for ${tool.name} in ${path}`);
612
+ if (tool.note) console.log(tool.note);
613
+ }
614
+
615
+ /** Drop [[hooks]] TOML blocks that mention carmoji; keep everything else. */
616
+ function removeKimiBlocks(contents) {
617
+ const lines = contents.split('\n');
618
+ const kept = [];
619
+ let index = 0;
620
+ while (index < lines.length) {
621
+ if (lines[index].trim() === '[[hooks]]') {
622
+ const block = [lines[index]];
623
+ let next = index + 1;
624
+ while (next < lines.length && !lines[next].trim().startsWith('[')) {
625
+ block.push(lines[next]);
626
+ next += 1;
627
+ }
628
+ if (!block.join('\n').includes('carmoji')) kept.push(...block);
629
+ index = next;
630
+ } else {
631
+ kept.push(lines[index]);
632
+ index += 1;
633
+ }
634
+ }
635
+ while (kept.length && !kept[kept.length - 1].trim()) kept.pop();
636
+ return kept.join('\n');
637
+ }
638
+
639
+ /** Kimi Code CLI reads TOML [[hooks]] blocks from ~/.kimi/config.toml. */
640
+ function installKimiHooks(key, tool) {
641
+ const path = join(homedir(), tool.config);
642
+ let contents = existsSync(path) ? readFileSync(path, 'utf8') : '';
643
+ contents = removeKimiBlocks(contents);
644
+ const command = `${HOOK_COMMAND} --source kimi`.replace(/"/g, '\\"');
645
+ const blocks = tool.events.map((event) => {
646
+ const toolEvent = ['PreToolUse', 'PostToolUse', 'PostToolUseFailure'].includes(event);
647
+ return `[[hooks]]\nevent = "${event}"\ncommand = "${command}"\ntimeout = 5`
648
+ + (toolEvent ? '\nmatcher = ".*"' : '');
649
+ });
650
+ if (contents && !contents.endsWith('\n')) contents += '\n';
651
+ if (contents) contents += '\n';
652
+ writeConfig(path, contents + blocks.join('\n\n') + '\n');
653
+ console.log(`Installed CarMoji hooks for ${tool.name} in ${path}`);
654
+ }
655
+
656
+ const CLINE_HOOK_MARKER = 'carmoji hook';
657
+
658
+ /** Cline runs one executable file per event from ~/Documents/Cline/Hooks. */
659
+ function installClineHooks(key, tool) {
660
+ const dir = join(homedir(), tool.config);
661
+ mkdirSync(dir, { recursive: true });
662
+ const script = '#!/bin/sh\n'
663
+ + '# carmoji hook — forwards Cline events to the CarMoji pet\n'
664
+ + `exec ${HOOK_COMMAND} --source cline\n`;
665
+ for (const event of tool.events) {
666
+ const path = join(dir, event);
667
+ if (existsSync(path) && !readFileSync(path, 'utf8').includes('carmoji')) {
668
+ console.log(`Skipped ${path} — another hook already lives there `
669
+ + '(Cline runs one script per event).');
670
+ continue;
671
+ }
672
+ writeFileSync(path, script, { mode: 0o755 });
673
+ }
674
+ console.log(`Installed CarMoji hooks for ${tool.name} in ${dir}`);
675
+ }
676
+
677
+ /**
678
+ * Codex: hooks.json in $CODEX_HOME (Claude-style events, nested entries).
679
+ * The legacy `notify` line only reported finished turns; hooks.json covers
680
+ * the full lifecycle, so a carmoji notify line is removed to avoid double
681
+ * turn_done events. `install-codex-notify` remains for older Codex versions.
682
+ */
683
+ function installCodexHooks() {
684
+ installJsonHooks('codex', {
685
+ name: 'Codex', format: 'codex',
686
+ events: ['SessionStart', 'SessionEnd', 'UserPromptSubmit',
687
+ 'PreToolUse', 'PostToolUse', 'Stop'],
688
+ });
689
+ const configPath = join(codexHome(), 'config.toml');
690
+ if (!existsSync(configPath)) return;
691
+ const text = readFileSync(configPath, 'utf8');
692
+ const lines = text.split('\n');
693
+ const notifyIndex = lines.findIndex(
694
+ (line) => /^\s*notify\s*=/.test(line) && line.includes('carmoji'));
695
+ if (notifyIndex >= 0) {
696
+ backupFile(configPath);
697
+ lines.splice(notifyIndex, 1);
698
+ writeFileSync(configPath, lines.join('\n'));
699
+ console.log(`Removed the legacy carmoji notify line from ${configPath}`);
700
+ console.log('(hooks.json now covers turn completion — no double events).');
701
+ }
702
+ }
703
+
704
+ function installTool(key, { skipMissing = false } = {}) {
705
+ const tool = TOOLS[key];
706
+ if (!tool) {
707
+ console.error(`Unknown tool "${key}". Known: ${Object.keys(TOOLS).join(', ')}`);
708
+ process.exit(1);
709
+ }
710
+ if (!isToolPresent(key, tool)) {
711
+ console.log(`${tool.name}: not found on this machine — skipped.`);
712
+ if (!skipMissing) {
713
+ console.log(`(Looked for ~/${tool.requiresDir
714
+ ?? (key === 'codex' ? '.codex' : dirname(tool.config))}.)`);
715
+ }
716
+ return;
717
+ }
718
+ if (key === 'claude') { installClaude(); return; }
719
+ if (key === 'codex') { installCodexHooks(); return; }
720
+ if (tool.format === 'kimi') { installKimiHooks(key, tool); return; }
721
+ if (tool.format === 'cline') { installClineHooks(key, tool); return; }
722
+ installJsonHooks(key, tool);
723
+ }
724
+
725
+ function uninstallTool(key) {
726
+ const tool = TOOLS[key];
727
+ if (!tool) {
728
+ console.error(`Unknown tool "${key}". Known: ${Object.keys(TOOLS).join(', ')}`);
729
+ process.exit(1);
730
+ }
731
+ if (tool.format === 'cline') {
732
+ const dir = join(homedir(), tool.config);
733
+ for (const event of tool.events) {
734
+ const path = join(dir, event);
735
+ if (existsSync(path) && readFileSync(path, 'utf8').includes('carmoji')) {
736
+ try { unlinkSync(path); } catch { /* leave it */ }
737
+ }
738
+ }
739
+ console.log(`Removed CarMoji hooks for ${tool.name}.`);
740
+ return;
741
+ }
742
+ if (tool.format === 'kimi') {
743
+ const path = join(homedir(), tool.config);
744
+ if (existsSync(path)) {
745
+ writeConfig(path, removeKimiBlocks(readFileSync(path, 'utf8')) + '\n');
746
+ }
747
+ console.log(`Removed CarMoji hooks for ${tool.name}.`);
748
+ return;
749
+ }
750
+ const path = key === 'codex' ? join(codexHome(), 'hooks.json')
751
+ : join(homedir(), tool.config);
752
+ if (!existsSync(path)) { console.log(`${tool.name}: nothing installed.`); return; }
753
+ const root = readJsonConfig(path);
754
+ if (root.hooks && typeof root.hooks === 'object') {
755
+ for (const [event, value] of Object.entries(root.hooks)) {
756
+ if (!Array.isArray(value)) continue;
757
+ const kept = value.filter((entry) => !JSON.stringify(entry).includes('carmoji'));
758
+ if (kept.length) root.hooks[event] = kept;
759
+ else delete root.hooks[event];
760
+ }
761
+ }
762
+ writeConfig(path, JSON.stringify(root, null, 2) + '\n');
763
+ console.log(`Removed CarMoji hooks for ${tool.name} from ${path}`);
764
+ }
765
+
363
766
  /** Merge the CarMoji hooks into ~/.claude/settings.json, keeping a backup. */
364
767
  function installClaude() {
365
768
  const path = join(homedir(), '.claude', 'settings.json');
@@ -394,9 +797,10 @@ function installClaude() {
394
797
  console.log('New Claude Code sessions will pick them up.');
395
798
  }
396
799
 
397
- /** Set the CarMoji notify program in ~/.codex/config.toml, keeping a backup. */
398
- function installCodex() {
399
- const path = join(homedir(), '.codex', 'config.toml');
800
+ /** Legacy: set the CarMoji notify program in ~/.codex/config.toml (older
801
+ * Codex versions without hooks.json support), keeping a backup. */
802
+ function installCodexNotify() {
803
+ const path = join(codexHome(), 'config.toml');
400
804
  const notifyLine = RUNNING_FROM_PACKAGE
401
805
  ? 'notify = ["npx", "-y", "carmoji", "codex-notify"]'
402
806
  : `notify = ["node", "${SCRIPT_PATH}", "codex-notify"]`;
@@ -474,19 +878,28 @@ async function main() {
474
878
  }
475
879
 
476
880
  case 'hook': {
477
- // Runs inside Claude Code on every hook — must be silent, quick, and
478
- // always exit 0 so it can never disturb the session.
881
+ // Runs inside the coding agent on every hook — must be silent, quick,
882
+ // and always exit 0 so it can never disturb the session.
883
+ // Flags: --source <tool> (default claude), --event <name> for tools
884
+ // whose stdin lacks the event name, --gate for phone approvals.
479
885
  try {
480
886
  const config = loadConfig();
481
887
  if (!config) break;
482
- const payload = JSON.parse(readStdin() || '{}');
888
+ const flags = [arg, ...rest].filter(Boolean);
889
+ const flagValue = (name) => {
890
+ const index = flags.indexOf(name);
891
+ return index >= 0 ? flags[index + 1] : undefined;
892
+ };
893
+ const source = flagValue('--source') || 'claude';
894
+ const payload = normalizePayload(JSON.parse(readStdin() || '{}'),
895
+ flagValue('--event'));
483
896
 
484
897
  // Gate mode (opt-in, see `gate-config`): PreToolUse blocks while
485
898
  // the phone shows Allow/Deny; the printed JSON settles the
486
899
  // permission. Timeout falls through to the normal terminal prompt.
487
- if (arg === '--gate' && payload.hook_event_name === 'PreToolUse') {
900
+ if (flags.includes('--gate') && payload.hook_event_name === 'PreToolUse') {
488
901
  const decision = await requestApproval(config, {
489
- source: 'claude',
902
+ source,
490
903
  session: payload.session_id,
491
904
  event: 'approval_request',
492
905
  tool: payload.tool_name,
@@ -507,21 +920,32 @@ async function main() {
507
920
  break;
508
921
  }
509
922
 
510
- const event = eventFromClaudeHook(payload);
923
+ const event = eventFromHook(payload);
511
924
  if (!event) break;
512
- const message = { source: 'claude', session: payload.session_id, ...event };
925
+ const message = { source, session: payload.session_id, ...event };
926
+ // Claude-format transcripts power the tok/s readout; the forks share
927
+ // the format, and sessionTokens quietly no-ops for everyone else.
513
928
  const tokens = sessionTokens(payload);
514
929
  if (tokens !== undefined) message.tokens = tokens;
515
- if (payload.cwd) message.project = basename(payload.cwd);
930
+ // Gemini and Trae hooks carry no cwd the hook itself runs in the
931
+ // project directory, so fall back to that.
932
+ const cwd = payload.cwd || process.cwd();
933
+ if (cwd) message.project = basename(cwd);
516
934
  // A snippet of human-readable context for the phone to show.
517
- const detail = payload.hook_event_name === 'UserPromptSubmit' ? payload.prompt
518
- : payload.hook_event_name === 'Notification' ? payload.message
519
- : undefined;
935
+ const detail = event.event === 'prompt'
936
+ ? firstString(payload.prompt, payload.user_prompt, payload.userPrompt,
937
+ payload.message, payload.input, payload.text)
938
+ : event.event === 'permission'
939
+ ? firstString(payload.message, payload.detail)
940
+ : undefined;
520
941
  if (detail) message.detail = String(detail).replace(/\s+/g, ' ').slice(0, 140);
521
- // Plan-window usage: recompute only at turn boundaries.
522
- const recompute = ['SessionStart', 'Stop'].includes(payload.hook_event_name);
523
- const usage = claudeUsage(recompute);
524
- if (usage) message.usage = usage;
942
+ // Plan-window usage comes from Claude Code's local transcripts;
943
+ // recompute only at turn boundaries.
944
+ if (source === 'claude') {
945
+ const recompute = ['SessionStart', 'Stop'].includes(payload.hook_event_name);
946
+ const usage = claudeUsage(recompute);
947
+ if (usage) message.usage = usage;
948
+ }
525
949
  await send(config, message);
526
950
  } catch {
527
951
  // Never surface errors into the coding session.
@@ -580,12 +1004,50 @@ async function main() {
580
1004
  break;
581
1005
  }
582
1006
 
1007
+ case 'install': {
1008
+ if (arg === 'all') {
1009
+ for (const key of Object.keys(TOOLS)) {
1010
+ installTool(key, { skipMissing: true });
1011
+ }
1012
+ break;
1013
+ }
1014
+ if (!arg) {
1015
+ console.log(`Usage: carmoji install <tool>|all\nTools: ${Object.keys(TOOLS).join(', ')}`);
1016
+ process.exit(1);
1017
+ }
1018
+ installTool(arg);
1019
+ break;
1020
+ }
1021
+
1022
+ case 'uninstall': {
1023
+ if (!arg) {
1024
+ console.log(`Usage: carmoji uninstall <tool>\nTools: ${Object.keys(TOOLS).join(', ')}`);
1025
+ process.exit(1);
1026
+ }
1027
+ uninstallTool(arg);
1028
+ break;
1029
+ }
1030
+
1031
+ case 'tools': {
1032
+ console.log('Supported tools (carmoji install <tool>):');
1033
+ for (const [key, tool] of Object.entries(TOOLS)) {
1034
+ const where = key === 'codex' ? '$CODEX_HOME/hooks.json' : `~/${tool.config}`;
1035
+ const found = isToolPresent(key, tool) ? '' : ' (not detected)';
1036
+ console.log(` ${key.padEnd(10)} ${tool.name.padEnd(20)} ${where}${found}`);
1037
+ }
1038
+ break;
1039
+ }
1040
+
583
1041
  case 'install-claude':
584
1042
  installClaude();
585
1043
  break;
586
1044
 
587
1045
  case 'install-codex':
588
- installCodex();
1046
+ installCodexHooks();
1047
+ break;
1048
+
1049
+ case 'install-codex-notify':
1050
+ installCodexNotify();
589
1051
  break;
590
1052
 
591
1053
  case 'claude-config':
@@ -595,7 +1057,9 @@ async function main() {
595
1057
  break;
596
1058
 
597
1059
  case 'codex-config':
598
- console.log('Add this line to ~/.codex/config.toml:\n');
1060
+ console.log('Modern Codex reads hooks from $CODEX_HOME/hooks.json — prefer:');
1061
+ console.log(' carmoji install codex\n');
1062
+ console.log('For older Codex versions, add this line to ~/.codex/config.toml:\n');
599
1063
  console.log(RUNNING_FROM_PACKAGE
600
1064
  ? 'notify = ["npx", "-y", "carmoji", "codex-notify"]'
601
1065
  : `notify = ["node", "${SCRIPT_PATH}", "codex-notify"]`);
@@ -617,7 +1081,7 @@ async function main() {
617
1081
  break;
618
1082
 
619
1083
  default:
620
- console.log('carmoji bridge — usage: pair <code> [ip[:port]] | discover | install-claude | install-codex | test <event> | hook [--gate] | codex-notify | claude-config | codex-config | gate-config');
1084
+ console.log('carmoji bridge — usage: pair <code> [ip[:port]] | discover | install <tool>|all | uninstall <tool> | tools | test <event> | hook [--source <tool>] [--event <name>] [--gate] | codex-notify | claude-config | codex-config | gate-config');
621
1085
  }
622
1086
  }
623
1087
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Bring your CarMoji pet to life from Claude Code / Codex — a LAN bridge that streams coding events to the CarMoji iOS app",
5
5
  "type": "module",
6
6
  "bin": {