claude-controller 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +1189 -0
  4. package/bin/native-app.py +6 -3
  5. package/bin/watchdog.sh +357 -0
  6. package/cognitive/__init__.py +14 -0
  7. package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
  9. package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
  10. package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
  11. package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
  12. package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
  13. package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
  14. package/cognitive/dispatcher.py +192 -0
  15. package/cognitive/evaluator.py +289 -0
  16. package/cognitive/goal_engine.py +232 -0
  17. package/cognitive/learning.py +189 -0
  18. package/cognitive/orchestrator.py +303 -0
  19. package/cognitive/planner.py +207 -0
  20. package/cognitive/prompts/analyst.md +31 -0
  21. package/cognitive/prompts/coder.md +22 -0
  22. package/cognitive/prompts/reviewer.md +33 -0
  23. package/cognitive/prompts/tester.md +21 -0
  24. package/cognitive/prompts/writer.md +25 -0
  25. package/config.sh +6 -1
  26. package/dag/__init__.py +5 -0
  27. package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/dag/__pycache__/graph.cpython-314.pyc +0 -0
  29. package/dag/graph.py +222 -0
  30. package/lib/jobs.sh +12 -1
  31. package/package.json +11 -5
  32. package/postinstall.sh +1 -1
  33. package/service/controller.sh +43 -11
  34. package/web/audit.py +122 -0
  35. package/web/checkpoint.py +80 -0
  36. package/web/config.py +2 -5
  37. package/web/handler.py +634 -473
  38. package/web/handler_fs.py +153 -0
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +372 -0
  41. package/web/handler_memory.py +203 -0
  42. package/web/handler_sessions.py +132 -0
  43. package/web/jobs.py +585 -13
  44. package/web/personas.py +419 -0
  45. package/web/pipeline.py +981 -0
  46. package/web/presets.py +506 -0
  47. package/web/projects.py +246 -0
  48. package/web/static/api.js +141 -0
  49. package/web/static/app.js +25 -1937
  50. package/web/static/attachments.js +144 -0
  51. package/web/static/base.css +497 -0
  52. package/web/static/context.js +204 -0
  53. package/web/static/dirs.js +246 -0
  54. package/web/static/form.css +763 -0
  55. package/web/static/goals.css +363 -0
  56. package/web/static/goals.js +300 -0
  57. package/web/static/i18n.js +625 -0
  58. package/web/static/index.html +215 -13
  59. package/web/static/{styles.css → jobs.css} +746 -1141
  60. package/web/static/jobs.js +1270 -0
  61. package/web/static/memoryview.js +117 -0
  62. package/web/static/personas.js +228 -0
  63. package/web/static/pipeline.css +338 -0
  64. package/web/static/pipelines.js +487 -0
  65. package/web/static/presets.js +244 -0
  66. package/web/static/send.js +135 -0
  67. package/web/static/settings-style.css +291 -0
  68. package/web/static/settings.js +81 -0
  69. package/web/static/stream.js +534 -0
  70. package/web/static/utils.js +131 -0
  71. package/web/webhook.py +210 -0
package/bin/ctl ADDED
@@ -0,0 +1,1189 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ctl — Controller CLI
4
+
5
+ 웹 대시보드의 모든 기능을 커맨드라인에서 사용할 수 있는 인터페이스.
6
+ Claude Code AI가 Bash 도구로 직접 호출하여 작업을 관리할 수 있다.
7
+
8
+ Usage:
9
+ ctl status 서비스 상태 확인
10
+ ctl send "prompt" [--cwd PATH] [--session ID] 프롬프트 전송
11
+ ctl jobs 작업 목록 조회
12
+ ctl jobs purge 완료된 작업 일괄 삭제
13
+ ctl job <id> 작업 결과 조회
14
+ ctl job <id> stream [--offset N] 스트림 이벤트 조회
15
+ ctl job <id> checkpoints 체크포인트 목록
16
+ ctl job <id> rewind <hash> "prompt" 체크포인트 되감기
17
+ ctl job <id> delete 작업 삭제
18
+ ctl sessions [--cwd PATH] 세션 목록 조회
19
+ ctl config 설정 조회
20
+ ctl config set <key> <value> 설정 변경
21
+ ctl service start 서비스 시작
22
+ ctl service stop 서비스 종료
23
+ ctl dirs [PATH] 디렉토리 탐색
24
+ ctl mkdir <parent> <name> 디렉토리 생성
25
+ ctl upload <file> 파일 업로드
26
+ ctl project list 프로젝트 목록
27
+ ctl project add <path> [--name N] 기존 프로젝트 등록
28
+ ctl project create <path> [--name N] 신규 프로젝트 생성
29
+ ctl project info <id> 프로젝트 상세
30
+ ctl project remove <id> 프로젝트 제거
31
+ ctl project update <id> [--name N] [--desc D] 프로젝트 수정
32
+ ctl pipeline list 파이프라인 목록
33
+ ctl pipeline create -p <프로젝트> -c "명령어" [-i 5m] 파이프라인 생성
34
+ ctl pipeline status --id <id> 파이프라인 상태
35
+ ctl pipeline run --id <id> [--force] 실행/강제 실행
36
+ ctl pipeline tick 모든 활성 파이프라인 진행
37
+ ctl pipeline reset --id <id> 상태 초기화
38
+ ctl pipeline delete --id <id> 파이프라인 삭제
39
+ ctl summary 전체 상태 요약 (AI 컨텍스트용)
40
+ ctl stats [--limit N] 작업 통계 집계
41
+ ctl health 헬스체크 + 디스크 + stuck 작업
42
+ ctl watchdog [start|stop|status|install|uninstall] 프로세스 워치독 관리
43
+ ctl job <id> text 최종 텍스트 결과만 추출
44
+
45
+ Output: 기본 JSON. --pretty 플래그로 포맷팅된 출력.
46
+ """
47
+
48
+ import argparse
49
+ import base64
50
+ import json
51
+ import os
52
+ import sys
53
+ import time
54
+ from pathlib import Path
55
+
56
+ # ── 경로 설정 ──
57
+ CONTROLLER_DIR = Path(__file__).resolve().parent.parent
58
+ sys.path.insert(0, str(CONTROLLER_DIR / "web"))
59
+
60
+ from config import (
61
+ LOGS_DIR, UPLOADS_DIR, DATA_DIR,
62
+ RECENT_DIRS_FILE, SETTINGS_FILE, SESSIONS_DIR,
63
+ CLAUDE_PROJECTS_DIR, FIFO_PATH,
64
+ )
65
+ from jobs import get_all_jobs, get_job_result, send_to_fifo, start_controller_service, stop_controller_service
66
+ from checkpoint import get_job_checkpoints, rewind_job
67
+ from utils import parse_meta_file, is_service_running, cwd_to_project_dir, scan_claude_sessions
68
+ from projects import list_projects, get_project, add_project, create_project, remove_project, update_project
69
+ from pipeline import (
70
+ list_pipelines, get_pipeline_status, create_pipeline, delete_pipeline,
71
+ run_next, force_run, reset_phase, tick, tick_all,
72
+ )
73
+ import shutil
74
+
75
+
76
+ # ══════════════════════════════════════════════════════════════
77
+ # 출력 헬퍼
78
+ # ══════════════════════════════════════════════════════════════
79
+
80
+ def out(data, pretty=False):
81
+ """JSON 출력. pretty=True면 들여쓰기."""
82
+ indent = 2 if pretty else None
83
+ print(json.dumps(data, ensure_ascii=False, indent=indent))
84
+
85
+
86
+ def err(message, code=1):
87
+ """에러 출력 후 종료."""
88
+ print(json.dumps({"error": message}, ensure_ascii=False), file=sys.stderr)
89
+ sys.exit(code)
90
+
91
+
92
+ # ══════════════════════════════════════════════════════════════
93
+ # 명령어 핸들러
94
+ # ══════════════════════════════════════════════════════════════
95
+
96
+ def cmd_status(args):
97
+ """서비스 상태 확인."""
98
+ running, pid = is_service_running()
99
+ out({"running": running, "pid": pid}, args.pretty)
100
+
101
+
102
+ def cmd_send(args):
103
+ """프롬프트 전송."""
104
+ prompt = args.prompt
105
+ if not prompt:
106
+ err("프롬프트를 입력하세요")
107
+
108
+ images = None
109
+ if args.image:
110
+ images = []
111
+ for img_path in args.image:
112
+ p = Path(img_path).resolve()
113
+ if not p.exists():
114
+ err(f"파일을 찾을 수 없습니다: {img_path}")
115
+ images.append(str(p))
116
+
117
+ session = None
118
+ if args.session:
119
+ session = {"id": args.session, "mode": args.mode or "resume"}
120
+
121
+ result, error = send_to_fifo(
122
+ prompt,
123
+ cwd=args.cwd or os.getcwd(),
124
+ job_id=args.id or None,
125
+ images=images,
126
+ session=session,
127
+ )
128
+ if error:
129
+ err(error)
130
+ out(result, args.pretty)
131
+
132
+
133
+ def cmd_jobs(args):
134
+ """작업 목록 또는 일괄 삭제."""
135
+ if args.action == "purge":
136
+ deleted = []
137
+ for mf in list(LOGS_DIR.glob("job_*.meta")):
138
+ meta = parse_meta_file(mf)
139
+ if not meta:
140
+ continue
141
+ status = meta.get("STATUS", "")
142
+ if status in ("done", "failed"):
143
+ job_id = meta.get("JOB_ID", "")
144
+ out_file = LOGS_DIR / f"job_{job_id}.out"
145
+ try:
146
+ mf.unlink()
147
+ if out_file.exists():
148
+ out_file.unlink()
149
+ deleted.append(job_id)
150
+ except OSError:
151
+ pass
152
+ out({"deleted": deleted, "count": len(deleted)}, args.pretty)
153
+ else:
154
+ jobs = get_all_jobs()
155
+ out(jobs, args.pretty)
156
+
157
+
158
+ def cmd_job(args):
159
+ """단일 작업 조작 (결과/스트림/체크포인트/되감기/삭제)."""
160
+ job_id = args.job_id
161
+
162
+ # ── delete ──
163
+ if args.action == "delete":
164
+ meta_file = LOGS_DIR / f"job_{job_id}.meta"
165
+ out_file = LOGS_DIR / f"job_{job_id}.out"
166
+ if not meta_file.exists():
167
+ err("작업을 찾을 수 없습니다")
168
+ meta = parse_meta_file(meta_file)
169
+ if meta.get("STATUS") == "running" and meta.get("PID"):
170
+ try:
171
+ os.kill(int(meta["PID"]), 0)
172
+ err("실행 중인 작업은 삭제할 수 없습니다")
173
+ except (ProcessLookupError, ValueError, OSError):
174
+ pass
175
+ try:
176
+ meta_file.unlink()
177
+ if out_file.exists():
178
+ out_file.unlink()
179
+ out({"deleted": True, "job_id": job_id}, args.pretty)
180
+ except OSError as e:
181
+ err(f"삭제 실패: {e}")
182
+
183
+ # ── stream ──
184
+ elif args.action == "stream":
185
+ out_file = LOGS_DIR / f"job_{job_id}.out"
186
+ meta_file = LOGS_DIR / f"job_{job_id}.meta"
187
+ if not meta_file.exists():
188
+ err("작업을 찾을 수 없습니다")
189
+ if not out_file.exists():
190
+ out({"events": [], "offset": 0, "done": False}, args.pretty)
191
+ return
192
+
193
+ offset = args.offset or 0
194
+ events = []
195
+ try:
196
+ with open(out_file, "r") as f:
197
+ f.seek(offset)
198
+ for raw_line in f:
199
+ if '"type":"assistant"' not in raw_line and '"type":"result"' not in raw_line:
200
+ continue
201
+ try:
202
+ evt = json.loads(raw_line)
203
+ evt_type = evt.get("type", "")
204
+ if evt_type == "assistant":
205
+ msg = evt.get("message", {})
206
+ content = msg.get("content", [])
207
+ text_parts = [c.get("text", "") for c in content if c.get("type") == "text"]
208
+ if text_parts:
209
+ events.append({"type": "text", "text": "".join(text_parts)})
210
+ for tp in content:
211
+ if tp.get("type") == "tool_use":
212
+ events.append({
213
+ "type": "tool_use",
214
+ "tool": tp.get("name", ""),
215
+ "input": str(tp.get("input", ""))[:200],
216
+ })
217
+ elif evt_type == "result":
218
+ events.append({
219
+ "type": "result",
220
+ "result": evt.get("result", ""),
221
+ "cost_usd": evt.get("total_cost_usd"),
222
+ "duration_ms": evt.get("duration_ms"),
223
+ "is_error": evt.get("is_error", False),
224
+ "session_id": evt.get("session_id", ""),
225
+ })
226
+ except json.JSONDecodeError:
227
+ continue
228
+ new_offset = f.tell()
229
+ except OSError as e:
230
+ err(f"스트림 읽기 실패: {e}")
231
+
232
+ meta = parse_meta_file(meta_file)
233
+ done = meta.get("STATUS", "") in ("done", "failed")
234
+ out({"events": events, "offset": new_offset, "done": done}, args.pretty)
235
+
236
+ # ── checkpoints ──
237
+ elif args.action == "checkpoints":
238
+ checkpoints, error = get_job_checkpoints(job_id)
239
+ if error:
240
+ err(error)
241
+ out(checkpoints, args.pretty)
242
+
243
+ # ── rewind ──
244
+ elif args.action == "rewind":
245
+ if not args.checkpoint:
246
+ err("--checkpoint 해시가 필요합니다")
247
+ if not args.prompt:
248
+ err("--prompt 가 필요합니다")
249
+ result, error = rewind_job(job_id, args.checkpoint, args.prompt)
250
+ if error:
251
+ err(error)
252
+ out(result, args.pretty)
253
+
254
+ # ── text (최종 텍스트만) ──
255
+ elif args.action == "text":
256
+ cmd_job_text(job_id, args.pretty, raw=getattr(args, "raw", False))
257
+ return
258
+
259
+ # ── 기본: 결과 조회 ──
260
+ else:
261
+ result, error = get_job_result(job_id)
262
+ if error:
263
+ err(error)
264
+ out(result, args.pretty)
265
+
266
+
267
+ def cmd_sessions(args):
268
+ """세션 목록 조회."""
269
+ seen = {}
270
+ filter_cwd = args.cwd
271
+
272
+ if filter_cwd:
273
+ proj_name = cwd_to_project_dir(filter_cwd)
274
+ project_dirs = [CLAUDE_PROJECTS_DIR / proj_name]
275
+ else:
276
+ if CLAUDE_PROJECTS_DIR.exists():
277
+ all_dirs = sorted(
278
+ (d for d in CLAUDE_PROJECTS_DIR.iterdir() if d.is_dir()),
279
+ key=lambda d: d.stat().st_mtime,
280
+ reverse=True,
281
+ )
282
+ project_dirs = all_dirs[:15]
283
+ else:
284
+ project_dirs = []
285
+
286
+ for pd in project_dirs:
287
+ native = scan_claude_sessions(pd, limit=60)
288
+ for sid, info in native.items():
289
+ if sid not in seen:
290
+ seen[sid] = info
291
+
292
+ # Job meta 파일에서 보강
293
+ if LOGS_DIR.exists():
294
+ meta_files = sorted(
295
+ LOGS_DIR.glob("job_*.meta"),
296
+ key=lambda f: int(f.stem.split("_")[1]),
297
+ reverse=True,
298
+ )
299
+ for mf in meta_files:
300
+ meta = parse_meta_file(mf)
301
+ if not meta:
302
+ continue
303
+ sid = meta.get("SESSION_ID", "").strip()
304
+ if not sid:
305
+ continue
306
+ status = meta.get("STATUS", "unknown")
307
+ if status == "running" and meta.get("PID"):
308
+ try:
309
+ os.kill(int(meta["PID"]), 0)
310
+ except (ProcessLookupError, ValueError, OSError):
311
+ status = "done"
312
+
313
+ job_id = meta.get("JOB_ID", "")
314
+ entry = {
315
+ "session_id": sid,
316
+ "job_id": job_id,
317
+ "prompt": meta.get("PROMPT", ""),
318
+ "timestamp": meta.get("CREATED_AT", ""),
319
+ "status": status,
320
+ "cwd": meta.get("CWD", ""),
321
+ }
322
+ if sid not in seen:
323
+ seen[sid] = entry
324
+ else:
325
+ existing = seen[sid]
326
+ if existing.get("job_id") is None:
327
+ existing.update({"job_id": job_id, "status": status})
328
+
329
+ # history.log 보충
330
+ history_file = SESSIONS_DIR / "history.log"
331
+ if history_file.exists():
332
+ try:
333
+ for line in history_file.read_text("utf-8").strip().split("\n"):
334
+ parts = line.split("|", 2)
335
+ if len(parts) >= 2:
336
+ ts, sid = parts[0].strip(), parts[1].strip()
337
+ if not sid or sid in seen:
338
+ continue
339
+ prompt = parts[2].strip() if len(parts) > 2 else ""
340
+ seen[sid] = {
341
+ "session_id": sid, "job_id": None,
342
+ "prompt": prompt, "timestamp": ts,
343
+ "status": "done", "cwd": None,
344
+ }
345
+ except OSError:
346
+ pass
347
+
348
+ # cwd 필터
349
+ if filter_cwd:
350
+ norm = os.path.normpath(filter_cwd)
351
+ seen = {
352
+ sid: s for sid, s in seen.items()
353
+ if s.get("cwd") and os.path.normpath(s["cwd"]) == norm
354
+ }
355
+
356
+ sessions = sorted(seen.values(), key=lambda s: s.get("timestamp") or "", reverse=True)
357
+ out(sessions[:50], args.pretty)
358
+
359
+
360
+ def cmd_config(args):
361
+ """설정 조회 또는 변경."""
362
+ defaults = {
363
+ "skip_permissions": False,
364
+ "allowed_tools": "Bash,Read,Write,Edit,Glob,Grep,Agent,NotebookEdit,WebFetch,WebSearch",
365
+ "model": "",
366
+ "max_jobs": 10,
367
+ "append_system_prompt": "",
368
+ "target_repo": "",
369
+ "base_branch": "main",
370
+ "checkpoint_interval": 5,
371
+ "locale": "ko",
372
+ }
373
+
374
+ try:
375
+ if SETTINGS_FILE.exists():
376
+ saved = json.loads(SETTINGS_FILE.read_text("utf-8"))
377
+ defaults.update(saved)
378
+ except (json.JSONDecodeError, OSError):
379
+ pass
380
+
381
+ if args.action == "set":
382
+ if not args.key or args.value is None:
383
+ err("key와 value가 필요합니다")
384
+
385
+ key = args.key
386
+ value = args.value
387
+ allowed_keys = {
388
+ "skip_permissions", "allowed_tools", "model", "max_jobs",
389
+ "append_system_prompt", "target_repo", "base_branch",
390
+ "checkpoint_interval", "locale",
391
+ }
392
+ if key not in allowed_keys:
393
+ err(f"허용되지 않는 설정 키: {key}. 허용: {', '.join(sorted(allowed_keys))}")
394
+
395
+ # 타입 변환
396
+ if value.lower() in ("true", "false"):
397
+ value = value.lower() == "true"
398
+ else:
399
+ try:
400
+ value = int(value)
401
+ except ValueError:
402
+ pass
403
+
404
+ defaults[key] = value
405
+ try:
406
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
407
+ SETTINGS_FILE.write_text(
408
+ json.dumps(defaults, ensure_ascii=False, indent=2), "utf-8"
409
+ )
410
+ out({"ok": True, "config": defaults}, args.pretty)
411
+ except OSError as e:
412
+ err(f"설정 저장 실패: {e}")
413
+ else:
414
+ out(defaults, args.pretty)
415
+
416
+
417
+ def cmd_service(args):
418
+ """서비스 시작/종료."""
419
+ if args.action == "start":
420
+ ok, pid = start_controller_service()
421
+ if ok:
422
+ out({"started": True, "pid": pid}, args.pretty)
423
+ else:
424
+ err("서비스 시작 실패")
425
+ elif args.action == "stop":
426
+ ok, error = stop_controller_service()
427
+ if ok:
428
+ out({"stopped": True}, args.pretty)
429
+ else:
430
+ err(error or "서비스 종료 실패")
431
+ else:
432
+ err(f"알 수 없는 서비스 액션: {args.action}")
433
+
434
+
435
+ def cmd_dirs(args):
436
+ """디렉토리 탐색."""
437
+ dir_path = os.path.abspath(os.path.expanduser(args.path or os.getcwd()))
438
+ if not os.path.isdir(dir_path):
439
+ err("디렉토리가 아닙니다")
440
+
441
+ entries = []
442
+ try:
443
+ items = sorted(os.listdir(dir_path))
444
+ except PermissionError:
445
+ err("접근 권한 없음")
446
+
447
+ parent = os.path.dirname(dir_path)
448
+ if parent != dir_path:
449
+ entries.append({"name": "..", "path": parent, "type": "dir"})
450
+
451
+ for item in items:
452
+ if item.startswith("."):
453
+ continue
454
+ full = os.path.join(dir_path, item)
455
+ entry = {"name": item, "path": full}
456
+ if os.path.isdir(full):
457
+ entry["type"] = "dir"
458
+ else:
459
+ entry["type"] = "file"
460
+ try:
461
+ entry["size"] = os.path.getsize(full)
462
+ except OSError:
463
+ entry["size"] = 0
464
+ entries.append(entry)
465
+
466
+ out({"current": dir_path, "entries": entries}, args.pretty)
467
+
468
+
469
+ def cmd_mkdir(args):
470
+ """디렉토리 생성."""
471
+ parent = args.parent
472
+ name = args.name
473
+
474
+ if "/" in name or "\\" in name or name in (".", ".."):
475
+ err("잘못된 디렉토리 이름입니다")
476
+
477
+ parent = os.path.abspath(os.path.expanduser(parent))
478
+ if not os.path.isdir(parent):
479
+ err("상위 디렉토리가 존재하지 않습니다")
480
+
481
+ new_dir = os.path.join(parent, name)
482
+ if os.path.exists(new_dir):
483
+ err("이미 존재하는 이름입니다")
484
+
485
+ try:
486
+ os.makedirs(new_dir)
487
+ out({"ok": True, "path": new_dir}, args.pretty)
488
+ except PermissionError:
489
+ err("접근 권한 없음")
490
+ except OSError as e:
491
+ err(f"디렉토리 생성 실패: {e}")
492
+
493
+
494
+ def cmd_upload(args):
495
+ """파일 업로드."""
496
+ file_path = Path(args.file).resolve()
497
+ if not file_path.exists():
498
+ err(f"파일을 찾을 수 없습니다: {args.file}")
499
+
500
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
501
+ ext = file_path.suffix.lower()
502
+ prefix = "img" if ext in IMAGE_EXTS else "file"
503
+ safe_name = f"{prefix}_{int(time.time())}_{os.getpid()}_{id(file_path) % 10000}{ext}"
504
+
505
+ UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
506
+ dest = UPLOADS_DIR / safe_name
507
+
508
+ try:
509
+ raw = file_path.read_bytes()
510
+ dest.write_bytes(raw)
511
+ out({
512
+ "path": str(dest),
513
+ "filename": safe_name,
514
+ "originalName": file_path.name,
515
+ "size": len(raw),
516
+ "isImage": ext in IMAGE_EXTS,
517
+ }, args.pretty)
518
+ except OSError as e:
519
+ err(f"업로드 실패: {e}")
520
+
521
+
522
+ def cmd_project(args):
523
+ """프로젝트 관리."""
524
+ action = args.action
525
+
526
+ if action == "list" or action is None:
527
+ out(list_projects(), args.pretty)
528
+
529
+ elif action == "add":
530
+ if not args.path:
531
+ err("경로를 지정하세요")
532
+ project, error = add_project(
533
+ args.path,
534
+ name=args.name or "",
535
+ description=args.desc or "",
536
+ )
537
+ if error:
538
+ err(error)
539
+ out(project, args.pretty)
540
+
541
+ elif action == "create":
542
+ if not args.path:
543
+ err("경로를 지정하세요")
544
+ project, error = create_project(
545
+ args.path,
546
+ name=args.name or "",
547
+ description=args.desc or "",
548
+ init_git=not args.no_git,
549
+ )
550
+ if error:
551
+ err(error)
552
+ out(project, args.pretty)
553
+
554
+ elif action == "info":
555
+ if not args.id:
556
+ err("프로젝트 ID를 지정하세요")
557
+ project, error = get_project(args.id)
558
+ if error:
559
+ err(error)
560
+ out(project, args.pretty)
561
+
562
+ elif action == "remove":
563
+ if not args.id:
564
+ err("프로젝트 ID를 지정하세요")
565
+ result, error = remove_project(args.id)
566
+ if error:
567
+ err(error)
568
+ out({"removed": True, "project": result}, args.pretty)
569
+
570
+ elif action == "update":
571
+ if not args.id:
572
+ err("프로젝트 ID를 지정하세요")
573
+ project, error = update_project(
574
+ args.id,
575
+ name=args.name,
576
+ description=args.desc,
577
+ )
578
+ if error:
579
+ err(error)
580
+ out(project, args.pretty)
581
+
582
+
583
+ def _resolve_project_path(args) -> str:
584
+ """--project (이름/ID) 또는 path에서 프로젝트 경로를 찾는다."""
585
+ projects = list_projects()
586
+
587
+ # --project 옵션: 이름 또는 ID로 검색
588
+ key = getattr(args, "project", None)
589
+ if key:
590
+ for p in projects:
591
+ if p["id"] == key or p.get("name", "").lower() == key.lower():
592
+ return p["path"]
593
+ # 부분 매칭 시도
594
+ for p in projects:
595
+ if key.lower() in p.get("name", "").lower():
596
+ return p["path"]
597
+ err(f"프로젝트를 찾을 수 없습니다: '{key}'\n"
598
+ f"등록된 프로젝트: {', '.join(p['name'] for p in projects) or '(없음)'}\n"
599
+ f"ctl project add <경로> 로 먼저 등록하세요")
600
+
601
+ # path 직접 지정 (하위 호환)
602
+ if getattr(args, "path", None):
603
+ return args.path
604
+
605
+ # 둘 다 없으면 목록 안내
606
+ if not projects:
607
+ err("등록된 프로젝트가 없습니다.\n"
608
+ "ctl project add <경로> --name <이름> 으로 프로젝트를 등록하세요")
609
+ lines = ["--project 로 프로젝트를 선택하세요:", ""]
610
+ for p in projects:
611
+ lines.append(f" {p['name']:<20s} {p['id']}")
612
+ lines.append("")
613
+ lines.append("예: ctl pipeline create --project <이름> --goal \"...\"")
614
+ err("\n".join(lines))
615
+
616
+
617
+ def _tick_all_human(results: list[dict]) -> str:
618
+ """tick-all 결과를 사람이 읽기 좋은 한 줄 요약으로 변환."""
619
+ if not results:
620
+ return "활성 파이프라인 없음"
621
+ lines = []
622
+ for r in results:
623
+ name = r.get("name", r["pipeline_id"])
624
+ phase = r.get("phase", "?")
625
+ if r.get("error"):
626
+ lines.append(f" ✗ {name} [{phase}] {r['error']}")
627
+ elif r.get("result"):
628
+ action = r["result"].get("action", "")
629
+ msg = r["result"].get("message", action)
630
+ lines.append(f" → {name} [{phase}] {msg}")
631
+ else:
632
+ lines.append(f" - {name} [{phase}]")
633
+ return "\n".join(lines)
634
+
635
+
636
+ def cmd_pipeline(args):
637
+ """파이프라인 관리 및 실행."""
638
+ action = args.action
639
+
640
+ if action == "list" or action is None:
641
+ pipes = list_pipelines()
642
+ if not args.pretty and not pipes:
643
+ print("[]")
644
+ return
645
+ if args.pretty:
646
+ # 사람 친화적 목록
647
+ if not pipes:
648
+ print("등록된 파이프라인이 없습니다.")
649
+ return
650
+ for p in pipes:
651
+ status_label = {"done": "완료", "running": "실행중", "failed": "실패", "idle": "대기"}.get(p.get("status", ""), p.get("status", ""))
652
+ interval_label = f" [{p['interval']}]" if p.get("interval") else ""
653
+ print(f" {p['id']} [{status_label:<6s}]{interval_label} {p.get('name', '')}")
654
+ print(f" 명령: {(p.get('command', '') or '')[:60]}")
655
+ return
656
+ out(pipes, False)
657
+
658
+ elif action == "create":
659
+ project_path = _resolve_project_path(args)
660
+ command = args.cmd
661
+ if not command:
662
+ err("--cmd (-c) 로 명령어를 지정하세요\n"
663
+ "예: ctl pipeline create --project <이름> --cmd \"테스트 실행\" [--interval 5m]")
664
+ pipe, error = create_pipeline(
665
+ project_path,
666
+ command=command,
667
+ interval=args.interval or "",
668
+ name=args.name or "",
669
+ )
670
+ if error:
671
+ err(error)
672
+ out(pipe, args.pretty)
673
+
674
+ elif action == "status":
675
+ if not args.id:
676
+ err("파이프라인 ID를 지정하세요 (--id)")
677
+ result, error = get_pipeline_status(args.id)
678
+ if error:
679
+ err(error)
680
+ if args.pretty:
681
+ status_labels = {"done": "완료", "running": "실행 중", "failed": "실패", "idle": "대기"}
682
+ print(f"파이프라인: {result['name']} [{status_labels.get(result['status'], result['status'])}]")
683
+ print(f"명령어: {result['command']}")
684
+ print(f"경로: {result['project_path']}")
685
+ if result.get("interval"):
686
+ print(f"간격: {result['interval']}")
687
+ print(f"실행 횟수: {result.get('run_count', 0)}")
688
+ if result.get("last_run"):
689
+ print(f"마지막 실행: {result['last_run']}")
690
+ if result.get("last_result"):
691
+ print(f"마지막 결과: {result['last_result'][:100]}")
692
+ if result.get("last_error"):
693
+ print(f"에러: {result['last_error']}")
694
+ if result.get("job_status"):
695
+ js = result["job_status"]
696
+ print(f"현재 작업: job#{js['job_id']} [{js.get('status', '?')}]")
697
+ return
698
+ out(result, False)
699
+
700
+ elif action == "run":
701
+ if not args.id:
702
+ err("파이프라인 ID를 지정하세요 (--id)")
703
+ if args.force:
704
+ result, error = force_run(args.id)
705
+ else:
706
+ result, error = run_next(args.id)
707
+ if error:
708
+ err(error)
709
+ out(result, args.pretty)
710
+
711
+ elif action == "tick":
712
+ if args.id:
713
+ result, error = tick(args.id)
714
+ if error:
715
+ err(error)
716
+ out(result, args.pretty)
717
+ else:
718
+ # --id 없으면 tick-all
719
+ results = tick_all()
720
+ if args.pretty:
721
+ print(_tick_all_human(results))
722
+ else:
723
+ out(results, False)
724
+
725
+ elif action == "tick-all":
726
+ results = tick_all()
727
+ if args.pretty:
728
+ print(_tick_all_human(results))
729
+ else:
730
+ out(results, False)
731
+
732
+ elif action == "reset":
733
+ if not args.id:
734
+ err("파이프라인 ID를 지정하세요 (--id)")
735
+ result, error = reset_phase(args.id, phase=args.phase)
736
+ if error:
737
+ err(error)
738
+ out(result, args.pretty)
739
+
740
+ elif action == "delete":
741
+ if not args.id:
742
+ err("파이프라인 ID를 지정하세요 (--id)")
743
+ result, error = delete_pipeline(args.id)
744
+ if error:
745
+ err(error)
746
+ out({"deleted": True, "pipeline": result}, args.pretty)
747
+
748
+ elif action == "evolution":
749
+ from pipeline import get_evolution_summary
750
+ summary = get_evolution_summary()
751
+ if args.pretty:
752
+ print(f"파이프라인 진화 요약")
753
+ print(f" 활성: {summary['active_count']}/{summary['total_pipelines']}")
754
+ print(f" 총 실행: {summary['total_runs']}회 | 총 비용: ${summary['total_cost_usd']}")
755
+ print(f" 효율성: {summary['efficiency_pct']}% (변경 있는 실행 비율)")
756
+ cls = summary["classifications"]
757
+ print(f" 분류: 변경있음={cls.get('has_change',0)} / 변경없음={cls.get('no_change',0)} / 불명={cls.get('unknown',0)}")
758
+ if summary["interval_adaptations"]:
759
+ print(f" 인터벌 적응:")
760
+ for ia in summary["interval_adaptations"]:
761
+ print(f" {ia['name']}: {ia['base_sec']}s → {ia['effective_sec']}s ({ia['change_pct']:+d}%)")
762
+ else:
763
+ out(summary, False)
764
+
765
+
766
+ # ══════════════════════════════════════════════════════════════
767
+ # AI 컨텍스트 명령어 — summary, stats, health, job text
768
+ # ══════════════════════════════════════════════════════════════
769
+
770
+ def cmd_summary(args):
771
+ """서비스 + 최근 작업 + 파이프라인을 한 번에 요약."""
772
+ running, pid = is_service_running()
773
+
774
+ jobs = get_all_jobs()
775
+ total = len(jobs)
776
+ by_status = {}
777
+ recent_failures = []
778
+ total_cost = 0.0
779
+ running_jobs = []
780
+
781
+ for j in jobs:
782
+ st = j.get("status", "unknown")
783
+ by_status[st] = by_status.get(st, 0) + 1
784
+ if j.get("cost_usd"):
785
+ total_cost += j["cost_usd"]
786
+ if st == "running":
787
+ running_jobs.append({
788
+ "job_id": j["job_id"],
789
+ "prompt": (j.get("prompt") or "")[:80],
790
+ "cwd": j.get("cwd"),
791
+ })
792
+ if st == "failed":
793
+ recent_failures.append({
794
+ "job_id": j["job_id"],
795
+ "prompt": (j.get("prompt") or "")[:80],
796
+ "result": (j.get("result") or "")[:200],
797
+ })
798
+
799
+ done = by_status.get("done", 0)
800
+ failed = by_status.get("failed", 0)
801
+ success_rate = round(done / (done + failed), 3) if (done + failed) > 0 else None
802
+
803
+ # 파이프라인 요약
804
+ pipes = list_pipelines()
805
+ active_pipes = [
806
+ {"id": p["id"], "name": p.get("name", ""), "status": p.get("status", ""), "command": (p.get("command") or "")[:60]}
807
+ for p in pipes if p.get("status") in ("running", "idle")
808
+ ]
809
+
810
+ result = {
811
+ "service": {"running": running, "pid": pid},
812
+ "jobs": {
813
+ "total": total,
814
+ "by_status": by_status,
815
+ "success_rate": success_rate,
816
+ "total_cost_usd": round(total_cost, 4),
817
+ "running": running_jobs,
818
+ "recent_failures": recent_failures[:5],
819
+ },
820
+ "pipelines": {"total": len(pipes), "active": active_pipes},
821
+ }
822
+ out(result, args.pretty)
823
+
824
+
825
+ def cmd_stats(args):
826
+ """작업 통계 집계 — 성공률, 비용, 소요시간, 에러 패턴."""
827
+ jobs = get_all_jobs()
828
+
829
+ # 기간 필터
830
+ limit = args.limit or len(jobs)
831
+ jobs = jobs[:limit]
832
+
833
+ total = len(jobs)
834
+ done_count = 0
835
+ failed_count = 0
836
+ costs = []
837
+ durations = []
838
+ error_patterns = {}
839
+
840
+ for j in jobs:
841
+ st = j.get("status", "unknown")
842
+ if st == "done":
843
+ done_count += 1
844
+ elif st == "failed":
845
+ failed_count += 1
846
+ # 에러 패턴 수집
847
+ result_text = (j.get("result") or "")[:300]
848
+ if result_text:
849
+ # 첫 줄을 패턴 키로 사용
850
+ key = result_text.split("\n")[0][:80]
851
+ error_patterns[key] = error_patterns.get(key, 0) + 1
852
+
853
+ if j.get("cost_usd"):
854
+ costs.append(j["cost_usd"])
855
+ if j.get("duration_ms"):
856
+ durations.append(j["duration_ms"])
857
+
858
+ completed = done_count + failed_count
859
+ result = {
860
+ "total_jobs": total,
861
+ "completed": completed,
862
+ "done": done_count,
863
+ "failed": failed_count,
864
+ "success_rate": round(done_count / completed, 3) if completed > 0 else None,
865
+ "cost": {
866
+ "total_usd": round(sum(costs), 4) if costs else 0,
867
+ "avg_usd": round(sum(costs) / len(costs), 4) if costs else None,
868
+ "max_usd": round(max(costs), 4) if costs else None,
869
+ },
870
+ "duration": {
871
+ "avg_ms": round(sum(durations) / len(durations)) if durations else None,
872
+ "median_ms": round(sorted(durations)[len(durations) // 2]) if durations else None,
873
+ "max_ms": max(durations) if durations else None,
874
+ },
875
+ "top_errors": dict(sorted(error_patterns.items(), key=lambda x: -x[1])[:5]),
876
+ }
877
+ out(result, args.pretty)
878
+
879
+
880
+ def cmd_health(args):
881
+ """서비스 상태 + 디스크 사용량 + stuck 작업 + 고아 프로세스."""
882
+ running, pid = is_service_running()
883
+
884
+ # 디스크 사용량
885
+ def dir_size_mb(path):
886
+ total = 0
887
+ p = Path(path)
888
+ if not p.exists():
889
+ return 0
890
+ for f in p.rglob("*"):
891
+ if f.is_file():
892
+ total += f.stat().st_size
893
+ return round(total / (1024 * 1024), 2)
894
+
895
+ disk = {
896
+ "logs_mb": dir_size_mb(LOGS_DIR),
897
+ "uploads_mb": dir_size_mb(UPLOADS_DIR),
898
+ "worktrees_mb": dir_size_mb(CONTROLLER_DIR / "worktrees"),
899
+ }
900
+ total_disk = CONTROLLER_DIR.resolve()
901
+ usage = shutil.disk_usage(str(total_disk))
902
+ disk["system_free_gb"] = round(usage.free / (1024 ** 3), 2)
903
+
904
+ # Stuck 작업 (30분 이상 running)
905
+ jobs = get_all_jobs()
906
+ stuck = []
907
+ import datetime
908
+ now = datetime.datetime.now()
909
+ for j in jobs:
910
+ if j.get("status") != "running":
911
+ continue
912
+ created = j.get("created_at", "")
913
+ if not created:
914
+ continue
915
+ try:
916
+ started = datetime.datetime.strptime(created, "%Y-%m-%d %H:%M:%S")
917
+ elapsed_min = (now - started).total_seconds() / 60
918
+ if elapsed_min > 30:
919
+ stuck.append({
920
+ "job_id": j["job_id"],
921
+ "prompt": (j.get("prompt") or "")[:60],
922
+ "elapsed_min": round(elapsed_min, 1),
923
+ })
924
+ except ValueError:
925
+ continue
926
+
927
+ # FIFO 상태
928
+ fifo_ok = FIFO_PATH.exists() and Path(FIFO_PATH).is_fifo() if hasattr(Path, 'is_fifo') else FIFO_PATH.exists()
929
+
930
+ result = {
931
+ "service": {"running": running, "pid": pid, "fifo_ok": fifo_ok},
932
+ "disk": disk,
933
+ "stuck_jobs": stuck,
934
+ "job_counts": {
935
+ "total": len(jobs),
936
+ "running": sum(1 for j in jobs if j.get("status") == "running"),
937
+ },
938
+ }
939
+ out(result, args.pretty)
940
+
941
+
942
+ def cmd_watchdog(args):
943
+ """워치독 관리 — start/stop/status/install/uninstall."""
944
+ action = args.action or "status"
945
+ watchdog_sh = CONTROLLER_DIR / "bin" / "watchdog.sh"
946
+ state_file = CONTROLLER_DIR / "data" / "watchdog_state.json"
947
+ pid_file = CONTROLLER_DIR / "service" / "watchdog.pid"
948
+
949
+ if action in ("start", "stop", "install", "uninstall"):
950
+ import subprocess
951
+ result = subprocess.run(
952
+ ["bash", str(watchdog_sh), action],
953
+ capture_output=True, text=True
954
+ )
955
+ if result.stdout.strip():
956
+ if args.pretty:
957
+ out({"action": action, "message": result.stdout.strip()}, True)
958
+ else:
959
+ print(result.stdout.strip())
960
+ if result.returncode != 0 and result.stderr.strip():
961
+ print(result.stderr.strip(), file=sys.stderr)
962
+ sys.exit(result.returncode)
963
+ else:
964
+ # status — JSON 출력
965
+ running = False
966
+ pid = None
967
+ state = {}
968
+
969
+ if pid_file.exists():
970
+ try:
971
+ pid = int(pid_file.read_text().strip())
972
+ import signal
973
+ os.kill(pid, 0)
974
+ running = True
975
+ except (ValueError, OSError):
976
+ pid = None
977
+
978
+ if state_file.exists():
979
+ try:
980
+ state = json.loads(state_file.read_text())
981
+ except (json.JSONDecodeError, OSError):
982
+ pass
983
+
984
+ out({
985
+ "running": running,
986
+ "pid": pid,
987
+ "status": state.get("status", "unknown"),
988
+ "restart_count": state.get("restart_count", 0),
989
+ "consecutive_fails": state.get("consecutive_fails", 0),
990
+ "last_restart": state.get("last_restart", ""),
991
+ "last_check": state.get("last_check", ""),
992
+ "message": state.get("message", ""),
993
+ }, args.pretty)
994
+
995
+
996
+ def cmd_job_text(job_id, pretty=False, raw=False):
997
+ """작업의 최종 텍스트 결과만 추출 (이벤트 스트림 없이)."""
998
+ result, error = get_job_result(job_id)
999
+ if error:
1000
+ err(error)
1001
+
1002
+ text = result.get("result") if result else None
1003
+ if text is None:
1004
+ # result 없으면 .out 에서 text 이벤트를 합산
1005
+ out_file = LOGS_DIR / f"job_{job_id}.out"
1006
+ if out_file.exists():
1007
+ parts = []
1008
+ try:
1009
+ with open(out_file, "r") as f:
1010
+ for raw_line in f:
1011
+ if '"type":"assistant"' not in raw_line:
1012
+ continue
1013
+ try:
1014
+ evt = json.loads(raw_line)
1015
+ if evt.get("type") == "assistant":
1016
+ content = evt.get("message", {}).get("content", [])
1017
+ for c in content:
1018
+ if c.get("type") == "text" and c.get("text"):
1019
+ parts.append(c["text"])
1020
+ except json.JSONDecodeError:
1021
+ continue
1022
+ except OSError:
1023
+ pass
1024
+ text = "\n".join(parts) if parts else None
1025
+
1026
+ if raw:
1027
+ # --raw: JSON 래핑 없이 텍스트만 stdout에 출력
1028
+ print(text or "")
1029
+ return
1030
+
1031
+ out({
1032
+ "job_id": job_id,
1033
+ "status": result.get("status") if result else "unknown",
1034
+ "text": text,
1035
+ "cost_usd": result.get("cost_usd") if result else None,
1036
+ "session_id": result.get("session_id") if result else None,
1037
+ }, pretty)
1038
+
1039
+
1040
+ # ══════════════════════════════════════════════════════════════
1041
+ # argparse 설정
1042
+ # ══════════════════════════════════════════════════════════════
1043
+
1044
+ def build_parser():
1045
+ # 공통 옵션을 모든 서브커맨드에 상속
1046
+ common = argparse.ArgumentParser(add_help=False)
1047
+ common.add_argument("--pretty", action="store_true", help="JSON 출력 포맷팅")
1048
+
1049
+ parser = argparse.ArgumentParser(
1050
+ prog="ctl",
1051
+ description="Controller CLI — 웹 대시보드 기능을 커맨드라인에서 사용",
1052
+ )
1053
+ sub = parser.add_subparsers(dest="command", required=True)
1054
+
1055
+ # ── status ──
1056
+ sub.add_parser("status", parents=[common], help="서비스 상태 확인")
1057
+
1058
+ # ── send ──
1059
+ p_send = sub.add_parser("send", parents=[common], help="프롬프트 전송")
1060
+ p_send.add_argument("prompt", help="전송할 프롬프트")
1061
+ p_send.add_argument("--cwd", help="작업 디렉토리")
1062
+ p_send.add_argument("--id", help="사용자 지정 작업 ID")
1063
+ p_send.add_argument("--session", help="기존 세션 ID (재개/포크)")
1064
+ p_send.add_argument("--mode", choices=["resume", "fork", "new"], help="세션 모드")
1065
+ p_send.add_argument("--image", action="append", help="첨부할 이미지 경로 (반복 가능)")
1066
+
1067
+ # ── jobs ──
1068
+ p_jobs = sub.add_parser("jobs", parents=[common], help="작업 목록 조회")
1069
+ p_jobs.add_argument("action", nargs="?", choices=["purge"], help="purge: 완료된 작업 삭제")
1070
+
1071
+ # ── job ──
1072
+ p_job = sub.add_parser("job", parents=[common], help="단일 작업 조작")
1073
+ p_job.add_argument("job_id", help="작업 ID")
1074
+ p_job.add_argument("action", nargs="?",
1075
+ choices=["stream", "checkpoints", "rewind", "delete", "text"],
1076
+ help="수행할 작업 (생략 시 결과 조회, text: 최종 텍스트만)")
1077
+ p_job.add_argument("--offset", type=int, default=0, help="스트림 오프셋 (stream용)")
1078
+ p_job.add_argument("--checkpoint", help="체크포인트 해시 (rewind용)")
1079
+ p_job.add_argument("--prompt", dest="prompt", help="리와인드 프롬프트 (rewind용)")
1080
+ p_job.add_argument("--raw", action="store_true", help="텍스트만 출력 (text 액션용, JSON 래핑 없이)")
1081
+
1082
+ # ── sessions ──
1083
+ p_sessions = sub.add_parser("sessions", parents=[common], help="세션 목록 조회")
1084
+ p_sessions.add_argument("--cwd", help="CWD 필터")
1085
+
1086
+ # ── config ──
1087
+ p_config = sub.add_parser("config", parents=[common], help="설정 조회/변경")
1088
+ p_config.add_argument("action", nargs="?", choices=["set"], help="set: 설정 변경")
1089
+ p_config.add_argument("key", nargs="?", help="설정 키")
1090
+ p_config.add_argument("value", nargs="?", help="설정 값")
1091
+
1092
+ # ── service ──
1093
+ p_service = sub.add_parser("service", parents=[common], help="서비스 시작/종료")
1094
+ p_service.add_argument("action", choices=["start", "stop"], help="start 또는 stop")
1095
+
1096
+ # ── dirs ──
1097
+ p_dirs = sub.add_parser("dirs", parents=[common], help="디렉토리 탐색")
1098
+ p_dirs.add_argument("path", nargs="?", help="탐색할 경로 (기본: 현재 디렉토리)")
1099
+
1100
+ # ── mkdir ──
1101
+ p_mkdir = sub.add_parser("mkdir", parents=[common], help="디렉토리 생성")
1102
+ p_mkdir.add_argument("parent", help="상위 디렉토리 경로")
1103
+ p_mkdir.add_argument("name", help="생성할 디렉토리 이름")
1104
+
1105
+ # ── upload ──
1106
+ p_upload = sub.add_parser("upload", parents=[common], help="파일 업로드")
1107
+ p_upload.add_argument("file", help="업로드할 파일 경로")
1108
+
1109
+ # ── project ──
1110
+ p_proj = sub.add_parser("project", parents=[common], help="프로젝트 관리")
1111
+ p_proj.add_argument("action", nargs="?",
1112
+ choices=["list", "add", "create", "info", "remove", "update"],
1113
+ help="수행할 작업 (생략 시 목록)")
1114
+ p_proj.add_argument("path", nargs="?", help="프로젝트 경로 (add/create용)")
1115
+ p_proj.add_argument("--id", help="프로젝트 ID (info/remove/update용)")
1116
+ p_proj.add_argument("--name", help="프로젝트 이름")
1117
+ p_proj.add_argument("--desc", help="프로젝트 설명")
1118
+ p_proj.add_argument("--no-git", action="store_true", help="git init 건너뛰기 (create용)")
1119
+
1120
+ # ── summary (AI 컨텍스트) ──
1121
+ sub.add_parser("summary", parents=[common], help="서비스 + 작업 + 파이프라인 전체 요약 (AI용)")
1122
+
1123
+ # ── stats ──
1124
+ p_stats = sub.add_parser("stats", parents=[common], help="작업 통계 집계")
1125
+ p_stats.add_argument("--limit", "-n", type=int, help="최근 N개 작업만 집계")
1126
+
1127
+ # ── health ──
1128
+ sub.add_parser("health", parents=[common], help="서비스 헬스체크 + 디스크 + stuck 작업")
1129
+
1130
+ # ── watchdog ──
1131
+ p_wd = sub.add_parser("watchdog", parents=[common], help="프로세스 워치독 관리")
1132
+ p_wd.add_argument("action", nargs="?",
1133
+ choices=["start", "stop", "status", "install", "uninstall"],
1134
+ help="수행할 작업 (생략 시 status)")
1135
+
1136
+ # ── pipeline ──
1137
+ p_pipe = sub.add_parser("pipeline", parents=[common], help="파이프라인 관리/실행")
1138
+ p_pipe.add_argument("action", nargs="?",
1139
+ choices=["list", "create", "status", "run", "tick", "tick-all", "reset", "delete", "evolution"],
1140
+ help="수행할 작업 (생략 시 목록)")
1141
+ p_pipe.add_argument("path", nargs="?", help="프로젝트 경로 (하위 호환)")
1142
+ p_pipe.add_argument("--project", "-p", help="프로젝트 이름 또는 ID (create용)")
1143
+ p_pipe.add_argument("--id", help="파이프라인 ID")
1144
+ p_pipe.add_argument("--cmd", "-c", dest="cmd", help="Claude에게 전달할 명령어 (create용)")
1145
+ p_pipe.add_argument("--interval", "-i", help="반복 간격 예: 5m, 1h (create용, 생략 시 1회)")
1146
+ p_pipe.add_argument("--name", help="파이프라인 이름")
1147
+ p_pipe.add_argument("--phase", help="초기화할 단계 (reset용)")
1148
+ p_pipe.add_argument("--force", action="store_true", help="중복 체크 무시하고 강제 실행")
1149
+
1150
+ return parser
1151
+
1152
+
1153
+ # ══════════════════════════════════════════════════════════════
1154
+ # 메인
1155
+ # ══════════════════════════════════════════════════════════════
1156
+
1157
+ DISPATCH = {
1158
+ "status": cmd_status,
1159
+ "send": cmd_send,
1160
+ "jobs": cmd_jobs,
1161
+ "job": cmd_job,
1162
+ "sessions": cmd_sessions,
1163
+ "config": cmd_config,
1164
+ "service": cmd_service,
1165
+ "dirs": cmd_dirs,
1166
+ "mkdir": cmd_mkdir,
1167
+ "upload": cmd_upload,
1168
+ "project": cmd_project,
1169
+ "pipeline": cmd_pipeline,
1170
+ "summary": cmd_summary,
1171
+ "stats": cmd_stats,
1172
+ "health": cmd_health,
1173
+ "watchdog": cmd_watchdog,
1174
+ }
1175
+
1176
+
1177
+ def main():
1178
+ parser = build_parser()
1179
+ args = parser.parse_args()
1180
+ handler = DISPATCH.get(args.command)
1181
+ if handler:
1182
+ handler(args)
1183
+ else:
1184
+ parser.print_help()
1185
+ sys.exit(1)
1186
+
1187
+
1188
+ if __name__ == "__main__":
1189
+ main()