diff --git a/.env.example b/.env.example index 9c3f355..33a9a07 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,8 @@ SLACK_APP_TOKEN=xapp-your-app-token SLACK_ALLOWED_USER_ID=U0XXXXXXXXX SLACK_ALLOWED_CHANNEL_ID=C0XXXXXXXXX -# PTY 설정 -DEFAULT_SHELL=claude +# PTY / tmux 연결 설정 +TMUX_SESSION_NAME=claude PTY_READ_TIMEOUT=5 # 출력 버퍼 설정 diff --git a/README.md b/README.md index 92d4b06..2134d17 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,12 @@ ## 동작 방식 -1. Slack에서 `/start-claude` 실행 -2. 로컬에서 `claude` 프로세스(기본값)가 PTY로 시작됨 -3. Slack 채널 메시지가 CLI 입력으로 전달됨 -4. CLI 출력이 Slack으로 다시 전송됨 -5. `/stop-claude`로 세션 종료 +1. 로컬에서 Claude를 tmux 세션으로 미리 실행 (`tmux new -s claude claude`) +2. Slack에서 `/start-claude` 실행 +3. 브릿지가 기존 tmux 세션에 attach +4. Slack 채널 메시지가 CLI 입력으로 전달됨 +5. CLI 출력이 Slack으로 다시 전송됨 +6. `/stop-claude`로 브릿지 연결 해제 (tmux 세션은 유지) ## 빠른 시작 @@ -34,7 +35,7 @@ cp .env.example .env - `SLACK_ALLOWED_CHANNEL_ID` 선택 환경 변수: -- `DEFAULT_SHELL` (기본: `claude`) +- `TMUX_SESSION_NAME` (기본: `claude`) - `PTY_READ_TIMEOUT` (기본: `5`) - `OUTPUT_BUFFER_INTERVAL` (기본: `2.0`) - `MAX_MESSAGE_LENGTH` (기본: `3000`) @@ -55,6 +56,14 @@ cp .env.example .env ## 실행 +먼저 로컬에서 Claude tmux 세션을 실행: + +```bash +tmux new -s claude claude +``` + +그 다음 브릿지 실행: + ```bash lazy-enter # 또는 @@ -62,9 +71,9 @@ python -m lazy_enter ``` 실행 후 Slack의 허용된 채널에서: -- `/start-claude`: 세션 시작 +- `/start-claude`: 기존 세션에 연결 - 일반 메시지 전송: Claude CLI로 입력 전달 -- `/stop-claude`: 세션 종료 +- `/stop-claude`: 브릿지 연결 해제 (세션 유지) ## 테스트 및 품질 점검 diff --git a/src/lazy_enter/bridge.py b/src/lazy_enter/bridge.py index 1295e35..dc62ff8 100644 --- a/src/lazy_enter/bridge.py +++ b/src/lazy_enter/bridge.py @@ -48,7 +48,7 @@ class Bridge: self._input_idle_reported = False logger.info("입력 전달: %s", text) else: - self.slack.send_message(channel, ":warning: 실행 중인 세션이 없습니다.") + self.slack.send_message(channel, ":warning: 연결된 세션이 없습니다.") @staticmethod def _is_blocked_input(text: str) -> bool: @@ -73,17 +73,23 @@ class Bridge: self._stop_session(channel) def _start_session(self, channel: str) -> None: - """Claude Code 세션을 시작한다.""" + """기존 Claude tmux 세션에 연결한다.""" if self.pty and self.pty.is_alive: self.slack.send_message( - channel, ":information_source: 이미 세션이 실행 중입니다." + channel, ":information_source: 이미 세션에 연결되어 있습니다." ) return self._channel = channel self._last_sent_output = "" - self.pty = PtyManager(self.config.default_shell) - self.pty.start() + self.pty = PtyManager(self.config.tmux_session_name) + try: + self.pty.start() + except RuntimeError as exc: + self.pty = None + self.slack.send_message(channel, f":warning: {exc}") + return + self._running = True self._last_input_at = time.monotonic() self._last_output_at = time.monotonic() @@ -92,16 +98,16 @@ class Bridge: self._output_thread = threading.Thread(target=self._poll_output, daemon=True) self._output_thread.start() - self.slack.send_message(channel, ":rocket: Claude Code 세션이 시작되었습니다.") + self.slack.send_message(channel, ":link: Claude 세션에 연결되었습니다.") def _stop_session(self, channel: str) -> None: - """Claude Code 세션을 종료한다.""" + """브릿지 연결만 해제한다.""" self._running = False if self.pty: self.pty.stop() self.pty = None self._last_sent_output = "" - self.slack.send_message(channel, ":stop_sign: 세션이 종료되었습니다.") + self.slack.send_message(channel, ":electric_plug: 세션 연결이 해제되었습니다.") def _poll_output(self) -> None: """PTY 출력을 주기적으로 읽어 Slack으로 전송한다.""" @@ -158,8 +164,8 @@ class Bridge: if not self._running: return - # 프로세스가 예기치 않게 종료된 경우 - self.slack.send_message(self._channel, ":warning: 프로세스가 종료되었습니다.") + # attach 프로세스가 예기치 않게 종료된 경우 + self.slack.send_message(self._channel, ":warning: 세션 연결이 종료되었습니다.") def run(self) -> None: """브릿지를 시작한다.""" diff --git a/src/lazy_enter/config.py b/src/lazy_enter/config.py index 5f478b0..2230582 100644 --- a/src/lazy_enter/config.py +++ b/src/lazy_enter/config.py @@ -18,8 +18,8 @@ class Config: allowed_user_id: str = os.getenv("SLACK_ALLOWED_USER_ID", "") allowed_channel_id: str = os.getenv("SLACK_ALLOWED_CHANNEL_ID", "") - # PTY - default_shell: str = os.getenv("DEFAULT_SHELL", "claude") + # PTY / tmux attach + tmux_session_name: str = os.getenv("TMUX_SESSION_NAME", "claude") pty_read_timeout: int = int(os.getenv("PTY_READ_TIMEOUT", "5")) # Buffer diff --git a/src/lazy_enter/pty_manager.py b/src/lazy_enter/pty_manager.py index 8213bd2..2b7853f 100644 --- a/src/lazy_enter/pty_manager.py +++ b/src/lazy_enter/pty_manager.py @@ -1,8 +1,9 @@ -"""pexpect 기반 PTY 프로세스 관리.""" +"""기존 tmux 세션에 attach하여 CLI 입출력을 중계한다.""" from __future__ import annotations import logging +import subprocess import pexpect @@ -10,21 +11,37 @@ logger = logging.getLogger(__name__) class PtyManager: - """가상 터미널에서 CLI 프로세스를 생성하고 입출력을 제어한다.""" + """기존 tmux 세션에 attach하고 입출력을 제어한다.""" - def __init__(self, command: str = "claude") -> None: - self.command = command + def __init__(self, session_name: str = "claude") -> None: + self.session_name = session_name self._process: pexpect.spawn | None = None @property def is_alive(self) -> bool: return self._process is not None and self._process.isalive() + def _ensure_session_exists(self) -> None: + """attach 대상 tmux 세션 존재 여부를 검증한다.""" + result = subprocess.run( + ["tmux", "has-session", "-t", self.session_name], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + "tmux 세션이 없습니다. 먼저 실행하세요: " + f"tmux new -s {self.session_name} claude" + ) + def start(self) -> None: - """프로세스를 시작한다.""" - logger.info("프로세스 시작: %s", self.command) + """기존 tmux 세션에 attach한다.""" + self._ensure_session_exists() + logger.info("tmux 세션 attach: %s", self.session_name) self._process = pexpect.spawn( - self.command, + "tmux", + ["attach-session", "-t", self.session_name], encoding="utf-8", timeout=None, ) @@ -49,8 +66,8 @@ class PtyManager: return self._process.before or "" def stop(self) -> None: - """프로세스를 종료한다.""" + """attach 연결만 종료한다(원격 세션은 유지).""" if self._process is not None: - logger.info("프로세스 종료") + logger.info("tmux attach 종료") self._process.close(force=True) self._process = None diff --git a/tests/test_config.py b/tests/test_config.py index 70931b8..18b1454 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,7 @@ from lazy_enter.config import Config def test_config_defaults(): config = Config() - assert config.default_shell == "claude" + assert config.tmux_session_name == "claude" assert config.pty_read_timeout == 5 assert config.output_buffer_interval == 2.0 assert config.max_message_length == 3000 diff --git a/tests/test_pty_manager.py b/tests/test_pty_manager.py index 09a184d..4b596a9 100644 --- a/tests/test_pty_manager.py +++ b/tests/test_pty_manager.py @@ -1,22 +1,84 @@ """PtyManager 테스트.""" +from __future__ import annotations + +from collections.abc import Sequence + import pytest from lazy_enter.pty_manager import PtyManager +class FakeSpawn: + def __init__(self, *_args: object, **_kwargs: object) -> None: + self.before = "" + self._alive = True + + def isalive(self) -> bool: + return self._alive + + def sendline(self, _text: str) -> None: + return None + + def expect(self, *_args: object, **_kwargs: object) -> None: + return None + + def close(self, force: bool = False) -> None: + assert force is True + self._alive = False + + def test_initial_state(): - pty = PtyManager("echo hello") + pty = PtyManager("claude") assert not pty.is_alive -def test_start_and_stop(): - pty = PtyManager("cat") - try: - pty.start() - except OSError as exc: - pytest.skip(f"PTY unavailable in this environment: {exc}") +def test_start_raises_when_tmux_session_missing(monkeypatch: pytest.MonkeyPatch): + def fake_run( + cmd: Sequence[str], + check: bool, + capture_output: bool, + text: bool, + ): + assert list(cmd) == ["tmux", "has-session", "-t", "claude"] + assert check is False + assert capture_output is True + assert text is True + class Result: + returncode = 1 + + return Result() + + monkeypatch.setattr("lazy_enter.pty_manager.subprocess.run", fake_run) + + pty = PtyManager("claude") + with pytest.raises(RuntimeError): + pty.start() + + +def test_start_and_stop_attach(monkeypatch: pytest.MonkeyPatch): + def fake_run( + _cmd: Sequence[str], + check: bool, + capture_output: bool, + text: bool, + ): + assert check is False + assert capture_output is True + assert text is True + + class Result: + returncode = 0 + + return Result() + + monkeypatch.setattr("lazy_enter.pty_manager.subprocess.run", fake_run) + monkeypatch.setattr("lazy_enter.pty_manager.pexpect.spawn", FakeSpawn) + + pty = PtyManager("claude") + pty.start() assert pty.is_alive + pty.stop() assert not pty.is_alive