@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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/bin/cli.js +68 -0
  4. package/bin/install-service.sh +107 -0
  5. package/bin/self-update.sh +43 -0
  6. package/bin/uninstall-service.sh +22 -0
  7. package/lib/answer.js +64 -0
  8. package/lib/auth.js +81 -0
  9. package/lib/config.js +118 -0
  10. package/lib/push.js +153 -0
  11. package/lib/resources.js +137 -0
  12. package/lib/sessions.js +529 -0
  13. package/lib/terminal.js +278 -0
  14. package/lib/tmux.js +462 -0
  15. package/lib/transcript.js +451 -0
  16. package/lib/tui.js +50 -0
  17. package/lib/uploads.js +42 -0
  18. package/lib/version.js +73 -0
  19. package/package.json +49 -0
  20. package/public/app.js +756 -0
  21. package/public/index.html +120 -0
  22. package/public/styles.css +848 -0
  23. package/server.js +910 -0
  24. package/web/README.md +66 -0
  25. package/web/dist/apple-touch-icon.png +0 -0
  26. package/web/dist/assets/bash-I8pq0VWm.js +1 -0
  27. package/web/dist/assets/core-BYJcZW10.js +3 -0
  28. package/web/dist/assets/css-DazXZka4.js +1 -0
  29. package/web/dist/assets/diff-DiTmLxSS.js +1 -0
  30. package/web/dist/assets/index-Bb7gXgl-.css +1 -0
  31. package/web/dist/assets/index-wrjqfzbL.js +77 -0
  32. package/web/dist/assets/javascript-BKRaQes9.js +1 -0
  33. package/web/dist/assets/json-DIYVocXf.js +1 -0
  34. package/web/dist/assets/markdown-BrP960CR.js +1 -0
  35. package/web/dist/assets/python-sE43i1Pi.js +1 -0
  36. package/web/dist/assets/typescript-C2FFdlUC.js +1 -0
  37. package/web/dist/assets/xml-BXBhIUeX.js +1 -0
  38. package/web/dist/icon-192.png +0 -0
  39. package/web/dist/icon-512.png +0 -0
  40. package/web/dist/index.html +25 -0
  41. package/web/dist/manifest.webmanifest +25 -0
  42. 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
+ }