diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 899c1c6..40dd1c5 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -1,6 +1,6 @@ { "name": "@mosaic/mosaic", - "version": "0.0.16", + "version": "0.0.17", "repository": { "type": "git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", @@ -11,6 +11,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { + "mosaic": "dist/cli.js", "mosaic-wizard": "dist/index.js" }, "exports": { @@ -26,6 +27,7 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { + "@mosaic/config": "workspace:*", "@mosaic/forge": "workspace:*", "@mosaic/macp": "workspace:*", "@mosaic/prdy": "workspace:*", @@ -33,12 +35,19 @@ "@mosaic/types": "workspace:*", "@clack/prompts": "^0.9.1", "commander": "^13.0.0", + "ink": "^5.0.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "picocolors": "^1.1.1", + "react": "^18.3.0", + "socket.io-client": "^4.8.0", "yaml": "^2.6.1", "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^22.0.0", + "@types/react": "^18.3.0", + "tsx": "^4.0.0", "typescript": "^5.8.0", "vitest": "^2.0.0" }, diff --git a/packages/mosaic/src/auth.ts b/packages/mosaic/src/auth.ts new file mode 100644 index 0000000..e9fe792 --- /dev/null +++ b/packages/mosaic/src/auth.ts @@ -0,0 +1,115 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { homedir } from 'node:os'; + +const SESSION_DIR = resolve(homedir(), '.mosaic'); +const SESSION_FILE = resolve(SESSION_DIR, 'session.json'); + +interface StoredSession { + gatewayUrl: string; + cookie: string; + userId: string; + email: string; + expiresAt: string; +} + +export interface AuthResult { + cookie: string; + userId: string; + email: string; +} + +/** + * Sign in to the gateway and return the session cookie. + */ +export async function signIn( + gatewayUrl: string, + email: string, + password: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/auth/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: gatewayUrl }, + body: JSON.stringify({ email, password }), + redirect: 'manual', + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Sign-in failed (${res.status}): ${body}`); + } + + // Extract set-cookie header + const setCookieHeader = res.headers.getSetCookie?.() ?? []; + const sessionCookie = setCookieHeader + .map((c) => c.split(';')[0]!) + .filter((c) => c.startsWith('better-auth.session_token=')) + .join('; '); + + if (!sessionCookie) { + throw new Error('No session cookie returned from sign-in'); + } + + // Parse the response body for user info + const data = (await res.json()) as { user?: { id: string; email: string } }; + const userId = data.user?.id ?? 'unknown'; + const userEmail = data.user?.email ?? email; + + return { cookie: sessionCookie, userId, email: userEmail }; +} + +/** + * Save session to ~/.mosaic/session.json + */ +export function saveSession(gatewayUrl: string, auth: AuthResult): void { + if (!existsSync(SESSION_DIR)) { + mkdirSync(SESSION_DIR, { recursive: true }); + } + + const session: StoredSession = { + gatewayUrl, + cookie: auth.cookie, + userId: auth.userId, + email: auth.email, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days + }; + + writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8'); +} + +/** + * Load a saved session. Returns null if no session, expired, or wrong gateway. + */ +export function loadSession(gatewayUrl: string): AuthResult | null { + if (!existsSync(SESSION_FILE)) return null; + + try { + const raw = readFileSync(SESSION_FILE, 'utf-8'); + const session = JSON.parse(raw) as StoredSession; + + if (session.gatewayUrl !== gatewayUrl) return null; + if (new Date(session.expiresAt) < new Date()) return null; + + return { + cookie: session.cookie, + userId: session.userId, + email: session.email, + }; + } catch { + return null; + } +} + +/** + * Validate that a stored session is still active by hitting get-session. + */ +export async function validateSession(gatewayUrl: string, cookie: string): Promise { + try { + const res = await fetch(`${gatewayUrl}/api/auth/get-session`, { + headers: { Cookie: cookie, Origin: gatewayUrl }, + }); + return res.ok; + } catch { + return false; + } +} diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts new file mode 100644 index 0000000..9d773dd --- /dev/null +++ b/packages/mosaic/src/cli.ts @@ -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 ', 'Gateway URL', 'http://localhost:14242') + .option('-e, --email ', 'Email address') + .option('-p, --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 => 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 ', 'Gateway URL', 'http://localhost:14242') + .option('-c, --conversation ', 'Resume a conversation by ID') + .option('-m, --model ', 'Model ID to use (e.g. gpt-4o, llama3.2)') + .option('-p, --provider ', 'Provider to use (e.g. openai, ollama)') + .option('--agent ', 'Connect to a specific agent') + .option('--project ', '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 => + 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 ', '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 ') + .description('Resume an existing agent session in the TUI') + .option('-g, --gateway ', '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 ') + .description('Terminate an active agent session') + .option('-g, --gateway ', '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 ', 'Source directory for framework files') + .option('--mosaic-home ', 'Target config directory') + .option('--name ', 'Agent name') + .option('--role ', 'Agent role description') + .option('--style