diff --git a/.gitignore b/.gitignore index cb76b31..1797bcc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ resources/ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ +.venv/ +*.db diff --git a/bin/telecoder b/bin/telecoder new file mode 100755 index 0000000..8e86b39 --- /dev/null +++ b/bin/telecoder @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -euo pipefail + +# TeleCoder — run a coding agent on your own VPS. +# A thin CLI that composes: claude CLI + git + tmux + sqlite3. + +VERSION="0.1.0" + +# Resolve lib directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="${SCRIPT_DIR}/../lib" + +# Load config +if [ -f "${TELECODER_CONFIG:-}" ]; then + # shellcheck source=/dev/null + source "$TELECODER_CONFIG" +elif [ -f "$HOME/.config/telecoder/config.sh" ]; then + source "$HOME/.config/telecoder/config.sh" +elif [ -f "/etc/telecoder/config.sh" ]; then + source "/etc/telecoder/config.sh" +fi + +# Defaults +TELECODER_DATA="${TELECODER_DATA:-$HOME/.telecoder}" +TELECODER_RUNTIME="${TELECODER_RUNTIME:-claude}" +TELECODER_BRANCH_PREFIX="${TELECODER_BRANCH_PREFIX:-telecoder/}" +TELECODER_AUTO_PUSH="${TELECODER_AUTO_PUSH:-false}" + +# Load library modules +source "${LIB_DIR}/db.sh" +source "${LIB_DIR}/session.sh" +source "${LIB_DIR}/verify.sh" + +# --- Commands --- + +cmd_init() { + mkdir -p "${TELECODER_DATA}"/{workspaces,logs} + db_init + echo "TeleCoder initialized at ${TELECODER_DATA}" + + local config_dest="$HOME/.config/telecoder/config.sh" + if [ ! -f "$config_dest" ]; then + mkdir -p "$(dirname "$config_dest")" + local example="${SCRIPT_DIR}/../config.example.sh" + if [ -f "$example" ]; then + cp "$example" "$config_dest" + echo "Config template copied to $config_dest" + fi + fi + + echo "" + echo "Next: edit $config_dest and set TELECODER_RUNTIME if needed." +} + +cmd_create() { + local repo_url="" repo_path="" branch="" test_cmd="" lint_cmd="" + + while [ $# -gt 0 ]; do + case "$1" in + --repo-url) repo_url="$2"; shift 2 ;; + --repo-path) repo_path="$2"; shift 2 ;; + --branch) branch="$2"; shift 2 ;; + --test-cmd) test_cmd="$2"; shift 2 ;; + --lint-cmd) lint_cmd="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; return 1 ;; + esac + done + + local id + id=$(session_create "$repo_url" "$repo_path" "$branch" "$test_cmd" "$lint_cmd") + echo "Session created: $id" +} + +cmd_run() { + if [ $# -lt 2 ]; then + echo "Usage: telecoder run " >&2 + return 1 + fi + local id="$1"; shift + local prompt="$*" + session_run "$id" "$prompt" +} + +cmd_stop() { + if [ $# -lt 1 ]; then + echo "Usage: telecoder stop " >&2 + return 1 + fi + session_stop "$1" +} + +cmd_list() { + local status_filter="" + if [ $# -gt 0 ] && [ "$1" = "--status" ] && [ $# -gt 1 ]; then + status_filter="$2" + fi + session_list "$status_filter" +} + +cmd_inspect() { + if [ $# -lt 1 ]; then + echo "Usage: telecoder inspect " >&2 + return 1 + fi + session_inspect "$1" +} + +cmd_logs() { + if [ $# -lt 1 ]; then + echo "Usage: telecoder logs [--stream stderr] [--tail N]" >&2 + return 1 + fi + local id="$1"; shift + local stream="stdout" tail_n="100" + while [ $# -gt 0 ]; do + case "$1" in + --stream) stream="$2"; shift 2 ;; + --tail) tail_n="$2"; shift 2 ;; + *) shift ;; + esac + done + session_logs "$id" "$stream" "$tail_n" +} + +cmd_attach() { + if [ $# -lt 1 ]; then + echo "Usage: telecoder attach " >&2 + return 1 + fi + session_attach "$1" +} + +cmd_verify() { + if [ $# -lt 1 ]; then + echo "Usage: telecoder verify " >&2 + return 1 + fi + session_verify "$1" +} + +cmd_delete() { + if [ $# -lt 1 ]; then + echo "Usage: telecoder delete " >&2 + return 1 + fi + echo -n "Delete session $1? [y/N] " + read -r confirm + if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then + session_delete "$1" + fi +} + +cmd_help() { + cat < [options] + +Commands: + init First-time setup + create [options] Create a new session + --repo-url Git repo to clone + --repo-path Local repo path + --branch Branch to create/use + --test-cmd Test command + --lint-cmd Lint command + run Run a prompt in a session + stop Stop a running session + list [--status ] List sessions + inspect Show session details + logs [--stream stderr] View session logs + attach Attach to tmux session + verify Run tests/lint + delete Delete a session + help Show this help + +Session runs in tmux. Close your terminal — it keeps going. +Come back with: telecoder attach +EOF +} + +# --- Main dispatch --- + +cmd="${1:-help}" +shift || true + +case "$cmd" in + init) cmd_init "$@" ;; + create) cmd_create "$@" ;; + run) cmd_run "$@" ;; + stop) cmd_stop "$@" ;; + list|ls) cmd_list "$@" ;; + inspect) cmd_inspect "$@" ;; + logs) cmd_logs "$@" ;; + attach) cmd_attach "$@" ;; + verify) cmd_verify "$@" ;; + delete) cmd_delete "$@" ;; + help|-h|--help) cmd_help ;; + version|--version) echo "telecoder v${VERSION}" ;; + *) echo "Unknown command: $cmd. Run 'telecoder help'." >&2; exit 1 ;; +esac diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..e8c11bc --- /dev/null +++ b/bot/main.py @@ -0,0 +1,171 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "python-telegram-bot>=21.0", +# ] +# /// +"""Telegram bot for TeleCoder — relay messages straight to Claude Code.""" + +import logging +import os + +from telegram import Update +from telegram.ext import ( + ApplicationBuilder, + CommandHandler, + ContextTypes, + MessageHandler, + filters, +) + +from bot import telecoder + +logging.basicConfig(level=logging.INFO) +log = logging.getLogger("telecoder-bot") + +# Optional: restrict to specific Telegram user IDs for security +ALLOWED_USERS: set[int] = set() +_allowed = os.environ.get("TELECODER_TG_ALLOWED_USERS", "") +if _allowed: + ALLOWED_USERS = {int(uid.strip()) for uid in _allowed.split(",") if uid.strip()} + + +def _authorized(update: Update) -> bool: + if not ALLOWED_USERS: + return True # no restriction configured + return update.effective_user and update.effective_user.id in ALLOWED_USERS + + +# ── State: one active session per Telegram chat ────────────────────── +# chat_id -> session_id +_sessions: dict[int, str] = {} + + +async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + await update.message.reply_text( + "Send me what to do. It runs immediately.\n\n" + "Commands:\n" + "/new — create a session for a repo\n" + "/status — check current session\n" + "/logs — last 30 lines of output\n" + "/stop — stop current session\n\n" + "Or just type what you want done." + ) + + +async def cmd_new(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + if not _authorized(update): + return + args = ctx.args + repo_url = args[0] if args else None + chat_id = update.effective_chat.id + + await update.message.reply_text("Creating session…") + try: + sid = telecoder.create(repo_url=repo_url) + _sessions[chat_id] = sid.strip() + await update.message.reply_text(f"Session `{sid.strip()}` ready. Send me what to do.", parse_mode="Markdown") + except Exception as e: + await update.message.reply_text(f"Failed: {e}") + + +async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + if not _authorized(update): + return + chat_id = update.effective_chat.id + sid = _sessions.get(chat_id) + if not sid: + await update.message.reply_text("No active session. Use /new or just send a message.") + return + try: + info = telecoder.inspect(sid) + await update.message.reply_text(f"```\n{info}\n```", parse_mode="Markdown") + except Exception as e: + await update.message.reply_text(f"Error: {e}") + + +async def cmd_logs(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + if not _authorized(update): + return + chat_id = update.effective_chat.id + sid = _sessions.get(chat_id) + if not sid: + await update.message.reply_text("No active session.") + return + try: + output = telecoder.logs(sid) + # Telegram has a 4096 char limit + if len(output) > 4000: + output = "…" + output[-4000:] + await update.message.reply_text(f"```\n{output}\n```", parse_mode="Markdown") + except Exception as e: + await update.message.reply_text(f"Error: {e}") + + +async def cmd_stop(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + if not _authorized(update): + return + chat_id = update.effective_chat.id + sid = _sessions.get(chat_id) + if not sid: + await update.message.reply_text("No active session.") + return + try: + telecoder.stop(sid) + await update.message.reply_text(f"Stopped `{sid}`.", parse_mode="Markdown") + except Exception as e: + await update.message.reply_text(f"Error: {e}") + + +async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + """Plain English message → create session if needed → run immediately.""" + if not _authorized(update): + return + + chat_id = update.effective_chat.id + text = update.message.text.strip() + + if not text: + return + + sid = _sessions.get(chat_id) + + # Auto-create a session if none exists + if not sid: + try: + sid = telecoder.create().strip() + _sessions[chat_id] = sid + except Exception as e: + await update.message.reply_text(f"Could not create session: {e}") + return + + # Send the message as-is to Claude Code + await update.message.reply_text("On it.") + try: + result = telecoder.run(sid, text) + await update.message.reply_text(result) + except Exception as e: + await update.message.reply_text(f"Error: {e}") + + +def main() -> None: + token = os.environ.get("TELEGRAM_BOT_TOKEN") + if not token: + raise SystemExit("Set TELEGRAM_BOT_TOKEN env var") + + app = ApplicationBuilder().token(token).build() + + app.add_handler(CommandHandler("start", cmd_start)) + app.add_handler(CommandHandler("help", cmd_start)) + app.add_handler(CommandHandler("new", cmd_new)) + app.add_handler(CommandHandler("status", cmd_status)) + app.add_handler(CommandHandler("logs", cmd_logs)) + app.add_handler(CommandHandler("stop", cmd_stop)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) + + log.info("Bot started — polling for messages") + app.run_polling() + + +if __name__ == "__main__": + main() diff --git a/bot/telecoder.py b/bot/telecoder.py new file mode 100644 index 0000000..a7f4d7b --- /dev/null +++ b/bot/telecoder.py @@ -0,0 +1,73 @@ +"""Thin wrapper that shells out to the telecoder CLI.""" + +import subprocess + + +def _run(*args: str) -> str: + """Run a telecoder command and return its stdout.""" + result = subprocess.run( + ["telecoder", *args], + capture_output=True, + text=True, + timeout=30, + ) + output = result.stdout.strip() + if result.returncode != 0: + err = result.stderr.strip() + raise RuntimeError(err or f"telecoder exited {result.returncode}") + return output + + +def create( + *, + repo_url: str | None = None, + repo_path: str | None = None, + branch: str | None = None, + test_cmd: str | None = None, + lint_cmd: str | None = None, +) -> str: + """Create a session, return the session ID.""" + args = ["create"] + if repo_url: + args += ["--repo-url", repo_url] + if repo_path: + args += ["--repo-path", repo_path] + if branch: + args += ["--branch", branch] + if test_cmd: + args += ["--test-cmd", test_cmd] + if lint_cmd: + args += ["--lint-cmd", lint_cmd] + return _run(*args) + + +def run(session_id: str, prompt: str) -> str: + """Run a prompt in a session.""" + return _run("run", session_id, prompt) + + +def stop(session_id: str) -> str: + return _run("stop", session_id) + + +def inspect(session_id: str) -> str: + return _run("inspect", session_id) + + +def logs(session_id: str, stream: str = "stdout") -> str: + return _run("logs", session_id, "--stream", stream) + + +def verify(session_id: str) -> str: + return _run("verify", session_id) + + +def delete(session_id: str) -> str: + return _run("delete", session_id) + + +def list_sessions(status: str | None = None) -> str: + args = ["list"] + if status: + args += ["--status", status] + return _run(*args) diff --git a/config.example.sh b/config.example.sh new file mode 100644 index 0000000..cc55fb4 --- /dev/null +++ b/config.example.sh @@ -0,0 +1,18 @@ +# TeleCoder configuration +# Source this file — it's just shell variables. + +# Where TeleCoder stores sessions, logs, and the database +TELECODER_DATA="${TELECODER_DATA:-$HOME/.telecoder}" + +# Runtime binary (claude CLI) +TELECODER_RUNTIME="${TELECODER_RUNTIME:-claude}" + +# Git branch prefix for session branches +TELECODER_BRANCH_PREFIX="${TELECODER_BRANCH_PREFIX:-telecoder/}" + +# Auto-push after session completes +TELECODER_AUTO_PUSH="${TELECODER_AUTO_PUSH:-false}" + +# Web UI host/port (future) +TELECODER_HOST="${TELECODER_HOST:-127.0.0.1}" +TELECODER_PORT="${TELECODER_PORT:-7830}" diff --git a/docs/git-credentials.md b/docs/git-credentials.md new file mode 100644 index 0000000..003031c --- /dev/null +++ b/docs/git-credentials.md @@ -0,0 +1,44 @@ +# Git Credential Guide + +TeleCoder needs git access to clone and optionally push to your repos. + +## SSH Keys (Recommended) + +```bash +ssh-keygen -t ed25519 -C "telecoder@your-vps" +``` + +Add the public key to your GitHub/GitLab account as a deploy key: + +```bash +cat ~/.ssh/id_ed25519.pub +``` + +## HTTPS with Token + +```bash +git config --global credential.helper store +``` + +Then create `~/.git-credentials`: + +``` +https://your-token@github.com +``` + +## Testing Access + +```bash +git clone https://github.com/you/your-repo /tmp/test-clone +rm -rf /tmp/test-clone +``` + +## Auto-Push + +To auto-push branches, set in config: + +```bash +# ~/.config/telecoder/config.sh +TELECODER_AUTO_PUSH=true +TELECODER_BRANCH_PREFIX="telecoder/" +``` diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..e17b83b --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,72 @@ +# Quick Start + +Get TeleCoder running on your VPS in under 5 minutes. + +## Prerequisites + +- Ubuntu 22.04+ VPS (2GB+ RAM recommended) +- Git, tmux, sqlite3 (installed automatically by install script) +- Claude Code CLI (`npm install -g @anthropic-ai/claude-code`) +- An Anthropic API key (set as `ANTHROPIC_API_KEY` env var) + +## Install + +```bash +git clone https://github.com/you/telecoder +cd telecoder +sudo ./install.sh +``` + +## Configure + +Edit `~/.config/telecoder/config.sh` (user install) or `/etc/telecoder/config.sh` (system install): + +```bash +TELECODER_RUNTIME="claude" # path to claude CLI +``` + +Make sure your `ANTHROPIC_API_KEY` is set in your shell environment. + +## Initialize + +```bash +telecoder init +``` + +## Create and run a session + +```bash +# Create a session from a repo +telecoder create --repo-url https://github.com/you/your-repo + +# Run a task +telecoder run fix the failing tests in src/auth.py + +# Check status +telecoder list + +# View output +telecoder logs + +# See full details +telecoder inspect +``` + +## Close your laptop + +The session runs in tmux on the VPS. Come back later: + +```bash +# Check results +telecoder inspect + +# Or attach to the live tmux session +telecoder attach +``` + +## Stop or resume + +```bash +telecoder stop +telecoder run now run the linter and fix warnings +``` diff --git a/docs/runtime-setup.md b/docs/runtime-setup.md new file mode 100644 index 0000000..9c33ab0 --- /dev/null +++ b/docs/runtime-setup.md @@ -0,0 +1,50 @@ +# Runtime Setup: Claude Code + +TeleCoder v1 uses Claude Code as its coding agent runtime. + +## Install Claude Code + +```bash +npm install -g @anthropic-ai/claude-code +``` + +## Set API Key + +Export your Anthropic API key: + +```bash +export ANTHROPIC_API_KEY="sk-ant-your-key-here" +``` + +Add it to your shell profile so it persists: + +```bash +echo 'export ANTHROPIC_API_KEY="sk-ant-your-key-here"' >> ~/.bashrc +``` + +## Verify Runtime + +```bash +claude --print "say hello" +``` + +You should see a response from Claude. + +## Runtime Configuration + +If `claude` is not in PATH, set it in your config: + +```bash +# ~/.config/telecoder/config.sh +TELECODER_RUNTIME="/usr/local/bin/claude" +``` + +## How TeleCoder Uses Claude Code + +When you run a session, TeleCoder: + +1. Launches `claude -p ''` inside a tmux session +2. Captures stdout and stderr to log files +3. The tmux session survives terminal disconnects +4. You can attach to it live with `telecoder attach ` +5. Or stop it with `telecoder stop ` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..204701c --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,71 @@ +# Troubleshooting + +## "claude: command not found" + +```bash +npm install -g @anthropic-ai/claude-code +which claude +``` + +If installed but not in PATH, set it explicitly: + +```bash +# ~/.config/telecoder/config.sh +TELECODER_RUNTIME="/usr/local/bin/claude" +``` + +## Session starts but no output + +Check stderr: + +```bash +telecoder logs --stream stderr +``` + +Common causes: missing API key, network issues, bad binary path. + +## Session stuck in "running" + +The tmux session may have exited. `telecoder inspect ` auto-detects this and updates the status. + +Or check manually: + +```bash +tmux ls | grep tc- +``` + +## Git clone fails + +Test git access directly: + +```bash +git clone /tmp/test +``` + +See [Git Credentials Guide](git-credentials.md). + +## tmux not found + +```bash +sudo apt install -y tmux +``` + +## Logs location + +```bash +# stdout +telecoder logs + +# stderr +telecoder logs --stream stderr + +# Raw files +ls ~/.telecoder/logs/ +``` + +## Reset everything + +```bash +rm -rf ~/.telecoder +telecoder init +``` diff --git a/docs/vps-setup.md b/docs/vps-setup.md new file mode 100644 index 0000000..ac51626 --- /dev/null +++ b/docs/vps-setup.md @@ -0,0 +1,54 @@ +# VPS Setup Guide + +## Recommended VPS + +- **Provider**: Any (DigitalOcean, Hetzner, Linode, AWS Lightsail, etc.) +- **OS**: Ubuntu 22.04 or 24.04 +- **RAM**: 2GB minimum, 4GB recommended +- **Storage**: 20GB+ (depends on repo sizes) + +## Initial VPS Setup + +```bash +# Update system +sudo apt update && sudo apt upgrade -y + +# Install dependencies (install.sh does this too) +sudo apt install -y git tmux sqlite3 curl +``` + +## Install Node.js (for Claude Code) + +```bash +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs +``` + +## Install Claude Code CLI + +```bash +npm install -g @anthropic-ai/claude-code +``` + +Verify: + +```bash +claude --version +``` + +## Install TeleCoder + +```bash +cd /opt +git clone https://github.com/your-org/telecoder.git +cd telecoder +sudo ./install.sh +``` + +## Firewall + +If you want remote access later: + +```bash +sudo ufw allow 22/tcp # SSH +``` diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e8c830c --- /dev/null +++ b/install.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +# TeleCoder Installer +# Usage: curl -fsSL https://your-domain/install.sh | bash +# or: git clone ... && cd telecoder && sudo ./install.sh + +INSTALL_DIR="/usr/local/share/telecoder" +BIN_LINK="/usr/local/bin/telecoder" +TELECODER_CONFIG="/etc/telecoder" + +echo "=== TeleCoder Installer ===" +echo "" + +# Check for root +if [ "$(id -u)" -ne 0 ]; then + echo "Please run as root (or with sudo)." + exit 1 +fi + +# Check dependencies +for dep in bash git sqlite3 tmux; do + if ! command -v "$dep" &>/dev/null; then + echo "$dep not found. Installing..." + apt-get update -qq && apt-get install -y -qq "$dep" + fi +done + +echo "Dependencies OK." + +# Copy telecoder to install dir +echo "Installing to ${INSTALL_DIR}..." +mkdir -p "$INSTALL_DIR" +cp -r bin/ lib/ config.example.sh "$INSTALL_DIR/" +chmod +x "$INSTALL_DIR/bin/telecoder" "$INSTALL_DIR"/lib/*.sh + +# Symlink to PATH +ln -sf "$INSTALL_DIR/bin/telecoder" "$BIN_LINK" + +# Copy config if not present +mkdir -p "$TELECODER_CONFIG" +if [ ! -f "$TELECODER_CONFIG/config.sh" ]; then + cp config.example.sh "$TELECODER_CONFIG/config.sh" + # Point data dir to /var/lib for system install + sed -i 's|\$HOME/.telecoder|/var/lib/telecoder|' "$TELECODER_CONFIG/config.sh" + echo "Config written to $TELECODER_CONFIG/config.sh" +fi + +# Create data dirs +source "$TELECODER_CONFIG/config.sh" +mkdir -p "${TELECODER_DATA}"/{workspaces,logs} + +echo "" +echo "=== TeleCoder installed ===" +echo "" +echo "Next steps:" +echo " 1. Edit $TELECODER_CONFIG/config.sh" +echo " - Set TELECODER_RUNTIME if claude is not in PATH" +echo " 2. Initialize:" +echo " telecoder init" +echo " 3. Create your first session:" +echo " telecoder create --repo-url https://github.com/you/repo" +echo " telecoder run 'fix the failing tests'" +echo "" +echo "Sessions run in tmux. Close your terminal — they keep going." +echo "Come back with: telecoder attach " +echo "" diff --git a/lib/db.sh b/lib/db.sh new file mode 100755 index 0000000..55e229c --- /dev/null +++ b/lib/db.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Database helpers — thin wrappers around sqlite3 CLI. +# All user input goes through parameterized queries to avoid injection. + +TC_DB="${TELECODER_DATA}/telecoder.db" + +db_init() { + sqlite3 "$TC_DB" <<'SQL' +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + repo_url TEXT, + repo_path TEXT, + workspace TEXT NOT NULL, + branch TEXT, + status TEXT NOT NULL DEFAULT 'created', + prompt TEXT, + test_cmd TEXT, + lint_cmd TEXT, + pid INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +SQL +} + +db_query() { + sqlite3 -separator '|' "$TC_DB" "$1" +} + +# Safe insert/update: write values via .param to avoid SQL injection. +# Usage: db_param_exec "INSERT INTO t (a,b) VALUES (:a,:b)" a "val1" b "val2" +db_param_exec() { + local sql="$1"; shift + local param_cmds="" + while [ $# -ge 2 ]; do + local name="$1" value="$2"; shift 2 + # .param set binds a named parameter safely (no escaping needed) + param_cmds="${param_cmds}.param set :${name} '$(printf '%s' "$value" | sed "s/'/''/g")' +" + done + sqlite3 "$TC_DB" <&1 + workspace="${workspace}/repo" + elif [ -n "$repo_path" ]; then + if git -C "$repo_path" rev-parse --git-dir >/dev/null 2>&1; then + git -C "$repo_path" worktree add "$workspace/repo" -b "${TELECODER_BRANCH_PREFIX}${id}" 2>&1 + workspace="${workspace}/repo" + else + workspace="$repo_path" + fi + fi + + if [ -n "$branch" ] && [ -d "${workspace}/.git" ]; then + git -C "$workspace" checkout -b "$branch" 2>/dev/null || git -C "$workspace" checkout "$branch" 2>/dev/null + fi + + db_param_exec \ + "INSERT INTO sessions (id, repo_url, repo_path, workspace, branch, status, test_cmd, lint_cmd) VALUES (:id, :repo_url, :repo_path, :workspace, :branch, 'created', :test_cmd, :lint_cmd)" \ + id "$id" \ + repo_url "$repo_url" \ + repo_path "$repo_path" \ + workspace "$workspace" \ + branch "$branch" \ + test_cmd "$test_cmd" \ + lint_cmd "$lint_cmd" + + echo "$id" +} + +session_run() { + local id="$1" prompt="$2" + local workspace + workspace=$(db_query "SELECT workspace FROM sessions WHERE id='${id}';") + + if [ -z "$workspace" ]; then + echo "Session not found: $id" >&2 + return 1 + fi + + local logs_dir="${TELECODER_DATA}/logs" + local prompt_file="${TELECODER_DATA}/logs/${id}.prompt" + mkdir -p "$logs_dir" + + # Write prompt to file so it never touches shell interpolation + printf '%s' "$prompt" > "$prompt_file" + + # Build a shell script that reads the prompt from file + local runner="${logs_dir}/${id}.run.sh" + cat > "$runner" <