claude-controller 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ctl ADDED
@@ -0,0 +1,867 @@
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
+
40
+ Output: 기본 JSON. --pretty 플래그로 포맷팅된 출력.
41
+ """
42
+
43
+ import argparse
44
+ import base64
45
+ import json
46
+ import os
47
+ import sys
48
+ import time
49
+ from pathlib import Path
50
+
51
+ # ── 경로 설정 ──
52
+ CONTROLLER_DIR = Path(__file__).resolve().parent.parent
53
+ sys.path.insert(0, str(CONTROLLER_DIR / "web"))
54
+
55
+ from config import (
56
+ LOGS_DIR, UPLOADS_DIR, DATA_DIR,
57
+ RECENT_DIRS_FILE, SETTINGS_FILE, SESSIONS_DIR,
58
+ CLAUDE_PROJECTS_DIR,
59
+ )
60
+ from jobs import get_all_jobs, get_job_result, send_to_fifo, start_controller_service, stop_controller_service
61
+ from checkpoint import get_job_checkpoints, rewind_job
62
+ from utils import parse_meta_file, is_service_running, cwd_to_project_dir, scan_claude_sessions
63
+ from projects import list_projects, get_project, add_project, create_project, remove_project, update_project
64
+ from pipeline import (
65
+ list_pipelines, get_pipeline_status, create_pipeline, delete_pipeline,
66
+ run_next, force_run, reset_phase, tick, tick_all,
67
+ )
68
+
69
+
70
+ # ══════════════════════════════════════════════════════════════
71
+ # 출력 헬퍼
72
+ # ══════════════════════════════════════════════════════════════
73
+
74
+ def out(data, pretty=False):
75
+ """JSON 출력. pretty=True면 들여쓰기."""
76
+ indent = 2 if pretty else None
77
+ print(json.dumps(data, ensure_ascii=False, indent=indent))
78
+
79
+
80
+ def err(message, code=1):
81
+ """에러 출력 후 종료."""
82
+ print(json.dumps({"error": message}, ensure_ascii=False), file=sys.stderr)
83
+ sys.exit(code)
84
+
85
+
86
+ # ══════════════════════════════════════════════════════════════
87
+ # 명령어 핸들러
88
+ # ══════════════════════════════════════════════════════════════
89
+
90
+ def cmd_status(args):
91
+ """서비스 상태 확인."""
92
+ running, pid = is_service_running()
93
+ out({"running": running, "pid": pid}, args.pretty)
94
+
95
+
96
+ def cmd_send(args):
97
+ """프롬프트 전송."""
98
+ prompt = args.prompt
99
+ if not prompt:
100
+ err("프롬프트를 입력하세요")
101
+
102
+ images = None
103
+ if args.image:
104
+ images = []
105
+ for img_path in args.image:
106
+ p = Path(img_path).resolve()
107
+ if not p.exists():
108
+ err(f"파일을 찾을 수 없습니다: {img_path}")
109
+ images.append(str(p))
110
+
111
+ session = None
112
+ if args.session:
113
+ session = {"id": args.session, "mode": args.mode or "resume"}
114
+
115
+ result, error = send_to_fifo(
116
+ prompt,
117
+ cwd=args.cwd or os.getcwd(),
118
+ job_id=args.id or None,
119
+ images=images,
120
+ session=session,
121
+ )
122
+ if error:
123
+ err(error)
124
+ out(result, args.pretty)
125
+
126
+
127
+ def cmd_jobs(args):
128
+ """작업 목록 또는 일괄 삭제."""
129
+ if args.action == "purge":
130
+ deleted = []
131
+ for mf in list(LOGS_DIR.glob("job_*.meta")):
132
+ meta = parse_meta_file(mf)
133
+ if not meta:
134
+ continue
135
+ status = meta.get("STATUS", "")
136
+ if status in ("done", "failed"):
137
+ job_id = meta.get("JOB_ID", "")
138
+ out_file = LOGS_DIR / f"job_{job_id}.out"
139
+ try:
140
+ mf.unlink()
141
+ if out_file.exists():
142
+ out_file.unlink()
143
+ deleted.append(job_id)
144
+ except OSError:
145
+ pass
146
+ out({"deleted": deleted, "count": len(deleted)}, args.pretty)
147
+ else:
148
+ jobs = get_all_jobs()
149
+ out(jobs, args.pretty)
150
+
151
+
152
+ def cmd_job(args):
153
+ """단일 작업 조작 (결과/스트림/체크포인트/되감기/삭제)."""
154
+ job_id = args.job_id
155
+
156
+ # ── delete ──
157
+ if args.action == "delete":
158
+ meta_file = LOGS_DIR / f"job_{job_id}.meta"
159
+ out_file = LOGS_DIR / f"job_{job_id}.out"
160
+ if not meta_file.exists():
161
+ err("작업을 찾을 수 없습니다")
162
+ meta = parse_meta_file(meta_file)
163
+ if meta.get("STATUS") == "running" and meta.get("PID"):
164
+ try:
165
+ os.kill(int(meta["PID"]), 0)
166
+ err("실행 중인 작업은 삭제할 수 없습니다")
167
+ except (ProcessLookupError, ValueError, OSError):
168
+ pass
169
+ try:
170
+ meta_file.unlink()
171
+ if out_file.exists():
172
+ out_file.unlink()
173
+ out({"deleted": True, "job_id": job_id}, args.pretty)
174
+ except OSError as e:
175
+ err(f"삭제 실패: {e}")
176
+
177
+ # ── stream ──
178
+ elif args.action == "stream":
179
+ out_file = LOGS_DIR / f"job_{job_id}.out"
180
+ meta_file = LOGS_DIR / f"job_{job_id}.meta"
181
+ if not meta_file.exists():
182
+ err("작업을 찾을 수 없습니다")
183
+ if not out_file.exists():
184
+ out({"events": [], "offset": 0, "done": False}, args.pretty)
185
+ return
186
+
187
+ offset = args.offset or 0
188
+ events = []
189
+ try:
190
+ with open(out_file, "r") as f:
191
+ f.seek(offset)
192
+ for raw_line in f:
193
+ if '"type":"assistant"' not in raw_line and '"type":"result"' not in raw_line:
194
+ continue
195
+ try:
196
+ evt = json.loads(raw_line)
197
+ evt_type = evt.get("type", "")
198
+ if evt_type == "assistant":
199
+ msg = evt.get("message", {})
200
+ content = msg.get("content", [])
201
+ text_parts = [c.get("text", "") for c in content if c.get("type") == "text"]
202
+ if text_parts:
203
+ events.append({"type": "text", "text": "".join(text_parts)})
204
+ for tp in content:
205
+ if tp.get("type") == "tool_use":
206
+ events.append({
207
+ "type": "tool_use",
208
+ "tool": tp.get("name", ""),
209
+ "input": str(tp.get("input", ""))[:200],
210
+ })
211
+ elif evt_type == "result":
212
+ events.append({
213
+ "type": "result",
214
+ "result": evt.get("result", ""),
215
+ "cost_usd": evt.get("total_cost_usd"),
216
+ "duration_ms": evt.get("duration_ms"),
217
+ "is_error": evt.get("is_error", False),
218
+ "session_id": evt.get("session_id", ""),
219
+ })
220
+ except json.JSONDecodeError:
221
+ continue
222
+ new_offset = f.tell()
223
+ except OSError as e:
224
+ err(f"스트림 읽기 실패: {e}")
225
+
226
+ meta = parse_meta_file(meta_file)
227
+ done = meta.get("STATUS", "") in ("done", "failed")
228
+ out({"events": events, "offset": new_offset, "done": done}, args.pretty)
229
+
230
+ # ── checkpoints ──
231
+ elif args.action == "checkpoints":
232
+ checkpoints, error = get_job_checkpoints(job_id)
233
+ if error:
234
+ err(error)
235
+ out(checkpoints, args.pretty)
236
+
237
+ # ── rewind ──
238
+ elif args.action == "rewind":
239
+ if not args.checkpoint:
240
+ err("--checkpoint 해시가 필요합니다")
241
+ if not args.prompt:
242
+ err("--prompt 가 필요합니다")
243
+ result, error = rewind_job(job_id, args.checkpoint, args.prompt)
244
+ if error:
245
+ err(error)
246
+ out(result, args.pretty)
247
+
248
+ # ── 기본: 결과 조회 ──
249
+ else:
250
+ result, error = get_job_result(job_id)
251
+ if error:
252
+ err(error)
253
+ out(result, args.pretty)
254
+
255
+
256
+ def cmd_sessions(args):
257
+ """세션 목록 조회."""
258
+ seen = {}
259
+ filter_cwd = args.cwd
260
+
261
+ if filter_cwd:
262
+ proj_name = cwd_to_project_dir(filter_cwd)
263
+ project_dirs = [CLAUDE_PROJECTS_DIR / proj_name]
264
+ else:
265
+ if CLAUDE_PROJECTS_DIR.exists():
266
+ all_dirs = sorted(
267
+ (d for d in CLAUDE_PROJECTS_DIR.iterdir() if d.is_dir()),
268
+ key=lambda d: d.stat().st_mtime,
269
+ reverse=True,
270
+ )
271
+ project_dirs = all_dirs[:15]
272
+ else:
273
+ project_dirs = []
274
+
275
+ for pd in project_dirs:
276
+ native = scan_claude_sessions(pd, limit=60)
277
+ for sid, info in native.items():
278
+ if sid not in seen:
279
+ seen[sid] = info
280
+
281
+ # Job meta 파일에서 보강
282
+ if LOGS_DIR.exists():
283
+ meta_files = sorted(
284
+ LOGS_DIR.glob("job_*.meta"),
285
+ key=lambda f: int(f.stem.split("_")[1]),
286
+ reverse=True,
287
+ )
288
+ for mf in meta_files:
289
+ meta = parse_meta_file(mf)
290
+ if not meta:
291
+ continue
292
+ sid = meta.get("SESSION_ID", "").strip()
293
+ if not sid:
294
+ continue
295
+ status = meta.get("STATUS", "unknown")
296
+ if status == "running" and meta.get("PID"):
297
+ try:
298
+ os.kill(int(meta["PID"]), 0)
299
+ except (ProcessLookupError, ValueError, OSError):
300
+ status = "done"
301
+
302
+ job_id = meta.get("JOB_ID", "")
303
+ entry = {
304
+ "session_id": sid,
305
+ "job_id": job_id,
306
+ "prompt": meta.get("PROMPT", ""),
307
+ "timestamp": meta.get("CREATED_AT", ""),
308
+ "status": status,
309
+ "cwd": meta.get("CWD", ""),
310
+ }
311
+ if sid not in seen:
312
+ seen[sid] = entry
313
+ else:
314
+ existing = seen[sid]
315
+ if existing.get("job_id") is None:
316
+ existing.update({"job_id": job_id, "status": status})
317
+
318
+ # history.log 보충
319
+ history_file = SESSIONS_DIR / "history.log"
320
+ if history_file.exists():
321
+ try:
322
+ for line in history_file.read_text("utf-8").strip().split("\n"):
323
+ parts = line.split("|", 2)
324
+ if len(parts) >= 2:
325
+ ts, sid = parts[0].strip(), parts[1].strip()
326
+ if not sid or sid in seen:
327
+ continue
328
+ prompt = parts[2].strip() if len(parts) > 2 else ""
329
+ seen[sid] = {
330
+ "session_id": sid, "job_id": None,
331
+ "prompt": prompt, "timestamp": ts,
332
+ "status": "done", "cwd": None,
333
+ }
334
+ except OSError:
335
+ pass
336
+
337
+ # cwd 필터
338
+ if filter_cwd:
339
+ norm = os.path.normpath(filter_cwd)
340
+ seen = {
341
+ sid: s for sid, s in seen.items()
342
+ if s.get("cwd") and os.path.normpath(s["cwd"]) == norm
343
+ }
344
+
345
+ sessions = sorted(seen.values(), key=lambda s: s.get("timestamp") or "", reverse=True)
346
+ out(sessions[:50], args.pretty)
347
+
348
+
349
+ def cmd_config(args):
350
+ """설정 조회 또는 변경."""
351
+ defaults = {
352
+ "skip_permissions": True,
353
+ "allowed_tools": "Bash,Read,Write,Edit,Glob,Grep,Agent,NotebookEdit,WebFetch,WebSearch",
354
+ "model": "",
355
+ "max_jobs": 10,
356
+ "append_system_prompt": "",
357
+ "target_repo": "",
358
+ "base_branch": "main",
359
+ "checkpoint_interval": 5,
360
+ "locale": "ko",
361
+ }
362
+
363
+ try:
364
+ if SETTINGS_FILE.exists():
365
+ saved = json.loads(SETTINGS_FILE.read_text("utf-8"))
366
+ defaults.update(saved)
367
+ except (json.JSONDecodeError, OSError):
368
+ pass
369
+
370
+ if args.action == "set":
371
+ if not args.key or args.value is None:
372
+ err("key와 value가 필요합니다")
373
+
374
+ key = args.key
375
+ value = args.value
376
+ allowed_keys = {
377
+ "skip_permissions", "allowed_tools", "model", "max_jobs",
378
+ "append_system_prompt", "target_repo", "base_branch",
379
+ "checkpoint_interval", "locale",
380
+ }
381
+ if key not in allowed_keys:
382
+ err(f"허용되지 않는 설정 키: {key}. 허용: {', '.join(sorted(allowed_keys))}")
383
+
384
+ # 타입 변환
385
+ if value.lower() in ("true", "false"):
386
+ value = value.lower() == "true"
387
+ else:
388
+ try:
389
+ value = int(value)
390
+ except ValueError:
391
+ pass
392
+
393
+ defaults[key] = value
394
+ try:
395
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
396
+ SETTINGS_FILE.write_text(
397
+ json.dumps(defaults, ensure_ascii=False, indent=2), "utf-8"
398
+ )
399
+ out({"ok": True, "config": defaults}, args.pretty)
400
+ except OSError as e:
401
+ err(f"설정 저장 실패: {e}")
402
+ else:
403
+ out(defaults, args.pretty)
404
+
405
+
406
+ def cmd_service(args):
407
+ """서비스 시작/종료."""
408
+ if args.action == "start":
409
+ ok, pid = start_controller_service()
410
+ if ok:
411
+ out({"started": True, "pid": pid}, args.pretty)
412
+ else:
413
+ err("서비스 시작 실패")
414
+ elif args.action == "stop":
415
+ ok, error = stop_controller_service()
416
+ if ok:
417
+ out({"stopped": True}, args.pretty)
418
+ else:
419
+ err(error or "서비스 종료 실패")
420
+ else:
421
+ err(f"알 수 없는 서비스 액션: {args.action}")
422
+
423
+
424
+ def cmd_dirs(args):
425
+ """디렉토리 탐색."""
426
+ dir_path = os.path.abspath(os.path.expanduser(args.path or os.getcwd()))
427
+ if not os.path.isdir(dir_path):
428
+ err("디렉토리가 아닙니다")
429
+
430
+ entries = []
431
+ try:
432
+ items = sorted(os.listdir(dir_path))
433
+ except PermissionError:
434
+ err("접근 권한 없음")
435
+
436
+ parent = os.path.dirname(dir_path)
437
+ if parent != dir_path:
438
+ entries.append({"name": "..", "path": parent, "type": "dir"})
439
+
440
+ for item in items:
441
+ if item.startswith("."):
442
+ continue
443
+ full = os.path.join(dir_path, item)
444
+ entry = {"name": item, "path": full}
445
+ if os.path.isdir(full):
446
+ entry["type"] = "dir"
447
+ else:
448
+ entry["type"] = "file"
449
+ try:
450
+ entry["size"] = os.path.getsize(full)
451
+ except OSError:
452
+ entry["size"] = 0
453
+ entries.append(entry)
454
+
455
+ out({"current": dir_path, "entries": entries}, args.pretty)
456
+
457
+
458
+ def cmd_mkdir(args):
459
+ """디렉토리 생성."""
460
+ parent = args.parent
461
+ name = args.name
462
+
463
+ if "/" in name or "\\" in name or name in (".", ".."):
464
+ err("잘못된 디렉토리 이름입니다")
465
+
466
+ parent = os.path.abspath(os.path.expanduser(parent))
467
+ if not os.path.isdir(parent):
468
+ err("상위 디렉토리가 존재하지 않습니다")
469
+
470
+ new_dir = os.path.join(parent, name)
471
+ if os.path.exists(new_dir):
472
+ err("이미 존재하는 이름입니다")
473
+
474
+ try:
475
+ os.makedirs(new_dir)
476
+ out({"ok": True, "path": new_dir}, args.pretty)
477
+ except PermissionError:
478
+ err("접근 권한 없음")
479
+ except OSError as e:
480
+ err(f"디렉토리 생성 실패: {e}")
481
+
482
+
483
+ def cmd_upload(args):
484
+ """파일 업로드."""
485
+ file_path = Path(args.file).resolve()
486
+ if not file_path.exists():
487
+ err(f"파일을 찾을 수 없습니다: {args.file}")
488
+
489
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
490
+ ext = file_path.suffix.lower()
491
+ prefix = "img" if ext in IMAGE_EXTS else "file"
492
+ safe_name = f"{prefix}_{int(time.time())}_{os.getpid()}_{id(file_path) % 10000}{ext}"
493
+
494
+ UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
495
+ dest = UPLOADS_DIR / safe_name
496
+
497
+ try:
498
+ raw = file_path.read_bytes()
499
+ dest.write_bytes(raw)
500
+ out({
501
+ "path": str(dest),
502
+ "filename": safe_name,
503
+ "originalName": file_path.name,
504
+ "size": len(raw),
505
+ "isImage": ext in IMAGE_EXTS,
506
+ }, args.pretty)
507
+ except OSError as e:
508
+ err(f"업로드 실패: {e}")
509
+
510
+
511
+ def cmd_project(args):
512
+ """프로젝트 관리."""
513
+ action = args.action
514
+
515
+ if action == "list" or action is None:
516
+ out(list_projects(), args.pretty)
517
+
518
+ elif action == "add":
519
+ if not args.path:
520
+ err("경로를 지정하세요")
521
+ project, error = add_project(
522
+ args.path,
523
+ name=args.name or "",
524
+ description=args.desc or "",
525
+ )
526
+ if error:
527
+ err(error)
528
+ out(project, args.pretty)
529
+
530
+ elif action == "create":
531
+ if not args.path:
532
+ err("경로를 지정하세요")
533
+ project, error = create_project(
534
+ args.path,
535
+ name=args.name or "",
536
+ description=args.desc or "",
537
+ init_git=not args.no_git,
538
+ )
539
+ if error:
540
+ err(error)
541
+ out(project, args.pretty)
542
+
543
+ elif action == "info":
544
+ if not args.id:
545
+ err("프로젝트 ID를 지정하세요")
546
+ project, error = get_project(args.id)
547
+ if error:
548
+ err(error)
549
+ out(project, args.pretty)
550
+
551
+ elif action == "remove":
552
+ if not args.id:
553
+ err("프로젝트 ID를 지정하세요")
554
+ result, error = remove_project(args.id)
555
+ if error:
556
+ err(error)
557
+ out({"removed": True, "project": result}, args.pretty)
558
+
559
+ elif action == "update":
560
+ if not args.id:
561
+ err("프로젝트 ID를 지정하세요")
562
+ project, error = update_project(
563
+ args.id,
564
+ name=args.name,
565
+ description=args.desc,
566
+ )
567
+ if error:
568
+ err(error)
569
+ out(project, args.pretty)
570
+
571
+
572
+ def _resolve_project_path(args) -> str:
573
+ """--project (이름/ID) 또는 path에서 프로젝트 경로를 찾는다."""
574
+ projects = list_projects()
575
+
576
+ # --project 옵션: 이름 또는 ID로 검색
577
+ key = getattr(args, "project", None)
578
+ if key:
579
+ for p in projects:
580
+ if p["id"] == key or p.get("name", "").lower() == key.lower():
581
+ return p["path"]
582
+ # 부분 매칭 시도
583
+ for p in projects:
584
+ if key.lower() in p.get("name", "").lower():
585
+ return p["path"]
586
+ err(f"프로젝트를 찾을 수 없습니다: '{key}'\n"
587
+ f"등록된 프로젝트: {', '.join(p['name'] for p in projects) or '(없음)'}\n"
588
+ f"ctl project add <경로> 로 먼저 등록하세요")
589
+
590
+ # path 직접 지정 (하위 호환)
591
+ if getattr(args, "path", None):
592
+ return args.path
593
+
594
+ # 둘 다 없으면 목록 안내
595
+ if not projects:
596
+ err("등록된 프로젝트가 없습니다.\n"
597
+ "ctl project add <경로> --name <이름> 으로 프로젝트를 등록하세요")
598
+ lines = ["--project 로 프로젝트를 선택하세요:", ""]
599
+ for p in projects:
600
+ lines.append(f" {p['name']:<20s} {p['id']}")
601
+ lines.append("")
602
+ lines.append("예: ctl pipeline create --project <이름> --goal \"...\"")
603
+ err("\n".join(lines))
604
+
605
+
606
+ def _tick_all_human(results: list[dict]) -> str:
607
+ """tick-all 결과를 사람이 읽기 좋은 한 줄 요약으로 변환."""
608
+ if not results:
609
+ return "활성 파이프라인 없음"
610
+ lines = []
611
+ for r in results:
612
+ name = r.get("name", r["pipeline_id"])
613
+ phase = r.get("phase", "?")
614
+ if r.get("error"):
615
+ lines.append(f" ✗ {name} [{phase}] {r['error']}")
616
+ elif r.get("result"):
617
+ action = r["result"].get("action", "")
618
+ msg = r["result"].get("message", action)
619
+ lines.append(f" → {name} [{phase}] {msg}")
620
+ else:
621
+ lines.append(f" - {name} [{phase}]")
622
+ return "\n".join(lines)
623
+
624
+
625
+ def cmd_pipeline(args):
626
+ """파이프라인 관리 및 실행."""
627
+ action = args.action
628
+
629
+ if action == "list" or action is None:
630
+ pipes = list_pipelines()
631
+ if not args.pretty and not pipes:
632
+ print("[]")
633
+ return
634
+ if args.pretty:
635
+ # 사람 친화적 목록
636
+ if not pipes:
637
+ print("등록된 파이프라인이 없습니다.")
638
+ return
639
+ for p in pipes:
640
+ status_label = {"done": "완료", "running": "실행중", "failed": "실패", "idle": "대기"}.get(p.get("status", ""), p.get("status", ""))
641
+ interval_label = f" [{p['interval']}]" if p.get("interval") else ""
642
+ print(f" {p['id']} [{status_label:<6s}]{interval_label} {p.get('name', '')}")
643
+ print(f" 명령: {(p.get('command', '') or '')[:60]}")
644
+ return
645
+ out(pipes, False)
646
+
647
+ elif action == "create":
648
+ project_path = _resolve_project_path(args)
649
+ command = args.cmd
650
+ if not command:
651
+ err("--cmd (-c) 로 명령어를 지정하세요\n"
652
+ "예: ctl pipeline create --project <이름> --cmd \"테스트 실행\" [--interval 5m]")
653
+ pipe, error = create_pipeline(
654
+ project_path,
655
+ command=command,
656
+ interval=args.interval or "",
657
+ name=args.name or "",
658
+ )
659
+ if error:
660
+ err(error)
661
+ out(pipe, args.pretty)
662
+
663
+ elif action == "status":
664
+ if not args.id:
665
+ err("파이프라인 ID를 지정하세요 (--id)")
666
+ result, error = get_pipeline_status(args.id)
667
+ if error:
668
+ err(error)
669
+ if args.pretty:
670
+ status_labels = {"done": "완료", "running": "실행 중", "failed": "실패", "idle": "대기"}
671
+ print(f"파이프라인: {result['name']} [{status_labels.get(result['status'], result['status'])}]")
672
+ print(f"명령어: {result['command']}")
673
+ print(f"경로: {result['project_path']}")
674
+ if result.get("interval"):
675
+ print(f"간격: {result['interval']}")
676
+ print(f"실행 횟수: {result.get('run_count', 0)}")
677
+ if result.get("last_run"):
678
+ print(f"마지막 실행: {result['last_run']}")
679
+ if result.get("last_result"):
680
+ print(f"마지막 결과: {result['last_result'][:100]}")
681
+ if result.get("last_error"):
682
+ print(f"에러: {result['last_error']}")
683
+ if result.get("job_status"):
684
+ js = result["job_status"]
685
+ print(f"현재 작업: job#{js['job_id']} [{js.get('status', '?')}]")
686
+ return
687
+ out(result, False)
688
+
689
+ elif action == "run":
690
+ if not args.id:
691
+ err("파이프라인 ID를 지정하세요 (--id)")
692
+ if args.force:
693
+ result, error = force_run(args.id)
694
+ else:
695
+ result, error = run_next(args.id)
696
+ if error:
697
+ err(error)
698
+ out(result, args.pretty)
699
+
700
+ elif action == "tick":
701
+ if args.id:
702
+ result, error = tick(args.id)
703
+ if error:
704
+ err(error)
705
+ out(result, args.pretty)
706
+ else:
707
+ # --id 없으면 tick-all
708
+ results = tick_all()
709
+ if args.pretty:
710
+ print(_tick_all_human(results))
711
+ else:
712
+ out(results, False)
713
+
714
+ elif action == "tick-all":
715
+ results = tick_all()
716
+ if args.pretty:
717
+ print(_tick_all_human(results))
718
+ else:
719
+ out(results, False)
720
+
721
+ elif action == "reset":
722
+ if not args.id:
723
+ err("파이프라인 ID를 지정하세요 (--id)")
724
+ result, error = reset_phase(args.id, phase=args.phase)
725
+ if error:
726
+ err(error)
727
+ out(result, args.pretty)
728
+
729
+ elif action == "delete":
730
+ if not args.id:
731
+ err("파이프라인 ID를 지정하세요 (--id)")
732
+ result, error = delete_pipeline(args.id)
733
+ if error:
734
+ err(error)
735
+ out({"deleted": True, "pipeline": result}, args.pretty)
736
+
737
+
738
+
739
+ # ══════════════════════════════════════════════════════════════
740
+ # argparse 설정
741
+ # ══════════════════════════════════════════════════════════════
742
+
743
+ def build_parser():
744
+ # 공통 옵션을 모든 서브커맨드에 상속
745
+ common = argparse.ArgumentParser(add_help=False)
746
+ common.add_argument("--pretty", action="store_true", help="JSON 출력 포맷팅")
747
+
748
+ parser = argparse.ArgumentParser(
749
+ prog="ctl",
750
+ description="Controller CLI — 웹 대시보드 기능을 커맨드라인에서 사용",
751
+ )
752
+ sub = parser.add_subparsers(dest="command", required=True)
753
+
754
+ # ── status ──
755
+ sub.add_parser("status", parents=[common], help="서비스 상태 확인")
756
+
757
+ # ── send ──
758
+ p_send = sub.add_parser("send", parents=[common], help="프롬프트 전송")
759
+ p_send.add_argument("prompt", help="전송할 프롬프트")
760
+ p_send.add_argument("--cwd", help="작업 디렉토리")
761
+ p_send.add_argument("--id", help="사용자 지정 작업 ID")
762
+ p_send.add_argument("--session", help="기존 세션 ID (재개/포크)")
763
+ p_send.add_argument("--mode", choices=["resume", "fork", "new"], help="세션 모드")
764
+ p_send.add_argument("--image", action="append", help="첨부할 이미지 경로 (반복 가능)")
765
+
766
+ # ── jobs ──
767
+ p_jobs = sub.add_parser("jobs", parents=[common], help="작업 목록 조회")
768
+ p_jobs.add_argument("action", nargs="?", choices=["purge"], help="purge: 완료된 작업 삭제")
769
+
770
+ # ── job ──
771
+ p_job = sub.add_parser("job", parents=[common], help="단일 작업 조작")
772
+ p_job.add_argument("job_id", help="작업 ID")
773
+ p_job.add_argument("action", nargs="?",
774
+ choices=["stream", "checkpoints", "rewind", "delete"],
775
+ help="수행할 작업 (생략 시 결과 조회)")
776
+ p_job.add_argument("--offset", type=int, default=0, help="스트림 오프셋 (stream용)")
777
+ p_job.add_argument("--checkpoint", help="체크포인트 해시 (rewind용)")
778
+ p_job.add_argument("--prompt", dest="prompt", help="리와인드 프롬프트 (rewind용)")
779
+
780
+ # ── sessions ──
781
+ p_sessions = sub.add_parser("sessions", parents=[common], help="세션 목록 조회")
782
+ p_sessions.add_argument("--cwd", help="CWD 필터")
783
+
784
+ # ── config ──
785
+ p_config = sub.add_parser("config", parents=[common], help="설정 조회/변경")
786
+ p_config.add_argument("action", nargs="?", choices=["set"], help="set: 설정 변경")
787
+ p_config.add_argument("key", nargs="?", help="설정 키")
788
+ p_config.add_argument("value", nargs="?", help="설정 값")
789
+
790
+ # ── service ──
791
+ p_service = sub.add_parser("service", parents=[common], help="서비스 시작/종료")
792
+ p_service.add_argument("action", choices=["start", "stop"], help="start 또는 stop")
793
+
794
+ # ── dirs ──
795
+ p_dirs = sub.add_parser("dirs", parents=[common], help="디렉토리 탐색")
796
+ p_dirs.add_argument("path", nargs="?", help="탐색할 경로 (기본: 현재 디렉토리)")
797
+
798
+ # ── mkdir ──
799
+ p_mkdir = sub.add_parser("mkdir", parents=[common], help="디렉토리 생성")
800
+ p_mkdir.add_argument("parent", help="상위 디렉토리 경로")
801
+ p_mkdir.add_argument("name", help="생성할 디렉토리 이름")
802
+
803
+ # ── upload ──
804
+ p_upload = sub.add_parser("upload", parents=[common], help="파일 업로드")
805
+ p_upload.add_argument("file", help="업로드할 파일 경로")
806
+
807
+ # ── project ──
808
+ p_proj = sub.add_parser("project", parents=[common], help="프로젝트 관리")
809
+ p_proj.add_argument("action", nargs="?",
810
+ choices=["list", "add", "create", "info", "remove", "update"],
811
+ help="수행할 작업 (생략 시 목록)")
812
+ p_proj.add_argument("path", nargs="?", help="프로젝트 경로 (add/create용)")
813
+ p_proj.add_argument("--id", help="프로젝트 ID (info/remove/update용)")
814
+ p_proj.add_argument("--name", help="프로젝트 이름")
815
+ p_proj.add_argument("--desc", help="프로젝트 설명")
816
+ p_proj.add_argument("--no-git", action="store_true", help="git init 건너뛰기 (create용)")
817
+
818
+ # ── pipeline ──
819
+ p_pipe = sub.add_parser("pipeline", parents=[common], help="파이프라인 관리/실행")
820
+ p_pipe.add_argument("action", nargs="?",
821
+ choices=["list", "create", "status", "run", "tick", "tick-all", "reset", "delete"],
822
+ help="수행할 작업 (생략 시 목록)")
823
+ p_pipe.add_argument("path", nargs="?", help="프로젝트 경로 (하위 호환)")
824
+ p_pipe.add_argument("--project", "-p", help="프로젝트 이름 또는 ID (create용)")
825
+ p_pipe.add_argument("--id", help="파이프라인 ID")
826
+ p_pipe.add_argument("--cmd", "-c", dest="cmd", help="Claude에게 전달할 명령어 (create용)")
827
+ p_pipe.add_argument("--interval", "-i", help="반복 간격 예: 5m, 1h (create용, 생략 시 1회)")
828
+ p_pipe.add_argument("--name", help="파이프라인 이름")
829
+ p_pipe.add_argument("--phase", help="초기화할 단계 (reset용)")
830
+ p_pipe.add_argument("--force", action="store_true", help="중복 체크 무시하고 강제 실행")
831
+
832
+ return parser
833
+
834
+
835
+ # ══════════════════════════════════════════════════════════════
836
+ # 메인
837
+ # ══════════════════════════════════════════════════════════════
838
+
839
+ DISPATCH = {
840
+ "status": cmd_status,
841
+ "send": cmd_send,
842
+ "jobs": cmd_jobs,
843
+ "job": cmd_job,
844
+ "sessions": cmd_sessions,
845
+ "config": cmd_config,
846
+ "service": cmd_service,
847
+ "dirs": cmd_dirs,
848
+ "mkdir": cmd_mkdir,
849
+ "upload": cmd_upload,
850
+ "project": cmd_project,
851
+ "pipeline": cmd_pipeline,
852
+ }
853
+
854
+
855
+ def main():
856
+ parser = build_parser()
857
+ args = parser.parse_args()
858
+ handler = DISPATCH.get(args.command)
859
+ if handler:
860
+ handler(args)
861
+ else:
862
+ parser.print_help()
863
+ sys.exit(1)
864
+
865
+
866
+ if __name__ == "__main__":
867
+ main()