eojeon-cli 0.0.0-stage → 0.1.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 (42) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +26 -0
  3. package/README.md +216 -2
  4. package/cli/eojeon.js +78 -0
  5. package/court.config.json +316 -0
  6. package/package.json +60 -4
  7. package/public/app.js +397 -0
  8. package/public/court-types.js +33 -0
  9. package/public/demo.js +100 -0
  10. package/public/fonts/D2Coding-Ver1.4.0-20261003.ttf +0 -0
  11. package/public/fonts/Galmuri11-Bold.woff2 +0 -0
  12. package/public/fonts/Galmuri11.woff2 +0 -0
  13. package/public/fonts/Galmuri7.woff2 +0 -0
  14. package/public/fonts/Galmuri9.woff2 +0 -0
  15. package/public/fonts/LICENSE-D2Coding.txt +97 -0
  16. package/public/fonts/LICENSE-Galmuri.txt +93 -0
  17. package/public/index.html +97 -0
  18. package/public/scene.js +1079 -0
  19. package/public/style.css +314 -0
  20. package/scripts/fix-pty.js +25 -0
  21. package/server/agents.js +78 -0
  22. package/server/hook.js +47 -0
  23. package/server/index.js +430 -0
  24. package/server/sanitize.js +155 -0
  25. package/server/sessions.js +115 -0
  26. package/templates/agents/studio/animation-expert.md +29 -0
  27. package/templates/agents/studio/apple-designer.md +28 -0
  28. package/templates/agents/studio/branding-expert.md +29 -0
  29. package/templates/agents/studio/copywriter.md +28 -0
  30. package/templates/agents/studio/data-analyst.md +29 -0
  31. package/templates/agents/studio/marketer.md +28 -0
  32. package/templates/agents/studio/pr-lead.md +28 -0
  33. package/templates/agents/studio/product-designer.md +29 -0
  34. package/templates/agents/studio/sns-manager.md +28 -0
  35. package/templates/agents/studio/uiux-expert.md +31 -0
  36. package/templates/agents/studio/video-producer.md +29 -0
  37. package/templates/agents/yukjo/byeongjo.md +29 -0
  38. package/templates/agents/yukjo/gongjo.md +29 -0
  39. package/templates/agents/yukjo/hojo.md +29 -0
  40. package/templates/agents/yukjo/hyeongjo.md +29 -0
  41. package/templates/agents/yukjo/ijo.md +29 -0
  42. package/templates/agents/yukjo/yejo.md +29 -0
@@ -0,0 +1,430 @@
1
+ #!/usr/bin/env node
2
+ // Local server: serves the pixel court UI, runs your real `claude` in a PTY,
3
+ // bridges it to the browser terminal over WebSocket, and receives Claude Code
4
+ // hook events (injected with `claude --settings <temp file>`). Binds to 127.0.0.1 only.
5
+ import http from 'node:http';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import crypto from 'node:crypto';
10
+ import { spawn as spawnChild } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { createRequire } from 'node:module';
13
+ import { WebSocketServer } from 'ws';
14
+ import { listSessions } from './sessions.js';
15
+ import { sanitize } from './sanitize.js';
16
+ import { inspect as inspectAgents, install as installAgents, PACKS } from './agents.js';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const PUBLIC = path.join(ROOT, 'public');
21
+ const HOOK = path.join(ROOT, 'server', 'hook.js');
22
+
23
+ // ---------- args ----------
24
+ const argv = process.argv.slice(2);
25
+ const flag = (n) => argv.includes(n);
26
+ const opt = (n, d) => {
27
+ const i = argv.indexOf(n);
28
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : d;
29
+ };
30
+ const DEMO = flag('--demo');
31
+ const NO_OPEN = flag('--no-open') || process.env.COURT_NO_OPEN === '1';
32
+ const WANT_PORT = Number(opt('--port', process.env.COURT_PORT || 4747));
33
+ const DEFAULT_CWD = path.resolve(expandHome(opt('--cwd', process.env.INIT_CWD || process.cwd())));
34
+ const TOKEN = crypto.randomBytes(18).toString('hex');
35
+ const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
36
+
37
+ function expandHome(p) {
38
+ return typeof p === 'string' && (p === '~' || p.startsWith('~/')) ? path.join(os.homedir(), p.slice(1)) : p;
39
+ }
40
+
41
+ // ---------- claude binary ----------
42
+ function findClaude() {
43
+ if (process.env.COURT_CLAUDE_BIN) return process.env.COURT_CLAUDE_BIN;
44
+ const names = process.platform === 'win32' ? ['claude.exe', 'claude.cmd', 'claude'] : ['claude'];
45
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
46
+ for (const n of names) {
47
+ const p = path.join(dir, n);
48
+ try {
49
+ if (fs.statSync(p).isFile()) return p;
50
+ } catch {}
51
+ }
52
+ }
53
+ const local = path.join(os.homedir(), '.local', 'bin', 'claude');
54
+ return fs.existsSync(local) ? local : null;
55
+ }
56
+ const CLAUDE = findClaude();
57
+
58
+ // ---------- temp settings with hooks ----------
59
+ const HOOK_EVENTS = [
60
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure',
61
+ 'PermissionRequest', 'PermissionDenied', 'SubagentStart', 'SubagentStop', 'Notification',
62
+ 'Stop', 'StopFailure', 'SessionEnd',
63
+ ];
64
+ let tmpDir = null;
65
+ let settingsPath = null;
66
+ let runtimePath = null;
67
+
68
+ function writeSettings(port) {
69
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'eojeon-'));
70
+ settingsPath = path.join(tmpDir, 'settings.json');
71
+ const q = (s) => `"${s.replace(/"/g, '\\"')}"`;
72
+ const command = `${q(process.execPath)} ${q(HOOK)} ${port} ${TOKEN}`;
73
+ const hooks = {};
74
+ for (const ev of HOOK_EVENTS) hooks[ev] = [{ hooks: [{ type: 'command', command, timeout: 5 }] }];
75
+ fs.writeFileSync(settingsPath, JSON.stringify({ hooks }, null, 2), { mode: 0o600 });
76
+ // lets `npm run fake-hooks` find this server (token stays on this machine, owner-only file)
77
+ runtimePath = path.join(os.tmpdir(), `eojeon-${port}.json`);
78
+ fs.writeFileSync(runtimePath, JSON.stringify({ port, token: TOKEN, hook: HOOK }), { mode: 0o600 });
79
+ }
80
+
81
+ function cleanup() {
82
+ try { if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
83
+ try { if (runtimePath) fs.rmSync(runtimePath, { force: true }); } catch {}
84
+ }
85
+
86
+ // ---------- state ----------
87
+ let pty = null;
88
+ let ptyInfo = null; // { cwd, mode, sessionId, startedAt }
89
+ let outBuf = '';
90
+ const OUT_MAX = 256 * 1024;
91
+ const events = [];
92
+ const EV_MAX = 400;
93
+ let size = { cols: 100, rows: 30 };
94
+ const clients = new Set();
95
+
96
+ function broadcast(msg) {
97
+ const s = JSON.stringify(msg);
98
+ for (const ws of clients) if (ws.readyState === 1) ws.send(s);
99
+ }
100
+
101
+ function pushEvent(ev) {
102
+ events.push(ev);
103
+ if (events.length > EV_MAX) events.splice(0, events.length - EV_MAX);
104
+ broadcast({ t: 'event', ev });
105
+ }
106
+
107
+ function sysEvent(type, extra = {}) {
108
+ pushEvent({ type, time: Date.now(), ...extra });
109
+ }
110
+
111
+ // node-pty ships prebuilt binaries for macOS (arm64/x64) and Windows (x64/arm64).
112
+ // On Linux the prebuilt fork @homebridge/node-pty-prebuilt-multiarch is used instead.
113
+ // Both are optionalDependencies so `npx eojeon-cli` never fails to install (demo still works).
114
+ const PTY_MODULES = ['node-pty', '@homebridge/node-pty-prebuilt-multiarch'];
115
+ let ptyMod = null;
116
+ function loadPty() {
117
+ if (ptyMod) return ptyMod;
118
+ const errors = [];
119
+ for (const name of PTY_MODULES) {
120
+ try {
121
+ ptyMod = require(name);
122
+ return ptyMod;
123
+ } catch (e) {
124
+ errors.push(`${name}: ${String(e.message).split('\n')[0]}`);
125
+ }
126
+ }
127
+ throw new Error(
128
+ '터미널 모듈(node-pty)을 불러오지 못했습니다. `npm i -g eojeon` 으로 다시 설치하거나 빌드 도구(python3, make, C++ 컴파일러)를 설치해 주세요. ' +
129
+ 'Could not load node-pty. ' + errors.join(' | '),
130
+ );
131
+ }
132
+
133
+ // `claude --help` → --permission-mode choices. bypassPermissions is left out on purpose:
134
+ // with it no petition ever reaches the court, and it skips every safety prompt.
135
+ export const PERMISSION_MODES = ['manual', 'acceptEdits', 'plan', 'auto', 'dontAsk'];
136
+
137
+ function startClaude({ cwd, mode, sessionId, permissionMode }) {
138
+ if (DEMO) throw new Error('데모 모드에서는 claude 를 띄우지 않습니다.');
139
+ if (!CLAUDE) throw new Error('PATH 에서 claude 를 찾지 못했습니다. Claude Code 를 먼저 설치하세요.');
140
+ const dir = path.resolve(expandHome(cwd || DEFAULT_CWD));
141
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw new Error(`폴더가 없습니다: ${dir}`);
142
+ stopClaude();
143
+ const args = ['--settings', settingsPath];
144
+ if (mode === 'continue') args.push('--continue');
145
+ else if (mode === 'resume') {
146
+ if (sessionId) {
147
+ if (!/^[0-9a-zA-Z-]{8,64}$/.test(sessionId)) throw new Error('세션 ID 형식이 이상합니다.');
148
+ args.push('--resume', sessionId);
149
+ } else args.push('--resume'); // Claude Code's own picker
150
+ }
151
+ if (permissionMode) {
152
+ if (!PERMISSION_MODES.includes(permissionMode)) throw new Error(`권한 모드가 이상합니다: ${permissionMode}`);
153
+ args.push('--permission-mode', permissionMode);
154
+ }
155
+
156
+ const env = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '1' };
157
+ // if this server itself was started from inside Claude Code, don't let the child think it's nested
158
+ // (session-scoped markers only; user config such as CLAUDE_CODE_USE_BEDROCK is kept)
159
+ for (const k of [
160
+ 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID',
161
+ 'CLAUDE_CODE_SESSION_ATTENDED', 'CLAUDE_CODE_SSE_PORT', 'CLAUDE_CODE_MESSAGING_SOCKET',
162
+ 'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_BRIDGE_SESSION_ID', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'CLAUDE_EFFORT',
163
+ ]) delete env[k];
164
+ for (const k of Object.keys(env)) if (k.startsWith('npm_')) delete env[k];
165
+
166
+ const { spawn } = loadPty();
167
+ pty = spawn(CLAUDE, args, { name: 'xterm-256color', cols: size.cols, rows: size.rows, cwd: dir, env });
168
+ ptyInfo = { cwd: dir, folder: path.basename(dir), mode: mode || 'new', sessionId: sessionId || '', permissionMode: permissionMode || '', startedAt: Date.now(), pid: pty.pid };
169
+ outBuf = '';
170
+ const me = pty;
171
+ pty.onData((d) => {
172
+ outBuf += d;
173
+ if (outBuf.length > OUT_MAX) outBuf = outBuf.slice(outBuf.length - OUT_MAX);
174
+ broadcast({ t: 'out', d });
175
+ });
176
+ pty.onExit(({ exitCode, signal }) => {
177
+ if (pty === me) {
178
+ pty = null;
179
+ broadcast({ t: 'exit', code: exitCode, signal });
180
+ sysEvent('PtyExit', { code: exitCode });
181
+ }
182
+ });
183
+ broadcast({ t: 'started', info: ptyInfo });
184
+ sysEvent('PtyStart', { folder: ptyInfo.folder, mode: ptyInfo.mode });
185
+ }
186
+
187
+ function stopClaude() {
188
+ if (pty) {
189
+ const p = pty;
190
+ pty = null;
191
+ try { p.kill(); } catch {}
192
+ }
193
+ }
194
+
195
+ // ---------- http ----------
196
+ const MIME = {
197
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
198
+ '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.woff2': 'font/woff2',
199
+ '.png': 'image/png', '.ttf': 'font/ttf', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8',
200
+ };
201
+ const VENDOR = {
202
+ '/vendor/xterm.js': () => require.resolve('@xterm/xterm/lib/xterm.js'),
203
+ '/vendor/xterm.css': () => path.join(path.dirname(require.resolve('@xterm/xterm/package.json')), 'css', 'xterm.css'),
204
+ '/vendor/addon-fit.js': () => require.resolve('@xterm/addon-fit/lib/addon-fit.js'),
205
+ };
206
+
207
+ let PORT = WANT_PORT;
208
+ function allowedHost(h) {
209
+ return h === `localhost:${PORT}` || h === `127.0.0.1:${PORT}` || h === `[::1]:${PORT}`;
210
+ }
211
+ function allowedOrigin(o) {
212
+ return o === `http://localhost:${PORT}` || o === `http://127.0.0.1:${PORT}` || o === `http://[::1]:${PORT}`;
213
+ }
214
+
215
+ function sendFile(res, file) {
216
+ fs.readFile(file, (err, data) => {
217
+ if (err) {
218
+ res.writeHead(404).end('not found');
219
+ return;
220
+ }
221
+ res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream', 'cache-control': 'no-cache' });
222
+ res.end(data);
223
+ });
224
+ }
225
+
226
+ function json(res, code, obj) {
227
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
228
+ res.end(JSON.stringify(obj));
229
+ }
230
+
231
+ function readBody(req, max = 64 * 1024) {
232
+ return new Promise((resolve, reject) => {
233
+ let b = '';
234
+ req.on('data', (c) => {
235
+ b += c;
236
+ if (b.length > max) {
237
+ reject(new Error('too big'));
238
+ req.destroy();
239
+ }
240
+ });
241
+ req.on('end', () => resolve(b));
242
+ req.on('error', reject);
243
+ });
244
+ }
245
+
246
+ function loadConfig() {
247
+ return JSON.parse(fs.readFileSync(path.join(ROOT, 'court.config.json'), 'utf8'));
248
+ }
249
+
250
+ const server = http.createServer(async (req, res) => {
251
+ // DNS-rebinding guard: only answer to localhost names
252
+ if (!allowedHost(req.headers.host || '')) {
253
+ res.writeHead(403).end('forbidden host');
254
+ return;
255
+ }
256
+ const url = new URL(req.url, `http://localhost:${PORT}`);
257
+ const p = url.pathname;
258
+
259
+ if (p === '/hook' && req.method === 'POST') {
260
+ if (req.headers['x-court-token'] !== TOKEN) {
261
+ res.writeHead(403).end();
262
+ return;
263
+ }
264
+ try {
265
+ const raw = JSON.parse(await readBody(req));
266
+ // hook.js already trimmed it; sanitize-shape check again in case of a stray client
267
+ const ev = raw && raw.type ? { ...raw, time: Date.now() } : sanitize(raw);
268
+ pushEvent(ev);
269
+ res.writeHead(204).end();
270
+ } catch {
271
+ res.writeHead(400).end();
272
+ }
273
+ return;
274
+ }
275
+
276
+ if (p === '/api/agents/install' && req.method === 'POST') {
277
+ // writes files: only from our own page (same-origin fetch always sends Origin) and only JSON
278
+ if (!allowedOrigin(req.headers.origin || '') || !/^application\/json/.test(req.headers['content-type'] || '')) {
279
+ res.writeHead(403).end();
280
+ return;
281
+ }
282
+ try {
283
+ const { cwd, pack } = JSON.parse(await readBody(req, 4096));
284
+ if (!['yukjo', 'studio', 'all'].includes(pack || 'yukjo')) throw new Error(`pack: ${PACKS.join(' | ')} | all`);
285
+ const r = installAgents(path.resolve(expandHome(cwd || DEFAULT_CWD)), pack || 'yukjo');
286
+ return json(res, 200, r);
287
+ } catch (e) {
288
+ return json(res, 400, { error: e.message });
289
+ }
290
+ }
291
+
292
+ if (req.method !== 'GET') {
293
+ res.writeHead(405).end();
294
+ return;
295
+ }
296
+
297
+ if (p === '/api/info') {
298
+ return json(res, 200, {
299
+ app: 'eojeon', version: VERSION,
300
+ demo: DEMO, defaultCwd: DEFAULT_CWD, claudeFound: !!CLAUDE, running: !!pty, info: ptyInfo,
301
+ home: os.homedir(), platform: process.platform, permissionModes: PERMISSION_MODES,
302
+ });
303
+ }
304
+ if (p === '/api/config') {
305
+ try {
306
+ return json(res, 200, loadConfig());
307
+ } catch (e) {
308
+ return json(res, 500, { error: 'court.config.json 을 읽지 못했습니다: ' + e.message });
309
+ }
310
+ }
311
+ if (p === '/api/agents') {
312
+ const cwd = path.resolve(expandHome(url.searchParams.get('cwd') || DEFAULT_CWD));
313
+ if (!fs.existsSync(cwd)) return json(res, 200, { cwd, folderMissing: true });
314
+ try {
315
+ return json(res, 200, { cwd, ...inspectAgents(cwd, 'yukjo'), studio: inspectAgents(cwd, 'studio').missing.length === 0 });
316
+ } catch (e) {
317
+ return json(res, 500, { error: e.message });
318
+ }
319
+ }
320
+ if (p === '/api/sessions') {
321
+ const cwd = path.resolve(expandHome(url.searchParams.get('cwd') || DEFAULT_CWD));
322
+ return json(res, 200, { cwd, sessions: listSessions(cwd, 30) });
323
+ }
324
+ if (VENDOR[p]) {
325
+ try {
326
+ return sendFile(res, VENDOR[p]());
327
+ } catch {
328
+ res.writeHead(404).end();
329
+ return;
330
+ }
331
+ }
332
+ const rel = p === '/' ? 'index.html' : decodeURIComponent(p).replace(/^\/+/, '');
333
+ const file = path.resolve(PUBLIC, rel);
334
+ if (!file.startsWith(PUBLIC + path.sep)) {
335
+ res.writeHead(403).end();
336
+ return;
337
+ }
338
+ sendFile(res, file);
339
+ });
340
+
341
+ // ---------- websocket ----------
342
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
343
+ server.on('upgrade', (req, socket, head) => {
344
+ // Cross-site WebSocket hijacking guard: a random web page must never be able to type into your terminal.
345
+ if (!allowedHost(req.headers.host || '') || !allowedOrigin(req.headers.origin || '')) {
346
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
347
+ socket.destroy();
348
+ return;
349
+ }
350
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
351
+ });
352
+
353
+ wss.on('connection', (ws) => {
354
+ clients.add(ws);
355
+ ws.send(JSON.stringify({ t: 'hello', demo: DEMO, running: !!pty, info: ptyInfo, history: events.slice(-200) }));
356
+ if (pty && outBuf) ws.send(JSON.stringify({ t: 'out', d: outBuf, replay: true }));
357
+ ws.on('message', (raw) => {
358
+ let m;
359
+ try { m = JSON.parse(raw.toString()); } catch { return; }
360
+ switch (m.t) {
361
+ case 'input':
362
+ if (pty && typeof m.d === 'string') pty.write(m.d);
363
+ break;
364
+ case 'resize': {
365
+ const cols = Math.max(20, Math.min(400, m.cols | 0));
366
+ const rows = Math.max(5, Math.min(200, m.rows | 0));
367
+ size = { cols, rows };
368
+ if (pty) try { pty.resize(cols, rows); } catch {}
369
+ break;
370
+ }
371
+ case 'start':
372
+ try {
373
+ startClaude(m);
374
+ } catch (e) {
375
+ ws.send(JSON.stringify({ t: 'error', message: e.message }));
376
+ }
377
+ break;
378
+ case 'stop':
379
+ stopClaude();
380
+ break;
381
+ default:
382
+ break;
383
+ }
384
+ });
385
+ ws.on('close', () => clients.delete(ws));
386
+ });
387
+
388
+ // ---------- boot ----------
389
+ function listen(port, tries = 10) {
390
+ return new Promise((resolve, reject) => {
391
+ const onErr = (e) => {
392
+ server.off('listening', onOk);
393
+ if (e.code === 'EADDRINUSE' && tries > 0) resolve(listen(port + 1, tries - 1));
394
+ else reject(e);
395
+ };
396
+ const onOk = () => {
397
+ server.off('error', onErr);
398
+ resolve(port);
399
+ };
400
+ server.once('error', onErr);
401
+ server.once('listening', onOk);
402
+ server.listen(port, '127.0.0.1');
403
+ });
404
+ }
405
+
406
+ function openBrowser(url) {
407
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
408
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
409
+ try {
410
+ spawnChild(cmd, args, { stdio: 'ignore', detached: true }).unref();
411
+ } catch {}
412
+ }
413
+
414
+ PORT = await listen(WANT_PORT);
415
+ writeSettings(PORT);
416
+ const url = `http://localhost:${PORT}/${DEMO ? '?demo=1' : ''}`;
417
+ console.log(`\n 어전 · eojeon ${VERSION} ${DEMO ? '(demo)' : ''}`);
418
+ console.log(` ${url}`);
419
+ console.log(` claude: ${CLAUDE || '(not found)'} folder: ${DEFAULT_CWD}`);
420
+ console.log(' Everything stays on this machine (127.0.0.1). Ctrl+C to quit.\n');
421
+ if (!NO_OPEN) openBrowser(url);
422
+
423
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
424
+ process.on(sig, () => {
425
+ stopClaude();
426
+ cleanup();
427
+ process.exit(0);
428
+ });
429
+ }
430
+ process.on('exit', cleanup);
@@ -0,0 +1,155 @@
1
+ // Turns a raw Claude Code hook payload into a small, display-safe event.
2
+ // Runs inside the hook process, so only the trimmed result ever leaves Claude Code
3
+ // (and even then only to 127.0.0.1). No file contents, no tool output, no full prompts.
4
+
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+
8
+ const MAX = 72;
9
+
10
+ // [pattern, replacement]. Order matters: specific token shapes first, generic KEY=value last.
11
+ const SECRET_PATTERNS = [
12
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, '***'],
13
+ [/sk-[A-Za-z0-9_-]{12,}/g, '***'], // Anthropic / OpenAI style keys
14
+ [/gh[pousr]_[A-Za-z0-9]{20,}/g, '***'], // GitHub tokens
15
+ [/github_pat_[A-Za-z0-9_]{20,}/g, '***'],
16
+ [/xox[abprs]-[A-Za-z0-9-]{10,}/g, '***'], // Slack
17
+ [/AKIA[0-9A-Z]{16}/g, '***'], // AWS access key id
18
+ [/AIza[0-9A-Za-z_-]{30,}/g, '***'], // Google API key
19
+ [/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}/g, '***'], // JWT
20
+ [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, '$1 ***'],
21
+ [/(:\/\/)[^\s/:@]+:[^\s/@]+@/g, '$1***@'], // user:pass@host
22
+ // API_KEY=value, token: value, --password value ...
23
+ [/\b([A-Za-z0-9_-]*(?:key|token|secret|passw(?:or)?d|pwd|auth|cookie|credential)[A-Za-z0-9_]*)(\s*[=:]\s*|\s+)("[^"]*"|'[^']*'|[^\s"'&;|]+)/gi, '$1$2***'],
24
+ ];
25
+
26
+ export function redact(s) {
27
+ if (typeof s !== 'string') return '';
28
+ let out = s;
29
+ for (const [re, rep] of SECRET_PATTERNS) out = out.replace(re, rep);
30
+ return out;
31
+ }
32
+
33
+ export function clip(s, n = MAX) {
34
+ if (typeof s !== 'string') return '';
35
+ const one = s.replace(/\s+/g, ' ').trim();
36
+ const chars = [...one];
37
+ return chars.length > n ? chars.slice(0, n - 1).join('') + '…' : one;
38
+ }
39
+
40
+ const HOME = os.homedir();
41
+
42
+ export function shortPath(p, cwd) {
43
+ if (typeof p !== 'string' || !p) return '';
44
+ let out = p;
45
+ if (cwd && (p === cwd || p.startsWith(cwd + path.sep))) out = path.relative(cwd, p) || '.';
46
+ else if (HOME && p.startsWith(HOME + path.sep)) out = '~' + p.slice(HOME.length);
47
+ if (out.length > 48) {
48
+ const parts = out.split(path.sep);
49
+ out = parts.length > 2 ? `${parts[0]}/…/${parts.slice(-2).join('/')}` : '…' + out.slice(-46);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function host(u) {
55
+ try {
56
+ return new URL(u).hostname;
57
+ } catch {
58
+ return '';
59
+ }
60
+ }
61
+
62
+ // One short line describing what a tool is doing, e.g. "npm test" or "App/Views/Home.swift".
63
+ export function toolSummary(tool, input, cwd) {
64
+ const i = input && typeof input === 'object' ? input : {};
65
+ const fp = i.file_path || i.notebook_path || i.path;
66
+ switch (tool) {
67
+ case 'Bash':
68
+ case 'PowerShell':
69
+ return clip(redact(String(i.command || '').split('\n')[0]), 56);
70
+ case 'Read':
71
+ case 'Write':
72
+ case 'Edit':
73
+ case 'MultiEdit':
74
+ case 'NotebookEdit':
75
+ return shortPath(fp, cwd);
76
+ case 'Grep':
77
+ return clip(redact(String(i.pattern || '')), 40) + (i.path ? ` · ${shortPath(i.path, cwd)}` : '');
78
+ case 'Glob':
79
+ return clip(String(i.pattern || ''), 48);
80
+ case 'WebFetch':
81
+ return host(i.url);
82
+ case 'WebSearch':
83
+ return clip(redact(String(i.query || '')), 48);
84
+ case 'Task':
85
+ case 'Agent':
86
+ return clip([i.subagent_type, i.description].filter(Boolean).join(' · '), 56);
87
+ case 'TodoWrite':
88
+ return Array.isArray(i.todos) ? `할 일 ${i.todos.length}개` : '';
89
+ case 'Skill':
90
+ return clip(String(i.skill || i.command || ''), 40);
91
+ default:
92
+ if (typeof tool === 'string' && tool.startsWith('mcp__')) return tool.split('__').slice(1).join(' · ');
93
+ return fp ? shortPath(fp, cwd) : '';
94
+ }
95
+ }
96
+
97
+ function toolLabel(tool) {
98
+ if (typeof tool !== 'string') return '';
99
+ if (tool.startsWith('mcp__')) return 'MCP';
100
+ return tool.slice(0, 32);
101
+ }
102
+
103
+ // raw hook stdin JSON -> display event
104
+ export function sanitize(raw) {
105
+ const r = raw && typeof raw === 'object' ? raw : {};
106
+ const cwd = typeof r.cwd === 'string' ? r.cwd : '';
107
+ const ev = {
108
+ type: String(r.hook_event_name || 'Unknown').slice(0, 40),
109
+ time: Date.now(),
110
+ session: typeof r.session_id === 'string' ? r.session_id.slice(0, 36) : '',
111
+ };
112
+ if (cwd) ev.folder = path.basename(cwd);
113
+ if (r.agent_id) ev.agentId = String(r.agent_id).slice(0, 64);
114
+ if (r.agent_type) ev.agentType = String(r.agent_type).slice(0, 64);
115
+ if (r.permission_mode) ev.mode = String(r.permission_mode).slice(0, 24);
116
+
117
+ switch (ev.type) {
118
+ case 'SessionStart':
119
+ ev.source = String(r.source || 'startup').slice(0, 16);
120
+ if (r.model) ev.model = String(r.model).slice(0, 48);
121
+ break;
122
+ case 'UserPromptSubmit':
123
+ // Only a short, redacted preview of the prompt (the "royal decree").
124
+ ev.prompt = clip(redact(String(r.prompt || '')), 60);
125
+ ev.slash = /^\s*\//.test(String(r.prompt || ''));
126
+ break;
127
+ case 'PreToolUse':
128
+ case 'PostToolUse':
129
+ case 'PostToolUseFailure':
130
+ case 'PermissionRequest':
131
+ case 'PermissionDenied':
132
+ ev.tool = toolLabel(r.tool_name);
133
+ ev.summary = toolSummary(r.tool_name, r.tool_input, cwd);
134
+ if (r.tool_use_id) ev.toolUseId = String(r.tool_use_id).slice(0, 64);
135
+ if ((r.tool_name === 'Task' || r.tool_name === 'Agent') && r.tool_input && r.tool_input.subagent_type) {
136
+ ev.spawnType = String(r.tool_input.subagent_type).slice(0, 64);
137
+ }
138
+ if (ev.type === 'PostToolUse' && typeof r.duration_ms === 'number') ev.ms = r.duration_ms;
139
+ break;
140
+ case 'Notification':
141
+ ev.kind = String(r.notification_type || '').slice(0, 40);
142
+ ev.message = clip(redact(String(r.message || '')), 80);
143
+ break;
144
+ case 'SubagentStop':
145
+ // A one-line taste of the report, nothing more.
146
+ ev.report = clip(redact(String(r.last_assistant_message || '')), 48);
147
+ break;
148
+ case 'SessionEnd':
149
+ if (r.reason) ev.reason = String(r.reason).slice(0, 24);
150
+ break;
151
+ default:
152
+ break;
153
+ }
154
+ return ev;
155
+ }