Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: claude-code-session
|
||||
description: Claude Code Remote Control 세션을 다중 인스턴스로 관리. 프로필(이름↔workdir)을 등록해두고 자연어로 세션을 열고 닫고 목록을 본다. Use when the owner says "클로드 세션 열어줘"·"X 세션 열어줘"·"openclaw-2 닫아줘"·"세션 목록"·"프로필 추가/삭제/수정", or whenever a request needs a new Python script / debugging / refactoring that 클로 cannot safely do itself — queue a spec under `~/.openclaw/dev-requests/` then open a session so the owner can connect from a phone and have Claude Code handle it.
|
||||
---
|
||||
|
||||
# Claude Code Remote Control 세션 (다중 세션 + 프로필)
|
||||
|
||||
## 데이터 모델
|
||||
|
||||
- **프로필**: 이름 ↔ workdir 매핑. `~/.openclaw/state/claude_sessions.json`에 JSON으로 저장. 첫 실행 시 `openclaw → ~/.openclaw` 자동 시드.
|
||||
- **세션**: 프로필을 기반으로 떠있는 클로드 데몬. 기본 프로필은 라벨 `ai.claude-session.YYMMDD-<N>`로 생성한다. plist 파일 존재 = 등록됨, `launchctl print state=running` = 떠있음. 2026-05-20 이전 구형 `<profile>-<N>`, `openclaw-YYYYMMDD-<N>`, `oc-YY-MM-DD-<N>` 세션도 조회·종료 가능.
|
||||
- **번호링**: `open` 호출 시 해당 프로필·날짜에서 빈 번호(1부터) 자동 할당. 닫으면 그 번호 재사용 가능.
|
||||
|
||||
## 툴
|
||||
|
||||
`scripts/session_tool.py` (Python) — 모든 동작은 이 툴 한 개로 처리. 클로는 자연어 요청을 아래 매핑으로 라우팅.
|
||||
|
||||
### 자연어 매핑
|
||||
|
||||
| 관리자님 발화 | 툴 호출 |
|
||||
|---|---|
|
||||
| "클로드 세션 열어줘" / "세션 열어줘" | `session_tool.py session open` (default profile=openclaw, `YYMMDD-N` 자동) |
|
||||
| "X 세션 열어줘" | `session_tool.py session open X` (프로필 X 등록 전이면 안내, 오늘 날짜 + 빈 번호 자동) |
|
||||
| "260520-2 닫아줘" / "클로드2 닫아줘" | `session_tool.py session close 260520-2` |
|
||||
| "세션 목록" / "안 닫힌 세션" | `session_tool.py session list` |
|
||||
| "260520-1 상태" | `session_tool.py session status 260520-1` |
|
||||
| "프로필 목록" / "등록된 폴더" | `session_tool.py profile list` |
|
||||
| "프로필 추가 X /path/to/X" | `session_tool.py profile add X /path/to/X` |
|
||||
| "프로필 삭제 X" | `session_tool.py profile remove X` (활성 세션 있으면 거부) |
|
||||
| "프로필 X 폴더 변경 /new/path" | `session_tool.py profile edit X /new/path` (활성 세션 있으면 거부) |
|
||||
|
||||
이름 매핑 주의: 관리자님이 "클로드N", "openclawN", "openclaw N" 등 다양하게 부르더라도 내부적으로는 오늘 날짜를 넣은 `YYMMDD-<N>` 형식으로 변환해서 호출 (예: 2026-05-20에 "클로드3" → `260520-3`). 단, 기존 구형 `<profile>-<N>`, `openclaw-YYYYMMDD-<N>`, `oc-YY-MM-DD-<N>` 세션명은 그대로 close/status 가능.
|
||||
|
||||
## 운영 메모
|
||||
|
||||
- 세션 plist는 ephemeral — `open` 시 `~/Library/LaunchAgents/ai.claude-session.YYMMDD-<N>.plist` 생성·bootstrap, `close` 시 bootout·plist 삭제. 따라서 LaunchAgents 폴더 스캔이 곧 활성 세션 목록.
|
||||
- 모든 세션 `RunAtLoad=false`, `KeepAlive=false` (on-demand). 자동 종료 옵션은 미구현 (`claude remote-control` 본체가 idle timeout 미지원). 작업 끝나면 `close` 권장.
|
||||
- 로그: `~/.openclaw/logs/claude-session-<session-name>.{log,err.log}`
|
||||
|
||||
## 레거시 세션과의 관계
|
||||
|
||||
- 기존 `ai.openclaw.claude-remote-control` (라벨: `openclaw`, plist: `ai.openclaw.claude-remote-control.plist`)는 본 툴이 건드리지 않음 — `ensure_session.sh`가 그대로 관리.
|
||||
- `session_tool.py session list`에는 참고용으로 `openclaw (legacy)` 줄이 함께 표시됨 (running/stopped 상태만).
|
||||
- 새로 다중 세션 운영하려면 본 툴(`session_tool.py`)만 쓰면 충분. 레거시 세션은 단일 세션으로 유지하거나 `ensure_session.sh close`로 정리해도 무방.
|
||||
|
||||
## 보안
|
||||
|
||||
- Remote Control 접속은 관리자님 Claude 구독 계정 인증 필수 — 외부망 노출 자동 차단
|
||||
- 세션 workdir 안의 자격증명은 그 세션을 통해 모두 노출 가능 — 외부 프로젝트 디렉터리를 프로필로 등록할 때 그 디렉터리에 민감 자료가 있는지 확인 후 등록
|
||||
- 작업 끝나면 `close` 권장 (분실·탈취 시 노출 창 최소화)
|
||||
|
||||
## 개발 요청 큐와의 연계
|
||||
|
||||
추가 진입점 신규 .py 작성·디버깅·리팩토링이 필요한 요청이 들어왔을 때(클로 본인이 직접 처리하면 검증·디버깅 사이클이 부족해 위험): 명세를 `~/.openclaw/dev-requests/<id>.md`에 큐잉한 뒤 `session open openclaw`로 정비공 세션을 띄우고, 관리자님께 "폰/웹에서 접속해서 dev-requests 처리 부탁드립니다" 안내. 또한 응답 안내 시 `~/.openclaw/dev-requests/`에 미처리 명세가 있으면 "대기 중인 개발 요청 N건" 함께 안내.
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# Claude Code Remote Control on-demand controller (클로 스킬용)
|
||||
#
|
||||
# 사용법:
|
||||
# ensure_session.sh open — Remote Control daemon 시작 (이미 떠있으면 안내만)
|
||||
# ensure_session.sh close — daemon 종료
|
||||
# ensure_session.sh status — 현재 상태
|
||||
#
|
||||
# launchd 등록 라벨: ai.openclaw.claude-remote-control (RunAtLoad=false, KeepAlive=false)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LABEL="ai.openclaw.claude-remote-control"
|
||||
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
||||
SERVICE="gui/$UID/$LABEL"
|
||||
|
||||
bootstrap_if_needed() {
|
||||
if ! launchctl print "$SERVICE" >/dev/null 2>&1; then
|
||||
if [ ! -f "$PLIST" ]; then
|
||||
echo "ERROR: plist 없음: $PLIST"
|
||||
exit 1
|
||||
fi
|
||||
launchctl bootstrap "gui/$UID" "$PLIST"
|
||||
fi
|
||||
}
|
||||
|
||||
is_running() {
|
||||
local state
|
||||
state=$(launchctl print "$SERVICE" 2>/dev/null | awk -F'=' '/^[[:space:]]*state[[:space:]]*=/{gsub(/[[:space:]]/,"",$2);print $2;exit}')
|
||||
[ "$state" = "running" ]
|
||||
}
|
||||
|
||||
cmd="${1:-status}"
|
||||
|
||||
case "$cmd" in
|
||||
open|start)
|
||||
bootstrap_if_needed
|
||||
if is_running; then
|
||||
echo "이미 동작 중. claude.ai/code 또는 Claude 앱에서 'openclaw' 세션에 접속하세요."
|
||||
else
|
||||
launchctl kickstart "$SERVICE" >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
if is_running; then
|
||||
echo "Claude Code 원격 세션 시작됨. claude.ai/code 또는 Claude 앱에서 'openclaw' 세션에 접속하세요."
|
||||
else
|
||||
echo "ERROR: 시작 실패. 로그: ~/.openclaw/logs/claude-remote-control.err.log"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
close|stop)
|
||||
if launchctl print "$SERVICE" >/dev/null 2>&1; then
|
||||
launchctl bootout "$SERVICE" 2>/dev/null || true
|
||||
echo "Claude Code 원격 세션 종료됨."
|
||||
else
|
||||
echo "이미 종료 상태."
|
||||
fi
|
||||
;;
|
||||
status)
|
||||
if launchctl print "$SERVICE" >/dev/null 2>&1 && is_running; then
|
||||
echo "동작 중. claude.ai/code 또는 Claude 앱에서 'openclaw' 세션 접속 가능."
|
||||
else
|
||||
echo "종료 상태. 'open' 명령으로 시작 가능."
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {open|close|status}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Claude Code Remote Control 세션 관리 툴 (다중 세션 + 프로필 CRUD).
|
||||
|
||||
데이터 모델:
|
||||
profile: 이름 ↔ workdir 매핑. `~/.openclaw/state/claude_sessions.json`에 저장.
|
||||
session: profile 기반으로 떠있는 클로드 데몬. plist 파일 존재 여부가 곧 세션 등록.
|
||||
라벨 `ai.claude-session.YYMMDD-<N>` (N은 날짜별 1부터 빈 번호 채움).
|
||||
|
||||
서브커맨드:
|
||||
profile list
|
||||
profile add <name> <workdir>
|
||||
profile remove <name>
|
||||
profile edit <name> <new-workdir>
|
||||
session open [profile=openclaw]
|
||||
session close <session-name>
|
||||
session list
|
||||
session status <session-name>
|
||||
|
||||
레거시 `ai.openclaw.claude-remote-control`는 본 툴이 건드리지 않음 (기존
|
||||
ensure_session.sh가 계속 관리). `session list`에서 참고용으로만 노출.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
HOME = Path.home()
|
||||
OPENCLAW_ROOT = HOME / '.openclaw'
|
||||
STATE_DIR = OPENCLAW_ROOT / 'state'
|
||||
STATE_FILE = STATE_DIR / 'claude_sessions.json'
|
||||
LOG_DIR = OPENCLAW_ROOT / 'logs'
|
||||
LA_DIR = HOME / 'Library' / 'LaunchAgents'
|
||||
CLAUDE_BIN = Path(os.environ.get('CLAUDE_BIN', HOME / '.local' / 'bin' / 'claude'))
|
||||
LABEL_PREFIX = 'ai.claude-session.'
|
||||
LEGACY_LABEL = 'ai.openclaw.claude-remote-control'
|
||||
DEFAULT_PROFILE = 'openclaw'
|
||||
DEFAULT_PROFILE_WORKDIR = OPENCLAW_ROOT
|
||||
NAME_RE = re.compile(r'^[a-z][a-z0-9_-]*$')
|
||||
SESSION_RE = re.compile(
|
||||
r'^(?:(?P<date_compact>\d{6})|'
|
||||
r'(?P<profile_compact>[a-z][a-z0-9_-]*?)-(?P<date_compact_profile>\d{6})|'
|
||||
r'(?P<profile_dash>[a-z][a-z0-9_-]*?)-(?P<date_dash>\d{2}-\d{2}-\d{2})|'
|
||||
r'(?P<profile_dated>[a-z][a-z0-9_-]*?)-(?P<date8>\d{8})|'
|
||||
r'(?P<profile_legacy>[a-z][a-z0-9_-]*))-(?P<n>[1-9]\d*)$'
|
||||
)
|
||||
|
||||
|
||||
# ───────── state I/O ─────────
|
||||
|
||||
def _load_state() -> dict:
|
||||
if not STATE_FILE.exists():
|
||||
return {'profiles': {DEFAULT_PROFILE: {'workdir': str(DEFAULT_PROFILE_WORKDIR)}}}
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text())
|
||||
except json.JSONDecodeError as e:
|
||||
raise SystemExit(f'state 파일 파싱 실패: {STATE_FILE} ({e})')
|
||||
data.setdefault('profiles', {})
|
||||
if DEFAULT_PROFILE not in data['profiles']:
|
||||
data['profiles'][DEFAULT_PROFILE] = {'workdir': str(DEFAULT_PROFILE_WORKDIR)}
|
||||
return data
|
||||
|
||||
|
||||
def _save_state(data: dict) -> None:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATE_FILE.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
tmp.replace(STATE_FILE)
|
||||
|
||||
|
||||
def _validate_name(name: str, kind: str = '이름') -> None:
|
||||
if not NAME_RE.match(name):
|
||||
raise SystemExit(f'{kind} 형식 오류: {name!r} (소문자·숫자·`_`·`-`만, 첫 글자는 소문자)')
|
||||
|
||||
|
||||
def _resolve_workdir(workdir: str) -> str:
|
||||
p = Path(workdir).expanduser()
|
||||
if not p.is_dir():
|
||||
raise SystemExit(f'workdir 없음 또는 디렉터리 아님: {p}')
|
||||
return str(p.resolve())
|
||||
|
||||
|
||||
# ───────── launchctl wrappers ─────────
|
||||
|
||||
def _uid() -> int:
|
||||
return os.getuid()
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
try:
|
||||
return datetime.now(ZoneInfo('Asia/Seoul')).strftime('%y%m%d')
|
||||
except Exception:
|
||||
return datetime.now().strftime('%y%m%d')
|
||||
|
||||
|
||||
def _session_name(profile: str, n: int, date: str | None = None) -> str:
|
||||
if date:
|
||||
if profile == DEFAULT_PROFILE:
|
||||
return f'{date}-{n}'
|
||||
return f'{profile}-{date}-{n}'
|
||||
return f'{profile}-{n}'
|
||||
|
||||
|
||||
def _label(session_name: str) -> str:
|
||||
return f'{LABEL_PREFIX}{session_name}'
|
||||
|
||||
|
||||
def _plist_path(session_name: str) -> Path:
|
||||
return LA_DIR / f'{_label(session_name)}.plist'
|
||||
|
||||
|
||||
def _service(label: str) -> str:
|
||||
return f'gui/{_uid()}/{label}'
|
||||
|
||||
|
||||
def _launchctl_print(label: str) -> Optional[str]:
|
||||
p = subprocess.run(['launchctl', 'print', _service(label)], capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
return None
|
||||
return p.stdout
|
||||
|
||||
|
||||
def _is_running(label: str) -> bool:
|
||||
out = _launchctl_print(label)
|
||||
if not out:
|
||||
return False
|
||||
for line in out.splitlines():
|
||||
m = re.match(r'\s*state\s*=\s*(\S+)', line)
|
||||
if m:
|
||||
return m.group(1) == 'running'
|
||||
return False
|
||||
|
||||
|
||||
def _bootstrap(plist: Path) -> None:
|
||||
subprocess.run(['launchctl', 'bootstrap', f'gui/{_uid()}', str(plist)], check=True)
|
||||
|
||||
|
||||
def _bootout(label: str) -> None:
|
||||
subprocess.run(['launchctl', 'bootout', _service(label)], capture_output=True)
|
||||
# bootout이 비동기적이라 잠깐 라벨이 graveyard에 남는다.
|
||||
# `launchctl print`가 실패할 때까지 짧게 폴링해서 후속 bootstrap 충돌 방지.
|
||||
import time
|
||||
for _ in range(20): # 최대 ~2초
|
||||
if _launchctl_print(label) is None:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def _kickstart(label: str) -> None:
|
||||
subprocess.run(['launchctl', 'kickstart', _service(label)], capture_output=True)
|
||||
|
||||
|
||||
# ───────── plist 생성 ─────────
|
||||
|
||||
PLIST_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{label}</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{claude_bin}</string>
|
||||
<string>remote-control</string>
|
||||
<string>--name</string>
|
||||
<string>{session_name}</string>
|
||||
</array>
|
||||
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{workdir}</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>{path_env}</string>
|
||||
<key>HOME</key>
|
||||
<string>{home}</string>
|
||||
</dict>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>{stdout}</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{stderr}</string>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<false/>
|
||||
|
||||
<key>ProcessType</key>
|
||||
<string>Background</string>
|
||||
</dict>
|
||||
</plist>
|
||||
'''
|
||||
|
||||
|
||||
def _write_plist(session_name: str, workdir: str) -> Path:
|
||||
LA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
label = _label(session_name)
|
||||
path = _plist_path(session_name)
|
||||
path.write_text(PLIST_TEMPLATE.format(
|
||||
label=label,
|
||||
claude_bin=CLAUDE_BIN,
|
||||
session_name=session_name,
|
||||
workdir=workdir,
|
||||
path_env=f'{HOME}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin',
|
||||
home=HOME,
|
||||
stdout=LOG_DIR / f'claude-session-{session_name}.log',
|
||||
stderr=LOG_DIR / f'claude-session-{session_name}.err.log',
|
||||
))
|
||||
return path
|
||||
|
||||
|
||||
# ───────── 활성 세션 스캔 ─────────
|
||||
|
||||
def _list_active_sessions() -> list[dict]:
|
||||
"""LaunchAgents 폴더 스캔. 본 툴이 만든 plist만."""
|
||||
out = []
|
||||
if not LA_DIR.exists():
|
||||
return out
|
||||
for plist in sorted(LA_DIR.glob(f'{LABEL_PREFIX}*.plist')):
|
||||
label = plist.stem
|
||||
suffix = label[len(LABEL_PREFIX):]
|
||||
m = SESSION_RE.match(suffix)
|
||||
if not m:
|
||||
continue
|
||||
profile = (
|
||||
m.group('profile_compact') or m.group('profile_dash') or
|
||||
m.group('profile_dated') or m.group('profile_legacy') or DEFAULT_PROFILE
|
||||
)
|
||||
if profile == 'oc':
|
||||
profile = DEFAULT_PROFILE
|
||||
date = (
|
||||
m.group('date_compact') or m.group('date_compact_profile') or
|
||||
m.group('date_dash') or m.group('date8')
|
||||
)
|
||||
n = int(m.group('n'))
|
||||
running = _is_running(label)
|
||||
workdir = _read_plist_workdir(plist)
|
||||
out.append({
|
||||
'session': suffix,
|
||||
'profile': profile,
|
||||
'date': date,
|
||||
'n': n,
|
||||
'workdir': workdir,
|
||||
'running': running,
|
||||
'plist': plist,
|
||||
'label': label,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _read_plist_workdir(plist: Path) -> str:
|
||||
p = subprocess.run(['/usr/libexec/PlistBuddy', '-c', 'Print :WorkingDirectory', str(plist)],
|
||||
capture_output=True, text=True)
|
||||
return p.stdout.strip() if p.returncode == 0 else '?'
|
||||
|
||||
|
||||
def _legacy_running() -> bool:
|
||||
return _is_running(LEGACY_LABEL)
|
||||
|
||||
|
||||
def _next_free_number(profile: str, date: str) -> int:
|
||||
used = {s['n'] for s in _list_active_sessions() if s['profile'] == profile and s.get('date') == date}
|
||||
n = 1
|
||||
while n in used:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
# ───────── 커맨드 핸들러 ─────────
|
||||
|
||||
def cmd_profile_list(args) -> int:
|
||||
state = _load_state()
|
||||
print(f'{"NAME":<16} WORKDIR')
|
||||
for name, prof in sorted(state['profiles'].items()):
|
||||
print(f'{name:<16} {prof["workdir"]}')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_profile_add(args) -> int:
|
||||
_validate_name(args.name, '프로필 이름')
|
||||
workdir = _resolve_workdir(args.workdir)
|
||||
state = _load_state()
|
||||
if args.name in state['profiles']:
|
||||
raise SystemExit(f"이미 등록된 프로필: {args.name} → {state['profiles'][args.name]['workdir']}")
|
||||
state['profiles'][args.name] = {'workdir': workdir}
|
||||
_save_state(state)
|
||||
print(f'프로필 추가됨: {args.name} → {workdir}')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_profile_remove(args) -> int:
|
||||
state = _load_state()
|
||||
if args.name not in state['profiles']:
|
||||
raise SystemExit(f'등록되지 않은 프로필: {args.name}')
|
||||
if args.name == DEFAULT_PROFILE:
|
||||
raise SystemExit(f"기본 프로필 '{DEFAULT_PROFILE}'은 삭제 불가.")
|
||||
active = [s for s in _list_active_sessions() if s['profile'] == args.name]
|
||||
if active:
|
||||
names = ', '.join(s['session'] for s in active)
|
||||
raise SystemExit(f'활성 세션이 있어 삭제 불가: {names} (먼저 close)')
|
||||
del state['profiles'][args.name]
|
||||
_save_state(state)
|
||||
print(f'프로필 삭제됨: {args.name}')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_profile_edit(args) -> int:
|
||||
state = _load_state()
|
||||
if args.name not in state['profiles']:
|
||||
raise SystemExit(f'등록되지 않은 프로필: {args.name}')
|
||||
workdir = _resolve_workdir(args.workdir)
|
||||
active = [s for s in _list_active_sessions() if s['profile'] == args.name]
|
||||
if active:
|
||||
names = ', '.join(s['session'] for s in active)
|
||||
raise SystemExit(f'활성 세션이 있어 변경 불가: {names} (먼저 close)')
|
||||
state['profiles'][args.name]['workdir'] = workdir
|
||||
_save_state(state)
|
||||
print(f'프로필 수정됨: {args.name} → {workdir}')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_session_open(args) -> int:
|
||||
profile = args.profile or DEFAULT_PROFILE
|
||||
_validate_name(profile, '프로필 이름')
|
||||
state = _load_state()
|
||||
prof = state['profiles'].get(profile)
|
||||
if not prof:
|
||||
raise SystemExit(f'등록되지 않은 프로필: {profile} (먼저 `profile add` 실행)')
|
||||
workdir = prof['workdir']
|
||||
if not Path(workdir).is_dir():
|
||||
raise SystemExit(f'프로필 workdir 없음: {workdir}')
|
||||
date = _today()
|
||||
n = _next_free_number(profile, date)
|
||||
session_name = _session_name(profile, n, date)
|
||||
label = _label(session_name)
|
||||
plist = _write_plist(session_name, workdir)
|
||||
try:
|
||||
_bootstrap(plist)
|
||||
except subprocess.CalledProcessError as e:
|
||||
plist.unlink(missing_ok=True)
|
||||
raise SystemExit(f'launchctl bootstrap 실패: {e}')
|
||||
_kickstart(label)
|
||||
import time
|
||||
time.sleep(2)
|
||||
if _is_running(label):
|
||||
print(f'세션 시작됨: {session_name} (workdir={workdir})')
|
||||
print(f"claude.ai/code 또는 Claude 앱에서 '{session_name}' 클릭하여 접속하세요.")
|
||||
return 0
|
||||
print(f'WARN: {session_name} bootstrap은 됐는데 running 확인 실패. 로그: {LOG_DIR}/claude-session-{session_name}.err.log',
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_session_close(args) -> int:
|
||||
target = args.name
|
||||
m = SESSION_RE.match(target)
|
||||
if not m:
|
||||
raise SystemExit(f"세션 이름 형식 오류: {target} (예: 260520-1)")
|
||||
label = _label(target)
|
||||
plist = _plist_path(target)
|
||||
if not plist.exists() and not _launchctl_print(label):
|
||||
raise SystemExit(f'세션 없음: {target}')
|
||||
_bootout(label)
|
||||
plist.unlink(missing_ok=True)
|
||||
print(f'세션 종료됨: {target}')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_session_list(args) -> int:
|
||||
sessions = _list_active_sessions()
|
||||
print(f'{"SESSION":<24} {"STATE":<8} WORKDIR')
|
||||
legacy_state = 'running' if _legacy_running() else 'stopped'
|
||||
print(f'{"openclaw (legacy)":<24} {legacy_state:<8} {OPENCLAW_ROOT}')
|
||||
for s in sessions:
|
||||
state = 'running' if s['running'] else 'stopped'
|
||||
print(f'{s["session"]:<24} {state:<8} {s["workdir"]}')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_session_status(args) -> int:
|
||||
target = args.name
|
||||
m = SESSION_RE.match(target)
|
||||
if not m:
|
||||
raise SystemExit(f"세션 이름 형식 오류: {target} (예: 260520-1)")
|
||||
label = _label(target)
|
||||
if _is_running(label):
|
||||
print(f'{target} 동작 중')
|
||||
return 0
|
||||
if _plist_path(target).exists():
|
||||
print(f'{target} 등록됐으나 정지 상태')
|
||||
return 1
|
||||
print(f'{target} 없음')
|
||||
return 2
|
||||
|
||||
|
||||
# ───────── argparse ─────────
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog='session_tool', description=__doc__.splitlines()[0] if __doc__ else None)
|
||||
sub = p.add_subparsers(dest='group', required=True)
|
||||
|
||||
pp = sub.add_parser('profile', help='프로필(이름↔workdir) CRUD')
|
||||
psub = pp.add_subparsers(dest='cmd', required=True)
|
||||
psub.add_parser('list', help='등록된 프로필 출력').set_defaults(func=cmd_profile_list)
|
||||
pa = psub.add_parser('add', help='프로필 추가')
|
||||
pa.add_argument('name'); pa.add_argument('workdir'); pa.set_defaults(func=cmd_profile_add)
|
||||
pr = psub.add_parser('remove', help='프로필 삭제 (활성 세션 없을 때만)')
|
||||
pr.add_argument('name'); pr.set_defaults(func=cmd_profile_remove)
|
||||
pe = psub.add_parser('edit', help='프로필 workdir 변경 (활성 세션 없을 때만)')
|
||||
pe.add_argument('name'); pe.add_argument('workdir'); pe.set_defaults(func=cmd_profile_edit)
|
||||
|
||||
sp = sub.add_parser('session', help='세션 (실제 떠있는 클로드 인스턴스) 관리')
|
||||
ssub = sp.add_subparsers(dest='cmd', required=True)
|
||||
so = ssub.add_parser('open', help='세션 열기 (빈 번호 자동 할당)')
|
||||
so.add_argument('profile', nargs='?'); so.set_defaults(func=cmd_session_open)
|
||||
sc = ssub.add_parser('close', help='세션 종료')
|
||||
sc.add_argument('name'); sc.set_defaults(func=cmd_session_close)
|
||||
ssub.add_parser('list', help='활성 세션 목록').set_defaults(func=cmd_session_list)
|
||||
ss = ssub.add_parser('status', help='세션 상태')
|
||||
ss.add_argument('name'); ss.set_defaults(func=cmd_session_status)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user