Skip to content

hendricius/dev-vm

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dev-vm

Personal dev-machine bootstrap: clone, make install, get the same Claude-Code-over-Telegram setup on every machine. Currently ships:

Binary What it does
cl Launches Claude Code in a tmux session (cl) with the Telegram channel attached. Workdir defaults to ~/workspace/clawy if it exists, else $HOME. Override with $CL_WORKDIR. On a fresh launch (no existing session) it first stops every running docker container to reclaim memory — restart your project's stack when you next need it. Re-attaching to a live session leaves docker untouched.
cl-clear Restarts the cl session's claude process via tmux respawn-window -k — the session itself never dies, so there is no kill/new server-exit race. Retries with backoff, then posts the real outcome (success or failure) to the chat_id given as the first arg. Used by the !clear hook (see below), but safe to run manually.
cl-watchdog Health check: relaunches the cl tmux session if it has died. Run every 60s by the cl-watchdog systemd user timer (see Watchdog). Silent when healthy; logs state changes to log/watchdog.log.
transcribe FILE Sends an audio file to OpenAI Whisper and prints the transcript to stdout. Needs your own OpenAI API key (see Voice transcription).

Install

On a fresh machine:

git clone https://github.com/hendricius/dev-vm.git ~/dev-vm
cd ~/dev-vm && make install

make install is the one command that fully sets up the machine. Each step is idempotent, so re-running it is safe and fast:

  1. install-deps — system packages (curl, tmux, python3, jq, git, ca-certificates) via apt/dnf/yum/pacman/apk/brew, plus Bun via its official installer (the Telegram MCP plugin runs on Bun).
  2. link-bins — symlinks ~/.local/bin/{cl,cl-clear,cl-watchdog,transcribe} into the checkout.
  3. install-claude-skills — symlinks claude/skills/ to ~/.claude/skills/, exposing the shipped skills as user-invocable slash commands:
    • /transcribe <path> — runs ~/.local/bin/transcribe on an audio file and reports the transcript. Also reinforces the auto-trigger on inbound voice messages via its description.
    • /tmux — helpers for driving tmux sessions.
  4. install-telegram-plugin — if claude is on PATH, runs claude plugin install -s user telegram@claude-plugins-official (idempotent; no-ops if already installed). Then bun install inside the plugin dir to prewarm node_modules so the first launch doesn't stall on dep resolution.
  5. install-telegram-clear-hook — injects a UserPromptSubmit hook into ~/.claude/settings.json so that an exact !clear message from Telegram triggers cl-clear, which restarts the claude process in the cl session and posts the outcome back to the chat. The hook fires cl-clear under setsid so it can't be killed mid-restart. Idempotent; preserves any other hooks in the file.
  6. install-telegram-goal/loop/effort/model-hooks — more UserPromptSubmit hooks that bridge Telegram messages to real harness slash commands: !goal/goal, !loop/loop, !effort/effort, !model/model (see Remote /goal and the sections after it). Idempotent; preserve any other hooks.
  7. install-skill-sync-hookStop hook that auto-commits and pushes any change under claude/skills/ (see Auto-sync skills).
  8. install-telegram-session-mark + askuser-guard hooks — tag Telegram-driven sessions and block AskUserQuestion/ExitPlanMode in them, because Telegram has no UI to answer those interactive pickers and the session would stall.
  9. install-cl-watchdog — installs the cl-watchdog systemd user timer (see Watchdog). Best-effort: skips cleanly if the systemd user instance isn't reachable, so make install never fails on minimal images.

Make sure ~/.local/bin is on your PATH.

Not automated (intentional — needs user input)

Step Why manual How
Install Claude Code Multiple install paths (npm, native, brew). Pick one. https://claude.com/claude-code
Telegram bot token One bot per machine, you create it via BotFather. Inside claude: /telegram:configure <bot-token>
First-time pairing Per-machine security boundary. DM the bot → it returns a code → inside claude: /telegram:access pair <code>
OpenAI API key (optional) Only needed for transcribe; billed to your own OpenAI account. export OPENAI_API_KEY=… or write the key to ~/.config/openai/api-key (chmod 600)

After make install, run make doctor — it tells you exactly which of the manual steps are still pending.

Per-machine workdir

cl and cl-watchdog resolve the launch directory via, in order:

  1. $CL_WORKDIR (explicit full path) — the per-machine knob.
  2. ~/workspace/$CL_WORKSPACE (default subdir clawy, if it exists).
  3. ~/workspace.
  4. $HOME.

Because the right folder differs per machine, set CL_WORKDIR via env — not by patching bin/cl, which would conflict on every git pull. Two places, because systemd user services don't inherit your shell env:

Interactive cl — add to ~/.zshrc (or ~/.bashrc):

export CL_WORKDIR="$HOME/workspace/myproject"

cl-watchdog — systemd drop-in, since the timer runs without your shell:

mkdir -p ~/.config/systemd/user/cl-watchdog.service.d
cat > ~/.config/systemd/user/cl-watchdog.service.d/override.conf <<'EOF'
[Service]
Environment=CL_WORKDIR=%h/workspace/myproject
EOF
systemctl --user daemon-reload

Verify with systemctl --user show cl-watchdog.service -p Environment. Per-machine config lives in ~/.config/ and ~/.zshrc, so dev-vm stays pristine and make update never conflicts.

Update

make update         # = git pull --ff-only && make install

Because make install uses symlinks, any change you commit and pull is picked up immediately — no copy step needed. The update target also re-runs install in case binaries were added.

Watchdog

The cl tmux session is the lifeline for the Telegram channel — if it dies, you can't reach Claude until you SSH in and run cl by hand. Two layers keep it alive:

  1. Atomic restart. cl-clear (the !clear handler) uses tmux respawn-window -k, which restarts the claude process inside the existing window. The session never dies, so there's no kill-session / new-session race that can leave the server down. It also retries with backoff and reports the real outcome — a restart can't fail silently.
  2. cl-watchdog timer. A systemd user timer runs cl-watchdog every 60s. If the cl session is ever missing — a crash, an OOM kill, a restart that somehow still failed — it's relaunched within a minute, no SSH-in required. Healthy checks are silent; state changes go to log/watchdog.log. The service sets KillMode=process: without it, systemd reaps the tmux server the watchdog just spawned as soon as the oneshot exits, and the log claims "relaunched OK" forever while tmux ls stays empty.
  3. Usage-limit auto-resume. When the Claude plan quota runs out, the TUI parks on an interactive prompt that nobody headless can answer — Telegram goes dead until a human SSHes in. The watchdog detects that screen via capture-pane (same limit-looking pane two ticks in a row), parses the reset time, notifies the user on Telegram, and once the window passes restarts claude with --continue plus a nudge prompt so it picks the conversation back up on its own. State lives in log/limit-state, pane snapshots (for refining the detection patterns) in log/limit-pane.log. Opt out per-box with touch log/limit-watch.off.
make install-cl-watchdog                       # install / re-enable the timer
systemctl --user status cl-watchdog.timer      # inspect
systemctl --user list-timers cl-watchdog.timer # next/last run
journalctl --user -u cl-watchdog.service       # run history
~/.local/bin/cl-watchdog                       # run the check right now
tail -f ~/dev-vm/log/watchdog.log              # state-change log

The timer needs the systemd user instance plus linger so it keeps firing when no SSH session is attached. install-cl-watchdog enables linger automatically when it can; if it couldn't (no polkit rule), run it once as root: sudo loginctl enable-linger $USER. make doctor reports both the timer and linger state.

Remote /goal

Claude Code 2.1.139+ ships a built-in /goal <condition> command: you set a completion condition and the harness keeps working, turn after turn, until a fast model judges the condition met. It's a harness-level command, though — typing /goal into Telegram just sends literal text, because channel messages arrive as data, not as commands.

The !goal hook bridges that gap. Send from Telegram:

!goal <condition>      → runs /goal <condition>   (set a goal)
!goal                  → runs /goal               (show goal status)
!goal clear            → runs /goal clear         (clear the active goal)

scripts/telegram-goal-hook.sh is a UserPromptSubmit hook. It can't execute a slash command — a hook can only block a prompt or add context — so instead it blocks the !goal prompt and uses tmux send-keys to type the real /goal command into the cl session, exactly as if you'd typed it at the keyboard. The injection runs detached under setsid after a short delay so the blocked prompt has unwound first, and posts a 🎯 Injected … confirmation back to the chat (the harness's own /goal acknowledgement is terminal-only).

Every invocation is logged to ~/.claude/channels/telegram/goal-hook.log. To test the parse/extract logic without driving a real session into goal mode, run the hook with CL_GOAL_HOOK_DRYRUN=1 — it logs and prints what it would inject, then exits without touching tmux.

Hooks load at session startup. After make install adds the !goal hook, the running cl session won't have it yet — send !clear once to restart and pick it up. New sessions have it from the start.

!loop — run something on a recurring interval

Same bridge, for the harness /loop command. Send from Telegram:

!loop <interval> <prompt|/cmd>   → runs /loop <interval> <prompt|/cmd>
!loop <prompt|/cmd>              → runs /loop <prompt|/cmd>   (self-paced)
!loop                            → runs /loop

scripts/telegram-loop-hook.sh is the matching UserPromptSubmit hook — it blocks the !loop prompt and tmux send-keys the real /loop into the cl session, posting a 🔁 Injected … confirmation. Logs to ~/.claude/channels/telegram/loop-hook.log; dry-run with CL_LOOP_HOOK_DRYRUN=1.

!effort — set the reasoning effort

Same bridge, for the harness /effort command. Send from Telegram:

!effort <level>   → runs /effort <level>   (level: low|medium|high|xhigh|max|ultracode|auto)
!effort           → usage hint (no inject — a bare /effort opens the slider)
!effort bogus     → usage hint (unknown level)

A level is required: a bare /effort opens the interactive effort slider in the TUI, which waits for arrow-key input and would hang this headless cl session. So the hook only injects for a valid level, and otherwise blocks with a ⚙️ Usage … hint sent back to Telegram.

scripts/telegram-effort-hook.sh is the matching UserPromptSubmit hook — it blocks the !effort prompt and tmux send-keys the real /effort into the cl session, posting a ⚙️ Injected … confirmation. Logs to ~/.claude/channels/telegram/effort-hook.log; dry-run with CL_EFFORT_HOOK_DRYRUN=1.

!model — switch the Claude model

Same bridge, for the harness /model command. Send from Telegram:

!model <name>   → runs /model <name>   (name: default|best|fable|sonnet|opus|haiku|opusplan|opus[1m]|sonnet[1m], or a full claude-… id)
!model          → usage hint (no inject — a bare /model opens the picker)
!model bogus    → usage hint (unknown model)

A name is required: a bare /model opens the interactive model picker in the TUI, which waits for arrow-key input and would hang this headless cl session. So the hook only injects for a valid alias or a full claude-… id, and otherwise blocks with a 🧠 Usage … hint sent back to Telegram. /model <name> switches the model immediately and saves it as the default in ~/.claude/settings.json, so the choice survives the next cl relaunch.

scripts/telegram-model-hook.sh is the matching UserPromptSubmit hook — it blocks the !model prompt and tmux send-keys the real /model into the cl session, posting a 🧠 Injected … confirmation. Logs to ~/.claude/channels/telegram/model-hook.log; dry-run with CL_MODEL_HOOK_DRYRUN=1.

!usage — see your usage numbers in the chat

Same bridge, for the harness /usage command. Send from Telegram:

!usage   → runs /usage and relays the rendered panel back to the chat

/usage differs from the other bridged commands: it opens an interactive TUI panel that only ever draws to the terminal, so merely injecting it would show you nothing on your phone. So scripts/telegram-usage-hook.sh does more — it blocks the !usage prompt, tmux send-keys the real /usage into the cl session, waits for the panel to fetch + render, tmux capture-panes the visible pane, posts that text back to the originating chat (monospace code block), and presses Escape to dismiss the panel. That way you get the account-wide usage numbers in Telegram, which /usage alone can't deliver. Logs to ~/.claude/channels/telegram/usage-hook.log; dry-run with CL_USAGE_HOOK_DRYRUN=1.

Install with scripts/install-telegram-usage-hook.sh (remove with the matching uninstall- script). Like the other hooks, a running cl session only picks it up after a !clear; new sessions have it from the start.

Voice transcription (OpenAI Whisper)

transcribe FILE [LANGUAGE] sends the audio straight to OpenAI's transcription API and prints the transcript — no middleman service. Telegram voice notes (.oga), mp3, m4a, wav, webm and flac all work. Paired with the /transcribe skill, a Claude session on this box can transcribe inbound Telegram voice messages on its own.

Configuration, all via env (or the key file):

Knob Default Meaning
OPENAI_API_KEY Your API key. Alternatively write it to ~/.config/openai/api-key (single line, chmod 600).
OPENAI_TRANSCRIBE_MODEL whisper-1 Also works with gpt-4o-mini-transcribe / gpt-4o-transcribe.
OPENAI_TRANSCRIBE_PROMPT Vocabulary hint for names the model would otherwise mishear (project names, jargon).
OPENAI_API_BASE https://api.openai.com Any OpenAI-compatible server works.
export OPENAI_API_KEY=sk-…          # or: ~/.config/openai/api-key
transcribe voice-note.oga           # → transcript on stdout
transcribe interview.mp3 de         # ISO-639-1 language hint

Auto-sync skills to the repo

~/.claude/skills is a single symlink to claude/skills/ in this repo, so every skill is version-controlled and a new skill created under ~/.claude/skills lands in the repo working tree automatically. But the working tree is not the remote — a new local skill still needs a git commit + push to reach it (and your other machines, on their next make update).

scripts/skill-sync-hook.sh closes that gap. It is a Stop hook: when Claude finishes a turn, it checks git status for changes under claude/skills/ and, if any, commits them (auto: skill-sync (<names>)), git pull --rebase --autostash, and git push — all detached so the push never blocks the turn, and scoped to claude/skills/ only (Makefile / scripts / README stay manual). On turns that didn't touch a skill it's a cheap no-op.

Install with scripts/install-skill-sync-hook.sh (remove with the matching uninstall- script; both wired into make install). Logs to log/skill-sync.log in the repo; dry-run with CL_SKILL_SYNC_DRYRUN=1. Hooks load at session start, so a running cl session picks it up after a !clear.

Doctor

make doctor

Reports whether the symlinks are in place, the telegram plugin + hooks are installed, the cl-watchdog timer and linger are enabled, and whether claude/tmux/jq are available.

Secrets

None. The repo tracks no API keys or tokens. The two credentials the setup can use live outside the repo, per machine:

  • Telegram bot token — configured inside claude via /telegram:configure <bot-token>; lives in ~/.claude/channels/telegram/.env.
  • OpenAI API key (optional, for transcribe) — $OPENAI_API_KEY or ~/.config/openai/api-key (chmod 600).

If you ever need to stage machine-local credentials next to the checkout, put them under secrets/ — the whole directory is gitignored.

Adding a new binary

  1. Drop the script in bin/, mark executable.
  2. Append it to the BINS := line in the Makefile.
  3. make install to symlink it locally.
  4. Commit + push.

About

Dev-machine bootstrap: Claude Code over Telegram — cl launcher, watchdog, bang-command hooks, OpenAI Whisper transcription

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors