carmoji 0.2.1 → 0.3.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/README.md +43 -14
- package/carmoji.js +501 -58
- 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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
##
|
|
56
|
+
## All the other tools
|
|
54
57
|
|
|
55
58
|
```sh
|
|
56
|
-
npx carmoji
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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,19 +6,23 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Commands:
|
|
8
8
|
// pair <code> discover the phone on this Wi-Fi and pair with it
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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)
|
|
11
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
|
-
import { homedir } from 'node:os';
|
|
25
|
+
import { homedir, hostname } from 'node:os';
|
|
22
26
|
import { join, dirname, basename } from 'node:path';
|
|
23
27
|
import { fileURLToPath } from 'node:url';
|
|
24
28
|
|
|
@@ -282,18 +286,120 @@ function claudeUsage(allowRecompute) {
|
|
|
282
286
|
return usage;
|
|
283
287
|
}
|
|
284
288
|
|
|
285
|
-
/**
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -361,43 +467,317 @@ function backupFile(path) {
|
|
|
361
467
|
const CLAUDE_HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse',
|
|
362
468
|
'PostToolUse', 'Notification', 'Stop', 'SessionEnd'];
|
|
363
469
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
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;
|
|
374
606
|
}
|
|
607
|
+
hooks[event] = entries;
|
|
375
608
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
+
}
|
|
385
634
|
}
|
|
386
|
-
|
|
387
|
-
|
|
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
|
+
}
|
|
388
716
|
return;
|
|
389
717
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Merge the CarMoji hooks into ~/.claude/settings.json, keeping a backup.
|
|
768
|
+
* Existing carmoji entries are replaced, not skipped, so switching between
|
|
769
|
+
* a repo clone (`node …/carmoji.js`) and the npm package (`npx carmoji`)
|
|
770
|
+
* always leaves the config pointing at whichever one ran the install.
|
|
771
|
+
*/
|
|
772
|
+
function installClaude() {
|
|
773
|
+
installJsonHooks('claude', TOOLS.claude);
|
|
395
774
|
console.log('New Claude Code sessions will pick them up.');
|
|
396
775
|
}
|
|
397
776
|
|
|
398
|
-
/**
|
|
399
|
-
|
|
400
|
-
|
|
777
|
+
/** Legacy: set the CarMoji notify program in ~/.codex/config.toml (older
|
|
778
|
+
* Codex versions without hooks.json support), keeping a backup. */
|
|
779
|
+
function installCodexNotify() {
|
|
780
|
+
const path = join(codexHome(), 'config.toml');
|
|
401
781
|
const notifyLine = RUNNING_FROM_PACKAGE
|
|
402
782
|
? 'notify = ["npx", "-y", "carmoji", "codex-notify"]'
|
|
403
783
|
: `notify = ["node", "${SCRIPT_PATH}", "codex-notify"]`;
|
|
@@ -475,19 +855,28 @@ async function main() {
|
|
|
475
855
|
}
|
|
476
856
|
|
|
477
857
|
case 'hook': {
|
|
478
|
-
// Runs inside
|
|
479
|
-
// always exit 0 so it can never disturb the session.
|
|
858
|
+
// Runs inside the coding agent on every hook — must be silent, quick,
|
|
859
|
+
// and always exit 0 so it can never disturb the session.
|
|
860
|
+
// Flags: --source <tool> (default claude), --event <name> for tools
|
|
861
|
+
// whose stdin lacks the event name, --gate for phone approvals.
|
|
480
862
|
try {
|
|
481
863
|
const config = loadConfig();
|
|
482
864
|
if (!config) break;
|
|
483
|
-
const
|
|
865
|
+
const flags = [arg, ...rest].filter(Boolean);
|
|
866
|
+
const flagValue = (name) => {
|
|
867
|
+
const index = flags.indexOf(name);
|
|
868
|
+
return index >= 0 ? flags[index + 1] : undefined;
|
|
869
|
+
};
|
|
870
|
+
const source = flagValue('--source') || 'claude';
|
|
871
|
+
const payload = normalizePayload(JSON.parse(readStdin() || '{}'),
|
|
872
|
+
flagValue('--event'));
|
|
484
873
|
|
|
485
874
|
// Gate mode (opt-in, see `gate-config`): PreToolUse blocks while
|
|
486
875
|
// the phone shows Allow/Deny; the printed JSON settles the
|
|
487
876
|
// permission. Timeout falls through to the normal terminal prompt.
|
|
488
|
-
if (
|
|
877
|
+
if (flags.includes('--gate') && payload.hook_event_name === 'PreToolUse') {
|
|
489
878
|
const decision = await requestApproval(config, {
|
|
490
|
-
source
|
|
879
|
+
source,
|
|
491
880
|
session: payload.session_id,
|
|
492
881
|
event: 'approval_request',
|
|
493
882
|
tool: payload.tool_name,
|
|
@@ -508,21 +897,35 @@ async function main() {
|
|
|
508
897
|
break;
|
|
509
898
|
}
|
|
510
899
|
|
|
511
|
-
const event =
|
|
900
|
+
const event = eventFromHook(payload);
|
|
512
901
|
if (!event) break;
|
|
513
|
-
|
|
902
|
+
// host lets the phone attribute plan-window usage per machine and
|
|
903
|
+
// sum across them instead of showing whichever reported last.
|
|
904
|
+
const message = { source, session: payload.session_id,
|
|
905
|
+
host: hostname(), ...event };
|
|
906
|
+
// Claude-format transcripts power the tok/s readout; the forks share
|
|
907
|
+
// the format, and sessionTokens quietly no-ops for everyone else.
|
|
514
908
|
const tokens = sessionTokens(payload);
|
|
515
909
|
if (tokens !== undefined) message.tokens = tokens;
|
|
516
|
-
|
|
910
|
+
// Gemini and Trae hooks carry no cwd — the hook itself runs in the
|
|
911
|
+
// project directory, so fall back to that.
|
|
912
|
+
const cwd = payload.cwd || process.cwd();
|
|
913
|
+
if (cwd) message.project = basename(cwd);
|
|
517
914
|
// A snippet of human-readable context for the phone to show.
|
|
518
|
-
const detail =
|
|
519
|
-
|
|
520
|
-
|
|
915
|
+
const detail = event.event === 'prompt'
|
|
916
|
+
? firstString(payload.prompt, payload.user_prompt, payload.userPrompt,
|
|
917
|
+
payload.message, payload.input, payload.text)
|
|
918
|
+
: event.event === 'permission'
|
|
919
|
+
? firstString(payload.message, payload.detail)
|
|
920
|
+
: undefined;
|
|
521
921
|
if (detail) message.detail = String(detail).replace(/\s+/g, ' ').slice(0, 140);
|
|
522
|
-
// Plan-window usage
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
922
|
+
// Plan-window usage comes from Claude Code's local transcripts;
|
|
923
|
+
// recompute only at turn boundaries.
|
|
924
|
+
if (source === 'claude') {
|
|
925
|
+
const recompute = ['SessionStart', 'Stop'].includes(payload.hook_event_name);
|
|
926
|
+
const usage = claudeUsage(recompute);
|
|
927
|
+
if (usage) message.usage = usage;
|
|
928
|
+
}
|
|
526
929
|
await send(config, message);
|
|
527
930
|
} catch {
|
|
528
931
|
// Never surface errors into the coding session.
|
|
@@ -559,7 +962,7 @@ async function main() {
|
|
|
559
962
|
process.exit(1);
|
|
560
963
|
}
|
|
561
964
|
const [session, tokens, project] = rest;
|
|
562
|
-
const message = { source: 'test', session, ...event };
|
|
965
|
+
const message = { source: 'test', session, host: hostname(), ...event };
|
|
563
966
|
if (tokens !== undefined) message.tokens = Number(tokens);
|
|
564
967
|
if (project) message.project = project;
|
|
565
968
|
const usage = claudeUsage(true);
|
|
@@ -581,12 +984,50 @@ async function main() {
|
|
|
581
984
|
break;
|
|
582
985
|
}
|
|
583
986
|
|
|
987
|
+
case 'install': {
|
|
988
|
+
if (arg === 'all') {
|
|
989
|
+
for (const key of Object.keys(TOOLS)) {
|
|
990
|
+
installTool(key, { skipMissing: true });
|
|
991
|
+
}
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
if (!arg) {
|
|
995
|
+
console.log(`Usage: carmoji install <tool>|all\nTools: ${Object.keys(TOOLS).join(', ')}`);
|
|
996
|
+
process.exit(1);
|
|
997
|
+
}
|
|
998
|
+
installTool(arg);
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
case 'uninstall': {
|
|
1003
|
+
if (!arg) {
|
|
1004
|
+
console.log(`Usage: carmoji uninstall <tool>\nTools: ${Object.keys(TOOLS).join(', ')}`);
|
|
1005
|
+
process.exit(1);
|
|
1006
|
+
}
|
|
1007
|
+
uninstallTool(arg);
|
|
1008
|
+
break;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
case 'tools': {
|
|
1012
|
+
console.log('Supported tools (carmoji install <tool>):');
|
|
1013
|
+
for (const [key, tool] of Object.entries(TOOLS)) {
|
|
1014
|
+
const where = key === 'codex' ? '$CODEX_HOME/hooks.json' : `~/${tool.config}`;
|
|
1015
|
+
const found = isToolPresent(key, tool) ? '' : ' (not detected)';
|
|
1016
|
+
console.log(` ${key.padEnd(10)} ${tool.name.padEnd(20)} ${where}${found}`);
|
|
1017
|
+
}
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
584
1021
|
case 'install-claude':
|
|
585
1022
|
installClaude();
|
|
586
1023
|
break;
|
|
587
1024
|
|
|
588
1025
|
case 'install-codex':
|
|
589
|
-
|
|
1026
|
+
installCodexHooks();
|
|
1027
|
+
break;
|
|
1028
|
+
|
|
1029
|
+
case 'install-codex-notify':
|
|
1030
|
+
installCodexNotify();
|
|
590
1031
|
break;
|
|
591
1032
|
|
|
592
1033
|
case 'claude-config':
|
|
@@ -596,7 +1037,9 @@ async function main() {
|
|
|
596
1037
|
break;
|
|
597
1038
|
|
|
598
1039
|
case 'codex-config':
|
|
599
|
-
console.log('
|
|
1040
|
+
console.log('Modern Codex reads hooks from $CODEX_HOME/hooks.json — prefer:');
|
|
1041
|
+
console.log(' carmoji install codex\n');
|
|
1042
|
+
console.log('For older Codex versions, add this line to ~/.codex/config.toml:\n');
|
|
600
1043
|
console.log(RUNNING_FROM_PACKAGE
|
|
601
1044
|
? 'notify = ["npx", "-y", "carmoji", "codex-notify"]'
|
|
602
1045
|
: `notify = ["node", "${SCRIPT_PATH}", "codex-notify"]`);
|
|
@@ -618,7 +1061,7 @@ async function main() {
|
|
|
618
1061
|
break;
|
|
619
1062
|
|
|
620
1063
|
default:
|
|
621
|
-
console.log('carmoji bridge — usage: pair <code> [ip[:port]] | discover | install
|
|
1064
|
+
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');
|
|
622
1065
|
}
|
|
623
1066
|
}
|
|
624
1067
|
|