Compare commits
1 Commits
fix/config
...
chore/bump
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4903b6a916 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/gateway",
|
||||
"version": "0.0.5",
|
||||
"version": "0.1.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/config",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/mosaic",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.16",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
|
||||
@@ -11,16 +11,13 @@
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"mosaic": "dist/cli.js",
|
||||
"mosaic-wizard": "dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./framework/*": "./framework/*"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
@@ -29,7 +26,6 @@
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaic/config": "workspace:*",
|
||||
"@mosaic/forge": "workspace:*",
|
||||
"@mosaic/macp": "workspace:*",
|
||||
"@mosaic/prdy": "workspace:*",
|
||||
@@ -37,19 +33,12 @@
|
||||
"@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"
|
||||
},
|
||||
|
||||
@@ -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<AuthResult> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/auth/get-session`, {
|
||||
headers: { Cookie: cookie, Origin: gatewayUrl },
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
#!/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();
|
||||
@@ -1,241 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import { withAuth } from './with-auth.js';
|
||||
import { selectItem } from './select-dialog.js';
|
||||
import {
|
||||
fetchAgentConfigs,
|
||||
createAgentConfig,
|
||||
updateAgentConfig,
|
||||
deleteAgentConfig,
|
||||
fetchProjects,
|
||||
fetchProviders,
|
||||
} from '../tui/gateway-api.js';
|
||||
import type { AgentConfigInfo } from '../tui/gateway-api.js';
|
||||
|
||||
function formatAgent(a: AgentConfigInfo): string {
|
||||
const sys = a.isSystem ? ' [system]' : '';
|
||||
return `${a.name}${sys} — ${a.provider}/${a.model} (${a.status})`;
|
||||
}
|
||||
|
||||
function showAgentDetail(a: AgentConfigInfo) {
|
||||
console.log(` ID: ${a.id}`);
|
||||
console.log(` Name: ${a.name}`);
|
||||
console.log(` Provider: ${a.provider}`);
|
||||
console.log(` Model: ${a.model}`);
|
||||
console.log(` Status: ${a.status}`);
|
||||
console.log(` System: ${a.isSystem ? 'yes' : 'no'}`);
|
||||
console.log(` Project: ${a.projectId ?? '—'}`);
|
||||
console.log(` System Prompt: ${a.systemPrompt ? `${a.systemPrompt.slice(0, 80)}...` : '—'}`);
|
||||
console.log(` Tools: ${a.allowedTools ? a.allowedTools.join(', ') : 'all'}`);
|
||||
console.log(` Skills: ${a.skills ? a.skills.join(', ') : '—'}`);
|
||||
console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`);
|
||||
}
|
||||
|
||||
export function registerAgentCommand(program: Command) {
|
||||
const cmd = program
|
||||
.command('agent')
|
||||
.description('Manage agent configurations')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--list', 'List all agents')
|
||||
.option('--new', 'Create a new agent')
|
||||
.option('--show <idOrName>', 'Show agent details')
|
||||
.option('--update <idOrName>', 'Update an agent')
|
||||
.option('--delete <idOrName>', 'Delete an agent')
|
||||
.action(
|
||||
async (opts: {
|
||||
gateway: string;
|
||||
list?: boolean;
|
||||
new?: boolean;
|
||||
show?: string;
|
||||
update?: string;
|
||||
delete?: string;
|
||||
}) => {
|
||||
const auth = await withAuth(opts.gateway);
|
||||
|
||||
if (opts.list) {
|
||||
return listAgents(auth.gateway, auth.cookie);
|
||||
}
|
||||
if (opts.new) {
|
||||
return createAgentWizard(auth.gateway, auth.cookie);
|
||||
}
|
||||
if (opts.show) {
|
||||
return showAgent(auth.gateway, auth.cookie, opts.show);
|
||||
}
|
||||
if (opts.update) {
|
||||
return updateAgentWizard(auth.gateway, auth.cookie, opts.update);
|
||||
}
|
||||
if (opts.delete) {
|
||||
return deleteAgent(auth.gateway, auth.cookie, opts.delete);
|
||||
}
|
||||
|
||||
// Default: interactive select
|
||||
return interactiveSelect(auth.gateway, auth.cookie);
|
||||
},
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
async function resolveAgent(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
idOrName: string,
|
||||
): Promise<AgentConfigInfo | undefined> {
|
||||
const agents = await fetchAgentConfigs(gateway, cookie);
|
||||
return agents.find((a) => a.id === idOrName || a.name === idOrName);
|
||||
}
|
||||
|
||||
async function listAgents(gateway: string, cookie: string) {
|
||||
const agents = await fetchAgentConfigs(gateway, cookie);
|
||||
if (agents.length === 0) {
|
||||
console.log('No agents found.');
|
||||
return;
|
||||
}
|
||||
console.log(`Agents (${agents.length}):\n`);
|
||||
for (const a of agents) {
|
||||
const sys = a.isSystem ? ' [system]' : '';
|
||||
const project = a.projectId ? ` project=${a.projectId.slice(0, 8)}` : '';
|
||||
console.log(` ${a.name}${sys} ${a.provider}/${a.model} ${a.status}${project}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showAgent(gateway: string, cookie: string, idOrName: string) {
|
||||
const agent = await resolveAgent(gateway, cookie, idOrName);
|
||||
if (!agent) {
|
||||
console.error(`Agent "${idOrName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
showAgentDetail(agent);
|
||||
}
|
||||
|
||||
async function interactiveSelect(gateway: string, cookie: string) {
|
||||
const agents = await fetchAgentConfigs(gateway, cookie);
|
||||
const selected = await selectItem(agents, {
|
||||
message: 'Select an agent:',
|
||||
render: formatAgent,
|
||||
emptyMessage: 'No agents found. Create one with `mosaic agent --new`.',
|
||||
});
|
||||
if (selected) {
|
||||
showAgentDetail(selected);
|
||||
}
|
||||
}
|
||||
|
||||
async function createAgentWizard(gateway: string, cookie: string) {
|
||||
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));
|
||||
|
||||
try {
|
||||
const name = await ask('Agent name: ');
|
||||
if (!name.trim()) {
|
||||
console.error('Name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Project selection
|
||||
const projects = await fetchProjects(gateway, cookie);
|
||||
let projectId: string | undefined;
|
||||
if (projects.length > 0) {
|
||||
const selected = await selectItem(projects, {
|
||||
message: 'Assign to project (optional):',
|
||||
render: (p) => `${p.name} (${p.status})`,
|
||||
});
|
||||
if (selected) projectId = selected.id;
|
||||
}
|
||||
|
||||
// Provider / model selection
|
||||
const providers = await fetchProviders(gateway, cookie);
|
||||
let provider = 'default';
|
||||
let model = 'default';
|
||||
|
||||
if (providers.length > 0) {
|
||||
const allModels = providers.flatMap((p) =>
|
||||
p.models.map((m) => ({ provider: p.name, model: m.id, label: `${p.name}/${m.id}` })),
|
||||
);
|
||||
if (allModels.length > 0) {
|
||||
const selected = await selectItem(allModels, {
|
||||
message: 'Select model:',
|
||||
render: (m) => m.label,
|
||||
});
|
||||
if (selected) {
|
||||
provider = selected.provider;
|
||||
model = selected.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = await ask('System prompt (optional, press Enter to skip): ');
|
||||
|
||||
const agent = await createAgentConfig(gateway, cookie, {
|
||||
name: name.trim(),
|
||||
provider,
|
||||
model,
|
||||
projectId,
|
||||
systemPrompt: systemPrompt.trim() || undefined,
|
||||
});
|
||||
|
||||
console.log(`\nAgent "${agent.name}" created (${agent.id}).`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAgentWizard(gateway: string, cookie: string, idOrName: string) {
|
||||
const agent = await resolveAgent(gateway, cookie, idOrName);
|
||||
if (!agent) {
|
||||
console.error(`Agent "${idOrName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
try {
|
||||
console.log(`Updating agent: ${agent.name}\n`);
|
||||
|
||||
const name = await ask(`Name [${agent.name}]: `);
|
||||
const systemPrompt = await ask(`System prompt [${agent.systemPrompt ? 'set' : 'none'}]: `);
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (name.trim()) updates['name'] = name.trim();
|
||||
if (systemPrompt.trim()) updates['systemPrompt'] = systemPrompt.trim();
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.log('No changes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateAgentConfig(gateway, cookie, agent.id, updates);
|
||||
console.log(`\nAgent "${updated.name}" updated.`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAgent(gateway: string, cookie: string, idOrName: string) {
|
||||
const agent = await resolveAgent(gateway, cookie, idOrName);
|
||||
if (!agent) {
|
||||
console.error(`Agent "${idOrName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (agent.isSystem) {
|
||||
console.error('Cannot delete system agents.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) =>
|
||||
rl.question(`Delete agent "${agent.name}"? (y/N): `, resolve),
|
||||
);
|
||||
rl.close();
|
||||
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteAgentConfig(gateway, cookie, agent.id);
|
||||
console.log(`Agent "${agent.name}" deleted.`);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
getDaemonPid,
|
||||
readMeta,
|
||||
startDaemon,
|
||||
stopDaemon,
|
||||
waitForHealth,
|
||||
} from './gateway/daemon.js';
|
||||
|
||||
interface GatewayParentOpts {
|
||||
host: string;
|
||||
port: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
function resolveOpts(raw: GatewayParentOpts): { host: string; port: number; token?: string } {
|
||||
const meta = readMeta();
|
||||
return {
|
||||
host: raw.host ?? meta?.host ?? 'localhost',
|
||||
port: parseInt(raw.port, 10) || meta?.port || 14242,
|
||||
token: raw.token ?? meta?.adminToken,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerGatewayCommand(program: Command): void {
|
||||
const gw = program
|
||||
.command('gateway')
|
||||
.description('Manage the Mosaic gateway daemon')
|
||||
.helpOption('--help', 'Display help')
|
||||
.option('-h, --host <host>', 'Gateway host', 'localhost')
|
||||
.option('-p, --port <port>', 'Gateway port', '14242')
|
||||
.option('-t, --token <token>', 'Admin API token')
|
||||
.action(() => {
|
||||
gw.outputHelp();
|
||||
});
|
||||
|
||||
// ─── install ────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('install')
|
||||
.description('Install and configure the gateway daemon')
|
||||
.option('--skip-install', 'Skip npm package installation (use local build)')
|
||||
.action(async (cmdOpts: { skipInstall?: boolean }) => {
|
||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||
const { runInstall } = await import('./gateway/install.js');
|
||||
await runInstall({ ...opts, skipInstall: cmdOpts.skipInstall });
|
||||
});
|
||||
|
||||
// ─── start ──────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('start')
|
||||
.description('Start the gateway daemon')
|
||||
.action(async () => {
|
||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||
try {
|
||||
const pid = startDaemon();
|
||||
console.log(`Gateway started (PID ${pid.toString()})`);
|
||||
console.log('Waiting for health...');
|
||||
const healthy = await waitForHealth(opts.host, opts.port);
|
||||
if (healthy) {
|
||||
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
|
||||
} else {
|
||||
console.warn('Gateway started but health check timed out. Check logs.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── stop ───────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('stop')
|
||||
.description('Stop the gateway daemon')
|
||||
.action(async () => {
|
||||
try {
|
||||
await stopDaemon();
|
||||
console.log('Gateway stopped.');
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── restart ────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('restart')
|
||||
.description('Restart the gateway daemon')
|
||||
.action(async () => {
|
||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||
const pid = getDaemonPid();
|
||||
if (pid !== null) {
|
||||
console.log('Stopping gateway...');
|
||||
await stopDaemon();
|
||||
}
|
||||
console.log('Starting gateway...');
|
||||
try {
|
||||
const newPid = startDaemon();
|
||||
console.log(`Gateway started (PID ${newPid.toString()})`);
|
||||
const healthy = await waitForHealth(opts.host, opts.port);
|
||||
if (healthy) {
|
||||
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
|
||||
} else {
|
||||
console.warn('Gateway started but health check timed out. Check logs.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── status ─────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('status')
|
||||
.description('Show gateway daemon status and health')
|
||||
.action(async () => {
|
||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||
const { runStatus } = await import('./gateway/status.js');
|
||||
await runStatus(opts);
|
||||
});
|
||||
|
||||
// ─── config ─────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('config')
|
||||
.description('View or modify gateway configuration')
|
||||
.option('--set <KEY=VALUE>', 'Set a configuration value')
|
||||
.option('--unset <KEY>', 'Remove a configuration key')
|
||||
.option('--edit', 'Open config in $EDITOR')
|
||||
.action(async (cmdOpts: { set?: string; unset?: string; edit?: boolean }) => {
|
||||
const { runConfig } = await import('./gateway/config.js');
|
||||
await runConfig(cmdOpts);
|
||||
});
|
||||
|
||||
// ─── logs ───────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('logs')
|
||||
.description('View gateway daemon logs')
|
||||
.option('-f, --follow', 'Follow log output')
|
||||
.option('-n, --lines <count>', 'Number of lines to show', '50')
|
||||
.action(async (cmdOpts: { follow?: boolean; lines?: string }) => {
|
||||
const { runLogs } = await import('./gateway/logs.js');
|
||||
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
|
||||
});
|
||||
|
||||
// ─── uninstall ──────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('uninstall')
|
||||
.description('Uninstall the gateway daemon and optionally remove data')
|
||||
.action(async () => {
|
||||
const { runUninstall } = await import('./gateway/uninstall.js');
|
||||
await runUninstall();
|
||||
});
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { ENV_FILE, getDaemonPid, readMeta, META_FILE, ensureDirs } from './daemon.js';
|
||||
|
||||
// Keys that should be masked in output
|
||||
const SECRET_KEYS = new Set([
|
||||
'BETTER_AUTH_SECRET',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'ZAI_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
'DISCORD_BOT_TOKEN',
|
||||
'TELEGRAM_BOT_TOKEN',
|
||||
]);
|
||||
|
||||
function maskValue(key: string, value: string): string {
|
||||
if (SECRET_KEYS.has(key) && value.length > 8) {
|
||||
return value.slice(0, 4) + '…' + value.slice(-4);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEnvFile(): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!existsSync(ENV_FILE)) return map;
|
||||
|
||||
const lines = readFileSync(ENV_FILE, 'utf-8').split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
map.set(trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function writeEnvFile(entries: Map<string, string>): void {
|
||||
ensureDirs();
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of entries) {
|
||||
lines.push(`${key}=${value}`);
|
||||
}
|
||||
writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
|
||||
}
|
||||
|
||||
interface ConfigOpts {
|
||||
set?: string;
|
||||
unset?: string;
|
||||
edit?: boolean;
|
||||
}
|
||||
|
||||
export async function runConfig(opts: ConfigOpts): Promise<void> {
|
||||
// Set a value
|
||||
if (opts.set) {
|
||||
const eqIdx = opts.set.indexOf('=');
|
||||
if (eqIdx === -1) {
|
||||
console.error('Usage: mosaic gateway config --set KEY=VALUE');
|
||||
process.exit(1);
|
||||
}
|
||||
const key = opts.set.slice(0, eqIdx);
|
||||
const value = opts.set.slice(eqIdx + 1);
|
||||
const entries = parseEnvFile();
|
||||
entries.set(key, value);
|
||||
writeEnvFile(entries);
|
||||
console.log(`Set ${key}=${maskValue(key, value)}`);
|
||||
promptRestart();
|
||||
return;
|
||||
}
|
||||
|
||||
// Unset a value
|
||||
if (opts.unset) {
|
||||
const entries = parseEnvFile();
|
||||
if (!entries.has(opts.unset)) {
|
||||
console.error(`Key not found: ${opts.unset}`);
|
||||
process.exit(1);
|
||||
}
|
||||
entries.delete(opts.unset);
|
||||
writeEnvFile(entries);
|
||||
console.log(`Removed ${opts.unset}`);
|
||||
promptRestart();
|
||||
return;
|
||||
}
|
||||
|
||||
// Open in editor
|
||||
if (opts.edit) {
|
||||
if (!existsSync(ENV_FILE)) {
|
||||
console.error(`No config file found at ${ENV_FILE}`);
|
||||
console.error('Run `mosaic gateway install` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
const editor = process.env['EDITOR'] ?? process.env['VISUAL'] ?? 'vi';
|
||||
try {
|
||||
execSync(`${editor} "${ENV_FILE}"`, { stdio: 'inherit' });
|
||||
promptRestart();
|
||||
} catch {
|
||||
console.error('Editor exited with error.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: show current config
|
||||
showConfig();
|
||||
}
|
||||
|
||||
function showConfig(): void {
|
||||
if (!existsSync(ENV_FILE)) {
|
||||
console.log('No gateway configuration found.');
|
||||
console.log('Run `mosaic gateway install` to set up.');
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = parseEnvFile();
|
||||
const meta = readMeta();
|
||||
|
||||
console.log('Mosaic Gateway Configuration');
|
||||
console.log('────────────────────────────');
|
||||
console.log(` Config file: ${ENV_FILE}`);
|
||||
console.log(` Meta file: ${META_FILE}`);
|
||||
console.log();
|
||||
|
||||
if (entries.size === 0) {
|
||||
console.log(' (empty)');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxKeyLen = Math.max(...[...entries.keys()].map((k) => k.length));
|
||||
for (const [key, value] of entries) {
|
||||
const padding = ' '.repeat(maxKeyLen - key.length);
|
||||
console.log(` ${key}${padding} ${maskValue(key, value)}`);
|
||||
}
|
||||
|
||||
if (meta?.adminToken) {
|
||||
console.log();
|
||||
console.log(` Admin token: ${maskValue('token', meta.adminToken)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function promptRestart(): void {
|
||||
if (getDaemonPid() !== null) {
|
||||
console.log('\nGateway is running — restart to apply changes: mosaic gateway restart');
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
openSync,
|
||||
constants,
|
||||
} from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
// ─── Paths ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GATEWAY_HOME = resolve(
|
||||
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
||||
);
|
||||
export const PID_FILE = join(GATEWAY_HOME, 'daemon.pid');
|
||||
export const LOG_DIR = join(GATEWAY_HOME, 'logs');
|
||||
export const LOG_FILE = join(LOG_DIR, 'gateway.log');
|
||||
export const ENV_FILE = join(GATEWAY_HOME, '.env');
|
||||
export const META_FILE = join(GATEWAY_HOME, 'meta.json');
|
||||
|
||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GatewayMeta {
|
||||
version: string;
|
||||
installedAt: string;
|
||||
entryPoint: string;
|
||||
adminToken?: string;
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function readMeta(): GatewayMeta | null {
|
||||
if (!existsSync(META_FILE)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(META_FILE, 'utf-8')) as GatewayMeta;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeMeta(meta: GatewayMeta): void {
|
||||
ensureDirs();
|
||||
writeFileSync(META_FILE, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
// ─── Directories ────────────────────────────────────────────────────────────
|
||||
|
||||
export function ensureDirs(): void {
|
||||
mkdirSync(GATEWAY_HOME, { recursive: true, mode: 0o700 });
|
||||
mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// ─── PID management ─────────────────────────────────────────────────────────
|
||||
|
||||
export function readPid(): number | null {
|
||||
if (!existsSync(PID_FILE)) return null;
|
||||
try {
|
||||
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
|
||||
return Number.isNaN(pid) ? null : pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDaemonPid(): number | null {
|
||||
const pid = readPid();
|
||||
if (pid === null) return null;
|
||||
return isRunning(pid) ? pid : null;
|
||||
}
|
||||
|
||||
// ─── Entry point resolution ─────────────────────────────────────────────────
|
||||
|
||||
export function resolveGatewayEntry(): string {
|
||||
// Check meta.json for custom entry point
|
||||
const meta = readMeta();
|
||||
if (meta?.entryPoint && existsSync(meta.entryPoint)) {
|
||||
return meta.entryPoint;
|
||||
}
|
||||
|
||||
// Try to resolve from globally installed @mosaic/gateway
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
const pkgPath = req.resolve('@mosaic/gateway/package.json');
|
||||
const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js');
|
||||
if (existsSync(mainEntry)) return mainEntry;
|
||||
} catch {
|
||||
// Not installed globally
|
||||
}
|
||||
|
||||
throw new Error('Cannot find gateway entry point. Run `mosaic gateway install` first.');
|
||||
}
|
||||
|
||||
// ─── Start / Stop / Health ──────────────────────────────────────────────────
|
||||
|
||||
export function startDaemon(): number {
|
||||
const running = getDaemonPid();
|
||||
if (running !== null) {
|
||||
throw new Error(`Gateway is already running (PID ${running.toString()})`);
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
const entryPoint = resolveGatewayEntry();
|
||||
|
||||
// Load env vars from gateway .env
|
||||
const env: Record<string, string> = { ...process.env } as Record<string, string>;
|
||||
if (existsSync(ENV_FILE)) {
|
||||
for (const line of readFileSync(ENV_FILE, 'utf-8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx > 0) env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const logFd = openSync(LOG_FILE, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND);
|
||||
|
||||
const child = spawn('node', [entryPoint], {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
env,
|
||||
cwd: GATEWAY_HOME,
|
||||
});
|
||||
|
||||
if (!child.pid) {
|
||||
throw new Error('Failed to spawn gateway process');
|
||||
}
|
||||
|
||||
writeFileSync(PID_FILE, child.pid.toString(), { mode: 0o600 });
|
||||
child.unref();
|
||||
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
export async function stopDaemon(timeoutMs = 10_000): Promise<void> {
|
||||
const pid = getDaemonPid();
|
||||
if (pid === null) {
|
||||
throw new Error('Gateway is not running');
|
||||
}
|
||||
|
||||
process.kill(pid, 'SIGTERM');
|
||||
|
||||
// Poll for exit
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (!isRunning(pid)) {
|
||||
cleanPidFile();
|
||||
return;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
// Force kill
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
cleanPidFile();
|
||||
}
|
||||
|
||||
function cleanPidFile(): void {
|
||||
try {
|
||||
unlinkSync(PID_FILE);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForHealth(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
let delay = 500;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(`http://${host}:${port.toString()}/health`);
|
||||
if (res.ok) return true;
|
||||
} catch {
|
||||
// Not ready yet
|
||||
}
|
||||
await sleep(delay);
|
||||
delay = Math.min(delay * 1.5, 3000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ─── npm install helper ─────────────────────────────────────────────────────
|
||||
|
||||
const GITEA_REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
|
||||
|
||||
export function installGatewayPackage(): void {
|
||||
console.log('Installing @mosaic/gateway from Gitea registry...');
|
||||
execSync(`npm install -g @mosaic/gateway@latest --@mosaic:registry=${GITEA_REGISTRY}`, {
|
||||
stdio: 'inherit',
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function uninstallGatewayPackage(): void {
|
||||
try {
|
||||
execSync('npm uninstall -g @mosaic/gateway', {
|
||||
stdio: 'inherit',
|
||||
timeout: 60_000,
|
||||
});
|
||||
} catch {
|
||||
console.warn('Warning: npm uninstall may not have completed cleanly.');
|
||||
}
|
||||
}
|
||||
|
||||
export function getInstalledGatewayVersion(): string | null {
|
||||
try {
|
||||
const output = execSync('npm ls -g @mosaic/gateway --json --depth=0', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 15_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const data = JSON.parse(output) as {
|
||||
dependencies?: { '@mosaic/gateway'?: { version?: string } };
|
||||
};
|
||||
return data.dependencies?.['@mosaic/gateway']?.version ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createInterface } from 'node:readline';
|
||||
import type { GatewayMeta } from './daemon.js';
|
||||
import {
|
||||
ENV_FILE,
|
||||
GATEWAY_HOME,
|
||||
ensureDirs,
|
||||
installGatewayPackage,
|
||||
readMeta,
|
||||
resolveGatewayEntry,
|
||||
startDaemon,
|
||||
waitForHealth,
|
||||
writeMeta,
|
||||
getInstalledGatewayVersion,
|
||||
} from './daemon.js';
|
||||
|
||||
interface InstallOpts {
|
||||
host: string;
|
||||
port: number;
|
||||
skipInstall?: boolean;
|
||||
}
|
||||
|
||||
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
export async function runInstall(opts: InstallOpts): Promise<void> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
await doInstall(rl, opts);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
|
||||
// Check existing installation
|
||||
const existing = readMeta();
|
||||
if (existing) {
|
||||
const answer = await prompt(
|
||||
rl,
|
||||
`Gateway already installed (v${existing.version}). Reinstall? [y/N] `,
|
||||
);
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Aborted.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Install npm package
|
||||
if (!opts.skipInstall) {
|
||||
installGatewayPackage();
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
|
||||
// Step 2: Collect configuration
|
||||
console.log('\n─── Gateway Configuration ───\n');
|
||||
|
||||
// Tier selection
|
||||
console.log('Storage tier:');
|
||||
console.log(' 1. Local (embedded database, no dependencies)');
|
||||
console.log(' 2. Team (PostgreSQL + Valkey required)');
|
||||
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
|
||||
const tier = tierAnswer === '2' ? 'team' : 'local';
|
||||
|
||||
const port =
|
||||
opts.port !== 14242
|
||||
? opts.port
|
||||
: parseInt(
|
||||
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
|
||||
10,
|
||||
);
|
||||
|
||||
let databaseUrl: string | undefined;
|
||||
let valkeyUrl: string | undefined;
|
||||
|
||||
if (tier === 'team') {
|
||||
databaseUrl =
|
||||
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
|
||||
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
valkeyUrl =
|
||||
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380';
|
||||
}
|
||||
|
||||
const anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
|
||||
|
||||
const corsOrigin =
|
||||
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
|
||||
|
||||
// Generate auth secret
|
||||
const authSecret = randomBytes(32).toString('hex');
|
||||
|
||||
// Step 3: Write .env
|
||||
const envLines = [
|
||||
`GATEWAY_PORT=${port.toString()}`,
|
||||
`BETTER_AUTH_SECRET=${authSecret}`,
|
||||
`BETTER_AUTH_URL=http://${opts.host}:${port.toString()}`,
|
||||
`GATEWAY_CORS_ORIGIN=${corsOrigin}`,
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`,
|
||||
`OTEL_SERVICE_NAME=mosaic-gateway`,
|
||||
];
|
||||
|
||||
if (tier === 'team' && databaseUrl && valkeyUrl) {
|
||||
envLines.push(`DATABASE_URL=${databaseUrl}`);
|
||||
envLines.push(`VALKEY_URL=${valkeyUrl}`);
|
||||
}
|
||||
|
||||
if (anthropicKey) {
|
||||
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
||||
}
|
||||
|
||||
writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 });
|
||||
console.log(`\nConfig written to ${ENV_FILE}`);
|
||||
|
||||
// Step 3b: Write mosaic.config.json
|
||||
const mosaicConfig =
|
||||
tier === 'local'
|
||||
? {
|
||||
tier: 'local',
|
||||
storage: { type: 'pglite', dataDir: join(GATEWAY_HOME, 'storage-pglite') },
|
||||
queue: { type: 'local', dataDir: join(GATEWAY_HOME, 'queue') },
|
||||
memory: { type: 'keyword' },
|
||||
}
|
||||
: {
|
||||
tier: 'team',
|
||||
storage: { type: 'postgres', url: databaseUrl },
|
||||
queue: { type: 'bullmq', url: valkeyUrl },
|
||||
memory: { type: 'pgvector' },
|
||||
};
|
||||
|
||||
const configFile = join(GATEWAY_HOME, 'mosaic.config.json');
|
||||
writeFileSync(configFile, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 });
|
||||
console.log(`Config written to ${configFile}`);
|
||||
|
||||
// Step 4: Write meta.json
|
||||
let entryPoint: string;
|
||||
try {
|
||||
entryPoint = resolveGatewayEntry();
|
||||
} catch {
|
||||
console.error('Error: Gateway package not found after install.');
|
||||
console.error('Check that @mosaic/gateway installed correctly.');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = getInstalledGatewayVersion() ?? 'unknown';
|
||||
|
||||
const meta = {
|
||||
version,
|
||||
installedAt: new Date().toISOString(),
|
||||
entryPoint,
|
||||
host: opts.host,
|
||||
port,
|
||||
};
|
||||
writeMeta(meta);
|
||||
|
||||
// Step 5: Start the daemon
|
||||
console.log('\nStarting gateway daemon...');
|
||||
try {
|
||||
const pid = startDaemon();
|
||||
console.log(`Gateway started (PID ${pid.toString()})`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 6: Wait for health
|
||||
console.log('Waiting for gateway to become healthy...');
|
||||
const healthy = await waitForHealth(opts.host, port, 30_000);
|
||||
if (!healthy) {
|
||||
console.error('Gateway did not become healthy within 30 seconds.');
|
||||
console.error(`Check logs: mosaic gateway logs`);
|
||||
return;
|
||||
}
|
||||
console.log('Gateway is healthy.\n');
|
||||
|
||||
// Step 7: Bootstrap — first user setup
|
||||
await bootstrapFirstUser(rl, opts.host, port, meta);
|
||||
|
||||
console.log('\n─── Installation Complete ───');
|
||||
console.log(` Endpoint: http://${opts.host}:${port.toString()}`);
|
||||
console.log(` Config: ${GATEWAY_HOME}`);
|
||||
console.log(` Logs: mosaic gateway logs`);
|
||||
console.log(` Status: mosaic gateway status`);
|
||||
}
|
||||
|
||||
async function bootstrapFirstUser(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
host: string,
|
||||
port: number,
|
||||
meta: Omit<GatewayMeta, 'adminToken'> & { adminToken?: string },
|
||||
): Promise<void> {
|
||||
const baseUrl = `http://${host}:${port.toString()}`;
|
||||
|
||||
try {
|
||||
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
|
||||
if (!statusRes.ok) return;
|
||||
|
||||
const status = (await statusRes.json()) as { needsSetup: boolean };
|
||||
if (!status.needsSetup) {
|
||||
console.log('Admin user already exists — skipping setup.');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.warn('Could not check bootstrap status — skipping first user setup.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('─── Admin User Setup ───\n');
|
||||
|
||||
const name = (await prompt(rl, 'Admin name: ')).trim();
|
||||
if (!name) {
|
||||
console.error('Name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = (await prompt(rl, 'Admin email: ')).trim();
|
||||
if (!email) {
|
||||
console.error('Email is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const password = (await prompt(rl, 'Admin password (min 8 chars): ')).trim();
|
||||
if (password.length < 8) {
|
||||
console.error('Password must be at least 8 characters.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/bootstrap/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
console.error(`Bootstrap failed (${res.status.toString()}): ${body}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = (await res.json()) as {
|
||||
user: { id: string; email: string };
|
||||
token: { plaintext: string };
|
||||
};
|
||||
|
||||
// Save admin token to meta
|
||||
meta.adminToken = result.token.plaintext;
|
||||
writeMeta(meta as GatewayMeta);
|
||||
|
||||
console.log(`\nAdmin user created: ${result.user.email}`);
|
||||
console.log('Admin API token saved to gateway config.');
|
||||
} catch (err) {
|
||||
console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { LOG_FILE } from './daemon.js';
|
||||
|
||||
interface LogsOpts {
|
||||
follow?: boolean;
|
||||
lines?: number;
|
||||
}
|
||||
|
||||
export function runLogs(opts: LogsOpts): void {
|
||||
if (!existsSync(LOG_FILE)) {
|
||||
console.log('No log file found. Is the gateway installed?');
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.follow) {
|
||||
const lines = opts.lines ?? 50;
|
||||
const tail = spawn('tail', ['-n', lines.toString(), '-f', LOG_FILE], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
tail.on('error', () => {
|
||||
// Fallback for systems without tail
|
||||
console.log(readLastLines(opts.lines ?? 50));
|
||||
console.log('\n(--follow requires `tail` command)');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Just print last N lines
|
||||
console.log(readLastLines(opts.lines ?? 50));
|
||||
}
|
||||
|
||||
function readLastLines(n: number): string {
|
||||
const content = readFileSync(LOG_FILE, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
return lines.slice(-n).join('\n');
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { getDaemonPid, readMeta, LOG_FILE, GATEWAY_HOME } from './daemon.js';
|
||||
|
||||
interface GatewayOpts {
|
||||
host: string;
|
||||
port: number;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface ServiceStatus {
|
||||
name: string;
|
||||
status: string;
|
||||
latency?: string;
|
||||
}
|
||||
|
||||
interface AdminHealth {
|
||||
status: string;
|
||||
services: {
|
||||
database: { status: string; latencyMs: number };
|
||||
cache: { status: string; latencyMs: number };
|
||||
};
|
||||
agentPool?: { active: number };
|
||||
providers?: Array<{ name: string; available: boolean; models: number }>;
|
||||
}
|
||||
|
||||
export async function runStatus(opts: GatewayOpts): Promise<void> {
|
||||
const meta = readMeta();
|
||||
const pid = getDaemonPid();
|
||||
|
||||
console.log('Mosaic Gateway Status');
|
||||
console.log('─────────────────────');
|
||||
|
||||
// Daemon status
|
||||
if (pid !== null) {
|
||||
console.log(` Status: running (PID ${pid.toString()})`);
|
||||
} else {
|
||||
console.log(' Status: stopped');
|
||||
}
|
||||
|
||||
// Version
|
||||
console.log(` Version: ${meta?.version ?? 'unknown'}`);
|
||||
|
||||
// Endpoint
|
||||
const host = opts.host;
|
||||
const port = opts.port;
|
||||
console.log(` Endpoint: http://${host}:${port.toString()}`);
|
||||
console.log(` Config: ${GATEWAY_HOME}`);
|
||||
console.log(` Logs: ${LOG_FILE}`);
|
||||
|
||||
if (pid === null) return;
|
||||
|
||||
// Health check
|
||||
try {
|
||||
const healthRes = await fetch(`http://${host}:${port.toString()}/health`);
|
||||
if (!healthRes.ok) {
|
||||
console.log('\n Health: unreachable');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.log('\n Health: unreachable');
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin health (requires token)
|
||||
const token = opts.token ?? meta?.adminToken;
|
||||
if (!token) {
|
||||
console.log(
|
||||
'\n (No admin token — run `mosaic gateway config` to set one for detailed status)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://${host}:${port.toString()}/api/admin/health`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.log('\n Admin health: unauthorized or unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
const health = (await res.json()) as AdminHealth;
|
||||
|
||||
console.log('\n Services:');
|
||||
const services: ServiceStatus[] = [
|
||||
{
|
||||
name: 'Database',
|
||||
status: health.services.database.status,
|
||||
latency: `${health.services.database.latencyMs.toString()}ms`,
|
||||
},
|
||||
{
|
||||
name: 'Cache',
|
||||
status: health.services.cache.status,
|
||||
latency: `${health.services.cache.latencyMs.toString()}ms`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const svc of services) {
|
||||
const latStr = svc.latency ? ` (${svc.latency})` : '';
|
||||
console.log(` ${svc.name}:${' '.repeat(10 - svc.name.length)}${svc.status}${latStr}`);
|
||||
}
|
||||
|
||||
if (health.providers && health.providers.length > 0) {
|
||||
const available = health.providers.filter((p) => p.available);
|
||||
const names = available.map((p) => p.name).join(', ');
|
||||
console.log(`\n Providers: ${available.length.toString()} active (${names})`);
|
||||
}
|
||||
|
||||
if (health.agentPool) {
|
||||
console.log(` Sessions: ${health.agentPool.active.toString()} active`);
|
||||
}
|
||||
} catch {
|
||||
console.log('\n Admin health: connection error');
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
import {
|
||||
GATEWAY_HOME,
|
||||
getDaemonPid,
|
||||
readMeta,
|
||||
stopDaemon,
|
||||
uninstallGatewayPackage,
|
||||
} from './daemon.js';
|
||||
|
||||
export async function runUninstall(): Promise<void> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
await doUninstall(rl);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||
const meta = readMeta();
|
||||
if (!meta) {
|
||||
console.log('Gateway is not installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Aborted.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop if running
|
||||
if (getDaemonPid() !== null) {
|
||||
console.log('Stopping gateway daemon...');
|
||||
try {
|
||||
await stopDaemon();
|
||||
console.log('Stopped.');
|
||||
} catch (err) {
|
||||
console.warn(`Warning: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove config/data
|
||||
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
||||
if (removeData.toLowerCase() === 'y') {
|
||||
if (existsSync(GATEWAY_HOME)) {
|
||||
rmSync(GATEWAY_HOME, { recursive: true, force: true });
|
||||
console.log('Gateway data removed.');
|
||||
}
|
||||
}
|
||||
|
||||
// Uninstall npm package
|
||||
console.log('Uninstalling npm package...');
|
||||
uninstallGatewayPackage();
|
||||
|
||||
console.log('\nGateway uninstalled.');
|
||||
}
|
||||
@@ -1,768 +0,0 @@
|
||||
/**
|
||||
* Native runtime launcher — replaces the bash mosaic-launch script.
|
||||
*
|
||||
* Builds a composed runtime prompt from AGENTS.md + RUNTIME.md + USER.md +
|
||||
* TOOLS.md + mission context + PRD status, then exec's into the target CLI.
|
||||
*/
|
||||
|
||||
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
|
||||
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||
|
||||
const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
||||
claude: 'Claude Code',
|
||||
codex: 'Codex',
|
||||
opencode: 'OpenCode',
|
||||
pi: 'Pi',
|
||||
};
|
||||
|
||||
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
||||
|
||||
function checkMosaicHome(): void {
|
||||
if (!existsSync(MOSAIC_HOME)) {
|
||||
console.error(`[mosaic] ERROR: ${MOSAIC_HOME} not found.`);
|
||||
console.error(
|
||||
'[mosaic] Install: bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function checkFile(path: string, label: string): void {
|
||||
if (!existsSync(path)) {
|
||||
console.error(`[mosaic] ERROR: ${label} not found: ${path}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function checkRuntime(cmd: string): void {
|
||||
try {
|
||||
execSync(`which ${cmd}`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
console.error(`[mosaic] ERROR: '${cmd}' not found in PATH.`);
|
||||
console.error(`[mosaic] Install ${cmd} before launching.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function checkSoul(): void {
|
||||
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
||||
if (!existsSync(soulPath)) {
|
||||
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
|
||||
|
||||
// Prefer the TypeScript wizard (idempotent, detects existing files)
|
||||
try {
|
||||
const result = spawnSync(process.execPath, [process.argv[1]!, 'wizard'], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.status === 0 && existsSync(soulPath)) return;
|
||||
} catch {
|
||||
// Fall through to legacy init
|
||||
}
|
||||
|
||||
// Fallback: legacy bash mosaic-init
|
||||
const initBin = fwScript('mosaic-init');
|
||||
if (existsSync(initBin)) {
|
||||
spawnSync(initBin, [], { stdio: 'inherit' });
|
||||
} else {
|
||||
console.error('[mosaic] Setup failed. Run: mosaic wizard');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkSequentialThinking(runtime: string): void {
|
||||
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||
if (!existsSync(checker)) return; // Skip if checker doesn't exist
|
||||
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
|
||||
if (result.status !== 0) {
|
||||
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
|
||||
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── File helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function readOptional(path: string): string {
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(path: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mission context ─────────────────────────────────────────────────────────
|
||||
|
||||
interface MissionInfo {
|
||||
name: string;
|
||||
id: string;
|
||||
status: string;
|
||||
milestoneCount: number;
|
||||
completedCount: number;
|
||||
}
|
||||
|
||||
function detectMission(): MissionInfo | null {
|
||||
const missionFile = '.mosaic/orchestrator/mission.json';
|
||||
const data = readJson(missionFile);
|
||||
if (!data) return null;
|
||||
|
||||
const status = String(data['status'] ?? 'inactive');
|
||||
if (status !== 'active' && status !== 'paused') return null;
|
||||
|
||||
const milestones = Array.isArray(data['milestones']) ? data['milestones'] : [];
|
||||
const completed = milestones.filter(
|
||||
(m) =>
|
||||
typeof m === 'object' &&
|
||||
m !== null &&
|
||||
(m as Record<string, unknown>)['status'] === 'completed',
|
||||
);
|
||||
|
||||
return {
|
||||
name: String(data['name'] ?? 'unnamed'),
|
||||
id: String(data['mission_id'] ?? ''),
|
||||
status,
|
||||
milestoneCount: milestones.length,
|
||||
completedCount: completed.length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMissionBlock(mission: MissionInfo): string {
|
||||
return `# ACTIVE MISSION — HARD GATE (Read Before Anything Else)
|
||||
|
||||
An active orchestration mission exists in this project. This is a BLOCKING requirement.
|
||||
|
||||
**Mission:** ${mission.name}
|
||||
**ID:** ${mission.id}
|
||||
**Status:** ${mission.status}
|
||||
**Milestones:** ${mission.completedCount} / ${mission.milestoneCount} completed
|
||||
|
||||
## MANDATORY — Before ANY Response to the User
|
||||
|
||||
You MUST complete these steps before responding to any user message, including simple greetings:
|
||||
|
||||
1. Read \`~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md\` (mission lifecycle protocol)
|
||||
2. Read \`docs/MISSION-MANIFEST.md\` for full mission scope, milestones, and success criteria
|
||||
3. Read the latest scratchpad in \`docs/scratchpads/\` for session history, decisions, and corrections
|
||||
4. Read \`docs/TASKS.md\` for current task state (what is done, what is next)
|
||||
5. After reading all four, acknowledge the mission state to the user before proceeding
|
||||
|
||||
If the user gives a task, execute it within the mission context. If no task is given, present mission status and ask how to proceed.
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── PRD status ──────────────────────────────────────────────────────────────
|
||||
|
||||
function buildPrdBlock(): string {
|
||||
const prdFile = 'docs/PRD.md';
|
||||
if (!existsSync(prdFile)) return '';
|
||||
|
||||
const content = readFileSync(prdFile, 'utf-8');
|
||||
const patterns = [
|
||||
/^#{2,3} .*(problem statement|objective)/im,
|
||||
/^#{2,3} .*(scope|non.goal|out of scope|in.scope)/im,
|
||||
/^#{2,3} .*(user stor|stakeholder|user.*requirement)/im,
|
||||
/^#{2,3} .*functional requirement/im,
|
||||
/^#{2,3} .*non.functional/im,
|
||||
/^#{2,3} .*acceptance criteria/im,
|
||||
/^#{2,3} .*(technical consideration|constraint|dependenc)/im,
|
||||
/^#{2,3} .*(risk|open question)/im,
|
||||
/^#{2,3} .*(success metric|test|verification)/im,
|
||||
/^#{2,3} .*(milestone|delivery|scope version)/im,
|
||||
];
|
||||
|
||||
let sections = 0;
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(content)) sections++;
|
||||
}
|
||||
|
||||
const assumptions = (content.match(/ASSUMPTION:/g) ?? []).length;
|
||||
const status = sections < 10 ? `incomplete (${sections}/10 sections)` : 'ready';
|
||||
|
||||
return `
|
||||
# PRD Status
|
||||
|
||||
- **File:** docs/PRD.md
|
||||
- **Status:** ${status}
|
||||
- **Assumptions:** ${assumptions}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Runtime prompt builder ──────────────────────────────────────────────────
|
||||
|
||||
function buildRuntimePrompt(runtime: RuntimeName): string {
|
||||
const runtimeContractPaths: Record<RuntimeName, string> = {
|
||||
claude: join(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'),
|
||||
codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'),
|
||||
opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'),
|
||||
pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'),
|
||||
};
|
||||
|
||||
const runtimeFile = runtimeContractPaths[runtime];
|
||||
checkFile(runtimeFile, `Runtime contract for ${runtime}`);
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Mission context (injected first)
|
||||
const mission = detectMission();
|
||||
if (mission) {
|
||||
parts.push(buildMissionBlock(mission));
|
||||
}
|
||||
|
||||
// PRD status
|
||||
const prdBlock = buildPrdBlock();
|
||||
if (prdBlock) parts.push(prdBlock);
|
||||
|
||||
// Hard gate
|
||||
parts.push(`# Mosaic Launcher Runtime Contract (Hard Gate)
|
||||
|
||||
This contract is injected by \`mosaic\` launch and is mandatory.
|
||||
|
||||
First assistant response MUST start with exactly one mode declaration line:
|
||||
1. Orchestration mission: \`Now initiating Orchestrator mode...\`
|
||||
2. Implementation mission: \`Now initiating Delivery mode...\`
|
||||
3. Review-only mission: \`Now initiating Review mode...\`
|
||||
|
||||
No tool call or implementation step may occur before that first line.
|
||||
|
||||
Mosaic hard gates OVERRIDE runtime-default caution for routine delivery operations.
|
||||
For required push/merge/issue-close/release actions, execute without routine confirmation prompts.
|
||||
`);
|
||||
|
||||
// AGENTS.md
|
||||
parts.push(readFileSync(join(MOSAIC_HOME, 'AGENTS.md'), 'utf-8'));
|
||||
|
||||
// USER.md
|
||||
const user = readOptional(join(MOSAIC_HOME, 'USER.md'));
|
||||
if (user) parts.push('\n\n# User Profile\n\n' + user);
|
||||
|
||||
// TOOLS.md
|
||||
const tools = readOptional(join(MOSAIC_HOME, 'TOOLS.md'));
|
||||
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
|
||||
|
||||
// Runtime-specific contract
|
||||
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// ─── Session lock ────────────────────────────────────────────────────────────
|
||||
|
||||
function writeSessionLock(runtime: string): void {
|
||||
const missionFile = '.mosaic/orchestrator/mission.json';
|
||||
const lockFile = '.mosaic/orchestrator/session.lock';
|
||||
const data = readJson(missionFile);
|
||||
if (!data) return;
|
||||
|
||||
const status = String(data['status'] ?? 'inactive');
|
||||
if (status !== 'active' && status !== 'paused') return;
|
||||
|
||||
const sessionId = `${runtime}-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
|
||||
const lock = {
|
||||
session_id: sessionId,
|
||||
runtime,
|
||||
pid: process.pid,
|
||||
started_at: new Date().toISOString(),
|
||||
project_path: process.cwd(),
|
||||
milestone_id: '',
|
||||
};
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(lockFile), { recursive: true });
|
||||
writeFileSync(lockFile, JSON.stringify(lock, null, 2) + '\n');
|
||||
|
||||
// Clean up on exit
|
||||
const cleanup = () => {
|
||||
try {
|
||||
rmSync(lockFile, { force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
process.exit(130);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
cleanup();
|
||||
process.exit(143);
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resumable session advisory ──────────────────────────────────────────────
|
||||
|
||||
function checkResumableSession(): void {
|
||||
const lockFile = '.mosaic/orchestrator/session.lock';
|
||||
const missionFile = '.mosaic/orchestrator/mission.json';
|
||||
|
||||
if (existsSync(lockFile)) {
|
||||
const lock = readJson(lockFile);
|
||||
if (lock) {
|
||||
const pid = Number(lock['pid'] ?? 0);
|
||||
if (pid > 0) {
|
||||
try {
|
||||
process.kill(pid, 0); // Check if alive
|
||||
} catch {
|
||||
// Process is dead — stale lock
|
||||
rmSync(lockFile, { force: true });
|
||||
console.log(`[mosaic] Cleaned up stale session lock (PID ${pid} no longer running).\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (existsSync(missionFile)) {
|
||||
const data = readJson(missionFile);
|
||||
if (data && data['status'] === 'active') {
|
||||
console.log('[mosaic] Active mission detected. Generate continuation prompt with:');
|
||||
console.log('[mosaic] mosaic coord continue\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Write config for runtimes that read from fixed paths ────────────────────
|
||||
|
||||
function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
|
||||
const prompt = buildRuntimePrompt(runtime);
|
||||
mkdirSync(dirname(destPath), { recursive: true });
|
||||
const existing = readOptional(destPath);
|
||||
if (existing !== prompt) {
|
||||
writeFileSync(destPath, prompt);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pi skill/extension discovery ────────────────────────────────────────────
|
||||
|
||||
function discoverPiSkills(): string[] {
|
||||
const args: string[] = [];
|
||||
for (const skillsRoot of [join(MOSAIC_HOME, 'skills'), join(MOSAIC_HOME, 'skills-local')]) {
|
||||
if (!existsSync(skillsRoot)) continue;
|
||||
try {
|
||||
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillDir = join(skillsRoot, entry.name);
|
||||
if (existsSync(join(skillDir, 'SKILL.md'))) {
|
||||
args.push('--skill', skillDir);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function discoverPiExtension(): string[] {
|
||||
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
|
||||
return existsSync(ext) ? ['--extension', ext] : [];
|
||||
}
|
||||
|
||||
// ─── Launch functions ────────────────────────────────────────────────────────
|
||||
|
||||
function getMissionPrompt(): string {
|
||||
const mission = detectMission();
|
||||
if (!mission) return '';
|
||||
return `Active mission detected: ${mission.name}. Read the mission state files and report status.`;
|
||||
}
|
||||
|
||||
function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): never {
|
||||
checkMosaicHome();
|
||||
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
||||
checkSoul();
|
||||
checkRuntime(runtime);
|
||||
|
||||
// Pi doesn't need sequential-thinking (has native thinking levels)
|
||||
if (runtime !== 'pi') {
|
||||
checkSequentialThinking(runtime);
|
||||
}
|
||||
|
||||
checkResumableSession();
|
||||
|
||||
const missionPrompt = getMissionPrompt();
|
||||
const hasMissionNoArgs = missionPrompt && args.length === 0;
|
||||
const label = RUNTIME_LABELS[runtime];
|
||||
const modeStr = yolo ? ' in YOLO mode' : '';
|
||||
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
|
||||
|
||||
writeSessionLock(runtime);
|
||||
|
||||
switch (runtime) {
|
||||
case 'claude': {
|
||||
const prompt = buildRuntimePrompt('claude');
|
||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||
cliArgs.push('--append-system-prompt', prompt);
|
||||
if (hasMissionNoArgs) {
|
||||
cliArgs.push(missionPrompt);
|
||||
} else {
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
execRuntime('claude', cliArgs);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'codex': {
|
||||
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
|
||||
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
||||
if (hasMissionNoArgs) {
|
||||
cliArgs.push(missionPrompt);
|
||||
} else {
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
execRuntime('codex', cliArgs);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'opencode': {
|
||||
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||
execRuntime('opencode', args);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pi': {
|
||||
const prompt = buildRuntimePrompt('pi');
|
||||
const cliArgs = ['--append-system-prompt', prompt];
|
||||
cliArgs.push(...discoverPiSkills());
|
||||
cliArgs.push(...discoverPiExtension());
|
||||
if (hasMissionNoArgs) {
|
||||
cliArgs.push(missionPrompt);
|
||||
} else {
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
execRuntime('pi', cliArgs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0); // Unreachable but satisfies never
|
||||
}
|
||||
|
||||
/** exec into the runtime, replacing the current process. */
|
||||
function execRuntime(cmd: string, args: string[]): void {
|
||||
try {
|
||||
// Use execFileSync with inherited stdio to replace the process
|
||||
const result = spawnSync(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
process.exit(result.status ?? 0);
|
||||
} catch (err) {
|
||||
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Framework script/tool delegation ───────────────────────────────────────
|
||||
|
||||
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
|
||||
if (!existsSync(scriptPath)) {
|
||||
console.error(`[mosaic] Script not found: ${scriptPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
execFileSync('bash', [scriptPath, ...args], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
process.exit((err as { status?: number }).status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path under the framework tools directory. Prefers the version
|
||||
* bundled in the @mosaic/mosaic npm package (always matches the installed
|
||||
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
|
||||
*/
|
||||
function resolveTool(...segments: string[]): string {
|
||||
// Resolve relative to the built file: dist/commands/launch.js → ../../framework/tools/...
|
||||
const thisFile = fileURLToPath(import.meta.url);
|
||||
const bundled = join(dirname(thisFile), '..', '..', 'framework', 'tools', ...segments);
|
||||
if (existsSync(bundled)) return bundled;
|
||||
return join(MOSAIC_HOME, 'tools', ...segments);
|
||||
}
|
||||
|
||||
function fwScript(name: string): string {
|
||||
return resolveTool('_scripts', name);
|
||||
}
|
||||
|
||||
function toolScript(toolDir: string, name: string): string {
|
||||
return resolveTool(toolDir, name);
|
||||
}
|
||||
|
||||
// ─── Coord (mission orchestrator) ───────────────────────────────────────────
|
||||
|
||||
const COORD_SUBCMDS: Record<string, string> = {
|
||||
status: 'session-status.sh',
|
||||
session: 'session-status.sh',
|
||||
init: 'mission-init.sh',
|
||||
mission: 'mission-status.sh',
|
||||
progress: 'mission-status.sh',
|
||||
continue: 'continue-prompt.sh',
|
||||
next: 'continue-prompt.sh',
|
||||
run: 'session-run.sh',
|
||||
start: 'session-run.sh',
|
||||
smoke: 'smoke-test.sh',
|
||||
test: 'smoke-test.sh',
|
||||
resume: 'session-resume.sh',
|
||||
recover: 'session-resume.sh',
|
||||
};
|
||||
|
||||
function runCoord(args: string[]): never {
|
||||
checkMosaicHome();
|
||||
let runtime = 'claude';
|
||||
let yoloFlag = '';
|
||||
const coordArgs: string[] = [];
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
|
||||
runtime = arg.slice(2);
|
||||
} else if (arg === '--yolo') {
|
||||
yoloFlag = '--yolo';
|
||||
} else {
|
||||
coordArgs.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const subcmd = coordArgs[0] ?? 'help';
|
||||
const subArgs = coordArgs.slice(1);
|
||||
const script = COORD_SUBCMDS[subcmd];
|
||||
|
||||
if (!script) {
|
||||
console.log(`mosaic coord — mission coordinator tools
|
||||
|
||||
Commands:
|
||||
init --name <name> [opts] Initialize a new mission
|
||||
mission [--project <path>] Show mission progress dashboard
|
||||
status [--project <path>] Check agent session health
|
||||
continue [--project <path>] Generate continuation prompt
|
||||
run [--project <path>] Launch runtime with mission context
|
||||
smoke Run orchestration smoke checks
|
||||
resume [--project <path>] Crash recovery
|
||||
|
||||
Runtime: --claude (default) | --codex | --pi | --yolo`);
|
||||
process.exit(subcmd === 'help' ? 0 : 1);
|
||||
}
|
||||
|
||||
if (yoloFlag) subArgs.unshift(yoloFlag);
|
||||
delegateToScript(toolScript('orchestrator', script), subArgs, {
|
||||
MOSAIC_COORD_RUNTIME: runtime,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Prdy (PRD tools via framework scripts) ─────────────────────────────────
|
||||
|
||||
const PRDY_SUBCMDS: Record<string, string> = {
|
||||
init: 'prdy-init.sh',
|
||||
update: 'prdy-update.sh',
|
||||
validate: 'prdy-validate.sh',
|
||||
check: 'prdy-validate.sh',
|
||||
status: 'prdy-status.sh',
|
||||
};
|
||||
|
||||
function runPrdyLocal(args: string[]): never {
|
||||
checkMosaicHome();
|
||||
let runtime = 'claude';
|
||||
const prdyArgs: string[] = [];
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
|
||||
runtime = arg.slice(2);
|
||||
} else {
|
||||
prdyArgs.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const subcmd = prdyArgs[0] ?? 'help';
|
||||
const subArgs = prdyArgs.slice(1);
|
||||
const script = PRDY_SUBCMDS[subcmd];
|
||||
|
||||
if (!script) {
|
||||
console.log(`mosaic prdy — PRD creation and validation
|
||||
|
||||
Commands:
|
||||
init [--project <path>] [--name <feature>] Create docs/PRD.md
|
||||
update [--project <path>] Update existing PRD
|
||||
validate [--project <path>] Check PRD completeness
|
||||
status [--project <path>] Quick PRD health check
|
||||
|
||||
Runtime: --claude (default) | --codex | --pi`);
|
||||
process.exit(subcmd === 'help' ? 0 : 1);
|
||||
}
|
||||
|
||||
delegateToScript(toolScript('prdy', script), subArgs, {
|
||||
MOSAIC_PRDY_RUNTIME: runtime,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Seq (sequential-thinking MCP) ──────────────────────────────────────────
|
||||
|
||||
function runSeq(args: string[]): never {
|
||||
checkMosaicHome();
|
||||
const action = args[0] ?? 'check';
|
||||
const rest = args.slice(1);
|
||||
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||
|
||||
switch (action) {
|
||||
case 'check':
|
||||
delegateToScript(checker, ['--check', ...rest]);
|
||||
break; // unreachable
|
||||
case 'fix':
|
||||
case 'apply':
|
||||
delegateToScript(checker, rest);
|
||||
break;
|
||||
case 'start': {
|
||||
console.log('[mosaic] Starting sequential-thinking MCP server...');
|
||||
try {
|
||||
execFileSync('npx', ['-y', '@modelcontextprotocol/server-sequential-thinking', ...rest], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
process.exit((err as { status?: number }).status ?? 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`[mosaic] Unknown seq subcommand '${action}'. Use: check|fix|start`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Upgrade ────────────────────────────────────────────────────────────────
|
||||
|
||||
function runUpgrade(args: string[]): never {
|
||||
checkMosaicHome();
|
||||
const subcmd = args[0];
|
||||
|
||||
if (!subcmd || subcmd === 'release') {
|
||||
delegateToScript(fwScript('mosaic-release-upgrade'), args.slice(subcmd === 'release' ? 1 : 0));
|
||||
} else if (subcmd === 'check') {
|
||||
delegateToScript(fwScript('mosaic-release-upgrade'), ['--dry-run', ...args.slice(1)]);
|
||||
} else if (subcmd === 'project') {
|
||||
delegateToScript(fwScript('mosaic-upgrade'), args.slice(1));
|
||||
} else if (subcmd.startsWith('-')) {
|
||||
delegateToScript(fwScript('mosaic-release-upgrade'), args);
|
||||
} else {
|
||||
delegateToScript(fwScript('mosaic-upgrade'), args);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Commander registration ─────────────────────────────────────────────────
|
||||
|
||||
export function registerLaunchCommands(program: Command): void {
|
||||
// Runtime launchers
|
||||
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
|
||||
program
|
||||
.command(runtime)
|
||||
.description(`Launch ${RUNTIME_LABELS[runtime]} with Mosaic injection`)
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
launchRuntime(runtime, cmd.args, false);
|
||||
});
|
||||
}
|
||||
|
||||
// Yolo mode
|
||||
program
|
||||
.command('yolo <runtime>')
|
||||
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((runtime: string, _opts: unknown, cmd: Command) => {
|
||||
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
|
||||
if (!valid.includes(runtime as RuntimeName)) {
|
||||
console.error(
|
||||
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
launchRuntime(runtime as RuntimeName, cmd.args, true);
|
||||
});
|
||||
|
||||
// Coord (mission orchestrator)
|
||||
program
|
||||
.command('coord')
|
||||
.description('Mission coordinator tools (init, status, run, continue, resume)')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
runCoord(cmd.args);
|
||||
});
|
||||
|
||||
// Prdy (PRD tools via local framework scripts)
|
||||
program
|
||||
.command('prdy')
|
||||
.description('PRD creation and validation (init, update, validate, status)')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
runPrdyLocal(cmd.args);
|
||||
});
|
||||
|
||||
// Seq (sequential-thinking MCP management)
|
||||
program
|
||||
.command('seq')
|
||||
.description('sequential-thinking MCP management (check/fix/start)')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
runSeq(cmd.args);
|
||||
});
|
||||
|
||||
// Upgrade (release + project)
|
||||
program
|
||||
.command('upgrade')
|
||||
.description('Upgrade Mosaic release or project files')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
runUpgrade(cmd.args);
|
||||
});
|
||||
|
||||
// Direct framework script delegates
|
||||
const directCommands: Record<string, { desc: string; script: string }> = {
|
||||
init: { desc: 'Generate SOUL.md (agent identity contract)', script: 'mosaic-init' },
|
||||
doctor: { desc: 'Health audit — detect drift and missing files', script: 'mosaic-doctor' },
|
||||
sync: { desc: 'Sync skills from canonical source', script: 'mosaic-sync-skills' },
|
||||
bootstrap: {
|
||||
desc: 'Bootstrap a repo with Mosaic standards',
|
||||
script: 'mosaic-bootstrap-repo',
|
||||
},
|
||||
};
|
||||
|
||||
for (const [name, { desc, script }] of Object.entries(directCommands)) {
|
||||
program
|
||||
.command(name)
|
||||
.description(desc)
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
checkMosaicHome();
|
||||
delegateToScript(fwScript(script), cmd.args);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import { withAuth } from './with-auth.js';
|
||||
import { selectItem } from './select-dialog.js';
|
||||
import {
|
||||
fetchMissions,
|
||||
fetchMission,
|
||||
createMission,
|
||||
updateMission,
|
||||
fetchMissionTasks,
|
||||
createMissionTask,
|
||||
updateMissionTask,
|
||||
fetchProjects,
|
||||
} from '../tui/gateway-api.js';
|
||||
import type { MissionInfo, MissionTaskInfo } from '../tui/gateway-api.js';
|
||||
|
||||
function formatMission(m: MissionInfo): string {
|
||||
return `${m.name} — ${m.status}${m.phase ? ` (${m.phase})` : ''}`;
|
||||
}
|
||||
|
||||
function showMissionDetail(m: MissionInfo) {
|
||||
console.log(` ID: ${m.id}`);
|
||||
console.log(` Name: ${m.name}`);
|
||||
console.log(` Status: ${m.status}`);
|
||||
console.log(` Phase: ${m.phase ?? '—'}`);
|
||||
console.log(` Project: ${m.projectId ?? '—'}`);
|
||||
console.log(` Description: ${m.description ?? '—'}`);
|
||||
console.log(` Created: ${new Date(m.createdAt).toLocaleString()}`);
|
||||
}
|
||||
|
||||
function showTaskDetail(t: MissionTaskInfo) {
|
||||
console.log(` ID: ${t.id}`);
|
||||
console.log(` Status: ${t.status}`);
|
||||
console.log(` Description: ${t.description ?? '—'}`);
|
||||
console.log(` Notes: ${t.notes ?? '—'}`);
|
||||
console.log(` PR: ${t.pr ?? '—'}`);
|
||||
console.log(` Created: ${new Date(t.createdAt).toLocaleString()}`);
|
||||
}
|
||||
|
||||
export function registerMissionCommand(program: Command) {
|
||||
const cmd = program
|
||||
.command('mission')
|
||||
.description('Manage missions')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--list', 'List all missions')
|
||||
.option('--init', 'Create a new mission')
|
||||
.option('--plan <idOrName>', 'Run PRD wizard for a mission')
|
||||
.option('--update <idOrName>', 'Update a mission')
|
||||
.option('--project <idOrName>', 'Scope to project')
|
||||
.argument('[id]', 'Show mission detail by ID')
|
||||
.action(
|
||||
async (
|
||||
id: string | undefined,
|
||||
opts: {
|
||||
gateway: string;
|
||||
list?: boolean;
|
||||
init?: boolean;
|
||||
plan?: string;
|
||||
update?: string;
|
||||
project?: string;
|
||||
},
|
||||
) => {
|
||||
const auth = await withAuth(opts.gateway);
|
||||
|
||||
if (opts.list) {
|
||||
return listMissions(auth.gateway, auth.cookie);
|
||||
}
|
||||
if (opts.init) {
|
||||
return initMission(auth.gateway, auth.cookie);
|
||||
}
|
||||
if (opts.plan) {
|
||||
return planMission(auth.gateway, auth.cookie, opts.plan, opts.project);
|
||||
}
|
||||
if (opts.update) {
|
||||
return updateMissionWizard(auth.gateway, auth.cookie, opts.update);
|
||||
}
|
||||
if (id) {
|
||||
return showMission(auth.gateway, auth.cookie, id);
|
||||
}
|
||||
|
||||
// Default: interactive select
|
||||
return interactiveSelect(auth.gateway, auth.cookie);
|
||||
},
|
||||
);
|
||||
|
||||
// Task subcommand
|
||||
cmd
|
||||
.command('task')
|
||||
.description('Manage mission tasks')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--list', 'List tasks for a mission')
|
||||
.option('--new', 'Create a task')
|
||||
.option('--update <taskId>', 'Update a task')
|
||||
.option('--mission <idOrName>', 'Mission ID or name')
|
||||
.argument('[taskId]', 'Show task detail')
|
||||
.action(
|
||||
async (
|
||||
taskId: string | undefined,
|
||||
taskOpts: {
|
||||
gateway: string;
|
||||
list?: boolean;
|
||||
new?: boolean;
|
||||
update?: string;
|
||||
mission?: string;
|
||||
},
|
||||
) => {
|
||||
const auth = await withAuth(taskOpts.gateway);
|
||||
|
||||
const missionId = await resolveMissionId(auth.gateway, auth.cookie, taskOpts.mission);
|
||||
if (!missionId) return;
|
||||
|
||||
if (taskOpts.list) {
|
||||
return listTasks(auth.gateway, auth.cookie, missionId);
|
||||
}
|
||||
if (taskOpts.new) {
|
||||
return createTaskWizard(auth.gateway, auth.cookie, missionId);
|
||||
}
|
||||
if (taskOpts.update) {
|
||||
return updateTaskWizard(auth.gateway, auth.cookie, missionId, taskOpts.update);
|
||||
}
|
||||
if (taskId) {
|
||||
return showTask(auth.gateway, auth.cookie, missionId, taskId);
|
||||
}
|
||||
|
||||
return listTasks(auth.gateway, auth.cookie, missionId);
|
||||
},
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
async function resolveMissionByName(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
idOrName: string,
|
||||
): Promise<MissionInfo | undefined> {
|
||||
const missions = await fetchMissions(gateway, cookie);
|
||||
return missions.find((m) => m.id === idOrName || m.name === idOrName);
|
||||
}
|
||||
|
||||
async function resolveMissionId(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
idOrName?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (idOrName) {
|
||||
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
||||
if (!mission) {
|
||||
console.error(`Mission "${idOrName}" not found.`);
|
||||
return undefined;
|
||||
}
|
||||
return mission.id;
|
||||
}
|
||||
|
||||
// Interactive select
|
||||
const missions = await fetchMissions(gateway, cookie);
|
||||
const selected = await selectItem(missions, {
|
||||
message: 'Select a mission:',
|
||||
render: formatMission,
|
||||
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
|
||||
});
|
||||
return selected?.id;
|
||||
}
|
||||
|
||||
async function listMissions(gateway: string, cookie: string) {
|
||||
const missions = await fetchMissions(gateway, cookie);
|
||||
if (missions.length === 0) {
|
||||
console.log('No missions found.');
|
||||
return;
|
||||
}
|
||||
console.log(`Missions (${missions.length}):\n`);
|
||||
for (const m of missions) {
|
||||
const phase = m.phase ? ` [${m.phase}]` : '';
|
||||
console.log(` ${m.name} ${m.status}${phase} ${m.id.slice(0, 8)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showMission(gateway: string, cookie: string, id: string) {
|
||||
try {
|
||||
const mission = await fetchMission(gateway, cookie, id);
|
||||
showMissionDetail(mission);
|
||||
} catch {
|
||||
// Try resolving by name
|
||||
const m = await resolveMissionByName(gateway, cookie, id);
|
||||
if (!m) {
|
||||
console.error(`Mission "${id}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
showMissionDetail(m);
|
||||
}
|
||||
}
|
||||
|
||||
async function interactiveSelect(gateway: string, cookie: string) {
|
||||
const missions = await fetchMissions(gateway, cookie);
|
||||
const selected = await selectItem(missions, {
|
||||
message: 'Select a mission:',
|
||||
render: formatMission,
|
||||
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
|
||||
});
|
||||
if (selected) {
|
||||
showMissionDetail(selected);
|
||||
}
|
||||
}
|
||||
|
||||
async function initMission(gateway: string, cookie: string) {
|
||||
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));
|
||||
|
||||
try {
|
||||
const name = await ask('Mission name: ');
|
||||
if (!name.trim()) {
|
||||
console.error('Name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Project selection
|
||||
const projects = await fetchProjects(gateway, cookie);
|
||||
let projectId: string | undefined;
|
||||
if (projects.length > 0) {
|
||||
const selected = await selectItem(projects, {
|
||||
message: 'Assign to project (required):',
|
||||
render: (p) => `${p.name} (${p.status})`,
|
||||
emptyMessage: 'No projects found.',
|
||||
});
|
||||
if (selected) projectId = selected.id;
|
||||
}
|
||||
|
||||
const description = await ask('Description (optional): ');
|
||||
|
||||
const mission = await createMission(gateway, cookie, {
|
||||
name: name.trim(),
|
||||
projectId,
|
||||
description: description.trim() || undefined,
|
||||
status: 'planning',
|
||||
});
|
||||
|
||||
console.log(`\nMission "${mission.name}" created (${mission.id}).`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function planMission(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
idOrName: string,
|
||||
_projectIdOrName?: string,
|
||||
) {
|
||||
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
||||
if (!mission) {
|
||||
console.error(`Mission "${idOrName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Planning mission: ${mission.name}\n`);
|
||||
|
||||
try {
|
||||
const { runPrdWizard } = await import('@mosaic/prdy');
|
||||
await runPrdWizard({
|
||||
name: mission.name,
|
||||
projectPath: process.cwd(),
|
||||
interactive: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMissionWizard(gateway: string, cookie: string, idOrName: string) {
|
||||
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
||||
if (!mission) {
|
||||
console.error(`Mission "${idOrName}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
try {
|
||||
console.log(`Updating mission: ${mission.name}\n`);
|
||||
|
||||
const name = await ask(`Name [${mission.name}]: `);
|
||||
const description = await ask(`Description [${mission.description ?? 'none'}]: `);
|
||||
const status = await ask(`Status [${mission.status}]: `);
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (name.trim()) updates['name'] = name.trim();
|
||||
if (description.trim()) updates['description'] = description.trim();
|
||||
if (status.trim()) updates['status'] = status.trim();
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.log('No changes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateMission(gateway, cookie, mission.id, updates);
|
||||
console.log(`\nMission "${updated.name}" updated.`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task operations ──
|
||||
|
||||
async function listTasks(gateway: string, cookie: string, missionId: string) {
|
||||
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
|
||||
if (tasks.length === 0) {
|
||||
console.log('No tasks found.');
|
||||
return;
|
||||
}
|
||||
console.log(`Tasks (${tasks.length}):\n`);
|
||||
for (const t of tasks) {
|
||||
const desc = t.description ? ` — ${t.description.slice(0, 60)}` : '';
|
||||
console.log(` ${t.id.slice(0, 8)} ${t.status}${desc}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function showTask(gateway: string, cookie: string, missionId: string, taskId: string) {
|
||||
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task) {
|
||||
console.error(`Task "${taskId}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
showTaskDetail(task);
|
||||
}
|
||||
|
||||
async function createTaskWizard(gateway: string, cookie: string, missionId: string) {
|
||||
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));
|
||||
|
||||
try {
|
||||
const description = await ask('Task description: ');
|
||||
if (!description.trim()) {
|
||||
console.error('Description is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await ask('Status [not-started]: ');
|
||||
|
||||
const task = await createMissionTask(gateway, cookie, missionId, {
|
||||
description: description.trim(),
|
||||
status: status.trim() || 'not-started',
|
||||
});
|
||||
|
||||
console.log(`\nTask created (${task.id}).`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTaskWizard(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
missionId: string,
|
||||
taskId: string,
|
||||
) {
|
||||
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));
|
||||
|
||||
try {
|
||||
const status = await ask('New status: ');
|
||||
const notes = await ask('Notes (optional): ');
|
||||
const pr = await ask('PR (optional): ');
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (status.trim()) updates['status'] = status.trim();
|
||||
if (notes.trim()) updates['notes'] = notes.trim();
|
||||
if (pr.trim()) updates['pr'] = pr.trim();
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.log('No changes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateMissionTask(gateway, cookie, missionId, taskId, updates);
|
||||
console.log(`\nTask ${updated.id.slice(0, 8)} updated (${updated.status}).`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import { withAuth } from './with-auth.js';
|
||||
import { fetchProjects } from '../tui/gateway-api.js';
|
||||
|
||||
export function registerPrdyCommand(program: Command) {
|
||||
const cmd = program
|
||||
.command('prdy')
|
||||
.description('PRD wizard — create and manage Product Requirement Documents')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--init [name]', 'Create a new PRD')
|
||||
.option('--update [name]', 'Update an existing PRD')
|
||||
.option('--project <idOrName>', 'Scope to project')
|
||||
.action(
|
||||
async (opts: {
|
||||
gateway: string;
|
||||
init?: string | boolean;
|
||||
update?: string | boolean;
|
||||
project?: string;
|
||||
}) => {
|
||||
// Detect project context when --project flag is provided
|
||||
if (opts.project) {
|
||||
try {
|
||||
const auth = await withAuth(opts.gateway);
|
||||
const projects = await fetchProjects(auth.gateway, auth.cookie);
|
||||
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
|
||||
if (match) {
|
||||
console.log(`Project context: ${match.name} (${match.id})\n`);
|
||||
}
|
||||
} catch {
|
||||
// Gateway not available — proceed without project context
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { runPrdWizard } = await import('@mosaic/prdy');
|
||||
const name =
|
||||
typeof opts.init === 'string'
|
||||
? opts.init
|
||||
: typeof opts.update === 'string'
|
||||
? opts.update
|
||||
: 'untitled';
|
||||
await runPrdWizard({
|
||||
name,
|
||||
projectPath: process.cwd(),
|
||||
interactive: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Interactive item selection. Uses @clack/prompts when TTY, falls back to numbered list.
|
||||
*/
|
||||
export async function selectItem<T>(
|
||||
items: T[],
|
||||
opts: {
|
||||
message: string;
|
||||
render: (item: T) => string;
|
||||
emptyMessage?: string;
|
||||
},
|
||||
): Promise<T | undefined> {
|
||||
if (items.length === 0) {
|
||||
console.log(opts.emptyMessage ?? 'No items found.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isTTY = process.stdin.isTTY;
|
||||
|
||||
if (isTTY) {
|
||||
try {
|
||||
const { select } = await import('@clack/prompts');
|
||||
const result = await select({
|
||||
message: opts.message,
|
||||
options: items.map((item, i) => ({
|
||||
value: i,
|
||||
label: opts.render(item),
|
||||
})),
|
||||
});
|
||||
|
||||
if (typeof result === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items[result as number];
|
||||
} catch {
|
||||
// Fall through to non-interactive
|
||||
}
|
||||
}
|
||||
|
||||
// Non-interactive: display numbered list and read a number
|
||||
console.log(`\n${opts.message}\n`);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
console.log(` ${i + 1}. ${opts.render(items[i]!)}`);
|
||||
}
|
||||
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) => rl.question('\nSelect: ', resolve));
|
||||
rl.close();
|
||||
|
||||
const index = parseInt(answer, 10) - 1;
|
||||
if (isNaN(index) || index < 0 || index >= items.length) {
|
||||
console.error('Invalid selection.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items[index];
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { AuthResult } from '../auth.js';
|
||||
|
||||
export interface AuthContext {
|
||||
gateway: string;
|
||||
session: AuthResult;
|
||||
cookie: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate the user's auth session.
|
||||
* Exits with an error message if not signed in or session expired.
|
||||
*/
|
||||
export async function withAuth(gateway: string): Promise<AuthContext> {
|
||||
const { loadSession, validateSession } = await import('../auth.js');
|
||||
|
||||
const session = loadSession(gateway);
|
||||
if (!session) {
|
||||
console.error('Not signed in. Run `mosaic login` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const valid = await validateSession(gateway, session.cookie);
|
||||
if (!valid) {
|
||||
console.error('Session expired. Run `mosaic login` again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { gateway, session, cookie: session.cookie };
|
||||
}
|
||||
@@ -1 +1,101 @@
|
||||
export const VERSION = '0.0.0';
|
||||
#!/usr/bin/env node
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { ClackPrompter } from './prompter/clack-prompter.js';
|
||||
import { HeadlessPrompter } from './prompter/headless-prompter.js';
|
||||
import { createConfigService } from './config/config-service.js';
|
||||
import { runWizard } from './wizard.js';
|
||||
import { WizardCancelledError } from './errors.js';
|
||||
import { VERSION, DEFAULT_MOSAIC_HOME } from './constants.js';
|
||||
import type { CommunicationStyle } from './types.js';
|
||||
|
||||
export { VERSION, DEFAULT_MOSAIC_HOME };
|
||||
export { runWizard } from './wizard.js';
|
||||
export {
|
||||
checkForUpdate,
|
||||
backgroundUpdateCheck,
|
||||
formatUpdateNotice,
|
||||
getInstalledVersion,
|
||||
getLatestVersion,
|
||||
semverLt,
|
||||
} from './runtime/update-checker.js';
|
||||
export type { UpdateCheckResult } from './runtime/update-checker.js';
|
||||
export { ClackPrompter } from './prompter/clack-prompter.js';
|
||||
export { HeadlessPrompter } from './prompter/headless-prompter.js';
|
||||
export { createConfigService } from './config/config-service.js';
|
||||
export { WizardCancelledError } from './errors.js';
|
||||
|
||||
const program = new Command()
|
||||
.name('mosaic-wizard')
|
||||
.description('Mosaic Installation Wizard')
|
||||
.version(VERSION);
|
||||
|
||||
program
|
||||
.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', DEFAULT_MOSAIC_HOME)
|
||||
// SOUL.md overrides
|
||||
.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')
|
||||
// USER.md overrides
|
||||
.option('--user-name <name>', 'Your name')
|
||||
.option('--pronouns <pronouns>', 'Your pronouns')
|
||||
.option('--timezone <tz>', 'Your timezone')
|
||||
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
||||
try {
|
||||
const mosaicHome = (opts['mosaicHome'] as string) ?? DEFAULT_MOSAIC_HOME;
|
||||
// Default source to the framework/ dir bundled in this npm package.
|
||||
// This ensures syncFramework copies AGENTS.md, STANDARDS.md, guides/, etc.
|
||||
// Falls back to mosaicHome if the bundled dir doesn't exist (shouldn't happen).
|
||||
const pkgRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const bundledFramework = resolve(pkgRoot, '..', 'framework');
|
||||
const sourceDir =
|
||||
(opts['sourceDir'] as string | undefined) ??
|
||||
(existsSync(bundledFramework) ? bundledFramework : mosaicHome);
|
||||
|
||||
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
|
||||
|
||||
const configService = createConfigService(mosaicHome, sourceDir);
|
||||
|
||||
const style = opts['style'] as CommunicationStyle | undefined;
|
||||
|
||||
await runWizard({
|
||||
mosaicHome,
|
||||
sourceDir,
|
||||
prompter,
|
||||
configService,
|
||||
cliOverrides: {
|
||||
soul: {
|
||||
agentName: opts['name'] as string | undefined,
|
||||
roleDescription: opts['role'] as string | undefined,
|
||||
communicationStyle: style,
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
const entryPath = process.argv[1] ? resolve(process.argv[1]) : '';
|
||||
if (entryPath.length > 0 && entryPath === fileURLToPath(import.meta.url)) {
|
||||
program.parse();
|
||||
}
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Box, useApp, useInput } from 'ink';
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
import { TopBar } from './components/top-bar.js';
|
||||
import { BottomBar } from './components/bottom-bar.js';
|
||||
import { MessageList } from './components/message-list.js';
|
||||
import { InputBar } from './components/input-bar.js';
|
||||
import { Sidebar } from './components/sidebar.js';
|
||||
import { SearchBar } from './components/search-bar.js';
|
||||
import { useSocket } from './hooks/use-socket.js';
|
||||
import { useGitInfo } from './hooks/use-git-info.js';
|
||||
import { useViewport } from './hooks/use-viewport.js';
|
||||
import { useAppMode } from './hooks/use-app-mode.js';
|
||||
import { useConversations } from './hooks/use-conversations.js';
|
||||
import { useSearch } from './hooks/use-search.js';
|
||||
import { executeHelp, executeStatus, executeHistory, commandRegistry } from './commands/index.js';
|
||||
import { fetchConversationMessages } from './gateway-api.js';
|
||||
import { expandFileRefs, hasFileRefs, handleAttachCommand } from './file-ref.js';
|
||||
|
||||
export interface TuiAppProps {
|
||||
gatewayUrl: string;
|
||||
conversationId?: string;
|
||||
sessionCookie?: string;
|
||||
initialModel?: string;
|
||||
initialProvider?: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
projectId?: string;
|
||||
/** CLI package version passed from the entry point (cli.ts). */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function TuiApp({
|
||||
gatewayUrl,
|
||||
conversationId,
|
||||
sessionCookie,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
agentName,
|
||||
projectId: _projectId,
|
||||
version = '0.0.0',
|
||||
}: TuiAppProps) {
|
||||
const { exit } = useApp();
|
||||
const gitInfo = useGitInfo();
|
||||
const appMode = useAppMode();
|
||||
|
||||
const socket = useSocket({
|
||||
gatewayUrl,
|
||||
sessionCookie,
|
||||
initialConversationId: conversationId,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
});
|
||||
|
||||
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
||||
|
||||
const viewport = useViewport({ totalItems: socket.messages.length });
|
||||
|
||||
const search = useSearch(socket.messages);
|
||||
|
||||
// Scroll to current match when it changes
|
||||
const currentMatch = search.matches[search.currentMatchIndex];
|
||||
useEffect(() => {
|
||||
if (currentMatch && appMode.mode === 'search') {
|
||||
viewport.scrollTo(currentMatch.messageIndex);
|
||||
}
|
||||
}, [currentMatch, appMode.mode, viewport]);
|
||||
|
||||
// Compute highlighted message indices for MessageList
|
||||
const highlightedMessageIndices = useMemo(() => {
|
||||
if (search.matches.length === 0) return undefined;
|
||||
return new Set(search.matches.map((m) => m.messageIndex));
|
||||
}, [search.matches]);
|
||||
|
||||
const currentHighlightIndex = currentMatch?.messageIndex;
|
||||
|
||||
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
|
||||
|
||||
// Controlled input state — held here so Ctrl+C can clear it
|
||||
const [tuiInput, setTuiInput] = useState('');
|
||||
// Ctrl+C double-press: first press with empty input shows hint; second exits
|
||||
const ctrlCPendingExit = useRef(false);
|
||||
// Flag to suppress the character that ink-text-input leaks when a Ctrl+key
|
||||
// combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't').
|
||||
const ctrlJustFired = useRef(false);
|
||||
|
||||
// Wrap sendMessage to expand @file references before sending
|
||||
const sendMessageWithFileRefs = useCallback(
|
||||
(content: string) => {
|
||||
if (!hasFileRefs(content)) {
|
||||
socket.sendMessage(content);
|
||||
return;
|
||||
}
|
||||
void expandFileRefs(content)
|
||||
.then(({ expandedMessage, filesAttached, errors }) => {
|
||||
for (const err of errors) {
|
||||
socket.addSystemMessage(err);
|
||||
}
|
||||
if (filesAttached.length > 0) {
|
||||
socket.addSystemMessage(
|
||||
`📎 Attached ${filesAttached.length} file(s): ${filesAttached.join(', ')}`,
|
||||
);
|
||||
}
|
||||
socket.sendMessage(expandedMessage);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
socket.addSystemMessage(
|
||||
`File expansion failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// Send original message without expansion
|
||||
socket.sendMessage(content);
|
||||
});
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
const handleLocalCommand = useCallback(
|
||||
(parsed: ParsedCommand) => {
|
||||
switch (parsed.command) {
|
||||
case 'help':
|
||||
case 'h': {
|
||||
const result = executeHelp(parsed);
|
||||
socket.addSystemMessage(result);
|
||||
break;
|
||||
}
|
||||
case 'status':
|
||||
case 's': {
|
||||
const result = executeStatus(parsed, {
|
||||
connected: socket.connected,
|
||||
model: socket.modelName,
|
||||
provider: socket.providerName,
|
||||
sessionId: socket.conversationId ?? null,
|
||||
tokenCount: socket.tokenUsage.total,
|
||||
});
|
||||
socket.addSystemMessage(result);
|
||||
break;
|
||||
}
|
||||
case 'clear':
|
||||
socket.clearMessages();
|
||||
break;
|
||||
case 'new':
|
||||
case 'n':
|
||||
void conversations
|
||||
.createConversation()
|
||||
.then((conv) => {
|
||||
if (conv) {
|
||||
socket.switchConversation(conv.id);
|
||||
appMode.setMode('chat');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
socket.addSystemMessage('Failed to create new conversation.');
|
||||
});
|
||||
break;
|
||||
case 'attach': {
|
||||
if (!parsed.args) {
|
||||
socket.addSystemMessage('Usage: /attach <file-path>');
|
||||
break;
|
||||
}
|
||||
void handleAttachCommand(parsed.args)
|
||||
.then(({ content, error }) => {
|
||||
if (error) {
|
||||
socket.addSystemMessage(`Attach error: ${error}`);
|
||||
} else if (content) {
|
||||
// Send the file content as a user message
|
||||
socket.sendMessage(content);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
socket.addSystemMessage(
|
||||
`Attach failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'stop':
|
||||
if (socket.isStreaming && socket.socketRef.current?.connected && socket.conversationId) {
|
||||
socket.socketRef.current.emit('abort', {
|
||||
conversationId: socket.conversationId,
|
||||
});
|
||||
socket.addSystemMessage('Abort signal sent.');
|
||||
} else {
|
||||
socket.addSystemMessage('No active stream to stop.');
|
||||
}
|
||||
break;
|
||||
case 'cost': {
|
||||
const u = socket.tokenUsage;
|
||||
socket.addSystemMessage(
|
||||
`Tokens — input: ${u.input}, output: ${u.output}, total: ${u.total}\nCost: $${u.cost.toFixed(6)}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'history':
|
||||
case 'hist': {
|
||||
void executeHistory({
|
||||
conversationId: socket.conversationId,
|
||||
gatewayUrl,
|
||||
sessionCookie,
|
||||
fetchMessages: fetchConversationMessages,
|
||||
})
|
||||
.then((result) => {
|
||||
socket.addSystemMessage(result);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
socket.addSystemMessage(`Failed to fetch history: ${msg}`);
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
socket.addSystemMessage(`Local command not implemented: /${parsed.command}`);
|
||||
}
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
const handleGatewayCommand = useCallback(
|
||||
(parsed: ParsedCommand) => {
|
||||
if (!socket.socketRef.current?.connected) {
|
||||
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
|
||||
return;
|
||||
}
|
||||
socket.socketRef.current.emit('command:execute', {
|
||||
conversationId: socket.conversationId ?? '',
|
||||
command: parsed.command,
|
||||
args: parsed.args ?? undefined,
|
||||
});
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
const handleSwitchConversation = useCallback(
|
||||
(id: string) => {
|
||||
socket.switchConversation(id);
|
||||
appMode.setMode('chat');
|
||||
},
|
||||
[socket, appMode],
|
||||
);
|
||||
|
||||
const handleDeleteConversation = useCallback(
|
||||
(id: string) => {
|
||||
void conversations
|
||||
.deleteConversation(id)
|
||||
.then((ok) => {
|
||||
if (ok && id === socket.conversationId) {
|
||||
socket.clearMessages();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
[conversations, socket],
|
||||
);
|
||||
|
||||
useInput((ch, key) => {
|
||||
// Ctrl+C: clear input → show hint → second empty press exits
|
||||
if (key.ctrl && ch === 'c') {
|
||||
if (tuiInput) {
|
||||
setTuiInput('');
|
||||
ctrlCPendingExit.current = false;
|
||||
} else if (ctrlCPendingExit.current) {
|
||||
exit();
|
||||
} else {
|
||||
ctrlCPendingExit.current = true;
|
||||
socket.addSystemMessage('Press Ctrl+C again to exit.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Any other key resets the pending-exit flag
|
||||
ctrlCPendingExit.current = false;
|
||||
// Ctrl+L: toggle sidebar (refresh on open)
|
||||
if (key.ctrl && ch === 'l') {
|
||||
ctrlJustFired.current = true;
|
||||
queueMicrotask(() => {
|
||||
ctrlJustFired.current = false;
|
||||
});
|
||||
const willOpen = !appMode.sidebarOpen;
|
||||
appMode.toggleSidebar();
|
||||
if (willOpen) {
|
||||
void conversations.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ctrl+N: create new conversation and switch to it
|
||||
if (key.ctrl && ch === 'n') {
|
||||
ctrlJustFired.current = true;
|
||||
queueMicrotask(() => {
|
||||
ctrlJustFired.current = false;
|
||||
});
|
||||
void conversations
|
||||
.createConversation()
|
||||
.then((conv) => {
|
||||
if (conv) {
|
||||
socket.switchConversation(conv.id);
|
||||
appMode.setMode('chat');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Ctrl+K: toggle search mode
|
||||
if (key.ctrl && ch === 'k') {
|
||||
ctrlJustFired.current = true;
|
||||
queueMicrotask(() => {
|
||||
ctrlJustFired.current = false;
|
||||
});
|
||||
if (appMode.mode === 'search') {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
} else {
|
||||
appMode.setMode('search');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Page Up / Page Down: scroll message history (only in chat mode)
|
||||
if (appMode.mode === 'chat') {
|
||||
if (key.pageUp) {
|
||||
viewport.scrollBy(-viewport.viewportSize);
|
||||
}
|
||||
if (key.pageDown) {
|
||||
viewport.scrollBy(viewport.viewportSize);
|
||||
}
|
||||
}
|
||||
// Ctrl+T: cycle thinking level
|
||||
if (key.ctrl && ch === 't') {
|
||||
ctrlJustFired.current = true;
|
||||
queueMicrotask(() => {
|
||||
ctrlJustFired.current = false;
|
||||
});
|
||||
const levels = socket.availableThinkingLevels;
|
||||
if (levels.length > 0) {
|
||||
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
||||
const nextIdx = (currentIdx + 1) % levels.length;
|
||||
const next = levels[nextIdx];
|
||||
if (next) {
|
||||
socket.setThinkingLevel(next);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
||||
if (key.escape) {
|
||||
if (appMode.mode === 'search') {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
} else if (appMode.mode === 'sidebar') {
|
||||
appMode.setMode('chat');
|
||||
} else if (appMode.mode === 'chat') {
|
||||
viewport.scrollToBottom();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const inputPlaceholder =
|
||||
appMode.mode === 'sidebar'
|
||||
? 'focus is on sidebar… press Esc to return'
|
||||
: appMode.mode === 'search'
|
||||
? 'search mode… press Esc to return'
|
||||
: undefined;
|
||||
|
||||
const isSearchMode = appMode.mode === 'search';
|
||||
|
||||
const messageArea = (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<MessageList
|
||||
messages={socket.messages}
|
||||
isStreaming={socket.isStreaming}
|
||||
currentStreamText={socket.currentStreamText}
|
||||
currentThinkingText={socket.currentThinkingText}
|
||||
activeToolCalls={socket.activeToolCalls}
|
||||
scrollOffset={viewport.scrollOffset}
|
||||
viewportSize={viewport.viewportSize}
|
||||
isScrolledUp={viewport.isScrolledUp}
|
||||
highlightedMessageIndices={highlightedMessageIndices}
|
||||
currentHighlightIndex={currentHighlightIndex}
|
||||
/>
|
||||
|
||||
{isSearchMode && (
|
||||
<SearchBar
|
||||
query={search.query}
|
||||
onQueryChange={search.setQuery}
|
||||
totalMatches={search.totalMatches}
|
||||
currentMatch={search.currentMatchIndex}
|
||||
onNext={search.nextMatch}
|
||||
onPrev={search.prevMatch}
|
||||
onClose={() => {
|
||||
search.clear();
|
||||
appMode.setMode('chat');
|
||||
}}
|
||||
focused={isSearchMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InputBar
|
||||
value={tuiInput}
|
||||
onChange={(val: string) => {
|
||||
// Suppress the character that ink-text-input leaks when a Ctrl+key
|
||||
// combo fires (e.g. Ctrl+T inserts 't'). The ctrlJustFired ref is
|
||||
// set synchronously in the useInput handler and cleared via a
|
||||
// microtask, so this callback sees it as still true on the same
|
||||
// event-loop tick.
|
||||
if (ctrlJustFired.current) {
|
||||
ctrlJustFired.current = false;
|
||||
return;
|
||||
}
|
||||
setTuiInput(val);
|
||||
}}
|
||||
onSubmit={sendMessageWithFileRefs}
|
||||
onSystemMessage={socket.addSystemMessage}
|
||||
onLocalCommand={handleLocalCommand}
|
||||
onGatewayCommand={handleGatewayCommand}
|
||||
isStreaming={socket.isStreaming}
|
||||
connected={socket.connected}
|
||||
focused={appMode.mode === 'chat'}
|
||||
placeholder={inputPlaceholder}
|
||||
allCommands={commandRegistry.getAll()}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height="100%">
|
||||
<Box marginTop={1} />
|
||||
<TopBar
|
||||
gatewayUrl={gatewayUrl}
|
||||
version={version}
|
||||
modelName={socket.modelName}
|
||||
thinkingLevel={socket.thinkingLevel}
|
||||
contextWindow={socket.tokenUsage.contextWindow}
|
||||
agentName={agentName ?? 'default'}
|
||||
connected={socket.connected}
|
||||
connecting={socket.connecting}
|
||||
/>
|
||||
|
||||
{appMode.sidebarOpen ? (
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
<Sidebar
|
||||
conversations={conversations.conversations}
|
||||
activeConversationId={socket.conversationId}
|
||||
selectedIndex={sidebarSelectedIndex}
|
||||
onSelectIndex={setSidebarSelectedIndex}
|
||||
onSwitchConversation={handleSwitchConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
loading={conversations.loading}
|
||||
focused={appMode.mode === 'sidebar'}
|
||||
width={30}
|
||||
/>
|
||||
{messageArea}
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexGrow={1}>{messageArea}</Box>
|
||||
)}
|
||||
|
||||
<BottomBar
|
||||
gitInfo={gitInfo}
|
||||
tokenUsage={socket.tokenUsage}
|
||||
connected={socket.connected}
|
||||
connecting={socket.connecting}
|
||||
modelName={socket.modelName}
|
||||
providerName={socket.providerName}
|
||||
thinkingLevel={socket.thinkingLevel}
|
||||
conversationId={socket.conversationId}
|
||||
routingDecision={socket.routingDecision}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
/**
|
||||
* Integration tests for TUI command parsing + registry (P8-019)
|
||||
*
|
||||
* Covers:
|
||||
* - parseSlashCommand() + commandRegistry.find() round-trip for all aliases
|
||||
* - /help, /stop, /cost, /status resolve to 'local' execution
|
||||
* - Unknown commands return null from find()
|
||||
* - Alias resolution: /h → help, /m → model, /n → new, etc.
|
||||
* - filterCommands prefix filtering
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { parseSlashCommand } from './parse.js';
|
||||
import { CommandRegistry } from './registry.js';
|
||||
import type { CommandDef } from '@mosaic/types';
|
||||
|
||||
// ─── Parse + Registry Round-trip ─────────────────────────────────────────────
|
||||
|
||||
describe('parseSlashCommand + CommandRegistry — integration', () => {
|
||||
let registry: CommandRegistry;
|
||||
|
||||
// Gateway-style commands to simulate a live manifest
|
||||
const gatewayCommands: CommandDef[] = [
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Switch the active model',
|
||||
aliases: ['m'],
|
||||
args: [{ name: 'model-name', type: 'string', optional: false, description: 'Model name' }],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'thinking',
|
||||
description: 'Set thinking level',
|
||||
aliases: ['t'],
|
||||
args: [
|
||||
{
|
||||
name: 'level',
|
||||
type: 'enum',
|
||||
optional: false,
|
||||
values: ['none', 'low', 'medium', 'high', 'auto'],
|
||||
description: 'Thinking level',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
description: 'Start a new conversation',
|
||||
aliases: ['n'],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'agent',
|
||||
description: 'Switch or list available agents',
|
||||
aliases: ['a'],
|
||||
args: [{ name: 'args', type: 'string', optional: true, description: 'list or <agent-id>' }],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'preferences',
|
||||
description: 'View or set user preferences',
|
||||
aliases: ['pref'],
|
||||
args: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'enum',
|
||||
optional: true,
|
||||
values: ['show', 'set', 'reset'],
|
||||
description: 'Action',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'gc',
|
||||
description: 'Trigger garbage collection sweep',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'mission',
|
||||
description: 'View or set active mission',
|
||||
aliases: [],
|
||||
args: [{ name: 'args', type: 'string', optional: true, description: 'status | set <id>' }],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new CommandRegistry();
|
||||
registry.updateManifest({ version: 1, commands: gatewayCommands, skills: [] });
|
||||
});
|
||||
|
||||
// ── parseSlashCommand tests ──
|
||||
|
||||
it('returns null for non-slash input', () => {
|
||||
expect(parseSlashCommand('hello world')).toBeNull();
|
||||
expect(parseSlashCommand('')).toBeNull();
|
||||
expect(parseSlashCommand('model')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses "/model claude-3-opus" → command=model args=claude-3-opus', () => {
|
||||
const parsed = parseSlashCommand('/model claude-3-opus');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.command).toBe('model');
|
||||
expect(parsed!.args).toBe('claude-3-opus');
|
||||
expect(parsed!.raw).toBe('/model claude-3-opus');
|
||||
});
|
||||
|
||||
it('parses "/gc" with no args → command=gc args=null', () => {
|
||||
const parsed = parseSlashCommand('/gc');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.command).toBe('gc');
|
||||
expect(parsed!.args).toBeNull();
|
||||
});
|
||||
|
||||
it('parses "/system you are a helpful assistant" → args contains full text', () => {
|
||||
const parsed = parseSlashCommand('/system you are a helpful assistant');
|
||||
expect(parsed!.command).toBe('system');
|
||||
expect(parsed!.args).toBe('you are a helpful assistant');
|
||||
});
|
||||
|
||||
it('parses "/help" → command=help args=null', () => {
|
||||
const parsed = parseSlashCommand('/help');
|
||||
expect(parsed!.command).toBe('help');
|
||||
expect(parsed!.args).toBeNull();
|
||||
});
|
||||
|
||||
// ── Round-trip: parse then find ──
|
||||
|
||||
it('round-trip: /m → resolves to "model" command via alias', () => {
|
||||
const parsed = parseSlashCommand('/m claude-3-haiku');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
// /m → model (alias map in registry)
|
||||
expect(cmd!.name === 'model' || cmd!.aliases.includes('m')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /h → resolves to "help" (local command)', () => {
|
||||
const parsed = parseSlashCommand('/h');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'help' || cmd!.aliases.includes('h')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /n → resolves to "new" via gateway manifest', () => {
|
||||
const parsed = parseSlashCommand('/n');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'new' || cmd!.aliases.includes('n')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /a → resolves to "agent" via gateway manifest', () => {
|
||||
const parsed = parseSlashCommand('/a list');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'agent' || cmd!.aliases.includes('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /pref → resolves to "preferences" via alias', () => {
|
||||
const parsed = parseSlashCommand('/pref show');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'preferences' || cmd!.aliases.includes('pref')).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trip: /t → resolves to "thinking" via alias', () => {
|
||||
const parsed = parseSlashCommand('/t high');
|
||||
expect(parsed).not.toBeNull();
|
||||
const cmd = registry.find(parsed!.command);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.name === 'thinking' || cmd!.aliases.includes('t')).toBe(true);
|
||||
});
|
||||
|
||||
// ── Local commands resolve to 'local' execution ──
|
||||
|
||||
it('/help resolves to local execution', () => {
|
||||
const cmd = registry.find('help');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.execution).toBe('local');
|
||||
});
|
||||
|
||||
it('/stop resolves to local execution', () => {
|
||||
const cmd = registry.find('stop');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.execution).toBe('local');
|
||||
});
|
||||
|
||||
it('/cost resolves to local execution', () => {
|
||||
const cmd = registry.find('cost');
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd!.execution).toBe('local');
|
||||
});
|
||||
|
||||
it('/status resolves to local execution (TUI local override)', () => {
|
||||
const cmd = registry.find('status');
|
||||
expect(cmd).not.toBeNull();
|
||||
// status is 'local' in the TUI registry (local takes precedence over gateway)
|
||||
expect(cmd!.execution).toBe('local');
|
||||
});
|
||||
|
||||
// ── Unknown commands return null ──
|
||||
|
||||
it('find() returns null for unknown command', () => {
|
||||
expect(registry.find('nonexistent')).toBeNull();
|
||||
expect(registry.find('xyz')).toBeNull();
|
||||
expect(registry.find('')).toBeNull();
|
||||
});
|
||||
|
||||
it('find() returns null when no gateway manifest and command not local', () => {
|
||||
const emptyRegistry = new CommandRegistry();
|
||||
expect(emptyRegistry.find('model')).toBeNull();
|
||||
expect(emptyRegistry.find('gc')).toBeNull();
|
||||
});
|
||||
|
||||
// ── getAll returns combined local + gateway ──
|
||||
|
||||
it('getAll() includes both local and gateway commands', () => {
|
||||
const all = registry.getAll();
|
||||
const names = all.map((c) => c.name);
|
||||
// Local commands
|
||||
expect(names).toContain('help');
|
||||
expect(names).toContain('stop');
|
||||
expect(names).toContain('cost');
|
||||
expect(names).toContain('status');
|
||||
// Gateway commands
|
||||
expect(names).toContain('model');
|
||||
expect(names).toContain('gc');
|
||||
});
|
||||
|
||||
it('getLocalCommands() returns only local commands', () => {
|
||||
const local = registry.getLocalCommands();
|
||||
expect(local.every((c) => c.execution === 'local')).toBe(true);
|
||||
expect(local.some((c) => c.name === 'help')).toBe(true);
|
||||
expect(local.some((c) => c.name === 'stop')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── filterCommands (autocomplete) ────────────────────────────────────────────
|
||||
|
||||
describe('filterCommands (from CommandAutocomplete)', () => {
|
||||
// Import inline since filterCommands is not exported — replicate the logic here
|
||||
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
|
||||
if (!query) return commands;
|
||||
const q = query.toLowerCase();
|
||||
return commands.filter(
|
||||
(c) =>
|
||||
c.name.includes(q) ||
|
||||
c.aliases.some((a) => a.includes(q)) ||
|
||||
c.description.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
const commands: CommandDef[] = [
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Switch the active model',
|
||||
aliases: ['m'],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'mission',
|
||||
description: 'View or set active mission',
|
||||
aliases: [],
|
||||
scope: 'agent',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show available commands',
|
||||
aliases: ['h'],
|
||||
scope: 'core',
|
||||
execution: 'local',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'gc',
|
||||
description: 'Trigger garbage collection sweep',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
it('returns all commands when query is empty', () => {
|
||||
expect(filterCommands(commands, '')).toHaveLength(commands.length);
|
||||
});
|
||||
|
||||
it('filters by name prefix "mi" → mission only (not model, as "mi" not in model name or aliases)', () => {
|
||||
const result = filterCommands(commands, 'mi');
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('mission');
|
||||
expect(names).not.toContain('gc');
|
||||
});
|
||||
|
||||
it('filters by name prefix "mo" → model only', () => {
|
||||
const result = filterCommands(commands, 'mo');
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('model');
|
||||
expect(names).not.toContain('mission');
|
||||
expect(names).not.toContain('gc');
|
||||
});
|
||||
|
||||
it('filters by exact name "gc" → gc only', () => {
|
||||
const result = filterCommands(commands, 'gc');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.name).toBe('gc');
|
||||
});
|
||||
|
||||
it('filters by alias "h" → help', () => {
|
||||
const result = filterCommands(commands, 'h');
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('help');
|
||||
});
|
||||
|
||||
it('filters by description keyword "switch" → model', () => {
|
||||
const result = filterCommands(commands, 'switch');
|
||||
const names = result.map((c) => c.name);
|
||||
expect(names).toContain('model');
|
||||
});
|
||||
|
||||
it('returns empty array when no commands match', () => {
|
||||
const result = filterCommands(commands, 'zzznotfound');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
export { parseSlashCommand } from './parse.js';
|
||||
export { commandRegistry, CommandRegistry } from './registry.js';
|
||||
export { executeHelp } from './local/help.js';
|
||||
export { executeStatus } from './local/status.js';
|
||||
export type { StatusContext } from './local/status.js';
|
||||
export { executeHistory } from './local/history.js';
|
||||
export type { HistoryContext } from './local/history.js';
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
import { commandRegistry } from '../registry.js';
|
||||
|
||||
export function executeHelp(_parsed: ParsedCommand): string {
|
||||
const commands = commandRegistry.getAll();
|
||||
const lines = ['Available commands:', ''];
|
||||
|
||||
for (const cmd of commands) {
|
||||
const aliases =
|
||||
cmd.aliases.length > 0 ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : '';
|
||||
const argsStr =
|
||||
cmd.args && cmd.args.length > 0
|
||||
? ' ' + cmd.args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ')
|
||||
: '';
|
||||
lines.push(` /${cmd.name}${argsStr}${aliases} — ${cmd.description}`);
|
||||
}
|
||||
|
||||
return lines.join('\n').trimEnd();
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { ConversationMessage } from '../../gateway-api.js';
|
||||
|
||||
const CONTEXT_WINDOW = 200_000;
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
|
||||
function estimateTokens(messages: ConversationMessage[]): number {
|
||||
const totalChars = messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
|
||||
return Math.round(totalChars / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
export interface HistoryContext {
|
||||
conversationId: string | undefined;
|
||||
conversationTitle?: string | null;
|
||||
gatewayUrl: string;
|
||||
sessionCookie: string | undefined;
|
||||
fetchMessages: (
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
conversationId: string,
|
||||
) => Promise<ConversationMessage[]>;
|
||||
}
|
||||
|
||||
export async function executeHistory(ctx: HistoryContext): Promise<string> {
|
||||
const { conversationId, conversationTitle, gatewayUrl, sessionCookie, fetchMessages } = ctx;
|
||||
|
||||
if (!conversationId) {
|
||||
return 'No active conversation.';
|
||||
}
|
||||
|
||||
if (!sessionCookie) {
|
||||
return 'Not authenticated — cannot fetch conversation messages.';
|
||||
}
|
||||
|
||||
const messages = await fetchMessages(gatewayUrl, sessionCookie, conversationId);
|
||||
|
||||
const userMessages = messages.filter((m) => m.role === 'user').length;
|
||||
const assistantMessages = messages.filter((m) => m.role === 'assistant').length;
|
||||
const totalMessages = messages.length;
|
||||
|
||||
const estimatedTokens = estimateTokens(messages);
|
||||
const contextPercent = Math.round((estimatedTokens / CONTEXT_WINDOW) * 100);
|
||||
|
||||
const label = conversationTitle ?? conversationId;
|
||||
|
||||
const lines = [
|
||||
`Conversation: ${label}`,
|
||||
`Messages: ${totalMessages} (${userMessages} user, ${assistantMessages} assistant)`,
|
||||
`Estimated tokens: ~${estimatedTokens.toLocaleString()}`,
|
||||
`Context usage: ~${contextPercent}% of ${(CONTEXT_WINDOW / 1000).toFixed(0)}K`,
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
|
||||
export interface StatusContext {
|
||||
connected: boolean;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
sessionId: string | null;
|
||||
tokenCount: number;
|
||||
}
|
||||
|
||||
export function executeStatus(_parsed: ParsedCommand, ctx: StatusContext): string {
|
||||
const lines = [
|
||||
`Connection: ${ctx.connected ? 'connected' : 'disconnected'}`,
|
||||
`Model: ${ctx.model ?? 'unknown'}`,
|
||||
`Provider: ${ctx.provider ?? 'unknown'}`,
|
||||
`Session: ${ctx.sessionId ?? 'none'}`,
|
||||
`Tokens (session): ${ctx.tokenCount}`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { ParsedCommand } from '@mosaic/types';
|
||||
|
||||
export function parseSlashCommand(input: string): ParsedCommand | null {
|
||||
const match = input.match(/^\/([a-z][a-z0-9:_-]*)\s*(.*)?$/i);
|
||||
if (!match) return null;
|
||||
return {
|
||||
command: match[1]!,
|
||||
args: match[2]?.trim() || null,
|
||||
raw: input,
|
||||
};
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { CommandDef, CommandManifest } from '@mosaic/types';
|
||||
|
||||
// Local-only commands (work even when gateway is disconnected)
|
||||
const LOCAL_COMMANDS: CommandDef[] = [
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show available commands',
|
||||
aliases: ['h'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
description: 'Cancel current streaming response',
|
||||
aliases: [],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'cost',
|
||||
description: 'Show token usage and cost for current session',
|
||||
aliases: [],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
description: 'Show connection and session status',
|
||||
aliases: ['s'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
description: 'Show conversation message count and context usage',
|
||||
aliases: ['hist'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
description: 'Clear the current conversation display',
|
||||
aliases: [],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'attach',
|
||||
description: 'Attach a file to the next message (@file syntax also works inline)',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'path',
|
||||
type: 'string' as const,
|
||||
optional: false,
|
||||
description: 'File path to attach',
|
||||
},
|
||||
],
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
description: 'Start a new conversation',
|
||||
aliases: ['n'],
|
||||
args: undefined,
|
||||
execution: 'local',
|
||||
available: true,
|
||||
scope: 'core',
|
||||
},
|
||||
];
|
||||
|
||||
const ALIASES: Record<string, string> = {
|
||||
m: 'model',
|
||||
t: 'thinking',
|
||||
a: 'agent',
|
||||
s: 'status',
|
||||
h: 'help',
|
||||
hist: 'history',
|
||||
pref: 'preferences',
|
||||
};
|
||||
|
||||
export class CommandRegistry {
|
||||
private gatewayManifest: CommandManifest | null = null;
|
||||
|
||||
updateManifest(manifest: CommandManifest): void {
|
||||
this.gatewayManifest = manifest;
|
||||
}
|
||||
|
||||
resolveAlias(command: string): string {
|
||||
return ALIASES[command] ?? command;
|
||||
}
|
||||
|
||||
find(command: string): CommandDef | null {
|
||||
const resolved = this.resolveAlias(command);
|
||||
// Search local first, then gateway manifest
|
||||
const local = LOCAL_COMMANDS.find((c) => c.name === resolved || c.aliases.includes(resolved));
|
||||
if (local) return local;
|
||||
if (this.gatewayManifest) {
|
||||
return (
|
||||
this.gatewayManifest.commands.find(
|
||||
(c) => c.name === resolved || c.aliases.includes(resolved),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getAll(): CommandDef[] {
|
||||
const gateway = this.gatewayManifest?.commands ?? [];
|
||||
// Local commands take precedence; deduplicate gateway commands that share
|
||||
// a name with a local command to avoid duplicate React keys and confusing
|
||||
// autocomplete entries.
|
||||
const localNames = new Set(LOCAL_COMMANDS.map((c) => c.name));
|
||||
const dedupedGateway = gateway.filter((c) => !localNames.has(c.name));
|
||||
return [...LOCAL_COMMANDS, ...dedupedGateway];
|
||||
}
|
||||
|
||||
getLocalCommands(): CommandDef[] {
|
||||
return LOCAL_COMMANDS;
|
||||
}
|
||||
}
|
||||
|
||||
export const commandRegistry = new CommandRegistry();
|
||||
@@ -1,138 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { RoutingDecisionInfo } from '@mosaic/types';
|
||||
import type { TokenUsage } from '../hooks/use-socket.js';
|
||||
import type { GitInfo } from '../hooks/use-git-info.js';
|
||||
|
||||
export interface BottomBarProps {
|
||||
gitInfo: GitInfo;
|
||||
tokenUsage: TokenUsage;
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
modelName: string | null;
|
||||
providerName: string | null;
|
||||
thinkingLevel: string;
|
||||
conversationId: string | undefined;
|
||||
/** Routing decision info for transparency display (M4-008) */
|
||||
routingDecision?: RoutingDecisionInfo | null;
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Compact the cwd — replace home with ~ */
|
||||
function compactCwd(cwd: string): string {
|
||||
const home = process.env['HOME'] ?? '';
|
||||
if (home && cwd.startsWith(home)) {
|
||||
return '~' + cwd.slice(home.length);
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export function BottomBar({
|
||||
gitInfo,
|
||||
tokenUsage,
|
||||
connected,
|
||||
connecting,
|
||||
modelName,
|
||||
providerName,
|
||||
thinkingLevel,
|
||||
conversationId,
|
||||
routingDecision,
|
||||
}: BottomBarProps) {
|
||||
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
|
||||
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||
|
||||
const hasTokens = tokenUsage.total > 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={0} marginTop={0}>
|
||||
{/* Line 0: keybinding hints */}
|
||||
<Box>
|
||||
<Text dimColor>^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll</Text>
|
||||
</Box>
|
||||
|
||||
{/* Line 1: blank ····· Gateway: Status */}
|
||||
<Box justifyContent="space-between">
|
||||
<Box />
|
||||
<Box>
|
||||
<Text dimColor>Gateway: </Text>
|
||||
<Text color={gatewayColor}>{gatewayStatus}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 2: cwd (branch) ····· Session: id */}
|
||||
<Box justifyContent="space-between">
|
||||
<Box>
|
||||
<Text dimColor>{compactCwd(gitInfo.cwd)}</Text>
|
||||
{gitInfo.branch && <Text dimColor> ({gitInfo.branch})</Text>}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{conversationId ? `Session: ${conversationId.slice(0, 8)}` : 'No session'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 3: token stats ····· (provider) model */}
|
||||
<Box justifyContent="space-between" minHeight={1}>
|
||||
<Box>
|
||||
{hasTokens ? (
|
||||
<>
|
||||
<Text dimColor>↑{formatTokens(tokenUsage.input)}</Text>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>↓{formatTokens(tokenUsage.output)}</Text>
|
||||
{tokenUsage.cacheRead > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>R{formatTokens(tokenUsage.cacheRead)}</Text>
|
||||
</>
|
||||
)}
|
||||
{tokenUsage.cacheWrite > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>W{formatTokens(tokenUsage.cacheWrite)}</Text>
|
||||
</>
|
||||
)}
|
||||
{tokenUsage.cost > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>${tokenUsage.cost.toFixed(3)}</Text>
|
||||
</>
|
||||
)}
|
||||
{tokenUsage.contextPercent > 0 && (
|
||||
<>
|
||||
<Text dimColor>{' '}</Text>
|
||||
<Text dimColor>
|
||||
{tokenUsage.contextPercent.toFixed(1)}%/{formatTokens(tokenUsage.contextWindow)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text dimColor>↑0 ↓0 $0.000</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{providerName ? `(${providerName}) ` : ''}
|
||||
{modelName ?? 'awaiting model'}
|
||||
{thinkingLevel !== 'off' ? ` • ${thinkingLevel}` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 4: routing transparency (M4-008) — only shown when a routing decision is available */}
|
||||
{routingDecision && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Routed: {routingDecision.model} ({routingDecision.reason})
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { CommandDef, CommandArgDef } from '@mosaic/types';
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
commands: CommandDef[];
|
||||
selectedIndex: number;
|
||||
inputValue: string; // the current input after '/'
|
||||
}
|
||||
|
||||
export function CommandAutocomplete({
|
||||
commands,
|
||||
selectedIndex,
|
||||
inputValue,
|
||||
}: CommandAutocompleteProps) {
|
||||
if (commands.length === 0) return null;
|
||||
|
||||
// Filter by inputValue prefix/fuzzy match
|
||||
const query = inputValue.startsWith('/') ? inputValue.slice(1) : inputValue;
|
||||
const filtered = filterCommands(commands, query);
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
const clampedIndex = Math.min(selectedIndex, filtered.length - 1);
|
||||
const selected = filtered[clampedIndex];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||
{filtered.slice(0, 8).map((cmd, i) => (
|
||||
<Box key={`${cmd.execution}-${cmd.name}`}>
|
||||
<Text color={i === clampedIndex ? 'cyan' : 'white'} bold={i === clampedIndex}>
|
||||
{i === clampedIndex ? '▶ ' : ' '}/{cmd.name}
|
||||
</Text>
|
||||
{cmd.aliases.length > 0 && (
|
||||
<Text color="gray"> ({cmd.aliases.map((a) => `/${a}`).join(', ')})</Text>
|
||||
)}
|
||||
<Text color="gray"> — {cmd.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{selected && selected.args && selected.args.length > 0 && (
|
||||
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text color="yellow">
|
||||
/{selected.name} {getArgHint(selected.args)}
|
||||
</Text>
|
||||
<Text color="gray"> — {selected.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
|
||||
if (!query) return commands;
|
||||
const q = query.toLowerCase();
|
||||
return commands.filter(
|
||||
(c) =>
|
||||
c.name.includes(q) ||
|
||||
c.aliases.some((a) => a.includes(q)) ||
|
||||
c.description.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
function getArgHint(args: CommandArgDef[]): string {
|
||||
if (!args || args.length === 0) return '';
|
||||
return args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ');
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import type { ParsedCommand, CommandDef } from '@mosaic/types';
|
||||
import { parseSlashCommand, commandRegistry } from '../commands/index.js';
|
||||
import { CommandAutocomplete } from './command-autocomplete.js';
|
||||
import { useInputHistory } from '../hooks/use-input-history.js';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface InputBarProps {
|
||||
/** Controlled input value — caller owns the state */
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onSystemMessage?: (message: string) => void;
|
||||
onLocalCommand?: (parsed: ParsedCommand) => void;
|
||||
onGatewayCommand?: (parsed: ParsedCommand) => void;
|
||||
isStreaming: boolean;
|
||||
connected: boolean;
|
||||
/** Whether this input bar is focused/active (default true). When false,
|
||||
* keyboard input is not captured — e.g. when the sidebar has focus. */
|
||||
focused?: boolean;
|
||||
placeholder?: string;
|
||||
allCommands?: CommandDef[];
|
||||
}
|
||||
|
||||
export function InputBar({
|
||||
value: input,
|
||||
onChange: setInput,
|
||||
onSubmit,
|
||||
onSystemMessage,
|
||||
onLocalCommand,
|
||||
onGatewayCommand,
|
||||
isStreaming,
|
||||
connected,
|
||||
focused = true,
|
||||
placeholder: placeholderOverride,
|
||||
allCommands,
|
||||
}: InputBarProps) {
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
|
||||
|
||||
const { addToHistory, navigateUp, navigateDown } = useInputHistory();
|
||||
|
||||
// Determine which commands to show in autocomplete
|
||||
const availableCommands = allCommands ?? commandRegistry.getAll();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
setInput(value);
|
||||
if (value.startsWith('/')) {
|
||||
setShowAutocomplete(true);
|
||||
setAutocompleteIndex(0);
|
||||
} else {
|
||||
setShowAutocomplete(false);
|
||||
}
|
||||
},
|
||||
[setInput],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (!value.trim() || isStreaming || !connected) return;
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
addToHistory(trimmed);
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
const parsed = parseSlashCommand(trimmed);
|
||||
if (!parsed) {
|
||||
// Bare "/" or malformed — ignore silently (autocomplete handles discovery)
|
||||
return;
|
||||
}
|
||||
const def = commandRegistry.find(parsed.command);
|
||||
if (!def) {
|
||||
onSystemMessage?.(
|
||||
`Unknown command: /${parsed.command}. Type /help for available commands.`,
|
||||
);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
if (def.execution === 'local') {
|
||||
onLocalCommand?.(parsed);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
// Gateway-executed commands
|
||||
onGatewayCommand?.(parsed);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(value);
|
||||
setInput('');
|
||||
},
|
||||
[
|
||||
onSubmit,
|
||||
onSystemMessage,
|
||||
onLocalCommand,
|
||||
onGatewayCommand,
|
||||
isStreaming,
|
||||
connected,
|
||||
addToHistory,
|
||||
setInput,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle Tab: fill in selected autocomplete command
|
||||
const fillAutocompleteSelection = useCallback(() => {
|
||||
if (!showAutocomplete) return false;
|
||||
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||
const filtered = availableCommands.filter(
|
||||
(c) =>
|
||||
!query ||
|
||||
c.name.includes(query.toLowerCase()) ||
|
||||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
if (filtered.length === 0) return false;
|
||||
const idx = Math.min(autocompleteIndex, filtered.length - 1);
|
||||
const selected = filtered[idx];
|
||||
if (selected) {
|
||||
setInput(`/${selected.name} `);
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
|
||||
|
||||
useInput(
|
||||
(_ch, key) => {
|
||||
if (key.escape && showAutocomplete) {
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab: fill autocomplete selection
|
||||
if (key.tab) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Up arrow
|
||||
if (key.upArrow) {
|
||||
if (showAutocomplete) {
|
||||
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
|
||||
} else {
|
||||
const prev = navigateUp(input);
|
||||
if (prev !== null) {
|
||||
setInput(prev);
|
||||
if (prev.startsWith('/')) setShowAutocomplete(true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Down arrow
|
||||
if (key.downArrow) {
|
||||
if (showAutocomplete) {
|
||||
const query = input.startsWith('/') ? input.slice(1) : input;
|
||||
const filteredLen = availableCommands.filter(
|
||||
(c) =>
|
||||
!query ||
|
||||
c.name.includes(query.toLowerCase()) ||
|
||||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
||||
c.description.toLowerCase().includes(query.toLowerCase()),
|
||||
).length;
|
||||
const maxVisible = Math.min(filteredLen, 8);
|
||||
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
|
||||
} else {
|
||||
const next = navigateDown();
|
||||
if (next !== null) {
|
||||
setInput(next);
|
||||
setShowAutocomplete(next.startsWith('/'));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Return/Enter on autocomplete: fill selected command
|
||||
if (key.return && showAutocomplete) {
|
||||
fillAutocompleteSelection();
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const placeholder =
|
||||
placeholderOverride ??
|
||||
(!connected
|
||||
? 'disconnected — waiting for gateway…'
|
||||
: isStreaming
|
||||
? 'waiting for response…'
|
||||
: 'message mosaic…');
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showAutocomplete && (
|
||||
<CommandAutocomplete
|
||||
commands={availableCommands}
|
||||
selectedIndex={autocompleteIndex}
|
||||
inputValue={input}
|
||||
/>
|
||||
)}
|
||||
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
||||
<Text bold color="green">
|
||||
{'❯ '}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
focus={focused}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import type { Message, ToolCall } from '../hooks/use-socket.js';
|
||||
|
||||
export interface MessageListProps {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
currentStreamText: string;
|
||||
currentThinkingText: string;
|
||||
activeToolCalls: ToolCall[];
|
||||
scrollOffset?: number;
|
||||
viewportSize?: number;
|
||||
isScrolledUp?: boolean;
|
||||
highlightedMessageIndices?: Set<number>;
|
||||
currentHighlightIndex?: number;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function SystemMessageBubble({ msg }: { msg: Message }) {
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1} marginLeft={2}>
|
||||
<Text dimColor>{'⚙ '}</Text>
|
||||
<Text dimColor wrap="wrap">
|
||||
{msg.content}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
msg,
|
||||
highlight,
|
||||
}: {
|
||||
msg: Message;
|
||||
highlight?: 'match' | 'current' | undefined;
|
||||
}) {
|
||||
if (msg.role === 'system') {
|
||||
return <SystemMessageBubble msg={msg} />;
|
||||
}
|
||||
|
||||
const isUser = msg.role === 'user';
|
||||
const prefix = isUser ? '❯' : '◆';
|
||||
const color = isUser ? 'green' : 'cyan';
|
||||
|
||||
const borderIndicator =
|
||||
highlight === 'current' ? (
|
||||
<Text color="yellowBright" bold>
|
||||
▌{' '}
|
||||
</Text>
|
||||
) : highlight === 'match' ? (
|
||||
<Text color="yellow">▌ </Text>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{borderIndicator}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={color}>
|
||||
{prefix}{' '}
|
||||
</Text>
|
||||
<Text bold color={color}>
|
||||
{isUser ? 'you' : 'assistant'}
|
||||
</Text>
|
||||
<Text dimColor> {formatTime(msg.timestamp)}</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text wrap="wrap">{msg.content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallIndicator({ toolCall }: { toolCall: ToolCall }) {
|
||||
const icon = toolCall.status === 'running' ? null : toolCall.status === 'success' ? '✓' : '✗';
|
||||
const color =
|
||||
toolCall.status === 'running' ? 'yellow' : toolCall.status === 'success' ? 'green' : 'red';
|
||||
|
||||
return (
|
||||
<Box marginLeft={2}>
|
||||
{toolCall.status === 'running' ? (
|
||||
<Text color="yellow">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={color}>{icon}</Text>
|
||||
)}
|
||||
<Text dimColor> tool: </Text>
|
||||
<Text color={color}>{toolCall.toolName}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
currentStreamText,
|
||||
currentThinkingText,
|
||||
activeToolCalls,
|
||||
scrollOffset,
|
||||
viewportSize,
|
||||
isScrolledUp,
|
||||
highlightedMessageIndices,
|
||||
currentHighlightIndex,
|
||||
}: MessageListProps) {
|
||||
const useSlicing = scrollOffset != null && viewportSize != null;
|
||||
const visibleMessages = useSlicing
|
||||
? messages.slice(scrollOffset, scrollOffset + viewportSize)
|
||||
: messages;
|
||||
const hiddenAbove = useSlicing ? scrollOffset : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
||||
{isScrolledUp && hiddenAbove > 0 && (
|
||||
<Box justifyContent="center">
|
||||
<Text dimColor>↑ {hiddenAbove} more messages ↑</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<Box justifyContent="center" marginY={1}>
|
||||
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{visibleMessages.map((msg, i) => {
|
||||
const globalIndex = hiddenAbove + i;
|
||||
const highlight =
|
||||
globalIndex === currentHighlightIndex
|
||||
? ('current' as const)
|
||||
: highlightedMessageIndices?.has(globalIndex)
|
||||
? ('match' as const)
|
||||
: undefined;
|
||||
return <MessageBubble key={globalIndex} msg={msg} highlight={highlight} />;
|
||||
})}
|
||||
|
||||
{/* Active thinking */}
|
||||
{isStreaming && currentThinkingText && (
|
||||
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
|
||||
<Text dimColor italic>
|
||||
💭 {currentThinkingText}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Active tool calls */}
|
||||
{activeToolCalls.length > 0 && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{activeToolCalls.map((tc) => (
|
||||
<ToolCallIndicator key={tc.toolCallId} toolCall={tc} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Streaming response */}
|
||||
{isStreaming && currentStreamText && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
◆{' '}
|
||||
</Text>
|
||||
<Text bold color="cyan">
|
||||
assistant
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text wrap="wrap">{currentStreamText}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Waiting spinner */}
|
||||
{isStreaming && !currentStreamText && activeToolCalls.length === 0 && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text dimColor> thinking…</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
export interface SearchBarProps {
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
totalMatches: number;
|
||||
currentMatch: number;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onClose: () => void;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
query,
|
||||
onQueryChange,
|
||||
totalMatches,
|
||||
currentMatch,
|
||||
onNext,
|
||||
onPrev,
|
||||
onClose,
|
||||
focused,
|
||||
}: SearchBarProps) {
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) {
|
||||
onPrev();
|
||||
}
|
||||
if (key.downArrow) {
|
||||
onNext();
|
||||
}
|
||||
if (key.escape) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const borderColor = focused ? 'yellow' : 'gray';
|
||||
|
||||
const matchDisplay =
|
||||
query.length >= 2
|
||||
? totalMatches > 0
|
||||
? `${String(currentMatch + 1)}/${String(totalMatches)}`
|
||||
: 'no matches'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="row" gap={1}>
|
||||
<Text>🔍</Text>
|
||||
<Box flexGrow={1}>
|
||||
<TextInput value={query} onChange={onQueryChange} focus={focused} />
|
||||
</Box>
|
||||
{matchDisplay && <Text dimColor>{matchDisplay}</Text>}
|
||||
<Text dimColor>↑↓ navigate · Esc close</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import type { ConversationSummary } from '../hooks/use-conversations.js';
|
||||
|
||||
export interface SidebarProps {
|
||||
conversations: ConversationSummary[];
|
||||
activeConversationId: string | undefined;
|
||||
selectedIndex: number;
|
||||
onSelectIndex: (index: number) => void;
|
||||
onSwitchConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
loading: boolean;
|
||||
focused: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
const mon = months[date.getMonth()];
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${mon} ${dd}`;
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen - 1) + '…';
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
conversations,
|
||||
activeConversationId,
|
||||
selectedIndex,
|
||||
onSelectIndex,
|
||||
onSwitchConversation,
|
||||
onDeleteConversation,
|
||||
loading,
|
||||
focused,
|
||||
width,
|
||||
}: SidebarProps) {
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) {
|
||||
onSelectIndex(Math.max(0, selectedIndex - 1));
|
||||
}
|
||||
if (key.downArrow) {
|
||||
onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
|
||||
}
|
||||
if (key.return) {
|
||||
const conv = conversations[selectedIndex];
|
||||
if (conv) {
|
||||
onSwitchConversation(conv.id);
|
||||
}
|
||||
}
|
||||
if (_input === 'd') {
|
||||
const conv = conversations[selectedIndex];
|
||||
if (conv) {
|
||||
onDeleteConversation(conv.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
const borderColor = focused ? 'cyan' : 'gray';
|
||||
// Available width for content inside border + padding
|
||||
const innerWidth = width - 4; // 2 border + 2 padding
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
borderStyle="single"
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Conversations
|
||||
</Text>
|
||||
<Box marginTop={0} flexDirection="column" flexGrow={1}>
|
||||
{loading && conversations.length === 0 ? (
|
||||
<Text dimColor>Loading…</Text>
|
||||
) : conversations.length === 0 ? (
|
||||
<Text dimColor>No conversations</Text>
|
||||
) : (
|
||||
conversations.map((conv, idx) => {
|
||||
const isActive = conv.id === activeConversationId;
|
||||
const isSelected = idx === selectedIndex && focused;
|
||||
const marker = isActive ? '● ' : ' ';
|
||||
const time = formatRelativeTime(conv.updatedAt);
|
||||
const title = conv.title ?? 'Untitled';
|
||||
// marker(2) + title + space(1) + time
|
||||
const maxTitleLen = Math.max(4, innerWidth - marker.length - time.length - 1);
|
||||
const displayTitle = truncate(title, maxTitleLen);
|
||||
|
||||
return (
|
||||
<Box key={conv.id}>
|
||||
<Text
|
||||
inverse={isSelected}
|
||||
color={isActive ? 'cyan' : undefined}
|
||||
dimColor={!isActive && !isSelected}
|
||||
>
|
||||
{marker}
|
||||
{displayTitle}
|
||||
{' '.repeat(
|
||||
Math.max(0, innerWidth - marker.length - displayTitle.length - time.length),
|
||||
)}
|
||||
{time}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
{focused && <Text dimColor>↑↓ navigate • enter switch • d delete</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
export interface TopBarProps {
|
||||
gatewayUrl: string;
|
||||
version: string;
|
||||
modelName: string | null;
|
||||
thinkingLevel: string;
|
||||
contextWindow: number;
|
||||
agentName: string;
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
}
|
||||
|
||||
/** Compact the URL — strip protocol */
|
||||
function compactHost(url: string): string {
|
||||
return url.replace(/^https?:\/\//, '');
|
||||
}
|
||||
|
||||
function formatContextWindow(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mosaic 3×3 icon — brand tiles with black gaps (windmill cross pattern)
|
||||
*
|
||||
* Layout:
|
||||
* blue ·· purple
|
||||
* ·· pink ··
|
||||
* amber ·· teal
|
||||
*/
|
||||
// Two-space gap between tiles (extracted to avoid prettier collapse)
|
||||
const GAP = ' ';
|
||||
|
||||
function MosaicIcon() {
|
||||
return (
|
||||
<Box flexDirection="column" marginRight={2}>
|
||||
<Text>
|
||||
<Text color="#2f80ff">██</Text>
|
||||
<Text>{GAP}</Text>
|
||||
<Text color="#8b5cf6">██</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text>{GAP}</Text>
|
||||
<Text color="#ec4899">██</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="#f59e0b">██</Text>
|
||||
<Text>{GAP}</Text>
|
||||
<Text color="#14b8a6">██</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
gatewayUrl,
|
||||
version,
|
||||
modelName,
|
||||
thinkingLevel,
|
||||
contextWindow,
|
||||
agentName,
|
||||
connected,
|
||||
connecting,
|
||||
}: TopBarProps) {
|
||||
const host = compactHost(gatewayUrl);
|
||||
const connectionIndicator = connected ? '●' : '○';
|
||||
const connectionColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||
|
||||
// Build model description line like: "claude-opus-4-6 (1M context) · default"
|
||||
const modelDisplay = modelName ?? 'awaiting model';
|
||||
const contextStr = contextWindow > 0 ? ` (${formatContextWindow(contextWindow)} context)` : '';
|
||||
const thinkingStr = thinkingLevel !== 'off' ? ` · ${thinkingLevel}` : '';
|
||||
|
||||
return (
|
||||
<Box paddingX={1} paddingY={0} marginBottom={1}>
|
||||
<MosaicIcon />
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Text>
|
||||
<Text bold color="#56a0ff">
|
||||
Mosaic Stack
|
||||
</Text>
|
||||
<Text dimColor> v{version}</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{modelDisplay}
|
||||
{contextStr}
|
||||
{thinkingStr} · {agentName}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color={connectionColor}>{connectionIndicator}</Text>
|
||||
<Text dimColor> {host}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* File reference expansion for TUI chat input.
|
||||
*
|
||||
* Detects @path/to/file patterns in user messages, reads the file contents,
|
||||
* and inlines them as fenced code blocks in the message.
|
||||
*
|
||||
* Supports:
|
||||
* - @relative/path.ts
|
||||
* - @./relative/path.ts
|
||||
* - @/absolute/path.ts
|
||||
* - @~/home-relative/path.ts
|
||||
*
|
||||
* Also provides an /attach <path> command handler.
|
||||
*/
|
||||
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { resolve, extname, basename } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const MAX_FILE_SIZE = 256 * 1024; // 256 KB
|
||||
const MAX_FILES_PER_MESSAGE = 10;
|
||||
|
||||
/**
|
||||
* Regex to detect @file references in user input.
|
||||
* Matches @<path> where path starts with /, ./, ~/, or a word char,
|
||||
* and continues until whitespace or end of string.
|
||||
* Excludes @mentions that look like usernames (no dots/slashes).
|
||||
*/
|
||||
const FILE_REF_PATTERN = /(?:^|\s)@((?:\.{0,2}\/|~\/|[a-zA-Z0-9_])[^\s]+)/g;
|
||||
|
||||
interface FileRefResult {
|
||||
/** The expanded message text with file contents inlined */
|
||||
expandedMessage: string;
|
||||
/** Files that were successfully read */
|
||||
filesAttached: string[];
|
||||
/** Errors encountered while reading files */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function resolveFilePath(ref: string): string {
|
||||
if (ref.startsWith('~/')) {
|
||||
return resolve(homedir(), ref.slice(2));
|
||||
}
|
||||
return resolve(process.cwd(), ref);
|
||||
}
|
||||
|
||||
function getLanguageHint(filePath: string): string {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
'.ts': 'typescript',
|
||||
'.tsx': 'typescript',
|
||||
'.js': 'javascript',
|
||||
'.jsx': 'javascript',
|
||||
'.py': 'python',
|
||||
'.rb': 'ruby',
|
||||
'.rs': 'rust',
|
||||
'.go': 'go',
|
||||
'.java': 'java',
|
||||
'.c': 'c',
|
||||
'.cpp': 'cpp',
|
||||
'.h': 'c',
|
||||
'.hpp': 'cpp',
|
||||
'.cs': 'csharp',
|
||||
'.sh': 'bash',
|
||||
'.bash': 'bash',
|
||||
'.zsh': 'zsh',
|
||||
'.fish': 'fish',
|
||||
'.json': 'json',
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.toml': 'toml',
|
||||
'.xml': 'xml',
|
||||
'.html': 'html',
|
||||
'.css': 'css',
|
||||
'.scss': 'scss',
|
||||
'.md': 'markdown',
|
||||
'.sql': 'sql',
|
||||
'.graphql': 'graphql',
|
||||
'.dockerfile': 'dockerfile',
|
||||
'.tf': 'terraform',
|
||||
'.vue': 'vue',
|
||||
'.svelte': 'svelte',
|
||||
};
|
||||
return map[ext] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input contains any @file references.
|
||||
*/
|
||||
export function hasFileRefs(input: string): boolean {
|
||||
FILE_REF_PATTERN.lastIndex = 0;
|
||||
return FILE_REF_PATTERN.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand @file references in a message by reading file contents
|
||||
* and appending them as fenced code blocks.
|
||||
*/
|
||||
export async function expandFileRefs(input: string): Promise<FileRefResult> {
|
||||
const refs: string[] = [];
|
||||
FILE_REF_PATTERN.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = FILE_REF_PATTERN.exec(input)) !== null) {
|
||||
const ref = match[1]!;
|
||||
if (!refs.includes(ref)) {
|
||||
refs.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
if (refs.length === 0) {
|
||||
return { expandedMessage: input, filesAttached: [], errors: [] };
|
||||
}
|
||||
|
||||
if (refs.length > MAX_FILES_PER_MESSAGE) {
|
||||
return {
|
||||
expandedMessage: input,
|
||||
filesAttached: [],
|
||||
errors: [`Too many file references (${refs.length}). Maximum is ${MAX_FILES_PER_MESSAGE}.`],
|
||||
};
|
||||
}
|
||||
|
||||
const filesAttached: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const attachments: string[] = [];
|
||||
|
||||
for (const ref of refs) {
|
||||
const filePath = resolveFilePath(ref);
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
if (!info.isFile()) {
|
||||
errors.push(`@${ref}: not a file`);
|
||||
continue;
|
||||
}
|
||||
if (info.size > MAX_FILE_SIZE) {
|
||||
errors.push(
|
||||
`@${ref}: file too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lang = getLanguageHint(filePath);
|
||||
const name = basename(filePath);
|
||||
attachments.push(`\n📎 ${ref} (${name}):\n\`\`\`${lang}\n${content}\n\`\`\``);
|
||||
filesAttached.push(ref);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Only report meaningful errors — ENOENT is common for false @mention matches
|
||||
if (msg.includes('ENOENT')) {
|
||||
// Check if this looks like a file path (has extension or slash)
|
||||
if (ref.includes('/') || ref.includes('.')) {
|
||||
errors.push(`@${ref}: file not found`);
|
||||
}
|
||||
// Otherwise silently skip — likely an @mention, not a file ref
|
||||
} else {
|
||||
errors.push(`@${ref}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return { expandedMessage: input, filesAttached, errors };
|
||||
}
|
||||
|
||||
const expandedMessage = input + '\n' + attachments.join('\n');
|
||||
return { expandedMessage, filesAttached, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the /attach <path> command.
|
||||
* Reads a file and returns the content formatted for inclusion in the chat.
|
||||
*/
|
||||
export async function handleAttachCommand(
|
||||
args: string,
|
||||
): Promise<{ content: string; error?: string }> {
|
||||
const filePath = args.trim();
|
||||
if (!filePath) {
|
||||
return { content: '', error: 'Usage: /attach <file-path>' };
|
||||
}
|
||||
|
||||
const resolved = resolveFilePath(filePath);
|
||||
try {
|
||||
const info = await stat(resolved);
|
||||
if (!info.isFile()) {
|
||||
return { content: '', error: `Not a file: ${filePath}` };
|
||||
}
|
||||
if (info.size > MAX_FILE_SIZE) {
|
||||
return {
|
||||
content: '',
|
||||
error: `File too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
|
||||
};
|
||||
}
|
||||
const content = await readFile(resolved, 'utf8');
|
||||
const lang = getLanguageHint(resolved);
|
||||
const name = basename(resolved);
|
||||
return {
|
||||
content: `📎 Attached file: ${name} (${filePath})\n\`\`\`${lang}\n${content}\n\`\`\``,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: '', error: `Failed to read file: ${msg}` };
|
||||
}
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
/**
|
||||
* Minimal gateway REST API client for the TUI and CLI commands.
|
||||
*/
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
createdAt: string;
|
||||
promptCount: number;
|
||||
channels: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface SessionListResult {
|
||||
sessions: SessionInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ── Agent Config types ──
|
||||
|
||||
export interface AgentConfigInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
status: string;
|
||||
projectId: string | null;
|
||||
ownerId: string | null;
|
||||
systemPrompt: string | null;
|
||||
allowedTools: string[] | null;
|
||||
skills: string[] | null;
|
||||
isSystem: boolean;
|
||||
config: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Project types ──
|
||||
|
||||
export interface ProjectInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
ownerId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Mission types ──
|
||||
|
||||
export interface MissionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
projectId: string | null;
|
||||
userId: string | null;
|
||||
phase: string | null;
|
||||
milestones: Record<string, unknown>[] | null;
|
||||
config: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Mission Task types ──
|
||||
|
||||
export interface MissionTaskInfo {
|
||||
id: string;
|
||||
missionId: string;
|
||||
taskId: string | null;
|
||||
userId: string;
|
||||
status: string;
|
||||
description: string | null;
|
||||
notes: string | null;
|
||||
pr: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function headers(sessionCookie: string, gatewayUrl: string) {
|
||||
return { Cookie: sessionCookie, Origin: gatewayUrl };
|
||||
}
|
||||
|
||||
function jsonHeaders(sessionCookie: string, gatewayUrl: string) {
|
||||
return { ...headers(sessionCookie, gatewayUrl), 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
async function handleResponse<T>(res: Response, errorPrefix: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`${errorPrefix} (${res.status}): ${body}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// ── Conversation types ──
|
||||
|
||||
export interface ConversationInfo {
|
||||
id: string;
|
||||
title: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Conversation endpoints ──
|
||||
|
||||
export async function createConversation(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
data: { title?: string; projectId?: string } = {},
|
||||
): Promise<ConversationInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<ConversationInfo>(res, 'Failed to create conversation');
|
||||
}
|
||||
|
||||
// ── Provider / Model endpoints ──
|
||||
|
||||
export async function fetchAvailableModels(
|
||||
gatewayUrl: string,
|
||||
sessionCookie?: string,
|
||||
): Promise<ModelInfo[]> {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/providers/models`, {
|
||||
headers: {
|
||||
...(sessionCookie ? { Cookie: sessionCookie } : {}),
|
||||
Origin: gatewayUrl,
|
||||
},
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as ModelInfo[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProviders(
|
||||
gatewayUrl: string,
|
||||
sessionCookie?: string,
|
||||
): Promise<ProviderInfo[]> {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/providers`, {
|
||||
headers: {
|
||||
...(sessionCookie ? { Cookie: sessionCookie } : {}),
|
||||
Origin: gatewayUrl,
|
||||
},
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as ProviderInfo[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session endpoints ──
|
||||
|
||||
export async function fetchSessions(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<SessionListResult> {
|
||||
const res = await fetch(`${gatewayUrl}/api/sessions`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<SessionListResult>(res, 'Failed to list sessions');
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to destroy session (${res.status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent Config endpoints ──
|
||||
|
||||
export async function fetchAgentConfigs(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<AgentConfigInfo[]> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<AgentConfigInfo[]>(res, 'Failed to list agents');
|
||||
}
|
||||
|
||||
export async function fetchAgentConfig(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
): Promise<AgentConfigInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<AgentConfigInfo>(res, 'Failed to get agent');
|
||||
}
|
||||
|
||||
export async function createAgentConfig(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
data: {
|
||||
name: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
projectId?: string;
|
||||
systemPrompt?: string;
|
||||
allowedTools?: string[];
|
||||
skills?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<AgentConfigInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<AgentConfigInfo>(res, 'Failed to create agent');
|
||||
}
|
||||
|
||||
export async function updateAgentConfig(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<AgentConfigInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<AgentConfigInfo>(res, 'Failed to update agent');
|
||||
}
|
||||
|
||||
export async function deleteAgentConfig(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to delete agent (${res.status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Project endpoints ──
|
||||
|
||||
export async function fetchProjects(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<ProjectInfo[]> {
|
||||
const res = await fetch(`${gatewayUrl}/api/projects`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<ProjectInfo[]>(res, 'Failed to list projects');
|
||||
}
|
||||
|
||||
// ── Mission endpoints ──
|
||||
|
||||
export async function fetchMissions(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<MissionInfo[]> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<MissionInfo[]>(res, 'Failed to list missions');
|
||||
}
|
||||
|
||||
export async function fetchMission(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
): Promise<MissionInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<MissionInfo>(res, 'Failed to get mission');
|
||||
}
|
||||
|
||||
export async function createMission(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
status?: string;
|
||||
phase?: string;
|
||||
milestones?: Record<string, unknown>[];
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<MissionInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<MissionInfo>(res, 'Failed to create mission');
|
||||
}
|
||||
|
||||
export async function updateMission(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<MissionInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<MissionInfo>(res, 'Failed to update mission');
|
||||
}
|
||||
|
||||
export async function deleteMission(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to delete mission (${res.status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Conversation Message types ──
|
||||
|
||||
export interface ConversationMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── Conversation Message endpoints ──
|
||||
|
||||
export async function fetchConversationMessages(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
conversationId: string,
|
||||
): Promise<ConversationMessage[]> {
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}/api/conversations/${encodeURIComponent(conversationId)}/messages`,
|
||||
{
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
},
|
||||
);
|
||||
return handleResponse<ConversationMessage[]>(res, 'Failed to fetch conversation messages');
|
||||
}
|
||||
|
||||
// ── Mission Task endpoints ──
|
||||
|
||||
export async function fetchMissionTasks(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
missionId: string,
|
||||
): Promise<MissionTaskInfo[]> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<MissionTaskInfo[]>(res, 'Failed to list mission tasks');
|
||||
}
|
||||
|
||||
export async function createMissionTask(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
missionId: string,
|
||||
data: {
|
||||
description?: string;
|
||||
status?: string;
|
||||
notes?: string;
|
||||
pr?: string;
|
||||
taskId?: string;
|
||||
},
|
||||
): Promise<MissionTaskInfo> {
|
||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<MissionTaskInfo>(res, 'Failed to create mission task');
|
||||
}
|
||||
|
||||
export async function updateMissionTask(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
missionId: string,
|
||||
taskId: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<MissionTaskInfo> {
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
return handleResponse<MissionTaskInfo>(res, 'Failed to update mission task');
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type AppMode = 'chat' | 'sidebar' | 'search';
|
||||
|
||||
export interface UseAppModeReturn {
|
||||
mode: AppMode;
|
||||
setMode: (mode: AppMode) => void;
|
||||
toggleSidebar: () => void;
|
||||
sidebarOpen: boolean;
|
||||
}
|
||||
|
||||
export function useAppMode(): UseAppModeReturn {
|
||||
const [mode, setModeState] = useState<AppMode>('chat');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const setMode = useCallback((next: AppMode) => {
|
||||
setModeState(next);
|
||||
if (next === 'sidebar') {
|
||||
setSidebarOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarOpen((prev) => {
|
||||
if (prev) {
|
||||
// Closing sidebar — return to chat
|
||||
setModeState('chat');
|
||||
return false;
|
||||
}
|
||||
// Opening sidebar — set mode to sidebar
|
||||
setModeState('sidebar');
|
||||
return true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { mode, setMode, toggleSidebar, sidebarOpen };
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
title: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UseConversationsOptions {
|
||||
gatewayUrl: string;
|
||||
sessionCookie?: string;
|
||||
}
|
||||
|
||||
export interface UseConversationsReturn {
|
||||
conversations: ConversationSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
createConversation: (title?: string) => Promise<ConversationSummary | null>;
|
||||
deleteConversation: (id: string) => Promise<boolean>;
|
||||
renameConversation: (id: string, title: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
|
||||
const { gatewayUrl, sessionCookie } = opts;
|
||||
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const headers = useCallback(
|
||||
(includeContentType = true): Record<string, string> => {
|
||||
const h: Record<string, string> = { Origin: gatewayUrl };
|
||||
if (includeContentType) h['Content-Type'] = 'application/json';
|
||||
if (sessionCookie) h['Cookie'] = sessionCookie;
|
||||
return h;
|
||||
},
|
||||
[gatewayUrl, sessionCookie],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers(false) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as ConversationSummary[];
|
||||
if (mountedRef.current) {
|
||||
setConversations(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [gatewayUrl, headers]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const createConversation = useCallback(
|
||||
async (title?: string): Promise<ConversationSummary | null> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ title: title ?? null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as ConversationSummary;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => [data, ...prev]);
|
||||
}
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(false),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
async (id: string, title: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
if (mountedRef.current) {
|
||||
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gatewayUrl, headers],
|
||||
);
|
||||
|
||||
return {
|
||||
conversations,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
createConversation,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
export interface GitInfo {
|
||||
branch: string | null;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function useGitInfo(): GitInfo {
|
||||
const [info, setInfo] = useState<GitInfo>({
|
||||
branch: null,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
setInfo({ branch, cwd: process.cwd() });
|
||||
} catch {
|
||||
setInfo({ branch: null, cwd: process.cwd() });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return info;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for input history logic extracted from useInputHistory.
|
||||
* We test the pure state transitions directly rather than using
|
||||
* React testing utilities to avoid react-dom version conflicts.
|
||||
*/
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
function createHistoryState() {
|
||||
let history: string[] = [];
|
||||
let historyIndex = -1;
|
||||
let savedInput = '';
|
||||
|
||||
function addToHistory(input: string): void {
|
||||
if (!input.trim()) return;
|
||||
if (history[0] === input) return;
|
||||
history = [input, ...history].slice(0, MAX_HISTORY);
|
||||
historyIndex = -1;
|
||||
}
|
||||
|
||||
function navigateUp(currentInput: string): string | null {
|
||||
if (history.length === 0) return null;
|
||||
if (historyIndex === -1) {
|
||||
savedInput = currentInput;
|
||||
}
|
||||
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||
historyIndex = nextIndex;
|
||||
return history[nextIndex] ?? null;
|
||||
}
|
||||
|
||||
function navigateDown(): string | null {
|
||||
if (historyIndex <= 0) {
|
||||
historyIndex = -1;
|
||||
return savedInput;
|
||||
}
|
||||
const nextIndex = historyIndex - 1;
|
||||
historyIndex = nextIndex;
|
||||
return history[nextIndex] ?? null;
|
||||
}
|
||||
|
||||
function resetNavigation(): void {
|
||||
historyIndex = -1;
|
||||
}
|
||||
|
||||
function getHistoryLength(): number {
|
||||
return history.length;
|
||||
}
|
||||
|
||||
return { addToHistory, navigateUp, navigateDown, resetNavigation, getHistoryLength };
|
||||
}
|
||||
|
||||
describe('useInputHistory (logic)', () => {
|
||||
let h: ReturnType<typeof createHistoryState>;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHistoryState();
|
||||
});
|
||||
|
||||
it('adds to history on submit', () => {
|
||||
h.addToHistory('hello');
|
||||
h.addToHistory('world');
|
||||
// navigateUp should return 'world' first (most recent)
|
||||
const val = h.navigateUp('');
|
||||
expect(val).toBe('world');
|
||||
});
|
||||
|
||||
it('does not add empty strings to history', () => {
|
||||
h.addToHistory('');
|
||||
h.addToHistory(' ');
|
||||
const val = h.navigateUp('');
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
|
||||
it('navigateDown after up returns saved input', () => {
|
||||
h.addToHistory('first');
|
||||
const up = h.navigateUp('current');
|
||||
expect(up).toBe('first');
|
||||
const down = h.navigateDown();
|
||||
expect(down).toBe('current');
|
||||
});
|
||||
|
||||
it('does not add duplicate consecutive entries', () => {
|
||||
h.addToHistory('same');
|
||||
h.addToHistory('same');
|
||||
expect(h.getHistoryLength()).toBe(1);
|
||||
});
|
||||
|
||||
it('caps history at MAX_HISTORY entries', () => {
|
||||
for (let i = 0; i < 55; i++) {
|
||||
h.addToHistory(`entry-${i}`);
|
||||
}
|
||||
expect(h.getHistoryLength()).toBe(50);
|
||||
// Navigate to the oldest entry
|
||||
let val: string | null = null;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
val = h.navigateUp('');
|
||||
}
|
||||
// Oldest entry at index 49 = entry-5 (entries 54 down to 5, 50 total)
|
||||
expect(val).toBe('entry-5');
|
||||
});
|
||||
|
||||
it('navigateUp returns null when history is empty', () => {
|
||||
const val = h.navigateUp('something');
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
|
||||
it('navigateUp cycles through multiple entries', () => {
|
||||
h.addToHistory('a');
|
||||
h.addToHistory('b');
|
||||
h.addToHistory('c');
|
||||
expect(h.navigateUp('')).toBe('c');
|
||||
expect(h.navigateUp('c')).toBe('b');
|
||||
expect(h.navigateUp('b')).toBe('a');
|
||||
});
|
||||
|
||||
it('resetNavigation resets index to -1', () => {
|
||||
h.addToHistory('test');
|
||||
h.navigateUp('');
|
||||
h.resetNavigation();
|
||||
// After reset, navigateUp from index -1 returns most recent again
|
||||
const val = h.navigateUp('');
|
||||
expect(val).toBe('test');
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
export function useInputHistory() {
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||
const [savedInput, setSavedInput] = useState<string>('');
|
||||
|
||||
const addToHistory = useCallback((input: string) => {
|
||||
if (!input.trim()) return;
|
||||
setHistory((prev) => {
|
||||
// Avoid duplicate consecutive entries
|
||||
if (prev[0] === input) return prev;
|
||||
return [input, ...prev].slice(0, MAX_HISTORY);
|
||||
});
|
||||
setHistoryIndex(-1);
|
||||
}, []);
|
||||
|
||||
const navigateUp = useCallback(
|
||||
(currentInput: string): string | null => {
|
||||
if (history.length === 0) return null;
|
||||
if (historyIndex === -1) {
|
||||
setSavedInput(currentInput);
|
||||
}
|
||||
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||
setHistoryIndex(nextIndex);
|
||||
return history[nextIndex] ?? null;
|
||||
},
|
||||
[history, historyIndex],
|
||||
);
|
||||
|
||||
const navigateDown = useCallback((): string | null => {
|
||||
if (historyIndex <= 0) {
|
||||
setHistoryIndex(-1);
|
||||
return savedInput;
|
||||
}
|
||||
const nextIndex = historyIndex - 1;
|
||||
setHistoryIndex(nextIndex);
|
||||
return history[nextIndex] ?? null;
|
||||
}, [history, historyIndex, savedInput]);
|
||||
|
||||
const resetNavigation = useCallback(() => {
|
||||
setHistoryIndex(-1);
|
||||
}, []);
|
||||
|
||||
return { addToHistory, navigateUp, navigateDown, resetNavigation };
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import type { Message } from './use-socket.js';
|
||||
|
||||
export interface SearchMatch {
|
||||
messageIndex: number;
|
||||
charOffset: number;
|
||||
}
|
||||
|
||||
export interface UseSearchReturn {
|
||||
query: string;
|
||||
setQuery: (q: string) => void;
|
||||
matches: SearchMatch[];
|
||||
currentMatchIndex: number;
|
||||
nextMatch: () => void;
|
||||
prevMatch: () => void;
|
||||
clear: () => void;
|
||||
totalMatches: number;
|
||||
}
|
||||
|
||||
export function useSearch(messages: Message[]): UseSearchReturn {
|
||||
const [query, setQuery] = useState('');
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||
|
||||
const matches = useMemo<SearchMatch[]>(() => {
|
||||
if (query.length < 2) return [];
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const result: SearchMatch[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (!msg) continue;
|
||||
const content = msg.content.toLowerCase();
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const idx = content.indexOf(lowerQuery, offset);
|
||||
if (idx === -1) break;
|
||||
result.push({ messageIndex: i, charOffset: idx });
|
||||
offset = idx + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [query, messages]);
|
||||
|
||||
// Reset match index when matches change
|
||||
useMemo(() => {
|
||||
setCurrentMatchIndex(0);
|
||||
}, [matches]);
|
||||
|
||||
const nextMatch = useCallback(() => {
|
||||
if (matches.length === 0) return;
|
||||
setCurrentMatchIndex((prev) => (prev + 1) % matches.length);
|
||||
}, [matches.length]);
|
||||
|
||||
const prevMatch = useCallback(() => {
|
||||
if (matches.length === 0) return;
|
||||
setCurrentMatchIndex((prev) => (prev - 1 + matches.length) % matches.length);
|
||||
}, [matches.length]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setQuery('');
|
||||
setCurrentMatchIndex(0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
matches,
|
||||
currentMatchIndex,
|
||||
nextMatch,
|
||||
prevMatch,
|
||||
clear,
|
||||
totalMatches: matches.length,
|
||||
};
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
import { type MutableRefObject, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import type {
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
MessageAckPayload,
|
||||
AgentEndPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ToolStartPayload,
|
||||
ToolEndPayload,
|
||||
SessionInfoPayload,
|
||||
ErrorPayload,
|
||||
CommandManifestPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
RoutingDecisionInfo,
|
||||
} from '@mosaic/types';
|
||||
import { commandRegistry } from '../commands/index.js';
|
||||
|
||||
export interface ToolCall {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
status: 'running' | 'success' | 'error';
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: 'user' | 'assistant' | 'thinking' | 'tool' | 'system';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
total: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: number;
|
||||
contextPercent: number;
|
||||
contextWindow: number;
|
||||
}
|
||||
|
||||
export interface UseSocketOptions {
|
||||
gatewayUrl: string;
|
||||
sessionCookie?: string;
|
||||
initialConversationId?: string;
|
||||
initialModel?: string;
|
||||
initialProvider?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
|
||||
export interface UseSocketReturn {
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
messages: Message[];
|
||||
conversationId: string | undefined;
|
||||
isStreaming: boolean;
|
||||
currentStreamText: string;
|
||||
currentThinkingText: string;
|
||||
activeToolCalls: ToolCall[];
|
||||
tokenUsage: TokenUsage;
|
||||
modelName: string | null;
|
||||
providerName: string | null;
|
||||
thinkingLevel: string;
|
||||
availableThinkingLevels: string[];
|
||||
/** Last routing decision received from the gateway (M4-008) */
|
||||
routingDecision: RoutingDecisionInfo | null;
|
||||
sendMessage: (content: string) => void;
|
||||
addSystemMessage: (content: string) => void;
|
||||
setThinkingLevel: (level: string) => void;
|
||||
switchConversation: (id: string) => void;
|
||||
clearMessages: () => void;
|
||||
connectionError: string | null;
|
||||
socketRef: MutableRefObject<TypedSocket | null>;
|
||||
}
|
||||
|
||||
const EMPTY_USAGE: TokenUsage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: 0,
|
||||
contextPercent: 0,
|
||||
contextWindow: 0,
|
||||
};
|
||||
|
||||
export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||
const {
|
||||
gatewayUrl,
|
||||
sessionCookie,
|
||||
initialConversationId,
|
||||
initialModel,
|
||||
initialProvider,
|
||||
agentId,
|
||||
} = opts;
|
||||
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(true);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [conversationId, setConversationId] = useState(initialConversationId);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [currentStreamText, setCurrentStreamText] = useState('');
|
||||
const [currentThinkingText, setCurrentThinkingText] = useState('');
|
||||
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
|
||||
const [tokenUsage, setTokenUsage] = useState<TokenUsage>(EMPTY_USAGE);
|
||||
const [modelName, setModelName] = useState<string | null>(null);
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [thinkingLevel, setThinkingLevelState] = useState<string>('off');
|
||||
const [availableThinkingLevels, setAvailableThinkingLevels] = useState<string[]>([]);
|
||||
const [routingDecision, setRoutingDecision] = useState<RoutingDecisionInfo | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<TypedSocket | null>(null);
|
||||
const conversationIdRef = useRef(conversationId);
|
||||
conversationIdRef.current = conversationId;
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io(`${gatewayUrl}/chat`, {
|
||||
transports: ['websocket'],
|
||||
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 2000,
|
||||
reconnectionAttempts: Infinity,
|
||||
}) as TypedSocket;
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
setConnected(true);
|
||||
setConnecting(false);
|
||||
setConnectionError(null);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
setConnected(false);
|
||||
setIsStreaming(false);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
});
|
||||
|
||||
socket.io.on('error', (err: Error) => {
|
||||
setConnecting(false);
|
||||
setConnectionError(err.message);
|
||||
});
|
||||
|
||||
socket.on('message:ack', (data: MessageAckPayload) => {
|
||||
setConversationId(data.conversationId);
|
||||
});
|
||||
|
||||
socket.on('session:info', (data: SessionInfoPayload) => {
|
||||
setProviderName(data.provider);
|
||||
setModelName(data.modelId);
|
||||
setThinkingLevelState(data.thinkingLevel);
|
||||
setAvailableThinkingLevels(data.availableThinkingLevels);
|
||||
// Update routing decision if provided (M4-008)
|
||||
if (data.routingDecision) {
|
||||
setRoutingDecision(data.routingDecision);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('agent:start', () => {
|
||||
setIsStreaming(true);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
});
|
||||
|
||||
socket.on('agent:text', (data: AgentTextPayload) => {
|
||||
setCurrentStreamText((prev) => prev + data.text);
|
||||
});
|
||||
|
||||
socket.on('agent:thinking', (data: AgentThinkingPayload) => {
|
||||
setCurrentThinkingText((prev) => prev + data.text);
|
||||
});
|
||||
|
||||
socket.on('agent:tool:start', (data: ToolStartPayload) => {
|
||||
setActiveToolCalls((prev) => [
|
||||
...prev,
|
||||
{ toolCallId: data.toolCallId, toolName: data.toolName, status: 'running' },
|
||||
]);
|
||||
});
|
||||
|
||||
socket.on('agent:tool:end', (data: ToolEndPayload) => {
|
||||
setActiveToolCalls((prev) =>
|
||||
prev.map((tc) =>
|
||||
tc.toolCallId === data.toolCallId
|
||||
? { ...tc, status: data.isError ? 'error' : 'success' }
|
||||
: tc,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('agent:end', (data: AgentEndPayload) => {
|
||||
setCurrentStreamText((prev) => {
|
||||
if (prev) {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'assistant', content: prev, timestamp: new Date() },
|
||||
]);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Update usage from the payload
|
||||
if (data.usage) {
|
||||
setProviderName(data.usage.provider);
|
||||
setModelName(data.usage.modelId);
|
||||
setThinkingLevelState(data.usage.thinkingLevel);
|
||||
setTokenUsage({
|
||||
input: data.usage.tokens.input,
|
||||
output: data.usage.tokens.output,
|
||||
total: data.usage.tokens.total,
|
||||
cacheRead: data.usage.tokens.cacheRead,
|
||||
cacheWrite: data.usage.tokens.cacheWrite,
|
||||
cost: data.usage.cost,
|
||||
contextPercent: data.usage.context.percent ?? 0,
|
||||
contextWindow: data.usage.context.window,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (data: ErrorPayload) => {
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'assistant', content: `Error: ${data.error}`, timestamp: new Date() },
|
||||
]);
|
||||
setIsStreaming(false);
|
||||
});
|
||||
|
||||
socket.on('commands:manifest', (data: CommandManifestPayload) => {
|
||||
commandRegistry.updateManifest(data.manifest);
|
||||
});
|
||||
|
||||
socket.on('command:result', (data: SlashCommandResultPayload) => {
|
||||
const prefix = data.success ? '' : 'Error: ';
|
||||
const text = data.message ?? (data.success ? 'Done.' : 'Command failed.');
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'system', content: `${prefix}${text}`, timestamp: new Date() },
|
||||
]);
|
||||
});
|
||||
|
||||
socket.on('system:reload', (data: SystemReloadPayload) => {
|
||||
commandRegistry.updateManifest({
|
||||
commands: data.commands,
|
||||
skills: data.skills,
|
||||
version: Date.now(),
|
||||
});
|
||||
setMessages((msgs) => [
|
||||
...msgs,
|
||||
{ role: 'system', content: data.message, timestamp: new Date() },
|
||||
]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [gatewayUrl, sessionCookie]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string) => {
|
||||
if (!content.trim() || isStreaming) return;
|
||||
if (!socketRef.current?.connected) return;
|
||||
|
||||
setMessages((msgs) => [...msgs, { role: 'user', content, timestamp: new Date() }]);
|
||||
|
||||
socketRef.current.emit('message', {
|
||||
conversationId,
|
||||
content,
|
||||
...(initialProvider ? { provider: initialProvider } : {}),
|
||||
...(initialModel ? { modelId: initialModel } : {}),
|
||||
...(agentId ? { agentId } : {}),
|
||||
});
|
||||
},
|
||||
[conversationId, isStreaming],
|
||||
);
|
||||
|
||||
const addSystemMessage = useCallback((content: string) => {
|
||||
setMessages((msgs) => [...msgs, { role: 'system', content, timestamp: new Date() }]);
|
||||
}, []);
|
||||
|
||||
const setThinkingLevel = useCallback((level: string) => {
|
||||
const cid = conversationIdRef.current;
|
||||
if (!socketRef.current?.connected || !cid) return;
|
||||
socketRef.current.emit('set:thinking', {
|
||||
conversationId: cid,
|
||||
level,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setCurrentStreamText('');
|
||||
setCurrentThinkingText('');
|
||||
setActiveToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
const switchConversation = useCallback(
|
||||
(id: string) => {
|
||||
clearMessages();
|
||||
setConversationId(id);
|
||||
},
|
||||
[clearMessages],
|
||||
);
|
||||
|
||||
return {
|
||||
connected,
|
||||
connecting,
|
||||
messages,
|
||||
conversationId,
|
||||
isStreaming,
|
||||
currentStreamText,
|
||||
currentThinkingText,
|
||||
activeToolCalls,
|
||||
tokenUsage,
|
||||
modelName,
|
||||
providerName,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
routingDecision,
|
||||
sendMessage,
|
||||
addSystemMessage,
|
||||
setThinkingLevel,
|
||||
switchConversation,
|
||||
clearMessages,
|
||||
connectionError,
|
||||
socketRef,
|
||||
};
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useStdout } from 'ink';
|
||||
|
||||
export interface UseViewportOptions {
|
||||
totalItems: number;
|
||||
reservedLines?: number;
|
||||
}
|
||||
|
||||
export interface UseViewportReturn {
|
||||
scrollOffset: number;
|
||||
viewportSize: number;
|
||||
isScrolledUp: boolean;
|
||||
scrollToBottom: () => void;
|
||||
scrollBy: (delta: number) => void;
|
||||
scrollTo: (offset: number) => void;
|
||||
canScrollUp: boolean;
|
||||
canScrollDown: boolean;
|
||||
}
|
||||
|
||||
export function useViewport({
|
||||
totalItems,
|
||||
reservedLines = 10,
|
||||
}: UseViewportOptions): UseViewportReturn {
|
||||
const { stdout } = useStdout();
|
||||
const rows = stdout?.rows ?? 24;
|
||||
const viewportSize = Math.max(1, rows - reservedLines);
|
||||
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [autoFollow, setAutoFollow] = useState(true);
|
||||
|
||||
// Compute the maximum valid scroll offset
|
||||
const maxOffset = Math.max(0, totalItems - viewportSize);
|
||||
|
||||
// Auto-follow: when new items arrive and auto-follow is on, snap to bottom
|
||||
useEffect(() => {
|
||||
if (autoFollow) {
|
||||
setScrollOffset(maxOffset);
|
||||
}
|
||||
}, [autoFollow, maxOffset]);
|
||||
|
||||
const scrollTo = useCallback(
|
||||
(offset: number) => {
|
||||
const clamped = Math.max(0, Math.min(offset, maxOffset));
|
||||
setScrollOffset(clamped);
|
||||
setAutoFollow(clamped >= maxOffset);
|
||||
},
|
||||
[maxOffset],
|
||||
);
|
||||
|
||||
const scrollBy = useCallback(
|
||||
(delta: number) => {
|
||||
setScrollOffset((prev) => {
|
||||
const next = Math.max(0, Math.min(prev + delta, maxOffset));
|
||||
setAutoFollow(next >= maxOffset);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[maxOffset],
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
setScrollOffset(maxOffset);
|
||||
setAutoFollow(true);
|
||||
}, [maxOffset]);
|
||||
|
||||
const isScrolledUp = scrollOffset < maxOffset;
|
||||
const canScrollUp = scrollOffset > 0;
|
||||
const canScrollDown = scrollOffset < maxOffset;
|
||||
|
||||
return {
|
||||
scrollOffset,
|
||||
viewportSize,
|
||||
isScrolledUp,
|
||||
scrollToBottom,
|
||||
scrollBy,
|
||||
scrollTo,
|
||||
canScrollUp,
|
||||
canScrollDown,
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx"
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
81
pnpm-lock.yaml
generated
81
pnpm-lock.yaml
generated
@@ -302,6 +302,61 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/cli:
|
||||
dependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.1
|
||||
'@mosaic/config':
|
||||
specifier: workspace:^
|
||||
version: link:../config
|
||||
'@mosaic/mosaic':
|
||||
specifier: workspace:^
|
||||
version: link:../mosaic
|
||||
'@mosaic/prdy':
|
||||
specifier: workspace:^
|
||||
version: link:../prdy
|
||||
'@mosaic/quality-rails':
|
||||
specifier: workspace:^
|
||||
version: link:../quality-rails
|
||||
'@mosaic/types':
|
||||
specifier: workspace:^
|
||||
version: link:../types
|
||||
commander:
|
||||
specifier: ^13.0.0
|
||||
version: 13.1.0
|
||||
ink:
|
||||
specifier: ^5.0.0
|
||||
version: 5.2.1(@types/react@18.3.28)(react@18.3.1)
|
||||
ink-spinner:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
||||
ink-text-input:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
||||
react:
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.1
|
||||
socket.io-client:
|
||||
specifier: ^4.8.0
|
||||
version: 4.8.3
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.15
|
||||
'@types/react':
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.28
|
||||
tsx:
|
||||
specifier: ^4.0.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
|
||||
packages/config:
|
||||
dependencies:
|
||||
'@mosaic/memory':
|
||||
@@ -454,9 +509,6 @@ importers:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.9.1
|
||||
version: 0.9.1
|
||||
'@mosaic/config':
|
||||
specifier: workspace:*
|
||||
version: link:../config
|
||||
'@mosaic/forge':
|
||||
specifier: workspace:*
|
||||
version: link:../forge
|
||||
@@ -475,24 +527,9 @@ importers:
|
||||
commander:
|
||||
specifier: ^13.0.0
|
||||
version: 13.1.0
|
||||
ink:
|
||||
specifier: ^5.0.0
|
||||
version: 5.2.1(@types/react@18.3.28)(react@18.3.1)
|
||||
ink-spinner:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
||||
ink-text-input:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0(ink@5.2.1(@types/react@18.3.28)(react@18.3.1))(react@18.3.1)
|
||||
picocolors:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
react:
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.1
|
||||
socket.io-client:
|
||||
specifier: ^4.8.0
|
||||
version: 4.8.3
|
||||
yaml:
|
||||
specifier: ^2.6.1
|
||||
version: 2.8.2
|
||||
@@ -503,12 +540,6 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.15
|
||||
'@types/react':
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.28
|
||||
tsx:
|
||||
specifier: ^4.0.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
@@ -11917,7 +11948,7 @@ snapshots:
|
||||
type-fest: 4.41.0
|
||||
widest-line: 5.0.0
|
||||
wrap-ansi: 9.0.2
|
||||
ws: 8.20.0
|
||||
ws: 8.19.0
|
||||
yoga-layout: 3.2.1
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.28
|
||||
|
||||
@@ -2,7 +2,6 @@ packages:
|
||||
- 'apps/*'
|
||||
- 'packages/*'
|
||||
- 'plugins/*'
|
||||
- '!packages/cli' # merged into @mosaic/mosaic
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- '@nestjs/core'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# Installs both components:
|
||||
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
|
||||
# 2. @mosaic/mosaic (npm) → ~/.npm-global/ (CLI, TUI, gateway client, wizard)
|
||||
# 2. @mosaic/cli (npm) → ~/.npm-global/ (TUI, gateway client, wizard)
|
||||
#
|
||||
# Remote install (recommended):
|
||||
# bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||
@@ -53,7 +53,7 @@ MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaic/npm/}"
|
||||
SCOPE="${MOSAIC_SCOPE:-@mosaic}"
|
||||
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
|
||||
CLI_PKG="${SCOPE}/mosaic"
|
||||
CLI_PKG="${SCOPE}/cli"
|
||||
REPO_BASE="https://git.mosaicstack.dev/mosaic/mosaic-stack"
|
||||
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
|
||||
|
||||
@@ -208,7 +208,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# PART 2: @mosaic/mosaic (npm — TUI, gateway client, wizard, CLI)
|
||||
# PART 2: @mosaic/cli (npm — TUI, gateway client, wizard)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
|
||||
Reference in New Issue
Block a user