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.
- package/README.md +43 -14
- package/carmoji.js +495 -31
- 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,16 +6,20 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Commands:
|
|
8
8
|
// pair <code> discover the phone on this Wi-Fi and pair with it
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
/**
|
|
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
|
}
|
|
@@ -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
|
-
/**
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
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
|
|
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 (
|
|
900
|
+
if (flags.includes('--gate') && payload.hook_event_name === 'PreToolUse') {
|
|
488
901
|
const decision = await requestApproval(config, {
|
|
489
|
-
source
|
|
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 =
|
|
923
|
+
const event = eventFromHook(payload);
|
|
511
924
|
if (!event) break;
|
|
512
|
-
const message = { source
|
|
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
|
-
|
|
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 =
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
|
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
|
|