feat(mosaic): merge @mosaic/cli into @mosaic/mosaic
@mosaic/mosaic is now the single package providing both: - 'mosaic' binary (CLI: yolo, coord, prdy, tui, gateway, etc.) - 'mosaic-wizard' binary (installation wizard) Changes: - Move packages/cli/src/* into packages/mosaic/src/ - Convert dynamic @mosaic/mosaic imports to static relative imports - Add CLI deps (ink, react, socket.io-client, @mosaic/config) to mosaic - Add jsx: react-jsx to mosaic's tsconfig - Exclude packages/cli from workspace (pnpm-workspace.yaml) - Update install.sh to install @mosaic/mosaic instead of @mosaic/cli - Bump version to 0.0.17 This eliminates the circular dependency between @mosaic/cli and @mosaic/mosaic that was blocking the build graph.
This commit is contained in:
425
packages/mosaic/src/cli.ts
Normal file
425
packages/mosaic/src/cli.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createRequire } from 'module';
|
||||
import { Command } from 'commander';
|
||||
import { registerQualityRails } from '@mosaic/quality-rails';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
// prdy is registered via launch.ts
|
||||
import { registerLaunchCommands } from './commands/launch.js';
|
||||
import { registerGatewayCommand } from './commands/gateway.js';
|
||||
import {
|
||||
backgroundUpdateCheck,
|
||||
checkForUpdate,
|
||||
formatUpdateNotice,
|
||||
} 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);
|
||||
|
||||
// ─── 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 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));
|
||||
|
||||
console.log(`Sign in to ${opts.gateway}`);
|
||||
const email = await ask('Email: ');
|
||||
const password = await ask('Password: ');
|
||||
rl.close();
|
||||
|
||||
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');
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── gateway ──────────────────────────────────────────────────────────
|
||||
|
||||
registerGatewayCommand(program);
|
||||
|
||||
// ─── agent ─────────────────────────────────────────────────────────────
|
||||
|
||||
registerAgentCommand(program);
|
||||
|
||||
// ─── mission ───────────────────────────────────────────────────────────
|
||||
|
||||
registerMissionCommand(program);
|
||||
|
||||
// ─── quality-rails ──────────────────────────────────────────────────────
|
||||
|
||||
registerQualityRails(program);
|
||||
|
||||
// ─── update ─────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command('update')
|
||||
.description('Check for and install Mosaic CLI updates')
|
||||
.option('--check', 'Check only, do not install')
|
||||
.action(async (opts: { check?: boolean }) => {
|
||||
// checkForUpdate and formatUpdateNotice imported statically above
|
||||
const { execSync } = await import('node:child_process');
|
||||
|
||||
console.log('Checking for updates…');
|
||||
const result = checkForUpdate({ skipCache: true });
|
||||
|
||||
if (!result.latest) {
|
||||
console.error('Could not reach the Mosaic registry.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(` Installed: ${result.current || '(none)'}`);
|
||||
console.log(` Latest: ${result.latest}`);
|
||||
|
||||
if (!result.updateAvailable) {
|
||||
console.log('\n✔ Up to date.');
|
||||
return;
|
||||
}
|
||||
|
||||
const notice = formatUpdateNotice(result);
|
||||
if (notice) console.log(notice);
|
||||
|
||||
if (opts.check) {
|
||||
process.exit(2); // Signal to callers that an update exists
|
||||
}
|
||||
|
||||
console.log('Installing update…');
|
||||
try {
|
||||
// Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry
|
||||
// globally or non-@mosaic deps will 404 against the Gitea registry.
|
||||
execSync('npm install -g @mosaic/cli@latest', {
|
||||
stdio: 'inherit',
|
||||
timeout: 60_000,
|
||||
});
|
||||
console.log('\n✔ Updated successfully.');
|
||||
} catch {
|
||||
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 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();
|
||||
Reference in New Issue
Block a user