#!/usr/bin/env bun import './cli-colors' // must precede citty: sets NO_COLOR before its color flag is computed import { defineCommand, runMain, showUsage } from 'citty' import { formatHex } from 'culori' import { parse as devalueParse } from 'devalue' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'path' import pc from './cli-pc' import { isAgentCaller } from './agent-caller' import { getAppConfig } from './app-config' import { COLOR_THEMES, DEFAULT_WORKSPACE_THEME, FONT_THEMES, RADIUS_THEMES, deriveThemeColors } from '@/lib/themes' import type { ColorTheme, FontTheme, RadiusTheme } from '@/lib/themes' import { isParamsRecord } from '@/lib/workspace-tabs' import type { AppletLogEntry, ScratchArrowEnd, ScratchColor, ScratchFill, ScratchImageQuality, ScratchOp, ScratchSize, ScratchStyle, WorkspaceEntry, WorkspaceSkillStatus, WorkspaceType } from '@/lib/types' import { execWithEnv, notifyEnvChanged, readSecretValue, renderEnvView, resolveCwdWorkspace } from './cli-env' import { columns, keyValue } from './cli-ui' import { CONTROL_HOST, CONTROL_PORT, CONTROL_URL, PORT } from './constants' import { type ControlProbe, controlFailureMessage, probeControlServer } from './control-client' import { HARNESS_TYPES, type DetectedAgent, agentBindingFor, detectHarness, harnessLabel, isHarnessName } from './harness/detect' import { type HermesProfile, discoverHermesProfiles, matchHermesProfile } from './harness/hermes/discovery' import { type OpenClawAgent, discoverOpenClawAgents } from './harness/openclaw/discovery' import { assertWorkspaceIdAvailable, liftToWorkspaceRoot, listWorkspaces, registerWorkspace } from './registry' import { serverCwd } from './server-cwd' import { isBehind, isMinorBehind, resolveWorkspace, skillStatuses, staleSkillNotice } from './skill-version' import { updateWorkspaceSkills } from './skill-update' import { ServiceError, analyzeInstall, captureServiceEnv, installService, queryServerInfo, restartService, serviceLogPath, serviceStatus, uninstallService } from './service' import { detectPackageManager, fetchLatestVersion, installedBinVersion, isNewer, manualUpdateLines, runPackageManager, superviseServerUpdates, updateArgv } from './update' import { VERSION, isPrerelease, versionWithCommit } from './version' import { getWorkspaceEnvView, isValidEnvKey, secretBackend, updateWorkspaceEnv } from './workspace-env' import { provisionWorkspace } from './workspace-init' // ---- helpers ---------------------------------------------------------------- async function isServerRunning(): Promise { return (await probeControlServer()).status === 'running' } // Print why the control socket could not be opened, then exit. Classifies the // failure first, so "no server is listening" and "the server is up but the // address is unreachable" no longer read as the same problem. async function exitControlUnreachable(): Promise { console.error(controlFailureMessage(await probeControlServer())) process.exit(1) } async function waitForServer(timeoutMs = 10_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if (await isServerRunning()) return true await Bun.sleep(200) } return false } async function registerViaControl(absPath: string): Promise { return new Promise((res, rej) => { const ws = new WebSocket(CONTROL_URL) ws.onopen = () => ws.send(JSON.stringify({ type: 'workspace:register', path: absPath })) ws.onmessage = event => { const data = JSON.parse(String(event.data)) if (data.id) { ws.close() res(data.id) } } ws.onerror = () => void probeControlServer().then(p => rej(new Error(controlFailureMessage(p)))) }) } async function openBrowser(url: string) { try { if (process.platform === 'darwin') await Bun.$`open ${url}`.quiet() else if (process.platform === 'linux') await Bun.$`xdg-open ${url}`.quiet() } catch {} } // Spawn the server as a child. MOI_SERVER=1 tells the child it is the actual // server process. No `--hot`: frontend HMR comes from Bun.serve's dev bundler, // and server reloads are a full process restart driven by the dev supervisor // (see runDevSupervisor). function spawnServer( cwd: string, env: Record = process.env ): ReturnType { return Bun.spawn(['bun', import.meta.filename, 'start'], { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', cwd, env: { ...env, MOI_SERVER: '1' } }) } // Dev supervisor: run the server as a child WITHOUT `bun --hot`, watch the // server-side source (`server/`, `lib/`) and full-restart the child on change. // Client files are intentionally not watched here — Bun.serve owns frontend HMR // and patches the browser in place. The child shuts down gracefully on SIGTERM // (closing servers + killing function workers), so restarts leak nothing. async function runDevSupervisor( projectRoot: string, env: Record ): Promise { const { watch } = await import('node:fs') let child = spawnServer(projectRoot, env) let restarting = false let debounce: ReturnType | undefined async function restart(reason: string) { if (restarting) return restarting = true console.log(pc.dim(`\n↻ ${reason} — restarting server…`)) try { child.kill('SIGTERM') } catch {} const sigkill = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, 3000) await child.exited clearTimeout(sigkill) restarting = false child = spawnServer(projectRoot, env) } for (const dir of ['server', 'lib']) { watch(join(projectRoot, dir), { recursive: true }, (_event, file) => { if (!file || !file.endsWith('.ts')) return clearTimeout(debounce) debounce = setTimeout(() => restart(`${file} changed`), 100) }) } // Forward termination to the child, then exit the supervisor. for (const sig of ['SIGINT', 'SIGTERM'] as const) { process.on(sig, () => { try { child.kill('SIGTERM') } catch {} process.exit(0) }) } // Keep the supervisor alive indefinitely. await new Promise(() => {}) } // ---- commands --------------------------------------------------------------- // `moi init`'s harness decision: an explicit `--harness` wins, otherwise // auto-detection (harness/detect.ts). Exits when neither settles it — silently // defaulting to Claude Code would install skills into `.claude/skills/` for an // agent that reads from somewhere else entirely, which fails later and quietly. async function resolveInitHarness( target: string, requested: string | undefined ): Promise<{ type: WorkspaceType; agent: DetectedAgent | null }> { if (requested !== undefined) { const value = requested.trim() if (!isHarnessName(value)) { console.error('\n' + pc.red('✗') + ' Unknown harness: ' + pc.bold(requested)) console.error(pc.dim(' Valid values: ' + HARNESS_TYPES.join(', ')) + '\n') process.exit(1) } // An explicit choice still picks up the agent binding when the provider // can confirm it. It often can't — OpenClaw needs a running gateway — so a // miss warns rather than blocking a deliberate override. const agent = await agentBindingFor(value, target) if (!agent && (value === 'openclaw' || value === 'hermes')) { const noun = value === 'hermes' ? 'profile' : 'agent' console.log( '\n' + pc.yellow('⚠') + ' No ' + harnessLabel[value] + ' ' + noun + ' found at this path — registering without one.\n' + pc.dim(' Run ') + pc.bold('moi ' + value + ' init') + pc.dim(' to bind it once ' + harnessLabel[value] + ' can see the workspace.') ) } return { type: value, agent } } // A registered workspace has already answered this question, and may carry no // on-disk signal at all (a folder created from the UI whose agent has never // run in it), so the registry outranks detection when re-running `moi init` // to refresh skills. const known = (await listWorkspaces()).find(e => e.path === target) if (known) { const type = known.type ?? 'claude-code' console.log( '\n' + pc.dim(' Already registered — initializing for ') + pc.bold(harnessLabel[type]) ) return { type, agent: known.agentId ? { agentId: known.agentId, ...(known.name ? { name: known.name } : {}), isDefault: known.isDefault ?? false, ...(known.lastRunAt ? { lastRunAt: known.lastRunAt } : {}) } : null } } const detected = await detectHarness(target) if (detected) { console.log( '\n' + pc.yellow('◆') + ' Detected ' + detected.reason + ' — initializing for ' + pc.bold(harnessLabel[detected.type]) + '.' ) return { type: detected.type, agent: detected.agent ?? null } } console.error('\n' + pc.red('✗') + ' No agent harness detected in ' + pc.bold(target)) console.error( pc.dim(' Looked for a Hermes profile, an OpenClaw agent, and Codex or Claude Code history.\n') ) console.error(' Pick one:\n') console.error( keyValue( HARNESS_TYPES.map(t => [pc.bold('moi init --harness=' + t), harnessLabel[t]]), ' ' ) ) console.error() process.exit(1) } const init = defineCommand({ meta: { name: 'init', description: 'Initialize workspace, copy skills and scaffold folder with widgets' }, args: { dir: { type: 'positional', default: '.', description: 'Target directory (default: current)' }, harness: { type: 'string', description: 'Agent harness: ' + HARNESS_TYPES.join(', ') + ' (default: auto-detect)' }, web: { type: 'boolean', default: false, description: 'Start the web server if not already running' }, id: { type: 'string', description: 'Register under this id instead of a generated one — fails if the workspace is already registered' } }, async run({ args }) { const requested = resolve(args.dir) // Locate the workspace root first: if invoked from inside a `.moi/` (or a // deeper accidental `.moi/.moi/…`), lift to the directory that owns it // instead of scaffolding a nested workspace. This is what stops the // `.moi/.moi` bug at the source. const target = liftToWorkspaceRoot(requested) if (target !== requested) { console.log( '\n' + pc.yellow('◆') + ' Ran inside ' + pc.bold('.moi') + ' — using the workspace root ' + pc.bold(target) + ' instead (no nested .moi created).' ) } // Flag a stray nested `.moi/.moi` left by the old cwd bug — harmless but // confusing, and easy to remove. const stray = join(target, '.moi', '.moi') if (existsSync(stray)) { console.log( '\n' + pc.yellow('⚠') + ' Found a stray nested ' + pc.bold('.moi/.moi') + ' (from an older bug). Safe to remove:\n' + pc.dim(' rm -rf ' + stray) ) } // A chosen id is only assignable at first registration, so refuse it here — // before any skills are copied or `.moi/` is scaffolded. if (args.id) { try { await assertWorkspaceIdAvailable(target, args.id) } catch (err) { console.error('\n' + pc.red('✗') + ' ' + (err as Error).message + '\n') process.exit(1) } } const projectRoot = join(import.meta.dir, '..') const isInteractive = process.stdout.isTTY // Which backend this workspace is for has to be settled before // provisioning: each harness reads skills from a different directory, and // agent-owned ones (OpenClaw, Hermes) need their agent metadata on the // registry entry — same as `moi init`. const { type, agent } = await resolveInitHarness(target, args.harness) // Provision: bundled skills + the `.moi/` bootstrap (widgets dir + // package.json + bun install). An existing `.moi/` is left untouched. console.log() const { scaffold, skillsDir } = await provisionWorkspace(target, type) if (scaffold !== 'exists') { if (scaffold === 'installing') { console.log(pc.dim(' Widget dependencies still installing in .moi/ (background)')) } else if (scaffold === 0) { console.log(pc.dim(' Installed widget dependencies in .moi/')) } else { console.warn(pc.yellow(' bun install failed — run it manually in .moi/')) } } // Always register the workspace in the persistent registry const entry = await registerWorkspace(target, { ...(args.id ? { id: args.id } : {}), type, ...(agent ? { name: agent.name, agentId: agent.agentId, isDefault: agent.isDefault, lastRunAt: agent.lastRunAt } : {}) }) console.log(pc.green('✓') + ' Initialized ' + pc.bold(target) + pc.dim(' (' + type + ')')) console.log( ' Skills installed to ' + pc.dim(skillsDir) + ' — ask ' + (type === 'claude-code' ? 'Claude' : 'your agent') + ' to build a widget to get started\n' ) // If --web and server not running, start it (stay alive as wrapper) let running = await isServerRunning() if (!running && args.web) { console.log(pc.dim(' Starting server…')) const cwd = serverCwd(projectRoot, false) const proc = spawnServer(cwd) running = await waitForServer() if (!running) { console.error(pc.red(' Server failed to start\n')) await proc.exited process.exit(1) } const url = `http://localhost:${PORT}/workspace/${entry.id}` console.log(pc.green('✓') + ' Server started on http://localhost:' + PORT) if (isInteractive) console.log(' Opening ' + pc.bold(url)) console.log(pc.dim(' Press Ctrl+C to stop\n')) if (isInteractive) await openBrowser(url) process.exit(await superviseServerUpdates(proc, () => spawnServer(cwd))) } if (running) { // Server already running — notify it and open (browser only in interactive mode) await registerViaControl(target) const url = `http://localhost:${PORT}/workspace/${entry.id}` if (isInteractive) { console.log(' Opening ' + pc.bold(url) + '\n') await openBrowser(url) } else { console.log(' Ready at ' + pc.bold(url) + '\n') } process.exit(0) } // Server not running and --web not passed console.log(' Run ' + pc.bold('moi start') + ' to open in the browser\n') } }) const start = defineCommand({ meta: { name: 'start', description: 'Start the moi web server' }, args: { port: { type: 'string', description: 'HTTP port to listen on (default: 13337)' } }, async run({ args }) { const projectRoot = join(import.meta.dir, '..') // Undocumented: --dev runs the watch-and-full-restart dev supervisor. const dev = process.argv.includes('--dev') // Undocumented: --debug turns on the messaging trace (MOI_DEBUG) in the // server — console lines for each message/session/turn in the chat pipeline. const debug = process.argv.includes('--debug') // Launcher path: we are the CLI, not the server. Decide whether to bail // (a server is already up), run the dev supervisor, or spawn a one-shot // server. Skipped when MOI_SERVER=1 (we are the spawned server itself). if (!process.env.MOI_SERVER) { if (await isServerRunning()) { // Say who owns the running server: a service-managed one is expected // to be here, and `moi service` is how to manage it. const info = await queryServerInfo() const port = info?.port || PORT if (info?.service) { console.log( '\n' + pc.yellow('◆') + ' The moi service is already running on http://localhost:' + port + pc.dim(` (v${info.version})`) + '\n' + pc.dim(' Manage it with `moi service` — restart, logs, uninstall.') + '\n' ) } else { console.log( '\n' + pc.yellow('◆') + ' Server is already running on http://localhost:' + port + '\n' ) } process.exit(0) } // Spawn server with correct cwd so bunfig.toml is picked up at Bun startup. // MOI_DEV tells the server to use the live bundler + HMR even if a stale // `dist/` exists in the tree (prod serves prebuilt `dist/` statically). const env = { ...process.env, // The dev bundler snapshots process.env at server startup, so the // PUBLIC_* inlining (bunfig `[serve.static] env`) only sees vars that // are set before the server process spawns — setting one later from // server code does nothing. Default the tldraw key here so an unset // key inlines as '' (→ watermark) instead of leaving a bare // `process.env.…` in the browser bundle that throws. PUBLIC_TLDRAW_LICENSE_KEY: process.env.PUBLIC_TLDRAW_LICENSE_KEY ?? '', ...(args.port ? { PORT: args.port } : {}), ...(dev ? { MOI_DEV: '1' } : {}), ...(debug ? { MOI_DEBUG: '1' } : {}) } if (dev) { await runDevSupervisor(projectRoot, env) return } const cwd = serverCwd(projectRoot, dev) const proc = spawnServer(cwd, env) process.exit(await superviseServerUpdates(proc, () => spawnServer(cwd, env))) } // This IS the server process (MOI_SERVER=1). cwd is the package root when the // dev bundler runs (bunfig loaded at Bun startup) or a neutral dir for a // prebuilt install — see serverCwd(). try { await import('./web') } catch (err) { // Startup failure (port taken, config error). Under the service manager // exit 0 on purpose: launchd (KeepAlive.SuccessfulExit=false) and // systemd (Restart=on-failure) both read a clean exit as "do not // respawn", so a permanently-broken start idles instead of looping. const code = (err as { code?: string })?.code if (code === 'EADDRINUSE') { console.error( `\n${pc.red('✗')} Port already in use (http :${PORT} / control :${CONTROL_PORT}) — ` + 'is another moi server running?' ) } else { console.error(`\n${pc.red('✗')} Server failed to start: ${String(err)}`) } if (process.env.MOI_SERVICE) { console.error(pc.dim(' The service stays idle — fix this, then `moi service restart`.\n')) process.exit(0) } process.exit(1) } console.log(`\n${pc.green('✓')} Server started on http://localhost:${PORT}`) console.log(pc.dim(' Press Ctrl+C to stop\n')) // Stay alive as the server } }) function colorStatus(status: string) { if (status === 'built') return pc.green(status) if (status === 'failed') return pc.red(status) return pc.dim(status) } const bundle = defineCommand({ meta: { name: 'bundle', description: 'Rebuild changed widgets and views' }, args: { dir: { type: 'positional', default: '.', description: 'Workspace directory (default: current)' }, force: { type: 'boolean', description: 'Rebuild everything, ignoring file modification times', default: false }, only: { type: 'string', description: 'Narrow the build to "widgets" or "views" (default: both)' }, status: { type: 'boolean', description: 'Advance a view builder to ready on success (use --no-status to skip)', default: true } }, async run({ args }) { const path = resolve(args.dir) // Computed locally up front so it can ride along in the success output; the // agent reads this and knows to run `moi skill update`. const notice = await staleSkillNotice(path) const ws = new WebSocket(CONTROL_URL) ws.onopen = () => ws.send( JSON.stringify({ type: 'bundle', path, force: args.force, only: args.only, noStatus: !args.status }) ) ws.onmessage = event => { const res = JSON.parse(String(event.data)) // The server fails loudly when the path isn't inside a registered // workspace (e.g. run from an unrelated dir) instead of silently no-op'ing. if (res.error) { console.error('\n' + pc.red(pc.bold('Error:')) + ' ' + res.error + '\n') ws.close() process.exit(1) } type Row = { kind?: string; name: string; status: string; error?: string } const results: Row[] = Array.isArray(res.results) ? res.results : [] const where = typeof res.workspacePath === 'string' ? res.workspacePath : path // Empty here means a *real* workspace with no widgets/views — not the old // "wrong dir" footgun (that's an error above now). Say so plainly. if (results.length === 0) { console.log( '\n' + pc.bold('moi bundle') + pc.dim(' — nothing to build') + '\n\n' + pc.dim(` No widgets or views found in ${where}/.moi/`) + '\n' ) if (notice) console.log(pc.yellow(notice) + '\n') ws.close() process.exit(0) } const counts: Record = {} for (const r of results) counts[r.status] = (counts[r.status] ?? 0) + 1 const summary = ['built', 'skipped', 'failed'] .filter(s => counts[s]) .map(s => `${counts[s]} ${s}`) .join(' · ') console.log('\n' + pc.bold('moi bundle') + pc.dim(` — ${summary}`) + '\n') console.log( columns( ['kind', 'name', 'status'].map(h => pc.dim(h)), results.map(r => [pc.dim(r.kind ?? ''), r.name, colorStatus(r.status)]) ) ) const failed = results.filter(r => r.status === 'failed') console.log() for (const f of failed) { console.log(pc.red(pc.bold(f.name + ':'))) console.log(' ' + f.error + '\n') } // Runtime errors still standing after the rebuild's clear-on-success // sweep — point the agent at the journal while it's paying attention. const logCount = typeof res.logCount === 'number' ? res.logCount : 0 if (logCount > 0) { console.log( pc.yellow(`ℹ ${logCount} applet runtime error(s) on record — run \`moi debug logs\`.`) + '\n' ) } if (notice) console.log(pc.yellow(notice) + '\n') ws.close() process.exit(failed.length > 0 ? 1 : 0) } ws.onerror = () => void exitControlUnreachable() } }) const builderSet = defineCommand({ meta: { name: 'set', description: 'Set a view or widget builder id, status, title, and icon' }, args: { id: { type: 'positional', required: true, description: 'View or widget id' }, dir: { type: 'positional', default: '.', description: 'Workspace directory (default: current)' }, kind: { type: 'string', default: 'view', description: '"view" or "widget"' }, status: { type: 'string', description: 'Report build state: "building" or "waiting"' }, title: { type: 'string', description: 'Display title' }, icon: { type: 'string', description: 'App icon registry id' }, builder: { type: 'string', description: 'Builder handle for a pending view builder (from its request)' } }, run({ args }) { const path = resolve(args.dir) const ws = new WebSocket(CONTROL_URL) ws.onopen = () => ws.send( JSON.stringify({ type: 'builder:set', path, id: args.id, kind: args.kind, status: args.status, title: args.title, icon: args.icon, builder: args.builder }) ) ws.onmessage = event => { const result = JSON.parse(String(event.data)) if (result.error) { console.error('\n' + pc.red(pc.bold('Error:')) + ' ' + result.error + '\n') ws.close() process.exit(1) } console.log( '\n' + pc.green('✓') + ' Builder set ' + pc.bold(String(result.builder?.viewId ?? args.id)) + '\n' ) ws.close() process.exit(0) } ws.onerror = () => void exitControlUnreachable() } }) const builder = defineCommand({ meta: { name: 'builder', description: 'Manage a view or widget builder' }, subCommands: { set: builderSet } }) const refresh = defineCommand({ meta: { name: 'refresh', description: 'Refresh widget and view data without rebuilding. Use after the agent mutates underlying data.' }, args: { only: { type: 'string', description: 'Narrow the refresh to "widgets" or "views" (default: both)' } }, async run({ args }) { // Validate up front: a typo'd filter silently refreshing everything would // read as "my filter worked". if (args.only && args.only !== 'widgets' && args.only !== 'views') { console.error( '\n' + pc.red('✗') + ` Unknown --only value "${args.only}" — use "widgets" or "views".\n` ) process.exit(1) } const notice = await staleSkillNotice(process.cwd()) const ws = new WebSocket(CONTROL_URL) ws.onopen = () => ws.send(JSON.stringify({ type: 'applets:refresh', only: args.only })) ws.onmessage = event => { const data = JSON.parse(String(event.data)) if (data.error) { console.error('\n' + pc.red('✗') + ' ' + data.error + '\n') ws.close() process.exit(1) } console.log( '\n' + pc.green('✓') + ' Refresh signal sent' + (args.only ? ` (${args.only})` : '') + '\n' ) if (notice) console.log(pc.yellow(notice) + '\n') ws.close() process.exit(0) } ws.onerror = () => void exitControlUnreachable() } }) function hexToRgb(hex: string): [number, number, number] { const value = Number.parseInt(hex.slice(1), 16) return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff] } // Truecolor swatch using raw ANSI 24-bit escapes (picocolors maxes at 8 colors). // Falls back to blank spaces when stdout is not a TTY, keeping piped output clean. function swatch(bg?: string, fg?: string): string { if (!process.stdout.isTTY || !bg) return ' ' const [br, bgg, bb] = hexToRgb(bg) const [fr, fgg, fb] = hexToRgb(fg ?? '#000000') return `\x1b[48;2;${br};${bgg};${bb}m\x1b[38;2;${fr};${fgg};${fb}m Aa \x1b[0m` } function themeSwatch(primary?: string): string { if (!primary) return swatch() const colors = deriveThemeColors(primary) return swatch(formatHex(colors.primary), formatHex(colors.primaryForeground)) } const theme = defineCommand({ meta: { name: 'theme', description: 'Show or set the workspace appearance' }, args: { dir: { type: 'positional', default: '.', description: 'Workspace directory (default: current)' }, font: { type: 'string', description: 'Font theme key to apply' }, color: { type: 'string', description: 'Color preset key to apply' }, radius: { type: 'string', description: 'Radius preset key to apply' } }, async run({ args }) { const path = resolve(args.dir) const notice = await staleSkillNotice(path) const ws = new WebSocket(CONTROL_URL) ws.onopen = () => ws.send( JSON.stringify({ type: 'theme', path, font: args.font ?? null, color: args.color ?? null, radius: args.radius ?? null }) ) ws.onmessage = event => { const res = JSON.parse(String(event.data)) if (res.error) { console.error('\n' + pc.red(pc.bold('Error:')) + ' ' + res.error + '\n') ws.close() process.exit(1) } if (res.ok) { console.log() if (res.font) { const config = FONT_THEMES[res.font as FontTheme] console.log( pc.green('✓') + ' Font set to ' + pc.bold(config.label) + pc.dim(` (${config.sans} / ${config.mono})`) ) } if (res.color) { const preset = COLOR_THEMES[res.color as ColorTheme] const chip = themeSwatch(preset.primary) console.log(pc.green('✓') + ' Color set to ' + pc.bold(preset.label) + ' ' + chip) } if (res.radius) { const preset = RADIUS_THEMES[res.radius as RadiusTheme] console.log(pc.green('✓') + ' Radius set to ' + pc.bold(preset.label)) } console.log() if (notice) console.log(pc.yellow(notice) + '\n') ws.close() process.exit(0) } const currentFont: FontTheme = res.currentFont ?? DEFAULT_WORKSPACE_THEME.font const currentColor: ColorTheme = res.currentColor ?? DEFAULT_WORKSPACE_THEME.color const currentRadius: RadiusTheme = res.currentRadius ?? DEFAULT_WORKSPACE_THEME.radius console.log('\n' + pc.bold('moi theme') + ' — workspace appearance') console.log(pc.dim(' Usage: moi theme --font= --color= --radius=') + '\n') const fontRows = (Object.keys(FONT_THEMES) as FontTheme[]).map(key => { const f = FONT_THEMES[key] const selected = key === currentFont return [ selected ? pc.green('→') : ' ', selected ? pc.bold(key) : key, f.label, pc.dim(f.sans), pc.dim(f.mono) ] }) console.log(pc.dim(' Fonts')) console.log( columns( ['', 'key', 'label', 'sans', 'mono'].map(h => pc.dim(h)), fontRows ) + '\n' ) const colorRows = (Object.keys(COLOR_THEMES) as ColorTheme[]).map(key => { const c = COLOR_THEMES[key] const selected = key === currentColor return [ selected ? pc.green('→') : ' ', selected ? pc.bold(key) : key, c.label, themeSwatch(c.primary) ] }) console.log(pc.dim(' Colors')) console.log( columns( ['', 'key', 'label', 'swatch'].map(h => pc.dim(h)), colorRows ) + '\n' ) const radiusRows = (Object.keys(RADIUS_THEMES) as RadiusTheme[]).map(key => { const radius = RADIUS_THEMES[key] const selected = key === currentRadius return [ selected ? pc.green('→') : ' ', selected ? pc.bold(key) : key, radius.label, pc.dim(radius.radius) ] }) console.log(pc.dim(' Radius')) console.log( columns( ['', 'key', 'label', 'value'].map(h => pc.dim(h)), radiusRows ) + '\n' ) if (notice) console.log(pc.yellow(notice) + '\n') ws.close() process.exit(0) } ws.onerror = () => void exitControlUnreachable() } }) // The CLI-vs-server freshness check, shared by `moi status` and `moi update`: // compares this CLI's version against what the running server reports over the // control port. Works no matter how the upgrade happened (`moi update` or a // manual package-manager install) — the comparison reads live state, it does // not live inside the update command. function versionMismatchNotice(serverVersion: string): string | null { if (serverVersion === VERSION) return null return ( pc.yellow('⚠') + ` Server is running v${serverVersion}, this CLI is v${VERSION}.\n` + pc.dim(' Restart to match: `moi service restart` (service) or Ctrl-C + `moi start`.') ) } const status = defineCommand({ meta: { name: 'status', description: 'Show server status and registered workspaces' }, async run() { const probe: ControlProbe = await probeControlServer() const notice = await staleSkillNotice(process.cwd()) if (probe.status === 'not-running') { console.log('\n' + pc.dim('○') + ' Server is ' + pc.bold('not running')) console.log(pc.dim(` cli version ${VERSION}`) + '\n') process.exit(0) } // The dial failed for a reason other than an empty port, so the server may // be up and healthy. Name the real obstacle instead of blaming the process. if (probe.status === 'unreachable') { console.log('\n' + pc.yellow('◆') + ' Server is ' + pc.bold('unreachable')) console.log(pc.dim(` control port ${CONTROL_HOST}:${CONTROL_PORT} — ${probe.reason}`)) console.log(pc.dim(` cli version ${VERSION}`) + '\n') process.exit(1) } const info = await queryServerInfo() console.log( '\n' + pc.green('●') + ' Server is ' + pc.bold('running') + pc.dim( ` (http port: ${info?.port || PORT}, control port: ${CONTROL_PORT}` + (info?.pid ? `, pid: ${info.pid}` : '') + ')' ) ) if (info) { console.log( pc.dim(` server version ${info.version}`) + (info.service ? pc.dim(' · service-managed (`moi service`)') : '') ) } console.log(pc.dim(` cli version ${VERSION}`)) const mismatch = info ? versionMismatchNotice(info.version) : null if (mismatch) console.log('\n' + mismatch) await new Promise((resolve, reject) => { const ws = new WebSocket(CONTROL_URL) ws.onopen = () => ws.send(JSON.stringify({ type: 'workspace:list' })) ws.onmessage = event => { const res = JSON.parse(String(event.data)) const workspaces: WorkspaceEntry[] = res.workspaces ?? [] const n = workspaces.length console.log(pc.dim(` ${n} workspace${n === 1 ? '' : 's'} registered\n`)) ws.close() resolve() } ws.onerror = () => void probeControlServer().then(p => reject(new Error(controlFailureMessage(p)))) }) if (notice) console.log(pc.yellow(notice) + '\n') process.exit(0) } }) // ---- env subcommands -------------------------------------------------------- function envFail(message: string): never { console.error('\n' + pc.red('✗') + ' ' + message + '\n') process.exit(1) } const envSet = defineCommand({ meta: { name: 'set', description: 'Set custom secrets: `moi env set KEY=value [KEY=value...]`, or `moi env set KEY` to read one value from stdin' }, args: { key: { type: 'positional', required: true, description: 'KEY=value pair(s), or a single bare KEY to read the value from stdin' } }, async run({ args }) { const entry = await resolveCwdWorkspace() const pairs = args._ const set: Record = {} if (pairs.length === 1 && !pairs[0].includes('=')) { // Bare-KEY form: one key, value from stdin (hidden prompt on a TTY). const key = pairs[0] if (!isValidEnvKey(key)) envFail(`Invalid env key: ${key}`) let value: string try { value = await readSecretValue(key) } catch { // Ctrl-C at the hidden prompt. process.exit(1) } // An empty value is almost always an unset variable on the piping side — // storing '' would silently shadow a real .env value with nothing. if (value === '') envFail(`Empty value for ${key} — pipe a non-empty value or pass ${key}=value`) set[key] = value } else { for (const pair of pairs) { const eq = pair.indexOf('=') if (eq === -1) { envFail( `Missing value for ${pair} — use KEY=value (bare KEY reads stdin only when set alone)` ) } const key = pair.slice(0, eq) const value = pair.slice(eq + 1) if (!isValidEnvKey(key)) envFail(`Invalid env key: ${key}`) if (value === '') envFail(`Empty value for ${key} — use \`moi env unset ${key}\` to remove a key`) set[key] = value } } await updateWorkspaceEnv(entry.path, { set }) await notifyEnvChanged(entry.path) for (const key of Object.keys(set)) { console.log(pc.green('✓') + ` Set ${pc.bold(key)} ${pc.dim('(custom)')}`) } // A plaintext fallback should never go unnoticed at write time. if ((await secretBackend()) === 'file') { console.log(pc.dim(' Stored in a 0600 file — OS keychain unavailable.')) } process.exit(0) } }) const envUnset = defineCommand({ meta: { name: 'unset', description: 'Remove custom secrets: `moi env unset KEY [KEY...]`' }, args: { key: { type: 'positional', required: true, description: 'Key(s) to remove' } }, async run({ args }) { const entry = await resolveCwdWorkspace() const view = await getWorkspaceEnvView(entry.path) const byKey = new Map(view.vars.map(v => [v.key, v])) // Only custom secrets are removable; a dotenv-sourced key lives in its file. const removable: string[] = [] let hadError = false for (const key of args._) { const v = byKey.get(key) if (!v) { console.warn(pc.yellow('!') + ` ${key} is not set — skipping`) continue } if (v.source === 'dotenv') { console.error( pc.red('✗') + ` ${key} comes from ${(v.files ?? []).join(', ')} — edit that file instead` + pc.dim(' (moi env unset only removes custom secrets)') ) hadError = true continue } removable.push(key) } if (removable.length > 0) { await updateWorkspaceEnv(entry.path, { remove: removable }) await notifyEnvChanged(entry.path) for (const key of removable) { const v = byKey.get(key) const unshadow = v?.source === 'both' ? pc.dim(` (falls back to ${(v.files ?? []).join(', ')})`) : '' console.log(pc.green('✓') + ` Removed ${pc.bold(key)}${unshadow}`) } } process.exit(hadError ? 1 : 0) } }) const envExec = defineCommand({ meta: { name: 'exec', description: 'Run a command with the workspace env: `moi env exec -- [args...]`' }, async run({ rawArgs }) { // Everything after `--` is the child command, untouched by flag parsing. const sep = rawArgs.indexOf('--') const cmd = sep === -1 ? [] : rawArgs.slice(sep + 1) if (cmd.length === 0) { console.error('\n' + pc.red('✗') + ' Usage: moi env exec -- [args...]\n') process.exit(1) } const entry = await resolveCwdWorkspace() process.exit(await execWithEnv(entry.path, cmd)) } }) const envSubCommands = { set: envSet, unset: envUnset, exec: envExec } const env = defineCommand({ meta: { name: 'env', description: 'Show the workspace env (key names only — never values)' }, subCommands: envSubCommands, async run({ rawArgs }) { // citty invokes the parent run even after dispatching a subcommand — only // render the table when no subcommand ran. Object.hasOwn so prototype // names ('constructor', 'toString') never count as a dispatched command. const first = rawArgs.find(a => !a.startsWith('-')) if (first && Object.hasOwn(envSubCommands, first)) return const entry = await resolveCwdWorkspace() // Lazy: required-env pulls the widget/view bundler chain (TS compiler), // which must not load for every other `moi` command's startup. const { requiredEnvFor } = await import('./required-env') const view = await getWorkspaceEnvView(entry.path, requiredEnvFor(entry.path)) console.log('\n' + renderEnvView(entry, view) + '\n') process.exit(0) } }) // ---- provider commands (openclaw / hermes) ---------------------------------- // `moi ` on its own lists the commands AND what they can be run // against, so discovering the installable agents never requires `init`. function printProviderCommands(provider: string, argHint: string) { console.log('\n' + pc.bold(' Commands')) console.log( keyValue( [ [ pc.bold(`moi ${provider} init`) + ' ' + pc.dim(argHint), 'Install moi skills and register the workspace' ] ], ' ' ) ) } // The argument is optional when there is exactly one candidate, so the hint // should not demand one that isn't needed. function initHint(provider: string, count: number, label: string): string { return count === 1 ? `moi ${provider} init` : `moi ${provider} init <${label}>` } // ---- openclaw subcommands --------------------------------------------------- // Match an `agent` argument against `agentId` (exact) or `name` // (case-insensitive). Returns the matching agent or null. function findAgent(agents: OpenClawAgent[], query: string): OpenClawAgent | null { const exact = agents.find(a => a.agentId === query) if (exact) return exact const q = query.toLowerCase() return agents.find(a => a.name?.toLowerCase() === q) ?? null } function printAgentTable(agents: OpenClawAgent[]) { console.log( columns( ['', 'agentId', 'name', 'workspace'].map(h => pc.dim(h)), agents.map(a => [ a.isDefault ? pc.green('●') : ' ', a.isDefault ? pc.bold(a.agentId) : a.agentId, a.name ?? pc.dim('—'), pc.dim(a.path) ]) ) ) } const openclawInit = defineCommand({ meta: { name: 'init', description: 'Install moi skills into an OpenClaw agent workspace. Run without args to list discovered agents.' }, args: { agent: { type: 'positional', required: false, description: 'Agent id or name (omit to list agents)' } }, async run({ args }) { const agents = await discoverOpenClawAgents() if (agents.length === 0) { console.error( '\n' + pc.red('✗') + ' No OpenClaw agents discovered.\n' + pc.dim( ' Make sure the OpenClaw gateway is running and ~/.openclaw/openclaw.json is set.\n' ) ) process.exit(1) } // No argument: with a single agent there is nothing to choose, so install // into it. Only an ambiguous choice needs the list. if (!args.agent && agents.length > 1) { console.log('\n' + pc.bold('OpenClaw agents')) console.log( pc.dim(' Run ' + pc.bold('moi openclaw init ') + ' to install skills.\n') ) printAgentTable(agents) console.log() process.exit(0) } const target = args.agent ? findAgent(agents, args.agent) : agents[0] if (!target) { console.error('\n' + pc.red('✗') + ' Agent not found: ' + pc.bold(args.agent) + '\n') console.log(' Available:\n') printAgentTable(agents) console.log() process.exit(1) } // Shared provisioning path with `moi init`: skills land in // /skills// (OpenClaw resolves /skills // with the highest precedence, so these win over any same-named bundled or // per-user skill), plus the `.moi/` bootstrap — the widgets skill assumes // the folder and its dependencies exist. Existing `.moi/` stays untouched. const { scaffold, skillsDir: skillsRoot } = await provisionWorkspace(target.path, 'openclaw') if (scaffold !== 'exists') { if (scaffold === 'installing') { console.log('\n' + pc.dim(' Widget dependencies still installing in .moi/ (background)')) } else if (scaffold === 0) { console.log('\n' + pc.dim(' Installed widget dependencies in .moi/')) } else { console.warn('\n' + pc.yellow(' bun install failed — run it manually in .moi/')) } } // Register in the moi registry so the agent's workspace appears in the // UI workspace list. Mirrors what `moi init` does for Claude Code. const entry = await registerWorkspace(target.path, { type: 'openclaw', name: target.name, agentId: target.agentId, isDefault: target.isDefault, lastRunAt: target.lastRunAt }) console.log('\n' + pc.green('✓') + ' Installed skills to ' + pc.bold(skillsRoot)) console.log( pc.dim(' Agent: ') + pc.bold(target.agentId) + (target.name ? pc.dim(' (' + target.name + ')') : '') ) const isInteractive = process.stdout.isTTY const running = await isServerRunning() if (running) { const url = `http://localhost:${PORT}/workspace/${entry.id}` // Notify the running server so it picks up the new entry without a // restart, then open (browser only in interactive mode) — same shape // as `moi init`'s already-running branch. await registerViaControl(target.path) if (isInteractive) { console.log(' Opening ' + pc.bold(url) + '\n') await openBrowser(url) } else { console.log(' Ready at ' + pc.bold(url) + '\n') } } else { console.log(' Run ' + pc.bold('moi start') + ' to open in the browser\n') } process.exit(0) } }) const openclaw = defineCommand({ meta: { name: 'openclaw', description: 'OpenClaw integration commands' }, async run({ rawArgs }) { // citty runs the parent even after dispatching a subcommand — the bare // form is the only one this handles. if (rawArgs.some(arg => !arg.startsWith('-'))) return console.log('\n' + pc.bold('OpenClaw')) printProviderCommands('openclaw', '[agent]') const agents = await discoverOpenClawAgents().catch(() => []) if (agents.length === 0) { console.log( '\n ' + pc.dim('No agents discovered — start the OpenClaw gateway and check ') + pc.dim('~/.openclaw/openclaw.json') + '\n' ) return } console.log('\n' + pc.bold(' Agents')) printAgentTable(agents) console.log(pc.dim('\n Run ') + pc.bold(initHint('openclaw', agents.length, 'agentId')) + '\n') }, subCommands: { init: openclawInit } }) // ---- hermes subcommands ----------------------------------------------------- function printProfileTable(profiles: HermesProfile[]) { console.log( columns( ['', 'profile', 'model', 'workspace'].map(h => pc.dim(h)), profiles.map(p => [ p.isDefault ? pc.green('●') : ' ', p.isDefault ? pc.bold(p.agentId) : p.agentId, p.model ?? pc.dim('—'), pc.dim(p.path) ]) ) ) } const hermesInit = defineCommand({ meta: { name: 'init', description: 'Install moi skills into a Hermes profile workspace. Run without args to list discovered profiles.' }, args: { profile: { type: 'positional', required: false, description: 'Profile id or description (omit to list profiles)' } }, async run({ args }) { const profiles = await discoverHermesProfiles() if (profiles.length === 0) { console.error( '\n' + pc.red('✗') + ' No Hermes profiles discovered.\n' + pc.dim(' Install Hermes and run ' + pc.bold('hermes setup') + ' first.\n') ) process.exit(1) } // No argument: with a single profile there is nothing to choose, so // install into it. Only an ambiguous choice needs the list. if (!args.profile && profiles.length > 1) { console.log('\n' + pc.bold('Hermes profiles')) console.log(pc.dim(' Run ' + pc.bold('moi hermes init ') + ' to install skills.\n')) printProfileTable(profiles) console.log() process.exit(0) } const target = args.profile ? matchHermesProfile(profiles, args.profile) : profiles[0] if (!target) { console.error('\n' + pc.red('✗') + ' Profile not found: ' + pc.bold(args.profile) + '\n') console.log(' Available:\n') printProfileTable(profiles) console.log() process.exit(1) } // Shared provisioning path with `moi init`: skills land in // /workspace/skills// (Hermes resolves the workspace skills // dir with the highest precedence, so these win over bundled ones), plus // the `.moi/` bootstrap. The default profile has no workspace directory // until now — provisionWorkspace creates it. const { scaffold, skillsDir: skillsRoot } = await provisionWorkspace(target.path, 'hermes') if (scaffold !== 'exists') { if (scaffold === 'installing') { console.log('\n' + pc.dim(' Widget dependencies still installing in .moi/ (background)')) } else if (scaffold === 0) { console.log('\n' + pc.dim(' Installed widget dependencies in .moi/')) } else { console.warn('\n' + pc.yellow(' bun install failed — run it manually in .moi/')) } } const entry = await registerWorkspace(target.path, { type: 'hermes', name: target.name, agentId: target.agentId, isDefault: target.isDefault }) console.log('\n' + pc.green('✓') + ' Installed skills to ' + pc.bold(skillsRoot)) console.log( pc.dim(' Profile: ') + pc.bold(target.agentId) + (target.model ? pc.dim(' (' + target.model + ')') : '') ) const isInteractive = process.stdout.isTTY const running = await isServerRunning() if (running) { const url = `http://localhost:${PORT}/workspace/${entry.id}` await registerViaControl(target.path) if (isInteractive) { console.log(' Opening ' + pc.bold(url) + '\n') await openBrowser(url) } else { console.log(' Ready at ' + pc.bold(url) + '\n') } } else { console.log(' Run ' + pc.bold('moi start') + ' to open in the browser\n') } process.exit(0) } }) const hermes = defineCommand({ meta: { name: 'hermes', description: 'Hermes Agent integration commands' }, async run({ rawArgs }) { // citty runs the parent even after dispatching a subcommand — the bare // form is the only one this handles. if (rawArgs.some(arg => !arg.startsWith('-'))) return console.log('\n' + pc.bold('Hermes')) printProviderCommands('hermes', '[profile]') const profiles = await discoverHermesProfiles().catch(() => []) if (profiles.length === 0) { console.log( '\n ' + pc.dim('No profiles discovered — install Hermes and run ') + pc.dim('hermes setup') + '\n' ) return } console.log('\n' + pc.bold(' Profiles')) printProfileTable(profiles) console.log(pc.dim('\n Run ') + pc.bold(initHint('hermes', profiles.length, 'profile')) + '\n') }, subCommands: { init: hermesInit } }) async function sendConfig(payload: { path: string name?: string iconPath?: string clearName?: boolean clearIcon?: boolean }) { const notice = await staleSkillNotice(payload.path) const ws = new WebSocket(CONTROL_URL) ws.onopen = () => ws.send(JSON.stringify({ type: 'config', ...payload })) ws.onmessage = event => { const res = JSON.parse(String(event.data)) if (res.error) { console.error('\n' + pc.red(pc.bold('Error:')) + ' ' + res.error + '\n') ws.close() process.exit(1) } console.log() if (res.ok) { if (res.clearedName) console.log(pc.green('✓') + ' Name reset to ' + pc.dim('(folder name)')) else if (res.name) console.log(pc.green('✓') + ' Name set to ' + pc.bold(res.name)) if (res.clearedIcon) console.log(pc.green('✓') + ' Icon reset to ' + pc.dim('default (provider)')) else if (res.icon) console.log(pc.green('✓') + ' Icon updated ' + pc.dim('(128×128 webp)')) } else { console.log(pc.bold('moi config') + ' — workspace identity\n') console.log( ' ' + pc.dim('name') + ' ' + (res.name ? pc.bold(res.name) : pc.dim('(folder name)')) ) console.log( ' ' + pc.dim('icon') + ' ' + (res.hasIcon ? pc.green('custom') : pc.dim('default (provider)')) ) } console.log() if (notice) console.log(pc.yellow(notice) + '\n') ws.close() process.exit(0) } ws.onerror = () => void exitControlUnreachable() } // A terse cheat sheet — one line per command — so an agent can grasp the // surface at a glance instead of parsing citty's full ARGUMENTS/OPTIONS dump. function printConfigHelp() { const row = (cmd: string, desc: string) => ' ' + pc.cyan(cmd.padEnd(34)) + pc.dim(desc) console.log() console.log(pc.bold('moi config') + pc.dim(' — workspace name & icon')) console.log() console.log(row('moi config', 'Show current name & icon')) console.log(row('moi config name "My WS"', 'Set the display name')) console.log(row('moi config name --clear', 'Reset name to folder default')) console.log(row('moi config icon ./logo.png', 'Set icon (png/jpg/gif/webp → 128×128 webp)')) console.log(row('moi config icon --clear', 'Reset icon to provider default')) console.log(row(' --dir ', 'Target workspace (default: current dir)')) console.log() } const config = defineCommand({ meta: { name: 'config', description: 'Show or set the workspace name and icon (moi config [name|icon] )' }, args: { field: { type: 'positional', required: false, description: '"name" or "icon" — omit to show the current config' }, value: { type: 'positional', required: false, description: 'The new name, or a path to an image file (png/jpg/gif/webp)' }, clear: { type: 'boolean', description: 'Reset the field to its default (folder name / provider icon)' }, dir: { type: 'string', default: '.', description: 'Workspace directory (default: current)' } }, run({ args }) { const path = resolve(args.dir) if (args.field === 'help') { printConfigHelp() return } if (!args.field) { sendConfig({ path }) return } if (args.field === 'name') { if (args.clear) { sendConfig({ path, clearName: true }) return } if (!args.value) { console.error(pc.red('Usage:') + ' moi config name ""') process.exit(1) } sendConfig({ path, name: args.value }) } else if (args.field === 'icon') { if (args.clear) { sendConfig({ path, clearIcon: true }) return } if (!args.value) { console.error(pc.red('Usage:') + ' moi config icon ') process.exit(1) } sendConfig({ path, iconPath: resolve(args.value) }) } else { console.error( pc.red(`Unknown field "${args.field}".`) + ' Use ' + pc.bold('name') + ' or ' + pc.bold('icon') + '.' ) process.exit(1) } } }) // ---- scratch (Scratchpad canvas) -------------------------------------------- // Parse a "x,y" coordinate pair (tldraw canvas space, y down). function parseXY(s: string): { x: number; y: number } { const parts = s.split(',').map(p => Number(p.trim())) if (parts.length !== 2 || !parts.every(n => Number.isFinite(n))) { throw new Error(`Expected "x,y", got "${s}"`) } return { x: parts[0], y: parts[1] } } // An arrow endpoint: a bare "x,y" is a free point; anything else is a shape name // to bind to (so the arrow follows that shape). function parseEnd(s: string): ScratchArrowEnd { if (/^-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?$/.test(s)) return parseXY(s) return { name: s } } // The Scratchpad palette (matches the UI toolbar's six swatches) and each color's // light-theme solid hex — used to snap an arbitrary `--color #rrggbb` to the nearest // palette entry (tldraw shapes can't hold free hex). Keep in sync with the swatches // in client/components/Scratchpad.tsx. const COLOR_HEX: Record = { black: '#1d1d1d', red: '#e03131', yellow: '#f1ac4b', green: '#099268', blue: '#4465e9', grey: '#9fa8b2' } const COLOR_NAMES = Object.keys(COLOR_HEX) as ScratchColor[] // Arrows expose tldraw's size as a line weight; the CLI mirrors the UI's two sizes. const STROKE_SIZES: Record = { small: 'm', large: 'xl' } const STROKE_NAMES = Object.keys(STROKE_SIZES) // Text & notes expose the same size style as a label font size, under friendlier names. const FONT_SIZES: Record = { regular: 'm', big: 'xl' } const FONT_SIZE_NAMES = Object.keys(FONT_SIZES) // Rectangle fills — the UI toolbar's four options. Each user-facing name maps onto a // tldraw DefaultFillStyle value (see ScratchFill for tldraw's semi/solid quirk). Keep // in sync with FILL_OPTIONS in client/components/Scratchpad.tsx. const FILL_STYLES: Record = { none: 'none', semi: 'solid', pattern: 'pattern', solid: 'fill' } const FILL_NAMES = Object.keys(FILL_STYLES) function hexToRgb(hex: string): [number, number, number] | null { let h = hex.trim().replace(/^#/, '') if (h.length === 3) h = h.replace(/(.)/g, '$1$1') if (!/^[0-9a-fA-F]{6}$/.test(h)) return null return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] } // Accept a palette name as-is, or snap any hex to the nearest palette color by // squared RGB distance. Throws on anything else. function parseColor(s: string): ScratchColor { const lower = s.trim().toLowerCase() if ((COLOR_NAMES as string[]).includes(lower)) return lower as ScratchColor const rgb = hexToRgb(s) if (!rgb) { throw new Error( `Unknown color "${s}". Use a hex like "#4465e9" or one of: ${COLOR_NAMES.join(', ')}.` ) } let best: ScratchColor = 'black' let bestDist = Infinity for (const name of COLOR_NAMES) { const [r, g, b] = hexToRgb(COLOR_HEX[name])! const d = (r - rgb[0]) ** 2 + (g - rgb[1]) ** 2 + (b - rgb[2]) ** 2 if (d < bestDist) { bestDist = d best = name } } return best } function parseStroke(s: string): ScratchSize { const size = STROKE_SIZES[s.trim().toLowerCase()] if (!size) throw new Error(`Unknown stroke "${s}". Use one of: ${STROKE_NAMES.join(', ')}.`) return size } function parseFontSize(s: string): ScratchSize { const size = FONT_SIZES[s.trim().toLowerCase()] if (!size) throw new Error(`Unknown font size "${s}". Use one of: ${FONT_SIZE_NAMES.join(', ')}.`) return size } function parseFill(s: string): ScratchFill { const fill = FILL_STYLES[s.trim().toLowerCase()] if (!fill) throw new Error(`Unknown fill "${s}". Use one of: ${FILL_NAMES.join(', ')}.`) return fill } // Resize preset for `add image` — defaults to 'lo' (keep the canvas light). function parseImageQuality(s: string | undefined): ScratchImageQuality { if (!s) return 'lo' const q = s.trim().toLowerCase() if (q === 'lo' || q === 'hi') return q throw new Error(`Unknown quality "${s}". Use "lo" or "hi".`) } // Optional styling shared across `add` commands — each command wires in only the // controls its shape exposes (mirroring the UI's per-tool style bar). const colorArg = { type: 'string', description: `Color: ${COLOR_NAMES.join(', ')}, or any hex (snapped to nearest)` } as const const strokeArg = { type: 'string', description: `Stroke weight: ${STROKE_NAMES.join(', ')}` } as const const fontSizeArg = { type: 'string', description: `Font size: ${FONT_SIZE_NAMES.join(', ')}` } as const const fillArg = { type: 'string', default: 'semi', description: `Fill: ${FILL_NAMES.join(', ')} (default: semi)` } as const // Build the optional style props from raw args. `stroke` and `fontSize` are two names // for the same tldraw size style, so at most one is wired per command. function styleArgs(args: { color?: string stroke?: string fontSize?: string fill?: string }): ScratchStyle { return { ...(args.color ? { color: parseColor(args.color) } : {}), ...(args.stroke ? { size: parseStroke(args.stroke) } : {}), ...(args.fontSize ? { size: parseFontSize(args.fontSize) } : {}), ...(args.fill ? { fill: parseFill(args.fill) } : {}) } } type ScratchCliOp = ScratchOp | { kind: 'read' } | { kind: 'read-image'; name: string } // Round-trip one request through the control port and hand the reply to // `onResult`. Mirrors the `bundle`/`theme` commands: one socket per invocation, // print, exit. Shared by `scratch`, `call-server-fn`, and `debug logs`. function sendControl( path: string, payload: Record, onResult: (res: Record) => void | Promise ) { const ws = new WebSocket(CONTROL_URL) ws.onopen = () => ws.send(JSON.stringify(payload)) ws.onmessage = async event => { const res = JSON.parse(String(event.data)) if (res.error) { console.error('\n' + pc.red('✗') + ' ' + res.error + '\n') ws.close() process.exit(1) } await onResult(res) // The stale-skill notice rides on stderr so it never corrupts a command's // structured stdout (read's JSON, view's PNG path) while the agent still sees it. const notice = await staleSkillNotice(path) if (notice) console.error('\n' + pc.yellow(notice) + '\n') ws.close() process.exit(0) } ws.onerror = () => void exitControlUnreachable() } function sendScratch( path: string, op: ScratchCliOp, onResult: (res: Record) => void | Promise ) { sendControl(path, { type: 'scratch', path, op }, onResult) } // Print the name a draw op landed on, so the agent can address it later. function printAdded(res: Record) { const result = res.result as { name?: string } | undefined console.log('\n' + pc.green('✓') + ' added ' + pc.bold(result?.name ?? '(shape)') + '\n') } const dirArg = { type: 'string', default: '.', description: 'Workspace directory (default: current)' } as const const scratchRead = defineCommand({ meta: { name: 'read', description: 'Print the canvas shapes as JSON (served off disk)' }, args: { dir: dirArg }, run({ args }) { sendScratch(resolve(args.dir), { kind: 'read' }, res => { console.log(JSON.stringify(res.shapes ?? [], null, 2)) }) } }) const scratchView = defineCommand({ meta: { name: 'view', description: 'Render the canvas to a PNG (needs an open Scratchpad tab)' }, args: { dir: dirArg, out: { type: 'string', description: 'Output PNG path (default: a temp file)' } }, async run({ args }) { sendScratch(resolve(args.dir), { kind: 'view' }, async res => { const result = res.result as { image?: string } | undefined if (!result?.image) { console.error(pc.red('No image returned')) process.exit(1) } const b64 = result.image.replace(/^data:image\/png;base64,/, '') const outPath = args.out ? resolve(args.out) : join(tmpdir(), `moi-scratch-${Date.now()}.png`) await Bun.write(outPath, Buffer.from(b64, 'base64')) console.log(outPath) }) } }) // Image/video data URL mime → file extension, for naming the saved file. Kept in // sync with the server's MIME_EXT (scratchpad-assets.ts) so avif/apng/video assets // round-trip through `read-image` with a real extension instead of `.bin`. const IMAGE_EXT: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/svg+xml': 'svg', 'image/avif': 'avif', 'image/apng': 'apng', 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/quicktime': 'mov' } const scratchReadImage = defineCommand({ meta: { name: 'read-image', description: 'Save an image shape to a file by id (served off disk)' }, args: { id: { type: 'positional', required: true, description: 'Image shape id (from `scratch read`)' }, out: { type: 'string', description: 'Output file path (default: a temp file)' }, dir: dirArg }, async run({ args }) { sendScratch(resolve(args.dir), { kind: 'read-image', name: args.id }, async res => { const src = res.src as string | undefined if (!src) { console.error(pc.red('No image data')) process.exit(1) } // A remote (http) asset has no local bytes to write — just print the URL. if (/^https?:\/\//.test(src)) { console.log(src) return } const m = src.match(/^data:([^;,]+)(;base64)?,(.*)$/s) if (!m) { console.error(pc.red('Unrecognized image source')) process.exit(1) } const [, mime, base64, data] = m const bytes = base64 ? Buffer.from(data, 'base64') : Buffer.from(decodeURIComponent(data), 'utf8') const ext = IMAGE_EXT[mime] ?? 'bin' const safeId = args.id.replace(/[^a-zA-Z0-9_-]/g, '_') const outPath = args.out ? resolve(args.out) : join(tmpdir(), `moi-scratch-${safeId}-${Date.now()}.${ext}`) await Bun.write(outPath, bytes) console.log(outPath) }) } }) const scratchAddText = defineCommand({ meta: { name: 'text', description: 'Add a text shape' }, args: { at: { type: 'string', required: true, description: 'Position "x,y"' }, text: { type: 'string', required: true, description: 'Text content' }, id: { type: 'string', description: 'Stable name to address this shape later' }, color: colorArg, fontSize: fontSizeArg, dir: dirArg }, run({ args }) { const { x, y } = parseXY(args.at) sendScratch( resolve(args.dir), { kind: 'add-text', name: args.id ?? '', x, y, text: args.text, ...styleArgs({ color: args.color, fontSize: args.fontSize }) }, printAdded ) } }) const scratchAddRect = defineCommand({ meta: { name: 'rect', description: 'Add a rectangle' }, args: { at: { type: 'string', required: true, description: 'Top-left position "x,y"' }, size: { type: 'string', required: true, description: 'Size "w,h"' }, text: { type: 'string', description: 'Optional label' }, id: { type: 'string', description: 'Stable name to address this shape later' }, color: colorArg, fill: fillArg, fontSize: fontSizeArg, dir: dirArg }, run({ args }) { const { x, y } = parseXY(args.at) const { x: w, y: h } = parseXY(args.size) sendScratch( resolve(args.dir), { kind: 'add-rect', name: args.id ?? '', x, y, w, h, ...(args.text ? { text: args.text } : {}), ...styleArgs({ color: args.color, fill: args.fill, fontSize: args.fontSize }) }, printAdded ) } }) const scratchAddNote = defineCommand({ meta: { name: 'note', description: 'Add a sticky note' }, args: { at: { type: 'string', required: true, description: 'Position "x,y"' }, text: { type: 'string', required: true, description: 'Note content' }, id: { type: 'string', description: 'Stable name to address this shape later' }, color: colorArg, fontSize: fontSizeArg, dir: dirArg }, run({ args }) { const { x, y } = parseXY(args.at) sendScratch( resolve(args.dir), { kind: 'add-note', name: args.id ?? '', x, y, text: args.text, ...styleArgs({ color: args.color, fontSize: args.fontSize }) }, printAdded ) } }) const scratchAddArrow = defineCommand({ meta: { name: 'arrow', description: 'Add an arrow connecting shapes or points' }, args: { from: { type: 'string', required: true, description: 'Start: a shape name or "x,y"' }, to: { type: 'string', required: true, description: 'End: a shape name or "x,y"' }, id: { type: 'string', description: 'Stable name to address this shape later' }, elbow: { type: 'boolean', description: 'Right-angle (squared) routing for diagrams; default is a curved arc' }, color: colorArg, stroke: strokeArg, dir: dirArg }, run({ args }) { sendScratch( resolve(args.dir), { kind: 'add-arrow', name: args.id ?? '', from: parseEnd(args.from), to: parseEnd(args.to), ...(args.elbow ? { elbow: true } : {}), ...styleArgs({ color: args.color, stroke: args.stroke }) }, printAdded ) } }) const scratchAddImage = defineCommand({ meta: { name: 'image', description: 'Add an image from a file (resized to fit the canvas)' }, args: { path: { type: 'positional', required: true, description: 'Path to an image file (png/jpg/webp/gif)' }, at: { type: 'string', description: 'Top-left position "x,y" (default: 0,0)' }, id: { type: 'string', description: 'Stable name to address this shape later' }, quality: { type: 'string', description: 'Resize: lo (default, smaller) or hi (sharper)' }, dir: dirArg }, run({ args }) { const { x, y } = args.at ? parseXY(args.at) : { x: 0, y: 0 } sendScratch( resolve(args.dir), { kind: 'add-image', name: args.id ?? '', x, y, path: resolve(args.path), quality: parseImageQuality(args.quality) }, printAdded ) } }) const scratchAdd = defineCommand({ meta: { name: 'add', description: 'Add a shape: text, rect, note, arrow, or image' }, subCommands: { text: scratchAddText, rect: scratchAddRect, note: scratchAddNote, arrow: scratchAddArrow, image: scratchAddImage } }) const scratchMove = defineCommand({ meta: { name: 'move', description: 'Move a shape to a new position' }, args: { id: { type: 'positional', required: true, description: 'Shape name' }, to: { type: 'string', required: true, description: 'New position "x,y"' }, dir: dirArg }, run({ args }) { const { x, y } = parseXY(args.to) sendScratch(resolve(args.dir), { kind: 'move', name: args.id, x, y }, () => console.log('\n' + pc.green('✓') + ' moved ' + pc.bold(args.id) + '\n') ) } }) const scratchSet = defineCommand({ meta: { name: 'set', description: "Relabel / edit a shape's text" }, args: { id: { type: 'positional', required: true, description: 'Shape name' }, text: { type: 'string', required: true, description: 'New text' }, dir: dirArg }, run({ args }) { sendScratch(resolve(args.dir), { kind: 'set', name: args.id, text: args.text }, () => console.log('\n' + pc.green('✓') + ' updated ' + pc.bold(args.id) + '\n') ) } }) const scratchDelete = defineCommand({ meta: { name: 'delete', description: 'Delete a shape' }, args: { id: { type: 'positional', required: true, description: 'Shape name' }, dir: dirArg }, run({ args }) { sendScratch(resolve(args.dir), { kind: 'delete', name: args.id }, () => console.log('\n' + pc.green('✓') + ' deleted ' + pc.bold(args.id) + '\n') ) } }) const scratchClear = defineCommand({ meta: { name: 'clear', description: 'Delete every shape — wipe the whole canvas' }, args: { dir: dirArg }, run({ args }) { sendScratch(resolve(args.dir), { kind: 'clear' }, () => console.log('\n' + pc.green('✓') + ' cleared the canvas\n') ) } }) const scratch = defineCommand({ meta: { name: 'scratch', description: 'Read and draw on the workspace Scratchpad canvas' }, subCommands: { read: scratchRead, 'read-image': scratchReadImage, view: scratchView, add: scratchAdd, move: scratchMove, set: scratchSet, delete: scratchDelete, clear: scratchClear } }) // ---- self-correction commands (docs/self-correction.md) --------------------- const callServerFn = defineCommand({ meta: { name: 'call-server-fn', description: 'Invoke an applet .server.ts function in an isolated one-shot worker (smoke test)' }, args: { fn: { type: 'positional', required: true, description: 'Function path: /, e.g. widgets/hello/getGreeting' }, args: { type: 'positional', required: false, description: 'Arguments as one JSON array, e.g. \'["ann", 10]\' (default [])' }, dir: dirArg }, run({ args }) { const path = resolve(args.dir) sendControl( path, { type: 'call-server-fn', path, fn: args.fn, args: args.args ?? '[]' }, res => { // The worker replies devalue-encoded (same wire format the browser RPC // parses), so Map/Set/Date render readably through Bun.inspect. const value = devalueParse(String(res.result)) console.log(Bun.inspect(value, { depth: 8, colors: process.stdout.isTTY })) // Duration on stderr: stdout stays clean data, and a slow call is a // warning sign worth surfacing (browser RPC times out at 30s). console.error(pc.dim(`↩ ${res.ms}ms`)) } ) } }) // Compact relative age for a journal row: "just now", "4m ago", "2h ago", … function agoLabel(ts: number): string { const s = Math.max(0, Math.round((Date.now() - ts) / 1000)) if (s < 60) return 'just now' if (s < 3600) return `${Math.round(s / 60)}m ago` if (s < 86400) return `${Math.round(s / 3600)}h ago` return `${Math.round(s / 86400)}d ago` } // Where a journal entry points: the server function for rpc rows, else the applet. function logSubject(e: { source: string kind?: string name?: string module?: string fn?: string }): string { if (e.module) return `${e.module}/${e.fn ?? '?'}` if (e.name) return `${e.kind ?? 'applet'} ${e.name}` return 'unattributed' } const MAX_LOG_MESSAGE_LINES = 12 const debugLogs = defineCommand({ meta: { name: 'logs', description: 'Applet runtime errors on record (load / render / rpc / build failures)' }, args: { dir: dirArg, json: { type: 'boolean', default: false, description: 'Machine-readable output, includes stacks and epoch timestamps' }, clear: { type: 'boolean', default: false, description: 'Wipe the journal' } }, run({ args }) { const path = resolve(args.dir) if (args.clear) { sendControl(path, { type: 'debug:logs', path, clear: true }, res => { console.log('\n' + pc.green('✓') + ` cleared ${res.cleared ?? 0} entries\n`) }) return } sendControl(path, { type: 'debug:logs', path }, res => { const entries = (Array.isArray(res.entries) ? res.entries : []) as AppletLogEntry[] if (args.json) { console.log(JSON.stringify(entries, null, 2)) return } if (entries.length === 0) { console.log( '\n' + pc.bold('moi debug logs') + pc.dim(' — no applet errors on record') + '\n' ) return } console.log( '\n' + pc.bold('moi debug logs') + pc.dim(` — ${entries.length} error(s) on record`) + '\n' ) for (const e of entries) { const count = e.count > 1 ? pc.dim(` ×${e.count}`) : '' console.log( ` ${pc.dim(agoLabel(e.ts).padEnd(10))} ${e.source.padEnd(7)} ${pc.bold(logSubject(e))}${count}` ) const lines = e.message.split('\n') for (const line of lines.slice(0, MAX_LOG_MESSAGE_LINES)) { console.log(' ' + line) } if (lines.length > MAX_LOG_MESSAGE_LINES) { console.log(pc.dim(` … ${lines.length - MAX_LOG_MESSAGE_LINES} more lines (--json)`)) } console.log() } console.log( pc.dim(' Entries clear when their applet next builds successfully, or via --clear.') + '\n' ) }) } }) // Experimental workspace-debugging toolbox. One subcommand for now (`logs`); // more introspection (worker state, RPC traces, …) may hang off it later. const debug = defineCommand({ meta: { name: 'debug', description: 'Debug the workspace (experimental) — `moi debug logs` for applet errors' }, subCommands: { logs: debugLogs } }) // ---- workspace tabs ---------------------------------------------------------- // The shared listing behind `moi tabs` and bare `moi tab`: every tab (static + // views), one per row, the saved default (`layout.tabs.active`) marked. The // output shape is documented in docs/rfc-intents-v2.md §3 — keep them in sync. function runTabsList(dir: string) { const path = resolve(dir) sendControl(path, { type: 'tabs', path }, res => { type Row = { id: string; title: string; isDefault: boolean } const rows: Row[] = Array.isArray(res.tabs) ? (res.tabs as Row[]) : [] console.log( '\n' + pc.bold('moi tabs') + pc.dim(' — workspace tabs, the default one marked') + '\n' ) console.log( columns( ['', 'tab', 'title'].map(h => pc.dim(h)), rows.map(row => [ row.isDefault ? pc.green('●') : ' ', row.isDefault ? pc.bold(row.id) : row.id, row.title ]) ) ) console.log( '\n' + pc.dim(' Focus one: moi tab focus [--params \'{"k":"v"}\']') + '\n' ) }) } const tabFocus = defineCommand({ meta: { name: 'focus', description: 'Focus a workspace tab in every open client' }, args: { tab: { type: 'positional', required: true, description: 'Tab id from `moi tabs`, e.g. view:orders' }, params: { type: 'string', description: 'One JSON object delivered to the view as its params, e.g. \'{"order":"o-1"}\'' }, dir: dirArg }, run({ args }) { const path = resolve(args.dir) let params: Record | undefined if (args.params !== undefined) { try { const parsed: unknown = JSON.parse(args.params) if (!isParamsRecord(parsed)) throw new Error('not a JSON object') params = parsed } catch { console.error( '\n' + pc.red('✗') + ' --params must be one JSON object, e.g. \'{"order":"o-1"}\'\n' ) process.exit(1) } } sendControl( path, { type: 'tab:focus', path, tab: args.tab, ...(params ? { params } : {}) }, res => { console.log('\n' + pc.green('✓') + ' Focused ' + pc.bold(String(res.tab)) + '\n') } ) } }) const tabSubCommands = { focus: tabFocus } const tab = defineCommand({ meta: { name: 'tab', description: 'List workspace tabs, or focus one: `moi tab focus `' }, subCommands: tabSubCommands, args: { dir: dirArg }, run({ args, rawArgs }) { // citty invokes the parent run even after dispatching a subcommand — only // list when none ran (same pattern as `moi env` / `moi skill`). const sub = rawArgs.find(a => !a.startsWith('-')) if (sub && Object.hasOwn(tabSubCommands, sub)) return runTabsList(args.dir) } }) const tabs = defineCommand({ meta: { name: 'tabs', description: 'List workspace tabs (alias for `moi tab`)' }, args: { dir: dirArg }, run({ args }) { runTabsList(args.dir) } }) // Re-copy bundled skills into a workspace, then report what changed. Pure // filesystem op — no running server needed. Resolves the workspace root the // same way `moi bundle` does, so it works from `.moi/` or any subdirectory. async function runSkillUpdate(cwd: string): Promise { // Type-aware: an OpenClaw workspace keeps its skills in `skills/`, so the // update must target the same dir the agent actually loads from. const { root, type } = await resolveWorkspace(cwd) const { before, status, appletTypesWritten } = await updateWorkspaceSkills( root, type ?? 'claude-code' ) const after = status.skills console.log('\n' + pc.green('✓') + ' Skills updated in ' + pc.bold(root) + '\n') printSkillUpdateTable(before, after) if (appletTypesWritten) { console.log(pc.dim(' Ambient applet types regenerated: ') + pc.bold('.moi/applet-env.d.ts\n')) } } function printSkillUpdateTable( before: WorkspaceSkillStatus[], after: WorkspaceSkillStatus[] ): void { console.log( columns( ['skill', 'from', 'to'].map(h => pc.dim(h)), after.map(s => { const prev = before.find(b => b.name === s.name)?.installed ?? null const changed = prev !== s.installed return [ s.name, prev ?? pc.dim('none'), changed ? pc.green(s.installed ?? '?') : pc.dim((s.installed ?? '?') + ' (no change)') ] }) ) ) console.log( '\n' + pc.dim( ' Changes apply when the skill is next loaded (new session or next skill invocation).' ) + '\n' ) } // Colored status label for one skill row: minor+ behind is actionable, a patch // gap is informational, otherwise current. function skillState(s: WorkspaceSkillStatus): string { if (isMinorBehind(s.installed, s.bundled)) return pc.yellow('update available') if (isBehind(s.installed, s.bundled)) return pc.dim('patch behind') return pc.green('up to date') } // `update` and `install` are the same operation under two names (install kept // for symmetry with how skills first land in a workspace). `dir` is a `--dir` // option, not a positional: citty treats a bare positional on a command that // carries subcommands as an unknown subcommand. Reuses the shared `dirArg`. const defineSkillUpdate = (name: string, description: string) => defineCommand({ meta: { name, description }, args: { dir: dirArg }, async run({ args }) { await runSkillUpdate(resolve(args.dir)) } }) const skillSubCommands = { update: defineSkillUpdate( 'update', 'Update this workspace’s skills to the version shipped with the CLI' ), install: defineSkillUpdate('install', 'Alias for `moi skill update`') } const skill = defineCommand({ meta: { name: 'skill', description: 'Show or update the workspace skills shipped with moi' }, subCommands: skillSubCommands, args: { dir: dirArg }, async run({ args, rawArgs }) { // citty runs this parent handler even after dispatching a subcommand, so // bail when one was given — otherwise `moi skill update` also prints status. const sub = rawArgs.find(a => !a.startsWith('-')) if (sub && sub in skillSubCommands) return const { root, type } = await resolveWorkspace(resolve(args.dir)) const statuses = await skillStatuses(root, type) console.log('\n' + pc.bold('moi skill') + pc.dim(' — workspace skills') + '\n') console.log( columns( ['skill', 'installed', 'bundled', 'status'].map(h => pc.dim(h)), statuses.map(s => [ s.name, s.installed ?? pc.dim('—'), s.bundled ?? pc.dim('—'), skillState(s) ]) ) ) if (statuses.some(s => isBehind(s.installed, s.bundled))) { console.log('\n' + pc.dim(' Run ') + pc.bold('moi skill update') + pc.dim(' to refresh.')) } console.log() } }) // ---- service ---------------------------------------------------------------- function serviceFail(err: unknown): never { const message = err instanceof ServiceError ? err.message : String(err) console.error('\n' + pc.red('✗') + ' ' + message + '\n') process.exit(1) } // Shared success line after install/restart: did the server actually come up, // and on which version? function printServiceServer(info: Awaited>) { if (info) { console.log( ' server ' + pc.bold(`v${info.version}`) + ` running on http://localhost:${info.port}` + pc.dim(` (pid ${info.pid})`) ) const mismatch = versionMismatchNotice(info.version) if (mismatch) console.log('\n' + mismatch) } else { console.log( ' ' + pc.yellow('server did not come up — check `moi service logs`') + pc.dim(' (it may still be starting)') ) } } const serviceInstall = defineCommand({ meta: { name: 'install', description: 'Install and start the user service (launchd on macOS, systemd on Linux)' }, args: { port: { type: 'string', description: 'HTTP port for the service server (default: 13337)' }, env: { type: 'string', description: 'Extra env var names to capture from this shell, comma-separated (e.g. --env MY_TOKEN,OTHER)' } }, async run({ args }) { // Check the install shape first: telling someone to stop their server only // to then refuse the checkout would be a pointless round-trip. const analysis = analyzeInstall() if (analysis.kind !== 'global') { try { await installService() // throws the precise refusal for this analysis } catch (err) { serviceFail(err) } } const port = args.port ? Number(args.port) : undefined if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) { serviceFail(new ServiceError(`Invalid --port value "${args.port}" — use a port number.`)) } // citty hands back an array when the flag repeats — accept both shapes. const envArg = Array.isArray(args.env) ? args.env.join(',') : args.env const extraEnv = (envArg ?? '') .split(',') .map(k => k.trim()) .filter(Boolean) const serviceEnv = captureServiceEnv(process.env, dirname(process.execPath), extraEnv) const missingEnv = extraEnv.filter(key => !(key in serviceEnv)) if (missingEnv.length > 0) { serviceFail( new ServiceError( `--env ${missingEnv.join(', ')}: not set in this shell (or the value is not capturable).` ) ) } // A foreground `moi start` holds the same ports the service needs. Refuse // rather than install a unit that instantly fails on bind. const runningInfo = await queryServerInfo() const foreground = (await isServerRunning()) && !runningInfo?.service if (foreground) { serviceFail( new ServiceError( 'A foreground server is running' + (runningInfo?.pid ? ` (pid ${runningInfo.pid})` : '') + '.\n Stop it (Ctrl-C in its terminal), then rerun `moi service install`.' ) ) } try { const result = await installService({ port, extraEnv }) console.log( '\n' + pc.green('✓') + ' Service installed ' + pc.dim(`(${process.platform === 'darwin' ? 'launchd' : 'systemd user unit'})`) ) console.log(' unit ' + pc.dim(tildifyPath(result.unitPath))) if (result.logPath) console.log(' logs ' + pc.dim(tildifyPath(result.logPath))) printServiceServer(result.info) console.log(pc.dim('\n Starts on login, restarts on crash, survives reboots.')) for (const note of result.notes) console.log(pc.yellow(' ' + note)) console.log() } catch (err) { serviceFail(err) } } }) const serviceUninstall = defineCommand({ meta: { name: 'uninstall', description: 'Stop the service and remove its unit' }, async run() { try { const { unitPath, existed } = await uninstallService() if (!existed) { console.log( '\n' + pc.dim('○') + ' Service is not installed ' + pc.dim(`(${tildifyPath(unitPath)})`) + '\n' ) return } console.log( '\n' + pc.green('✓') + ' Service removed ' + pc.dim(`(${tildifyPath(unitPath)})`) + '\n' ) } catch (err) { serviceFail(err) } } }) const serviceRestart = defineCommand({ meta: { name: 'restart', description: 'Restart the service server' }, async run() { try { const info = await restartService() console.log('\n' + pc.green('✓') + ' Service restarted') printServiceServer(info) console.log() } catch (err) { serviceFail(err) } } }) const serviceLogs = defineCommand({ meta: { name: 'logs', description: 'Show server logs (journalctl on Linux, log file on macOS)' }, args: { lines: { type: 'string', default: '80', description: 'How many trailing lines to show' }, follow: { type: 'boolean', alias: 'f', default: false, description: 'Keep following new output' } }, async run({ args }) { const n = Math.max(1, Number(args.lines) || 80) if (process.platform === 'linux') { // journald owns the log (bounded, rotated). Inherit stdio so -f streams. const argv = ['journalctl', '--user', '-u', 'moi.service', '-n', String(n), '--no-pager'] if (args.follow) argv.push('-f') const proc = Bun.spawn(argv, { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' }) process.exit(await proc.exited) } const logPath = serviceLogPath() if (!existsSync(logPath)) { console.log('\n' + pc.dim('○') + ' No logs yet ' + pc.dim(`(${tildifyPath(logPath)})`) + '\n') process.exit(0) } console.error(pc.dim(tildifyPath(logPath))) const proc = Bun.spawn(['tail', '-n', String(n), ...(args.follow ? ['-F'] : []), logPath], { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' }) process.exit(await proc.exited) } }) function tildifyPath(p: string): string { const home = process.env.HOME return home && p.startsWith(home) ? '~' + p.slice(home.length) : p } async function runServiceStatus() { try { const s = await serviceStatus() const flavor = s.platform === 'darwin' ? 'launchd user agent' : 'systemd user unit' console.log('\n' + pc.bold('moi service') + pc.dim(` — ${flavor}`)) console.log() if (!s.installed) { console.log(' ' + pc.dim('○') + ' Not installed') console.log( pc.dim(' Run `moi service install` to start moi on login and keep it running.') ) console.log() process.exit(0) } const rt = s.runtime const stateLabel = rt === null ? pc.dim('unknown') : rt.state === 'running' ? pc.green('● running') + (rt.pid ? pc.dim(` (pid ${rt.pid})`) : '') : rt.state === 'failed' ? pc.red('✗ failed') + pc.dim(rt.detail ? ` (${rt.detail})` : '') : rt.state === 'unavailable' ? pc.yellow('? unavailable') + pc.dim(rt.detail ? ` — ${rt.detail}` : '') : pc.dim('○ ' + rt.state) console.log(' state ' + stateLabel) console.log(' unit ' + pc.dim(tildifyPath(s.unitPath))) if (s.logPath) console.log(' logs ' + pc.dim(tildifyPath(s.logPath)) + pc.dim(' (`moi service logs`)')) else console.log(' logs ' + pc.dim('journalctl --user -u moi.service (`moi service logs`)')) const info = await queryServerInfo() if (info) { console.log( ' server ' + pc.bold(`v${info.version}`) + pc.dim(` on http://localhost:${info.port} (pid ${info.pid})`) ) if (!info.service) { console.log( pc.yellow(' ⚠ The running server is a foreground `moi start`, not the service.') ) } } else if (rt?.state === 'running') { console.log(' server ' + pc.yellow('unreachable on the control port')) } console.log() if (s.binMissing && s.bin) { console.log( pc.yellow(` ⚠ The service execs ${s.bin}, which no longer exists`) + pc.dim(' (moi moved or was reinstalled elsewhere).') + '\n' + pc.dim(' Rerun `moi service install` to re-capture paths.') ) } if (s.bunMissing) { console.log( pc.yellow(' ⚠ No bun on the service PATH — the server cannot start.') + '\n' + pc.dim(' Reinstall bun, then rerun `moi service install`.') ) } if (s.linger === 'disabled') { console.log( pc.yellow(' ⚠ Lingering is off — the service stops when your last session ends.') + '\n' + pc.dim(' Enable: loginctl enable-linger') ) } const mismatch = info ? versionMismatchNotice(info.version) : null if (mismatch) console.log(mismatch.replace(/^/gm, ' ').replace(/^ {2}⚠/, ' ⚠') + '\n') process.exit(0) } catch (err) { serviceFail(err) } } const serviceSubCommands = { install: serviceInstall, uninstall: serviceUninstall, restart: serviceRestart, logs: serviceLogs } const service = defineCommand({ meta: { name: 'service', description: 'Run moi as a user service: `moi service install|uninstall|restart|logs`' }, subCommands: serviceSubCommands, async run({ rawArgs }) { // citty invokes the parent run even after dispatching a subcommand — only // show status when none ran (same pattern as `moi env` / `moi tab`). const sub = rawArgs.find(a => !a.startsWith('-')) if (sub && Object.hasOwn(serviceSubCommands, sub)) return await runServiceStatus() } }) // ---- update ----------------------------------------------------------------- const update = defineCommand({ meta: { name: 'update', description: 'Update moi to the latest published version' }, args: { check: { type: 'boolean', default: false, description: 'Only check the registry, change nothing. Exit 0: up to date (or nothing to update), 1: update available, 2: check failed.' } }, async run({ args }) { // A checkout has no owning package manager — updating means `git pull`. const analysis = analyzeInstall() if (analysis.kind === 'checkout') { console.log( '\n' + pc.yellow('◆') + ` This moi runs from a source tree (${analysis.reason}) — update it with git instead.\n` ) process.exit(0) } console.log('\n' + pc.bold('moi update') + pc.dim(` — installed v${VERSION}`)) if (isPrerelease(VERSION)) { console.log( '\n' + pc.yellow('◆') + ' Prerelease installs are updated manually — pick the tag you want:\n' + pc.dim(' bun i -g moi-computer@next (or @latest to leave the prerelease)\n') ) process.exit(0) } let latest: string try { latest = await fetchLatestVersion() } catch (err) { console.error( '\n' + pc.red('✗') + ' ' + (err instanceof Error ? err.message : String(err)) + '\n' ) // Under --check, exit 2 keeps "check failed" distinct from "update // available" (exit 1) for scripts and agents. process.exit(args.check ? 2 : 1) } if (!isNewer(latest, VERSION)) { console.log(pc.green('✓') + ` Already up to date (latest is v${latest})`) // --check is side-effect-free: skip the server sync, which can restart // a lagging service. `moi status` reports that lag without acting on it. if (!args.check) await reportServerFreshness() process.exit(0) } if (args.check) { console.log( ` update available: ${pc.bold('v' + latest)}` + pc.dim(' — run `moi update` to install\n') ) process.exit(1) } console.log(` latest is ${pc.bold('v' + latest)} — updating`) const pm = await detectPackageManager() if (!pm) { console.error( '\n' + pc.red('✗') + ' Could not tell which package manager owns this install' + pc.dim(` (${analysis.root})`) + '.\n Update it yourself with the one you installed moi with:\n' + manualUpdateLines(latest) .map(l => pc.dim(' ' + l)) .join('\n') + '\n' ) process.exit(1) } const argv = updateArgv(pm, latest) console.log(pc.dim(` via ${pm}: ${argv.join(' ')}\n`)) const code = await runPackageManager(argv) if (code !== 0) { console.error('\n' + pc.red('✗') + ` ${pm} exited with code ${code}.`) console.error(pc.dim(' Run it manually: ') + argv.join(' ')) if (pm === 'npm') { console.error( pc.dim( ' If it failed with EACCES, your npm prefix is root-owned — fix ownership or use a user-level prefix.' ) ) } console.error() process.exit(1) } // Trust but verify: ask the bin users actually run what version it is now. // A stale answer means the update landed somewhere PATH does not point. const binVersion = analysis.kind === 'global' ? await installedBinVersion(analysis.bin) : null if (binVersion === latest) { console.log('\n' + pc.green('✓') + ` moi updated to ${pc.bold('v' + latest)}`) } else if (binVersion) { console.log( '\n' + pc.yellow('⚠') + ` ${pm} finished, but \`moi\` on PATH reports v${binVersion} (expected v${latest}).` + '\n' + pc.dim(' Another install may shadow the updated one — check `which moi`.') ) } else { console.log('\n' + pc.green('✓') + ` ${pm} finished (v${latest})`) } await reportServerFreshness(latest) console.log() process.exit(0) } }) // After an update (or an up-to-date check), bring the running server along: // restart a service-managed one and verify the version it comes back with; a // foreground server in someone's terminal only ever gets a warning. async function reportServerFreshness(expected?: string) { const running = await isServerRunning() if (!running) return const info = await queryServerInfo() if (!info) { console.log( pc.yellow('⚠') + ' A server is running but too old to report its version — restart it to finish the update.' ) return } const target = expected ?? VERSION if (info.version === target) { if (expected) console.log(pc.green('✓') + ` Server already on v${info.version}`) return } if (info.service) { console.log(pc.dim(` restarting the service (server was v${info.version})…`)) try { const fresh = await restartService() if (fresh && fresh.version === target) { console.log(pc.green('✓') + ` Service restarted on ${pc.bold('v' + fresh.version)}`) } else if (fresh) { console.log( pc.yellow('⚠') + ` Service restarted but reports v${fresh.version} (expected v${target}).` + '\n' + pc.dim(' The unit may exec a different install — see `moi service`.') ) } else { console.log( pc.yellow('⚠') + ' Service restarted but the server has not come back up — `moi service logs`.' ) } } catch (err) { console.log( pc.yellow('⚠') + ' Could not restart the service: ' + (err instanceof Error ? err.message : String(err)) ) } return } console.log( pc.yellow('⚠') + ` A foreground server is still running v${info.version}` + (info.pid ? pc.dim(` (pid ${info.pid})`) : '') + '.\n' + pc.dim(` Restart it when convenient (Ctrl-C, then \`moi start\`) to get v${target}.`) ) } const version = defineCommand({ meta: { name: 'version', description: 'Print the moi version' }, run() { console.log(versionWithCommit()) } }) // The root help splits the surface by audience: workspace commands are the // agent's day-to-day toolkit (skills tell it to run them); system commands // manage moi itself and are for the human at the keyboard. const workspaceCommands = { bundle, refresh, builder, 'call-server-fn': callServerFn, debug, theme, config, env, scratch, skill, tab, tabs } const systemCommands = { init, start, status, service, update, openclaw, hermes, version } const main = defineCommand({ // A function so the git lookup runs only for `moi --version` / `--help`, not on // every command. citty resolves a function meta (used for --version + usage). meta: () => ({ name: 'moi', description: 'moi — local AI workspace', version: versionWithCommit() }), subCommands: { ...workspaceCommands, ...systemCommands } }) // Cloud demo: system commands manage moi itself (service, updates, agent // runtimes) — in a demo container the instance is provisioned, not managed, // so they print a pointer to the real thing instead of running. Two stay: // `start`, because it is how the demo (container or a local test run) boots // at all — against an already-running server it just reports and exits — and // `version`, which is harmless and useful in bug reports. function exitIfDemoBlocked(): void { const invoked = process.argv[2] if (!invoked || invoked === 'version' || invoked === 'start') return if (!(invoked in systemCommands)) return const config = getAppConfig() if (!config.cloudDemo) return console.log( '\n' + pc.yellow('◆') + ` moi ${invoked} is not available in the demo.\n` + pc.dim(' Get moi on your computer: ') + pc.bold(config.demoInstallUrl) + '\n' ) process.exit(1) } exitIfDemoBlocked() type HelpCommand = { meta?: unknown } async function commandDescription(cmd: HelpCommand): Promise { const meta = typeof cmd.meta === 'function' ? await cmd.meta() : await cmd.meta return (meta as { description?: string })?.description ?? '' } // Two-section root help (replaces citty's flat list). For a human, the // system section carries an explicit note that agents should leave those // commands alone; for a detected agent caller, the section is omitted // entirely — an agent's `moi --help` shows only the workspace toolkit. async function printMainHelp() { const pad = Math.max(...Object.keys({ ...workspaceCommands, ...systemCommands }).map(n => n.length)) + 4 const row = async (name: string, cmd: HelpCommand) => ' ' + pc.cyan(name.padEnd(pad)) + pc.dim(await commandDescription(cmd)) console.log() console.log( pc.bold('moi') + pc.dim(' — local AI workspace ') + pc.dim(`v${versionWithCommit()}`) ) console.log() console.log(pc.dim('USAGE ') + 'moi [options]') console.log() console.log(pc.bold('Workspace commands:') + pc.dim(' for agents and humans alike')) console.log() for (const [name, cmd] of Object.entries(workspaceCommands)) console.log(await row(name, cmd)) console.log() if (!isAgentCaller()) { console.log( pc.bold('System commands:') + pc.dim(' manage moi itself (server, service, updates)') ) console.log(pc.yellow(' AGENTS: do not run these unless explicitly asked!')) console.log() for (const [name, cmd] of Object.entries(systemCommands)) console.log(await row(name, cmd)) console.log() } console.log(pc.dim('Use moi --help for more information about a command.')) console.log() } // Route `moi --help` (and bare `moi`) to the two-section help, and // `moi config --help` to its terse cheat sheet; every other command keeps // citty's default usage renderer. runMain(main, { async showUsage(cmd, parent) { const meta = typeof cmd.meta === 'function' ? await cmd.meta() : await cmd.meta if (meta?.name === 'moi') { await printMainHelp() return } if (meta?.name === 'config') { printConfigHelp() return } await showUsage(cmd, parent) } })