From 50e5fc009905dd654e7115401450c74b6613ce6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Sat, 24 Oct 2026 23:09:04 +0200 Subject: [PATCH] dev: run checkout and production sessions in parallel --- docs/guide.md | 33 ++++++++++++++ scripts/relay-dev.sh | 66 +++++++++++++++++++++++++++ tests/dev-session.test.mjs | 93 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100755 scripts/relay-dev.sh create mode 100644 tests/dev-session.test.mjs diff --git a/docs/guide.md b/docs/guide.md index 672a8026..c83a427d 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -25,6 +25,39 @@ claude plugin install relay@libar-relay Run `/reload-plugins` in an open session, or start a new one. Type `/relay:` and the command menu lists relay's commands. +## Develop beside the installed release + +From a checkout, run a development session alongside an ordinary production session: + +```bash +# Terminal 1: production, using the installed release +claude + +# Terminal 2: this checkout, with hot reload +scripts/relay-dev.sh +``` + +The development launcher uses `--plugin-dir` and disables `relay@libar-relay` **only in that process**. It does not +change your installed plugin or its settings. Each checkout has a persistent development home under +`${XDG_STATE_HOME:-$HOME/.local/state}/relay-dev/`, with separate jobs and admission directories. Development jobs +therefore never appear in the production job list, and production jobs are not used by the development guard. +`RELAY_DEV_HOME` overrides that state root; `RELAY_DEV_WORKDIR` overrides the session's working directory. + +```bash +# Print the development paths for another terminal +scripts/relay-dev.sh env + +# Inspect development jobs +scripts/relay-dev.sh status + +# Pass normal Claude arguments, such as --continue +scripts/relay-dev.sh --continue +``` + +Run production and development in separate sessions, not two copies of the same plugin name inside one session. +Claude Code can reject a second enabled plugin with the same name. To return to production, close the development +session and run `claude` normally. + ## Check your backends Type `/relay:setup`. Claude runs relay's setup and shows a table with one row for each backend home it finds: the diff --git a/scripts/relay-dev.sh b/scripts/relay-dev.sh new file mode 100755 index 00000000..de6535bd --- /dev/null +++ b/scripts/relay-dev.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/relay-dev.sh [run] [claude arguments] + scripts/relay-dev.sh env + scripts/relay-dev.sh status + +Run this checkout's relay plugin alongside an installed production release. +The development process uses its own jobs and admission homes and disables +relay@libar-relay only for that process. Normal Claude sessions are unchanged. + +RELAY_DEV_HOME overrides the development state root (default: +${XDG_STATE_HOME:-$HOME/.local/state}/relay-dev/). +RELAY_DEV_WORKDIR optionally changes the session's working directory. +EOF +} + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +repo=$(cd -- "$script_dir/.." && pwd -P) +if [[ ${1:-} == --help || ${1:-} == -h ]]; then usage; exit 0; fi + +command=run +if [[ $# -gt 0 ]]; then + case "$1" in + run|env|status) command=$1; shift ;; + esac +fi + +checkout_id=$(node - "$repo" <<'NODE' +const { createHash } = require('node:crypto'); +console.log(createHash('sha256').update(process.argv[2]).digest('hex').slice(0, 16)); +NODE +) +dev_root=${RELAY_DEV_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/relay-dev/$checkout_id} +jobs_home=$dev_root/jobs +admission_home=$dev_root/admission +plugin_dir=$repo/plugins/relay +settings='{"enabledPlugins":{"relay@libar-relay":false}}' + +if [[ ! -f $plugin_dir/.claude-plugin/plugin.json ]]; then + printf 'Not a relay checkout: %s\n' "$repo" >&2 + exit 2 +fi +mkdir -p "$jobs_home" "$admission_home" +export RELAY_JOBS_HOME=$jobs_home +export RELAY_ADMISSION_HOME=$admission_home + +case "$command" in + env) + printf 'export RELAY_JOBS_HOME=%q\n' "$RELAY_JOBS_HOME" + printf 'export RELAY_ADMISSION_HOME=%q\n' "$RELAY_ADMISSION_HOME" + printf 'export RELAY_DEV_PLUGIN_DIR=%q\n' "$plugin_dir" + printf 'export RELAY_DEV_SETTINGS=%q\n' "$settings" + ;; + status) + printf 'Development checkout: %s\n' "$repo" + printf 'Development state: %s\n' "$dev_root" + node "$plugin_dir/scripts/companion.mjs" status --all + ;; + run) + if [[ -n ${RELAY_DEV_WORKDIR:-} ]]; then cd -- "$RELAY_DEV_WORKDIR"; fi + exec claude --plugin-dir "$plugin_dir" --settings "$settings" "$@" + ;; +esac diff --git a/tests/dev-session.test.mjs b/tests/dev-session.test.mjs new file mode 100644 index 00000000..6639b4cb --- /dev/null +++ b/tests/dev-session.test.mjs @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const script = path.join(root, 'scripts/relay-dev.sh'); + +function fixture(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-dev-session-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const bin = path.join(dir, 'bin'); + fs.mkdirSync(bin); + const capture = path.join(dir, 'claude.json'); + fs.writeFileSync(path.join(bin, 'claude'), `#!${process.execPath}\n` + + 'const fs = require("node:fs");\n' + + 'fs.writeFileSync(process.env.FAKE_CLAUDE_CAPTURE, JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd(), jobs: process.env.RELAY_JOBS_HOME, admission: process.env.RELAY_ADMISSION_HOME }));\n', + { mode: 0o755 }); + return { dir, capture, env: { + ...process.env, + HOME: path.join(dir, 'home'), + XDG_STATE_HOME: path.join(dir, 'state with spaces'), + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + FAKE_CLAUDE_CAPTURE: capture, + RELAY_JOBS_HOME: '/production/jobs', + RELAY_ADMISSION_HOME: '/production/admission' + } }; +} + +test('help does not invoke Claude or create development state', t => { + const { dir, env } = fixture(t); + const result = spawnSync('bash', [script, '--help'], { env, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Usage:/); + assert.equal(fs.existsSync(path.join(dir, 'state with spaces')), false); +}); + +test('run loads only the checkout plugin with isolated persistent state and preserves arguments', t => { + const { dir, capture, env } = fixture(t); + const args = ['--continue', '--debug-file', 'debug with spaces.log']; + const first = spawnSync('bash', [script, ...args], { env, encoding: 'utf8' }); + assert.equal(first.status, 0, first.stderr); + const call = JSON.parse(fs.readFileSync(capture, 'utf8')); + const state = call.jobs.replace(/\/jobs$/, ''); + assert.notEqual(call.jobs, '/production/jobs'); + assert.notEqual(call.admission, '/production/admission'); + assert.equal(call.admission, path.join(state, 'admission')); + assert.ok(state.includes('state with spaces')); + assert.deepEqual(call.args, [ + '--plugin-dir', path.join(root, 'plugins/relay'), + '--settings', '{"enabledPlugins":{"relay@libar-relay":false}}', + ...args + ]); + assert.equal(call.cwd, root); + assert.equal(fs.existsSync(call.jobs), true); + assert.equal(fs.existsSync(call.admission), true); + + const second = spawnSync('bash', [script], { env, encoding: 'utf8' }); + assert.equal(second.status, 0, second.stderr); + assert.equal(JSON.parse(fs.readFileSync(capture, 'utf8')).jobs, call.jobs, 'the checkout keeps one development home'); +}); + +test('env reports only development paths and status uses the development home', t => { + const { capture, env } = fixture(t); + const printed = spawnSync('bash', [script, 'env'], { env, encoding: 'utf8' }); + assert.equal(printed.status, 0, printed.stderr); + assert.match(printed.stdout, /export RELAY_JOBS_HOME=/); + assert.match(printed.stdout, /export RELAY_ADMISSION_HOME=/); + assert.match(printed.stdout, /export RELAY_DEV_PLUGIN_DIR=/); + assert.doesNotMatch(printed.stdout, /\/production\//); + + const status = spawnSync('bash', [script, 'status'], { env, encoding: 'utf8' }); + assert.equal(status.status, 0, status.stderr); + assert.match(status.stdout, /Development checkout:/); + assert.doesNotMatch(status.stdout, /\/production\//); + assert.equal(fs.existsSync(capture), false, 'status does not invoke Claude'); +}); + +test('RELAY_DEV_HOME and RELAY_DEV_WORKDIR are honored without changing production settings', t => { + const { dir, capture, env } = fixture(t); + const work = path.join(dir, 'work with spaces'); + fs.mkdirSync(work); + const custom = path.join(dir, 'custom dev home'); + const result = spawnSync('bash', [script], { env: { ...env, RELAY_DEV_HOME: custom, RELAY_DEV_WORKDIR: work }, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + const call = JSON.parse(fs.readFileSync(capture, 'utf8')); + assert.equal(call.jobs, path.join(custom, 'jobs')); + assert.equal(call.admission, path.join(custom, 'admission')); + assert.equal(call.cwd, work); +});