eojeon-cli 0.0.0-stage → 0.2.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/CHANGELOG.md +39 -0
- package/LICENSE +26 -0
- package/README.md +251 -2
- package/cli/eojeon.js +104 -0
- package/court.config.json +325 -0
- package/package.json +60 -4
- package/public/app.js +405 -0
- package/public/court-types.js +33 -0
- package/public/demo.js +100 -0
- package/public/fonts/D2Coding-Ver1.4.0-20261003.ttf +0 -0
- package/public/fonts/Galmuri11-Bold.woff2 +0 -0
- package/public/fonts/Galmuri11.woff2 +0 -0
- package/public/fonts/Galmuri7.woff2 +0 -0
- package/public/fonts/Galmuri9.woff2 +0 -0
- package/public/fonts/LICENSE-D2Coding.txt +97 -0
- package/public/fonts/LICENSE-Galmuri.txt +93 -0
- package/public/index.html +98 -0
- package/public/scene.js +1082 -0
- package/public/style.css +319 -0
- package/scripts/fix-pty.js +25 -0
- package/server/agents.js +78 -0
- package/server/hook.js +68 -0
- package/server/index.js +541 -0
- package/server/sanitize.js +155 -0
- package/server/sessions.js +115 -0
- package/server/watch.js +96 -0
- package/templates/agents/studio/animation-expert.md +29 -0
- package/templates/agents/studio/apple-designer.md +28 -0
- package/templates/agents/studio/branding-expert.md +29 -0
- package/templates/agents/studio/copywriter.md +28 -0
- package/templates/agents/studio/data-analyst.md +29 -0
- package/templates/agents/studio/marketer.md +28 -0
- package/templates/agents/studio/pr-lead.md +28 -0
- package/templates/agents/studio/product-designer.md +29 -0
- package/templates/agents/studio/sns-manager.md +28 -0
- package/templates/agents/studio/uiux-expert.md +31 -0
- package/templates/agents/studio/video-producer.md +29 -0
- package/templates/agents/yukjo/byeongjo.md +29 -0
- package/templates/agents/yukjo/gongjo.md +29 -0
- package/templates/agents/yukjo/hojo.md +29 -0
- package/templates/agents/yukjo/hyeongjo.md +29 -0
- package/templates/agents/yukjo/ijo.md +29 -0
- package/templates/agents/yukjo/yejo.md +29 -0
package/server/index.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
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
|
+
// unless started with --host (other devices, e.g. over Tailscale), which then requires an access key.
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { spawn as spawnChild } from 'node:child_process';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { WebSocketServer } from 'ws';
|
|
15
|
+
import { listSessions } from './sessions.js';
|
|
16
|
+
import { sanitize } from './sanitize.js';
|
|
17
|
+
import { inspect as inspectAgents, install as installAgents, PACKS } from './agents.js';
|
|
18
|
+
import { HOOK_EVENTS, eojeonDir, runtimeFile } from './watch.js';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const PUBLIC = path.join(ROOT, 'public');
|
|
23
|
+
const HOOK = path.join(ROOT, 'server', 'hook.js');
|
|
24
|
+
|
|
25
|
+
// ---------- args ----------
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const flag = (n) => argv.includes(n);
|
|
28
|
+
const opt = (n, d) => {
|
|
29
|
+
const i = argv.indexOf(n);
|
|
30
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1] : d;
|
|
31
|
+
};
|
|
32
|
+
const DEMO = flag('--demo');
|
|
33
|
+
const NO_OPEN = flag('--no-open') || process.env.COURT_NO_OPEN === '1';
|
|
34
|
+
const WANT_PORT = Number(opt('--port', process.env.COURT_PORT || 4747));
|
|
35
|
+
const DEFAULT_CWD = path.resolve(expandHome(opt('--cwd', process.env.INIT_CWD || process.cwd())));
|
|
36
|
+
const HOST = opt('--host', process.env.COURT_HOST || '127.0.0.1');
|
|
37
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
38
|
+
const REMOTE = !LOOPBACK.has(HOST);
|
|
39
|
+
const TOKEN = crypto.randomBytes(18).toString('hex');
|
|
40
|
+
const KEY = REMOTE ? remoteKey(flag('--new-key')) : '';
|
|
41
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
|
|
42
|
+
|
|
43
|
+
// one key per machine, kept in ~/.eojeon/remote-key so a bookmarked address keeps working; --new-key rotates it
|
|
44
|
+
function remoteKey(rotate) {
|
|
45
|
+
const file = path.join(eojeonDir(), 'remote-key');
|
|
46
|
+
if (!rotate) {
|
|
47
|
+
try {
|
|
48
|
+
const k = fs.readFileSync(file, 'utf8').trim();
|
|
49
|
+
if (/^[0-9a-f]{32,}$/.test(k)) return k;
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
const k = crypto.randomBytes(24).toString('hex');
|
|
53
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
54
|
+
fs.writeFileSync(file, k + '\n', { mode: 0o600 });
|
|
55
|
+
return k;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function expandHome(p) {
|
|
59
|
+
return typeof p === 'string' && (p === '~' || p.startsWith('~/')) ? path.join(os.homedir(), p.slice(1)) : p;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------- claude binary ----------
|
|
63
|
+
function findClaude() {
|
|
64
|
+
if (process.env.COURT_CLAUDE_BIN) return process.env.COURT_CLAUDE_BIN;
|
|
65
|
+
const names = process.platform === 'win32' ? ['claude.exe', 'claude.cmd', 'claude'] : ['claude'];
|
|
66
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
67
|
+
for (const n of names) {
|
|
68
|
+
const p = path.join(dir, n);
|
|
69
|
+
try {
|
|
70
|
+
if (fs.statSync(p).isFile()) return p;
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const local = path.join(os.homedir(), '.local', 'bin', 'claude');
|
|
75
|
+
return fs.existsSync(local) ? local : null;
|
|
76
|
+
}
|
|
77
|
+
const CLAUDE = findClaude();
|
|
78
|
+
|
|
79
|
+
// ---------- temp settings with hooks ----------
|
|
80
|
+
let tmpDir = null;
|
|
81
|
+
let settingsPath = null;
|
|
82
|
+
let runtimePath = null;
|
|
83
|
+
|
|
84
|
+
function writeSettings(port) {
|
|
85
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'eojeon-'));
|
|
86
|
+
settingsPath = path.join(tmpDir, 'settings.json');
|
|
87
|
+
const q = (s) => `"${s.replace(/"/g, '\\"')}"`;
|
|
88
|
+
const command = `${q(process.execPath)} ${q(HOOK)} ${port} ${TOKEN}`;
|
|
89
|
+
const hooks = {};
|
|
90
|
+
for (const ev of HOOK_EVENTS) hooks[ev] = [{ hooks: [{ type: 'command', command, timeout: 5 }] }];
|
|
91
|
+
fs.writeFileSync(settingsPath, JSON.stringify({ hooks }, null, 2), { mode: 0o600 });
|
|
92
|
+
// lets `npm run fake-hooks` find this server (token stays on this machine, owner-only file)
|
|
93
|
+
runtimePath = path.join(os.tmpdir(), `eojeon-${port}.json`);
|
|
94
|
+
fs.writeFileSync(runtimePath, JSON.stringify({ port, token: TOKEN, hook: HOOK }), { mode: 0o600 });
|
|
95
|
+
// lets `eojeon watch` hooks (sessions outside the court) find this server
|
|
96
|
+
try {
|
|
97
|
+
fs.mkdirSync(eojeonDir(), { recursive: true, mode: 0o700 });
|
|
98
|
+
fs.writeFileSync(runtimeFile(), JSON.stringify({ port, token: TOKEN, pid: process.pid }), { mode: 0o600 });
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cleanup() {
|
|
103
|
+
try { if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
104
|
+
try { if (runtimePath) fs.rmSync(runtimePath, { force: true }); } catch {}
|
|
105
|
+
try {
|
|
106
|
+
// only our own: a second court may have taken over the file
|
|
107
|
+
if (JSON.parse(fs.readFileSync(runtimeFile(), 'utf8')).pid === process.pid) fs.rmSync(runtimeFile(), { force: true });
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------- state ----------
|
|
112
|
+
let pty = null;
|
|
113
|
+
let ptyInfo = null; // { cwd, mode, sessionId, startedAt }
|
|
114
|
+
let outBuf = '';
|
|
115
|
+
const OUT_MAX = 256 * 1024;
|
|
116
|
+
const events = [];
|
|
117
|
+
const EV_MAX = 400;
|
|
118
|
+
let size = { cols: 100, rows: 30 };
|
|
119
|
+
const clients = new Set();
|
|
120
|
+
|
|
121
|
+
function broadcast(msg) {
|
|
122
|
+
const s = JSON.stringify(msg);
|
|
123
|
+
for (const ws of clients) if (ws.readyState === 1) ws.send(s);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function pushEvent(ev) {
|
|
127
|
+
events.push(ev);
|
|
128
|
+
if (events.length > EV_MAX) events.splice(0, events.length - EV_MAX);
|
|
129
|
+
broadcast({ t: 'event', ev });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sysEvent(type, extra = {}) {
|
|
133
|
+
pushEvent({ type, time: Date.now(), ...extra });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// node-pty ships prebuilt binaries for macOS (arm64/x64) and Windows (x64/arm64).
|
|
137
|
+
// On Linux the prebuilt fork @homebridge/node-pty-prebuilt-multiarch is used instead.
|
|
138
|
+
// Both are optionalDependencies so `npx eojeon-cli` never fails to install (demo still works).
|
|
139
|
+
const PTY_MODULES = ['node-pty', '@homebridge/node-pty-prebuilt-multiarch'];
|
|
140
|
+
let ptyMod = null;
|
|
141
|
+
function loadPty() {
|
|
142
|
+
if (ptyMod) return ptyMod;
|
|
143
|
+
const errors = [];
|
|
144
|
+
for (const name of PTY_MODULES) {
|
|
145
|
+
try {
|
|
146
|
+
ptyMod = require(name);
|
|
147
|
+
return ptyMod;
|
|
148
|
+
} catch (e) {
|
|
149
|
+
errors.push(`${name}: ${String(e.message).split('\n')[0]}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
throw new Error(
|
|
153
|
+
'터미널 모듈(node-pty)을 불러오지 못했습니다. `npm i -g eojeon` 으로 다시 설치하거나 빌드 도구(python3, make, C++ 컴파일러)를 설치해 주세요. ' +
|
|
154
|
+
'Could not load node-pty. ' + errors.join(' | '),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// `claude --help` → --permission-mode choices. bypassPermissions is left out on purpose:
|
|
159
|
+
// with it no petition ever reaches the court, and it skips every safety prompt.
|
|
160
|
+
export const PERMISSION_MODES = ['manual', 'acceptEdits', 'plan', 'auto', 'dontAsk'];
|
|
161
|
+
|
|
162
|
+
function startClaude({ cwd, mode, sessionId, permissionMode }) {
|
|
163
|
+
if (DEMO) throw new Error('데모 모드에서는 claude 를 띄우지 않습니다.');
|
|
164
|
+
if (!CLAUDE) throw new Error('PATH 에서 claude 를 찾지 못했습니다. Claude Code 를 먼저 설치하세요.');
|
|
165
|
+
const dir = path.resolve(expandHome(cwd || DEFAULT_CWD));
|
|
166
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw new Error(`폴더가 없습니다: ${dir}`);
|
|
167
|
+
stopClaude();
|
|
168
|
+
const args = ['--settings', settingsPath];
|
|
169
|
+
if (mode === 'continue') args.push('--continue');
|
|
170
|
+
else if (mode === 'resume') {
|
|
171
|
+
if (sessionId) {
|
|
172
|
+
if (!/^[0-9a-zA-Z-]{8,64}$/.test(sessionId)) throw new Error('세션 ID 형식이 이상합니다.');
|
|
173
|
+
args.push('--resume', sessionId);
|
|
174
|
+
} else args.push('--resume'); // Claude Code's own picker
|
|
175
|
+
}
|
|
176
|
+
if (permissionMode) {
|
|
177
|
+
if (!PERMISSION_MODES.includes(permissionMode)) throw new Error(`권한 모드가 이상합니다: ${permissionMode}`);
|
|
178
|
+
args.push('--permission-mode', permissionMode);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const env = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '1' };
|
|
182
|
+
// if this server itself was started from inside Claude Code, don't let the child think it's nested
|
|
183
|
+
// (session-scoped markers only; user config such as CLAUDE_CODE_USE_BEDROCK is kept)
|
|
184
|
+
for (const k of [
|
|
185
|
+
'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID',
|
|
186
|
+
'CLAUDE_CODE_SESSION_ATTENDED', 'CLAUDE_CODE_SSE_PORT', 'CLAUDE_CODE_MESSAGING_SOCKET',
|
|
187
|
+
'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_BRIDGE_SESSION_ID', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'CLAUDE_EFFORT',
|
|
188
|
+
]) delete env[k];
|
|
189
|
+
for (const k of Object.keys(env)) if (k.startsWith('npm_')) delete env[k];
|
|
190
|
+
env.EOJEON_CHILD = '1'; // `eojeon watch` hooks skip this session (it already reports through --settings)
|
|
191
|
+
|
|
192
|
+
const { spawn } = loadPty();
|
|
193
|
+
pty = spawn(CLAUDE, args, { name: 'xterm-256color', cols: size.cols, rows: size.rows, cwd: dir, env });
|
|
194
|
+
ptyInfo = { cwd: dir, folder: path.basename(dir), mode: mode || 'new', sessionId: sessionId || '', permissionMode: permissionMode || '', startedAt: Date.now(), pid: pty.pid };
|
|
195
|
+
outBuf = '';
|
|
196
|
+
const me = pty;
|
|
197
|
+
pty.onData((d) => {
|
|
198
|
+
outBuf += d;
|
|
199
|
+
if (outBuf.length > OUT_MAX) outBuf = outBuf.slice(outBuf.length - OUT_MAX);
|
|
200
|
+
broadcast({ t: 'out', d });
|
|
201
|
+
});
|
|
202
|
+
pty.onExit(({ exitCode, signal }) => {
|
|
203
|
+
if (pty === me) {
|
|
204
|
+
pty = null;
|
|
205
|
+
broadcast({ t: 'exit', code: exitCode, signal });
|
|
206
|
+
sysEvent('PtyExit', { code: exitCode });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
broadcast({ t: 'started', info: ptyInfo });
|
|
210
|
+
sysEvent('PtyStart', { folder: ptyInfo.folder, mode: ptyInfo.mode });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function stopClaude() {
|
|
214
|
+
if (pty) {
|
|
215
|
+
const p = pty;
|
|
216
|
+
pty = null;
|
|
217
|
+
try { p.kill(); } catch {}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------- http ----------
|
|
222
|
+
const MIME = {
|
|
223
|
+
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
|
|
224
|
+
'.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.woff2': 'font/woff2',
|
|
225
|
+
'.png': 'image/png', '.ttf': 'font/ttf', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8',
|
|
226
|
+
};
|
|
227
|
+
const VENDOR = {
|
|
228
|
+
'/vendor/xterm.js': () => require.resolve('@xterm/xterm/lib/xterm.js'),
|
|
229
|
+
'/vendor/xterm.css': () => path.join(path.dirname(require.resolve('@xterm/xterm/package.json')), 'css', 'xterm.css'),
|
|
230
|
+
'/vendor/addon-fit.js': () => require.resolve('@xterm/addon-fit/lib/addon-fit.js'),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
let PORT = WANT_PORT;
|
|
234
|
+
function allowedHost(h) {
|
|
235
|
+
return h === `localhost:${PORT}` || h === `127.0.0.1:${PORT}` || h === `[::1]:${PORT}`;
|
|
236
|
+
}
|
|
237
|
+
function allowedOrigin(o) {
|
|
238
|
+
return o === `http://localhost:${PORT}` || o === `http://127.0.0.1:${PORT}` || o === `http://[::1]:${PORT}`;
|
|
239
|
+
}
|
|
240
|
+
const fromLoopback = (req) => /^(127\.|::1$|::ffff:127\.)/.test(req.socket.remoteAddress || '');
|
|
241
|
+
function cookieKey(req) {
|
|
242
|
+
const m = /(?:^|;\s*)eojeon_key=([0-9a-f]+)/.exec(req.headers.cookie || '');
|
|
243
|
+
return m ? m[1] : '';
|
|
244
|
+
}
|
|
245
|
+
function keyOk(k) {
|
|
246
|
+
return !!KEY && typeof k === 'string' && k.length === KEY.length && crypto.timingSafeEqual(Buffer.from(k), Buffer.from(KEY));
|
|
247
|
+
}
|
|
248
|
+
// Who may use the page and the terminal:
|
|
249
|
+
// - this computer, under a localhost name (DNS-rebinding guard) — as before
|
|
250
|
+
// - with --host: anyone holding the access key (cookie set from ?key=… on the first visit)
|
|
251
|
+
function access(req) {
|
|
252
|
+
if (fromLoopback(req) && allowedHost(req.headers.host || '')) return 'local';
|
|
253
|
+
if (REMOTE && keyOk(cookieKey(req))) return 'key';
|
|
254
|
+
return '';
|
|
255
|
+
}
|
|
256
|
+
// same-origin check for anything that writes (terminal socket, agent install)
|
|
257
|
+
function sameOrigin(req, who) {
|
|
258
|
+
const o = req.headers.origin || '';
|
|
259
|
+
return who === 'local' ? allowedOrigin(o) : o === `http://${req.headers.host}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function sendFile(res, file) {
|
|
263
|
+
fs.readFile(file, (err, data) => {
|
|
264
|
+
if (err) {
|
|
265
|
+
res.writeHead(404).end('not found');
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream', 'cache-control': 'no-cache' });
|
|
269
|
+
res.end(data);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function json(res, code, obj) {
|
|
274
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
275
|
+
res.end(JSON.stringify(obj));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function readBody(req, max = 64 * 1024) {
|
|
279
|
+
return new Promise((resolve, reject) => {
|
|
280
|
+
let b = '';
|
|
281
|
+
req.on('data', (c) => {
|
|
282
|
+
b += c;
|
|
283
|
+
if (b.length > max) {
|
|
284
|
+
reject(new Error('too big'));
|
|
285
|
+
req.destroy();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
req.on('end', () => resolve(b));
|
|
289
|
+
req.on('error', reject);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function loadConfig() {
|
|
294
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, 'court.config.json'), 'utf8'));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function handle(req, res) {
|
|
298
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
299
|
+
const p = url.pathname;
|
|
300
|
+
|
|
301
|
+
if (p === '/hook' && req.method === 'POST') {
|
|
302
|
+
// hooks always run on this computer
|
|
303
|
+
if (!fromLoopback(req) || req.headers['x-court-token'] !== TOKEN) {
|
|
304
|
+
res.writeHead(403).end();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const raw = JSON.parse(await readBody(req));
|
|
309
|
+
// hook.js already trimmed it; sanitize-shape check again in case of a stray client
|
|
310
|
+
const ev = raw && raw.type ? { ...raw, time: Date.now() } : sanitize(raw);
|
|
311
|
+
pushEvent(ev);
|
|
312
|
+
res.writeHead(204).end();
|
|
313
|
+
} catch {
|
|
314
|
+
res.writeHead(400).end();
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let who = access(req);
|
|
320
|
+
if (!who && REMOTE && req.method === 'GET' && keyOk(url.searchParams.get('key'))) {
|
|
321
|
+
// first visit from another device: trade the key in the address for a cookie, then drop it from the address bar
|
|
322
|
+
url.searchParams.delete('key');
|
|
323
|
+
res.writeHead(302, {
|
|
324
|
+
'set-cookie': `eojeon_key=${KEY}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${60 * 60 * 24 * 30}`,
|
|
325
|
+
location: url.pathname + url.search,
|
|
326
|
+
'cache-control': 'no-store',
|
|
327
|
+
});
|
|
328
|
+
res.end();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (!who) {
|
|
332
|
+
// DNS-rebinding guard: only localhost names, or the access key with --host
|
|
333
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' }).end(
|
|
334
|
+
REMOTE ? 'forbidden — 어전을 켠 터미널에 찍힌 주소(?key=…)로 한 번 열어 주세요' : 'forbidden host',
|
|
335
|
+
);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (p === '/api/agents/install' && req.method === 'POST') {
|
|
340
|
+
// writes files: only from our own page (same-origin fetch always sends Origin) and only JSON
|
|
341
|
+
if (!sameOrigin(req, who) || !/^application\/json/.test(req.headers['content-type'] || '')) {
|
|
342
|
+
res.writeHead(403).end();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const { cwd, pack } = JSON.parse(await readBody(req, 4096));
|
|
347
|
+
if (!['yukjo', 'studio', 'all'].includes(pack || 'yukjo')) throw new Error(`pack: ${PACKS.join(' | ')} | all`);
|
|
348
|
+
const r = installAgents(path.resolve(expandHome(cwd || DEFAULT_CWD)), pack || 'yukjo');
|
|
349
|
+
return json(res, 200, r);
|
|
350
|
+
} catch (e) {
|
|
351
|
+
return json(res, 400, { error: e.message });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (req.method !== 'GET') {
|
|
356
|
+
res.writeHead(405).end();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (p === '/api/info') {
|
|
361
|
+
return json(res, 200, {
|
|
362
|
+
app: 'eojeon', version: VERSION,
|
|
363
|
+
demo: DEMO, defaultCwd: DEFAULT_CWD, claudeFound: !!CLAUDE, running: !!pty, info: ptyInfo,
|
|
364
|
+
home: os.homedir(), platform: process.platform, permissionModes: PERMISSION_MODES, remote: REMOTE,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
if (p === '/api/config') {
|
|
368
|
+
try {
|
|
369
|
+
return json(res, 200, loadConfig());
|
|
370
|
+
} catch (e) {
|
|
371
|
+
return json(res, 500, { error: 'court.config.json 을 읽지 못했습니다: ' + e.message });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (p === '/api/agents') {
|
|
375
|
+
const cwd = path.resolve(expandHome(url.searchParams.get('cwd') || DEFAULT_CWD));
|
|
376
|
+
if (!fs.existsSync(cwd)) return json(res, 200, { cwd, folderMissing: true });
|
|
377
|
+
try {
|
|
378
|
+
return json(res, 200, { cwd, ...inspectAgents(cwd, 'yukjo'), studio: inspectAgents(cwd, 'studio').missing.length === 0 });
|
|
379
|
+
} catch (e) {
|
|
380
|
+
return json(res, 500, { error: e.message });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (p === '/api/sessions') {
|
|
384
|
+
const cwd = path.resolve(expandHome(url.searchParams.get('cwd') || DEFAULT_CWD));
|
|
385
|
+
return json(res, 200, { cwd, sessions: listSessions(cwd, 30) });
|
|
386
|
+
}
|
|
387
|
+
if (VENDOR[p]) {
|
|
388
|
+
try {
|
|
389
|
+
return sendFile(res, VENDOR[p]());
|
|
390
|
+
} catch {
|
|
391
|
+
res.writeHead(404).end();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const rel = p === '/' ? 'index.html' : decodeURIComponent(p).replace(/^\/+/, '');
|
|
396
|
+
const file = path.resolve(PUBLIC, rel);
|
|
397
|
+
if (!file.startsWith(PUBLIC + path.sep)) {
|
|
398
|
+
res.writeHead(403).end();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
sendFile(res, file);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---------- websocket ----------
|
|
405
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
|
|
406
|
+
function onUpgrade(req, socket, head) {
|
|
407
|
+
// Cross-site WebSocket hijacking guard: a random web page must never be able to type into your terminal.
|
|
408
|
+
const who = access(req);
|
|
409
|
+
if (!who || !sameOrigin(req, who)) {
|
|
410
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
411
|
+
socket.destroy();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
|
415
|
+
}
|
|
416
|
+
function makeServer() {
|
|
417
|
+
const s = http.createServer(handle);
|
|
418
|
+
s.on('upgrade', onUpgrade);
|
|
419
|
+
return s;
|
|
420
|
+
}
|
|
421
|
+
// always on 127.0.0.1 (hooks post there); with --host also on that address unless it already covers loopback
|
|
422
|
+
const server = makeServer();
|
|
423
|
+
const WILDCARD = HOST === '0.0.0.0' || HOST === '::';
|
|
424
|
+
|
|
425
|
+
wss.on('connection', (ws) => {
|
|
426
|
+
clients.add(ws);
|
|
427
|
+
ws.send(JSON.stringify({ t: 'hello', demo: DEMO, running: !!pty, info: ptyInfo, history: events.slice(-200) }));
|
|
428
|
+
if (pty && outBuf) ws.send(JSON.stringify({ t: 'out', d: outBuf, replay: true }));
|
|
429
|
+
ws.on('message', (raw) => {
|
|
430
|
+
let m;
|
|
431
|
+
try { m = JSON.parse(raw.toString()); } catch { return; }
|
|
432
|
+
switch (m.t) {
|
|
433
|
+
case 'input':
|
|
434
|
+
if (pty && typeof m.d === 'string') pty.write(m.d);
|
|
435
|
+
break;
|
|
436
|
+
case 'resize': {
|
|
437
|
+
const cols = Math.max(20, Math.min(400, m.cols | 0));
|
|
438
|
+
const rows = Math.max(5, Math.min(200, m.rows | 0));
|
|
439
|
+
size = { cols, rows };
|
|
440
|
+
if (pty) try { pty.resize(cols, rows); } catch {}
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
case 'start':
|
|
444
|
+
try {
|
|
445
|
+
startClaude(m);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
ws.send(JSON.stringify({ t: 'error', message: e.message }));
|
|
448
|
+
}
|
|
449
|
+
break;
|
|
450
|
+
case 'stop':
|
|
451
|
+
stopClaude();
|
|
452
|
+
break;
|
|
453
|
+
default:
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
ws.on('close', () => clients.delete(ws));
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// ---------- boot ----------
|
|
461
|
+
function listen(port, tries = 10) {
|
|
462
|
+
return new Promise((resolve, reject) => {
|
|
463
|
+
const onErr = (e) => {
|
|
464
|
+
server.off('listening', onOk);
|
|
465
|
+
if (e.code === 'EADDRINUSE' && tries > 0) resolve(listen(port + 1, tries - 1));
|
|
466
|
+
else reject(e);
|
|
467
|
+
};
|
|
468
|
+
const onOk = () => {
|
|
469
|
+
server.off('error', onErr);
|
|
470
|
+
resolve(port);
|
|
471
|
+
};
|
|
472
|
+
server.once('error', onErr);
|
|
473
|
+
server.once('listening', onOk);
|
|
474
|
+
server.listen(port, WILDCARD ? HOST : '127.0.0.1');
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function listenAlso(port) {
|
|
479
|
+
return new Promise((resolve, reject) => {
|
|
480
|
+
const s = makeServer();
|
|
481
|
+
s.once('error', reject);
|
|
482
|
+
s.listen(port, HOST, () => resolve(s));
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// addresses another device can use: Tailscale (100.64.0.0/10), LAN, and this computer's name
|
|
487
|
+
function remoteUrls(port) {
|
|
488
|
+
const out = [];
|
|
489
|
+
if (!WILDCARD) out.push(HOST);
|
|
490
|
+
else {
|
|
491
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
492
|
+
for (const a of list || []) if (a.family === 'IPv4' && !a.internal) out.push(a.address);
|
|
493
|
+
}
|
|
494
|
+
out.push(os.hostname().replace(/\.local$/, ''));
|
|
495
|
+
}
|
|
496
|
+
const ts = (a) => /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a);
|
|
497
|
+
return [...new Set(out)]
|
|
498
|
+
.sort((a, b) => ts(b) - ts(a))
|
|
499
|
+
.map((h) => `http://${h.includes(':') ? `[${h}]` : h}:${port}/?key=${KEY}${ts(h) ? ' (Tailscale)' : ''}`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function openBrowser(url) {
|
|
503
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
504
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
505
|
+
try {
|
|
506
|
+
spawnChild(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
507
|
+
} catch {}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
PORT = await listen(WANT_PORT);
|
|
511
|
+
if (REMOTE && !WILDCARD) {
|
|
512
|
+
try {
|
|
513
|
+
await listenAlso(PORT);
|
|
514
|
+
} catch (e) {
|
|
515
|
+
console.error(` --host ${HOST}: ${e.message}`);
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
writeSettings(PORT);
|
|
520
|
+
const url = `http://localhost:${PORT}/${DEMO ? '?demo=1' : ''}`;
|
|
521
|
+
console.log(`\n 어전 · eojeon ${VERSION} ${DEMO ? '(demo)' : ''}`);
|
|
522
|
+
console.log(` ${url}`);
|
|
523
|
+
console.log(` claude: ${CLAUDE || '(not found)'} folder: ${DEFAULT_CWD}`);
|
|
524
|
+
if (REMOTE) {
|
|
525
|
+
console.log(`\n 다른 기기에서 (--host ${HOST}) — 처음 한 번은 키가 붙은 주소로 여세요:`);
|
|
526
|
+
for (const u of remoteUrls(PORT)) console.log(` ${u}`);
|
|
527
|
+
console.log(' ! 이 키를 가진 사람은 이 컴퓨터의 claude 터미널을 쓸 수 있습니다. 믿는 기기에서만 여세요. 키 바꾸기: --new-key');
|
|
528
|
+
console.log(' Ctrl+C to quit.\n');
|
|
529
|
+
} else {
|
|
530
|
+
console.log(' Everything stays on this machine (127.0.0.1). Ctrl+C to quit.\n');
|
|
531
|
+
}
|
|
532
|
+
if (!NO_OPEN) openBrowser(url);
|
|
533
|
+
|
|
534
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
535
|
+
process.on(sig, () => {
|
|
536
|
+
stopClaude();
|
|
537
|
+
cleanup();
|
|
538
|
+
process.exit(0);
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
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
|
+
}
|