merge the agent chooser (feat/graphical-installer) into the integration branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 13:25:27 +01:00
commit da2f12fdbd
12 files changed
+1016 -124

No files matched your search

+293 -3
View File
@@ -51,7 +51,8 @@ const NOT_INSTALLED = {
let dom: JSDOM | null = null;
function open() {
/** `first`: what the app says to a page that just loaded, with no look at this Mac yet. */
function open(first?: Record<string, unknown>) {
const sent: Sent[] = [];
dom = new JSDOM(HTML, {
url: 'codemanapp://localhost/index.html',
@@ -77,8 +78,8 @@ function open() {
const rail = () =>
Array.from(doc.querySelectorAll('#wiz-rail li')).map((li) => [li.querySelector('span')?.textContent, li.className]);
const sentOf = (cmd: string) => sent.filter((m) => m.cmd === cmd);
native({ type: 'init', servers: [], autoConnect: false, startFullscreen: false, version: '0.1.0' });
native({ type: 'localStatus', status: NOT_INSTALLED });
native({ type: 'init', servers: [], autoConnect: false, startFullscreen: false, version: '0.1.0', ...first });
if (!first) native({ type: 'localStatus', status: NOT_INSTALLED });
return { sent, sentOf, doc, $, input, native, click, visible, rail };
}
@@ -445,3 +446,292 @@ describe('an install that was already running', () => {
expect(t.$('local-title').textContent).toContain('Terminal');
});
});
describe('adding a coding agent to a Mac that has Codeman', () => {
const RUNNING = { ...NOT_INSTALLED, state: 'running', version: '1.50.0', installed: true, installComplete: true };
const STOPPED = { ...NOT_INSTALLED, state: 'stopped', installed: true, installComplete: true };
const AGENTS = {
clis: [
cli('claude', 'Claude Code', { installed: true, installable: false }),
cli('codex', 'Codex'),
cli('copilot', 'GitHub Copilot'),
cli('deepseek', 'DeepSeek', { installable: false }),
],
};
function chooser(status: Record<string, unknown> = RUNNING, over: Record<string, unknown> = AGENTS) {
const t = open();
t.native({ type: 'localStatus', status });
t.click('local-agents');
t.native({ type: 'installPlan', plan: plan({ needs: { node: false, tmux: false, git: false }, ...over }) });
return t;
}
function installing(status: Record<string, unknown> = RUNNING) {
const t = chooser(status);
t.click('wiz-next');
t.native({ type: 'installUpdate', phase: 'agent', log: ['Installing Codex...'] });
return t;
}
it('is offered whenever Codeman is here, and never when it is not', () => {
const t = open();
t.native({ type: 'localStatus', status: NOT_INSTALLED });
expect(t.visible('local-agents')).toBe(false);
for (const status of [
RUNNING,
STOPPED,
{ ...RUNNING, state: 'needsLogin' },
{ ...RUNNING, state: 'runningElsewhere', elsewhere: ['127.0.0.1:4000'] },
]) {
t.native({ type: 'localStatus', status });
expect(t.visible('local-agents'), String(status.state)).toBe(true);
}
});
it('is not offered while an install in Terminal still runs', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...STOPPED, installRunning: true } });
expect(t.visible('local-agents')).toBe(false);
});
it('asks the app for the agent route, never the install route', () => {
const t = open();
t.native({ type: 'localStatus', status: RUNNING });
t.click('local-agents');
expect(t.sentOf('agentPrepare')).toHaveLength(1);
expect(t.sentOf('installPrepare')).toHaveLength(0);
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
expect(t.visible('wiz-back')).toBe(false); // A second prepare would clear the first one's folder.
});
it('lists what can be added first, what is here after it, and leaves out the rest', () => {
const t = chooser();
expect(t.$('wiz-title').textContent).toBe('Add a coding agent');
const rows = [...t.$('wiz-agents').querySelectorAll('label')];
expect(rows.map((r) => r.querySelector('b')?.textContent)).toEqual(['Codex', 'GitHub Copilot', 'Claude Code']);
const inputs = rows.map((r) => r.querySelector('input') as HTMLInputElement);
expect(inputs.map((i) => i.disabled)).toEqual([false, false, true]);
expect(rows[2].textContent).toContain('Installed');
expect(inputs[0].checked).toBe(true);
});
it('asks nothing about Codeman, whatever the installer says about installing it', () => {
const t = chooser(RUNNING, { ...AGENTS, blocker: 'macOS is too old for the tmux download.' });
expect(t.$('wiz-title').textContent).toBe('Add a coding agent');
expect(t.visible('wiz-reach-step')).toBe(false);
expect(t.visible('wiz-skip')).toBe(false);
expect(t.visible('wiz-terminal')).toBe(false);
});
it('sends the one agent chosen, and names it on the button', () => {
const t = chooser();
expect(t.$('wiz-next').textContent).toBe('Install Codex');
const copilot = t.$('wiz-agents').querySelector('input[value=copilot]') as HTMLInputElement;
copilot.click();
expect(t.$('wiz-next').textContent).toBe('Install GitHub Copilot');
t.click('wiz-next');
expect(t.sentOf('agentStart')).toEqual([{ cmd: 'agentStart', aiCli: 'copilot' }]);
expect(t.sentOf('installStart')).toHaveLength(0);
expect(t.$('wiz-title').textContent).toBe('Installing GitHub Copilot');
});
it('draws one row, for that agent', () => {
const t = installing();
expect(t.rail()).toEqual([['Installing Codex', 'now']]);
expect(t.visible('wiz-cancel')).toBe(true);
});
it('ends on what happened, and opens Codeman only when asked', () => {
const t = installing();
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(t.$('wiz-title').textContent).toBe('Codex is installed');
expect(t.$('wiz-lead').textContent).toContain('within a few minutes');
expect(t.$('wiz-lead').textContent).toContain('reload');
expect(t.rail()).toEqual([['Installing Codex', 'done']]);
expect(t.sentOf('localStatus').length).toBeGreaterThan(0);
expect(t.sentOf('connect')).toHaveLength(0);
t.native({ type: 'localStatus', status: RUNNING });
expect(t.$('wiz-title').textContent).toBe('Codex is installed');
expect(t.$('wiz-next').textContent).toBe('Open Codeman');
expect(t.$('wiz-back').textContent).toBe('Done');
t.click('wiz-next');
expect(t.sentOf('connect')).toEqual([expect.objectContaining({ url: RUNNING.url })]);
expect(t.visible('wizard')).toBe(false);
});
it('has nothing to open when Codeman is stopped, and Done goes back to This Mac', () => {
const t = installing(STOPPED);
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
t.native({ type: 'localStatus', status: STOPPED });
expect(t.visible('wiz-next')).toBe(false);
t.click('wiz-back');
expect(t.visible('wizard')).toBe(false);
expect(t.visible('local-start')).toBe(true);
expect(t.visible('local-agents')).toBe(true);
});
it('offers another only when there is another, and asks the installer again', () => {
const t = installing();
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(t.$('wiz-skip').textContent).toBe('Add another');
t.click('wiz-skip');
expect(t.sentOf('agentPrepare')).toHaveLength(2);
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
const last = chooser(RUNNING, { clis: [cli('codex', 'Codex')] });
last.click('wiz-next');
last.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(last.visible('wiz-skip')).toBe(false);
});
it('says so when there is nothing left to add', () => {
const t = chooser(RUNNING, { clis: [cli('claude', 'Claude Code', { installed: true, installable: false })] });
expect(t.$('wiz-lead').textContent).toContain('already on this Mac');
expect(t.visible('wiz-next')).toBe(false);
expect(t.visible('wiz-back')).toBe(true);
t.click('wiz-back');
expect(t.visible('wizard')).toBe(false);
});
it('a failure shows the reason, and Try again asks again with the same choice', () => {
const t = chooser();
(t.$('wiz-agents').querySelector('input[value=copilot]') as HTMLInputElement).click();
t.click('wiz-next');
t.native({
type: 'installFinished',
ok: false,
cancelled: false,
error: 'GitHub Copilot did not install.',
log: ['npm ERR! nope'],
});
expect(t.$('wiz-title').textContent).toBe('The install stopped');
expect(t.$('wiz-message').textContent).toBe('GitHub Copilot did not install.');
expect(t.$('wiz-log').textContent).toContain('npm ERR! nope');
expect(t.visible('wiz-terminal')).toBe(false);
expect(t.visible('wiz-back')).toBe(true);
t.click('wiz-next');
expect(t.sentOf('agentPrepare')).toHaveLength(2);
t.native({ type: 'installPlan', plan: plan(AGENTS) });
expect(t.$('wiz-next').textContent).toBe('Install GitHub Copilot');
});
it('a list it could not get says so, with no Terminal route to Codeman', () => {
const t = open();
t.native({ type: 'localStatus', status: RUNNING });
t.click('local-agents');
t.native({ type: 'installPrepareFailed', message: 'No network.' });
expect(t.$('wiz-title').textContent).toBe('Could not get the list of coding agents');
expect(t.$('wiz-message').textContent).toBe('No network.');
expect(t.visible('wiz-terminal')).toBe(false);
t.click('wiz-next');
expect(t.sentOf('agentPrepare')).toHaveLength(2);
});
it('Cancel goes through the same command and leaves This Mac as it was', () => {
const t = installing();
t.click('wiz-cancel');
expect(t.sentOf('installCancel')).toHaveLength(1);
t.native({ type: 'installFinished', ok: false, cancelled: true, error: null, log: [] });
expect(t.visible('wizard')).toBe(false);
expect(t.$('status-text').textContent).toContain('Stopped installing Codex.');
});
it('is picked up after the app was reopened, as an agent run and never as an install', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...RUNNING, agentInstallInApp: true } });
expect(t.sentOf('agentAttach')).toHaveLength(1);
expect(t.sentOf('installAttach')).toHaveLength(0);
expect(t.$('wiz-title').textContent).toBe('Installing a coding agent');
t.native({ type: 'installUpdate', phase: 'agent', log: [] });
expect(t.rail()).toEqual([['Installing a coding agent', 'now']]);
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(t.$('wiz-title').textContent).toBe('The coding agent is installed');
});
});
describe('the menu item that adds a coding agent', () => {
const RUNNING = { ...NOT_INSTALLED, state: 'running', version: '1.50.0', installed: true, installComplete: true };
it('opens the chooser on a page that is up', () => {
const t = open();
t.native({ type: 'localStatus', status: RUNNING });
t.native({ type: 'addAgent' });
expect(t.sentOf('agentPrepare')).toHaveLength(1);
});
it('waits for the first look when it left a Codeman page to get here', () => {
const t = open({ servers: [{ url: 'http://example.org:3000/' }], addAgent: true });
expect(t.sentOf('agentPrepare')).toHaveLength(0);
t.native({ type: 'localStatus', status: RUNNING });
expect(t.sentOf('agentPrepare')).toHaveLength(1);
expect(t.$('tab-local').getAttribute('aria-selected')).toBe('true');
});
it('leaves a Mac without Codeman on This Mac, with Install', () => {
const t = open({ addAgent: true });
t.native({ type: 'localStatus', status: NOT_INSTALLED });
expect(t.sentOf('agentPrepare')).toHaveLength(0);
expect(t.visible('wizard')).toBe(false);
expect(t.visible('local-install')).toBe(true);
});
it('never replaces a run on screen', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...RUNNING, agentInstallInApp: true } });
t.native({ type: 'addAgent' });
expect(t.sentOf('agentPrepare')).toHaveLength(0);
});
});
describe('after Skip for now', () => {
function skipped() {
const t = open();
t.native({ type: 'localStatus', status: NOT_INSTALLED });
t.click('local-install');
t.native({ type: 'installPlan', plan: plan() });
t.click('wiz-skip');
t.click('wiz-next');
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
t.native({
type: 'localStatus',
status: { ...NOT_INSTALLED, state: 'running', installed: true, version: '1.50.0' },
});
return t;
}
it('stops at Codeman being ready instead of opening it with nothing to run', () => {
const t = skipped();
expect(t.$('wiz-title').textContent).toBe('Codeman is ready');
expect(t.$('wiz-lead').textContent).toContain('no coding agent');
expect(t.sentOf('connect')).toHaveLength(0);
});
it('Open Codeman opens it anyway', () => {
const t = skipped();
t.click('wiz-next');
expect(t.sentOf('connect')).toHaveLength(1);
});
it('Add a coding agent goes to the chooser', () => {
const t = skipped();
expect(t.$('wiz-skip').textContent).toBe('Add a coding agent');
t.click('wiz-skip');
expect(t.sentOf('agentPrepare')).toHaveLength(1);
});
it('an install that got an agent still opens Codeman by itself', () => {
const t = open();
t.native({ type: 'localStatus', status: NOT_INSTALLED });
t.click('local-install');
t.native({ type: 'installPlan', plan: plan() });
t.click('wiz-next');
t.click('wiz-next');
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
t.native({
type: 'localStatus',
status: { ...NOT_INSTALLED, state: 'running', installed: true, version: '1.50.0' },
});
expect(t.sentOf('connect')).toHaveLength(1);
});
});
+118
View File
@@ -539,6 +539,124 @@ describe('install.sh preflight', () => {
});
});
describe('install.sh agent, one AI CLI and nothing else', () => {
// The real subcommand in a real bash, with detection pointed at nothing and every
// trusted command rewritten: LEAVES names the binary a stand-in install leaves
// behind (on PATH, in the run's own folder), FOUND is a CLI already here.
const DRIVER = `
set -euo pipefail
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
set +e
parse_flags "\${@:2}" || exit 9
set -e
bin=$(mktemp -d); trap 'rm -rf "$bin"' EXIT; export PATH="$bin:$PATH"
k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done
k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done
k=0; while [[ $k -lt \${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do
if [[ "\${KEEP_COMMANDS:-}" != "1" && -n "\${CLI_INSTALL_CMD_TRUSTED[$k]}" ]]; then
CLI_INSTALL_CMD_TRUSTED[$k]="echo RAN=\${CLI_IDS[$k]}; if [[ \${CLI_IDS[$k]} == \\"\${LEAVES:-}\\" ]]; then printf '#!/bin/sh\\n' > $bin/codeman-test-left-$k; chmod +x $bin/codeman-test-left-$k; fi"
fi
if [[ "\${CLI_IDS[$k]}" == "\${FOUND:-}" ]]; then
printf '#!/bin/sh\n' > "$bin/codeman-test-found"; chmod +x "$bin/codeman-test-found"
off=\${CLI_BIN_OFF[$k]}; CLI_ALL_BINS[$off]=codeman-test-found
fi
if [[ "\${CLI_IDS[$k]}" == "\${LEAVES:-}" ]]; then
off=\${CLI_BIN_OFF[$k]}; CLI_ALL_BINS[$off]=codeman-test-left-$k
fi
k=$((k + 1))
done
if [[ -n "\${NO_NPM:-}" ]]; then check_npm() { return 1; }; fi
CLI_DETECT_DONE=""
agent_subcommand
echo "REACHED THE END"
`;
function agent(args: string[], env: Record<string, string> = {}) {
const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH, ...args], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, CODEMAN_PROGRESS: '', ...env },
});
const stdout = result.stdout ?? '';
const stderr = result.stderr ?? '';
return { status: result.status, stdout, stderr, ran: [...stdout.matchAll(/^RAN=(.+)$/gm)].map((m) => m[1]) };
}
it('installs the one it was told to, and says done', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { LEAVES: 'codex' });
expect(run.status, run.stderr).toBe(0);
expect(run.ran).toEqual(['codex']);
expect(run.stderr).toContain('@@codeman phase agent');
expect(run.stderr).toContain('@@codeman phase done');
});
it('never says done when that agent did not land, even with another one here', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { FOUND: 'copilot' });
expect(run.ran).toEqual(['codex']);
expect(run.status).toBe(1);
expect(run.stderr).toContain('@@codeman error Codex did not install.');
expect(run.stderr).not.toContain('@@codeman phase done');
expect(run.stdout).not.toContain('REACHED THE END');
});
it('installs nothing for an agent that is here already, and that is a success', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { FOUND: 'codex' });
expect(run.status, run.stderr).toBe(0);
expect(run.ran).toEqual([]);
expect(run.stderr).toContain('@@codeman phase done');
});
it('names what is wrong when npm cannot be found, before running anything', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { KEEP_COMMANDS: '1', NO_NPM: '1' });
expect(run.status).toBe(1);
expect(run.stderr).toContain('Codex installs with npm, and this run cannot find it');
expect(run.stderr).toContain('npm install -g @openai/codex');
expect(run.stderr).not.toContain('@@codeman phase done');
});
it('refuses an agent it cannot install, and installs no other in its place', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'deepseek'], { KEEP_COMMANDS: '1' });
expect(run.status).toBe(1);
expect(run.stderr).toContain('DeepSeek cannot be installed automatically here');
expect(run.stderr).not.toContain('@@codeman phase done');
});
it('it takes the name as a plain word, as a person would type it', () => {
const driver = `
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
set +e
parse_flags "\${@:2}" || exit 9
echo "sub=$SUBCOMMAND cli=$AI_CLI_CHOICE"
`;
const parsed = (...args: string[]) =>
spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH, ...args], { encoding: 'utf-8', timeout: 30_000 });
expect(parsed('agent', 'codex', '--no-admin').stdout).toContain('sub=agent cli=codex');
expect(parsed('agent', '--ai-cli', 'codex').stdout).toContain('sub=agent cli=codex');
expect(parsed('agent', 'codx').stderr).toContain('Unknown AI CLI: codx');
expect(parsed('agent', 'shell').stderr).toContain('Unknown AI CLI: shell');
expect(parsed('agent', 'codex', 'copilot').stderr).toContain('Unexpected argument: copilot');
expect(parsed('codex').stderr).toContain('Unexpected argument: codex');
});
it('wants a name, and none is not one', () => {
expect(agent([]).stderr).toContain('Name the AI CLI to install');
expect(agent(['--ai-cli', 'none']).stderr).toContain('Name the AI CLI to install');
});
it('leaves Codeman alone: nothing in it touches the service, the source or the build', () => {
const body = SOURCE.slice(
SOURCE.indexOf('\nagent_subcommand() {'),
SOURCE.indexOf('\n# Offer to install one AI CLI')
);
for (const word of ['launchctl', 'systemctl', 'setup_launchd', 'install_or_update_repo', 'npm run', 'sudo']) {
expect(body, word).not.toContain(word);
}
expect(body).toContain('install_offered_cli "$idx"');
});
});
describe('install.sh --progress, the protocol the desktop app reads', () => {
const CONNECT_HTML = readFileSync(
fileURLToPath(new URL('../packages/desktop/assets/connect.html', import.meta.url)),