From 87482efd6d6bd15ca8c5a992e478e74ede05b0a3 Mon Sep 17 00:00:00 2001 From: jihoson Date: Mon, 16 Feb 2026 22:48:05 +0900 Subject: [PATCH] Add contributor guide and project usage docs --- .env.example | 15 +++++ AGENTS.md | 44 +++++++++++++ CLAUDE.md | 55 ++++++++++++++++ README.md | 80 ++++++++++++++++++++++- pyproject.toml | 42 ++++++++++++ src/lazy_enter/__init__.py | 3 + src/lazy_enter/__main__.py | 12 ++++ src/lazy_enter/bridge.py | 109 ++++++++++++++++++++++++++++++++ src/lazy_enter/config.py | 27 ++++++++ src/lazy_enter/hooks.py | 60 ++++++++++++++++++ src/lazy_enter/pty_manager.py | 56 ++++++++++++++++ src/lazy_enter/slack_handler.py | 108 +++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/test_config.py | 11 ++++ tests/test_hooks.py | 34 ++++++++++ tests/test_pty_manager.py | 16 +++++ 16 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 pyproject.toml create mode 100644 src/lazy_enter/__init__.py create mode 100644 src/lazy_enter/__main__.py create mode 100644 src/lazy_enter/bridge.py create mode 100644 src/lazy_enter/config.py create mode 100644 src/lazy_enter/hooks.py create mode 100644 src/lazy_enter/pty_manager.py create mode 100644 src/lazy_enter/slack_handler.py create mode 100644 tests/__init__.py create mode 100644 tests/test_config.py create mode 100644 tests/test_hooks.py create mode 100644 tests/test_pty_manager.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c64adbf --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Slack App 설정 +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_APP_TOKEN=xapp-your-app-token + +# 보안: 허가된 사용자/채널만 명령 수락 +SLACK_ALLOWED_USER_ID=U0XXXXXXXXX +SLACK_ALLOWED_CHANNEL_ID=C0XXXXXXXXX + +# PTY 설정 +DEFAULT_SHELL=claude +PTY_READ_TIMEOUT=5 + +# 출력 버퍼 설정 +OUTPUT_BUFFER_INTERVAL=2.0 +MAX_MESSAGE_LENGTH=3000 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4e5af57 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ +# Repository Guidelines + +## Project Structure & Module Organization +This project uses a `src/` layout: +- `src/lazy_enter/`: application code (`bridge.py`, `slack_handler.py`, `pty_manager.py`, `config.py`, `hooks.py`) +- `tests/`: `pytest` test suite (`test_*.py` files) +- `pyproject.toml`: packaging, lint, type-check, and test configuration +- `.env.example`: environment variable template for local setup + +Use module boundaries intentionally: `bridge.py` orchestrates flow, while Slack, PTY, config, and hook parsing stay in their dedicated modules. + +## Build, Test, and Development Commands +- `pip install -e .`: install the package in editable mode for local development. +- `python -m lazy_enter` or `lazy-enter`: run the bridge locally. +- `pytest`: run all tests. +- `pytest tests/test_hooks.py -v`: run one test file with verbose output. +- `ruff check src/ tests/`: lint imports and code quality rules. +- `ruff format src/ tests/`: apply standard formatting. +- `mypy src/`: run strict static type checks. + +## Coding Style & Naming Conventions +Target runtime is Python 3.10+ with 4-space indentation and type hints on public functions. +Follow `ruff` rules (`E`, `F`, `I`, `N`, `W`, `UP`) and keep imports sorted. +Naming patterns: +- modules/files: `snake_case.py` +- functions/variables: `snake_case` +- classes: `PascalCase` +- constants/env keys: `UPPER_SNAKE_CASE` + +## Testing Guidelines +Use `pytest` with tests under `tests/` and names matching `test_*.py` (configured in `pyproject.toml`). +Prefer focused unit tests per module (see `tests/test_hooks.py`, `tests/test_config.py`). +For bug fixes, add or update a regression test in the matching test file before finalizing. + +## Commit & Pull Request Guidelines +Current git history is minimal (`Initial commit`), so enforce clear conventions now: +- Commit messages: imperative, concise subject (e.g., `Add Slack user/channel guard`). +- Keep commits scoped to one logical change. +- PRs should include: problem summary, what changed, test evidence (`pytest`, `ruff`, `mypy`), and linked issue/context. +- Include screenshots or log snippets when behavior changes are user-visible (for example, Slack message formatting). + +## Security & Configuration Tips +Do not commit secrets. Copy `.env.example` to `.env` locally. +Restrict Slack access with `SLACK_ALLOWED_USER_ID` and `SLACK_ALLOWED_CHANNEL_ID` before running in shared workspaces. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..62f8512 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +LazyEnter("소파에서 엔터나 치자")는 로컬 PC의 Claude Code CLI 세션을 Slack으로 중계하여, 모바일에서 승인(y/n) 및 명령을 원격 수행하는 브릿지 시스템이다. Slack Socket Mode를 사용하므로 공인 IP나 방화벽 설정이 불필요하다. + +## Architecture + +``` +Slack App (모바일/데스크톱) + ↕ Socket Mode +SlackHandler ← Bridge → PtyManager + ↑ ↕ + Config pexpect (claude 프로세스) + ↑ + CLI Hooks (hooks.py) +``` + +- **Bridge** (`bridge.py`): 핵심 중계기. SlackHandler와 PtyManager를 연결하고 출력 폴링 스레드를 관리 +- **SlackHandler** (`slack_handler.py`): slack_bolt 기반 Socket Mode 이벤트 수신/메시지 전송 +- **PtyManager** (`pty_manager.py`): pexpect로 가상 터미널 프로세스 생성·입출력 제어 +- **Config** (`config.py`): python-dotenv 기반 환경 변수 관리 +- **Hooks** (`hooks.py`): Claude Code `/hooks` 이벤트를 파싱하여 Slack 알림 전송 (승인 대기, 작업 완료) + +## Commands + +```bash +# 의존성 설치 +pip install -e . + +# 실행 +lazy-enter +# 또는 +python -m lazy_enter + +# 테스트 +pytest +pytest tests/test_hooks.py -v # 단일 파일 +pytest tests/test_hooks.py::test_parse_hook_event_valid -v # 단일 테스트 + +# 린트 +ruff check src/ tests/ +ruff format src/ tests/ + +# 타입 체크 +mypy src/ +``` + +## Key Conventions + +- `src/` 레이아웃 사용 (`src/lazy_enter/`) +- 환경 변수는 `.env` 파일로 관리 (`.env.example` 참조) +- 보안: `SLACK_ALLOWED_USER_ID`, `SLACK_ALLOWED_CHANNEL_ID`로 접근 제한 필수 diff --git a/README.md b/README.md index fd5e23e..55918f4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,81 @@ # LazyEnter -소파에서 엔터나 치자 \ No newline at end of file +소파에서 엔터나 치자. +로컬 PC의 Claude Code CLI 세션을 Slack Socket Mode로 중계해 모바일에서도 원격으로 입력/승인을 처리하는 브릿지입니다. + +## 동작 방식 + +1. Slack에서 `/start-claude` 실행 +2. 로컬에서 `claude` 프로세스(기본값)가 PTY로 시작됨 +3. Slack 채널 메시지가 CLI 입력으로 전달됨 +4. CLI 출력이 Slack으로 다시 전송됨 +5. `/stop-claude`로 세션 종료 + +## 빠른 시작 + +```bash +git clone +cd LazyEnter +python -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +`.env` 파일을 생성하고 값을 채우세요: + +```bash +cp .env.example .env +``` + +필수 환경 변수: +- `SLACK_BOT_TOKEN` +- `SLACK_APP_TOKEN` +- `SLACK_ALLOWED_USER_ID` +- `SLACK_ALLOWED_CHANNEL_ID` + +선택 환경 변수: +- `DEFAULT_SHELL` (기본: `claude`) +- `PTY_READ_TIMEOUT` (기본: `5`) +- `OUTPUT_BUFFER_INTERVAL` (기본: `2.0`) +- `MAX_MESSAGE_LENGTH` (기본: `3000`) + +## Slack 앱 설정 + +- Socket Mode 활성화 +- Bot Token Scopes에 `chat:write`, `commands` 추가 +- Slash Commands 생성: + - `/start-claude` + - `/stop-claude` +- 앱을 워크스페이스에 설치 후 토큰을 `.env`에 반영 + +## 실행 + +```bash +lazy-enter +# 또는 +python -m lazy_enter +``` + +실행 후 Slack의 허용된 채널에서: +- `/start-claude`: 세션 시작 +- 일반 메시지 전송: Claude CLI로 입력 전달 +- `/stop-claude`: 세션 종료 + +## 테스트 및 품질 점검 + +```bash +pytest +ruff check src/ tests/ +ruff format src/ tests/ +mypy src/ +``` + +## hooks 알림 사용 (선택) + +`hooks.py`는 JSON 이벤트를 받아 Slack 알림 문구로 변환합니다. + +```bash +echo '{"type":"prompt","tool":"Bash"}' | python -m lazy_enter.hooks +``` + +`SLACK_ALLOWED_CHANNEL_ID`가 설정된 경우 해당 채널로 알림을 보냅니다. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ae3953c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "lazy-enter" +version = "0.1.0" +description = "소파에서 엔터나 치자 - Slack을 통한 Claude Code 원격 제어 브릿지" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "jihoson", email = "kiparang7th@gmail.com" }, +] +dependencies = [ + "slack-bolt>=1.21.0", + "slack-sdk>=3.33.0", + "pexpect>=4.9.0", + "python-dotenv>=1.0.0", +] + +[project.scripts] +lazy-enter = "lazy_enter.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/lazy_enter"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.ruff] +target-version = "py310" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.mypy] +python_version = "3.10" +mypy_path = "src" +strict = true diff --git a/src/lazy_enter/__init__.py b/src/lazy_enter/__init__.py new file mode 100644 index 0000000..0ba503d --- /dev/null +++ b/src/lazy_enter/__init__.py @@ -0,0 +1,3 @@ +"""LazyEnter - Slack을 통한 Claude Code 원격 제어 브릿지.""" + +__version__ = "0.1.0" diff --git a/src/lazy_enter/__main__.py b/src/lazy_enter/__main__.py new file mode 100644 index 0000000..c0ac639 --- /dev/null +++ b/src/lazy_enter/__main__.py @@ -0,0 +1,12 @@ +"""CLI 엔트리포인트.""" + +from lazy_enter.bridge import Bridge + + +def main() -> None: + bridge = Bridge() + bridge.run() + + +if __name__ == "__main__": + main() diff --git a/src/lazy_enter/bridge.py b/src/lazy_enter/bridge.py new file mode 100644 index 0000000..d3c0a69 --- /dev/null +++ b/src/lazy_enter/bridge.py @@ -0,0 +1,109 @@ +"""Bridge Agent - Slack과 PTY 프로세스를 연결하는 핵심 중계 로직.""" + +from __future__ import annotations + +import logging +import threading +import time + +from lazy_enter.config import Config +from lazy_enter.pty_manager import PtyManager +from lazy_enter.slack_handler import SlackHandler + +logger = logging.getLogger(__name__) + + +class Bridge: + """Slack ↔ CLI 프로세스 간의 중계기.""" + + def __init__(self, config: Config | None = None) -> None: + self.config = config or Config() + self.slack = SlackHandler(self.config) + self.pty: PtyManager | None = None + self._output_thread: threading.Thread | None = None + self._running = False + self._channel: str = self.config.allowed_channel_id + + self.slack.on_message(self._handle_message) + self.slack.on_command(self._handle_command) + + def _handle_message(self, text: str, channel: str) -> None: + """Slack 메시지를 PTY 프로세스로 전달한다.""" + if self.pty and self.pty.is_alive: + self.pty.send(text) + logger.info("입력 전달: %s", text) + else: + self.slack.send_message(channel, ":warning: 실행 중인 세션이 없습니다.") + + def _handle_command(self, command: str, channel: str) -> None: + """슬래시 커맨드를 처리한다.""" + if command == "start": + self._start_session(channel) + elif command == "stop": + self._stop_session(channel) + + def _start_session(self, channel: str) -> None: + """Claude Code 세션을 시작한다.""" + if self.pty and self.pty.is_alive: + self.slack.send_message( + channel, ":information_source: 이미 세션이 실행 중입니다." + ) + return + + self._channel = channel + self.pty = PtyManager(self.config.default_shell) + self.pty.start() + self._running = True + self._output_thread = threading.Thread(target=self._poll_output, daemon=True) + self._output_thread.start() + + self.slack.send_message(channel, ":rocket: Claude Code 세션이 시작되었습니다.") + + def _stop_session(self, channel: str) -> None: + """Claude Code 세션을 종료한다.""" + self._running = False + if self.pty: + self.pty.stop() + self.pty = None + self.slack.send_message(channel, ":stop_sign: 세션이 종료되었습니다.") + + def _poll_output(self) -> None: + """PTY 출력을 주기적으로 읽어 Slack으로 전송한다.""" + buffer = "" + while self._running and self.pty and self.pty.is_alive: + output = self.pty.read_output(timeout=self.config.pty_read_timeout) + if output: + buffer += output + + if buffer: + # 메시지 길이 제한 적용 + message = buffer[: self.config.max_message_length] + if len(buffer) > self.config.max_message_length: + message += "\n... (truncated)" + self.slack.send_message(self._channel, f"```\n{message}\n```") + buffer = "" + + time.sleep(self.config.output_buffer_interval) + + if not self._running: + return + + # 프로세스가 예기치 않게 종료된 경우 + self.slack.send_message(self._channel, ":warning: 프로세스가 종료되었습니다.") + + def run(self) -> None: + """브릿지를 시작한다.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + ) + logger.info("LazyEnter Bridge 시작") + try: + self.slack.start() + except KeyboardInterrupt: + logger.info("종료 신호 수신") + finally: + self._running = False + if self.pty: + self.pty.stop() + self.slack.stop() diff --git a/src/lazy_enter/config.py b/src/lazy_enter/config.py new file mode 100644 index 0000000..d824992 --- /dev/null +++ b/src/lazy_enter/config.py @@ -0,0 +1,27 @@ +"""환경 변수 및 설정 관리.""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + + +class Config: + """앱 전체 설정을 담당하는 클래스.""" + + # Slack + slack_bot_token: str = os.getenv("SLACK_BOT_TOKEN", "") + slack_app_token: str = os.getenv("SLACK_APP_TOKEN", "") + 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_read_timeout: int = int(os.getenv("PTY_READ_TIMEOUT", "5")) + + # Buffer + output_buffer_interval: float = float(os.getenv("OUTPUT_BUFFER_INTERVAL", "2.0")) + max_message_length: int = int(os.getenv("MAX_MESSAGE_LENGTH", "3000")) diff --git a/src/lazy_enter/hooks.py b/src/lazy_enter/hooks.py new file mode 100644 index 0000000..ac6f0f4 --- /dev/null +++ b/src/lazy_enter/hooks.py @@ -0,0 +1,60 @@ +"""Claude Code hooks 연동 - 상태 알림 서비스.""" + +from __future__ import annotations + +import json +import logging +import sys + +from lazy_enter.config import Config + +logger = logging.getLogger(__name__) + + +def parse_hook_event(raw: str) -> dict: + """훅 이벤트 JSON을 파싱한다.""" + try: + return json.loads(raw) + except json.JSONDecodeError: + logger.warning("훅 이벤트 파싱 실패: %s", raw) + return {} + + +def format_notification(event: dict) -> str | None: + """훅 이벤트를 슬랙 알림 메시지로 변환한다. + + Returns: + 포맷된 메시지 문자열. 알림이 불필요한 이벤트면 None. + """ + event_type = event.get("type", "") + + if event_type == "prompt": + tool = event.get("tool", "unknown") + return f":bell: *[승인 대기 중]* `{tool}` 실행을 승인해주세요. (y/n)" + + if event_type == "completion": + summary = event.get("summary", "작업이 완료되었습니다.") + return f":white_check_mark: *[완료]* {summary}" + + return None + + +def hook_main() -> None: + """hooks 스크립트로 직접 실행될 때의 엔트리포인트. + + stdin으로 전달된 이벤트를 파싱하여 Slack으로 전송한다. + """ + from lazy_enter.slack_handler import SlackHandler + + config = Config() + raw = sys.stdin.read() + event = parse_hook_event(raw) + message = format_notification(event) + + if message and config.allowed_channel_id: + slack = SlackHandler(config) + slack.send_message(config.allowed_channel_id, message) + + +if __name__ == "__main__": + hook_main() diff --git a/src/lazy_enter/pty_manager.py b/src/lazy_enter/pty_manager.py new file mode 100644 index 0000000..8213bd2 --- /dev/null +++ b/src/lazy_enter/pty_manager.py @@ -0,0 +1,56 @@ +"""pexpect 기반 PTY 프로세스 관리.""" + +from __future__ import annotations + +import logging + +import pexpect + +logger = logging.getLogger(__name__) + + +class PtyManager: + """가상 터미널에서 CLI 프로세스를 생성하고 입출력을 제어한다.""" + + def __init__(self, command: str = "claude") -> None: + self.command = command + self._process: pexpect.spawn | None = None + + @property + def is_alive(self) -> bool: + return self._process is not None and self._process.isalive() + + def start(self) -> None: + """프로세스를 시작한다.""" + logger.info("프로세스 시작: %s", self.command) + self._process = pexpect.spawn( + self.command, + encoding="utf-8", + timeout=None, + ) + + def send(self, text: str) -> None: + """프로세스에 텍스트 입력을 전달한다.""" + if not self.is_alive: + raise RuntimeError("프로세스가 실행 중이 아닙니다.") + assert self._process is not None + logger.debug("입력 전송: %s", text) + self._process.sendline(text) + + def read_output(self, timeout: int = 5) -> str: + """프로세스의 출력을 읽는다.""" + if not self.is_alive: + raise RuntimeError("프로세스가 실행 중이 아닙니다.") + assert self._process is not None + try: + self._process.expect(pexpect.TIMEOUT, timeout=timeout) + except pexpect.TIMEOUT: + pass + return self._process.before or "" + + def stop(self) -> None: + """프로세스를 종료한다.""" + if self._process is not None: + logger.info("프로세스 종료") + self._process.close(force=True) + self._process = None diff --git a/src/lazy_enter/slack_handler.py b/src/lazy_enter/slack_handler.py new file mode 100644 index 0000000..26d9100 --- /dev/null +++ b/src/lazy_enter/slack_handler.py @@ -0,0 +1,108 @@ +"""Slack Socket Mode 이벤트 핸들링.""" + +from __future__ import annotations + +import logging + +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler +from slack_sdk import WebClient + +from lazy_enter.config import Config + +logger = logging.getLogger(__name__) + + +class SlackHandler: + """Slack 앱과의 통신을 담당한다.""" + + def __init__(self, config: Config) -> None: + self.config = config + self.app = App(token=config.slack_bot_token) + self._handler = SocketModeHandler(self.app, config.slack_app_token) + self._on_message_callback: callable | None = None + self._on_command_callback: callable | None = None + + self._register_listeners() + + @property + def client(self) -> WebClient: + return self.app.client + + def _is_authorized(self, user_id: str, channel_id: str) -> bool: + """허가된 사용자·채널인지 확인한다.""" + if self.config.allowed_user_id and user_id != self.config.allowed_user_id: + return False + if ( + self.config.allowed_channel_id + and channel_id != self.config.allowed_channel_id + ): + return False + return True + + def _register_listeners(self) -> None: + """Slack 이벤트 리스너를 등록한다.""" + + @self.app.event("message") + def handle_message(event: dict, say: callable) -> None: + user_id = event.get("user", "") + channel_id = event.get("channel", "") + text = event.get("text", "") + + if not self._is_authorized(user_id, channel_id): + return + + if self._on_message_callback: + self._on_message_callback(text, channel_id) + + @self.app.command("/start-claude") + def handle_start(ack: callable, body: dict) -> None: + ack() + user_id = body.get("user_id", "") + channel_id = body.get("channel_id", "") + + if not self._is_authorized(user_id, channel_id): + return + + if self._on_command_callback: + self._on_command_callback("start", channel_id) + + @self.app.command("/stop-claude") + def handle_stop(ack: callable, body: dict) -> None: + ack() + user_id = body.get("user_id", "") + channel_id = body.get("channel_id", "") + + if not self._is_authorized(user_id, channel_id): + return + + if self._on_command_callback: + self._on_command_callback("stop", channel_id) + + def on_message(self, callback: callable) -> None: + """메시지 수신 콜백을 등록한다.""" + self._on_message_callback = callback + + def on_command(self, callback: callable) -> None: + """슬래시 커맨드 콜백을 등록한다.""" + self._on_command_callback = callback + + def send_message( + self, channel: str, text: str, thread_ts: str | None = None + ) -> None: + """슬랙 채널에 메시지를 전송한다.""" + self.client.chat_postMessage( + channel=channel, + text=text, + thread_ts=thread_ts, + ) + + def start(self) -> None: + """Socket Mode 핸들러를 시작한다.""" + logger.info("Slack Socket Mode 시작") + self._handler.start() + + def stop(self) -> None: + """Socket Mode 핸들러를 종료한다.""" + logger.info("Slack Socket Mode 종료") + self._handler.close() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..93d0014 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,11 @@ +"""Config 테스트.""" + +from lazy_enter.config import Config + + +def test_config_defaults(): + config = Config() + assert config.default_shell == "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_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..4c1c2c4 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,34 @@ +"""hooks 모듈 테스트.""" + +from lazy_enter.hooks import format_notification, parse_hook_event + + +def test_parse_hook_event_valid(): + raw = '{"type": "prompt", "tool": "Bash"}' + event = parse_hook_event(raw) + assert event["type"] == "prompt" + assert event["tool"] == "Bash" + + +def test_parse_hook_event_invalid(): + event = parse_hook_event("not json") + assert event == {} + + +def test_format_notification_prompt(): + event = {"type": "prompt", "tool": "Bash"} + msg = format_notification(event) + assert msg is not None + assert "승인 대기 중" in msg + + +def test_format_notification_completion(): + event = {"type": "completion", "summary": "리팩토링 완료"} + msg = format_notification(event) + assert msg is not None + assert "완료" in msg + + +def test_format_notification_unknown(): + event = {"type": "unknown"} + assert format_notification(event) is None diff --git a/tests/test_pty_manager.py b/tests/test_pty_manager.py new file mode 100644 index 0000000..856cb75 --- /dev/null +++ b/tests/test_pty_manager.py @@ -0,0 +1,16 @@ +"""PtyManager 테스트.""" + +from lazy_enter.pty_manager import PtyManager + + +def test_initial_state(): + pty = PtyManager("echo hello") + assert not pty.is_alive + + +def test_start_and_stop(): + pty = PtyManager("cat") + pty.start() + assert pty.is_alive + pty.stop() + assert not pty.is_alive -- 2.49.1