diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index dafacdc..e006bf5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -6,6 +6,7 @@ import { createQualityRailsCli } from '@mosaic/quality-rails'; import { registerAgentCommand } from './commands/agent.js'; import { registerMissionCommand } from './commands/mission.js'; import { registerPrdyCommand } from './commands/prdy.js'; +import { registerLaunchCommands } from './commands/launch.js'; const _require = createRequire(import.meta.url); const CLI_VERSION: string = (_require('../package.json') as { version: string }).version; @@ -22,6 +23,10 @@ const program = new Command(); program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION); +// ─── runtime launchers + framework commands ──────────────────────────── + +registerLaunchCommands(program); + // ─── login ────────────────────────────────────────────────────────────── program diff --git a/packages/cli/src/commands/launch.ts b/packages/cli/src/commands/launch.ts new file mode 100644 index 0000000..cb39e90 --- /dev/null +++ b/packages/cli/src/commands/launch.ts @@ -0,0 +1,531 @@ +/** + * 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 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 = { + 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 mosaic init...'); + const initBin = join(MOSAIC_HOME, 'bin', 'mosaic-init'); + if (existsSync(initBin)) { + spawnSync(initBin, [], { stdio: 'inherit' }); + } else { + console.error('[mosaic] mosaic-init not found. Run: mosaic wizard'); + process.exit(1); + } + } +} + +function checkSequentialThinking(runtime: string): void { + const checker = join(MOSAIC_HOME, 'bin', '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 | null { + try { + return JSON.parse(readFileSync(path, 'utf-8')) as Record; + } 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)['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 = { + 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 delegation (for tools that remain in bash) ───────────── + +function delegateToFrameworkScript(script: string, args: string[]): never { + const scriptPath = join(MOSAIC_HOME, 'bin', script); + if (!existsSync(scriptPath)) { + console.error(`[mosaic] Script not found: ${scriptPath}`); + process.exit(1); + } + try { + execFileSync(scriptPath, args, { stdio: 'inherit' }); + process.exit(0); + } catch (err) { + process.exit((err as { status?: number }).status ?? 1); + } +} + +// ─── 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 ') + .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); + }); + + // Framework management commands (delegate to bash scripts) + const frameworkCommands: Record = { + 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(frameworkCommands)) { + program + .command(name) + .description(desc) + .allowUnknownOption(true) + .allowExcessArguments(true) + .action((_opts: unknown, cmd: Command) => { + checkMosaicHome(); + delegateToFrameworkScript(script, cmd.args); + }); + } +} diff --git a/packages/mosaic/framework/bin/mosaic b/packages/mosaic/framework/bin/mosaic-launch similarity index 97% rename from packages/mosaic/framework/bin/mosaic rename to packages/mosaic/framework/bin/mosaic-launch index 5a686f9..b696a40 100755 --- a/packages/mosaic/framework/bin/mosaic +++ b/packages/mosaic/framework/bin/mosaic-launch @@ -1,25 +1,21 @@ #!/usr/bin/env bash set -euo pipefail -# mosaic — Unified agent launcher and management CLI +# mosaic-launch — Framework agent launcher (called by the mosaic npm CLI) # # AGENTS.md is the global policy source for all agent sessions. # The launcher injects a composed runtime contract (AGENTS + runtime reference). # -# Usage: +# Usage (via mosaic CLI): # mosaic claude [args...] Launch Claude Code with runtime contract injected -# mosaic opencode [args...] Launch OpenCode with runtime contract injected # mosaic codex [args...] Launch Codex with runtime contract injected +# mosaic opencode [args...] Launch OpenCode with runtime contract injected +# mosaic pi [args...] Launch Pi with runtime contract injected # mosaic yolo [args...] Launch runtime in dangerous-permissions mode -# mosaic --yolo [args...] Alias for yolo -# mosaic init [args...] Generate SOUL.md interactively -# mosaic doctor [args...] Health audit -# mosaic sync [args...] Sync skills -# mosaic seq [subcommand] sequential-thinking MCP management (check/fix/start) -# mosaic bootstrap Bootstrap a repo -# mosaic upgrade release Upgrade installed Mosaic release -# mosaic upgrade check Check release upgrade status (no changes) -# mosaic upgrade project [args] Upgrade project-local stale files +# +# Direct usage: +# mosaic-launch claude [args...] +# mosaic-launch yolo claude [args...] MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" VERSION="0.1.0" diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index aae05be..d79eca6 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -4,7 +4,7 @@ set -euo pipefail SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}" INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}" # prompt|keep|overwrite -PRESERVE_PATHS=("SOUL.md" "USER.md" "TOOLS.md" "memory") +PRESERVE_PATHS=("SOUL.md" "USER.md" "TOOLS.md" "memory" "sources") # Colors (disabled if not a terminal) if [[ -t 1 ]]; then @@ -21,7 +21,7 @@ step() { echo -e "\n${BOLD}$1${RESET}"; } is_existing_install() { [[ -d "$TARGET_DIR" ]] || return 1 - [[ -f "$TARGET_DIR/bin/mosaic" || -f "$TARGET_DIR/AGENTS.md" || -f "$TARGET_DIR/SOUL.md" ]] + [[ -f "$TARGET_DIR/bin/mosaic-launch" || -f "$TARGET_DIR/bin/mosaic" || -f "$TARGET_DIR/AGENTS.md" || -f "$TARGET_DIR/SOUL.md" ]] } select_install_mode() { diff --git a/packages/mosaic/framework/tools/orchestrator/session-run.sh b/packages/mosaic/framework/tools/orchestrator/session-run.sh index dcbda36..4196ca2 100755 --- a/packages/mosaic/framework/tools/orchestrator/session-run.sh +++ b/packages/mosaic/framework/tools/orchestrator/session-run.sh @@ -80,11 +80,11 @@ echo -e "${C_CYAN}Capsule:${C_RESET} $(next_task_capsule_path "$PROJECT")" cd "$PROJECT" if [[ "$YOLO" == true ]]; then - exec "$MOSAIC_HOME/bin/mosaic" yolo "$runtime" "$launch_prompt" + exec "$MOSAIC_HOME/bin/mosaic-launch" yolo "$runtime" "$launch_prompt" elif [[ "$runtime" == "claude" ]]; then - exec "$MOSAIC_HOME/bin/mosaic" claude "$launch_prompt" + exec "$MOSAIC_HOME/bin/mosaic-launch" claude "$launch_prompt" elif [[ "$runtime" == "codex" ]]; then - exec "$MOSAIC_HOME/bin/mosaic" codex "$launch_prompt" + exec "$MOSAIC_HOME/bin/mosaic-launch" codex "$launch_prompt" fi echo -e "${C_RED}Unsupported coord runtime: $runtime${C_RESET}" >&2 diff --git a/tools/install.sh b/tools/install.sh index 4a4cffd..0cb6a9e 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -202,13 +202,8 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then ok "Framework installed" echo "" - # Ensure framework bin is on PATH - FRAMEWORK_BIN="$MOSAIC_HOME/bin" - if [[ ":$PATH:" != *":$FRAMEWORK_BIN:"* ]]; then - warn "$FRAMEWORK_BIN is not on your PATH" - dim " The 'mosaic' launcher lives here. Add to your shell rc:" - dim " export PATH=\"$FRAMEWORK_BIN:\$PATH\"" - fi + # Framework bin is no longer needed on PATH — the npm CLI delegates + # to mosaic-launch directly via its absolute path. fi fi @@ -290,7 +285,6 @@ if [[ "$FLAG_CLI" == "true" ]]; then # PATH check for npm prefix if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then warn "$PREFIX/bin is not on your PATH" - dim " The 'mosaic' TUI/gateway CLI lives here (separate from the launcher)." dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" fi fi @@ -303,26 +297,19 @@ fi if [[ "$FLAG_CHECK" == "false" ]]; then step "Summary" - echo " ${BOLD}Framework launcher:${RESET} $MOSAIC_HOME/bin/mosaic" - echo " ${DIM}mosaic claude, mosaic yolo claude, mosaic pi, mosaic doctor, …${RESET}" - echo "" - echo " ${BOLD}npm CLI (TUI):${RESET} $PREFIX/bin/mosaic" - echo " ${DIM}mosaic tui, mosaic login, mosaic wizard, mosaic update, …${RESET}" + echo " ${BOLD}mosaic CLI:${RESET} $PREFIX/bin/mosaic" + dim " All commands: mosaic claude, mosaic yolo pi, mosaic tui, mosaic doctor, …" echo "" + dim " Framework data: $MOSAIC_HOME/" + dim " Launcher backend: $MOSAIC_HOME/bin/mosaic-launch" - # Warn if there's a naming collision (both on PATH) + # Clean up old mosaic binary from PATH if framework bin is still there FRAMEWORK_BIN="$MOSAIC_HOME/bin" - if [[ ":$PATH:" == *":$FRAMEWORK_BIN:"* ]] && [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then - # Check which one wins - WHICH_MOSAIC="$(command -v mosaic 2>/dev/null || true)" - if [[ -n "$WHICH_MOSAIC" ]]; then - dim " Active 'mosaic' binary: $WHICH_MOSAIC" - if [[ "$WHICH_MOSAIC" == "$FRAMEWORK_BIN/mosaic" ]]; then - dim " (Framework launcher takes priority — this is correct)" - else - warn "npm CLI shadows the framework launcher!" - dim " Ensure $FRAMEWORK_BIN appears BEFORE $PREFIX/bin in your PATH." - fi + if [[ ":$PATH:" == *":$FRAMEWORK_BIN:"* ]]; then + OLD_MOSAIC="$FRAMEWORK_BIN/mosaic" + if [[ -f "$OLD_MOSAIC" ]] && [[ ! -L "$OLD_MOSAIC" ]]; then + info "Removing old framework 'mosaic' binary (replaced by npm CLI)" + rm -f "$OLD_MOSAIC" fi fi