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 +867 -0
- package/bin/native-app.py +1 -1
- package/package.json +7 -5
- package/web/handler.py +190 -467
- package/web/handler_fs.py +152 -0
- package/web/handler_jobs.py +249 -0
- package/web/handler_sessions.py +132 -0
- package/web/jobs.py +9 -1
- package/web/pipeline.py +349 -0
- package/web/projects.py +192 -0
- package/web/static/api.js +54 -0
- package/web/static/app.js +17 -1937
- package/web/static/attachments.js +144 -0
- package/web/static/base.css +458 -0
- package/web/static/context.js +194 -0
- package/web/static/dirs.js +246 -0
- package/web/static/form.css +762 -0
- package/web/static/i18n.js +337 -0
- package/web/static/index.html +77 -11
- package/web/static/jobs.css +580 -0
- package/web/static/jobs.js +434 -0
- package/web/static/pipeline.css +31 -0
- package/web/static/pipelines.js +252 -0
- package/web/static/send.js +113 -0
- package/web/static/settings-style.css +260 -0
- package/web/static/settings.js +45 -0
- package/web/static/stream.js +311 -0
- package/web/static/utils.js +79 -0
- package/web/static/styles.css +0 -1922
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()
|