Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,11 @@ TMUX_SESSION_NAME=ccbot
# Claude command to run in new windows (optional, defaults to "claude")
CLAUDE_COMMAND=claude

# Claude permission mode (optional; unset = use claude's default).
# One of: default, acceptEdits, plan, auto, bypassPermissions.
# Prefer `auto` over `bypassPermissions` on headless hosts — auto keeps
# the safety classifier that blocks risky actions; bypass disables it.
# CLAUDE_PERMISSION_MODE=auto

# Monitor polling interval in seconds (optional, defaults to 2.0)
MONITOR_POLL_INTERVAL=2.0
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ ALLOWED_USERS=your_telegram_user_id
| `CCBOT_DIR` | `~/.ccbot` | Config/state directory (`.env` loaded from here) |
| `TMUX_SESSION_NAME` | `ccbot` | Tmux session name |
| `CLAUDE_COMMAND` | `claude` | Command to run in new windows |
| `CLAUDE_PERMISSION_MODE` | _(unset)_ | `default` / `acceptEdits` / `plan` / `auto` / `bypassPermissions`. Appended as `--permission-mode <mode>` when set. |
| `MONITOR_POLL_INTERVAL` | `2.0` | Polling interval in seconds |
| `CCBOT_SHOW_HIDDEN_DIRS` | `false` | Show hidden (dot) directories in directory browser |
| `OPENAI_API_KEY` | _(none)_ | OpenAI API key for voice message transcription |
Expand All @@ -101,11 +102,13 @@ ALLOWED_USERS=your_telegram_user_id
Message formatting is always HTML via `chatgpt-md-converter` (`chatgpt_md_converter` package).
There is no runtime formatter switch to MarkdownV2.

> If running on a VPS where there's no interactive terminal to approve permissions, consider:
> If running on a VPS where there's no interactive terminal to approve permissions, consider using **auto mode** — Claude Code runs actions automatically but a background classifier blocks risky ones (exfiltration, force-pushes to main, arbitrary downloads, etc.):
>
> ```
> CLAUDE_COMMAND=IS_SANDBOX=1 claude --dangerously-skip-permissions
> CLAUDE_PERMISSION_MODE=auto
> ```
>
> As a last resort, `CLAUDE_PERMISSION_MODE=bypassPermissions` skips all prompts but also disables the classifier — prefer `auto` when your plan/model supports it.

## Hook Setup (Recommended)

Expand Down
8 changes: 6 additions & 2 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ ALLOWED_USERS=your_telegram_user_id
| `CCBOT_DIR` | `~/.ccbot` | 配置/状态目录(`.env` 从此目录加载) |
| `TMUX_SESSION_NAME` | `ccbot` | tmux 会话名称 |
| `CLAUDE_COMMAND` | `claude` | 新窗口中运行的命令 |
| `CLAUDE_PERMISSION_MODE` | _(未设置)_ | `default` / `acceptEdits` / `plan` / `auto` / `bypassPermissions`。设置后会在启动 claude 时追加 `--permission-mode <mode>`。 |
| `MONITOR_POLL_INTERVAL` | `2.0` | 轮询间隔(秒) |
| `CCBOT_SHOW_HIDDEN_DIRS` | `false` | 在目录浏览器中显示隐藏(点开头)目录 |
| `OPENAI_API_KEY` | _(无)_ | OpenAI API 密钥,用于语音消息转录 |
Expand All @@ -98,10 +99,13 @@ ALLOWED_USERS=your_telegram_user_id
消息格式化目前固定为 HTML,使用 `chatgpt-md-converter`(`chatgpt_md_converter` 包)。
不再提供运行时切换到 MarkdownV2 的开关。

> 如果在 VPS 上运行且没有交互终端来批准权限,可以考虑:
> 如果在 VPS 上运行且没有交互终端来批准权限,建议使用 **auto 模式** —— Claude Code 会自动执行动作,同时后台分类器会拦截危险操作(数据外泄、强推 main、任意下载等):
>
> ```
> CLAUDE_COMMAND=IS_SANDBOX=1 claude --dangerously-skip-permissions
> CLAUDE_PERMISSION_MODE=auto
> ```
>
> 作为兜底方案,`CLAUDE_PERMISSION_MODE=bypassPermissions` 会跳过所有确认,但也会关闭分类器 —— 如果你的套餐/模型支持,优先使用 `auto`。

## Hook 设置(推荐)

Expand Down
7 changes: 5 additions & 2 deletions README_RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ ALLOWED_USERS=your_telegram_user_id
| `CCBOT_DIR` | `~/.ccbot` | Каталог конфигурации/состояния (`.env` грузится отсюда) |
| `TMUX_SESSION_NAME` | `ccbot` | Имя tmux-сессии |
| `CLAUDE_COMMAND` | `claude` | Команда запуска в новых окнах |
| `CLAUDE_PERMISSION_MODE` | _(не задано)_ | `default` / `acceptEdits` / `plan` / `auto` / `bypassPermissions`. Добавляется как `--permission-mode <mode>` при запуске claude. |
| `MONITOR_POLL_INTERVAL` | `2.0` | Интервал опроса в секундах |
| `CCBOT_SHOW_HIDDEN_DIRS` | `false` | Показывать скрытые (dot) директории в браузере каталогов |
| `OPENAI_API_KEY` | _(нет)_ | API-ключ OpenAI для транскрипции голосовых сообщений |
Expand All @@ -99,11 +100,13 @@ ALLOWED_USERS=your_telegram_user_id
Форматирование сообщений всегда HTML через `chatgpt-md-converter` (`chatgpt_md_converter`).
Переключателя формата на MarkdownV2 во время выполнения нет.

> Если бот запущен на VPS без интерактивного терминала для подтверждений, можно использовать:
> Если бот запущен на VPS без интерактивного терминала для подтверждений, используйте **auto-режим** — Claude Code выполняет действия автоматически, а фоновый классификатор блокирует опасные (утечки, force-push в main, произвольные загрузки и т. п.):
>
> ```
> CLAUDE_COMMAND=IS_SANDBOX=1 claude --dangerously-skip-permissions
> CLAUDE_PERMISSION_MODE=auto
> ```
>
> В крайнем случае `CLAUDE_PERMISSION_MODE=bypassPermissions` пропускает все подтверждения, но отключает и классификатор — предпочитайте `auto`, если ваш план/модель это поддерживают.

## Настройка Hook (рекомендуется)

Expand Down
19 changes: 19 additions & 0 deletions src/ccbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
# Env vars that must not leak to child processes (e.g. Claude Code via tmux)
SENSITIVE_ENV_VARS = {"TELEGRAM_BOT_TOKEN", "ALLOWED_USERS", "OPENAI_API_KEY"}

# Valid values for CLAUDE_PERMISSION_MODE (mirrors `claude --permission-mode`).
# Empty string / unset means "don't pass the flag".
CLAUDE_PERMISSION_MODES = frozenset(
{"default", "acceptEdits", "plan", "auto", "bypassPermissions"}
)


class Config:
"""Application configuration loaded from environment variables."""
Expand Down Expand Up @@ -64,6 +70,19 @@ def __init__(self) -> None:
# Claude command to run in new windows
self.claude_command = os.getenv("CLAUDE_COMMAND", "claude")

# Optional Claude permission mode — appended as `--permission-mode <mode>`
# when launching claude. Prefer `auto` over `bypassPermissions`: auto mode
# keeps a safety classifier that blocks risky actions, while bypass skips
# all checks. See https://code.claude.com/docs/en/permission-modes.md
permission_mode = os.getenv("CLAUDE_PERMISSION_MODE", "").strip()
if permission_mode and permission_mode not in CLAUDE_PERMISSION_MODES:
raise ValueError(
f"CLAUDE_PERMISSION_MODE={permission_mode!r} is invalid. "
f"Expected one of: {', '.join(sorted(CLAUDE_PERMISSION_MODES))}, "
"or leave unset."
)
self.claude_permission_mode: str = permission_mode

# All state files live under config_dir
self.state_file = self.config_dir / "state.json"
self.session_map_file = self.config_dir / "session_map.json"
Expand Down
27 changes: 24 additions & 3 deletions src/ccbot/tmux_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@
logger = logging.getLogger(__name__)


def build_claude_command(
base_command: str,
*,
permission_mode: str = "",
resume_session_id: str | None = None,
) -> str:
"""Compose the shell command used to launch Claude Code in a new window.

`--permission-mode` is placed before `--resume` so mode applies to the
resumed session. Both args are only appended when set.
"""
cmd = base_command
if permission_mode:
cmd = f"{cmd} --permission-mode {permission_mode}"
if resume_session_id:
cmd = f"{cmd} --resume {resume_session_id}"
return cmd


@dataclass
class TmuxWindow:
"""Information about a tmux window."""
Expand Down Expand Up @@ -418,9 +437,11 @@ def _create_and_start() -> tuple[bool, str, str, str]:
if start_claude:
pane = window.active_pane
if pane:
cmd = config.claude_command
if resume_session_id:
cmd = f"{cmd} --resume {resume_session_id}"
cmd = build_claude_command(
config.claude_command,
permission_mode=config.claude_permission_mode,
resume_session_id=resume_session_id,
)
pane.send_keys(cmd, enter=True)

logger.info(
Expand Down
37 changes: 37 additions & 0 deletions tests/ccbot/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,43 @@ def test_ccbot_projects_path_takes_priority(self, monkeypatch):
assert cfg.claude_projects_path == Path("/priority/path")


@pytest.mark.usefixtures("_base_env")
class TestConfigPermissionMode:
def test_default_is_empty(self, monkeypatch):
monkeypatch.delenv("CLAUDE_PERMISSION_MODE", raising=False)
cfg = Config()
assert cfg.claude_permission_mode == ""

@pytest.mark.parametrize(
"mode", ["default", "acceptEdits", "plan", "auto", "bypassPermissions"]
)
def test_accepts_valid_modes(self, monkeypatch, mode):
monkeypatch.setenv("CLAUDE_PERMISSION_MODE", mode)
cfg = Config()
assert cfg.claude_permission_mode == mode

def test_whitespace_trimmed(self, monkeypatch):
monkeypatch.setenv("CLAUDE_PERMISSION_MODE", " auto ")
cfg = Config()
assert cfg.claude_permission_mode == "auto"

def test_empty_string_is_no_op(self, monkeypatch):
monkeypatch.setenv("CLAUDE_PERMISSION_MODE", "")
cfg = Config()
assert cfg.claude_permission_mode == ""

def test_rejects_unknown_mode(self, monkeypatch):
monkeypatch.setenv("CLAUDE_PERMISSION_MODE", "yolo")
with pytest.raises(ValueError, match="CLAUDE_PERMISSION_MODE"):
Config()

def test_case_sensitive(self, monkeypatch):
# Claude CLI is case-sensitive (`acceptEdits` not `acceptedits`).
monkeypatch.setenv("CLAUDE_PERMISSION_MODE", "Auto")
with pytest.raises(ValueError, match="CLAUDE_PERMISSION_MODE"):
Config()


@pytest.mark.usefixtures("_base_env")
class TestConfigOpenAI:
def test_openai_defaults(self, monkeypatch):
Expand Down
46 changes: 46 additions & 0 deletions tests/ccbot/test_tmux_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Unit tests for build_claude_command — shell command string assembly."""

import pytest

from ccbot.tmux_manager import build_claude_command


class TestBuildClaudeCommand:
def test_plain_command(self):
assert build_claude_command("claude") == "claude"

def test_preserves_custom_base_command(self):
# Matches the README pattern `IS_SANDBOX=1 claude`.
assert build_claude_command("IS_SANDBOX=1 claude") == "IS_SANDBOX=1 claude"

def test_permission_mode_appended(self):
assert (
build_claude_command("claude", permission_mode="auto")
== "claude --permission-mode auto"
)

def test_empty_permission_mode_is_no_op(self):
assert build_claude_command("claude", permission_mode="") == "claude"

def test_resume_only(self):
assert (
build_claude_command("claude", resume_session_id="abc-123")
== "claude --resume abc-123"
)

def test_permission_mode_precedes_resume(self):
# Placing --permission-mode before --resume ensures the flag applies
# to the resumed session (CLI order matters for some claude versions).
assert (
build_claude_command(
"claude", permission_mode="auto", resume_session_id="abc-123"
)
== "claude --permission-mode auto --resume abc-123"
)

@pytest.mark.parametrize(
"mode", ["default", "acceptEdits", "plan", "auto", "bypassPermissions"]
)
def test_each_mode_roundtrips(self, mode):
result = build_claude_command("claude", permission_mode=mode)
assert result == f"claude --permission-mode {mode}"
Loading