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
package/public/app.js ADDED
@@ -0,0 +1,397 @@
1
+ import { Court, W, H } from './scene.js';
2
+ import { runDemo } from './demo.js';
3
+ import { titleOf as typeTitle } from './court-types.js';
4
+
5
+ const $ = (id) => document.getElementById(id);
6
+ const params = new URLSearchParams(location.search);
7
+
8
+ const [config, info] = await Promise.all([
9
+ fetch('/api/config').then((r) => r.json()),
10
+ fetch('/api/info').then((r) => r.json()).catch(() => ({ demo: true })),
11
+ ]);
12
+ const DEMO = info.demo || params.get('demo') === '1';
13
+
14
+ // ---------- branding from config ----------
15
+ document.title = config.name || document.title;
16
+ $('appName').textContent = config.name || '';
17
+ $('appSub').textContent = config.tagline || config.subtitle || '';
18
+ const root = document.documentElement.style;
19
+ for (const [k, v] of Object.entries(config.palette || {})) if (!k.startsWith('_')) root.setProperty(`--${k}`, v);
20
+ for (const k of ['hong', 'cheong', 'nok', 'inju', 'paper']) if (config.pixel?.[k]) root.setProperty(`--px-${k}`, config.pixel[k]);
21
+
22
+ // ---------- terminal ----------
23
+ // D2Coding first: its Hangul is exactly 2 cells wide, so Korean text doesn't drift.
24
+ // xterm measures the cell size once, so wait for the font before opening the terminal.
25
+ await Promise.race([document.fonts.load('13px "D2Coding"', '가A').catch(() => {}), new Promise((r) => setTimeout(r, 2500))]);
26
+ const term = new window.Terminal({
27
+ fontFamily: '"D2Coding", Menlo, Monaco, Consolas, "Noto Sans Mono CJK KR", monospace',
28
+ fontSize: 13,
29
+ lineHeight: 1.1,
30
+ cursorBlink: true,
31
+ scrollback: 5000,
32
+ allowProposedApi: true,
33
+ macOptionIsMeta: true,
34
+ theme: {
35
+ background: config.palette.paper,
36
+ foreground: config.palette.ink,
37
+ cursor: config.palette.hwang,
38
+ selectionBackground: '#3A3F4A',
39
+ },
40
+ });
41
+ const fit = new window.FitAddon.FitAddon();
42
+ term.loadAddon(fit);
43
+ term.open($('term'));
44
+
45
+ let ws = null;
46
+ let running = false;
47
+ const send = (m) => ws && ws.readyState === 1 && ws.send(JSON.stringify(m));
48
+ const sendInput = (d) => (DEMO ? null : send({ t: 'input', d }));
49
+
50
+ function doFit() {
51
+ try {
52
+ fit.fit();
53
+ send({ t: 'resize', cols: term.cols, rows: term.rows });
54
+ } catch {}
55
+ }
56
+ new ResizeObserver(() => doFit()).observe($('term'));
57
+ term.onData((d) => {
58
+ sendInput(d);
59
+ // answered the permission dialog directly in the terminal: Esc = refused
60
+ if (d === '\x1b' && court.petition && !court.petition.closed) {
61
+ court.closePetition('no');
62
+ addLog({ type: 'Verdict', time: Date.now(), verdict: 'no' });
63
+ }
64
+ });
65
+
66
+ // ---------- stage scaling (integer when big enough, so pixels stay square) ----------
67
+ const stageWrap = document.querySelector('.stage-wrap');
68
+ const stage = $('stage');
69
+ const canvas = $('scene');
70
+ const overlay = $('overlay');
71
+ function scaleStage() {
72
+ const r = stageWrap.getBoundingClientRect();
73
+ let s = Math.min(r.width / W, r.height / H);
74
+ if (s >= 2) s = Math.floor(s);
75
+ s = Math.max(0.5, s);
76
+ stage.style.width = `${W * s}px`;
77
+ stage.style.height = `${H * s}px`;
78
+ canvas.style.width = `${W * s}px`;
79
+ canvas.style.height = `${H * s}px`;
80
+ overlay.style.transform = `scale(${s})`;
81
+ overlay.style.transformOrigin = '0 0';
82
+ }
83
+ new ResizeObserver(scaleStage).observe(stageWrap);
84
+ scaleStage();
85
+
86
+ // ---------- court ----------
87
+ const court = new Court({
88
+ canvas,
89
+ overlay,
90
+ config,
91
+ onPetitionAction: (kind) => {
92
+ const keys = config.permissionKeys || {};
93
+ const k = keys[kind];
94
+ if (k) sendInput(k);
95
+ addLog({ type: 'Verdict', time: Date.now(), verdict: kind });
96
+ term.focus();
97
+ },
98
+ });
99
+
100
+ // ---------- 사관 일지 (event log) ----------
101
+ const logEl = $('log');
102
+ const ph = config.phrases || {};
103
+ const officials = config.officials || {};
104
+ const titleOf = (t) => typeTitle(t, config);
105
+ const fmt = (tpl, v) => String(tpl || '').replace(/\{(\w+)\}/g, (_, k) => (v[k] == null ? '' : v[k]));
106
+ const byTool = new Map();
107
+ let lastAsk = 0;
108
+
109
+ function logLine(ev) {
110
+ const who = titleOf(ev.agentType);
111
+ switch (ev.type) {
112
+ case 'PtyStart': return ['sys', '·', `claude 를 띄웠습니다 · ${ev.folder} (${{ new: '새 조회', continue: '이어서', resume: '세션 이어받기' }[ev.mode] || ev.mode})`];
113
+ case 'PtyExit': return ['sys', '·', `claude 가 끝났습니다 (code ${ev.code})`];
114
+ case 'SessionStart':
115
+ if (ev.source === 'clear') return ['sys', '·', '조정을 새로 엽니다 (/clear)'];
116
+ if (ev.source === 'compact') return ['sys', '·', '기록을 줄였사옵니다 (compact)'];
117
+ return ['sys', '開', fmt(ev.source === 'resume' || ev.source === 'fork' ? ph.sessionResume : ph.sessionStart, { folder: ev.folder || '' })];
118
+ case 'UserPromptSubmit': return ['decree', '敎', `교지: ${ev.prompt || ''}`];
119
+ case 'PreToolUse': return ['tool', '筆', `${who} · ${ev.tool}${ev.summary ? ': ' + ev.summary : ''}`];
120
+ case 'PostToolUseFailure': return ['fail', '×', `${who} · ${fmt(ph.failed, { tool: ev.tool })}`];
121
+ case 'PermissionRequest': lastAsk = ev.time; return ['ask', '?', `${who} · ${ph.permission} — ${ev.tool}${ev.summary ? ': ' + ev.summary : ''}`];
122
+ case 'PermissionDenied': return ['fail', '×', `${who} · ${fmt(ph.denied, { tool: ev.tool })}`];
123
+ case 'Notification':
124
+ if (ev.kind === 'permission_prompt') return ev.time - lastAsk < 15000 ? null : ['ask', '?', `${ph.permission} — ${ev.message || ''}`];
125
+ if (ev.kind === 'idle_prompt') return ['sys', '…', ph.idle];
126
+ return ['sys', '·', ev.message || ev.kind];
127
+ case 'SubagentStart': return ['sub', '入', `${titleOf(ev.agentType)} 입궐 (${ev.agentType})`];
128
+ case 'Agents': return ['sys', '六', ev.text];
129
+ case 'SubagentStop': return ['sub', '出', `${titleOf(ev.agentType)} 상소: ${ev.report || '맡은 일 마침'}`];
130
+ case 'Stop': return ['stop', '完', ph.stop];
131
+ case 'StopFailure': return ['fail', '×', '일을 마치지 못했사옵니다'];
132
+ case 'SessionEnd': return ['sys', '退', `${ph.sessionEnd}${ev.reason ? ` (${ev.reason})` : ''}`];
133
+ case 'Verdict': {
134
+ const s = config.stamps?.[ev.verdict] || {};
135
+ return ['ask', ev.verdict === 'ok' ? '○' : ev.verdict === 'no' ? '×' : '…', `판정: ${s.full || ev.verdict}`];
136
+ }
137
+ default: return null;
138
+ }
139
+ }
140
+
141
+ function addLog(ev) {
142
+ if (ev.type === 'PostToolUse') {
143
+ const li = ev.toolUseId && byTool.get(ev.toolUseId);
144
+ if (li && !li.querySelector('.ok')) {
145
+ const s = document.createElement('span');
146
+ s.className = 'ok';
147
+ s.textContent = ev.ms != null ? `✓ ${ev.ms < 1000 ? ev.ms + 'ms' : (ev.ms / 1000).toFixed(1) + 's'}` : '✓';
148
+ li.querySelector('.tx').appendChild(s);
149
+ }
150
+ return;
151
+ }
152
+ const line = logLine(ev);
153
+ if (!line) return;
154
+ const [kind, icon, text] = line;
155
+ const li = document.createElement('li');
156
+ li.className = `k-${kind}`;
157
+ const t = document.createElement('time');
158
+ const d = new Date(ev.time || Date.now());
159
+ t.textContent = [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, '0')).join(':');
160
+ const ic = document.createElement('span');
161
+ ic.className = 'ic';
162
+ ic.textContent = icon;
163
+ const tx = document.createElement('span');
164
+ tx.className = 'tx';
165
+ tx.textContent = text;
166
+ li.append(t, ic, tx);
167
+ if (ev.toolUseId) byTool.set(ev.toolUseId, li);
168
+ const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 30;
169
+ logEl.appendChild(li);
170
+ while (logEl.children.length > 400) logEl.firstChild.remove();
171
+ if (atBottom) logEl.scrollTop = logEl.scrollHeight;
172
+ }
173
+
174
+ function onEvent(ev, opts = {}) {
175
+ addLog(ev);
176
+ court.handle(ev, opts);
177
+ }
178
+
179
+ // ---------- launch controls ----------
180
+ const cwdIn = $('cwd');
181
+ const sessSel = $('session');
182
+ const statusEl = $('status');
183
+ const hint = $('termHint');
184
+ function setStatus(state, text) {
185
+ statusEl.dataset.state = state;
186
+ statusEl.textContent = text;
187
+ }
188
+ let lastCwd = '';
189
+ try { lastCwd = localStorage.getItem('eojeon.cwd') || ''; } catch {}
190
+ cwdIn.value = DEMO ? '~/projects/my-app' : lastCwd || info.defaultCwd || '';
191
+
192
+ function fmtTime(ms) {
193
+ const d = new Date(ms);
194
+ const p = (n) => String(n).padStart(2, '0');
195
+ return `${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
196
+ }
197
+
198
+ async function loadSessions() {
199
+ const keep = sessSel.value;
200
+ sessSel.innerHTML = '';
201
+ const add = (v, label, parent = sessSel) => {
202
+ const o = document.createElement('option');
203
+ o.value = v;
204
+ o.textContent = label;
205
+ parent.appendChild(o);
206
+ return o;
207
+ };
208
+ add('new', '새 조회 (새 세션)');
209
+ add('continue', '이어서 — 가장 최근 세션 (--continue)');
210
+ add('resume:', '세션 고르기 — Claude Code 목록 (/resume)');
211
+ if (DEMO) return;
212
+ try {
213
+ const r = await fetch(`/api/sessions?cwd=${encodeURIComponent(cwdIn.value.trim())}`).then((x) => x.json());
214
+ if (r.sessions?.length) {
215
+ const g = document.createElement('optgroup');
216
+ g.label = `지난 조회 ${r.sessions.length}개 (최신순)`;
217
+ for (const s of r.sessions) add(`resume:${s.id}`, `${fmtTime(s.mtime)} · ${s.title ? `[${s.title}] ` : ''}${s.first || s.id.slice(0, 8)}`, g);
218
+ sessSel.appendChild(g);
219
+ }
220
+ } catch {}
221
+ if ([...sessSel.options].some((o) => o.value === keep)) sessSel.value = keep;
222
+ }
223
+ let sessTimer;
224
+ cwdIn.addEventListener('input', () => {
225
+ clearTimeout(sessTimer);
226
+ sessTimer = setTimeout(loadSessions, 350);
227
+ });
228
+ loadSessions();
229
+
230
+ // ---------- permission mode ----------
231
+ const pmodeSel = $('pmode');
232
+ try { pmodeSel.value = localStorage.getItem('eojeon.pmode') || ''; } catch {}
233
+ if (![...pmodeSel.options].some((o) => o.value === pmodeSel.value)) pmodeSel.value = '';
234
+ pmodeSel.addEventListener('change', () => {
235
+ try { localStorage.setItem('eojeon.pmode', pmodeSel.value); } catch {}
236
+ });
237
+
238
+ // ---------- 육조 들이기 (offer the default subagents for this folder, once) ----------
239
+ const YUKJO = ['ijo', 'hojo', 'yejo', 'byeongjo', 'hyeongjo', 'gongjo'];
240
+ const askedKey = (cwd) => `eojeon.yukjo.never:${cwd}`;
241
+ async function offerYukjo(cwd) {
242
+ let st;
243
+ try {
244
+ st = await fetch(`/api/agents?cwd=${encodeURIComponent(cwd)}`).then((r) => r.json());
245
+ } catch {
246
+ return;
247
+ }
248
+ if (!st || st.error || st.folderMissing || !st.missing?.length) return;
249
+ try { if (localStorage.getItem(askedKey(st.cwd))) return; } catch {}
250
+ const dlg = $('yukjoDlg');
251
+ $('yukjoWhere').textContent = st.dir;
252
+ $('yukjoMissing').textContent = `${st.missing.length}명`;
253
+ const list = $('yukjoList');
254
+ list.innerHTML = '';
255
+ for (const k of YUKJO) {
256
+ const li = document.createElement('li');
257
+ const d = officials[k] || {};
258
+ li.textContent = `${d.title || k} (${k}) — ${d.role || ''}`;
259
+ if (!st.missing.includes(k)) {
260
+ li.className = 'have';
261
+ li.title = '이미 있음 · 그대로 둡니다';
262
+ }
263
+ list.appendChild(li);
264
+ }
265
+ $('studioChk').checked = false;
266
+ const answer = await new Promise((resolve) => {
267
+ dlg.addEventListener('close', () => resolve(dlg.returnValue), { once: true });
268
+ dlg.showModal();
269
+ });
270
+ if (answer === 'never') {
271
+ try { localStorage.setItem(askedKey(st.cwd), '1'); } catch {}
272
+ return;
273
+ }
274
+ if (answer !== 'yes') return;
275
+ const pack = $('studioChk').checked ? 'all' : 'yukjo';
276
+ try {
277
+ const r = await fetch('/api/agents/install', {
278
+ method: 'POST',
279
+ headers: { 'content-type': 'application/json' },
280
+ body: JSON.stringify({ cwd: st.cwd, pack }),
281
+ }).then((x) => x.json());
282
+ if (r.error) throw new Error(r.error);
283
+ const text = `육조를 들였사옵니다 · 새로 ${r.copied.length}명${r.skipped.length ? ` · 이미 있어 건너뜀 ${r.skipped.length}명(${r.skipped.join(', ')})` : ''}`;
284
+ addLog({ type: 'Agents', time: Date.now(), text });
285
+ court.showToast(text, 4200);
286
+ } catch (err) {
287
+ setStatus('error', `육조 들이기 실패: ${err.message}`);
288
+ }
289
+ }
290
+
291
+ $('launch').addEventListener('submit', async (e) => {
292
+ e.preventDefault();
293
+ if (DEMO) return;
294
+ const v = sessSel.value;
295
+ const cwd = cwdIn.value.trim();
296
+ if (running && !confirm('지금 조회를 끝내고 새로 시작할까요?')) return;
297
+ try { localStorage.setItem('eojeon.cwd', cwd); } catch {}
298
+ await offerYukjo(cwd);
299
+ let mode = 'new';
300
+ let sessionId = '';
301
+ if (v === 'continue') mode = 'continue';
302
+ else if (v.startsWith('resume:')) {
303
+ mode = 'resume';
304
+ sessionId = v.slice(7);
305
+ }
306
+ term.reset();
307
+ court.reset();
308
+ doFit();
309
+ send({ t: 'start', cwd, mode, sessionId, permissionMode: pmodeSel.value });
310
+ term.focus();
311
+ });
312
+ $('stopBtn').addEventListener('click', () => {
313
+ if (!DEMO && running && confirm('claude 를 끝낼까요? (진행 중인 일은 멈춥니다)')) send({ t: 'stop' });
314
+ });
315
+
316
+ // ---------- decree input (sent straight into the PTY) ----------
317
+ const decreeIn = $('decreeInput');
318
+ $('decreeForm').addEventListener('submit', (e) => {
319
+ e.preventDefault();
320
+ const text = decreeIn.value;
321
+ if (!text.trim()) return;
322
+ if (!running && !DEMO) {
323
+ setStatus('error', '먼저 입궐(시작)하세요');
324
+ return;
325
+ }
326
+ // text first, Enter a beat later so Claude Code doesn't treat it as a pasted newline
327
+ sendInput(text);
328
+ setTimeout(() => sendInput('\r'), 60);
329
+ decreeIn.value = '';
330
+ term.focus();
331
+ });
332
+ decreeIn.addEventListener('keydown', (e) => {
333
+ if (e.key === 'Enter' && e.isComposing) e.preventDefault(); // Korean IME: finish composition first
334
+ });
335
+
336
+ // ---------- connection ----------
337
+ function connect() {
338
+ ws = new WebSocket(`ws://${location.host}/`);
339
+ ws.onopen = () => doFit();
340
+ ws.onmessage = (m) => {
341
+ const msg = JSON.parse(m.data);
342
+ switch (msg.t) {
343
+ case 'hello':
344
+ running = msg.running;
345
+ setStatus(running ? 'running' : 'idle', running ? `진행 중 · ${msg.info?.folder || ''}` : '대기');
346
+ hint.classList.toggle('hidden', running);
347
+ logEl.innerHTML = '';
348
+ for (const ev of msg.history || []) onEvent(ev, { replay: true });
349
+ break;
350
+ case 'out':
351
+ hint.classList.add('hidden');
352
+ term.write(msg.d);
353
+ break;
354
+ case 'started':
355
+ running = true;
356
+ hint.classList.add('hidden');
357
+ setStatus('running', `진행 중 · ${msg.info.folder}`);
358
+ break;
359
+ case 'exit':
360
+ running = false;
361
+ setStatus('idle', `끝남 (code ${msg.code})`);
362
+ term.write('\r\n\x1b[2m[claude 가 끝났습니다. 위에서 다시 입궐할 수 있습니다]\x1b[0m\r\n');
363
+ break;
364
+ case 'event':
365
+ onEvent(msg.ev);
366
+ break;
367
+ case 'error':
368
+ setStatus('error', msg.message);
369
+ term.write(`\r\n\x1b[31m${msg.message}\x1b[0m\r\n`);
370
+ break;
371
+ default:
372
+ break;
373
+ }
374
+ };
375
+ ws.onclose = () => {
376
+ setStatus('error', '서버 연결 끊김 — 다시 연결 중');
377
+ setTimeout(connect, 1500);
378
+ };
379
+ }
380
+
381
+ if (DEMO) {
382
+ setStatus('demo', '데모 재생 중 (claude 없음)');
383
+ hint.classList.add('hidden');
384
+ $('startBtn').disabled = true;
385
+ $('stopBtn').disabled = true;
386
+ runDemo({
387
+ term,
388
+ onEvent,
389
+ court,
390
+ speed: Number(params.get('speed')) || 1,
391
+ stopAt: params.has('stop') ? Number(params.get('stop')) : null,
392
+ loop: !params.has('stop'),
393
+ });
394
+ } else {
395
+ if (!info.claudeFound) setStatus('error', 'claude 를 찾지 못했습니다 — Claude Code 를 설치하세요');
396
+ connect();
397
+ }
@@ -0,0 +1,33 @@
1
+ // Pure helpers (no DOM) shared by the browser and the tests:
2
+ // map a Claude Code agent_type to an entry in court.config.json.
3
+
4
+ // Plugin agents arrive namespaced, e.g. "eojeon:ijo" or "my-plugin:review:security".
5
+ // Exact keys win; otherwise the part after the last colon is tried.
6
+ export function resolveAgentType(type, cfg) {
7
+ const t = typeof type === 'string' ? type : '';
8
+ const team = (cfg && cfg.officials) || {};
9
+ const guests = (cfg && cfg.guests) || {};
10
+ const has = (k) => k && !k.startsWith('_') && (Object.hasOwn(team, k) || Object.hasOwn(guests, k));
11
+ if (has(t)) return t;
12
+ if (t.includes(':')) {
13
+ const tail = t.slice(t.lastIndexOf(':') + 1);
14
+ if (has(tail)) return tail;
15
+ }
16
+ return t;
17
+ }
18
+
19
+ // -> { key, def, kind: 'main' | 'seat' | 'guest' | 'unknown' }
20
+ export function lookupOfficial(type, cfg) {
21
+ const team = (cfg && cfg.officials) || {};
22
+ const guests = (cfg && cfg.guests) || {};
23
+ if (!type) return { key: 'main', def: team.main, kind: 'main' };
24
+ const key = resolveAgentType(type, cfg);
25
+ if (key !== 'main' && Object.hasOwn(team, key) && !key.startsWith('_')) return { key, def: team[key], kind: 'seat' };
26
+ if (Object.hasOwn(guests, key) && key !== '*') return { key, def: guests[key], kind: 'guest' };
27
+ const any = guests['*'] || { title: '관리', role: '맡은 일을 한다', robe: 'nok' };
28
+ return { key, def: { ...any, title: `${any.title} · ${type}` }, kind: 'unknown' };
29
+ }
30
+
31
+ export function titleOf(type, cfg) {
32
+ return lookupOfficial(type, cfg).def?.title || String(type || '');
33
+ }
package/public/demo.js ADDED
@@ -0,0 +1,100 @@
1
+ // Demo mode: replays a scripted session (fake hook events + a fake terminal
2
+ // transcript) without running claude. Used for screenshots and promo videos.
3
+ // URL params: ?demo=1 [&speed=2] [&stop=<seconds> → play up to that moment, then hold]
4
+
5
+ const ESC = '\x1b[';
6
+ const dim = (s) => `${ESC}2m${s}${ESC}0m`;
7
+ const bold = (s) => `${ESC}1m${s}${ESC}0m`;
8
+ const orange = (s) => `${ESC}38;5;209m${s}${ESC}0m`;
9
+ const green = (s) => `${ESC}32m${s}${ESC}0m`;
10
+ const red = (s) => `${ESC}31m${s}${ESC}0m`;
11
+ const nl = '\r\n';
12
+
13
+ const SID = 'demo-0000-4a1c-9d2e-000000000001';
14
+ const base = { session: SID, folder: 'my-app' };
15
+
16
+ const BANNER =
17
+ dim('[데모 모드 — 실제 claude 가 아니라 녹화용 재생 화면입니다]') + nl + nl +
18
+ orange(' ▐▛███▜▌') + ' ' + bold('Claude Code') + nl +
19
+ orange('▝▜█████▛▘') + ' ' + dim('~/projects/my-app') + nl +
20
+ orange(' ▘▘ ▝▝') + nl + nl;
21
+
22
+ const PROMPT_LINE = (s) => `${dim('>')} ${s}${nl}${nl}`;
23
+
24
+ // [time (s), action]
25
+ export const SCRIPT = [
26
+ [0.0, { out: BANNER }],
27
+ [0.2, { ev: { type: 'SessionStart', source: 'startup', model: 'claude-opus-5-5' } }],
28
+ [2.0, { out: PROMPT_LINE('로그인 화면에 비밀번호 보기 버튼 넣고, 문구 다듬고, 테스트 돌려 줘') }],
29
+ [2.1, { ev: { type: 'UserPromptSubmit', prompt: '로그인 화면에 비밀번호 보기 버튼 넣고, 문구 다듬고, 테스트 돌려 줘', slash: false } }],
30
+ [4.6, { out: `${orange('⏺')} ${bold('Read')}(src/screens/Login.jsx)${nl}` }],
31
+ [4.6, { ev: { type: 'PreToolUse', tool: 'Read', summary: 'src/screens/Login.jsx', toolUseId: 't1' } }],
32
+ [5.6, { ev: { type: 'PostToolUse', tool: 'Read', summary: 'src/screens/Login.jsx', toolUseId: 't1', ms: 14 } }],
33
+ [5.7, { out: ` ${dim('⎿ Read 142 lines')}${nl}${nl}` }],
34
+ [6.2, { out: `${orange('⏺')} ${bold('Task')}(gongjo · 비밀번호 보기 버튼)${nl}` }],
35
+ [6.2, { ev: { type: 'PreToolUse', tool: 'Task', summary: 'gongjo · 비밀번호 보기 버튼', spawnType: 'gongjo', toolUseId: 't2' } }],
36
+ [6.5, { ev: { type: 'SubagentStart', agentId: 'a1', agentType: 'gongjo' } }],
37
+ [7.0, { out: `${orange('⏺')} ${bold('Task')}(hyeongjo · 로그인 화면 리뷰)${nl}` }],
38
+ [7.0, { ev: { type: 'PreToolUse', tool: 'Task', summary: 'hyeongjo · 로그인 화면 리뷰', spawnType: 'hyeongjo', toolUseId: 't3' } }],
39
+ [7.3, { ev: { type: 'SubagentStart', agentId: 'a2', agentType: 'hyeongjo' } }],
40
+ [12.0, { ev: { type: 'PreToolUse', tool: 'Edit', summary: 'src/screens/Login.jsx', toolUseId: 't4', agentId: 'a1', agentType: 'gongjo' } }],
41
+ [12.0, { out: ` ${dim('⎿')} ${bold('Edit')}(src/screens/Login.jsx)${nl}` }],
42
+ [13.0, { ev: { type: 'PreToolUse', tool: 'Grep', summary: '"로그인" · src', toolUseId: 't5', agentId: 'a2', agentType: 'hyeongjo' } }],
43
+ [13.0, { out: ` ${dim('⎿')} ${bold('Grep')}("로그인", src)${nl}` }],
44
+ [8.0, { out: `${orange('⏺')} ${bold('Task')}(eojeon:yejo · 변경 기록)${nl}` }],
45
+ [8.0, { ev: { type: 'PreToolUse', tool: 'Task', summary: 'eojeon:yejo · 변경 기록', spawnType: 'eojeon:yejo', toolUseId: 't9' } }],
46
+ [8.3, { ev: { type: 'SubagentStart', agentId: 'a3', agentType: 'eojeon:yejo' } }],
47
+ [14.2, { ev: { type: 'PreToolUse', tool: 'Edit', summary: 'CHANGELOG.md', toolUseId: 't10', agentId: 'a3', agentType: 'eojeon:yejo' } }],
48
+ [13.6, { ev: { type: 'PreToolUse', tool: 'Bash', summary: 'npx eslint src/screens', toolUseId: 't6' } }],
49
+ [16.5, { ev: { type: 'PostToolUseFailure', tool: 'Bash', summary: 'npx eslint src/screens', toolUseId: 't6' } }],
50
+ [16.5, { out: `${orange('⏺')} ${bold('Bash')}(npx eslint src/screens)${nl} ${red('⎿ Error: 1 problem (1 error)')}${nl}` }],
51
+ [17.0, { ev: { type: 'PostToolUse', tool: 'Grep', summary: '"로그인" · src', toolUseId: 't5', agentId: 'a2', agentType: 'hyeongjo', ms: 40 } }],
52
+ [17.5, { ev: { type: 'PostToolUse', tool: 'Edit', summary: 'src/screens/Login.jsx', toolUseId: 't4', agentId: 'a1', agentType: 'gongjo', ms: 22 } }],
53
+ [18.5, { ev: { type: 'SubagentStop', agentId: 'a1', agentType: 'gongjo', report: '보기 버튼 넣고 접근성 라벨 달았습니다' } }],
54
+ [18.6, { out: ` ${green('⎿ Done')} ${dim('(gongjo · 3 tool uses)')}${nl}` }],
55
+ [18.0, { ev: { type: 'PostToolUse', tool: 'Edit', summary: 'CHANGELOG.md', toolUseId: 't10', agentId: 'a3', agentType: 'eojeon:yejo', ms: 18 } }],
56
+ [20.2, { ev: { type: 'SubagentStop', agentId: 'a3', agentType: 'eojeon:yejo', report: 'CHANGELOG 에 한 줄 적었습니다' } }],
57
+ [19.2, { ev: { type: 'SubagentStop', agentId: 'a2', agentType: 'hyeongjo', report: '막을 문제 없음 · 사소한 것 1건' } }],
58
+ [19.3, { out: ` ${green('⎿ Done')} ${dim('(hyeongjo · 2 tool uses)')}${nl}${nl}` }],
59
+ [26.0, { ev: { type: 'PreToolUse', tool: 'Bash', summary: 'npm test', toolUseId: 't7' } }],
60
+ [26.1, { ev: { type: 'PermissionRequest', tool: 'Bash', summary: 'npm test' } }],
61
+ [26.1, {
62
+ out:
63
+ `${orange('⏺')} ${bold('Bash')}(npm test)${nl}${nl}` +
64
+ ` ${bold('Bash command')}${nl}${nl} npm test${nl} ${dim('Run the test suite')}${nl}${nl}` +
65
+ ` Do you want to proceed?${nl} ${orange('❯')} 1. Yes${nl} 2. Yes, and don't ask again for ${bold('npm test')} commands${nl} 3. No, and tell Claude what to do differently ${dim('(esc)')}${nl}${nl}`,
66
+ }],
67
+ [26.8, { ev: { type: 'Notification', kind: 'permission_prompt', message: 'Claude needs your permission to use Bash' } }],
68
+ [32.0, { press: 'ok' }],
69
+ [32.4, { out: ` ${dim('⎿')} ${green('Tests: 24 passed, 24 total')}${nl}${nl}` }],
70
+ [33.0, { ev: { type: 'PostToolUse', tool: 'Bash', summary: 'npm test', toolUseId: 't7', ms: 2140 } }],
71
+ [33.6, { ev: { type: 'PreToolUse', tool: 'Bash', summary: 'git diff --stat', toolUseId: 't8' } }],
72
+ [34.6, { ev: { type: 'PostToolUse', tool: 'Bash', summary: 'git diff --stat', toolUseId: 't8', ms: 30 } }],
73
+ [34.6, { out: `${orange('⏺')} ${bold('Bash')}(git diff --stat)${nl} ${dim('⎿ 2 files changed, 38 insertions(+), 6 deletions(-)')}${nl}${nl}` }],
74
+ [35.5, { out: `${orange('⏺')} 비밀번호 보기 버튼을 넣고 문구를 다듬었습니다. 테스트 24개 모두 통과했습니다.${nl}${nl}` }],
75
+ [35.6, { ev: { type: 'Stop' } }],
76
+ [42.0, { ev: { type: 'Notification', kind: 'idle_prompt', message: 'Claude is waiting for your input' } }],
77
+ [48.0, { end: true }],
78
+ ];
79
+
80
+ export function runDemo({ term, onEvent, court, speed = 1, stopAt = null, loop = true }) {
81
+ let timers = [];
82
+ const play = () => {
83
+ timers.forEach(clearTimeout);
84
+ timers = [];
85
+ term.reset();
86
+ court.reset();
87
+ for (const [t, a] of SCRIPT) {
88
+ if (stopAt != null && t > stopAt) continue;
89
+ timers.push(
90
+ setTimeout(() => {
91
+ if (a.out) term.write(a.out);
92
+ if (a.ev) onEvent({ ...base, ...a.ev, time: Date.now() });
93
+ if (a.press) court.petitionAction(a.press);
94
+ if (a.end && loop) play();
95
+ }, (t * 1000) / speed),
96
+ );
97
+ }
98
+ };
99
+ play();
100
+ }
Binary file
Binary file
Binary file
@@ -0,0 +1,97 @@
1
+ Copyright (c) 2015, NAVER Corporation (https://www.navercorp.com),
2
+ with Reserved Font Name D2Coding.
3
+
4
+ Copyright (c) 2015, NAVER Corporation (https://www.navercorp.com),
5
+ with Reserved Font Name D2Coding-Bold.
6
+
7
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
8
+ This license is copied below, and is also available with a FAQ at:
9
+ https://openfontlicense.org
10
+
11
+
12
+ -----------------------------------------------------------
13
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
14
+ -----------------------------------------------------------
15
+
16
+ PREAMBLE
17
+ The goals of the Open Font License (OFL) are to stimulate worldwide
18
+ development of collaborative font projects, to support the font creation
19
+ efforts of academic and linguistic communities, and to provide a free and
20
+ open framework in which fonts may be shared and improved in partnership
21
+ with others.
22
+
23
+ The OFL allows the licensed fonts to be used, studied, modified and
24
+ redistributed freely as long as they are not sold by themselves. The
25
+ fonts, including any derivative works, can be bundled, embedded,
26
+ redistributed and/or sold with any software provided that any reserved
27
+ names are not used by derivative works. The fonts and derivatives,
28
+ however, cannot be released under any other type of license. The
29
+ requirement for fonts to remain under this license does not apply
30
+ to any document created using the fonts or their derivatives.
31
+
32
+ DEFINITIONS
33
+ "Font Software" refers to the set of files released by the Copyright
34
+ Holder(s) under this license and clearly marked as such. This may
35
+ include source files, build scripts and documentation.
36
+
37
+ "Reserved Font Name" refers to any names specified as such after the
38
+ copyright statement(s).
39
+
40
+ "Original Version" refers to the collection of Font Software components as
41
+ distributed by the Copyright Holder(s).
42
+
43
+ "Modified Version" refers to any derivative made by adding to, deleting,
44
+ or substituting -- in part or in whole -- any of the components of the
45
+ Original Version, by changing formats or by porting the Font Software to a
46
+ new environment.
47
+
48
+ "Author" refers to any designer, engineer, programmer, technical
49
+ writer or other person who contributed to the Font Software.
50
+
51
+ PERMISSION & CONDITIONS
52
+ Permission is hereby granted, free of charge, to any person obtaining
53
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
54
+ redistribute, and sell modified and unmodified copies of the Font
55
+ Software, subject to the following conditions:
56
+
57
+ 1) Neither the Font Software nor any of its individual components,
58
+ in Original or Modified Versions, may be sold by itself.
59
+
60
+ 2) Original or Modified Versions of the Font Software may be bundled,
61
+ redistributed and/or sold with any software, provided that each copy
62
+ contains the above copyright notice and this license. These can be
63
+ included either as stand-alone text files, human-readable headers or
64
+ in the appropriate machine-readable metadata fields within text or
65
+ binary files as long as those fields can be easily viewed by the user.
66
+
67
+ 3) No Modified Version of the Font Software may use the Reserved Font
68
+ Name(s) unless explicit written permission is granted by the corresponding
69
+ Copyright Holder. This restriction only applies to the primary font name as
70
+ presented to the users.
71
+
72
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
73
+ Software shall not be used to promote, endorse or advertise any
74
+ Modified Version, except to acknowledge the contribution(s) of the
75
+ Copyright Holder(s) and the Author(s) or with their explicit written
76
+ permission.
77
+
78
+ 5) The Font Software, modified or unmodified, in part or in whole,
79
+ must be distributed entirely under this license, and must not be
80
+ distributed under any other license. The requirement for fonts to
81
+ remain under this license does not apply to any document created
82
+ using the Font Software.
83
+
84
+ TERMINATION
85
+ This license becomes null and void if any of the above conditions are
86
+ not met.
87
+
88
+ DISCLAIMER
89
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
90
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
91
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
92
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
93
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
94
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
95
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
96
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
97
+ OTHER DEALINGS IN THE FONT SOFTWARE.