claude-code-watcher 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env node
2
+ // session-tracker 훅 상태 전환 테스트
3
+
4
+ import { spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const HOOK = path.join(__dirname, 'session-tracker.mjs');
12
+ const TEST_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'tracker-test-'));
13
+
14
+ process.on('exit', () => { try { fs.rmSync(TEST_DIR, { recursive: true }); } catch {} });
15
+
16
+ let PASS = 0;
17
+ let FAIL = 0;
18
+
19
+ // ── 헬퍼 ──────────────────────────────────────────────────────────────
20
+
21
+ function fireEvent(sid, event, extras = {}) {
22
+ const payload = JSON.stringify({
23
+ session_id: sid,
24
+ hook_event_name: event,
25
+ cwd: '/test/proj',
26
+ transcript_path: '',
27
+ ...extras,
28
+ });
29
+ spawnSync('node', [HOOK], {
30
+ input: payload,
31
+ env: { ...process.env, ACTIVE_DIR: TEST_DIR },
32
+ encoding: 'utf8',
33
+ });
34
+ }
35
+
36
+ function sessionFile(sid) {
37
+ return path.join(TEST_DIR, `${sid}.json`);
38
+ }
39
+
40
+ function readJson(file) {
41
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
42
+ }
43
+
44
+ function field(file, key) {
45
+ const v = readJson(file)[key];
46
+ return v == null ? '' : String(v);
47
+ }
48
+
49
+ function subfield(file, idx, key) {
50
+ const subs = readJson(file).subagents ?? [];
51
+ const v = idx < subs.length ? subs[idx][key] : undefined;
52
+ return v == null ? '' : String(v);
53
+ }
54
+
55
+ function subsCount(file) {
56
+ return (readJson(file).subagents ?? []).length;
57
+ }
58
+
59
+ function patchJson(file, patch) {
60
+ const data = readJson(file);
61
+ Object.assign(data, patch);
62
+ const tmp = `${file}.tmp`;
63
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
64
+ fs.renameSync(tmp, file);
65
+ }
66
+
67
+ // ── assert 헬퍼 ───────────────────────────────────────────────────────
68
+
69
+ function assertEq(desc, expected, actual) {
70
+ if (expected === actual) {
71
+ console.log(` ✓ ${desc}`);
72
+ PASS++;
73
+ } else {
74
+ console.log(` ✗ ${desc}`);
75
+ console.log(` expected: ${expected}`);
76
+ console.log(` actual: ${actual}`);
77
+ FAIL++;
78
+ }
79
+ }
80
+
81
+ function assertField(desc, expected, file, key) {
82
+ assertEq(desc, expected, field(file, key));
83
+ }
84
+
85
+ function assertSubfield(desc, expected, file, idx, key) {
86
+ assertEq(desc, expected, subfield(file, idx, key));
87
+ }
88
+
89
+ function assertEmpty(desc, file, key) {
90
+ assertField(desc, '', file, key);
91
+ }
92
+
93
+ function assertNonempty(desc, file, key) {
94
+ const val = field(file, key);
95
+ if (val) {
96
+ console.log(` ✓ ${desc} (${val})`);
97
+ PASS++;
98
+ } else {
99
+ console.log(` ✗ ${desc} (비어있음)`);
100
+ FAIL++;
101
+ }
102
+ }
103
+
104
+ function assertExists(desc, file) {
105
+ if (fs.existsSync(file)) {
106
+ console.log(` ✓ ${desc}`);
107
+ PASS++;
108
+ } else {
109
+ console.log(` ✗ ${desc} (file not found: ${file})`);
110
+ FAIL++;
111
+ }
112
+ }
113
+
114
+ function assertNotExists(desc, file) {
115
+ if (!fs.existsSync(file)) {
116
+ console.log(` ✓ ${desc}`);
117
+ PASS++;
118
+ } else {
119
+ console.log(` ✗ ${desc} (file still exists: ${file})`);
120
+ FAIL++;
121
+ }
122
+ }
123
+
124
+ // ── 테스트 케이스 ─────────────────────────────────────────────────────
125
+
126
+ console.log('\n▶ 1. SessionStart → waiting');
127
+ {
128
+ const sid = 's1'; const sf = sessionFile(sid);
129
+ fireEvent(sid, 'SessionStart');
130
+ assertExists ('파일 생성됨', sf);
131
+ assertField ('status=waiting', 'waiting', sf, 'status');
132
+ assertField ('message=Session started', 'Session started', sf, 'message');
133
+ assertEmpty ('alertAt 비어있음', sf, 'alertAt');
134
+ }
135
+
136
+ console.log('\n▶ 2. UserPromptSubmit → working');
137
+ {
138
+ const sid = 's2'; const sf = sessionFile(sid);
139
+ fireEvent(sid, 'SessionStart');
140
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '파일 목록 보여줘' });
141
+ assertField ('status=working', 'working', sf, 'status');
142
+ assertField ('message=프롬프트 저장됨', '파일 목록 보여줘', sf, 'message');
143
+ assertEmpty ('alertAt 비어있음', sf, 'alertAt');
144
+ }
145
+
146
+ console.log('\n▶ 3. Stop → waiting + alertAt 설정');
147
+ {
148
+ const sid = 's3'; const sf = sessionFile(sid);
149
+ fireEvent(sid, 'SessionStart');
150
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '안녕' });
151
+ fireEvent(sid, 'Stop', { last_assistant_message: '안녕하세요!' });
152
+ assertField ('status=waiting', 'waiting', sf, 'status');
153
+ assertField ('alertEvent=Stop', 'Stop', sf, 'alertEvent');
154
+ assertField ('lastResponse 저장됨', '안녕하세요!', sf, 'lastResponse');
155
+ assertField ('message=Claude 응답으로 설정', '안녕하세요!', sf, 'message');
156
+ assertNonempty('alertAt 설정됨', sf, 'alertAt');
157
+ }
158
+
159
+ console.log('\n▶ 4. Notification (working 중) → notification + alertAt 설정');
160
+ {
161
+ const sid = 's4'; const sf = sessionFile(sid);
162
+ fireEvent(sid, 'SessionStart');
163
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '파일 삭제해줘' });
164
+ fireEvent(sid, 'Notification', { message: 'Claude needs your permission to use Bash' });
165
+ assertField ('status=notification', 'notification', sf, 'status');
166
+ assertField ('alertEvent=Notification', 'Notification', sf, 'alertEvent');
167
+ }
168
+
169
+ console.log('\n▶ 5. Notification (waiting 중) → waiting 유지 (백그라운드 핑)');
170
+ {
171
+ const sid = 's5'; const sf = sessionFile(sid);
172
+ fireEvent(sid, 'SessionStart');
173
+ fireEvent(sid, 'Stop', { last_assistant_message: '완료' });
174
+ fireEvent(sid, 'Notification', { message: 'background ping' });
175
+ assertField ('status=waiting 유지', 'waiting', sf, 'status');
176
+ assertField ('alertEvent=Notification', 'Notification', sf, 'alertEvent');
177
+ assertField ('message 덮어쓰지 않음', '완료', sf, 'message');
178
+ }
179
+
180
+ console.log('\n▶ 6. SubagentStart/Stop → working 유지 (alertAt 변화 없음)');
181
+ {
182
+ const sid = 's6'; const sf = sessionFile(sid);
183
+ fireEvent(sid, 'SessionStart');
184
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '분석해줘' });
185
+ fireEvent(sid, 'SubagentStart');
186
+ assertField ('SubagentStart → working', 'working', sf, 'status');
187
+ fireEvent(sid, 'SubagentStop');
188
+ assertField ('SubagentStop → working 유지', 'working', sf, 'status');
189
+ assertEmpty ('alertAt 비어있음 (알람 없음)', sf, 'alertAt');
190
+ }
191
+
192
+ console.log('\n▶ 7. notification → UserPromptSubmit → working');
193
+ {
194
+ const sid = 's7'; const sf = sessionFile(sid);
195
+ fireEvent(sid, 'SessionStart');
196
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '뭔가 해줘' });
197
+ fireEvent(sid, 'Notification', { message: '권한 요청' });
198
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '허락함' });
199
+ assertField ('status=working', 'working', sf, 'status');
200
+ }
201
+
202
+ console.log('\n▶ 7b. notification → PreToolUse (권한 승인) → working');
203
+ {
204
+ const sid = 's7b'; const sf = sessionFile(sid);
205
+ fireEvent(sid, 'SessionStart');
206
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '파일 삭제해줘' });
207
+ fireEvent(sid, 'Notification', { message: 'Bash 실행 권한 요청' });
208
+ assertField ('Notification → notification', 'notification', sf, 'status');
209
+ fireEvent(sid, 'PreToolUse');
210
+ assertField ('PreToolUse → working 복원', 'working', sf, 'status');
211
+ }
212
+
213
+ console.log('\n▶ 7c. notification → PostToolUse (실제 실행 순서) → working');
214
+ {
215
+ const sid = 's7c'; const sf = sessionFile(sid);
216
+ fireEvent(sid, 'SessionStart');
217
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '파일 삭제해줘' });
218
+ fireEvent(sid, 'PreToolUse');
219
+ fireEvent(sid, 'Notification', { message: 'Bash 실행 권한 요청' });
220
+ assertField ('Notification → notification', 'notification', sf, 'status');
221
+ fireEvent(sid, 'PostToolUse');
222
+ assertField ('PostToolUse → working 복원', 'working', sf, 'status');
223
+ }
224
+
225
+ console.log('\n▶ 8. SessionEnd → 파일 삭제');
226
+ {
227
+ const sid = 's8'; const sf = sessionFile(sid);
228
+ fireEvent(sid, 'SessionStart');
229
+ assertExists ('SessionStart 후 파일 존재', sf);
230
+ fireEvent(sid, 'SessionEnd');
231
+ assertNotExists ('SessionEnd 후 파일 삭제됨', sf);
232
+ }
233
+
234
+ console.log('\n▶ 9. session_id 없음 → 아무것도 안 함');
235
+ {
236
+ const countBefore = fs.readdirSync(TEST_DIR).length;
237
+ spawnSync('node', [HOOK], {
238
+ input: JSON.stringify({ hook_event_name: 'Stop', cwd: '/test/proj' }),
239
+ env: { ...process.env, ACTIVE_DIR: TEST_DIR },
240
+ encoding: 'utf8',
241
+ });
242
+ const countAfter = fs.readdirSync(TEST_DIR).length;
243
+ assertEq('파일 수 변화 없음', String(countBefore), String(countAfter));
244
+ }
245
+
246
+ console.log('\n▶ 10. startedAt 보존 (재기동 시)');
247
+ {
248
+ const sid = 's10'; const sf = sessionFile(sid);
249
+ fireEvent(sid, 'SessionStart');
250
+ const started = field(sf, 'startedAt');
251
+ fireEvent(sid, 'Stop', { last_assistant_message: '완료' });
252
+ assertField('startedAt 유지됨', started, sf, 'startedAt');
253
+ }
254
+
255
+ console.log('\n▶ 11. Stop 후 UserPromptSubmit → alertAt 미변경 (spurious alert 방지)');
256
+ {
257
+ const sid = 's11'; const sf = sessionFile(sid);
258
+ fireEvent(sid, 'SessionStart');
259
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '첫 번째 요청' });
260
+ fireEvent(sid, 'Stop', { last_assistant_message: '완료' });
261
+ const alertAtAfterStop = field(sf, 'alertAt');
262
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '두 번째 요청' });
263
+ assertField('status=working', 'working', sf, 'status');
264
+ assertField('alertAt 변경 없음 (Stop 시점 유지)', alertAtAfterStop, sf, 'alertAt');
265
+ }
266
+
267
+ console.log('\n▶ 12. Notification (notification 상태) → waiting 유지 (이중 권한 요청 시나리오)');
268
+ {
269
+ const sid = 's12'; const sf = sessionFile(sid);
270
+ fireEvent(sid, 'SessionStart');
271
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '파일 삭제해줘' });
272
+ fireEvent(sid, 'Notification', { message: '첫 번째 권한 요청' });
273
+ assertField('첫 Notification → notification', 'notification', sf, 'status');
274
+ fireEvent(sid, 'Stop', { last_assistant_message: '삭제 완료' });
275
+ fireEvent(sid, 'Notification', { message: '백그라운드 완료 알림' });
276
+ assertField('Stop 후 Notification → waiting', 'waiting', sf, 'status');
277
+ assertField('alertEvent=Notification', 'Notification', sf, 'alertEvent');
278
+ }
279
+
280
+ console.log('\n▶ 13. SubagentStart/Stop → 실행 중 배열에 추가, 종료 시 제거');
281
+ {
282
+ const sid = 's13'; const sf = sessionFile(sid);
283
+ fireEvent(sid, 'SessionStart');
284
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '분석해줘' });
285
+ fireEvent(sid, 'SubagentStart', { agent_id: 'agent-001', agent_type: 'general-purpose' });
286
+ assertSubfield('SubagentStart → status=working', 'working', sf, 0, 'status');
287
+ assertSubfield('agentType 기록됨', 'general-purpose', sf, 0, 'agentType');
288
+ fireEvent(sid, 'SubagentStop', { agent_id: 'agent-001' });
289
+ assertEq ('SubagentStop → 목록에서 제거됨', '0', String(subsCount(sf)));
290
+ }
291
+
292
+ console.log('\n▶ 14. subagents 보존 (이벤트 반복 시 기존 목록 유지)');
293
+ {
294
+ const sid = 's14'; const sf = sessionFile(sid);
295
+ fireEvent(sid, 'SessionStart');
296
+ patchJson(sf, { subagents: ['fake-child-1', 'fake-child-2'] });
297
+ fireEvent(sid, 'Stop', { last_assistant_message: '완료' });
298
+ assertEq('Stop 후 subagents 보존됨', '2', String(subsCount(sf)));
299
+ }
300
+
301
+ console.log('\n▶ 15. cwd / transcript / project 보존 (cwd 없는 이벤트)');
302
+ {
303
+ const sid = 's15'; const sf = sessionFile(sid);
304
+ fireEvent(sid, 'SessionStart');
305
+ patchJson(sf, { cwd: '/my/real/myapp', transcript: '/tmp/transcript.jsonl' });
306
+ // cwd 없는 이벤트 (SubagentStart payload에 cwd 없음)
307
+ spawnSync('node', [HOOK], {
308
+ input: JSON.stringify({ session_id: sid, hook_event_name: 'SubagentStart', agent_id: 'a1', agent_type: 'general-purpose' }),
309
+ env: { ...process.env, ACTIVE_DIR: TEST_DIR },
310
+ encoding: 'utf8',
311
+ });
312
+ assertField('cwd 보존됨', '/my/real/myapp', sf, 'cwd');
313
+ assertField('project 보존됨', 'myapp', sf, 'project');
314
+ assertField('transcript 보존됨', '/tmp/transcript.jsonl', sf, 'transcript');
315
+ }
316
+
317
+ console.log('\n▶ 16. 복수 subagent — 개별 종료 시 해당 항목만 제거');
318
+ {
319
+ const sid = 's16'; const sf = sessionFile(sid);
320
+ fireEvent(sid, 'SessionStart');
321
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '두 에이전트 실행' });
322
+ fireEvent(sid, 'SubagentStart', { agent_id: 'a1', agent_type: 'Explore' });
323
+ fireEvent(sid, 'SubagentStart', { agent_id: 'a2', agent_type: 'Plan' });
324
+ assertEq('두 SubagentStart → 2개', '2', String(subsCount(sf)));
325
+ fireEvent(sid, 'SubagentStop', { agent_id: 'a1' });
326
+ assertEq('a1 종료 후 1개 남음', '1', String(subsCount(sf)));
327
+ assertEq('남은 항목은 a2(Plan)', 'Plan', subfield(sf, 0, 'agentType'));
328
+ fireEvent(sid, 'SubagentStop', { agent_id: 'a2' });
329
+ assertEq('a2 종료 후 0개', '0', String(subsCount(sf)));
330
+ }
331
+
332
+ console.log('\n▶ 17. 중간 이벤트에도 subagents 보존');
333
+ {
334
+ const sid = 's17'; const sf = sessionFile(sid);
335
+ fireEvent(sid, 'SessionStart');
336
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '분석해줘' });
337
+ fireEvent(sid, 'SubagentStart', { agent_id: 'b1', agent_type: 'Explore' });
338
+ fireEvent(sid, 'Notification', { message: '권한 요청' });
339
+ fireEvent(sid, 'PreToolUse');
340
+ fireEvent(sid, 'Notification', { message: '다른 권한 요청' });
341
+ assertEq('Notification/PreToolUse 후 subagents 보존', '1', String(subsCount(sf)));
342
+ fireEvent(sid, 'SubagentStop', { agent_id: 'b1' });
343
+ fireEvent(sid, 'Stop', { last_assistant_message: '완료' });
344
+ assertEq ('Stop 후 subagents 비어있음', '0', String(subsCount(sf)));
345
+ assertField('최종 status=waiting', 'waiting', sf, 'status');
346
+ }
347
+
348
+ console.log('\n▶ 18. notification → working 전체 flow');
349
+ {
350
+ const sid = 's18'; const sf = sessionFile(sid);
351
+ fireEvent(sid, 'SessionStart');
352
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '파일 삭제해줘' });
353
+ assertField ('UserPromptSubmit → working', 'working', sf, 'status');
354
+ fireEvent(sid, 'Notification', { message: 'Bash 실행 권한 요청' });
355
+ assertField ('Notification → notification', 'notification', sf, 'status');
356
+ fireEvent(sid, 'PreToolUse');
357
+ assertField ('PreToolUse → working 복원', 'working', sf, 'status');
358
+ fireEvent(sid, 'Stop', { last_assistant_message: '삭제 완료' });
359
+ assertField ('Stop → waiting', 'waiting', sf, 'status');
360
+ assertField ('alertEvent=Stop', 'Stop', sf, 'alertEvent');
361
+ assertNonempty('alertAt 설정됨', sf, 'alertAt');
362
+ }
363
+
364
+ console.log('\n▶ 19. subagent 실행 중 Stop → subagents 보존 (비정상 종료 대비)');
365
+ {
366
+ const sid = 's19'; const sf = sessionFile(sid);
367
+ fireEvent(sid, 'SessionStart');
368
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '분석해줘' });
369
+ fireEvent(sid, 'SubagentStart', { agent_id: 'c1', agent_type: 'general-purpose' });
370
+ fireEvent(sid, 'Stop', { last_assistant_message: '완료' });
371
+ assertEq ('Stop 후 미완료 subagent 보존', '1', String(subsCount(sf)));
372
+ assertField('status=waiting', 'waiting', sf, 'status');
373
+ }
374
+
375
+ console.log('\n▶ 20. PostToolUse (waiting 중) → waiting 유지 (Stop 후 지연 이벤트 대비)');
376
+ {
377
+ const sid = 's20'; const sf = sessionFile(sid);
378
+ fireEvent(sid, 'SessionStart');
379
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '뭔가 해줘' });
380
+ fireEvent(sid, 'Stop', { last_assistant_message: '완료' });
381
+ assertField('Stop → waiting', 'waiting', sf, 'status');
382
+ fireEvent(sid, 'PostToolUse');
383
+ assertField('PostToolUse → waiting 유지', 'waiting', sf, 'status');
384
+ fireEvent(sid, 'PreToolUse');
385
+ assertField('PreToolUse → waiting 유지', 'waiting', sf, 'status');
386
+ }
387
+
388
+ console.log('\n▶ 21. notification → PostToolUse → working + alertAt 보존');
389
+ {
390
+ const sid = 's21'; const sf = sessionFile(sid);
391
+ fireEvent(sid, 'SessionStart');
392
+ fireEvent(sid, 'UserPromptSubmit', { prompt: '파일 삭제해줘' });
393
+ fireEvent(sid, 'Notification', { message: 'Bash 권한 요청' });
394
+ const alertAt = field(sf, 'alertAt');
395
+ assertNonempty('Notification → alertAt 설정됨', sf, 'alertAt');
396
+ fireEvent(sid, 'PostToolUse');
397
+ assertField ('PostToolUse → working 복원', 'working', sf, 'status');
398
+ assertField ('alertAt 보존됨', alertAt, sf, 'alertAt');
399
+ }
400
+
401
+ // ── 결과 ──────────────────────────────────────────────────────────────
402
+
403
+ console.log('\n────────────────────────────────────');
404
+ const total = PASS + FAIL;
405
+ console.log(`결과: ${PASS}/${total} 통과`);
406
+ if (FAIL > 0) {
407
+ console.log(`실패: ${FAIL}건`);
408
+ process.exit(1);
409
+ } else {
410
+ console.log('모두 통과!');
411
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "claude-code-watcher",
3
+ "version": "1.0.1",
4
+ "description": "Claude Code 세션 CLI 대시보드",
5
+ "type": "module",
6
+ "bin": {
7
+ "ccw": "bin/ccw.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "hooks/"
13
+ ],
14
+ "main": "./src/core/store.mjs",
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "keywords": [
19
+ "claude",
20
+ "claude-code",
21
+ "dashboard",
22
+ "cli",
23
+ "terminal"
24
+ ],
25
+ "author": "roy-jung",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://oss.navercorp.com/roy-jung/claude-code-watcher"
29
+ },
30
+ "license": "MIT",
31
+ "scripts": {
32
+ "publish:internal": "npm publish --registry https://artifactory.navercorp.com/artifactory/api/npm/npm-naver/",
33
+ "publish:public": "npm publish --registry https://registry.npmjs.org"
34
+ }
35
+ }
@@ -0,0 +1,39 @@
1
+ import { BOLD, color, FG, RESET } from '../ui/ansi.mjs';
2
+
3
+ const title = `${BOLD}${color(FG.BRIGHT_WHITE, 'ccw')}${RESET}`;
4
+ const dim = s => color(FG.BRIGHT_BLACK, s);
5
+
6
+ console.log(`
7
+ ${title} — VS Code 터미널용 Claude Code 세션 CLI 대시보드
8
+
9
+ ${BOLD}Usage:${RESET}
10
+ ccw 인터랙티브 대시보드 (기본)
11
+ ccw start 인터랙티브 대시보드
12
+ ccw setup 훅 설치 및 디렉토리 생성
13
+ ccw status 일회성 세션 목록 출력
14
+ ccw sessions 세션 목록 JSON 출력
15
+ ccw /sound 알림 사운드 설정 보기/변경
16
+ ccw help 이 도움말 출력
17
+
18
+ ${BOLD}Dashboard Keys:${RESET}
19
+ ${color(FG.CYAN, '↑ ↓')} 세션 선택 (위아래 이동)
20
+ ${color(FG.CYAN, 'enter')} 상세보기 ↔ 목록 전환
21
+ ${color(FG.CYAN, 'r')} 새로고침 (데이터 리로드)
22
+ ${color(FG.CYAN, 'R')} 재시작 (프로세스 전체 재실행)
23
+ ${color(FG.CYAN, 'q')} / ${color(FG.CYAN, 'Ctrl+C')} 종료
24
+
25
+ ${BOLD}Status Colors:${RESET}
26
+ ${color(FG.YELLOW, 'working')} Claude가 응답을 처리 중
27
+ ${color(FG.GREEN, 'waiting')} 사용자 입력 대기 중
28
+ ${color(FG.CYAN, 'notification')} 알림 수신됨
29
+ ${color(FG.BRIGHT_BLACK, 'stale')} 10분 이상 비활성
30
+ ${color(FG.RED, 'error')} 오류 발생
31
+
32
+ ${BOLD}Session Data:${RESET}
33
+ ~/.claude/dashboard/active/<sessionId>.json
34
+
35
+ ${BOLD}Getting Started:${RESET}
36
+ 1. ${dim('ccw setup')} # 훅 설치
37
+ 2. VS Code에서 Claude Code 실행
38
+ 3. ${dim('ccw start')} # 대시보드 실행 (별도 탭)
39
+ `);
@@ -0,0 +1,8 @@
1
+ import { SessionStore } from '../core/store.mjs';
2
+
3
+ const store = new SessionStore();
4
+ store.start();
5
+ store.stop();
6
+
7
+ const sessions = store.getSessions();
8
+ console.log(JSON.stringify(sessions, null, 2));
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import {
5
+ ACTIVE_DIR,
6
+ HOOK_SCRIPT_PATH,
7
+ HOOKS_DIR,
8
+ SETTINGS_FILE,
9
+ } from '../core/paths.mjs';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const SOURCE_HOOK = join(__dirname, '../../hooks/session-tracker.sh');
13
+ const SOURCE_MJS = join(__dirname, '../../hooks/session-tracker.mjs');
14
+
15
+ const HOOK_EVENTS = [
16
+ 'SessionStart',
17
+ 'UserPromptSubmit',
18
+ 'PreToolUse',
19
+ 'PostToolUse',
20
+ 'Stop',
21
+ 'Notification',
22
+ 'SessionEnd',
23
+ 'SubagentStart',
24
+ 'SubagentStop',
25
+ ];
26
+
27
+ // Tool events require ".*" matcher to fire for all tool names.
28
+ // Other lifecycle events ignore the matcher field.
29
+ const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
30
+
31
+ function ensureHook(settings, eventName, command) {
32
+ settings.hooks ??= {};
33
+ settings.hooks[eventName] ??= [];
34
+
35
+ // Prevent duplicate registration
36
+ const alreadyExists = settings.hooks[eventName].some(entry =>
37
+ (entry.hooks || []).some(h => h.command === command),
38
+ );
39
+ if (alreadyExists) return false;
40
+
41
+ const matcher = TOOL_EVENTS.has(eventName) ? '.*' : '';
42
+ const existing = settings.hooks[eventName].find(e => e.matcher === matcher);
43
+ if (existing) {
44
+ existing.hooks.push({ type: 'command', command });
45
+ } else {
46
+ settings.hooks[eventName].push({
47
+ matcher,
48
+ hooks: [{ type: 'command', command }],
49
+ });
50
+ }
51
+ return true;
52
+ }
53
+
54
+ // Create required directories
55
+ fs.mkdirSync(HOOKS_DIR, { recursive: true });
56
+ fs.mkdirSync(ACTIVE_DIR, { recursive: true });
57
+ console.log(`✓ Created directories:`);
58
+ console.log(` ${HOOKS_DIR}`);
59
+ console.log(` ${ACTIVE_DIR}`);
60
+
61
+ // Copy hook scripts (.sh wrapper + .mjs implementation)
62
+ for (const src of [SOURCE_HOOK, SOURCE_MJS]) {
63
+ if (!fs.existsSync(src)) {
64
+ console.error(`✗ Hook script not found at: ${src}`);
65
+ process.exit(1);
66
+ }
67
+ }
68
+
69
+ const MJS_PATH = join(HOOKS_DIR, 'session-tracker.mjs');
70
+ fs.copyFileSync(SOURCE_HOOK, HOOK_SCRIPT_PATH);
71
+ fs.chmodSync(HOOK_SCRIPT_PATH, 0o755);
72
+ fs.copyFileSync(SOURCE_MJS, MJS_PATH);
73
+ console.log(`✓ Installed hook script: ${HOOK_SCRIPT_PATH}`);
74
+
75
+ // Read existing settings.json
76
+ let settings = {};
77
+
78
+ if (fs.existsSync(SETTINGS_FILE)) {
79
+ try {
80
+ const raw = fs.readFileSync(SETTINGS_FILE, 'utf8');
81
+ settings = JSON.parse(raw);
82
+ } catch (err) {
83
+ console.error(`✗ Failed to parse ${SETTINGS_FILE}: ${err.message}`);
84
+ console.error(' Please fix the JSON syntax and run setup again.');
85
+ process.exit(1);
86
+ }
87
+
88
+ // Backup before modifying
89
+ const backupPath = `${SETTINGS_FILE}.bak.${Date.now()}`;
90
+ fs.copyFileSync(SETTINGS_FILE, backupPath);
91
+ console.log(`✓ Backed up settings to: ${backupPath}`);
92
+ }
93
+
94
+ // Register hooks
95
+ const command = HOOK_SCRIPT_PATH;
96
+ let registeredCount = 0;
97
+
98
+ for (const event of HOOK_EVENTS) {
99
+ const added = ensureHook(settings, event, command);
100
+ if (added) {
101
+ registeredCount++;
102
+ console.log(` + Registered hook: ${event}`);
103
+ } else {
104
+ console.log(` ~ Already registered: ${event}`);
105
+ }
106
+ }
107
+
108
+ // Write updated settings
109
+ fs.writeFileSync(
110
+ SETTINGS_FILE,
111
+ `${JSON.stringify(settings, null, 2)}\n`,
112
+ 'utf8',
113
+ );
114
+ console.log(`✓ Updated: ${SETTINGS_FILE}`);
115
+
116
+ console.log('');
117
+ if (registeredCount > 0) {
118
+ console.log(`✓ Setup complete! Registered ${registeredCount} new hook(s).`);
119
+ } else {
120
+ console.log('✓ Setup complete! All hooks were already registered.');
121
+ }
122
+ console.log('');
123
+ console.log('Next steps:');
124
+ console.log(' 1. Start a Claude Code session in your project directory');
125
+ console.log(' 2. In another terminal tab, run: ccw start');
@@ -0,0 +1,52 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readConfig, writeConfig } from '../core/config.mjs';
3
+
4
+ const EVENTS = ['noti', 'stop'];
5
+ const SOUNDS = [
6
+ 'Basso', 'Blow', 'Bottle', 'Frog', 'Funk',
7
+ 'Glass', 'Hero', 'Morse', 'Ping', 'Pop',
8
+ 'Purr', 'Sosumi', 'Submarine', 'Tink',
9
+ ];
10
+
11
+ const [event, soundName] = process.argv.slice(3);
12
+ const config = readConfig();
13
+
14
+ if (!event) {
15
+ console.log('Sound settings:');
16
+ console.log(` noti : ${config.sounds.noti} (Notification / permission request)`);
17
+ console.log(` stop : ${config.sounds.stop} (Claude finished responding)`);
18
+ console.log('');
19
+ console.log('Available sounds:');
20
+ console.log(` ${SOUNDS.join(', ')}`);
21
+ console.log('');
22
+ console.log('Usage:');
23
+ console.log(' ccw /sound <event> <sound>');
24
+ console.log(' ccw /sound noti Funk');
25
+ console.log(' ccw /sound stop Blow');
26
+ process.exit(0);
27
+ }
28
+
29
+ if (!EVENTS.includes(event)) {
30
+ console.error(`Unknown event: "${event}". Use one of: ${EVENTS.join(', ')}`);
31
+ process.exit(1);
32
+ }
33
+
34
+ if (!soundName) {
35
+ console.error(`Please specify a sound name.`);
36
+ console.log(`Available sounds: ${SOUNDS.join(', ')}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ if (!SOUNDS.includes(soundName)) {
41
+ console.error(`Unknown sound: "${soundName}"`);
42
+ console.log(`Available sounds: ${SOUNDS.join(', ')}`);
43
+ process.exit(1);
44
+ }
45
+
46
+ config.sounds[event] = soundName;
47
+ writeConfig(config);
48
+ console.log(`✓ Set ${event} sound to: ${soundName}`);
49
+
50
+ if (process.platform === 'darwin') {
51
+ execFile('afplay', [`/System/Library/Sounds/${soundName}.aiff`], { timeout: 3000 }, () => {});
52
+ }