Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.4.3] - 2026-06-14

### Changed

- 💬 **Telegram rich message formatting.** Telegram messages now use `sendRichMessage` / `sendRichMessageDraft` (Bot API `InputRichMessage`) for properly rendered Markdown with bold, italic, code blocks, and links. Falls back gracefully to plain text when the API is unavailable.
- 📝 **Increased Telegram streaming buffer.** The streaming buffer limit for Telegram was raised from 4 096 to 32 768 characters, allowing longer messages to stream without premature chunking.
- 🔒 **Proxy middleware authentication.** The reverse-proxy middleware now verifies the `cptr_session` cookie before forwarding requests, preventing unauthenticated access to proxied local services.
- 📖 **README refresh.** Updated the README with revised feature descriptions and setup instructions.

## [0.4.2] - 2026-06-14

### Fixed
Expand Down
90 changes: 72 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,40 +12,94 @@ Push a hotfix from the train. Check on a deploy from bed. Ship a side project fr

Close the tab. Come back tomorrow on any device. Everything is where you left it. Sessions survive disconnects. Your work doesn't care which screen you're on.

AI is there if you want it. Bring your own key. Works fine without it.

Life is short. Touch grass.

## Design principles

**Mobile is first-class.** The interface is built for the phone. Touch-native, portrait-native, designed for the screen people carry. Sessions survive disconnects because on a phone, they will. If a feature only works at a desk, it's not done.

**Your machine.** cptr serves the machine it runs on. The local filesystem, the local shell, local state. Where that machine lives is up to you.

**Computer, not chat.** The core is the filesystem, the terminal, and git. Files over apps. Plain files on your machine, not content trapped inside another product. AI conversations are files too: searchable, editable, movable, commit-able. cptr is a window into the real system, not a container on top of it.

Read our [Manifesto](MANIFESTO.md).

## Install

```bash
pip install cptr
cptr run
```

Or with [uv](https://docs.astral.sh/uv/): `uvx cptr@latest run`

## Run

```bash
cptr run
```

Opens in your browser. From other devices:

```bash
cptr run --host 0.0.0.0
```

## What you get

| | |
|---|---|
| 📁 **File browser** | Navigate, create, rename, upload, drag and drop. Icons by type, sizes at a glance. |
| ⌨️ **Terminal** | Full PTY-backed shell in the browser. Anything you'd run at your desk. |
| 🔀 **Git** | Stage, commit, diff, branch, push. Visual changes view. No command line required. |
| ✏️ **Editor** | Syntax-highlighted editing with tabs. Open multiple files side by side. |
| 📂 **Workspaces** | Multiple projects, one instance. Switch without losing your place. |
| 🔍 **Search** | Find files by name, search across file contents and chat history. ⌘K to find anything. |
| 📱 **Mobile-first** | Not a desktop UI made smaller. Built for the screen in your pocket. |
| 🔄 **Sessions persist** | Terminal keeps running when you close the tab. Come back on any device. |

## AI agent

Bring your own API key. Works with OpenAI, Anthropic, Ollama, or any OpenAI-compatible endpoint.

| | |
|---|---|
| 💬 **Chat** | Built-in AI with streaming responses and tool calling. Not just conversation: it can act. |
| 🔧 **File tools** | AI reads, writes, edits, and searches your codebase directly. |
| ▶️ **Run commands** | AI executes shell commands and reads the output. Foreground or background. |
| 🌐 **Web browsing** | Navigate pages, click elements, fill forms, take screenshots. |
| 🔍 **Web search** | Brave, DuckDuckGo, Exa, Tavily, Perplexity, or any chat completions endpoint. |
| 🖼️ **Image understanding** | AI reads and describes images and screenshots from your workspace. |
| 📋 **Plan mode** | Request an implementation plan before the AI writes a single line. |
| ✏️ **Output editing** | Review and edit AI-generated changes before applying. |
| 📎 **File mentions** | Type `@` to give the AI context about specific files. |
| 🧩 **Skills** | Reusable instruction sets (SKILL.md files). Type `$` to mention one. |
| ⏱️ **Automations** | Schedule recurring AI tasks. "Run tests every morning." "Deploy every Friday." |
| 🤖 **Sub-agents** | AI spins up parallel workers for complex tasks. Each gets full tool access. |
| 🔌 **Tool servers** | Connect external tools via MCP or OpenAPI. |
| 🧠 **Context compaction** | Long conversations are automatically summarised to stay fast. |

## Messaging bots

Connect the AI to your chat apps. Full tool access, streaming responses, conversations synced back to the web UI.

**Telegram** · **Discord** · **Slack** · **WhatsApp** · **Signal**

Message your computer from wherever you are. Ask it to check a build, push a fix, or explain a file. Switch workspaces with `/workspace`, start fresh with `/new`.

## Gateway API

cptr exposes an OpenAI-compatible API (`/v1/chat/completions`). Any client that speaks OpenAI, including [Open WebUI](https://github.com/open-webui/open-webui), can use each cptr workspace as a model with full agent capabilities: file access, terminal, web search, tools.

## More

| | |
|---|---|
| 🎙️ **Voice memos** | Record audio, auto-transcribe to markdown. |
| 💬 **Message queue** | Queue follow-up messages while the AI is responding. |
| 🔔 **Notifications** | Browser notifications and webhooks (Slack, Discord, Teams) when tasks finish. |
| 📊 **Usage** | Token counts and timing on every response. |
| 📄 **System prompts** | Per-model, per-workspace, or global. Template variables included. |
| ⌨️ **Keyboard shortcuts** | Customisable keybindings with a settings panel. |
| 🌍 **10 languages** | EN, DE, ES, FR, JA, KO, PT-BR, RU, ZH-CN, ZH-TW. |
| 🔐 **Auth** | Username/password with JWT sessions. Signup toggle for admins. |

## Design principles

**Mobile is first-class.** The interface is built for the phone. Touch-native, portrait-native, designed for the screen people carry. Sessions survive disconnects because on a phone, they will. If a feature only works at a desk, it's not done.

**Your machine.** cptr serves the machine it runs on. The local filesystem, the local shell, local state. Where that machine lives is up to you.

**Computer, not chat.** The core is the filesystem, the terminal, and git. Files over apps. Plain files on your machine, not content trapped inside another product. AI conversations are files too: searchable, editable, movable, commit-able. cptr is a window into the real system, not a container on top of it.

Read our [Manifesto](MANIFESTO.md).



## Docker

Run cptr with Docker:
Expand Down
12 changes: 12 additions & 0 deletions cptr/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ async def auth_middleware(request: Request, call_next):

app.add_middleware(ProxyFallbackMiddleware)

# CORS middleware: uses CPTR_CORS_ALLOWED_ORIGINS env var (default "*").
from fastapi.middleware.cors import CORSMiddleware
from cptr.env import CORS_ALLOWED_ORIGINS

app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ALLOWED_ORIGINS if isinstance(CORS_ALLOWED_ORIGINS, list) else ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)


# Path normalization middleware (Windows: \ → / in JSON responses)
import platform
Expand Down
10 changes: 10 additions & 0 deletions cptr/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,13 @@
# ── Automation scheduler ────────────────────────────────────
AUTOMATION_POLL_INTERVAL = int(os.environ.get("AUTOMATION_POLL_INTERVAL", "10"))

# ── CORS ────────────────────────────────────────────────────
# Socket.IO CORS allowed origins.
# Default → "*" (allow all origins)
# Comma-separated list → allow specific origins only
# e.g. "https://example.com,https://app.example.com"
_cors_raw = os.environ.get("CPTR_CORS_ALLOWED_ORIGINS", "*")
if _cors_raw.strip() == "*":
CORS_ALLOWED_ORIGINS = "*"
else:
CORS_ALLOWED_ORIGINS = [o.strip() for o in _cors_raw.split(",") if o.strip()] or "*"
3 changes: 2 additions & 1 deletion cptr/socket/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
import socketio

from cptr.utils.config import check_access
from cptr.env import CORS_ALLOWED_ORIGINS

sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins=CORS_ALLOWED_ORIGINS)

# user_id → set of connected sids
_user_sids: dict[str, set[str]] = {}
Expand Down
68 changes: 66 additions & 2 deletions cptr/utils/adapters/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import httpx

from cptr.utils.bridge import BaseAdapter, MessageEvent, chunk_message
from cptr.utils.bridge import Attachment, BaseAdapter, MessageEvent, chunk_message

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -232,7 +232,29 @@ async def _handle_message_create(self, data: dict) -> None:
return

content = data.get("content", "").strip()
if not content:

# Process Discord attachments
attachments: list[Attachment] = []
for att in data.get("attachments", []):
url = att.get("url")
if not url:
continue
file_data = await self._download_url(url)
if not file_data:
continue
fname = att.get("filename", "file")
ctype = att.get("content_type", "application/octet-stream")
if ctype.startswith("image/"):
att_type = "image"
elif ctype.startswith("audio/"):
att_type = "audio"
else:
att_type = "document"
attachments.append(Attachment(
type=att_type, filename=fname, data=file_data, mime_type=ctype,
))

if not content and not attachments:
return

event = MessageEvent(
Expand All @@ -241,11 +263,53 @@ async def _handle_message_create(self, data: dict) -> None:
sender_id=author.get("id", ""),
sender_name=author.get("global_name") or author.get("username", "User"),
text=content,
attachments=attachments,
)

if self.on_message:
await self.on_message(event)

async def _download_url(self, url: str) -> bytes | None:
"""Download a file from a URL (Discord CDN)."""
if not self._http:
return None
try:
resp = await self._http.get(url)
if resp.status_code == 200:
return resp.content
logger.warning("[discord] File download failed: HTTP %d", resp.status_code)
except Exception:
logger.exception("[discord] Failed to download %s", url)
return None

async def send_photo(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None:
"""Send a photo as a file attachment."""
return await self._send_file(chat_id, data, filename, caption)

async def send_document(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None:
"""Send a document as a file attachment."""
return await self._send_file(chat_id, data, filename, caption)

async def _send_file(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None:
"""Send a file via Discord multipart upload."""
if not self._http:
return None
try:
files = {"files[0]": (filename, data, "application/octet-stream")}
form_data = {}
if caption:
form_data["content"] = caption[:MAX_MESSAGE_LEN]
resp = await self._http.post(
f"{API_BASE}/channels/{chat_id}/messages",
files=files,
data=form_data,
)
if resp.status_code == 200:
return resp.json().get("id")
except Exception:
logger.exception("[discord] Failed to send file")
return None


async def verify_token(token: str) -> dict:
async with httpx.AsyncClient(timeout=10) as client:
Expand Down
85 changes: 83 additions & 2 deletions cptr/utils/adapters/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import httpx

from cptr.utils.bridge import BaseAdapter, MessageEvent, chunk_message
from cptr.utils.bridge import Attachment, BaseAdapter, MessageEvent, chunk_message

logger = logging.getLogger(__name__)

Expand All @@ -29,6 +29,16 @@
RECONNECT_MAX_DELAY = 60.0


def _ext_for_mime(mime: str) -> str:
"""Map common MIME types to file extensions."""
mapping = {
"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif",
"image/webp": ".webp", "audio/ogg": ".ogg", "audio/mpeg": ".mp3",
"audio/aac": ".aac", "video/mp4": ".mp4", "application/pdf": ".pdf",
}
return mapping.get(mime, "")


def _parse_token(token: str) -> tuple[str, str]:
"""Parse 'base_url|phone_number' format."""
if "|" in token:
Expand Down Expand Up @@ -171,7 +181,29 @@ async def _process_message(self, msg: dict) -> None:
return

text = (data_message.get("message") or "").strip()
if not text:

# Process attachments from signal-cli
attachments: list[Attachment] = []
for att in data_message.get("attachments", []):
att_id = att.get("id")
if not att_id:
continue
file_data = await self._download_attachment(att_id)
if not file_data:
continue
content_type = att.get("contentType", "application/octet-stream")
fname = att.get("filename") or f"attachment{_ext_for_mime(content_type)}"
if content_type.startswith("image/"):
att_type = "image"
elif content_type.startswith("audio/"):
att_type = "audio"
else:
att_type = "document"
attachments.append(Attachment(
type=att_type, filename=fname, data=file_data, mime_type=content_type,
))

if not text and not attachments:
return

source = envelope.get("source", "")
Expand All @@ -187,11 +219,60 @@ async def _process_message(self, msg: dict) -> None:
sender_id=source,
sender_name=source_name,
text=text,
attachments=attachments,
)

if self.on_message:
await self.on_message(event)

async def _download_attachment(self, attachment_id: str) -> bytes | None:
"""Download an attachment from signal-cli REST API."""
if not self._http:
return None
try:
resp = await self._http.get(
f"{self._base_url}/v1/attachments/{attachment_id}",
)
if resp.status_code == 200:
return resp.content
logger.warning("[signal] Attachment download failed: HTTP %d", resp.status_code)
except Exception:
logger.exception("[signal] Failed to download attachment %s", attachment_id)
return None

async def send_photo(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None:
"""Send a photo as a base64 attachment."""
return await self._send_with_attachment(chat_id, data, filename, caption)

async def send_document(self, chat_id: str, data: bytes, filename: str, caption: str = "") -> str | None:
"""Send a document as a base64 attachment."""
return await self._send_with_attachment(chat_id, data, filename, caption)

async def _send_with_attachment(
self, chat_id: str, data: bytes, filename: str, caption: str = "",
) -> str | None:
"""Send a message with a base64-encoded attachment via signal-cli."""
if not self._http:
return None
import base64
try:
resp = await self._http.post(
f"{self._base_url}/v2/send",
json={
"message": caption or "",
"number": self._phone,
"recipients": [chat_id],
"base64_attachments": [
base64.b64encode(data).decode("ascii")
],
},
)
result = resp.json()
return str(result.get("timestamp", ""))
except Exception:
logger.exception("[signal] Failed to send attachment")
return None


async def verify_token(token: str) -> dict:
"""Verify signal-cli REST API connection and phone number."""
Expand Down
Loading
Loading