diff --git a/README.md b/README.md index e5deec2..937e8e3 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,22 @@ cd server && npm install && npm run dev cd web && npm install && npm run dev ``` +For a persistent local deployment (rather than an offline development server), +install and verify the agent CLIs explicitly. Docker image build steps do not +run when the backend is launched directly by Node or systemd: + +```bash +./scripts/install-local-clis.sh + +# Or install only selected CLIs: +./scripts/install-local-clis.sh opencode codex +``` + +The script installs into `${NPM_CONFIG_PREFIX:-$HOME/.local}` and fails if a +requested binary is not available on `PATH`. Include that prefix's `bin` +directory in the service's `PATH`, then restart Agent Manager: CLI availability +is cached for the lifetime of the backend process. + This repo *is* the Space — the build runs the `Dockerfile`. ### Deploying a branch to a dev Space diff --git a/scripts/install-local-clis.sh b/scripts/install-local-clis.sh new file mode 100755 index 0000000..c698776 --- /dev/null +++ b/scripts/install-local-clis.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -eu + +# Provision the npm-distributed agent CLIs for a non-Docker Agent Manager. +# With no arguments, install every CLI the Docker image installs through npm. +# Pass CLI ids (for example, "opencode codex") to install only those tools. + +prefix="${NPM_CONFIG_PREFIX:-$HOME/.local}" +export NPM_CONFIG_PREFIX="$prefix" +export PATH="$prefix/bin:$PATH" + +selected="${*:-claude codex gemini opencode openclaw}" + +for cli in $selected; do + case "$cli" in + claude) package='@anthropic-ai/claude-code@latest'; binary='claude' ;; + codex) package='@openai/codex@latest'; binary='codex' ;; + gemini) package='@google/gemini-cli@latest'; binary='gemini' ;; + opencode) package='opencode-ai@latest'; binary='opencode' ;; + openclaw) package='openclaw@latest'; binary='openclaw' ;; + *) + echo "unknown CLI '$cli' (expected: claude codex gemini opencode openclaw)" >&2 + exit 2 + ;; + esac + + echo "Installing $cli ($package) into $prefix ..." + npm install -g --no-audit --no-fund "$package" + if ! command -v "$binary" >/dev/null 2>&1; then + echo "$cli installation completed but '$binary' is not on PATH" >&2 + exit 1 + fi + echo "Installed $binary: $($binary --version 2>/dev/null | head -n 1)" +done + +echo "Agent CLIs are installed in $prefix/bin." diff --git a/server/package.json b/server/package.json index 3041a55..22c7660 100644 --- a/server/package.json +++ b/server/package.json @@ -32,5 +32,10 @@ "devDependencies": { "@xterm/headless": "^6.0.0", "playwright": "^1.62.1" + }, + "allowScripts": { + "node-pty@1.1.0": true, + "@coder/libghostty-vt-node@0.1.0-beta.0": true, + "fsevents@2.3.2": false } } diff --git a/server/src/runner.js b/server/src/runner.js index bbf3ec7..22cccb8 100644 --- a/server/src/runner.js +++ b/server/src/runner.js @@ -4,6 +4,7 @@ import crypto from 'node:crypto'; import pty from 'node-pty'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import { remoteState, setPaused } from './remote.js'; import { cliById, isRemote, PORT, STATE_DIR, WORKSPACES_DIR } from './config.js'; import { update, list } from './sessions.js'; @@ -38,6 +39,12 @@ const TERM_ENV = { LANG: process.env.LANG || 'C.UTF-8', }; +// Resolve app-owned helpers from the checkout rather than assuming the Docker +// layout. In the image this is /app/scripts; in a local install it is the +// repository's scripts directory. +const APP_SCRIPTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts'); +const appScript = (name) => path.join(APP_SCRIPTS_DIR, name); + const BASHRC = process.env.AM_BASHRC || '/app/session.bashrc'; const AM_USER = process.env.SPACE_AUTHOR_NAME || process.env.AM_USER || os.userInfo().username || 'user'; @@ -1317,8 +1324,8 @@ export async function codexRolloutForId(id) { // would be a terrible trade), and every failure is non-fatal: without the // hook the watcher simply keeps today's behaviour. export function installClaudeRepinHook( - hookCmd = '/app/scripts/am-repin-hook.sh', - inputRequiredCmd = '/app/scripts/am-input-required-hook.sh', + hookCmd = appScript('am-repin-hook.sh'), + inputRequiredCmd = appScript('am-input-required-hook.sh'), ) { const dir = process.env.CLAUDE_CONFIG_DIR; if (!dir) return false; @@ -1382,7 +1389,7 @@ export function installClaudeRepinHook( // plugins and opencode.json settings remain untouched. This config directory // can be on the Space's FUSE bucket, whose rename semantics are unreliable; // install before launching OpenCode and write the app-owned file directly. -export function installOpencodeRepinPlugin(source = '/app/scripts/am-opencode-repin.js') { +export function installOpencodeRepinPlugin(source = appScript('am-opencode-repin.js')) { const base = process.env.OPENCODE_CONFIG_DIR || path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || os.homedir(), '.config'), 'opencode'); const dir = path.join(base, 'plugins'); diff --git a/server/test/local-install.test.mjs b/server/test/local-install.test.mjs new file mode 100644 index 0000000..165c1c2 --- /dev/null +++ b/server/test/local-install.test.mjs @@ -0,0 +1,34 @@ +// Local Node/systemd deployments resolve app-owned lifecycle helpers from the +// checkout instead of relying on the Docker image's /app layout. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'am-local-install-')); +process.env.DATA_DIR = path.join(tmp, 'data'); +process.env.CLAUDE_CONFIG_DIR = path.join(tmp, 'claude'); +process.env.XDG_CONFIG_HOME = path.join(tmp, 'config'); +fs.mkdirSync(process.env.CLAUDE_CONFIG_DIR, { recursive: true }); + +const runner = await import('../src/runner.js'); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const scripts = path.join(root, 'scripts'); + +assert.equal(runner.installClaudeRepinHook(), true); +const settings = JSON.parse(fs.readFileSync(path.join(process.env.CLAUDE_CONFIG_DIR, 'settings.json'), 'utf8')); +assert.equal(settings.hooks.SessionStart[0].hooks[0].command, path.join(scripts, 'am-repin-hook.sh')); +assert.equal(settings.hooks.Notification[0].hooks[0].command, path.join(scripts, 'am-input-required-hook.sh')); + +assert.equal(runner.installOpencodeRepinPlugin(), true); +const installedPlugin = path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'plugins', 'am-agent-manager.js'); +assert.equal(fs.readFileSync(installedPlugin, 'utf8'), fs.readFileSync(path.join(scripts, 'am-opencode-repin.js'), 'utf8')); + +const installer = path.join(scripts, 'install-local-clis.sh'); +assert.notEqual(fs.statSync(installer).mode & 0o111, 0, 'CLI installer is executable'); +assert.equal(spawnSync('sh', ['-n', installer]).status, 0, 'CLI installer parses as POSIX shell'); + +fs.rmSync(tmp, { recursive: true, force: true }); +console.log('local-install: ok'); diff --git a/server/test/opencode-resume.test.mjs b/server/test/opencode-resume.test.mjs index e981e40..e2ba540 100644 --- a/server/test/opencode-resume.test.mjs +++ b/server/test/opencode-resume.test.mjs @@ -65,7 +65,7 @@ check('missing exact row is null', traces.opencodeSessionInfo(GONE), null); console.log('\nthe global plugin reports exact root-session lifecycle events'); const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts'); const pluginSource = path.join(scripts, 'am-opencode-repin.js'); -check('plugin installs globally', runner.installOpencodeRepinPlugin(pluginSource), true); +check('default plugin source resolves outside Docker', runner.installOpencodeRepinPlugin(), true); const installed = path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'plugins', 'am-agent-manager.js'); check('installed plugin is the app-owned source', fs.readFileSync(installed, 'utf8'), fs.readFileSync(pluginSource, 'utf8')); check('plugin install is idempotent', runner.installOpencodeRepinPlugin(pluginSource), true);