Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
559 lines
22 KiB
JavaScript
559 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import { createRequire } from 'module';
|
||
import { Command } from 'commander';
|
||
import { registerBrainCommand } from '@mosaicstack/brain';
|
||
import { registerForgeCommand } from '@mosaicstack/forge';
|
||
import { registerLogCommand } from '@mosaicstack/log';
|
||
import { registerMacpCommand } from '@mosaicstack/macp';
|
||
import { registerMemoryCommand } from '@mosaicstack/memory';
|
||
import { registerQualityRails } from '@mosaicstack/quality-rails';
|
||
import { registerQueueCommand } from '@mosaicstack/queue';
|
||
import { registerStorageCommand } from '@mosaicstack/storage';
|
||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||
import { registerAgentCommand } from './commands/agent.js';
|
||
import { registerConfigCommand } from './commands/config.js';
|
||
import { registerFleetCommand } from './commands/fleet.js';
|
||
import { registerMissionCommand } from './commands/mission.js';
|
||
import { registerUninstallCommand } from './commands/uninstall.js';
|
||
// prdy is registered via launch.ts
|
||
import { registerLaunchCommands } from './commands/launch.js';
|
||
import { registerAuthCommand } from './commands/auth.js';
|
||
import { registerFederationCommand } from './commands/federation.js';
|
||
import { registerGatewayCommand } from './commands/gateway.js';
|
||
import {
|
||
backgroundUpdateCheck,
|
||
checkForAllUpdates,
|
||
formatAllPackagesTable,
|
||
getInstallAllCommand,
|
||
runFrameworkReseed,
|
||
readRosterAgentNames,
|
||
buildRelaunchCommands,
|
||
FRAMEWORK_RESEED_PACKAGE,
|
||
} from './runtime/update-checker.js';
|
||
import { runWizard } from './wizard.js';
|
||
import { ClackPrompter } from './prompter/clack-prompter.js';
|
||
import { HeadlessPrompter } from './prompter/headless-prompter.js';
|
||
import { createConfigService } from './config/config-service.js';
|
||
import { WizardCancelledError } from './errors.js';
|
||
import { DEFAULT_MOSAIC_HOME } from './constants.js';
|
||
|
||
const _require = createRequire(import.meta.url);
|
||
const CLI_VERSION: string = (_require('../package.json') as { version: string }).version;
|
||
|
||
// Fire-and-forget update check at startup (non-blocking, cached 1h)
|
||
try {
|
||
backgroundUpdateCheck();
|
||
} catch {
|
||
// Silently ignore — update check is best-effort
|
||
}
|
||
|
||
const program = new Command();
|
||
|
||
program
|
||
.name('mosaic')
|
||
.description('Mosaic Stack CLI')
|
||
.version(CLI_VERSION)
|
||
.configureHelp({ sortSubcommands: true })
|
||
.addHelpText(
|
||
'after',
|
||
`
|
||
Command Groups:
|
||
|
||
Runtime: tui, login, sessions
|
||
Gateway: gateway
|
||
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
|
||
Platform: update
|
||
Runtimes: claude, codex, opencode, pi
|
||
`,
|
||
);
|
||
|
||
// ─── runtime launchers + framework commands ────────────────────────────
|
||
|
||
registerLaunchCommands(program);
|
||
|
||
// ─── login ──────────────────────────────────────────────────────────────
|
||
|
||
program
|
||
.command('login')
|
||
.description('Sign in to a Mosaic gateway')
|
||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||
.option('-e, --email <email>', 'Email address')
|
||
.option('-p, --password <password>', 'Password')
|
||
.action(async (opts: { gateway: string; email?: string; password?: string }) => {
|
||
const { signIn, saveSession } = await import('./auth.js');
|
||
|
||
let email = opts.email;
|
||
let password = opts.password;
|
||
|
||
if (!email || !password) {
|
||
const readline = await import('node:readline');
|
||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||
|
||
if (!email) email = await ask('Email: ');
|
||
if (!password) password = await ask('Password: ');
|
||
rl.close();
|
||
}
|
||
|
||
try {
|
||
const auth = await signIn(opts.gateway, email, password);
|
||
saveSession(opts.gateway, auth);
|
||
console.log(`Signed in as ${auth.email} (${opts.gateway})`);
|
||
} catch (err) {
|
||
console.error(err instanceof Error ? err.message : String(err));
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
// ─── tui ────────────────────────────────────────────────────────────────
|
||
|
||
program
|
||
.command('tui')
|
||
.description('Launch interactive TUI connected to the gateway')
|
||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
||
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
|
||
.option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)')
|
||
.option('--agent <idOrName>', 'Connect to a specific agent')
|
||
.option('--project <idOrName>', 'Scope session to project')
|
||
.action(
|
||
async (opts: {
|
||
gateway: string;
|
||
conversation?: string;
|
||
model?: string;
|
||
provider?: string;
|
||
agent?: string;
|
||
project?: string;
|
||
}) => {
|
||
const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js');
|
||
|
||
// Try loading saved session
|
||
let session = loadSession(opts.gateway);
|
||
|
||
if (session) {
|
||
const valid = await validateSession(opts.gateway, session.cookie);
|
||
if (!valid) {
|
||
console.log('Session expired. Please sign in again.');
|
||
session = null;
|
||
}
|
||
}
|
||
|
||
// No valid session — prompt for credentials
|
||
if (!session) {
|
||
const { promptLine, promptSecret } = await import('./commands/gateway/login.js');
|
||
|
||
console.log(`Sign in to ${opts.gateway}`);
|
||
const email = await promptLine('Email: ');
|
||
const password = await promptSecret('Password: ');
|
||
|
||
try {
|
||
const auth = await signIn(opts.gateway, email, password);
|
||
saveSession(opts.gateway, auth);
|
||
session = auth;
|
||
console.log(`Signed in as ${auth.email}\n`);
|
||
} catch (err) {
|
||
console.error(err instanceof Error ? err.message : String(err));
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Resolve agent ID if --agent was passed by name
|
||
let agentId: string | undefined;
|
||
let agentName: string | undefined;
|
||
if (opts.agent) {
|
||
try {
|
||
const { fetchAgentConfigs } = await import('./tui/gateway-api.js');
|
||
const agents = await fetchAgentConfigs(opts.gateway, session.cookie);
|
||
const match = agents.find((a) => a.id === opts.agent || a.name === opts.agent);
|
||
if (match) {
|
||
agentId = match.id;
|
||
agentName = match.name;
|
||
} else {
|
||
console.error(`Agent "${opts.agent}" not found.`);
|
||
process.exit(1);
|
||
}
|
||
} catch (err) {
|
||
console.error(
|
||
`Failed to resolve agent: ${err instanceof Error ? err.message : String(err)}`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Resolve project ID if --project was passed by name
|
||
let projectId: string | undefined;
|
||
if (opts.project) {
|
||
try {
|
||
const { fetchProjects } = await import('./tui/gateway-api.js');
|
||
const projects = await fetchProjects(opts.gateway, session.cookie);
|
||
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
|
||
if (match) {
|
||
projectId = match.id;
|
||
} else {
|
||
console.error(`Project "${opts.project}" not found.`);
|
||
process.exit(1);
|
||
}
|
||
} catch (err) {
|
||
console.error(
|
||
`Failed to resolve project: ${err instanceof Error ? err.message : String(err)}`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Auto-create a conversation if none was specified
|
||
let conversationId = opts.conversation;
|
||
if (!conversationId) {
|
||
try {
|
||
const { createConversation } = await import('./tui/gateway-api.js');
|
||
const conv = await createConversation(opts.gateway, session.cookie, {
|
||
...(projectId ? { projectId } : {}),
|
||
});
|
||
conversationId = conv.id;
|
||
} catch (err) {
|
||
console.error(
|
||
`Failed to create conversation: ${err instanceof Error ? err.message : String(err)}`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Dynamic import to avoid loading React/Ink for other commands
|
||
const { render } = await import('ink');
|
||
const React = await import('react');
|
||
const { TuiApp } = await import('./tui/app.js');
|
||
|
||
render(
|
||
React.createElement(TuiApp, {
|
||
gatewayUrl: opts.gateway,
|
||
conversationId,
|
||
sessionCookie: session.cookie,
|
||
initialModel: opts.model,
|
||
initialProvider: opts.provider,
|
||
agentId,
|
||
agentName: agentName ?? undefined,
|
||
projectId,
|
||
version: CLI_VERSION,
|
||
}),
|
||
{ exitOnCtrlC: false },
|
||
);
|
||
},
|
||
);
|
||
|
||
// ─── sessions ───────────────────────────────────────────────────────────
|
||
|
||
const sessionsCmd = program
|
||
.command('sessions')
|
||
.description('Manage active agent sessions')
|
||
.configureHelp({ sortSubcommands: true });
|
||
|
||
sessionsCmd
|
||
.command('list')
|
||
.description('List active agent sessions')
|
||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||
.action(async (opts: { gateway: string }) => {
|
||
const { withAuth } = await import('./commands/with-auth.js');
|
||
const auth = await withAuth(opts.gateway);
|
||
const { fetchSessions } = await import('./tui/gateway-api.js');
|
||
|
||
try {
|
||
const result = await fetchSessions(auth.gateway, auth.cookie);
|
||
if (result.total === 0) {
|
||
console.log('No active sessions.');
|
||
return;
|
||
}
|
||
console.log(`Active sessions (${result.total}):\n`);
|
||
for (const s of result.sessions) {
|
||
const created = new Date(s.createdAt).toLocaleString();
|
||
const durationSec = Math.round(s.durationMs / 1000);
|
||
console.log(` ID: ${s.id}`);
|
||
console.log(` Model: ${s.provider}/${s.modelId}`);
|
||
console.log(` Created: ${created}`);
|
||
console.log(` Prompts: ${s.promptCount}`);
|
||
console.log(` Duration: ${durationSec}s`);
|
||
if (s.channels.length > 0) {
|
||
console.log(` Channels: ${s.channels.join(', ')}`);
|
||
}
|
||
console.log('');
|
||
}
|
||
} catch (err) {
|
||
console.error(err instanceof Error ? err.message : String(err));
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
sessionsCmd
|
||
.command('resume <id>')
|
||
.description('Resume an existing agent session in the TUI')
|
||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||
.action(async (id: string, opts: { gateway: string }) => {
|
||
const { loadSession, validateSession } = await import('./auth.js');
|
||
|
||
const session = loadSession(opts.gateway);
|
||
if (!session) {
|
||
console.error('Not signed in. Run `mosaic login` first.');
|
||
process.exit(1);
|
||
}
|
||
|
||
const valid = await validateSession(opts.gateway, session.cookie);
|
||
if (!valid) {
|
||
console.error('Session expired. Run `mosaic login` again.');
|
||
process.exit(1);
|
||
}
|
||
|
||
const { render } = await import('ink');
|
||
const React = await import('react');
|
||
const { TuiApp } = await import('./tui/app.js');
|
||
|
||
render(
|
||
React.createElement(TuiApp, {
|
||
gatewayUrl: opts.gateway,
|
||
conversationId: id,
|
||
sessionCookie: session.cookie,
|
||
version: CLI_VERSION,
|
||
}),
|
||
);
|
||
});
|
||
|
||
sessionsCmd
|
||
.command('destroy <id>')
|
||
.description('Terminate an active agent session')
|
||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||
.action(async (id: string, opts: { gateway: string }) => {
|
||
const { withAuth } = await import('./commands/with-auth.js');
|
||
const auth = await withAuth(opts.gateway);
|
||
const { deleteSession } = await import('./tui/gateway-api.js');
|
||
|
||
try {
|
||
await deleteSession(auth.gateway, auth.cookie, id);
|
||
console.log(`Session ${id} destroyed.`);
|
||
} catch (err) {
|
||
console.error(err instanceof Error ? err.message : String(err));
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
// ─── auth ────────────────────────────────────────────────────────────────
|
||
|
||
registerAuthCommand(program);
|
||
|
||
// ─── gateway ──────────────────────────────────────────────────────────
|
||
|
||
registerGatewayCommand(program);
|
||
|
||
// ─── federation ───────────────────────────────────────────────────────
|
||
|
||
registerFederationCommand(program);
|
||
|
||
// ─── agent ─────────────────────────────────────────────────────────────
|
||
|
||
registerAgentCommand(program);
|
||
|
||
// ─── fleet ─────────────────────────────────────────────────────────────
|
||
|
||
registerFleetCommand(program);
|
||
|
||
// ─── config ────────────────────────────────────────────────────────────
|
||
|
||
registerConfigCommand(program);
|
||
|
||
// ─── mission ───────────────────────────────────────────────────────────
|
||
|
||
registerMissionCommand(program);
|
||
|
||
// ─── brain ──────────────────────────────────────────────────────────────
|
||
|
||
registerBrainCommand(program);
|
||
|
||
// ─── forge ───────────────────────────────────────────────────────────────
|
||
|
||
registerForgeCommand(program);
|
||
|
||
// ─── macp ────────────────────────────────────────────────────────────────
|
||
|
||
registerMacpCommand(program);
|
||
|
||
// ─── quality-rails ──────────────────────────────────────────────────────
|
||
|
||
registerQualityRails(program);
|
||
|
||
// ─── log ─────────────────────────────────────────────────────────────────
|
||
|
||
registerLogCommand(program);
|
||
|
||
// ─── memory ──────────────────────────────────────────────────────────────
|
||
|
||
registerMemoryCommand(program);
|
||
|
||
// ─── queue ───────────────────────────────────────────────────────────────
|
||
|
||
registerQueueCommand(program);
|
||
|
||
// ─── storage ─────────────────────────────────────────────────────────────
|
||
|
||
registerStorageCommand(program);
|
||
|
||
// ─── uninstall ───────────────────────────────────────────────────────────────
|
||
|
||
registerUninstallCommand(program);
|
||
|
||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||
|
||
registerTelemetryCommand(program);
|
||
|
||
// ─── update ─────────────────────────────────────────────────────────────
|
||
|
||
program
|
||
.command('update')
|
||
.description('Check for and install Mosaic CLI updates')
|
||
.option('--check', 'Check only, do not install')
|
||
.option(
|
||
'--no-reseed',
|
||
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update',
|
||
)
|
||
.option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
|
||
.action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => {
|
||
// checkForAllUpdates imported statically above
|
||
const { execSync } = await import('node:child_process');
|
||
|
||
console.log('Checking for updates…');
|
||
const results = checkForAllUpdates({ skipCache: true });
|
||
|
||
console.log('');
|
||
console.log(formatAllPackagesTable(results));
|
||
|
||
const outdated = results.filter((r: { updateAvailable: boolean }) => r.updateAvailable);
|
||
if (outdated.length === 0) {
|
||
const anyInstalled = results.some((r: { current: string }) => r.current);
|
||
if (!anyInstalled) {
|
||
console.error('No @mosaicstack/* packages are installed.');
|
||
process.exit(1);
|
||
}
|
||
console.log('\n✔ All packages up to date.');
|
||
return;
|
||
}
|
||
|
||
if (opts.check) {
|
||
process.exit(2); // Signal to callers that an update exists
|
||
}
|
||
|
||
console.log(`\nInstalling ${outdated.length} update(s)…`);
|
||
try {
|
||
// Relies on @mosaicstack:registry in ~/.npmrc
|
||
const cmd = getInstallAllCommand(outdated);
|
||
execSync(cmd, {
|
||
stdio: 'inherit',
|
||
timeout: 60_000,
|
||
});
|
||
console.log('\n✔ Updated successfully.');
|
||
} catch {
|
||
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
||
process.exit(1);
|
||
}
|
||
|
||
// F3-m3 / R13: the CLI is updated, but the framework files in
|
||
// ~/.config/mosaic/ are still the previous version. Re-seed them from the
|
||
// freshly-installed package so shipped launcher/runtime changes ACTIVATE.
|
||
// Only when the framework-bearing package itself updated.
|
||
const mosaicUpdated = outdated.some(
|
||
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
|
||
);
|
||
if (mosaicUpdated && opts.reseed !== false) {
|
||
console.log(
|
||
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
|
||
);
|
||
const reseed = runFrameworkReseed();
|
||
if (reseed.ok) {
|
||
console.log('✔ Framework re-seeded.');
|
||
const agents = readRosterAgentNames();
|
||
if (agents.length > 0) {
|
||
if (opts.relaunch) {
|
||
console.log(
|
||
`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`,
|
||
);
|
||
for (const restart of buildRelaunchCommands(agents)) {
|
||
try {
|
||
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
|
||
} catch {
|
||
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
|
||
}
|
||
}
|
||
console.log('✔ Agents relaunched.');
|
||
} else {
|
||
console.log(
|
||
`\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
|
||
'Restart them to activate the update:\n mosaic update --relaunch ' +
|
||
'(or: mosaic fleet restart <agent>)',
|
||
);
|
||
}
|
||
}
|
||
} else {
|
||
console.error(
|
||
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
|
||
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
|
||
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
// ─── wizard ─────────────────────────────────────────────────────────────
|
||
|
||
program
|
||
.command('wizard')
|
||
.description('Run the Mosaic installation wizard')
|
||
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
|
||
.option('--source-dir <path>', 'Source directory for framework files')
|
||
.option('--mosaic-home <path>', 'Target config directory')
|
||
.option('--name <name>', 'Agent name')
|
||
.option('--role <description>', 'Agent role description')
|
||
.option('--style <style>', 'Communication style: direct|friendly|formal')
|
||
.option('--accessibility <prefs>', 'Accessibility preferences')
|
||
.option('--guardrails <rules>', 'Custom guardrails')
|
||
.option('--user-name <name>', 'Your name')
|
||
.option('--pronouns <pronouns>', 'Your pronouns')
|
||
.option('--timezone <tz>', 'Your timezone')
|
||
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
||
// All wizard imports are now static (see top of file)
|
||
|
||
try {
|
||
const mosaicHome = (opts['mosaicHome'] as string | undefined) ?? DEFAULT_MOSAIC_HOME;
|
||
const sourceDir = (opts['sourceDir'] as string | undefined) ?? mosaicHome;
|
||
|
||
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
|
||
|
||
const configService = createConfigService(mosaicHome, sourceDir);
|
||
|
||
await runWizard({
|
||
mosaicHome,
|
||
sourceDir,
|
||
prompter,
|
||
configService,
|
||
cliOverrides: {
|
||
soul: {
|
||
agentName: opts['name'] as string | undefined,
|
||
roleDescription: opts['role'] as string | undefined,
|
||
communicationStyle: opts['style'] as 'direct' | 'friendly' | 'formal' | undefined,
|
||
accessibility: opts['accessibility'] as string | undefined,
|
||
customGuardrails: opts['guardrails'] as string | undefined,
|
||
},
|
||
user: {
|
||
userName: opts['userName'] as string | undefined,
|
||
pronouns: opts['pronouns'] as string | undefined,
|
||
timezone: opts['timezone'] as string | undefined,
|
||
},
|
||
},
|
||
});
|
||
} catch (err) {
|
||
if (err instanceof WizardCancelledError) {
|
||
console.log('\nWizard cancelled.');
|
||
process.exit(0);
|
||
}
|
||
console.error('Wizard failed:', err);
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
program.parse();
|