diff --git a/AGENTS.md b/AGENTS.md index 1dc4e37..fd87e1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo | `packages/brain` | Data layer (PG-backed) | @mosaicstack/db | | `packages/queue` | Valkey task queue + MCP | ioredis | | `packages/coord` | Mission coordination | @mosaicstack/queue | -| `packages/cli` | Unified CLI + Pi TUI | Ink, Pi SDK | +| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander | | `plugins/discord` | Discord channel plugin | discord.js | | `plugins/telegram` | Telegram channel plugin | Telegraf | diff --git a/CLAUDE.md b/CLAUDE.md index d9fbbc3..cd9c2c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Self-hosted, multi-user AI agent platform. TypeScript monorepo. - **Web**: Next.js 16 + React 19 (`apps/web`) - **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`) - **Auth**: BetterAuth (`packages/auth`) -- **Agent**: Pi SDK (`packages/agent`, `packages/cli`) +- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`) - **Queue**: Valkey 8 (`packages/queue`) - **Build**: pnpm workspaces + Turborepo - **CI**: Woodpecker CI diff --git a/README.md b/README.md index 9b8dd6b..cf46c77 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@ bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/mai This installs both components: -| Component | What | Where | -| -------------------- | ----------------------------------------------------- | -------------------- | -| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` | -| **@mosaicstack/cli** | TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` | +| Component | What | Where | +| ----------------------- | ---------------------------------------------------------------- | -------------------- | +| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` | +| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` | After install, set up your agent identity: @@ -26,7 +26,7 @@ mosaic init # Interactive wizard ### Requirements - Node.js ≥ 20 -- npm (for global @mosaicstack/cli install) +- npm (for global @mosaicstack/mosaic install) - One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent) ## Usage diff --git a/docs/guides/user-guide.md b/docs/guides/user-guide.md index 2e09fd7..007cd52 100644 --- a/docs/guides/user-guide.md +++ b/docs/guides/user-guide.md @@ -160,12 +160,12 @@ The `mosaic` CLI provides a terminal interface to the same gateway API. ### Installation -The CLI ships as part of the `@mosaicstack/cli` package: +The CLI ships as part of the `@mosaicstack/mosaic` package: ```bash # From the monorepo root -pnpm --filter @mosaicstack/cli build -node packages/cli/dist/cli.js --help +pnpm --filter @mosaicstack/mosaic build +node packages/mosaic/dist/cli.js --help ``` Or if installed globally: diff --git a/packages/cli/package.json b/packages/cli/package.json deleted file mode 100644 index b15e99c..0000000 --- a/packages/cli/package.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "@mosaicstack/cli", - "version": "0.0.17", - "repository": { - "type": "git", - "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git", - "directory": "packages/cli" - }, - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "bin": { - "mosaic": "dist/cli.js" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc -p tsconfig.build.json", - "dev": "tsx src/cli.ts", - "lint": "eslint src", - "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" - }, - "dependencies": { - "@clack/prompts": "^0.9.0", - "@mosaicstack/config": "workspace:^", - "@mosaicstack/mosaic": "workspace:^", - "@mosaicstack/prdy": "workspace:^", - "@mosaicstack/quality-rails": "workspace:^", - "@mosaicstack/types": "workspace:^", - "commander": "^13.0.0", - "ink": "^5.0.0", - "ink-spinner": "^5.0.0", - "ink-text-input": "^6.0.0", - "react": "^18.3.0", - "socket.io-client": "^4.8.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "@types/react": "^18.3.0", - "tsx": "^4.0.0", - "typescript": "^5.8.0", - "vitest": "^2.0.0" - }, - "publishConfig": { - "registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/", - "access": "public" - }, - "files": [ - "dist" - ] -} diff --git a/packages/cli/src/auth.ts b/packages/cli/src/auth.ts deleted file mode 100644 index e9fe792..0000000 --- a/packages/cli/src/auth.ts +++ /dev/null @@ -1,115 +0,0 @@ -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/cli/src/cli.ts b/packages/cli/src/cli.ts deleted file mode 100644 index 089fa33..0000000 --- a/packages/cli/src/cli.ts +++ /dev/null @@ -1,421 +0,0 @@ -#!/usr/bin/env node - -import { createRequire } from 'module'; -import { Command } from 'commander'; -import { registerQualityRails } from '@mosaicstack/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'; - -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 { - const { backgroundUpdateCheck } = await import('@mosaicstack/mosaic'); - 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 }) => { - const { checkForAllUpdates, formatAllPackagesTable, getInstallAllCommand } = - await import('@mosaicstack/mosaic'); - 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); - } - }); - -// ─── 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