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
124 changes: 124 additions & 0 deletions skills/buildr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Buildr

**Bridge Claude Code to Telegram** - control your coding sessions from your phone.

Buildr mirrors your Claude Code session to a Telegram bot. Same conversation, two windows. No extra API tokens wasted - it's pure file-based IPC.

## What You Get

- **Full mirror**: Every CC response appears in Telegram, every TG message reaches CC
- **Permission forwarding**: Tool approval requests come to TG - reply YES/NO from your phone
- **STOP command**: Send `STOP` in Telegram to halt CC immediately
- **Await system**: When CC is idle, it waits for your TG message (no timeout, no session death)
- **Offline fallback**: If CC dies (laptop closed), messages are processed via `claude -p`
- **Persistence**: Optional tmux setup keeps CC alive when you close your laptop
- **Typing indicator**: Shows "typing..." animation before every message

## Requirements

- Node.js 18+
- Python 3
- Claude Code CLI (`claude`)
- PM2 (`npm install -g pm2`)
- A Telegram bot token (from [@BotFather](https://t.me/BotFather))
- Your Telegram user ID (from [@userinfobot](https://t.me/userinfobot))

## Install

```bash
git clone <repo-url>
cd buildr
./setup.sh <BOT_TOKEN> <USER_ID>
```

Or run interactively (prompts for token and ID):
```bash
./setup.sh
```

That's it. The setup:
1. Creates `~/.buildr/` with all files
2. Starts the relay daemon via PM2
3. Configures Claude Code hooks automatically
4. Sends a test message to your Telegram

## Laptop Persistence

To keep CC alive when you close your laptop:

```bash
~/.buildr/start-cc.sh
```

This starts Claude Code inside a tmux session that survives SSH disconnects. Attach/detach with:
```bash
tmux attach -t buildr-cc # attach
# Ctrl+B then D # detach
```

## Telegram Commands

| Command | Description |
|---------|-------------|
| `/status` | Check CC session status |
| `/help` | Show available commands |
| `/clear` | Clear message inbox |
| `/reconnect` | Info about reconnecting |
| `STOP` | Halt CC immediately |

When CC is waiting, reply:
- **YES** - continue
- **Any message** - CC receives it as instructions

## How It Works

```
┌─────────────┐ files ┌─────────────┐ HTTPS ┌──────────┐
│ Claude Code │ ←── inbox ─── │ Relay │ ←── poll ─── │ Telegram │
│ (hooks) │ ─── outbox ──→ │ (PM2 daemon)│ ─── send ──→ │ Bot │
│ │ ─── heartbeat │ │ │ │
└─────────────┘ └─────────────┘ └──────────┘
```

**Files** (all in `~/.buildr/`):
- `inbox.jsonl` - TG messages (relay writes, CC reads via hook)
- `outbox.jsonl` - CC responses (CC writes, relay sends to TG)
- `heartbeat` - CC liveness timestamp (hook writes on every tool call)
- `stop-flag` - STOP signal (relay creates, hook checks)
- `await-flag` - CC waiting for user (CC creates, hook polls)
- `config.env` - Bot token + user ID

**Components**:
- `relay.js` - Always-on PM2 daemon. Polls TG, sends outbox, monitors heartbeat
- `bridge-hook.py` - PreToolUse hook. Auto-heartbeat, STOP, await, message injection
- `perm-hook.sh` - PermissionRequest hook. Forwards approvals to TG

## Uninstall

```bash
./teardown.sh
```

Stops PM2 process, removes hooks from Claude Code, optionally deletes `~/.buildr/`.

## Security

- Only the configured user ID can interact with the bot
- All other Telegram users are silently ignored
- Bot token and user ID stored in `~/.buildr/config.env`

## Troubleshooting

**Messages not appearing in Telegram?**
```bash
pm2 logs buildr-relay # check relay logs
pm2 restart buildr-relay # restart if needed
```

**CC not reading TG messages?**
- Ensure Claude Code hooks are configured: check `~/.claude/settings.json`
- The PreToolUse hook runs before every tool call - CC must be actively working

**Session dying when laptop closes?**
- Use `~/.buildr/start-cc.sh` to run CC in tmux
- Or: relay automatically uses `claude -p` as offline fallback
96 changes: 96 additions & 0 deletions skills/buildr/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
name: buildr
description: Bridge Claude Code to Telegram - mirror your CC session to a Telegram bot. Send messages, approve permissions, stop tasks, and control coding sessions from your phone. One command setup with just a bot token and Telegram user ID.
license: MIT
---

# Buildr - Telegram Bridge for Claude Code

Bridge your Claude Code session to a Telegram bot. Same conversation, two windows. Control your coding sessions from your phone.

## Setup

Run the setup script with your Telegram bot token and user ID:

```bash
cd scripts
./setup.sh <BOT_TOKEN> <USER_ID>
```

Get these from:
- Bot token: [@BotFather](https://t.me/BotFather) on Telegram
- User ID: [@userinfobot](https://t.me/userinfobot) on Telegram

Setup handles everything automatically - creates `~/.buildr/`, starts PM2 relay daemon, configures Claude Code hooks.

## What It Does

- **Message mirroring**: Every CC response goes to Telegram, every TG message reaches CC
- **Permission forwarding**: Tool approval requests come to your phone - reply YES/NO
- **STOP command**: Send `STOP` to halt CC instantly from Telegram
- **Await system**: When CC is idle, it waits for your TG message (no timeout)
- **Offline fallback**: If CC dies (laptop closed), messages processed via `claude -p`
- **Persistence**: Optional tmux setup keeps CC alive when laptop closes

## Protocol

When this skill is active, follow these rules for every response:

### 1. Mirror to Telegram
Write every response to the outbox:
```
python3 -c "import json, os; home=os.environ.get('BUILDR_HOME',os.path.expanduser('~/.buildr')); open(os.path.join(home,'outbox.jsonl'),'a').write(json.dumps({'text':'YOUR MESSAGE HERE'}) + '\n')"
```

### 2. On Compaction
Send "COMPACTED" to Telegram so the user knows the session continued.

### 3. When Idle/Waiting
Create the await flag, then make any tool call:
```
touch ~/.buildr/await-flag
```
The hook notifies the user on Telegram and polls until they respond. No timeout.

## Rules
- Do NOT use curl or the Telegram API directly - use the outbox file only
- If user sends STOP on Telegram - halt immediately
- All Telegram messages arrive via the PreToolUse hook (blocks once to show you)
- Permission requests are forwarded to Telegram automatically
- The user can reply YES, NO, or send a custom message to any prompt

## Architecture

```
Claude Code <-- inbox.jsonl -- Relay Daemon (PM2) <-- poll -- Telegram Bot
--> outbox.jsonl -> --> send ->
--> heartbeat
```

**Components** (in `scripts/`):
- `relay.js` - Always-on PM2 daemon. Polls TG, sends outbox, monitors heartbeat
- `bridge-hook.py` - PreToolUse hook. Auto-heartbeat, STOP, await, message injection
- `perm-hook.sh` - PermissionRequest hook. Forwards tool approvals to TG
- `setup.sh` - One-command installer
- `teardown.sh` - Clean uninstaller

## Telegram Commands

- `/status` - Check CC session status
- `/help` - Show available commands
- `/clear` - Clear message inbox
- `STOP` - Halt CC immediately

## Uninstall

```bash
cd scripts
./teardown.sh
```

## Requirements

- Node.js 18+
- Python 3
- PM2 (`npm install -g pm2`)
- Claude Code CLI
175 changes: 175 additions & 0 deletions skills/buildr/scripts/bridge-hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
Buildr Bridge Hook - PreToolUse hook for CC <> Telegram bridge

Runs before every Claude Code tool call:
1. Auto-heartbeat (keeps relay daemon informed CC is alive)
2. STOP flag → block CC, auto-enter await mode
3. Await flag → notify user on TG, poll until they respond (NO TIMEOUT)
4. New unread messages → inject into CC context (block once)
5. Otherwise → allow tool call

Zero API calls. Pure file-based IPC.
"""
import json, os, sys, time

# --- Paths ---
BUILDR_HOME = os.environ.get('BUILDR_HOME', os.path.join(os.path.expanduser('~'), '.buildr'))

STOP_FLAG = os.path.join(BUILDR_HOME, 'stop-flag')
AWAIT_FLAG = os.path.join(BUILDR_HOME, 'await-flag')
INBOX = os.path.join(BUILDR_HOME, 'inbox.jsonl')
OUTBOX = os.path.join(BUILDR_HOME, 'outbox.jsonl')
SHOWN_FILE = os.path.join(BUILDR_HOME, 'hook-shown')
HEARTBEAT = os.path.join(BUILDR_HOME, 'heartbeat')

# --- Auto-heartbeat on every tool call ---
try:
with open(HEARTBEAT, 'w') as f:
f.write(str(int(time.time() * 1000)))
except:
pass

# --- Helpers ---
def read_inbox():
msgs = []
if os.path.exists(INBOX):
with open(INBOX) as f:
for line in f:
line = line.strip()
if line:
try:
msgs.append(json.loads(line))
except:
pass
return msgs

def get_last_shown():
try:
with open(SHOWN_FILE) as f:
return int(f.read().strip())
except:
return 0

def set_last_shown(uid):
with open(SHOWN_FILE, 'w') as f:
f.write(str(uid))

def write_outbox(text):
with open(OUTBOX, 'a') as f:
f.write(json.dumps({'text': text}) + '\n')

def output(decision, reason=None):
result = {"decision": decision}
if reason:
result["reason"] = reason
print(json.dumps(result))
sys.exit(0)

# --- 1. STOP check ---
if os.path.exists(STOP_FLAG):
try:
os.unlink(STOP_FLAG)
except:
pass
# Auto-create await flag so CC enters wait mode on next tool call
with open(AWAIT_FLAG, 'w') as f:
f.write('stop')
msgs = read_inbox()
recent = msgs[-5:]
lines = [f" - {m.get('from','User')}: {m.get('text','')[:200]}" for m in recent]
reason = (
"\U0001f6d1 STOP received from Telegram. Halting current work.\n\n"
"Recent messages:\n" + "\n".join(lines) + "\n\n"
"Await mode auto-created. Make any tool call to wait for user input.\n"
"The hook will notify the user on Telegram and wait for their response."
)
output("block", reason)

# --- 2. Await mode (poll TG until user responds - NO TIMEOUT) ---
if os.path.exists(AWAIT_FLAG):
try:
os.unlink(AWAIT_FLAG)
except:
pass

# Clean up stale permission files so relay doesn't intercept await messages
for f in ['perm-reqid', 'perm-response']:
p = os.path.join(BUILDR_HOME, f)
if os.path.exists(p):
try: os.unlink(p)
except: pass

# Notify user on TG
write_outbox(
"CC is waiting for you.\n\n"
"Reply:\n"
"\u2022 YES - continue\n"
"\u2022 Or send any message with instructions\n\n"
"(No timeout - waiting until you respond)"
)

# Baseline: current max update_id
msgs = read_inbox()
baseline = max((m.get('update_id', 0) for m in msgs), default=0)

# Poll forever
while True:
time.sleep(2)
# Keep heartbeat alive during polling
try:
with open(HEARTBEAT, 'w') as f:
f.write(str(int(time.time() * 1000)))
except:
pass
msgs = read_inbox()
for m in msgs:
uid = m.get('update_id', 0)
text = m.get('text', '').strip()
if uid > baseline and text.lower() != 'stop':
set_last_shown(uid)
if text.lower() in ('yes', 'y'):
output("allow")
else:
reason = (
"User responded on Telegram:\n\n"
f" [{m.get('from','User')}]: {text[:500]}\n\n"
"Process this message and respond.\n"
"Mirror your reply to TG via outbox:\n"
f"python3 -c \"import json; open('{OUTBOX}','a').write(json.dumps({{'text':'YOUR REPLY'}}) + '\\n')\""
)
output("block", reason)

# --- 3. New unread messages → inject once ---
last_shown = get_last_shown()
msgs = read_inbox()
new_msgs = []
max_uid = last_shown

SKIP = {'yes', 'y', 'no', 'n', 'stop'}
for m in msgs:
uid = m.get('update_id', 0)
text = m.get('text', '').strip().lower()
if uid > last_shown and text not in SKIP:
new_msgs.append(m)
max_uid = max(max_uid, uid)

if new_msgs:
set_last_shown(max_uid)
lines = []
for m in new_msgs:
sender = m.get('from', 'User')
text = m.get('text', '')[:500]
lines.append(f" [{sender}]: {text}")

reason = (
"New Telegram message(s):\n\n" +
"\n".join(lines) + "\n\n"
"Respond to these, then mirror reply to TG via outbox:\n"
f"python3 -c \"import json; open('{OUTBOX}','a').write(json.dumps({{'text':'YOUR REPLY'}}) + '\\n')\"\n\n"
"After responding, continue with your current task."
)
output("block", reason)

# --- 4. All clear ---
output("allow")
Loading