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,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