@idl3/claude-control 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/bin/cli.js +68 -0
- package/bin/install-service.sh +107 -0
- package/bin/self-update.sh +43 -0
- package/bin/uninstall-service.sh +22 -0
- package/lib/answer.js +64 -0
- package/lib/auth.js +81 -0
- package/lib/config.js +118 -0
- package/lib/push.js +153 -0
- package/lib/resources.js +137 -0
- package/lib/sessions.js +529 -0
- package/lib/terminal.js +278 -0
- package/lib/tmux.js +462 -0
- package/lib/transcript.js +451 -0
- package/lib/tui.js +50 -0
- package/lib/uploads.js +42 -0
- package/lib/version.js +73 -0
- package/package.json +49 -0
- package/public/app.js +756 -0
- package/public/index.html +120 -0
- package/public/styles.css +848 -0
- package/server.js +910 -0
- package/web/README.md +66 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/bash-I8pq0VWm.js +1 -0
- package/web/dist/assets/core-BYJcZW10.js +3 -0
- package/web/dist/assets/css-DazXZka4.js +1 -0
- package/web/dist/assets/diff-DiTmLxSS.js +1 -0
- package/web/dist/assets/index-Bb7gXgl-.css +1 -0
- package/web/dist/assets/index-wrjqfzbL.js +77 -0
- package/web/dist/assets/javascript-BKRaQes9.js +1 -0
- package/web/dist/assets/json-DIYVocXf.js +1 -0
- package/web/dist/assets/markdown-BrP960CR.js +1 -0
- package/web/dist/assets/python-sE43i1Pi.js +1 -0
- package/web/dist/assets/typescript-C2FFdlUC.js +1 -0
- package/web/dist/assets/xml-BXBhIUeX.js +1 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +25 -0
- package/web/dist/sw.js +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 idl3
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# claude-control
|
|
2
|
+
|
|
3
|
+
A tiny, local web UI to **watch and drive your Claude Code sessions from a
|
|
4
|
+
browser or phone**. It discovers the Claude sessions you already run inside
|
|
5
|
+
**tmux**, streams each session's transcript live, lets you reply, answer
|
|
6
|
+
`AskUserQuestion` prompts, attach screenshots/files, and capture the pane — all
|
|
7
|
+
over `127.0.0.1` (or your Tailscale tailnet), guarded by a token.
|
|
8
|
+
|
|
9
|
+
No daemon to babysit, no database: it reads Claude Code's transcript files and
|
|
10
|
+
talks to tmux. Bind is localhost-only by default.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Install (npm)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g @idl3/claude-control # or run once: npx @idl3/claude-control
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**Prerequisites:** Node ≥20 and **tmux** on your `PATH` (`brew install tmux` · `sudo apt install tmux`). The web UI ships prebuilt — no build step on install.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
claude-control # start the server (prints the URL)
|
|
24
|
+
claude-control --help # config + subcommands
|
|
25
|
+
claude-control install-service # macOS: launchd auto-start on login + restart on crash
|
|
26
|
+
claude-control uninstall-service
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Open the printed URL. If a token is configured (env `CLAUDE_CONTROL_TOKEN`, or a
|
|
30
|
+
token in `~/.claude-control/token`), the app **prompts for it on first load** and
|
|
31
|
+
stores it in your browser — the token is never placed in the URL. With no token
|
|
32
|
+
set, it runs open on `127.0.0.1` / your tailnet.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Quick start (from source)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
git clone https://github.com/idl3/claude-control.git
|
|
40
|
+
cd claude-control
|
|
41
|
+
npm install
|
|
42
|
+
npm run build # builds the web UI (web/dist)
|
|
43
|
+
npm start # prints a URL with a token
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Open the printed URL (e.g. `http://127.0.0.1:4317/?token=…`). Any Claude Code
|
|
47
|
+
session running in tmux shows up in the left rail.
|
|
48
|
+
|
|
49
|
+
> **Already have tmux running with Claude sessions?** You're done — just run
|
|
50
|
+
> `npm start` and they appear automatically.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## The tmux setup (the one requirement)
|
|
55
|
+
|
|
56
|
+
claude-control manages sessions **through tmux**: it lists tmux windows, finds
|
|
57
|
+
the ones running Claude Code, and sends your replies as keystrokes to the right
|
|
58
|
+
pane. So your Claude sessions need to live in tmux.
|
|
59
|
+
|
|
60
|
+
### A) You already use tmux
|
|
61
|
+
|
|
62
|
+
Nothing to do. claude-control reads your **default tmux server** (the same one
|
|
63
|
+
`tmux ls` shows). Start it and your sessions appear. To point at a non-default
|
|
64
|
+
tmux binary, set `CLAUDE_CONTROL_TMUX=/path/to/tmux`.
|
|
65
|
+
|
|
66
|
+
### B) You don't use tmux yet
|
|
67
|
+
|
|
68
|
+
Install it and run Claude *inside* a tmux session so claude-control can see it:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# macOS: brew install tmux · Debian/Ubuntu: sudo apt install tmux
|
|
72
|
+
|
|
73
|
+
tmux new -s work # start (or attach) a tmux session
|
|
74
|
+
claude # run Claude Code inside it — now it's discoverable
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
That's it. Open more windows (`Ctrl-b c`) and run more Claude sessions; each
|
|
78
|
+
becomes a row in claude-control. (Tip: detach with `Ctrl-b d` — the sessions
|
|
79
|
+
keep running and stay visible in claude-control.)
|
|
80
|
+
|
|
81
|
+
A session is recognized when its pane is running Claude Code **or** has a
|
|
82
|
+
matching transcript under `~/.claude/projects/`.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Updating
|
|
87
|
+
|
|
88
|
+
claude-control compares your checkout against its git upstream (`origin`) and
|
|
89
|
+
shows an **update banner** when new commits are available. Click **Update now**
|
|
90
|
+
— the server pulls, reinstalls, rebuilds the web bundle, and restarts itself in
|
|
91
|
+
place; the page reconnects automatically. (Equivalent manual update: `git pull
|
|
92
|
+
&& npm install && npm run build`, then restart.)
|
|
93
|
+
|
|
94
|
+
Version numbers follow npm semver (bump `package.json` per release); this is
|
|
95
|
+
**v0.1.0**.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
All optional. Prefer `CLAUDE_CONTROL_*`; legacy `COCKPIT_*` names still work.
|
|
102
|
+
|
|
103
|
+
| Env | Default | Purpose |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `CLAUDE_CONTROL_PORT` | `4317` | HTTP/WS port |
|
|
106
|
+
| `CLAUDE_CONTROL_HOST` | `127.0.0.1` | Bind address |
|
|
107
|
+
| `CLAUDE_CONTROL_TOKEN` | _(none)_ | Require `?token=` on every request (recommended if exposed beyond localhost) |
|
|
108
|
+
| `CLAUDE_CONTROL_PROJECTS` | `~/.claude/projects` | Where Claude Code transcripts live |
|
|
109
|
+
| `CLAUDE_CONTROL_UPLOADS` | `~/.claude-control/uploads` | Where attachments are stored (TTL-swept) |
|
|
110
|
+
| `CLAUDE_CONTROL_TMUX` | _(auto)_ | tmux binary override |
|
|
111
|
+
| `CLAUDE_CONTROL_MAX_UPLOAD_MB` | `25` | Per-file upload cap |
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Security
|
|
116
|
+
|
|
117
|
+
- Binds `127.0.0.1` by default; cross-origin WebSocket upgrades are rejected.
|
|
118
|
+
- Set `CLAUDE_CONTROL_TOKEN` before exposing it (e.g. via `tailscale serve`) —
|
|
119
|
+
**this UI can type into your live sessions.**
|
|
120
|
+
- Uploads are written `0600` under the uploads dir and swept after a TTL.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## How it works
|
|
125
|
+
|
|
126
|
+
- **Discovery** — polls `tmux list-windows` every few seconds and matches each
|
|
127
|
+
window to the newest transcript for its cwd (`lib/sessions.js`).
|
|
128
|
+
- **Transcript** — tails each subscribed session's `*.jsonl` (bounded reads)
|
|
129
|
+
and streams appends over WebSocket (`lib/transcript.js`).
|
|
130
|
+
- **Input** — replies and answers are sent with `tmux send-keys` to the exact
|
|
131
|
+
pane (`lib/tmux.js`); attachments upload to the uploads dir and their path is
|
|
132
|
+
appended to the message for Claude to read.
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
npm run dev # server with --watch
|
|
138
|
+
cd web && npm run dev # Vite dev server for the UI
|
|
139
|
+
npm test # node:test unit tests
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// claude-control CLI entry. Default action starts the server (server.js runs on
|
|
3
|
+
// import); subcommands wrap the launchd service scripts and version/help.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const ROOT = path.join(__dirname, '..');
|
|
11
|
+
const pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
12
|
+
|
|
13
|
+
const cmd = process.argv[2];
|
|
14
|
+
|
|
15
|
+
function runScript(name) {
|
|
16
|
+
const child = spawn('/bin/bash', [path.join(ROOT, 'bin', name)], {
|
|
17
|
+
stdio: 'inherit',
|
|
18
|
+
env: process.env,
|
|
19
|
+
});
|
|
20
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
switch (cmd) {
|
|
24
|
+
case '-v':
|
|
25
|
+
case '--version':
|
|
26
|
+
console.log(pkg.version);
|
|
27
|
+
break;
|
|
28
|
+
|
|
29
|
+
case '-h':
|
|
30
|
+
case '--help':
|
|
31
|
+
console.log(`claude-control v${pkg.version}
|
|
32
|
+
Local web UI to watch and drive Claude Code tmux sessions.
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
claude-control [start] Start the server (default)
|
|
36
|
+
claude-control install-service Install the launchd service (macOS): auto-start + restart
|
|
37
|
+
claude-control uninstall-service Remove the launchd service
|
|
38
|
+
claude-control --version
|
|
39
|
+
claude-control --help
|
|
40
|
+
|
|
41
|
+
Config (env vars, all optional):
|
|
42
|
+
CLAUDE_CONTROL_PORT (default 4317)
|
|
43
|
+
CLAUDE_CONTROL_HOST (default 127.0.0.1)
|
|
44
|
+
CLAUDE_CONTROL_TOKEN token auth; also read from ~/.claude-control/token.
|
|
45
|
+
Unset + no file = tokenless (relies on bind/tailnet).
|
|
46
|
+
CLAUDE_CONTROL_PROJECTS (default ~/.claude/projects)
|
|
47
|
+
|
|
48
|
+
Requires: Node >=20 and tmux on PATH.`);
|
|
49
|
+
break;
|
|
50
|
+
|
|
51
|
+
case 'install-service':
|
|
52
|
+
runScript('install-service.sh');
|
|
53
|
+
break;
|
|
54
|
+
|
|
55
|
+
case 'uninstall-service':
|
|
56
|
+
runScript('uninstall-service.sh');
|
|
57
|
+
break;
|
|
58
|
+
|
|
59
|
+
case undefined:
|
|
60
|
+
case 'start':
|
|
61
|
+
// server.js executes main() on import.
|
|
62
|
+
await import(path.join(ROOT, 'server.js'));
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
default:
|
|
66
|
+
console.error(`unknown command: ${cmd}\nrun "claude-control --help"`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Install claude-control as a launchd service: auto-start on login, restart on
|
|
3
|
+
# crash. Token auth is OPTIONAL: gates on ~/.claude-control/token if present,
|
|
4
|
+
# else runs tokenless (tailnet-only). Idempotent — safe to re-run after updates.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
LABEL="com.ernest.claude-control"
|
|
8
|
+
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
9
|
+
CONFIG_DIR="$HOME/.claude-control"
|
|
10
|
+
TOKEN_FILE="$CONFIG_DIR/token"
|
|
11
|
+
LOG_DIR="$CONFIG_DIR/logs"
|
|
12
|
+
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
|
13
|
+
PORT="${CLAUDE_CONTROL_PORT:-4317}"
|
|
14
|
+
|
|
15
|
+
mkdir -p "$CONFIG_DIR" "$LOG_DIR" "$HOME/Library/LaunchAgents"
|
|
16
|
+
|
|
17
|
+
# Token (OPTIONAL): if ~/.claude-control/token exists, gate the UI on it;
|
|
18
|
+
# otherwise run tokenless — open to anything that can reach the port (the
|
|
19
|
+
# 127.0.0.1 bind + tailnet ACL + cross-origin check are the remaining guards).
|
|
20
|
+
# To (re-)enable token auth: write a token to that file and re-run this script.
|
|
21
|
+
TOKEN="$(cat "$TOKEN_FILE" 2>/dev/null || true)"
|
|
22
|
+
if [ -n "$TOKEN" ]; then
|
|
23
|
+
TOKEN_ENV=" <key>CLAUDE_CONTROL_TOKEN</key><string>$TOKEN</string>"
|
|
24
|
+
else
|
|
25
|
+
TOKEN_ENV=""
|
|
26
|
+
echo "no token file ($TOKEN_FILE) — installing TOKENLESS (open on the tailnet)"
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
# launchd runs with a minimal PATH — resolve absolute binaries and a PATH that
|
|
30
|
+
# lets the server find tmux.
|
|
31
|
+
NODE_BIN="$(command -v node)"
|
|
32
|
+
TMUX_BIN="$(command -v tmux 2>/dev/null || echo /opt/homebrew/bin/tmux)"
|
|
33
|
+
TMUX_DIR="$(dirname "$TMUX_BIN")"
|
|
34
|
+
SVC_PATH="$TMUX_DIR:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
35
|
+
|
|
36
|
+
# launchd gives a LaunchAgent a different $TMPDIR than the login session, so the
|
|
37
|
+
# bundled tmux can't find the running server's socket (-> 0 sessions). Pin
|
|
38
|
+
# TMUX_TMPDIR to where the socket actually lives.
|
|
39
|
+
TMUX_TMPDIR_VAL="$("$TMUX_BIN" display-message -p '#{socket_path}' 2>/dev/null | sed -E 's#/tmux-[0-9]+/[^/]+$##')"
|
|
40
|
+
if [ -z "$TMUX_TMPDIR_VAL" ]; then
|
|
41
|
+
for d in /private/tmp /tmp "${TMPDIR:-}"; do
|
|
42
|
+
[ -n "$d" ] && [ -S "$d/tmux-$(id -u)/default" ] && { TMUX_TMPDIR_VAL="$d"; break; }
|
|
43
|
+
done
|
|
44
|
+
fi
|
|
45
|
+
TMUX_TMPDIR_VAL="${TMUX_TMPDIR_VAL:-/tmp}"
|
|
46
|
+
echo "tmux socket dir: $TMUX_TMPDIR_VAL"
|
|
47
|
+
|
|
48
|
+
# Build the web bundle if absent (server falls back to public/ otherwise).
|
|
49
|
+
if [ ! -f "$REPO/web/dist/index.html" ]; then
|
|
50
|
+
echo "building web bundle…"; (cd "$REPO" && npm run build)
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# Stop any manually-launched server already holding the port.
|
|
54
|
+
EXISTING="$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t 2>/dev/null || true)"
|
|
55
|
+
if [ -n "$EXISTING" ]; then echo "stopping existing server (pid $EXISTING)"; kill $EXISTING 2>/dev/null || true; sleep 1; fi
|
|
56
|
+
|
|
57
|
+
cat > "$PLIST" <<PLIST
|
|
58
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
59
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
60
|
+
<plist version="1.0">
|
|
61
|
+
<dict>
|
|
62
|
+
<key>Label</key><string>$LABEL</string>
|
|
63
|
+
<key>ProgramArguments</key>
|
|
64
|
+
<array>
|
|
65
|
+
<string>$NODE_BIN</string>
|
|
66
|
+
<string>$REPO/server.js</string>
|
|
67
|
+
</array>
|
|
68
|
+
<key>WorkingDirectory</key><string>$REPO</string>
|
|
69
|
+
<key>EnvironmentVariables</key>
|
|
70
|
+
<dict>
|
|
71
|
+
$TOKEN_ENV
|
|
72
|
+
<key>CLAUDE_CONTROL_PORT</key><string>$PORT</string>
|
|
73
|
+
<key>HOME</key><string>$HOME</string>
|
|
74
|
+
<key>PATH</key><string>$SVC_PATH</string>
|
|
75
|
+
<key>TMUX_TMPDIR</key><string>$TMUX_TMPDIR_VAL</string>
|
|
76
|
+
</dict>
|
|
77
|
+
<key>RunAtLoad</key><true/>
|
|
78
|
+
<key>KeepAlive</key><true/>
|
|
79
|
+
<key>ThrottleInterval</key><integer>5</integer>
|
|
80
|
+
<key>StandardOutPath</key><string>$LOG_DIR/out.log</string>
|
|
81
|
+
<key>StandardErrorPath</key><string>$LOG_DIR/err.log</string>
|
|
82
|
+
</dict>
|
|
83
|
+
</plist>
|
|
84
|
+
PLIST
|
|
85
|
+
|
|
86
|
+
launchctl unload "$PLIST" 2>/dev/null || true
|
|
87
|
+
launchctl load "$PLIST"
|
|
88
|
+
|
|
89
|
+
# Expose over Tailscale (tailnet-only HTTPS). Harmless if already configured.
|
|
90
|
+
if command -v tailscale >/dev/null 2>&1; then
|
|
91
|
+
tailscale serve --bg --https=443 "localhost:$PORT" >/dev/null 2>&1 || true
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
HOSTDNS="$(tailscale status --json 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write((JSON.parse(d).Self?.DNSName||"").replace(/\.$/,""))}catch{}})' 2>/dev/null || true)"
|
|
95
|
+
|
|
96
|
+
BASE="$([ -n "$HOSTDNS" ] && echo "https://$HOSTDNS/" || echo "http://127.0.0.1:$PORT/")"
|
|
97
|
+
echo ""
|
|
98
|
+
echo "✓ claude-control installed as $LABEL (auto-starts on login, restarts on crash)"
|
|
99
|
+
echo " logs: $LOG_DIR/{out,err}.log"
|
|
100
|
+
echo " url: $BASE"
|
|
101
|
+
if [ -n "$TOKEN" ]; then
|
|
102
|
+
# Don't put the token in the URL (it leaks via history/logs); the web app
|
|
103
|
+
# prompts for it and stores it in localStorage.
|
|
104
|
+
echo " auth: token — enter it at the login prompt: $TOKEN"
|
|
105
|
+
else
|
|
106
|
+
echo " auth: TOKENLESS (tailnet-only)"
|
|
107
|
+
fi
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# claude-control self-update — invoked by POST /api/update (the in-UI "Update
|
|
4
|
+
# now" button). Pulls the latest source, reinstalls deps, rebuilds the web
|
|
5
|
+
# bundle, then restarts the server in place. Runs DETACHED from the server it
|
|
6
|
+
# restarts, so killing the old process can't kill this script.
|
|
7
|
+
#
|
|
8
|
+
# Inherits the server's env (token/port/host), so the relaunched server keeps
|
|
9
|
+
# the same configuration. All output goes to the update log.
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
|
|
12
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
13
|
+
cd "$ROOT" || exit 1
|
|
14
|
+
|
|
15
|
+
DATA_DIR="${CLAUDE_CONTROL_DATA:-$HOME/.claude-control}"
|
|
16
|
+
mkdir -p "$DATA_DIR"
|
|
17
|
+
LOG="$DATA_DIR/update.log"
|
|
18
|
+
PORT="${CLAUDE_CONTROL_PORT:-${COCKPIT_PORT:-4317}}"
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
echo "=== self-update $(date) (port $PORT) ==="
|
|
22
|
+
|
|
23
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
24
|
+
git pull --ff-only && echo "git pull ok" || echo "git pull skipped/failed"
|
|
25
|
+
else
|
|
26
|
+
echo "not a git checkout — skipping git pull"
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
npm install --no-audit --no-fund && echo "root deps ok" || echo "root deps failed"
|
|
30
|
+
( cd web && npm install --no-audit --no-fund && npm run build ) \
|
|
31
|
+
&& echo "web build ok" || echo "web build failed"
|
|
32
|
+
|
|
33
|
+
# Restart: stop whatever holds the port, then relaunch with the inherited env.
|
|
34
|
+
OLD="$(lsof -ti tcp:"$PORT" 2>/dev/null || true)"
|
|
35
|
+
if [ -n "$OLD" ]; then kill $OLD 2>/dev/null || true; fi
|
|
36
|
+
for _ in 1 2 3 4 5 6; do
|
|
37
|
+
lsof -ti tcp:"$PORT" >/dev/null 2>&1 || break
|
|
38
|
+
sleep 1
|
|
39
|
+
done
|
|
40
|
+
|
|
41
|
+
nohup node "$ROOT/server.js" > "$DATA_DIR/server.log" 2>&1 </dev/null &
|
|
42
|
+
echo "restarted server pid $!"
|
|
43
|
+
} >> "$LOG" 2>&1
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Remove the claude-control launchd service. Leaves the token + uploads intact.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
LABEL="com.ernest.claude-control"
|
|
6
|
+
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
|
7
|
+
PORT="${CLAUDE_CONTROL_PORT:-4317}"
|
|
8
|
+
|
|
9
|
+
if [ -f "$PLIST" ]; then
|
|
10
|
+
launchctl unload "$PLIST" 2>/dev/null || true
|
|
11
|
+
rm -f "$PLIST"
|
|
12
|
+
echo "✓ removed $LABEL"
|
|
13
|
+
else
|
|
14
|
+
echo "service not installed ($PLIST not found)"
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
# Optional: stop tailscale serve for this port.
|
|
18
|
+
if command -v tailscale >/dev/null 2>&1; then
|
|
19
|
+
tailscale serve --https=443 off >/dev/null 2>&1 || true
|
|
20
|
+
echo " tailscale serve stopped"
|
|
21
|
+
fi
|
|
22
|
+
echo " (token + uploads under ~/.claude-control are left in place)"
|
package/lib/answer.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Translate an AskUserQuestion selection into Claude Code TUI picker keystrokes.
|
|
2
|
+
//
|
|
3
|
+
// Verified against the live Claude Code picker (a multi-question tabbed UI):
|
|
4
|
+
// - Options are NUMBERED (1..N) in question.options order, with extra meta
|
|
5
|
+
// options ("Type something", "Chat about this") appended after.
|
|
6
|
+
// - SINGLE-select: pressing the option's number selects it AND auto-advances
|
|
7
|
+
// to the next question tab.
|
|
8
|
+
// - MULTI-select: pressing a number TOGGLES that option (cursor stays); press
|
|
9
|
+
// Right (→) to advance to the next tab.
|
|
10
|
+
// - After the last question the picker lands on the "Submit" tab showing
|
|
11
|
+
// "1. Submit answers / 2. Cancel"; pressing "1" commits all answers.
|
|
12
|
+
// Keys are sent one at a time with a small delay (see tmux.sendRawKeysSequenced)
|
|
13
|
+
// so the single-select auto-advance re-render completes before the next key.
|
|
14
|
+
|
|
15
|
+
const MAX_NUMBERED = 9; // number-key selection only works for options 1..9
|
|
16
|
+
|
|
17
|
+
function numberKey(optionIndex) {
|
|
18
|
+
const n = optionIndex + 1;
|
|
19
|
+
if (n > MAX_NUMBERED) {
|
|
20
|
+
throw new Error(`option #${n} beyond number-key range (1..${MAX_NUMBERED})`);
|
|
21
|
+
}
|
|
22
|
+
return String(n);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Keys to answer ONE question (not including the final Submit).
|
|
27
|
+
* @param {{multiSelect?: boolean, options: {label:string}[]}} question
|
|
28
|
+
* @param {string[]} selectedLabels
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
export function buildAnswerKeys(question, selectedLabels) {
|
|
32
|
+
const options = Array.isArray(question?.options) ? question.options : [];
|
|
33
|
+
const indices = (selectedLabels || [])
|
|
34
|
+
.map((label) => options.findIndex((o) => o.label === label))
|
|
35
|
+
.filter((i) => i >= 0)
|
|
36
|
+
.sort((a, b) => a - b);
|
|
37
|
+
|
|
38
|
+
if (indices.length === 0) throw new Error('no valid option selected for question');
|
|
39
|
+
|
|
40
|
+
if (!question.multiSelect) {
|
|
41
|
+
// Selecting a numbered option auto-advances to the next tab.
|
|
42
|
+
return [numberKey(indices[0])];
|
|
43
|
+
}
|
|
44
|
+
// Toggle each chosen option, then advance to the next tab with Right.
|
|
45
|
+
return [...indices.map(numberKey), 'Right'];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Full key program for a (possibly multi-question) AskUserQuestion, ending with
|
|
50
|
+
* the Submit-tab confirmation.
|
|
51
|
+
* @param {{questions: object[]}} pending
|
|
52
|
+
* @param {string[][]} selections selections[i] = chosen labels for questions[i]
|
|
53
|
+
* @returns {string[]}
|
|
54
|
+
*/
|
|
55
|
+
export function buildAnswerProgram(pending, selections) {
|
|
56
|
+
const questions = pending?.questions || [];
|
|
57
|
+
if (questions.length === 0) throw new Error('pending has no questions');
|
|
58
|
+
const program = [];
|
|
59
|
+
for (let i = 0; i < questions.length; i += 1) {
|
|
60
|
+
program.push(...buildAnswerKeys(questions[i], selections?.[i] || []));
|
|
61
|
+
}
|
|
62
|
+
program.push('1'); // "Submit answers" on the Submit tab
|
|
63
|
+
return program;
|
|
64
|
+
}
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Request authentication for claude-control.
|
|
2
|
+
//
|
|
3
|
+
// Tokens NEVER ride the URL anymore (URLs leak via history/logs/referrer).
|
|
4
|
+
// Instead:
|
|
5
|
+
// - HTTP/API requests carry the token in `Authorization: Bearer <token>`.
|
|
6
|
+
// - WebSocket upgrades carry it as a `Sec-WebSocket-Protocol` value, because
|
|
7
|
+
// browsers cannot set arbitrary headers on `new WebSocket(...)` but CAN
|
|
8
|
+
// offer subprotocols.
|
|
9
|
+
//
|
|
10
|
+
// Tokenless mode (no token configured server-side) stays fully open: every
|
|
11
|
+
// check returns true and the client shows no login prompt.
|
|
12
|
+
//
|
|
13
|
+
// The ttyd raw-terminal surface is the ONE exception — it is opened via
|
|
14
|
+
// window.open to a separately-proxied URL that cannot send an Authorization
|
|
15
|
+
// header, so it keeps its own `?token=` mechanism (handled in server.js's
|
|
16
|
+
// /term/ branch, not here).
|
|
17
|
+
|
|
18
|
+
// A dedicated subprotocol label the client always offers alongside the token,
|
|
19
|
+
// so the server can select a non-secret protocol to echo back (some proxies /
|
|
20
|
+
// strict clients want a selection) without ever reflecting the raw token.
|
|
21
|
+
export const WS_PROTOCOL = 'claude-control';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract the bearer token from an incoming HTTP request's Authorization
|
|
25
|
+
* header. Scheme match is case-insensitive ("Bearer", "bearer", "BEARER").
|
|
26
|
+
* Returns the token string, or null when absent/malformed.
|
|
27
|
+
*
|
|
28
|
+
* @param {import('node:http').IncomingMessage} req
|
|
29
|
+
* @returns {string|null}
|
|
30
|
+
*/
|
|
31
|
+
export function tokenFromRequest(req) {
|
|
32
|
+
const header = req?.headers?.authorization;
|
|
33
|
+
if (typeof header !== 'string') return null;
|
|
34
|
+
const m = /^\s*Bearer\s+(.+)\s*$/i.exec(header);
|
|
35
|
+
return m ? m[1].trim() : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Authenticate an HTTP/API request against the configured token.
|
|
40
|
+
*
|
|
41
|
+
* Tokenless server → always authorized. Otherwise the request must present the
|
|
42
|
+
* exact token via `Authorization: Bearer <token>`.
|
|
43
|
+
*
|
|
44
|
+
* @param {import('node:http').IncomingMessage} req
|
|
45
|
+
* @param {string|null|undefined} configToken
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function checkToken(req, configToken) {
|
|
49
|
+
if (!configToken) return true;
|
|
50
|
+
return tokenFromRequest(req) === configToken;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse the offered WebSocket subprotocols from a `Sec-WebSocket-Protocol`
|
|
55
|
+
* header value (comma-separated, each entry trimmed). Returns [] when absent.
|
|
56
|
+
*
|
|
57
|
+
* @param {string|undefined} headerValue
|
|
58
|
+
* @returns {string[]}
|
|
59
|
+
*/
|
|
60
|
+
export function parseWsProtocols(headerValue) {
|
|
61
|
+
if (typeof headerValue !== 'string') return [];
|
|
62
|
+
return headerValue
|
|
63
|
+
.split(',')
|
|
64
|
+
.map((s) => s.trim())
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Authenticate a WebSocket upgrade against the configured token. The client
|
|
70
|
+
* offers the token as one of its subprotocols (alongside WS_PROTOCOL). Tokenless
|
|
71
|
+
* server → always authorized.
|
|
72
|
+
*
|
|
73
|
+
* @param {import('node:http').IncomingMessage} req
|
|
74
|
+
* @param {string|null|undefined} configToken
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
export function checkWsToken(req, configToken) {
|
|
78
|
+
if (!configToken) return true;
|
|
79
|
+
const offered = parseWsProtocols(req?.headers?.['sec-websocket-protocol']);
|
|
80
|
+
return offered.includes(configToken);
|
|
81
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/config.js — tiny persisted config store.
|
|
3
|
+
*
|
|
4
|
+
* Holds the operator-editable settings that drive "new session" creation:
|
|
5
|
+
* the launch command to run in a fresh tmux window (default "claude", but
|
|
6
|
+
* overridable to a shell alias like `yolo` or `claude --flags`) and the
|
|
7
|
+
* default cwd new sessions start in.
|
|
8
|
+
*
|
|
9
|
+
* Persisted at ~/.claude-control/config.json (honour CLAUDE_CONTROL_DATA when
|
|
10
|
+
* set, matching server.js's env-override convention). Reads never throw —
|
|
11
|
+
* defaults are merged over whatever's on disk. Writes validate strictly and
|
|
12
|
+
* use mode 0600 (same as the uploads handler) since this is a single-user
|
|
13
|
+
* localhost tool.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
|
|
19
|
+
// Env lookup mirrors server.js: prefer CLAUDE_CONTROL_<X>, fall back to the
|
|
20
|
+
// legacy COCKPIT_<X> so existing launchers keep working.
|
|
21
|
+
const env = (name) =>
|
|
22
|
+
process.env[`CLAUDE_CONTROL_${name}`] ?? process.env[`COCKPIT_${name}`];
|
|
23
|
+
|
|
24
|
+
/** Resolve the data directory (CLAUDE_CONTROL_DATA or ~/.claude-control). */
|
|
25
|
+
function dataDir() {
|
|
26
|
+
return env('DATA') || path.join(os.homedir(), '.claude-control');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Absolute path to the config file. */
|
|
30
|
+
function configPath() {
|
|
31
|
+
return path.join(dataDir(), 'config.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LAUNCH_MAX = 500;
|
|
35
|
+
|
|
36
|
+
/** Defaults, recomputed each call so a changed HOME/env is honoured. */
|
|
37
|
+
function defaults() {
|
|
38
|
+
return {
|
|
39
|
+
launchCommand: 'claude',
|
|
40
|
+
defaultCwd: os.homedir(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read the persisted config, merged over defaults. Never throws — a missing,
|
|
46
|
+
* empty, or corrupt file falls back to defaults. Only known keys are surfaced.
|
|
47
|
+
*
|
|
48
|
+
* @returns {{ launchCommand: string, defaultCwd: string }}
|
|
49
|
+
*/
|
|
50
|
+
export function readConfig() {
|
|
51
|
+
const base = defaults();
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return base;
|
|
57
|
+
}
|
|
58
|
+
if (!parsed || typeof parsed !== 'object') return base;
|
|
59
|
+
return {
|
|
60
|
+
launchCommand:
|
|
61
|
+
typeof parsed.launchCommand === 'string' && parsed.launchCommand.trim()
|
|
62
|
+
? parsed.launchCommand
|
|
63
|
+
: base.launchCommand,
|
|
64
|
+
defaultCwd:
|
|
65
|
+
typeof parsed.defaultCwd === 'string' && parsed.defaultCwd.trim()
|
|
66
|
+
? parsed.defaultCwd
|
|
67
|
+
: base.defaultCwd,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate a partial update against the current config and persist the merged
|
|
73
|
+
* result. Throws on validation failure (the caller maps that to HTTP 400).
|
|
74
|
+
*
|
|
75
|
+
* Validation:
|
|
76
|
+
* - launchCommand: non-empty string, ≤500 chars.
|
|
77
|
+
* - defaultCwd: a path that exists and is a directory.
|
|
78
|
+
*
|
|
79
|
+
* @param {{ launchCommand?: unknown, defaultCwd?: unknown }} partial
|
|
80
|
+
* @returns {{ launchCommand: string, defaultCwd: string }} the saved config
|
|
81
|
+
*/
|
|
82
|
+
export function writeConfig(partial = {}) {
|
|
83
|
+
const current = readConfig();
|
|
84
|
+
const next = { ...current };
|
|
85
|
+
|
|
86
|
+
if (partial.launchCommand !== undefined) {
|
|
87
|
+
const cmd = partial.launchCommand;
|
|
88
|
+
if (typeof cmd !== 'string' || !cmd.trim()) {
|
|
89
|
+
throw new Error('launchCommand must be a non-empty string');
|
|
90
|
+
}
|
|
91
|
+
if (cmd.length > LAUNCH_MAX) {
|
|
92
|
+
throw new Error(`launchCommand must be ≤${LAUNCH_MAX} characters`);
|
|
93
|
+
}
|
|
94
|
+
next.launchCommand = cmd;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (partial.defaultCwd !== undefined) {
|
|
98
|
+
const cwd = partial.defaultCwd;
|
|
99
|
+
if (typeof cwd !== 'string' || !cwd.trim()) {
|
|
100
|
+
throw new Error('defaultCwd must be a non-empty string');
|
|
101
|
+
}
|
|
102
|
+
let stat;
|
|
103
|
+
try {
|
|
104
|
+
stat = fs.statSync(cwd);
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error(`defaultCwd does not exist: ${cwd}`);
|
|
107
|
+
}
|
|
108
|
+
if (!stat.isDirectory()) {
|
|
109
|
+
throw new Error(`defaultCwd is not a directory: ${cwd}`);
|
|
110
|
+
}
|
|
111
|
+
next.defaultCwd = cwd;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const dir = dataDir();
|
|
115
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
116
|
+
fs.writeFileSync(configPath(), JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
117
|
+
return next;
|
|
118
|
+
}
|