Files
stack/packages/mosaic/src/commands/launch.spec.ts
T
terraandClaude Opus 5 309a99a600 fleet: fix four defects that made no seat launchable on a clean install
Found by rehearsing the full install on a greenfield Debian 13 VM
(mosaic-sbx-dev) rather than on a host that already had a working Mosaic
tree. Each one is invisible on a developer machine and fatal on a new host.

1. Required system settings layer. The framework ships runtime/<harness>/
   for claude, codex, opencode and pi but a settings.json only for claude,
   so requiring the file made every pi, codex and opencode seat refuse to
   compose. The system layer is now optional; what must exist is the
   harness runtime directory, which is the thing that actually proves the
   framework is installed and carries that harness.

2. Required mcpServers in canonical Claude settings. The shipped
   settings.json has no such key, so `fleet agent new` refused to scaffold
   any Claude seat. Absent now means the same as empty. A present but
   wrong-typed value is still an error.

3. Never-enrolled hosts were told their auth directory "must be a real,
   non-symlink directory", which reads as a tampering report when the real
   situation is that nobody has logged in yet. Absent and wrong-shaped are
   now separate messages, and the absent one names `mosaic auth enroll`.

4. A fleet seat whose host had no system SOUL.md reached checkSoul(),
   which spawns the interactive `mosaic wizard` with inherited stdio. On a
   detached tmux seat that parks the pane on a menu with nobody at it: the
   session is live, the systemd unit reports fine, and no agent ever
   starts. A seat's identity is its own SOUL.md, written by `fleet agent
   new`, so the fleet path checks that and fails loudly instead.

Each fix has a regression test verified red against the unfixed source.
The launch.spec.ts seat fixtures gained a SOUL.md they always should have
had -- without it those tests were satisfied by whatever SOUL.md the
developer's real ~/.config/mosaic happened to contain.

Full suite before and after: the same 5 pre-existing failures in
mutator-gate.acceptance.spec.ts and install-ordering-guard.spec.ts,
1585 -> 1591 passing. typecheck and eslint clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-14 19:03:30 -05:00

644 lines
24 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { Command } from 'commander';
import { spawnSync } from 'node:child_process';
import {
chmodSync,
copyFileSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
writeFileSync,
symlinkSync,
rmSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
buildPiSkillArgs,
enumerateSkillDirs,
piForceSkillNames,
registerRuntimeLaunchers,
checkSequentialThinking,
launchFleetRuntimeForTest,
type RuntimeLaunchHandler,
type ClaudexLaunchHandler,
} from './launch.js';
/**
* Tests for the commander wiring between `mosaic <runtime>` / `mosaic yolo <runtime>`
* subcommands and the internal `launchRuntime` dispatcher.
*
* Regression target: see mosaicstack/stack#454 — before the fix, `mosaic yolo claude`
* passed the literal string "claude" as an excess positional argument to the
* underlying CLI, which Claude Code then interpreted as the first user message.
*
* The bug existed because Commander.js includes declared positional arguments
* (here `<runtime>`) in `cmd.args` alongside any true excess args. The action
* handler must slice them off before forwarding.
*/
function buildProgram(handler: RuntimeLaunchHandler): Command {
const program = new Command();
program.exitOverride(); // prevent process.exit on parse errors
registerRuntimeLaunchers(program, handler);
return program;
}
function buildProgramWithClaudex(
handler: RuntimeLaunchHandler,
claudexHandler: ClaudexLaunchHandler,
): Command {
const program = new Command();
program.exitOverride();
registerRuntimeLaunchers(program, handler, claudexHandler);
return program;
}
const fakeSkills = ['--skill', '/skills/test-driven-development', '--skill', '/skills/pdf'];
const fakeForced = ['--skill', '/skills/mosaic-tools'];
// `process.exit` returns `never`, so vi.spyOn demands a replacement with the
// same signature. We throw from the mock to short-circuit into test-land.
const exitThrows = (): never => {
throw new Error('process.exit called');
};
describe('registerRuntimeLaunchers — non-yolo subcommands', () => {
let mockExit: MockInstance<typeof process.exit>;
beforeEach(() => {
// process.exit is called when the yolo action rejects an invalid runtime.
// Stub it so the assertion catches the rejection instead of terminating
// the test runner.
mockExit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
});
afterEach(() => {
mockExit.mockRestore();
});
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'forwards %s with empty extraArgs and yolo=false',
(runtime) => {
const handler = vi.fn();
const program = buildProgram(handler);
program.parse(['node', 'mosaic', runtime]);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(runtime, [], false);
},
);
it('forwards excess args after a non-yolo runtime subcommand', () => {
const handler = vi.fn();
const program = buildProgram(handler);
program.parse(['node', 'mosaic', 'claude', '--print', 'hello']);
expect(handler).toHaveBeenCalledWith('claude', ['--print', 'hello'], false);
});
});
describe('checkSequentialThinking', () => {
it('runs the real fleet launch preflight against the injected seat, not HOME', () => {
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
try {
expect(
JSON.parse(
readFileSync(
join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json'),
'utf8',
),
).mcpServers['sequential-thinking'],
).toEqual({
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
});
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
copyFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
checker,
);
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
mkdirSync(join(agentDir, '.claude'), { recursive: true });
writeFileSync(
join(agentDir, '.claude', '.claude.json'),
JSON.stringify({
mcpServers: {
'sequential-thinking': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
},
},
}),
{ mode: 0o600 },
);
vi.stubEnv('HOME', home);
const final = vi.fn((): never => {
throw new Error('final runtime boundary');
});
expect(() =>
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, final),
).toThrow('final runtime boundary');
expect(final).toHaveBeenCalledOnce();
} finally {
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
it('fails the real fleet launch preflight when only operator HOME is seeded', () => {
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
try {
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
copyFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
checker,
);
writeFileSync(
join(home, '.claude.json'),
JSON.stringify({
mcpServers: {
'sequential-thinking': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
},
},
}),
);
// Scaffolded seat, unconfigured harness: the seat's own identity is present so this
// still fails on the missing MCP configuration rather than on a missing SOUL.md.
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
vi.stubEnv('HOME', home);
expect(() =>
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => {
throw new Error('must not execute');
}),
).toThrow('process.exit called');
} finally {
exit.mockRestore();
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
it('passes with a seeded seat even when operator HOME has no MCP configuration', () => {
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
try {
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
copyFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
checker,
);
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
mkdirSync(join(agentDir, '.claude'), { recursive: true });
writeFileSync(
join(agentDir, '.claude', '.claude.json'),
JSON.stringify({
mcpServers: {
'sequential-thinking': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
},
},
}),
{ mode: 0o600 },
);
vi.stubEnv('MOSAIC_HOME', installed);
vi.stubEnv('HOME', home);
expect(() =>
checkSequentialThinking('claude', { agentDir, mosaicHome: installed }),
).not.toThrow();
} finally {
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
it('repairs a legacy seat config in place without using operator HOME', () => {
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
const bin = join(installed, 'bin');
try {
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
mkdirSync(join(agentDir, '.claude'), { recursive: true });
mkdirSync(bin, { recursive: true });
copyFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
checker,
);
for (const name of ['node', 'npx']) {
writeFileSync(join(bin, name), '#!/usr/bin/env bash\nexit 0\n');
chmodSync(join(bin, name), 0o755);
}
writeFileSync(
join(agentDir, '.claude', '.claude.json'),
JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }),
{ mode: 0o600 },
);
const env = { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` };
expect(
spawnSync(
checker,
['--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')],
{
env,
},
).status,
).toBe(0);
expect(
spawnSync(
checker,
['--check', '--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')],
{ env },
).status,
).toBe(0);
expect(
JSON.parse(readFileSync(join(agentDir, '.claude', '.claude.json'), 'utf8')),
).toMatchObject({
hasCompletedOnboarding: true,
theme: 'dark',
mcpServers: { 'sequential-thinking': { command: 'npx' } },
});
expect(existsSync(join(home, '.claude.json'))).toBe(false);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
it('refuses an unscaffolded seat instead of opening the interactive setup wizard', () => {
// Measured on a greenfield VM: a roster-started seat whose host had no system SOUL.md
// reached checkSoul(), which spawns `mosaic wizard` with inherited stdio. With nobody at
// the pane the seat parked on the wizard's menu -- tmux session live, unit reporting
// fine, no agent ever launched. A fleet seat's identity is its own SOUL.md, and an
// unattended launch must fail loudly rather than wait for a keystroke.
const home = mkdtempSync(join(tmpdir(), 'mosaic-soul-home-'));
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-soul-seat-'));
const installed = mkdtempSync(join(tmpdir(), 'mosaic-soul-installed-'));
const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
vi.stubEnv('HOME', home);
expect(() =>
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => {
throw new Error('must not execute');
}),
).toThrow('process.exit called');
expect(exit).toHaveBeenCalledWith(1);
expect(error).toHaveBeenCalledWith(expect.stringContaining(join(agentDir, 'SOUL.md')));
expect(error).toHaveBeenCalledWith(expect.stringContaining('mosaic fleet agent new'));
} finally {
error.mockRestore();
exit.mockRestore();
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
it('rejects a group-writable installed helper root', () => {
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
try {
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
copyFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
checker,
);
chmodSync(installed, 0o770);
expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
/not a trusted installed file/,
);
} finally {
chmodSync(installed, 0o700);
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
it('refuses an empty seat even when operator HOME is configured', () => {
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
try {
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
copyFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
checker,
);
writeFileSync(
join(home, '.claude.json'),
JSON.stringify({
mcpServers: {
'sequential-thinking': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
},
},
}),
);
vi.stubEnv('MOSAIC_HOME', installed);
vi.stubEnv('HOME', home);
expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
'process.exit called',
);
expect(exit).toHaveBeenCalledWith(1);
} finally {
exit.mockRestore();
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
});
describe('buildPiSkillArgs', () => {
it('disables auto-discovery but force-loads fleet-critical skills by default', () => {
expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([
'--no-skills',
'--skill',
'/skills/mosaic-tools',
]);
});
it('ignores _runtimeArgs (user --skill flags reach Pi via the launch handler, not here)', () => {
expect(buildPiSkillArgs(['--skill', '/tmp/custom'], {}, fakeSkills, fakeForced)).toEqual([
'--no-skills',
'--skill',
'/skills/mosaic-tools',
]);
});
it('emits only --no-skills when no forced skills are present on disk', () => {
expect(buildPiSkillArgs([], {}, fakeSkills, [])).toEqual(['--no-skills']);
});
it('all-skills mode merges the forced set in without duplicating discovered skills', () => {
expect(buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'all' }, fakeSkills, fakeForced)).toEqual([
'--no-skills',
'--skill',
'/skills/test-driven-development',
'--skill',
'/skills/pdf',
'--skill',
'/skills/mosaic-tools',
]);
});
it('all-skills mode does not double-load a forced skill already discovered', () => {
expect(
buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'all' }, fakeSkills, ['--skill', '/skills/pdf']),
).toEqual([
'--no-skills',
'--skill',
'/skills/test-driven-development',
'--skill',
'/skills/pdf',
]);
});
it('force-loads fleet skills under native Pi discovery when not already discoverable', () => {
// Empty native set => Pi would not find mosaic-tools on its own, so force it.
expect(
buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'discover' }, fakeSkills, fakeForced, new Set()),
).toEqual(['--skill', '/skills/mosaic-tools']);
});
it('discover mode drops a forced skill Pi already discovers natively (no double-load)', () => {
// mosaic-tools is reachable from a Pi native root, so native discovery
// covers it — forcing it again would register the same skill twice.
expect(
buildPiSkillArgs(
[],
{ MOSAIC_PI_SKILL_MODE: 'discover' },
fakeSkills,
fakeForced,
new Set(['/skills/mosaic-tools']),
),
).toEqual([]);
});
it('discover mode keeps a forced skill that no native root provides', () => {
expect(
buildPiSkillArgs(
[],
{ MOSAIC_PI_SKILL_MODE: 'discover' },
fakeSkills,
fakeForced,
new Set(['/skills/some-other-skill']),
),
).toEqual(['--skill', '/skills/mosaic-tools']);
});
it('discover mode collapses a forced skill listed twice to a single --skill', () => {
// Mirror 'all' mode: intra-forced-set duplicates (same realpath) dedup.
expect(
buildPiSkillArgs(
[],
{ MOSAIC_PI_SKILL_MODE: 'discover' },
fakeSkills,
['--skill', '/skills/mosaic-tools', '--skill', '/skills/mosaic-tools'],
new Set(),
),
).toEqual(['--skill', '/skills/mosaic-tools']);
});
});
describe('enumerateSkillDirs (real FS)', () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mosaic-skills-'));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function makeSkill(parent: string, name: string): string {
const dir = join(parent, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'SKILL.md'), `# ${name}\n`);
return dir;
}
it('accepts a symlinked skill dir (regression: synced fleet skills are symlinks)', () => {
// Real skill lives under `canonical/`; the scanned root only has a symlink to it.
const canonical = makeSkill(join(root, 'canonical'), 'mosaic-tools');
const scanned = join(root, 'scanned');
mkdirSync(scanned, { recursive: true });
symlinkSync(canonical, join(scanned, 'mosaic-tools'), 'dir');
expect(enumerateSkillDirs([scanned])).toEqual(['--skill', join(scanned, 'mosaic-tools')]);
});
it('dedups by real path when the same skill is reachable from two roots', () => {
// Root A holds the real dir; root B symlinks to it — one --skill, not two.
const rootA = join(root, 'a');
const rootB = join(root, 'b');
const real = makeSkill(rootA, 'mosaic-tools');
mkdirSync(rootB, { recursive: true });
symlinkSync(real, join(rootB, 'mosaic-tools'), 'dir');
expect(enumerateSkillDirs([rootA, rootB])).toEqual(['--skill', real]);
});
it('skips directories without a SKILL.md and missing roots', () => {
mkdirSync(join(root, 'present', 'not-a-skill'), { recursive: true });
makeSkill(join(root, 'present'), 'real-skill');
expect(enumerateSkillDirs([join(root, 'present'), join(root, 'does-not-exist')])).toEqual([
'--skill',
join(root, 'present', 'real-skill'),
]);
});
});
describe('piForceSkillNames', () => {
it('defaults to mosaic-tools when MOSAIC_PI_FORCE_SKILLS is unset', () => {
expect(piForceSkillNames({})).toEqual(['mosaic-tools']);
});
it('treats an empty string as "disable force-loading" (distinct from unset)', () => {
expect(piForceSkillNames({ MOSAIC_PI_FORCE_SKILLS: '' })).toEqual([]);
});
it('parses a colon list, trimming blanks and whitespace', () => {
expect(piForceSkillNames({ MOSAIC_PI_FORCE_SKILLS: 'mosaic-tools: mosaic-gitea ::' })).toEqual([
'mosaic-tools',
'mosaic-gitea',
]);
});
});
describe('registerRuntimeLaunchers — yolo <runtime>', () => {
let mockExit: MockInstance<typeof process.exit>;
let mockError: MockInstance<typeof console.error>;
beforeEach(() => {
mockExit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
mockError = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
mockExit.mockRestore();
mockError.mockRestore();
});
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'does NOT pass the runtime name as an extra arg (regression #454) for yolo %s',
(runtime) => {
const handler = vi.fn();
const program = buildProgram(handler);
program.parse(['node', 'mosaic', 'yolo', runtime]);
expect(handler).toHaveBeenCalledTimes(1);
// The critical assertion: extraArgs must be empty, not [runtime].
// Before the fix, cmd.args was [runtime] and the runtime name leaked
// through to the underlying CLI as an initial positional argument.
expect(handler).toHaveBeenCalledWith(runtime, [], true);
},
);
it('forwards true excess args after a yolo runtime', () => {
const handler = vi.fn();
const program = buildProgram(handler);
program.parse(['node', 'mosaic', 'yolo', 'claude', '--print', 'hi']);
expect(handler).toHaveBeenCalledWith('claude', ['--print', 'hi'], true);
});
it('rejects an unknown runtime under yolo without invoking the handler', () => {
const handler = vi.fn();
const program = buildProgram(handler);
expect(() => program.parse(['node', 'mosaic', 'yolo', 'bogus'])).toThrow('process.exit called');
expect(handler).not.toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(1);
});
});
describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
let mockExit: MockInstance<typeof process.exit>;
let mockError: MockInstance<typeof console.error>;
beforeEach(() => {
mockExit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
mockError = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
mockExit.mockRestore();
mockError.mockRestore();
});
it('dispatches `claudex` to the claudex handler (yolo=false), not the runtime handler', () => {
const handler = vi.fn();
const claudex = vi.fn();
const program = buildProgramWithClaudex(handler, claudex);
program.parse(['node', 'mosaic', 'claudex']);
expect(claudex).toHaveBeenCalledTimes(1);
expect(claudex).toHaveBeenCalledWith([], false);
expect(handler).not.toHaveBeenCalled();
});
it('forwards excess args after `claudex`', () => {
const handler = vi.fn();
const claudex = vi.fn();
const program = buildProgramWithClaudex(handler, claudex);
program.parse(['node', 'mosaic', 'claudex', '--print', 'hi']);
expect(claudex).toHaveBeenCalledWith(['--print', 'hi'], false);
});
it('dispatches `yolo claudex` with yolo=true and slices off the runtime name (#454)', () => {
const handler = vi.fn();
const claudex = vi.fn();
const program = buildProgramWithClaudex(handler, claudex);
program.parse(['node', 'mosaic', 'yolo', 'claudex']);
expect(claudex).toHaveBeenCalledTimes(1);
// extraArgs must be empty — the positional 'claudex' must not leak through.
expect(claudex).toHaveBeenCalledWith([], true);
expect(handler).not.toHaveBeenCalled();
expect(mockExit).not.toHaveBeenCalled();
});
it('forwards true excess args after `yolo claudex`', () => {
const handler = vi.fn();
const claudex = vi.fn();
const program = buildProgramWithClaudex(handler, claudex);
program.parse(['node', 'mosaic', 'yolo', 'claudex', '--model', 'gpt-5.6-sol']);
expect(claudex).toHaveBeenCalledWith(['--model', 'gpt-5.6-sol'], true);
expect(mockExit).not.toHaveBeenCalled();
});
});