use crate::{
commands::CommandRegistry,
config::{CliConfigOverrides, McPaths},
herdr::{HerdrOwner, HerdrSessionStartSource},
sessions::SessionManager,
tui::TuiSessionConfig,
};
use anyhow::{Error, Result};
use clap::{Parser, Subcommand};
use std::{env, io, io::IsTerminal};
mod mcp;
mod sessions;
mod validation;
use validation::{
validate_initial_prompt_launch, validate_session_launch, validate_subcommand_launch,
validate_tui_launch,
};
pub(crate) type AppResult<T> = std::result::Result<T, AppError>;
#[derive(Debug)]
pub(crate) enum AppError {
Usage(Error),
Runtime(Error),
}
impl AppError {
pub(crate) fn usage(error: impl Into<Error>) -> Self {
Self::Usage(error.into())
}
pub(crate) fn runtime(error: impl Into<Error>) -> Self {
Self::Runtime(error.into())
}
pub(crate) fn exit_code(&self) -> std::process::ExitCode {
match self {
Self::Usage(_) => std::process::ExitCode::from(2),
Self::Runtime(_) => std::process::ExitCode::from(1),
}
}
pub(crate) fn into_error(self) -> Error {
match self {
Self::Usage(error) | Self::Runtime(error) => error,
}
}
pub(crate) fn as_error(&self) -> &Error {
match self {
Self::Usage(error) | Self::Runtime(error) => error,
}
}
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.as_error().fmt(f)
}
}
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.as_error().source()
}
}
impl From<Error> for AppError {
fn from(error: Error) -> Self {
Self::Runtime(error)
}
}
fn runtime<T>(result: Result<T>) -> AppResult<T> {
result.map_err(AppError::runtime)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct HerdrStartupSnapshot {
session_id: Option<String>,
session_start_source: Option<HerdrSessionStartSource>,
initial_title: Option<String>,
}
impl HerdrStartupSnapshot {
fn from_session(
session: Option<&crate::sessions::Session>,
session_start_source: Option<HerdrSessionStartSource>,
) -> Self {
let session_id = session.map(|session| session.id().to_string());
let initial_title = session
.and_then(|session| crate::sessions::latest_session_title(session).ok())
.flatten();
Self {
session_start_source: session_id.as_ref().and(session_start_source),
session_id,
initial_title,
}
}
}
fn report_herdr_startup(owner: &HerdrOwner, snapshot: &HerdrStartupSnapshot) {
let reporter = owner.reporter();
reporter.report_ready();
reporter.report_metadata(snapshot.initial_title.as_deref(), Some("ready"));
if let (Some(session_id), Some(source)) = (
snapshot.session_id.as_deref(),
snapshot.session_start_source,
) {
reporter.report_agent_session_with_source(session_id, source);
}
}
fn run_with_herdr_lifecycle<T>(
owner: Option<HerdrOwner>,
startup: Option<HerdrStartupSnapshot>,
mode: impl FnOnce() -> Result<T>,
) -> Result<T> {
if let (Some(owner), Some(snapshot)) = (owner.as_ref(), startup.as_ref()) {
report_herdr_startup(owner, snapshot);
}
let mode_result = mode();
if let Some(owner) = owner {
owner.release();
}
mode_result
}
fn prepare_session_for_launch(
session: crate::sessions::Session,
) -> AppResult<crate::sessions::Session> {
let session = runtime(session.admit_standalone_writer())?;
let replay = session
.read_events_tolerant_bounded(
crate::context::REPLAY_JSONL_MAX_LINES,
crate::context::REPLAY_JSONL_MAX_BYTES,
)
.map_err(|_| {
AppError::runtime(anyhow::anyhow!(
"cannot attach session: history is unreadable or exceeds bounded replay limits; use --compact or start a new session"
))
})?;
if !replay.diagnostics.is_empty() {
return Err(AppError::runtime(anyhow::anyhow!(
"cannot attach session: history contains unreadable events; use --compact or start a new session"
)));
}
Ok(session)
}
#[derive(Debug, Parser)]
#[command(
name = "magi-code",
about = "Coding agent with Mission Control",
version,
after_long_help = "Mission Control slash commands: /help, /update, /login, /logout, /model, /settings, /new, /changes, /rewind [--to <turn>] [--dry-run], /compact, /fast [on|off|status], /ultrafast [on|off|status], /prune-sessions, /sessions, /skills, /subagents, /mcp, /system-prompt, /quit"
)]
pub struct CliArgs {
#[arg(long, help = "Update this Cargo installation from crates.io")]
pub update: bool,
#[arg(long, hide = true, conflicts_with = "update")]
pub update_restart: bool,
#[arg(
long = "prompt",
value_name = "PROMPT",
help = "Initial prompt for Mission Control"
)]
pub initial_prompt: Option<String>,
#[arg(long, help = "Override model name")]
pub model: Option<String>,
#[arg(long, help = "Override provider name")]
pub provider: Option<String>,
#[arg(
long,
help = "Deprecated: override provider API key for this process; prefer provider-specific environment variables or /login because argv can be exposed"
)]
pub api_key: Option<String>,
#[arg(
long,
value_name = "THEME",
help = "Select a built-in or custom appearance theme for this run"
)]
pub theme: Option<String>,
#[arg(long, help = "Do not create or attach to a session")]
pub no_session: bool,
#[arg(long = "continue", help = "Continue the most recent session")]
pub continue_session: bool,
#[arg(long, value_name = "SESSION_ID", help = "Resume a specific session")]
pub resume: Option<String>,
#[command(subcommand)]
pub command: Option<CliCommand>,
}
#[derive(Debug, Subcommand)]
pub enum CliCommand {
#[command(about = "Serve the bounded local-agent protocol over inherited pipes")]
Serve {
#[arg(
long,
required = true,
help = "Use newline-delimited JSON on stdin/stdout"
)]
stdio: bool,
},
#[command(about = "Repair or inspect session storage")]
Sessions {
#[command(subcommand)]
command: SessionsCommand,
},
#[command(about = "Diagnose configured MCP servers")]
Mcp {
#[command(subcommand)]
command: McpCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum SessionsCommand {
#[command(about = "Measure offline compression candidates for active sessions")]
MeasureCompression {
#[arg(
long,
value_name = "MODEL",
help = "Supported openai-codex text model used for local BPE estimates"
)]
model: String,
#[arg(
long,
value_name = "N",
default_value_t = crate::tool_output_measurement::DEFAULT_MAX_FILES,
help = "Maximum recent active session files to measure (1-10000)"
)]
max_files: usize,
#[arg(
long,
value_name = "BYTES",
default_value_t = crate::tool_output_measurement::DEFAULT_MAX_BYTES,
help = "Maximum planned input bytes (1 MiB-1 GiB)"
)]
max_bytes: u64,
},
#[command(about = "Repair recognized legacy session permissions")]
RepairPermissions {
#[arg(long, conflicts_with = "dry_run", help = "Apply without confirmation")]
yes: bool,
#[arg(
long,
conflicts_with = "yes",
help = "Preview without changing permissions"
)]
dry_run: bool,
},
}
#[derive(Debug, Subcommand)]
pub enum McpCommand {
#[command(about = "List configured MCP servers and connection status")]
List,
#[command(about = "Test one configured MCP server")]
Test { server: String },
#[command(about = "Authenticate one OAuth MCP server")]
Login { server: String },
#[command(about = "Delete local OAuth token for one MCP server")]
Logout { server: String },
}
pub fn run(args: CliArgs) -> AppResult<()> {
if args.update {
validation::validate_update_launch(&args)?;
return runtime(crate::updates::run_update().map(|_| ()));
}
validate_initial_prompt_launch(&args)?;
if let Some(command) = &args.command {
validate_subcommand_launch(&args)?;
return runtime(run_subcommand(command));
}
validate_session_launch(&args)?;
validate_tui_launch()?;
let cwd = env::current_dir().map_err(AppError::runtime)?;
if args.api_key.is_some() {
eprintln!(
"warning: --api-key is deprecated because command-line arguments can be exposed by process listings; prefer provider-specific environment variables or /login"
);
}
let paths = runtime(McPaths::resolve())?;
let crate::runtime_context::LaunchContext {
cwd,
config,
settings,
appearance_settings,
retention_days,
instructions,
discovered_skills,
skills,
} = runtime(crate::runtime_context::LaunchContext::load(
cwd,
paths.clone(),
CliConfigOverrides {
provider: args.provider.clone(),
model: args.model.clone(),
api_key: args.api_key.clone(),
color: None,
},
))?;
let appearance_resolution = crate::appearance::resolve_startup(
&paths,
&settings,
&appearance_settings,
args.theme.as_deref(),
None,
io::stdout().is_terminal(),
)
.map_err(|error| {
if error
.downcast_ref::<crate::appearance::InvalidExplicitTheme>()
.is_some()
{
AppError::usage(error)
} else {
AppError::runtime(error)
}
})?;
for diagnostic in &appearance_resolution.diagnostics {
eprintln!("warning: {diagnostic}");
}
let appearance = appearance_resolution.appearance;
let commands = CommandRegistry::mvp();
let manager = SessionManager::new(paths.sessions.clone());
let (active_session, session_start_source) = if args.no_session {
(None, None)
} else if let Some(id) = args.resume {
let id = crate::sessions::validate_session_id(id).map_err(AppError::usage)?;
let session = manager
.open_existing(id)
.and_then(crate::sessions::Session::admit_standalone_writer)
.map_err(|_| {
AppError::runtime(anyhow::anyhow!(
"cannot resume session: session is missing, unreadable, unsafe, or active elsewhere; use --continue or start a new session"
))
})?;
(
Some(prepare_session_for_launch(session)?),
Some(HerdrSessionStartSource::Resume),
)
} else if args.continue_session {
match manager.most_recent() {
Ok(Some(session)) => (
Some(prepare_session_for_launch(runtime(
session.admit_standalone_writer(),
)?)?),
Some(HerdrSessionStartSource::Resume),
),
Ok(None) => (
Some(runtime(
runtime(manager.create())?.admit_standalone_writer(),
)?),
Some(HerdrSessionStartSource::Startup),
),
Err(_) => {
return Err(AppError::runtime(anyhow::anyhow!(
"cannot continue session: history discovery failed; use --resume with a valid session or start a new session"
)));
}
}
} else {
(
Some(runtime(
runtime(manager.create())?.admit_standalone_writer(),
)?),
Some(HerdrSessionStartSource::Startup),
)
};
manager.spawn_auto_prune(
retention_days,
active_session.as_ref().map(|session| session.id()),
);
let herdr_owner = HerdrOwner::from_env(&settings.integrations.herdr);
let herdr_reporter = herdr_owner.as_ref().map(HerdrOwner::reporter);
let herdr_startup =
HerdrStartupSnapshot::from_session(active_session.as_ref(), session_start_source);
let mode_result = run_with_herdr_lifecycle(herdr_owner, Some(herdr_startup), || {
crate::tui::run_tui(TuiSessionConfig {
config,
settings,
appearance,
theme_cli_override: args.theme.is_some(),
instructions,
initial_prompt: args.initial_prompt,
discovered_skills,
skills,
commands,
manager,
active_session,
resumed_session: matches!(session_start_source, Some(HerdrSessionStartSource::Resume)),
update_restart: args.update_restart,
cwd,
herdr_reporter,
})
});
if let Some(restart) = mode_result.map_err(AppError::runtime)? {
runtime(crate::updates::update_and_restart(restart))?;
}
Ok(())
}
fn run_subcommand(command: &CliCommand) -> Result<()> {
match command {
CliCommand::Serve { .. } => crate::local_agent::run(),
CliCommand::Sessions { command } => match command {
SessionsCommand::MeasureCompression {
model,
max_files,
max_bytes,
} => {
let paths = McPaths::resolve_read_only()?;
let stdout = io::stdout();
let mut stdout = stdout.lock();
crate::tool_output_measurement::run(
&paths.sessions,
model,
crate::tool_output_measurement::MeasurementLimits::new(*max_files, *max_bytes),
&mut stdout,
)
}
SessionsCommand::RepairPermissions { yes, dry_run } => {
let paths = McPaths::resolve()?;
sessions::run(&paths.sessions, *yes, *dry_run)
}
},
CliCommand::Mcp { command } => {
let stdout = io::stdout();
let stderr = io::stderr();
let mut stdout = stdout.lock();
let mut stderr = stderr.lock();
match command {
McpCommand::List => mcp::run_list(&mut stdout, &mut stderr),
McpCommand::Test { server } => mcp::run_test(server, &mut stdout, &mut stderr),
McpCommand::Login { server } => mcp::run_login(server, &mut stdout, &mut stderr),
McpCommand::Logout { server } => mcp::run_logout(server, &mut stdout, &mut stderr),
}
}
}
}