//! meerkat-cli - Headless CLI for Meerkat
#![allow(
clippy::expect_used,
clippy::large_futures,
clippy::needless_borrows_for_generic_args,
clippy::redundant_closure_for_method_calls
)]
mod cli_parse;
#[cfg(feature = "mcp")]
mod mcp;
#[cfg(feature = "comms")]
mod stdin_events;
mod stream_renderer;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use chrono::Utc;
#[cfg(not(feature = "mob"))]
use meerkat::surface::NoopScheduleMobHost;
use meerkat::surface::{
AcceptedScheduledInput, ScheduledPromptDispatch, SharedScheduleTargetAdapter,
SurfaceScheduleMobHost, SurfaceScheduleSessionHost, build_dispatch_from_accepted,
immediate_delivery_failure, schedule_attempt_idempotency_key, schedule_host_supported,
spawn_schedule_host,
};
use meerkat::{
AgentFactory, EphemeralSessionService, FactoryAgentBuilder, PersistenceBundle, ScheduleService,
ScheduleToolDispatcher,
};
use meerkat_contracts::{SessionLocator, SessionLocatorError, format_session_ref};
use meerkat_core::AgentToolDispatcher;
#[cfg(feature = "comms")]
use meerkat_core::CommsRuntimeMode;
#[cfg(feature = "mob")]
use meerkat_core::config::CliOverrides;
use meerkat_core::service::{
CreateSessionRequest, DeferredPromptPolicy, SessionBuildOptions, SessionQuery, SessionService,
SessionServiceCommsExt, SessionSummary, StartTurnRequest, TurnToolOverlay,
};
use meerkat_core::{
AgentEvent, AuthBindingRef, AuthStatusPhase, BlobId, EventEnvelope, RealmConfig, RealmLocator,
RealmSelection, ScopedAgentEvent, StreamScopeFrame, format_verbose_event,
};
use meerkat_core::{
Config, ConfigDelta, ConfigEnvelope, ConfigEnvelopePolicy, ConfigStore, FileConfigStore,
Session,
};
#[cfg(feature = "mcp")]
use meerkat_mcp::McpRouterAdapter;
#[cfg(feature = "mob")]
use meerkat_mob::{FlowId, MobDefinition, RunId};
#[cfg(feature = "mob")]
use meerkat_mob_pack::archive::MobpackArchive;
#[cfg(all(feature = "mob", test))]
use meerkat_mob_pack::pack::compute_archive_digest;
#[cfg(feature = "mob")]
use meerkat_mob_pack::pack::{inspect_archive_bytes, pack_directory_with_excludes};
#[cfg(feature = "mob")]
use meerkat_mob_pack::targz::extract_targz_safe;
#[cfg(feature = "mob")]
use meerkat_mob_pack::trust::{TrustPolicy, load_trusted_signers, verify_extracted_pack_trust};
use meerkat_runtime::input::{InputDurability, InputHeader, InputVisibility};
use meerkat_runtime::{CorrelationId, IdempotencyKey, Input, InputOrigin, PromptInput};
use meerkat_tools::find_project_root;
use tokio::io::{AsyncBufRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::sync::mpsc;
use clap::{Parser, Subcommand, ValueEnum};
use meerkat_core::HookRunOverrides;
use meerkat_core::SessionId;
use meerkat_core::budget::BudgetLimits;
use meerkat_core::error::AgentError;
use meerkat_core::mcp_config::{McpScope, McpTransportKind};
use meerkat_core::types::OutputSchema;
use meerkat_store::{RealmBackend, RealmOrigin};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::Duration;
use tokio::process::Command as TokioCommand;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
/// Exit codes as per DESIGN.md §12
const EXIT_SUCCESS: u8 = 0;
const EXIT_ERROR: u8 = 1;
const EXIT_BUDGET_EXHAUSTED: u8 = 2;
const CLI_ABOUT: &str = "Run agent tasks and manage local Meerkat surfaces from the terminal";
const ROOT_AFTER_HELP: &str = "Command groups:\n Runtime: run, help\n Realm config: init, config, realm\n Utility: session, blob, models, capabilities, doctor\n\nAdditional commands appear when their supporting capabilities are compiled in.\n\nRealm defaults:\n - context root: current directory, unless --context-root is supplied\n - state root: <context-root>/.rkat/realms, unless --state-root is supplied\n - realm id: workspace-derived ws-... unless --realm or --isolated is supplied\n\nExamples:\n rkat \"summarize this repository\"\n rkat help \"How do I add an mcp server?\"\n cat story.txt | rkat \"summarize the story\"\n git diff | rkat run \"review these changes\"\n tail -f app.log | rkat run --stdin lines \"watch for incidents\"\n rkat run -t workspace \"fix the failing test\"\n\nUse `<binary> <command> -h` for the basic view and `<binary> <command> --help` for all options.";
const HELP_AFTER_HELP: &str = "Examples:\n rkat help \"How do I add an mcp server and schedule to remove it in 30 minutes\"\n rkat help \"Use gemini with my vertex auth, load ~/codex/skills\" --prompt \"Write a match-3 game in Erlang\"";
const RUN_AFTER_HELP: &str = "Examples:\n rkat run \"summarize this repository\"\n cat story.txt | rkat run \"summarize the story\"\n git diff | rkat run --json \"review these changes\"\n rkat run --html \"make a visual explainer\"\n rkat run --browser \"create an implementation plan\"\n rkat run --resume \"keep going\"\n rkat run --resume ~2 \"pick this thread back up\"\n tail -f app.log | rkat run --stdin lines \"watch for incidents\"\n rkat run -t workspace \"fix the failing test\"\n\nDefaults:\n - realm state under <context-root>/.rkat/realms; use --verbose to print the active realm root\n - `--tools safe`\n - provider-native web search on for supporting models; use `--no-web-search` to disable\n - stream on in a TTY, off in pipes/scripts\n - piped stdin is read as blob context unless `--stdin lines` is set";
const DEFAULT_TRACE_FILTER: &str = "off";
const VERBOSE_TRACE_FILTER: &str = "info";
/// Safely truncate a string to approximately `max_bytes`, respecting UTF-8 char boundaries.
fn truncate_str(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
// Find the last char boundary before max_bytes
let truncate_at = s
.char_indices()
.take_while(|(i, _)| *i < max_bytes)
.last()
.map_or(0, |(i, c)| i + c.len_utf8());
&s[..truncate_at]
}
/// Parse a `key=value` label for `--label` / `--agent-label`.
fn parse_label(s: &str) -> Result<(String, String), String> {
let (key, value) = s
.split_once('=')
.ok_or_else(|| format!("expected key=value, got: {s}"))?;
Ok((key.to_string(), value.to_string()))
}
/// Spawn a task that handles verbose event output.
fn spawn_verbose_event_handler(
mut agent_event_rx: mpsc::Receiver<EventEnvelope<AgentEvent>>,
verbose: bool,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
while let Some(event) = agent_event_rx.recv().await {
if verbose && let Some(line) = format_verbose_event(&event.payload) {
eprintln!("{line}");
}
}
})
}
/// Spawn a task that renders scoped streaming output.
fn spawn_scoped_event_handler(
mut scoped_event_rx: mpsc::Receiver<ScopedAgentEvent>,
policy: stream_renderer::StreamRenderPolicy,
verbose: bool,
) -> tokio::task::JoinHandle<stream_renderer::StreamRenderSummary> {
tokio::spawn(async move {
let ansi = stream_renderer::stderr_is_tty();
let mut renderer = stream_renderer::StreamRenderer::new(ansi, policy, verbose);
while let Some(event) = scoped_event_rx.recv().await {
renderer.render(&event);
}
renderer.finish()
})
}
struct CliOutputPipeline {
event_tx: Option<mpsc::Sender<EventEnvelope<AgentEvent>>>,
scoped_event_tx: Option<mpsc::Sender<ScopedAgentEvent>>,
verbose_task: Option<tokio::task::JoinHandle<()>>,
stream_task: Option<tokio::task::JoinHandle<stream_renderer::StreamRenderSummary>>,
primary_to_scoped_bridge_task: Option<tokio::task::JoinHandle<()>>,
}
impl CliOutputPipeline {
fn new(
stream: bool,
verbose: bool,
stream_policy: Option<stream_renderer::StreamRenderPolicy>,
primary_scope_path: Vec<StreamScopeFrame>,
) -> anyhow::Result<Self> {
let mut pipeline = Self {
event_tx: None,
scoped_event_tx: None,
verbose_task: None,
stream_task: None,
primary_to_scoped_bridge_task: None,
};
if stream {
let policy =
stream_policy.ok_or_else(|| anyhow::anyhow!("internal stream policy missing"))?;
let (primary_tx, mut primary_rx) = mpsc::channel::<EventEnvelope<AgentEvent>>(100);
let (scoped_tx, scoped_rx) = mpsc::channel::<ScopedAgentEvent>(200);
let bridge_scoped_tx = scoped_tx.clone();
pipeline.primary_to_scoped_bridge_task = Some(tokio::spawn(async move {
while let Some(event) = primary_rx.recv().await {
let scoped = ScopedAgentEvent::new(primary_scope_path.clone(), event.payload);
if bridge_scoped_tx.send(scoped).await.is_err() {
break;
}
}
}));
pipeline.event_tx = Some(primary_tx);
pipeline.scoped_event_tx = Some(scoped_tx);
pipeline.stream_task = Some(spawn_scoped_event_handler(scoped_rx, policy, verbose));
} else if verbose {
let (tx, rx) = mpsc::channel::<EventEnvelope<AgentEvent>>(100);
pipeline.event_tx = Some(tx);
pipeline.verbose_task = Some(spawn_verbose_event_handler(rx, verbose));
}
Ok(pipeline)
}
fn event_sender(&self) -> Option<mpsc::Sender<EventEnvelope<AgentEvent>>> {
self.event_tx.clone()
}
async fn shutdown_after<F>(self, after_sender_drop: F) -> anyhow::Result<()>
where
F: std::future::Future<Output = anyhow::Result<()>>,
{
let Self {
event_tx,
scoped_event_tx,
verbose_task,
stream_task,
primary_to_scoped_bridge_task,
} = self;
drop(event_tx);
drop(scoped_event_tx);
let mut shutdown_err = after_sender_drop.await.err();
if let Some(task) = primary_to_scoped_bridge_task
&& let Err(err) = task.await
{
accumulate_anyhow_error(
&mut shutdown_err,
anyhow::anyhow!("primary stream bridge task failed: {err}"),
);
}
if let Some(task) = verbose_task
&& let Err(err) = task.await
{
accumulate_anyhow_error(
&mut shutdown_err,
anyhow::anyhow!("verbose event task failed: {err}"),
);
}
if let Some(task) = stream_task {
match task.await {
Ok(summary) => {
println!();
if let Err(err) = validate_stream_render_summary(&summary) {
accumulate_anyhow_error(&mut shutdown_err, err);
}
}
Err(err) => accumulate_anyhow_error(
&mut shutdown_err,
anyhow::anyhow!("stream renderer task failed: {err}"),
),
}
}
if let Some(err) = shutdown_err {
Err(err)
} else {
Ok(())
}
}
}
fn accumulate_anyhow_error(slot: &mut Option<anyhow::Error>, err: anyhow::Error) {
match slot.take() {
Some(existing) => {
*slot = Some(anyhow::anyhow!(
"{existing}; additionally failed during shutdown: {err}"
));
}
None => {
*slot = Some(err);
}
}
}
#[derive(Debug, Clone, PartialEq)]
struct CliCallbackPending {
session_id: SessionId,
session_ref: String,
session_created: bool,
resumable: bool,
tool_name: String,
args: serde_json::Value,
}
#[derive(Debug, Clone)]
enum CliRuntimeTurnResult {
Completed(meerkat_core::types::RunResult),
CallbackPending(CliCallbackPending),
}
fn completion_outcome_to_cli_runtime_turn_result(
outcome: meerkat_runtime::completion::CompletionOutcome,
session_id: &SessionId,
realm_id: &meerkat_core::connection::RealmId,
session_created: bool,
) -> anyhow::Result<CliRuntimeTurnResult> {
match outcome {
meerkat_runtime::completion::CompletionOutcome::Completed(result) => {
Ok(CliRuntimeTurnResult::Completed(*result))
}
meerkat_runtime::completion::CompletionOutcome::CompletedWithoutResult => {
Err(anyhow::anyhow!("turn completed without result"))
}
meerkat_runtime::completion::CompletionOutcome::CallbackPending { tool_name, args } => {
Ok(CliRuntimeTurnResult::CallbackPending(CliCallbackPending {
session_id: session_id.clone(),
session_ref: format_session_ref(realm_id, session_id),
session_created,
resumable: true,
tool_name,
args,
}))
}
meerkat_runtime::completion::CompletionOutcome::Cancelled => {
Err(anyhow::anyhow!("request cancelled"))
}
meerkat_runtime::completion::CompletionOutcome::Abandoned(reason) => {
Err(anyhow::anyhow!("turn abandoned: {reason}"))
}
meerkat_runtime::completion::CompletionOutcome::AbandonedWithError { reason, error } => {
Err(anyhow::anyhow!(
"turn abandoned: {reason}; error={}",
serde_json::to_string(&error).unwrap_or_else(|_| "<unserializable>".to_string())
))
}
meerkat_runtime::completion::CompletionOutcome::CompletedWithFinalizationFailure {
result,
error,
} => {
let structured_output = result
.structured_output
.as_ref()
.map(serde_json::Value::to_string)
.unwrap_or_else(|| "null".to_string());
Err(anyhow::anyhow!(
"turn finalization failed after output: {}; structured_output={structured_output}",
error
.detail
.as_deref()
.unwrap_or("turn finalization failed")
))
}
meerkat_runtime::completion::CompletionOutcome::RuntimeTerminated(reason) => {
Err(anyhow::anyhow!("runtime terminated: {reason}"))
}
}
}
fn callback_pending_json_value(pending: &CliCallbackPending) -> serde_json::Value {
serde_json::json!({
"status": "pending_tool_call",
"session_id": pending.session_id.to_string(),
"session_ref": pending.session_ref.clone(),
"session_created": pending.session_created,
"resumable": pending.resumable,
"pending_tool_calls": [{
"tool_name": pending.tool_name.clone(),
"args": pending.args.clone(),
}],
})
}
fn print_cli_callback_pending(
pending: &CliCallbackPending,
output: Option<&str>,
) -> anyhow::Result<()> {
if output.is_some_and(|value| value.eq_ignore_ascii_case("json")) {
println!(
"{}",
serde_json::to_string_pretty(&callback_pending_json_value(pending))?
);
return Ok(());
}
let session_state = if pending.session_created {
"Session created"
} else {
"Session resumed"
};
eprintln!("{session_state}; waiting for external tool results.");
eprintln!(
"[Session: {} | Ref: {} | Resumable: {}]",
short_session_id(&pending.session_id),
pending.session_ref,
if pending.resumable { "yes" } else { "no" }
);
eprintln!("[Pending tool: {} {}]", pending.tool_name, pending.args);
eprintln!("Provide the tool result, then resume the session using the session ref above.");
Ok(())
}
fn normalize_html_document(raw: &str) -> anyhow::Result<String> {
let trimmed = raw.trim();
let candidate = strip_markdown_fence(trimmed).unwrap_or(trimmed).trim();
let lower = candidate.trim_start().to_ascii_lowercase();
if !(lower.starts_with("<!doctype html")
|| lower.starts_with("<html")
|| lower.contains("<html"))
{
anyhow::bail!(
"HTML output mode expected a complete HTML document, but the final answer did not contain <html> or <!doctype html>"
);
}
Ok(candidate.to_string())
}
fn strip_markdown_fence(value: &str) -> Option<&str> {
let rest = value.strip_prefix("```")?;
let first_newline = rest.find('\n')?;
let body = &rest[first_newline + 1..];
let end = body.rfind("```")?;
Some(&body[..end])
}
async fn write_html_output_artifact(
scope: &RuntimeScope,
result: &meerkat_core::types::RunResult,
) -> anyhow::Result<PathBuf> {
let html = normalize_html_document(&result.text)?;
let realm_paths =
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str());
let dir = realm_paths.root.join("presentation").join("html");
tokio::fs::create_dir_all(&dir).await.map_err(|e| {
anyhow::anyhow!(
"Failed to create HTML artifact directory {}: {e}",
dir.display()
)
})?;
let filename = format!(
"{}-{}-{}.html",
Utc::now().format("%Y%m%dT%H%M%SZ"),
short_session_id(&result.session_id),
uuid::Uuid::new_v4().simple()
);
let path = dir.join(filename);
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.await
.map_err(|e| anyhow::anyhow!("Failed to create HTML artifact {}: {e}", path.display()))?;
file.write_all(html.as_bytes())
.await
.map_err(|e| anyhow::anyhow!("Failed to write HTML artifact {}: {e}", path.display()))?;
Ok(path)
}
fn file_url_for_path(path: &Path) -> Result<String, String> {
let canonical_path = std::fs::canonicalize(path)
.map_err(|e| format!("failed to canonicalize {}: {e}", path.display()))?;
url::Url::from_file_path(&canonical_path)
.map(|url| url.to_string())
.map_err(|()| {
format!(
"failed to convert {} to a file URL",
canonical_path.display()
)
})
}
fn open_html_artifact_in_browser(path: &Path) -> Result<(), String> {
let url = file_url_for_path(path)?;
webbrowser::open(&url).map_err(|e| e.to_string())
}
async fn print_completed_run_result(
result: meerkat_core::types::RunResult,
output: &CliOutputSelection,
stream: bool,
scope: &RuntimeScope,
include_ref_in_summary: bool,
) -> anyhow::Result<()> {
match output.format {
CliOutputFormat::Json => {
let session_id = result.session_id.clone();
let mut wire: meerkat_contracts::WireRunResult = result.into();
wire.session_ref = Some(format_session_ref(&scope.locator.realm, &session_id));
println!("{}", serde_json::to_string_pretty(&wire)?);
}
CliOutputFormat::Html => {
let path = write_html_output_artifact(scope, &result).await?;
println!("{}", path.display());
if output.open_in_browser
&& let Err(error) = open_html_artifact_in_browser(&path)
{
eprintln!(
"Warning: failed to open HTML artifact in browser: {error}. Open {} manually.",
path.display()
);
}
if include_ref_in_summary {
eprintln!(
"\n[Session: {} | Ref: {} | Turns: {} | Tokens: {} in / {} out | HTML: {}]",
result.session_id,
format_session_ref(&scope.locator.realm, &result.session_id),
result.turns,
result.usage.input_tokens,
result.usage.output_tokens,
path.display()
);
} else {
eprintln!(
"\n[Session: {} | Turns: {} | Tokens: {} in / {} out | HTML: {}]",
short_session_id(&result.session_id),
result.turns,
result.usage.input_tokens,
result.usage.output_tokens,
path.display()
);
}
}
CliOutputFormat::Text => {
if !stream {
println!("{}", result.text);
}
if include_ref_in_summary {
eprintln!(
"\n[Session: {} | Ref: {} | Turns: {} | Tokens: {} in / {} out]",
result.session_id,
format_session_ref(&scope.locator.realm, &result.session_id),
result.turns,
result.usage.input_tokens,
result.usage.output_tokens
);
} else {
eprintln!(
"\n[Session: {} | Turns: {} | Tokens: {} in / {} out]",
short_session_id(&result.session_id),
result.turns,
result.usage.input_tokens,
result.usage.output_tokens
);
}
if let Some(warnings) = &result.schema_warnings
&& !warnings.is_empty()
{
eprintln!("\n[Schema warnings]");
for warning in warnings {
eprintln!(
"- {:?} {}: {}",
warning.provider, warning.path, warning.message
);
}
}
if let Some(diag) = &result.skill_diagnostics
&& diag.source_health.state != meerkat_core::skills::SourceHealthState::Healthy
{
eprintln!(
"\n[Skill source health: {:?} | invalid_ratio: {:.3} | streak: {} | quarantined: {}]",
diag.source_health.state,
diag.source_health.invalid_ratio,
diag.source_health.failure_streak,
diag.quarantined.len()
);
}
}
}
Ok(())
}
async fn finalize_cli_runtime_backed_turn<T, F>(
output_pipeline: CliOutputPipeline,
turn_result: anyhow::Result<T>,
after_sender_drop: F,
) -> anyhow::Result<T>
where
F: std::future::Future<Output = anyhow::Result<()>>,
{
let shutdown_result = output_pipeline.shutdown_after(after_sender_drop).await;
match (turn_result, shutdown_result) {
(Ok(result), Ok(())) => Ok(result),
(Ok(_), Err(shutdown_err)) => Err(shutdown_err),
(Err(turn_err), Ok(())) => Err(turn_err),
(Err(turn_err), Err(shutdown_err)) => Err(anyhow::anyhow!(
"{turn_err}; additionally failed during CLI shutdown: {shutdown_err}"
)),
}
}
fn validate_stream_render_summary(
summary: &stream_renderer::StreamRenderSummary,
) -> anyhow::Result<()> {
if let Some(focus) = &summary.focus_requested
&& !summary.focus_seen
{
let discovered = if summary.discovered_scopes.is_empty() {
"<none>".to_string()
} else {
summary.discovered_scopes.join(", ")
};
anyhow::bail!(
"stream focus '{focus}' did not match any emitted scope (discovered scopes: {discovered})"
);
}
Ok(())
}
#[derive(Clone, Copy, Debug)]
struct ToolPresetResolution {
builtins: bool,
shell: bool,
memory: bool,
workgraph: bool,
mob: bool,
}
fn resolve_tool_preset(preset: ToolPreset, yolo: bool) -> ToolPresetResolution {
let preset = if yolo { ToolPreset::Full } else { preset };
match preset {
ToolPreset::Safe => ToolPresetResolution {
builtins: true,
shell: false,
memory: false,
workgraph: false,
mob: false,
},
ToolPreset::Workspace => ToolPresetResolution {
builtins: true,
shell: true,
memory: false,
workgraph: false,
mob: false,
},
ToolPreset::Full => ToolPresetResolution {
builtins: true,
shell: true,
memory: true,
workgraph: true,
mob: cfg!(feature = "mob"),
},
ToolPreset::None => ToolPresetResolution {
builtins: false,
shell: false,
memory: false,
workgraph: false,
mob: false,
},
}
}
#[cfg(test)]
fn apply_yolo_tooling_override(tooling: &mut meerkat_core::SessionTooling) {
let yolo = resolve_tool_preset(ToolPreset::Safe, true);
tooling.builtins = meerkat_core::ToolCategoryOverride::from_effective(yolo.builtins);
tooling.shell = meerkat_core::ToolCategoryOverride::from_effective(yolo.shell);
tooling.memory = meerkat_core::ToolCategoryOverride::from_effective(yolo.memory);
tooling.workgraph = meerkat_core::ToolCategoryOverride::from_effective(yolo.workgraph);
tooling.mob = meerkat_core::ToolCategoryOverride::from_effective(yolo.mob);
}
fn resolve_stream_enabled(
stream: bool,
no_stream: bool,
stream_by_default: bool,
) -> anyhow::Result<bool> {
use std::io::IsTerminal;
if stream && no_stream {
return Err(anyhow::anyhow!(
"cannot use --stream and --no-stream together"
));
}
if stream {
Ok(true)
} else if no_stream {
Ok(false)
} else {
Ok(stream_by_default && std::io::stdout().is_terminal())
}
}
fn resolve_stdin_mode(mode: StdinMode) -> StdinMode {
use std::io::IsTerminal;
if std::io::stdin().is_terminal() {
StdinMode::Off
} else {
mode
}
}
fn is_root_flag_with_value(arg: &str) -> bool {
matches!(
arg,
"-r" | "--realm"
| "--instance"
| "--realm-backend"
| "--state-root"
| "--context-root"
| "--user-config-root"
)
}
fn is_root_passthrough_flag(arg: &str) -> bool {
matches!(arg, "-h" | "--help" | "-V" | "--version")
}
fn is_root_flag_without_value(arg: &str) -> bool {
matches!(arg, "--isolated")
}
/// Inject `run` as the default subcommand when the first positional argument
/// is not a known command, while preserving top-level help/version handling.
fn normalize_cli_args(
args: impl IntoIterator<Item = std::ffi::OsString>,
) -> Vec<std::ffi::OsString> {
const SUBCOMMANDS: &[&str] = &[
"init",
"run",
"session",
"sessions",
"blob",
"realm",
"realms",
"workgraph",
"mcp",
"skill",
"skills",
"mob",
"config",
"capabilities",
"models",
"doctor",
"auth",
"help",
"resume",
"continue",
"c",
];
let args: Vec<std::ffi::OsString> = args.into_iter().collect();
let mut i = 1; // skip binary name
while i < args.len() {
let arg_str = args[i].to_str().unwrap_or("");
if arg_str.starts_with('-') {
if is_root_passthrough_flag(arg_str) {
return args;
}
if is_root_flag_without_value(arg_str) {
i += 1;
} else if is_root_flag_with_value(arg_str) {
i += 2; // skip flag and its value
} else {
break;
}
} else {
if SUBCOMMANDS.contains(&arg_str) {
return args;
}
let mut patched = args[..i].to_vec();
patched.push("run".into());
patched.extend_from_slice(&args[i..]);
return patched;
}
}
if i < args.len() && args[i].to_string_lossy().starts_with('-') {
let mut patched = args[..i].to_vec();
patched.push("run".into());
patched.extend_from_slice(&args[i..]);
if let Some(resume_index) = patched
.iter()
.position(|arg| arg.to_str() == Some("--resume"))
{
let remaining_positionals = patched
.iter()
.skip(resume_index + 1)
.filter(|arg| !arg.to_string_lossy().starts_with('-'))
.count();
let next_value = patched
.get(resume_index + 1)
.and_then(|arg| arg.to_str())
.filter(|arg| !arg.starts_with('-'));
let should_insert_last = match (next_value, remaining_positionals) {
(None, _) => true,
(Some(_), 0) => true,
(Some(value), 1) => !looks_like_resume_target(value),
(Some(_), _) => false,
};
if should_insert_last {
patched.insert(resume_index + 1, "last".into());
}
}
return patched;
}
args
}
/// Read piped stdin content and prepend it to the prompt as context.
fn prepend_stdin_blob_context(prompt: String) -> String {
use std::io::IsTerminal;
if std::io::stdin().is_terminal() {
return prompt;
}
let mut stdin_content = String::new();
if let Err(e) = std::io::Read::read_to_string(&mut std::io::stdin(), &mut stdin_content) {
eprintln!("Warning: failed to read stdin: {e}");
return prompt;
}
let stdin_content = stdin_content.trim();
if stdin_content.is_empty() {
return prompt;
}
format!("<stdin>\n{stdin_content}\n</stdin>\n\n{prompt}")
}
async fn init_project_config() -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let rkat_dir = cwd.join(".rkat");
tokio::fs::create_dir_all(&rkat_dir).await?;
let global_path = meerkat_core::Config::global_config_path().ok_or_else(|| {
anyhow::anyhow!("Unable to resolve global config path (~/.rkat/config.toml)")
})?;
if !global_path.exists() {
let _ = meerkat_core::FileConfigStore::global().await?;
}
let project_config = rkat_dir.join("config.toml");
if project_config.exists() {
return Err(anyhow::anyhow!(
"Project config already exists at {}",
project_config.display()
));
}
let content = tokio::fs::read_to_string(&global_path).await.map_err(|e| {
anyhow::anyhow!(
"Failed to read global config at {}: {}",
global_path.display(),
e
)
})?;
tokio::fs::write(&project_config, content)
.await
.map_err(|e| {
anyhow::anyhow!(
"Failed to write project config at {}: {}",
project_config.display(),
e
)
})?;
println!("Initialized {}", project_config.display());
Ok(())
}
#[derive(Parser)]
#[command(name = env!("CARGO_BIN_NAME"), version = env!("CARGO_PKG_VERSION"))]
#[command(about = CLI_ABOUT)]
#[command(override_usage = "rkat [OPTIONS] <PROMPT>\n rkat [OPTIONS] <COMMAND>")]
#[command(disable_help_subcommand = true)]
#[command(after_help = ROOT_AFTER_HELP)]
struct Cli {
/// Explicit realm ID (opaque). Reuse to share state across surfaces.
#[arg(
long,
short = 'r',
global = true,
hide_short_help = true,
help_heading = "Realm options"
)]
realm: Option<String>,
/// Start in isolated mode (new generated realm).
#[arg(
long,
global = true,
hide_short_help = true,
help_heading = "Realm options"
)]
isolated: bool,
/// Optional instance ID inside a realm.
#[arg(
long,
global = true,
hide_short_help = true,
help_heading = "Realm options"
)]
instance: Option<String>,
/// Realm backend when creating a new realm.
#[arg(
long,
global = true,
value_enum,
hide_short_help = true,
help_heading = "Realm options"
)]
realm_backend: Option<RealmBackendArg>,
/// Override realm state root. Defaults to <context-root>/.rkat/realms.
#[arg(
long,
global = true,
hide_short_help = true,
help_heading = "Realm options"
)]
state_root: Option<PathBuf>,
/// Context root for realm identity and project files. Defaults to CWD.
#[arg(
long,
global = true,
hide_short_help = true,
help_heading = "Realm options"
)]
context_root: Option<PathBuf>,
/// Optional user-global convention root.
#[arg(
long,
global = true,
hide_short_help = true,
help_heading = "Realm options"
)]
user_config_root: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum RealmBackendArg {
#[cfg(feature = "jsonl-store")]
Jsonl,
Sqlite,
}
impl From<RealmBackendArg> for RealmBackend {
fn from(value: RealmBackendArg) -> Self {
match value {
#[cfg(feature = "jsonl-store")]
RealmBackendArg::Jsonl => RealmBackend::Jsonl,
RealmBackendArg::Sqlite => {
#[cfg(feature = "session-store")]
{
RealmBackend::Sqlite
}
#[cfg(not(feature = "session-store"))]
{
panic!("RealmBackendArg::Sqlite requires session-store support")
}
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum CliOutputFormat {
Text,
Json,
Html,
}
impl CliOutputFormat {
fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Json => "json",
Self::Html => "html",
}
}
fn streams_by_default(self) -> bool {
matches!(self, Self::Text)
}
}
#[derive(Clone, Debug)]
struct CliOutputSelection {
format: CliOutputFormat,
open_in_browser: bool,
html_template: Option<String>,
html_template_file: Option<PathBuf>,
}
impl CliOutputSelection {
fn is_html(&self) -> bool {
self.format == CliOutputFormat::Html
}
}
#[derive(Clone, Debug)]
struct HtmlOutputRequest {
instruction: String,
}
const BUILTIN_HTML_TEMPLATE_POLISHED: &str = r"
Create a beautiful, readable, information-dense HTML artifact tailored to the user's request.
Design direction:
- Use a complete standalone HTML5 document with inline CSS and, when useful, small inline JavaScript.
- Make the page feel like a polished technical artifact: clear hierarchy, generous but not wasteful spacing, excellent typography, and fast scanning.
- Use tables, callouts, SVG diagrams, timelines, grids, annotated code blocks, tabs, disclosure sections, or lightweight controls when they genuinely improve comprehension.
- Keep the user's content and preferences primary. If the user asked for a terse answer, make a terse artifact; if they asked for a deep report, make it rich.
- Prefer restrained professional color with purposeful accents. Avoid generic purple-blue gradient hero pages, decorative blobs, and marketing-page fluff.
- Make it responsive for desktop and mobile, accessible, and printable enough that someone could share the file.
Content rules:
- Preserve the truth of the answer. Do not invent facts, metrics, citations, files, or conclusions.
- Label assumptions and uncertainty plainly.
- If code, diffs, plans, or schemas are important, render them in readable sections with syntax-like styling and short annotations.
- Add interaction only when it helps the reader compare, filter, copy, tune, or navigate the information.
";
fn resolve_cli_output_selection(
output: CliOutputFormat,
json: bool,
html: bool,
browser: bool,
open_in_browser: bool,
html_template: Option<String>,
html_template_file: Option<PathBuf>,
) -> anyhow::Result<CliOutputSelection> {
if html_template.is_some() && html_template_file.is_some() {
anyhow::bail!("--html-template and --html-template-file are mutually exclusive");
}
let mut format = output;
if json {
if !matches!(format, CliOutputFormat::Text | CliOutputFormat::Json) {
anyhow::bail!(
"--json cannot be combined with --output {}",
format.as_str()
);
}
format = CliOutputFormat::Json;
}
if html || browser {
if !matches!(format, CliOutputFormat::Text | CliOutputFormat::Html) {
anyhow::bail!(
"{} cannot be combined with --output {}",
if browser { "--browser" } else { "--html" },
format.as_str()
);
}
format = CliOutputFormat::Html;
}
if format != CliOutputFormat::Html {
if open_in_browser {
anyhow::bail!("--open-in-browser requires --html or --output html");
}
if html_template.is_some() || html_template_file.is_some() {
anyhow::bail!("HTML templates require --html or --output html");
}
}
Ok(CliOutputSelection {
format,
open_in_browser: browser || open_in_browser,
html_template,
html_template_file,
})
}
fn builtin_html_template(name: &str) -> Option<&'static str> {
match name {
"polished" => Some(BUILTIN_HTML_TEMPLATE_POLISHED),
_ => None,
}
}
async fn load_configured_html_template(
name: &str,
template: &meerkat_core::config::HtmlTemplateConfig,
config_base_dir: &Path,
) -> anyhow::Result<(String, String)> {
match (&template.body, &template.path) {
(Some(_), Some(_)) => {
anyhow::bail!(
"presentation.html.templates.{name} must set either body or path, not both"
)
}
(Some(body), None) => Ok((
body.clone(),
format!("presentation.html.templates.{name}.body"),
)),
(None, Some(path)) => {
let path = if path.is_absolute() {
path.clone()
} else {
config_base_dir.join(path)
};
let body = tokio::fs::read_to_string(&path).await.map_err(|e| {
anyhow::anyhow!(
"Failed to read HTML template `{name}` at {}: {e}",
path.display()
)
})?;
Ok((body, path.display().to_string()))
}
(None, None) => {
anyhow::bail!("presentation.html.templates.{name} must set body or path")
}
}
}
async fn resolve_named_html_template(
name: &str,
config: &Config,
config_base_dir: &Path,
) -> anyhow::Result<(String, String)> {
if let Some(template) = config.presentation.html.templates.get(name) {
return load_configured_html_template(name, template, config_base_dir).await;
}
if let Some(body) = builtin_html_template(name) {
return Ok((body.to_string(), format!("builtin:{name}")));
}
anyhow::bail!("unknown HTML template `{name}`")
}
async fn resolve_html_output_request(
output: &CliOutputSelection,
config: &Config,
config_base_dir: &Path,
) -> anyhow::Result<Option<HtmlOutputRequest>> {
if !output.is_html() {
return Ok(None);
}
let (template_body, template_source) = if let Some(path) = &output.html_template_file {
let body = tokio::fs::read_to_string(path).await.map_err(|e| {
anyhow::anyhow!("Failed to read HTML template file {}: {e}", path.display())
})?;
(body, path.display().to_string())
} else {
let template_name = output
.html_template
.as_deref()
.unwrap_or(config.presentation.html.default_template.as_str());
resolve_named_html_template(template_name, config, config_base_dir).await?
};
Ok(Some(HtmlOutputRequest {
instruction: build_html_output_instruction(&template_body, &template_source),
}))
}
fn build_html_output_instruction(template_body: &str, template_source: &str) -> String {
format!(
r"[Meerkat CLI HTML Output Mode]
The caller selected HTML output for this run. Your final assistant response must be only a complete, self-contained HTML document.
Output contract:
- Start with <!doctype html> or <html>.
- Include all CSS inline in a <style> tag.
- Include JavaScript inline only if it improves the artifact.
- Do not wrap the document in Markdown fences.
- Do not add explanatory text before or after the HTML.
- Make the document useful when opened directly from a local file.
Template source: {template_source}
Template instructions:
{template_body}
"
)
}
fn append_html_output_instruction(prompt: &mut String, request: &HtmlOutputRequest) {
prompt.push_str("\n\n");
prompt.push_str(&request.instruction);
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
enum Commands {
/// Initialize local project config from the global template
Init,
#[command(after_help = RUN_AFTER_HELP)]
/// Run an agent with a prompt
Run {
/// The prompt to execute
prompt: String,
/// Resume an existing session instead of starting a new one.
/// Omitting the value resumes `last`.
#[arg(long, value_name = "SESSION", num_args = 0..=1, default_missing_value = "last", help_heading = "Common options")]
resume: Option<String>,
/// Optional per-request system prompt override.
#[arg(
long = "system",
hide_short_help = true,
help_heading = "Advanced options"
)]
system_prompt: Option<String>,
/// Model to use (defaults to config when omitted)
#[arg(long, short = 'm', help_heading = "Common options")]
model: Option<String>,
/// LLM provider (anthropic, openai, gemini). Inferred from the model registry when omitted.
#[arg(
long,
short = 'p',
value_enum,
hide_short_help = true,
help_heading = "Advanced options"
)]
provider: Option<Provider>,
/// Maximum tokens per turn (defaults to config when omitted)
#[arg(long, hide_short_help = true, help_heading = "Advanced options")]
max_tokens: Option<u32>,
/// Maximum duration for the run (e.g., "5m", "1h30m")
#[arg(long, short = 'd', help_heading = "Common options")]
max_duration: Option<String>,
/// Maximum tool calls for the run
#[arg(long, hide_short_help = true, help_heading = "Advanced options")]
max_tool_calls: Option<usize>,
/// Output format (text, json, html)
#[arg(
long,
short = 'o',
value_enum,
default_value = "text",
help_heading = "Common options"
)]
output: CliOutputFormat,
/// Convenience alias for --output json
#[arg(long, help_heading = "Common options")]
json: bool,
/// Convenience alias for --output html
#[arg(long, help_heading = "Common options")]
html: bool,
/// Write HTML output and open it in the browser
#[arg(long, help_heading = "Common options")]
browser: bool,
/// Open HTML output in the browser after writing the artifact
#[arg(long, help_heading = "Common options")]
open_in_browser: bool,
/// HTML output template name
#[arg(long, value_name = "NAME", help_heading = "Common options")]
html_template: Option<String>,
/// HTML output template file
#[arg(long, value_name = "PATH", help_heading = "Common options")]
html_template_file: Option<PathBuf>,
/// Stream LLM response tokens to stdout as they arrive
#[arg(long, short = 's', help_heading = "Common options")]
stream: bool,
/// Disable streaming output
#[arg(long, help_heading = "Common options")]
no_stream: bool,
/// Disable provider-native web search for this run.
#[arg(long, help_heading = "Common options")]
no_web_search: bool,
/// Provider-specific parameter (KEY=VALUE). Can be repeated.
#[arg(
long = "param",
value_name = "KEY=VALUE",
hide_short_help = true,
help_heading = "Advanced options"
)]
params: Vec<String>,
/// Provider-specific params as a JSON object.
#[arg(
long = "params-json",
value_name = "JSON",
hide_short_help = true,
help_heading = "Advanced options"
)]
provider_params_json: Option<String>,
/// Structured output schema (wrapper or raw JSON schema; file path OR inline JSON)
#[arg(
long = "schema",
value_name = "SCHEMA",
hide_short_help = true,
help_heading = "Advanced options"
)]
output_schema: Option<String>,
/// Skill IDs or local skill paths to preload for this run. Repeatable.
#[cfg(feature = "skills")]
#[arg(
long = "skill",
value_name = "PATH_OR_ID",
help_heading = "Common options"
)]
skills: Vec<String>,
/// Per-turn allow list for tools on the first turn (repeatable).
#[arg(
long = "allow-tool",
value_name = "TOOL",
hide_short_help = true,
help_heading = "Advanced options"
)]
allow_tools: Vec<String>,
/// Per-turn block list for tools on the first turn (repeatable).
#[arg(
long = "block-tool",
value_name = "TOOL",
hide_short_help = true,
help_heading = "Advanced options"
)]
block_tools: Vec<String>,
/// Session label (key=value, repeatable). Attached at creation for filtering.
#[arg(long = "label", value_name = "KEY=VALUE", value_parser = parse_label, hide_short_help = true, help_heading = "Advanced options")]
labels: Vec<(String, String)>,
/// Additional instruction section appended to the system prompt (repeatable).
#[arg(
long = "instructions",
value_name = "TEXT",
hide_short_help = true,
help_heading = "Advanced options"
)]
instructions: Vec<String>,
/// Opaque application context (JSON). Passed through to custom builders.
#[arg(
long = "app-context",
value_name = "JSON",
hide_short_help = true,
help_heading = "Advanced options"
)]
app_context: Option<String>,
/// Tool preset
#[arg(long, short = 't', value_enum, help_heading = "Common options")]
tools: Option<ToolPreset>,
/// Alias for --tools full
#[arg(long, hide_short_help = true, help_heading = "Advanced options")]
yolo: bool,
/// Wait for all MCP servers to connect before running the first prompt.
/// By default MCP servers connect in the background and their tools
/// become available as each server is ready. Use this flag when the
/// first prompt requires MCP tools to be available.
#[cfg(feature = "mcp")]
#[arg(long, hide_short_help = true, help_heading = "Advanced options")]
wait_for_mcp: bool,
// === Output verbosity ===
/// Verbose output: show each turn, tool calls, and results as they happen
#[arg(long, short = 'v', help_heading = "Common options")]
verbose: bool,
/// Keep the session alive after the initial turn completes.
///
/// The agent stays running and wakes on background job completions,
/// comms messages, or stdin events. Without this flag, the session
/// exits after the agent's response.
///
/// Implied by `--stdin lines` (line-mode stdin requires keep-alive).
#[arg(long, help_heading = "Common options")]
keep_alive: bool,
/// How stdin should be handled
#[arg(
long,
value_enum,
default_value = "auto",
help_heading = "Common options"
)]
stdin: StdinMode,
/// How each stdin line is interpreted in line mode
#[arg(
long,
value_enum,
default_value = "text",
hide_short_help = true,
help_heading = "Advanced options"
)]
line_format: LineFormat,
/// Typed auth binding reference `realm:binding[:profile]`.
///
/// Parsed at the CLI boundary by
/// `cli_parse::parse_auth_binding_user_input`; a typed
/// [`meerkat_core::AuthBindingRef`] is threaded through
/// `SessionBuildOptions.auth_binding`. Opaque-string
/// ferry through the runtime is prohibited by the
/// wave-b deletion of `AuthBindingRef::parse` / `Display`.
#[arg(
long = "auth-binding",
value_name = "REALM:BINDING[:PROFILE]",
help_heading = "Auth options"
)]
auth_binding: Option<String>,
},
#[command(after_help = HELP_AFTER_HELP)]
/// Ask how to use Meerkat
Help {
/// The Meerkat usage question to answer
question: String,
/// Inert prompt payload for future execution-oriented help
#[arg(long, value_name = "PROMPT", help_heading = "Common options")]
prompt: Option<String>,
/// Plan future execution without executing anything
#[arg(
long = "plan-execution",
hide_short_help = true,
help_heading = "Advanced options"
)]
plan_execution: bool,
/// Model to use for the help session (defaults to config when omitted)
#[arg(long, short = 'm', help_heading = "Common options")]
model: Option<String>,
/// LLM provider for the help session
#[arg(
long,
short = 'p',
value_enum,
hide_short_help = true,
help_heading = "Advanced options"
)]
provider: Option<Provider>,
/// Maximum tokens for the help session
#[arg(long, hide_short_help = true, help_heading = "Advanced options")]
max_tokens: Option<u32>,
/// Output format (text, json, html)
#[arg(
long,
short = 'o',
value_enum,
default_value = "text",
help_heading = "Common options"
)]
output: CliOutputFormat,
/// Convenience alias for --output json
#[arg(long, help_heading = "Common options")]
json: bool,
/// Convenience alias for --output html
#[arg(long, help_heading = "Common options")]
html: bool,
/// Write HTML output and open it in the browser
#[arg(long, help_heading = "Common options")]
browser: bool,
/// Open HTML output in the browser after writing the artifact
#[arg(long, help_heading = "Common options")]
open_in_browser: bool,
/// HTML output template name
#[arg(long, value_name = "NAME", help_heading = "Common options")]
html_template: Option<String>,
/// HTML output template file
#[arg(long, value_name = "PATH", help_heading = "Common options")]
html_template_file: Option<PathBuf>,
/// Stream LLM response tokens to stdout as they arrive
#[arg(long, short = 's', help_heading = "Common options")]
stream: bool,
/// Disable streaming output
#[arg(long, help_heading = "Common options")]
no_stream: bool,
},
/// Session management
#[command(name = "session")]
Sessions {
#[command(subcommand)]
command: SessionCommands,
},
/// Blob management
Blob {
#[command(subcommand)]
command: BlobCommands,
},
/// Realm lifecycle management
#[command(name = "realm")]
Realms {
#[command(subcommand)]
command: RealmCommands,
},
/// WorkGraph observability and operator lookup
#[command(name = "workgraph")]
WorkGraph {
#[command(subcommand)]
command: WorkGraphCommands,
},
#[cfg(feature = "mcp")]
#[command(
after_help = "Examples:\n rkat mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem .\n rkat mcp add linear --transport http --url https://mcp.example.com\n rkat mcp list\n rkat mcp get filesystem --scope project"
)]
/// MCP server management
Mcp {
#[command(subcommand)]
command: McpCommands,
},
#[cfg(feature = "mob")]
#[command(
after_help = "Examples:\n rkat mob pack ./mobs/release-triage -o dist/release-triage.mobpack\n rkat mob inspect dist/release-triage.mobpack\n rkat mob validate dist/release-triage.mobpack\n rkat mob deploy dist/release-triage.mobpack \"triage the latest release regressions\"\n rkat mob web build dist/release-triage.mobpack -o dist/release-triage-web"
)]
/// Mob orchestration commands
Mob {
#[command(subcommand)]
command: MobCommands,
},
#[cfg(feature = "skills")]
#[command(name = "skill")]
/// Skill introspection and realm-local skill resources
Skills {
#[command(subcommand)]
command: SkillsCommands,
},
#[command(
after_help = "Examples:\n rkat config get --format toml\n rkat config set ./.rkat/config.toml\n rkat config patch --json '{\"agent\":{\"model\":\"gpt-5.4\"}}'"
)]
/// Config management
Config {
#[command(subcommand)]
command: ConfigCommands,
},
/// Show runtime capabilities
Capabilities,
/// Show model catalog and provider information
Models,
/// Check local setup and common prerequisites
Doctor,
/// Auth profile management — list, inspect, test, log in, log out,
/// delete, and check status of realm-scoped auth profiles.
/// `login` runs the interactive OAuth flow by default, or writes an
/// inline api_key when `--non-interactive --secret <S>` is passed.
/// Env-var auth (`RKAT_*` provider keys, ANTHROPIC_API_KEY,
/// OPENAI_API_KEY, AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT,
/// GEMINI_API_KEY / GOOGLE_API_KEY) continues to work as a fallback for
/// callers that haven't migrated.
Auth {
#[command(subcommand)]
command: AuthCommands,
},
}
#[derive(Subcommand)]
enum AuthCommands {
/// List realms defined in the active config.
Realms,
/// List auth profiles + backends + bindings for one realm.
Profiles,
/// Inspect a single auth profile.
Profile {
/// Auth profile id.
profile_id: String,
},
/// Delete an auth profile's persisted credentials from the TokenStore.
/// The realm config entry itself is declarative — this removes the
/// secret/token material bound to the profile's owning `<realm>:<binding_id>`.
ProfileDelete {
/// Auth profile id.
profile_id: String,
/// Skip interactive confirmation.
#[arg(short = 'y', long = "yes")]
yes: bool,
},
/// List all backend / auth / binding tuples across every realm in the
/// active config.
Bindings,
/// Dry-run a provider binding through the provider runtime registry.
Test {
/// Binding id (from [realm.<realm>.binding.<id>]).
binding_id: String,
},
/// Print auth profile status — reports realm config shape and the
/// observed AuthMachine lease lifecycle state.
Status {
/// Auth profile id.
profile_id: String,
},
/// Sign in to a provider and persist the resolved credential into the
/// TokenStore. Interactive (OAuth) when `--secret` is omitted; scripted
/// (inline api key) with `--non-interactive --secret`.
Login {
/// Provider — `anthropic` / `openai` / `gemini` (positional or
/// selected interactively when absent).
provider: Option<String>,
/// Target backend kind (e.g. `anthropic_api`, `openai_api`,
/// `chatgpt_backend`, `google_genai`). Defaults to the
/// provider's primary backend.
#[arg(long)]
backend: Option<String>,
/// Auth method (e.g. `api_key`, `azure_api_key`,
/// `managed_chatgpt_oauth`, `claude_ai_oauth`, `google_oauth`).
/// Defaults to the primary interactive flow for the provider.
#[arg(long)]
method: Option<String>,
/// Skip interactive prompts — resolve the secret from
/// `--secret` (or stdin if not given) and write directly to
/// TokenStore. Intended for CI / scripted provisioning.
#[arg(long, requires = "provider")]
non_interactive: bool,
/// Inline secret for `--non-interactive` flows. For
/// interactive flows the secret is captured via OAuth and
/// this flag is ignored.
#[arg(long, requires = "non_interactive")]
secret: Option<String>,
},
/// Clear persisted credentials for an auth profile from the TokenStore.
Logout {
/// Auth profile id (either `realm:binding` or bare `binding` — the
/// latter assumes realm `dev`).
profile_id: String,
},
/// Force a refresh of the persisted credential for an auth profile.
///
/// For OAuth-backed methods this exchanges the persisted refresh
/// token for a fresh access token and writes the result back into
/// the TokenStore. For api_key / static-bearer methods this is a
/// no-op (nothing to refresh); a descriptive message is printed.
///
/// Parallel to `rkat auth test <binding>` which also triggers a
/// refresh as a side effect of resolving the binding — this
/// subcommand is the explicit refresh-only entrypoint.
Refresh {
/// Auth profile id.
profile_id: String,
},
}
#[derive(Subcommand)]
enum ConfigCommands {
/// Print the current config
Get {
#[arg(long, default_value = "toml")]
format: ConfigFormat,
/// Include the monotonic config generation in the output envelope/header
#[arg(long)]
with_generation: bool,
},
/// Replace the config with the provided content
Set {
/// Path to a TOML or JSON config file
#[arg(value_name = "FILE", required_unless_present_any = ["json", "toml_payload"])]
file: Option<PathBuf>,
/// Raw JSON config payload
#[arg(long, conflicts_with = "toml_payload")]
json: Option<String>,
/// Raw TOML config payload
#[arg(long = "toml", conflicts_with = "json")]
toml_payload: Option<String>,
/// Reject the write unless the current generation matches
#[arg(long = "expected-generation")]
expected_generation: Option<u64>,
},
/// Apply a JSON merge patch to the config
Patch {
/// Path to a JSON patch file
#[arg(value_name = "FILE", required_unless_present = "json")]
file: Option<PathBuf>,
/// Raw JSON patch payload
#[arg(long, conflicts_with = "file")]
json: Option<String>,
/// Reject the write unless the current generation matches
#[arg(long = "expected-generation")]
expected_generation: Option<u64>,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ConfigFormat {
Toml,
Json,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum ToolPreset {
Safe,
Workspace,
Full,
None,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum StdinMode {
Auto,
Blob,
Lines,
Off,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum LineFormat {
Text,
Json,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum StreamView {
Primary,
Mux,
Focus,
}
#[derive(Subcommand)]
enum SessionCommands {
/// List sessions
List {
#[arg(long, default_value = "20")]
limit: usize,
#[arg(long)]
offset: Option<usize>,
/// Filter by label (key=value, repeatable). Only sessions matching ALL labels are shown.
#[arg(long = "label", value_name = "KEY=VALUE", value_parser = parse_label)]
labels: Vec<(String, String)>,
},
/// Show session details
Show {
/// Session ID
id: String,
},
/// Delete a session
Delete {
/// Session ID to delete
session_id: String,
},
/// Interrupt an in-flight turn for a session
Interrupt {
/// Session ID to interrupt
session_id: String,
},
}
#[derive(Subcommand)]
enum BlobCommands {
/// Fetch raw blob bytes or blob payload JSON.
Get {
/// Blob ID to fetch.
blob_id: String,
/// Write raw bytes to a file instead of stdout.
#[arg(long, value_name = "FILE")]
output: Option<PathBuf>,
/// Print the blob payload as JSON instead of raw bytes.
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum RealmCommands {
/// Print the current default realm ID (from CLI scope).
Current,
/// List realm manifests in the active state root.
List,
/// Show details for one realm.
Show {
/// Realm ID
realm_id: String,
},
/// Create a realm manifest with an optional backend pin.
Create {
/// Realm ID
realm_id: String,
/// Backend to pin when creating a new realm.
#[arg(long, value_enum)]
backend: Option<RealmBackendArg>,
},
/// Delete a realm and all its state.
Delete {
/// Realm ID
realm_id: String,
/// Delete even if active lease is present.
#[arg(long)]
force: bool,
},
/// Prune old realms.
Prune {
/// Only prune generated realms.
#[arg(long)]
isolated_only: bool,
/// Minimum age threshold in hours (default: 24).
#[arg(long, default_value_t = 24)]
older_than_hours: u64,
/// Ignore active lease and legacy safety checks.
#[arg(long)]
force: bool,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum WorkGraphStatusArg {
Open,
InProgress,
Blocked,
Completed,
Cancelled,
Failed,
}
impl From<WorkGraphStatusArg> for meerkat::WorkStatus {
fn from(value: WorkGraphStatusArg) -> Self {
match value {
WorkGraphStatusArg::Open => Self::Open,
WorkGraphStatusArg::InProgress => Self::InProgress,
WorkGraphStatusArg::Blocked => Self::Blocked,
WorkGraphStatusArg::Completed => Self::Completed,
WorkGraphStatusArg::Cancelled => Self::Cancelled,
WorkGraphStatusArg::Failed => Self::Failed,
}
}
}
#[derive(Subcommand)]
enum WorkGraphCommands {
/// List WorkGraph items
List {
#[arg(long)]
namespace: Option<String>,
#[arg(long)]
all_namespaces: bool,
#[arg(long = "status", value_enum)]
statuses: Vec<WorkGraphStatusArg>,
#[arg(long = "label")]
labels: Vec<String>,
#[arg(long)]
include_terminal: bool,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
json: bool,
},
/// Show one WorkGraph item
Show {
id: String,
#[arg(long)]
namespace: Option<String>,
#[arg(long)]
json: bool,
},
/// List ready WorkGraph items
Ready {
#[arg(long)]
namespace: Option<String>,
#[arg(long = "label")]
labels: Vec<String>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
json: bool,
},
/// Show a graph snapshot
Snapshot {
#[arg(long)]
namespace: Option<String>,
#[arg(long)]
all_namespaces: bool,
#[arg(long = "status", value_enum)]
statuses: Vec<WorkGraphStatusArg>,
#[arg(long = "label")]
labels: Vec<String>,
#[arg(long)]
include_terminal: bool,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
json: bool,
},
/// List WorkGraph events
Events {
#[arg(long)]
namespace: Option<String>,
#[arg(long)]
all_namespaces: bool,
#[arg(long)]
after_seq: Option<i64>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
json: bool,
},
}
/// CLI transport type selection
#[derive(Clone, Copy, Debug, ValueEnum, Default)]
enum CliTransport {
/// Local process via stdin/stdout (default)
#[default]
Stdio,
/// Streamable HTTP (modern standard)
Http,
/// Server-Sent Events (legacy)
Sse,
}
#[cfg(feature = "skills")]
#[derive(Subcommand)]
enum SkillsCommands {
/// Add a filesystem-backed skill source to the current realm config
Add {
/// Path to a skill directory, SKILL.md file, or repository root
path: String,
/// Optional repository name override
#[arg(long)]
name: Option<String>,
},
/// Remove a configured skill source by name, source UUID, or path
Remove {
/// Configured repository name, source UUID, or path
selector: String,
},
/// Show a configured skill source by name, source UUID, or path
Get {
/// Configured repository name, source UUID, or path
selector: String,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// List available skills with provenance information
List {
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Inspect a skill's full content
Inspect {
/// Skill name (for example "email-extractor")
skill_name: String,
/// Canonical source UUID for the skill source
#[arg(long)]
source_uuid: String,
/// Output as JSON
#[arg(long)]
json: bool,
},
}
#[cfg(feature = "mcp")]
#[derive(Subcommand)]
enum McpCommands {
/// Add an MCP server
Add {
/// Server name
name: String,
/// Transport type (default: stdio for command, http for url)
#[arg(long, short = 't', value_enum)]
transport: Option<CliTransport>,
/// Config scope
#[arg(long, value_enum, default_value = "project")]
scope: CliMcpScope,
/// Server URL (for http/sse transports)
#[arg(long, short = 'u')]
url: Option<String>,
/// HTTP header (KEY:VALUE). Can be repeated. (for http/sse transports)
#[arg(long = "header", short = 'H', value_name = "KEY:VALUE")]
headers: Vec<String>,
/// Environment variable (KEY=VALUE). Can be repeated. (for stdio transport)
#[arg(short = 'e', long = "env", value_name = "KEY=VALUE")]
env: Vec<String>,
/// Command and arguments after -- (for stdio transport)
#[arg(last = true, num_args = 0..)]
command: Vec<String>,
},
/// Remove an MCP server
Remove {
/// Server name
name: String,
/// Scope to remove from
#[arg(long, value_enum)]
scope: Option<CliMcpScope>,
},
/// List configured MCP servers
List {
/// Scope to list (default: all)
#[arg(long, value_enum)]
scope: Option<CliMcpScope>,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Get details of an MCP server
Get {
/// Server name
name: String,
/// Scope to search (default: all)
#[arg(long, value_enum)]
scope: Option<CliMcpScope>,
/// Output as JSON
#[arg(long)]
json: bool,
},
}
#[cfg(feature = "mob")]
#[derive(Subcommand)]
enum MobCommands {
/// Pack a mob directory into a .mobpack archive.
Pack {
dir: PathBuf,
#[arg(short = 'o', long)]
output: PathBuf,
/// Path to an Ed25519 signing key (hex-encoded).
/// Requires --signer-id.
#[arg(long, requires = "signer_id")]
sign: Option<PathBuf>,
/// Semantic signer identity recorded in the pack signature
/// (e.g. "team@example.com"). Required when --sign is set.
#[arg(long, requires = "sign")]
signer_id: Option<String>,
},
/// Inspect a .mobpack archive.
Inspect { pack: PathBuf },
/// Validate a .mobpack archive.
Validate {
pack: PathBuf,
#[arg(long, value_enum)]
trust_policy: Option<TrustPolicyArg>,
},
/// Deploy a .mobpack archive with a prompt.
Deploy {
pack: PathBuf,
prompt: String,
/// Override model at deploy time.
#[arg(long, short = 'm')]
model: Option<String>,
/// Override maximum total tokens at deploy time.
#[arg(long)]
max_total_tokens: Option<u64>,
/// Override maximum duration at deploy time (e.g., "5m", "1h30m").
#[arg(long, short = 'd')]
max_duration: Option<String>,
/// Override maximum tool calls at deploy time.
#[arg(long)]
max_tool_calls: Option<usize>,
#[arg(long, value_enum)]
trust_policy: Option<TrustPolicyArg>,
#[arg(long, value_enum, default_value = "cli")]
surface: DeploySurfaceArg,
},
/// Start a flow run and print the run_id.
RunFlow {
mob_id: String,
#[arg(long = "flow")]
flow: String,
#[arg(long = "params")]
params: Option<String>,
/// Stream flow member outputs while the run is executing
#[arg(long, short = 's')]
stream: bool,
/// Disable streaming output
#[arg(long)]
no_stream: bool,
},
/// Show JSON status for a flow run.
FlowStatus { mob_id: String, run_id: String },
/// Spawn a short-lived helper member, wait for it to finish, and print the result.
SpawnHelper {
/// Mob ID to spawn into
mob_id: String,
/// Task prompt for the helper
prompt: String,
/// Agent identity for the helper (auto-generated if omitted)
#[arg(long)]
agent_identity: Option<String>,
/// Profile to use
#[arg(long)]
profile: Option<String>,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Fork from an existing member's context, wait for completion, and print the result.
ForkHelper {
/// Mob ID
mob_id: String,
/// Source member to fork from
source_member: String,
/// Task prompt for the forked helper
prompt: String,
/// Agent identity for the helper (auto-generated if omitted)
#[arg(long)]
agent_identity: Option<String>,
/// Profile to use
#[arg(long)]
profile: Option<String>,
/// Fork context type (full-history or last-messages)
#[arg(long, default_value = "full-history")]
fork_context: String,
/// Number of last messages (when fork-context is last-messages)
#[arg(long)]
last_messages: Option<u32>,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Get execution status snapshot for a mob member.
MemberStatus {
/// Mob ID
mob_id: String,
/// Agent identity of the member
agent_identity: String,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Force-cancel a member's in-flight turn.
ForceCancel {
/// Mob ID
mob_id: String,
/// Agent identity of the member to cancel
agent_identity: String,
},
/// Retire and respawn a mob member with the same profile.
Respawn {
/// Mob ID
mob_id: String,
/// Agent identity to respawn
agent_identity: String,
/// Initial message for the respawned member
#[arg(long)]
initial_message: Option<String>,
},
/// Wait for autonomous kickoff turns to complete.
WaitKickoff {
/// Mob ID
mob_id: String,
/// Restrict wait to specific members (repeatable)
#[arg(long = "member")]
member_ids: Vec<String>,
/// Timeout in milliseconds (defaults to 10 minutes)
#[arg(long)]
timeout_ms: Option<u64>,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Web deployment commands.
Web {
#[command(subcommand)]
command: MobWebCommands,
},
}
#[cfg(feature = "mob")]
#[derive(Subcommand)]
enum MobWebCommands {
/// Build a browser-deployable WASM bundle from a .mobpack archive.
Build {
pack: PathBuf,
#[arg(short = 'o', long)]
output: PathBuf,
#[arg(long, value_enum)]
trust_policy: Option<TrustPolicyArg>,
},
}
#[cfg(feature = "mob")]
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum TrustPolicyArg {
Permissive,
Strict,
}
#[cfg(feature = "mob")]
impl From<TrustPolicyArg> for TrustPolicy {
fn from(value: TrustPolicyArg) -> Self {
match value {
TrustPolicyArg::Permissive => TrustPolicy::Permissive,
TrustPolicyArg::Strict => TrustPolicy::Strict,
}
}
}
#[cfg(feature = "mob")]
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum DeploySurfaceArg {
Cli,
Rpc,
}
/// CLI-side scope enum (maps to McpScope)
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CliMcpScope {
/// User-level config (~/.rkat/mcp.toml)
User,
/// Project-level config (.rkat/mcp.toml)
Project,
/// Alias for project (Claude compatibility)
Local,
}
impl From<CliMcpScope> for Option<McpScope> {
fn from(s: CliMcpScope) -> Self {
match s {
CliMcpScope::User => Some(McpScope::User),
CliMcpScope::Project | CliMcpScope::Local => Some(McpScope::Project),
}
}
}
fn cli_enables_verbose_tracing(cli: &Cli) -> bool {
matches!(&cli.command, Commands::Run { verbose: true, .. })
}
fn default_trace_filter(cli: &Cli) -> &'static str {
if cli_enables_verbose_tracing(cli) {
VERBOSE_TRACE_FILTER
} else {
DEFAULT_TRACE_FILTER
}
}
fn init_tracing(cli: &Cli) {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_trace_filter(cli)));
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.init();
}
#[tokio::main]
#[allow(clippy::large_futures)]
async fn main() -> anyhow::Result<ExitCode> {
let cli = Cli::parse_from(normalize_cli_args(std::env::args_os()));
let auth_config_realm = if matches!(&cli.command, Commands::Auth { .. }) {
cli.realm.clone()
} else {
None
};
init_tracing(&cli);
let cli_scope = if auth_config_realm.is_some() {
resolve_runtime_scope_with_realm(&cli, None)?
} else {
resolve_runtime_scope(&cli)?
};
let result = match cli.command {
Commands::Init => init_project_config().await,
Commands::Run {
prompt,
resume,
system_prompt,
model,
provider,
max_tokens,
max_duration,
max_tool_calls,
output,
json,
html,
browser,
open_in_browser,
html_template,
html_template_file,
stream,
no_stream,
no_web_search,
params,
provider_params_json,
output_schema,
#[cfg(feature = "skills")]
skills,
allow_tools,
block_tools,
labels,
instructions,
app_context,
tools,
yolo,
#[cfg(feature = "mcp")]
wait_for_mcp,
verbose,
keep_alive,
stdin,
line_format,
auth_binding,
} => {
#[cfg(feature = "skills")]
let run_skills = skills;
#[cfg(not(feature = "skills"))]
let run_skills = Vec::new();
#[cfg(feature = "mcp")]
let wait_for_mcp_enabled = wait_for_mcp;
#[cfg(not(feature = "mcp"))]
let wait_for_mcp_enabled = false;
// Wave-c C-12: parse user-supplied `realm:binding[:profile]`
// at the CLI argument boundary. Downstream receives the
// typed `Option<AuthBindingRef>`; the opaque-string form
// never crosses into session-build options.
let auth_binding = match auth_binding.as_deref() {
None => None,
Some(raw) => Some(
cli_parse::parse_auth_binding_user_input(raw)
.map_err(|e| anyhow::anyhow!("{e}"))?,
),
};
Box::pin(handle_run_command(
prompt,
resume,
system_prompt,
model,
provider,
max_tokens,
max_duration,
max_tool_calls,
output,
json,
html,
browser,
open_in_browser,
html_template,
html_template_file,
stream,
no_stream,
no_web_search,
params,
provider_params_json,
output_schema,
run_skills,
allow_tools,
block_tools,
labels,
instructions,
app_context,
tools,
yolo,
wait_for_mcp_enabled,
verbose,
keep_alive,
stdin,
line_format,
auth_binding,
&cli_scope,
))
.await
}
Commands::Help {
question,
prompt,
plan_execution,
model,
provider,
max_tokens,
output,
json,
html,
browser,
open_in_browser,
html_template,
html_template_file,
stream,
no_stream,
} => {
Box::pin(handle_help_command(
question,
prompt,
plan_execution,
model,
provider,
max_tokens,
output,
json,
html,
browser,
open_in_browser,
html_template,
html_template_file,
stream,
no_stream,
&cli_scope,
))
.await
}
Commands::Sessions { command } => match command {
SessionCommands::List {
limit,
offset,
labels,
} => list_sessions(limit, offset, labels, &cli_scope).await,
SessionCommands::Show { id } => show_session(&id, &cli_scope).await,
SessionCommands::Delete { session_id } => delete_session(&session_id, &cli_scope).await,
SessionCommands::Interrupt { session_id } => {
interrupt_session(&session_id, &cli_scope).await
}
},
Commands::Blob { command } => handle_blob_command(command, &cli_scope).await,
Commands::Realms { command } => handle_realm_command(command, &cli_scope).await,
Commands::WorkGraph { command } => handle_workgraph_command(command, &cli_scope).await,
#[cfg(feature = "mcp")]
Commands::Mcp { command } => handle_mcp_command(command).await,
#[cfg(feature = "skills")]
Commands::Skills { command } => handle_skills_command(command, &cli_scope).await,
#[cfg(feature = "mob")]
Commands::Mob { command } => handle_mob_command(command, &cli_scope).await,
Commands::Config { command } => match command {
ConfigCommands::Get {
format,
with_generation,
} => handle_config_get(format, with_generation, &cli_scope).await,
ConfigCommands::Set {
file,
json,
toml_payload,
expected_generation,
} => handle_config_set(file, json, toml_payload, expected_generation, &cli_scope).await,
ConfigCommands::Patch {
file,
json,
expected_generation,
} => handle_config_patch(file, json, expected_generation, &cli_scope).await,
},
Commands::Capabilities => handle_capabilities(&cli_scope).await,
Commands::Models => handle_models_catalog(&cli_scope).await,
Commands::Doctor => handle_doctor(&cli_scope).await,
Commands::Auth { command } => {
handle_auth_command(command, &cli_scope, auth_config_realm.as_deref()).await
}
};
// Map result to exit code
Ok(match result {
Ok(()) => ExitCode::from(EXIT_SUCCESS),
Err(e) => {
// Check if it's a budget exhaustion error
if let Some(agent_err) = e.downcast_ref::<AgentError>()
&& agent_err.is_graceful()
{
// Budget exhausted - this is a graceful termination
eprintln!("Budget exhausted: {agent_err}");
return Ok(ExitCode::from(EXIT_BUDGET_EXHAUSTED));
}
eprintln!("Error: {e}");
ExitCode::from(EXIT_ERROR)
}
})
}
#[allow(clippy::too_many_arguments)]
async fn handle_help_command(
question: String,
prompt: Option<String>,
plan_execution: bool,
model: Option<String>,
provider: Option<Provider>,
max_tokens: Option<u32>,
output: CliOutputFormat,
json: bool,
html: bool,
browser: bool,
open_in_browser: bool,
html_template: Option<String>,
html_template_file: Option<PathBuf>,
stream: bool,
no_stream: bool,
scope: &RuntimeScope,
) -> anyhow::Result<()> {
let request = meerkat_contracts::HelpRequest {
question,
prompt,
execution_mode: if plan_execution {
meerkat_contracts::HelpExecutionMode::PlanExecution
} else {
meerkat_contracts::HelpExecutionMode::ExplainOnly
},
model: model.clone(),
provider: provider.map(|provider| provider.as_str().to_string()),
max_tokens,
};
let help_prompt = meerkat::help::render_help_prompt(&request)?;
handle_run_command(
help_prompt,
None,
Some(meerkat::help::help_system_prompt().to_string()),
model,
provider,
max_tokens,
None,
None,
output,
json,
html,
browser,
open_in_browser,
html_template,
html_template_file,
stream,
no_stream,
false,
Vec::new(),
None,
None,
meerkat::help::platform_preload_skill_names(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
None,
Some(ToolPreset::None),
false,
false,
false,
false,
StdinMode::Off,
LineFormat::Text,
None,
scope,
)
.await
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::large_futures)]
async fn handle_run_command(
mut prompt: String,
resume: Option<String>,
system_prompt: Option<String>,
model: Option<String>,
provider: Option<Provider>,
max_tokens: Option<u32>,
max_duration: Option<String>,
max_tool_calls: Option<usize>,
output: CliOutputFormat,
json: bool,
html: bool,
browser: bool,
open_in_browser: bool,
html_template: Option<String>,
html_template_file: Option<PathBuf>,
stream: bool,
no_stream: bool,
no_web_search: bool,
params: Vec<String>,
provider_params_json: Option<String>,
output_schema: Option<String>,
skills: Vec<String>,
allow_tools: Vec<String>,
block_tools: Vec<String>,
labels: Vec<(String, String)>,
instructions: Vec<String>,
app_context: Option<String>,
tools: Option<ToolPreset>,
yolo: bool,
wait_for_mcp: bool,
verbose: bool,
keep_alive: bool,
stdin: StdinMode,
line_format: LineFormat,
auth_binding: Option<AuthBindingRef>,
scope: &RuntimeScope,
) -> anyhow::Result<()> {
let output = resolve_cli_output_selection(
output,
json,
html,
browser,
open_in_browser,
html_template,
html_template_file,
)?;
if output.is_html() && output_schema.is_some() {
anyhow::bail!("--html cannot be combined with --schema in HTML Output Mode V1");
}
if let Some(session_id) = resume {
return resume_session(
&session_id,
prompt,
system_prompt,
model,
provider,
max_tokens,
output_schema,
skills,
allow_tools,
block_tools,
labels,
instructions,
app_context,
max_duration,
max_tool_calls,
output,
params,
provider_params_json,
no_web_search,
stream,
no_stream,
stdin,
line_format,
auth_binding,
scope,
verbose,
wait_for_mcp,
tools,
yolo,
keep_alive,
)
.await;
}
let (config, config_base_dir) = load_config(scope).await?;
let (config, runtime_preload_skills) = resolve_runtime_skills(config, skills).await?;
let model_was_explicit = model.is_some();
let provider_was_explicit = provider.is_some();
let auth_binding_selection = auth_binding
.as_ref()
.map(|binding| resolve_cli_auth_binding_selection(&config, binding))
.transpose()?;
let model =
resolve_cli_effective_model(&config, model, provider, auth_binding_selection.as_ref());
let max_tokens = max_tokens.unwrap_or(config.agent.max_tokens_per_turn);
let resolved_provider = resolve_cli_provider_with_auth_binding(
&config,
&model,
provider,
auth_binding_selection.as_ref(),
)?;
let build_provider_override =
(provider.is_some() || auth_binding.is_some() || model_was_explicit)
.then_some(resolved_provider);
let duration = max_duration.map(|s| parse_duration(&s)).transpose();
let provider_params = parse_provider_params(¶ms);
let provider_params_json = parse_provider_params_json(provider_params_json);
let hooks_override = HookRunOverrides::default();
let stream = resolve_stream_enabled(stream, no_stream, output.format.streams_by_default())?;
let stream_policy = if stream {
Some(stream_renderer::StreamRenderPolicy::PrimaryOnly)
} else {
None
};
let stdin = resolve_stdin_mode(stdin);
let parsed_output_schema = output_schema
.as_ref()
.map(|s| parse_output_schema(s))
.transpose()?;
let tooling = resolve_tool_preset(tools.unwrap_or(ToolPreset::Safe), yolo);
let html_output_request =
resolve_html_output_request(&output, &config, &config_base_dir).await?;
if matches!(stdin, StdinMode::Blob | StdinMode::Auto) {
prompt = prepend_stdin_blob_context(prompt);
}
if let Some(request) = &html_output_request {
append_html_output_instruction(&mut prompt, request);
}
match (duration, provider_params, provider_params_json) {
(Ok(dur), Ok(parsed_params), Ok(parsed_params_json)) => {
let merged_provider_params = merge_provider_params(parsed_params, parsed_params_json)?;
let merged_provider_params = apply_no_web_search_provider_param(
resolved_provider,
merged_provider_params,
no_web_search,
)?;
let mut limits = config.budget_limits();
if let Some(max_duration) = dur {
limits.max_duration = Some(max_duration);
}
if let Some(max_tool_calls) = max_tool_calls {
limits.max_tool_calls = Some(max_tool_calls);
}
run_agent(
&prompt,
system_prompt,
&model,
resolved_provider,
build_provider_override,
model_was_explicit,
provider_was_explicit,
max_tokens,
limits,
output,
stream,
stream_policy.clone(),
merged_provider_params,
parsed_output_schema,
2,
CommsOverrides::default(),
tooling.builtins,
tooling.shell,
tooling.memory,
tooling.workgraph,
tooling.mob,
wait_for_mcp,
verbose,
keep_alive || matches!(stdin, StdinMode::Lines),
matches!(stdin, StdinMode::Lines),
line_format,
&config,
runtime_preload_skills,
allow_tools,
block_tools,
labels,
instructions,
app_context,
config_base_dir,
hooks_override,
auth_binding.clone(),
scope,
)
.await
}
(Err(e), _, _) => Err(e),
(_, Err(e), _) => Err(e),
(_, _, Err(e)) => Err(e),
}
}
/// Parse a duration string like "5m", "1h30m", "30s"
fn parse_duration(s: &str) -> anyhow::Result<Duration> {
humantime::parse_duration(s).map_err(|e| anyhow::anyhow!("Invalid duration '{s}': {e}"))
}
/// Parse --param KEY=VALUE flags into a JSON object
///
/// Returns None if params is empty, Some(object) otherwise.
/// Errors if any param is missing the '=' separator.
fn parse_provider_params(params: &[String]) -> anyhow::Result<Option<serde_json::Value>> {
if params.is_empty() {
return Ok(None);
}
let mut map = serde_json::Map::new();
for param in params {
let (key, value) = param.split_once('=').ok_or_else(|| {
anyhow::anyhow!("Invalid --param format '{param}': expected KEY=VALUE")
})?;
map.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
}
Ok(Some(serde_json::Value::Object(map)))
}
/// Parse --params-json into a JSON object.
fn parse_provider_params_json(raw: Option<String>) -> anyhow::Result<Option<serde_json::Value>> {
let Some(raw) = raw else {
return Ok(None);
};
let value: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("Invalid --params-json: {e}"))?;
if !value.is_object() {
return Err(anyhow::anyhow!("--params-json must be a JSON object"));
}
Ok(Some(value))
}
/// Merge provider params from --provider-params-json and repeated --param flags.
///
/// When both are provided, KEY=VALUE flags take precedence for matching keys.
fn merge_provider_params(
kv_params: Option<serde_json::Value>,
json_params: Option<serde_json::Value>,
) -> anyhow::Result<Option<serde_json::Value>> {
match (kv_params, json_params) {
(None, None) => Ok(None),
(Some(kv), None) => Ok(Some(kv)),
(None, Some(json)) => Ok(Some(json)),
(Some(serde_json::Value::Object(kv)), Some(serde_json::Value::Object(mut json))) => {
json.extend(kv);
Ok(Some(serde_json::Value::Object(json)))
}
_ => Err(anyhow::anyhow!(
"provider params must be JSON objects after parsing"
)),
}
}
fn provider_web_search_param_key(provider: Provider) -> Option<&'static str> {
match provider {
Provider::Anthropic | Provider::Openai => Some("web_search"),
Provider::Gemini => Some("google_search"),
Provider::SelfHosted => None,
}
}
fn apply_no_web_search_provider_param(
provider: Provider,
provider_params: Option<serde_json::Value>,
no_web_search: bool,
) -> anyhow::Result<Option<serde_json::Value>> {
if !no_web_search {
return Ok(provider_params);
}
let Some(key) = provider_web_search_param_key(provider) else {
return Ok(provider_params);
};
let mut opt_out = serde_json::Map::new();
opt_out.insert(key.to_string(), serde_json::Value::Null);
let opt_out = Some(serde_json::Value::Object(opt_out));
merge_provider_params(opt_out, provider_params)
}
fn apply_no_web_search_resume_provider_params(
provider: Option<Provider>,
model_override_provider: Option<Provider>,
stored_provider: meerkat_core::Provider,
stored_provider_params: Option<&serde_json::Value>,
merged_provider_params: &mut Option<serde_json::Value>,
no_web_search: bool,
) -> anyhow::Result<()> {
if !no_web_search {
return Ok(());
}
let Some(web_search_provider) = provider
.or(model_override_provider)
.or_else(|| Provider::from_core(stored_provider))
else {
return Ok(());
};
let base_params = merged_provider_params
.take()
.or_else(|| stored_provider_params.cloned());
*merged_provider_params =
apply_no_web_search_provider_param(web_search_provider, base_params, true)?;
Ok(())
}
fn looks_like_path(raw: &str) -> bool {
raw.starts_with("./")
|| raw.starts_with("../")
|| raw.starts_with("~/")
|| raw.starts_with('/')
|| std::path::Path::new(raw).exists()
}
fn expand_path(raw: &str) -> anyhow::Result<PathBuf> {
if let Some(rest) = raw.strip_prefix("~/") {
let home = std::env::var_os("HOME")
.ok_or_else(|| anyhow::anyhow!("Cannot expand '~' without HOME"))?;
return Ok(PathBuf::from(home).join(rest));
}
Ok(PathBuf::from(raw))
}
#[derive(Debug, Clone)]
struct ResolvedSkillRepoPath {
repo_path: PathBuf,
implied_skill_id: Option<String>,
default_name: String,
}
fn looks_like_resume_target(raw: &str) -> bool {
if matches!(raw, "last" | "~" | "~0") {
return true;
}
if raw
.strip_prefix('~')
.is_some_and(|rest| !rest.is_empty() && rest.chars().all(|ch| ch.is_ascii_digit()))
{
return true;
}
if SessionLocator::parse(raw).is_ok() {
return true;
}
raw.len() >= 8 && raw.len() <= 36 && raw.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '-')
}
fn derive_skill_source_uuid(repo_path: &Path) -> anyhow::Result<meerkat_core::skills::SourceUuid> {
let source_uuid = uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("rkat-skill-source:{}", repo_path.display()).as_bytes(),
);
meerkat_core::skills::SourceUuid::parse(&source_uuid.to_string())
.map_err(|e| anyhow::anyhow!("Failed to derive source UUID: {e}"))
}
async fn resolve_skill_repo_path(raw: &str) -> anyhow::Result<ResolvedSkillRepoPath> {
let input = expand_path(raw)?;
let absolute = if input.is_absolute() {
input
} else {
std::env::current_dir()?.join(input)
};
let canonical = tokio::fs::canonicalize(&absolute)
.await
.map_err(|e| anyhow::anyhow!("Failed to resolve skill path '{raw}': {e}"))?;
let (repo_path, implied_skill_id) = if canonical.is_file() {
if canonical.file_name().and_then(|name| name.to_str()) != Some("SKILL.md") {
return Err(anyhow::anyhow!(
"Skill file paths must point to SKILL.md: {}",
canonical.display()
));
}
let skill_dir = canonical
.parent()
.ok_or_else(|| anyhow::anyhow!("Skill file has no parent directory"))?
.to_path_buf();
let skill_id = skill_dir
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
anyhow::anyhow!("Invalid skill directory name: {}", skill_dir.display())
})?
.to_string();
(skill_dir, Some(skill_id))
} else if tokio::fs::try_exists(canonical.join("SKILL.md")).await? {
let skill_id = canonical
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
anyhow::anyhow!("Invalid skill directory name: {}", canonical.display())
})?
.to_string();
(canonical, Some(skill_id))
} else {
let default_name = canonical
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("Invalid skill source path: {}", canonical.display()))?;
return Ok(ResolvedSkillRepoPath {
repo_path: canonical,
implied_skill_id: None,
default_name,
});
};
Ok(ResolvedSkillRepoPath {
default_name: implied_skill_id
.clone()
.ok_or_else(|| anyhow::anyhow!("Missing implied skill id"))?,
repo_path,
implied_skill_id,
})
}
async fn resolve_runtime_skill_path(
raw: &str,
) -> anyhow::Result<(meerkat_core::skills_config::SkillRepositoryConfig, String)> {
let resolved = resolve_skill_repo_path(raw).await?;
let skill_id = resolved.implied_skill_id.ok_or_else(|| {
anyhow::anyhow!(
"Runtime --skill paths must point to a skill directory or SKILL.md file: {}",
resolved.repo_path.display()
)
})?;
let source_uuid = derive_skill_source_uuid(&resolved.repo_path)?;
Ok((
meerkat_core::skills_config::SkillRepositoryConfig {
name: format!("local-{skill_id}"),
source_uuid,
transport: meerkat_core::skills_config::SkillRepoTransport::Filesystem {
path: resolved.repo_path.display().to_string(),
},
},
skill_id,
))
}
async fn resolve_runtime_skills(
mut config: Config,
skills: Vec<String>,
) -> anyhow::Result<(Config, Vec<String>)> {
let mut preload = Vec::new();
for skill in skills {
if looks_like_path(&skill) {
let (repo, skill_id) = resolve_runtime_skill_path(&skill).await?;
let already_configured = config.skills.repositories.iter().any(|existing| {
existing.source_uuid == repo.source_uuid || existing.name == repo.name
});
if !already_configured {
config.skills.repositories.push(repo);
}
config.skills.enabled = true;
preload.push(skill_id);
} else {
preload.push(skill);
}
}
Ok((config, preload))
}
/// Parse output schema from CLI argument.
/// If the value starts with '{', treat it as inline JSON.
/// Otherwise, treat it as a file path.
fn parse_output_schema(schema_arg: &str) -> anyhow::Result<OutputSchema> {
let schema_str = if schema_arg.trim().starts_with('{') {
schema_arg.to_string()
} else {
std::fs::read_to_string(schema_arg)
.map_err(|e| anyhow::anyhow!("Failed to read schema file '{schema_arg}': {e}"))?
};
OutputSchema::from_json_str(&schema_str)
.map_err(|e| anyhow::anyhow!("Invalid output schema: {e}"))
}
#[cfg(test)]
/// Parse run-scoped hook overrides from either --hooks-override-json or --hooks-override-file.
fn parse_hook_run_overrides(
hooks_override_file: Option<PathBuf>,
hooks_override_json: Option<String>,
) -> anyhow::Result<HookRunOverrides> {
match (hooks_override_file, hooks_override_json) {
(Some(_), Some(_)) => Err(anyhow::anyhow!(
"Provide either --hooks-override-json or --hooks-override-file, not both"
)),
(Some(path), None) => {
let content = std::fs::read_to_string(&path).map_err(|e| {
anyhow::anyhow!(
"Failed to read hook override file '{}': {}",
path.display(),
e
)
})?;
serde_json::from_str::<HookRunOverrides>(&content).map_err(|e| {
anyhow::anyhow!(
"Invalid hook override JSON in file '{}': {}",
path.display(),
e
)
})
}
(None, Some(json_payload)) => serde_json::from_str::<HookRunOverrides>(&json_payload)
.map_err(|e| anyhow::anyhow!("Invalid --hooks-override-json payload: {e}")),
(None, None) => Ok(HookRunOverrides::default()),
}
}
#[derive(Debug, Clone, Default)]
struct CommsOverrides {
name: Option<String>,
listen_tcp: Option<String>,
disabled: bool,
peer_meta: Option<meerkat_core::PeerMeta>,
}
#[derive(Clone)]
struct RuntimeScope {
locator: RealmLocator,
instance_id: Option<String>,
backend_hint: Option<RealmBackend>,
origin_hint: RealmOrigin,
context_root: Option<PathBuf>,
user_config_root: Option<PathBuf>,
auth_lease: Arc<dyn meerkat_core::handles::AuthLeaseHandle>,
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
oauth_flow_authority: Arc<dyn meerkat_providers::oauth_flow::OAuthFlowAuthority>,
}
impl RuntimeScope {
fn backend_hint(&self) -> Option<RealmBackend> {
self.backend_hint
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn new_cli_auth_handles() -> (
Arc<dyn meerkat_core::handles::AuthLeaseHandle>,
Arc<dyn meerkat_providers::oauth_flow::OAuthFlowAuthority>,
) {
let auth_lease = Arc::new(meerkat_runtime::RuntimeAuthLeaseHandle::new());
let oauth_flow_authority = Arc::new(
meerkat_runtime::handles::RuntimeOAuthFlowHandle::new_with_auth_lease(
std::time::Duration::from_secs(10 * 60),
Arc::clone(&auth_lease),
),
);
(
auth_lease as Arc<dyn meerkat_core::handles::AuthLeaseHandle>,
oauth_flow_authority as Arc<dyn meerkat_providers::oauth_flow::OAuthFlowAuthority>,
)
}
#[cfg(not(all(feature = "anthropic", feature = "openai", feature = "gemini")))]
fn new_cli_auth_lease() -> Arc<dyn meerkat_core::handles::AuthLeaseHandle> {
Arc::new(meerkat_runtime::RuntimeAuthLeaseHandle::new())
}
fn resolve_runtime_scope(cli: &Cli) -> anyhow::Result<RuntimeScope> {
resolve_runtime_scope_with_realm(cli, cli.realm.clone())
}
fn resolve_runtime_scope_with_realm(
cli: &Cli,
realm_override: Option<String>,
) -> anyhow::Result<RuntimeScope> {
let context_root = cli
.context_root
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let default_selection = RealmSelection::WorkspaceDerived {
root: context_root.clone(),
};
let selection =
RealmConfig::selection_from_inputs(realm_override, cli.isolated, default_selection)?;
let origin_hint = match &selection {
RealmSelection::Explicit { .. } => RealmOrigin::Explicit,
RealmSelection::Isolated => RealmOrigin::Generated,
RealmSelection::WorkspaceDerived { .. } => RealmOrigin::Workspace,
};
let state_root = cli
.state_root
.clone()
.unwrap_or_else(|| default_cli_state_root(&context_root));
let realm_cfg = RealmConfig {
selection,
instance_id: cli.instance.clone(),
backend_hint: cli
.realm_backend
.map(Into::into)
.map(|b: RealmBackend| b.as_str().to_string()),
state_root: Some(state_root),
};
let locator = realm_cfg.resolve_locator()?;
let user_config_root = cli.user_config_root.clone().or_else(dirs::home_dir);
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
let (auth_lease, oauth_flow_authority) = new_cli_auth_handles();
#[cfg(not(all(feature = "anthropic", feature = "openai", feature = "gemini")))]
let auth_lease = new_cli_auth_lease();
Ok(RuntimeScope {
locator,
instance_id: cli.instance.clone(),
// Only pass an explicit backend hint when the caller asked for one.
// Existing realms are always opened using their pinned manifest backend.
backend_hint: cli.realm_backend.map(Into::into),
origin_hint,
context_root: Some(context_root),
user_config_root,
auth_lease,
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
oauth_flow_authority,
})
}
fn default_cli_state_root(context_root: &Path) -> PathBuf {
context_root.join(".rkat").join("realms")
}
async fn resolve_config_store(
scope: &RuntimeScope,
) -> anyhow::Result<(Arc<dyn ConfigStore>, PathBuf)> {
let paths =
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str());
if let Some(parent) = paths.config_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| anyhow::anyhow!("Failed to create realm config directory: {e}"))?;
}
Ok((
Arc::new(FileConfigStore::new(paths.config_path)),
paths.root,
))
}
async fn load_config(scope: &RuntimeScope) -> anyhow::Result<(Config, PathBuf)> {
let (store, base_dir) = resolve_config_store(scope).await?;
let mut config = store
.get()
.await
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
config
.apply_env_overrides()
.map_err(|e| anyhow::anyhow!("Failed to apply env overrides: {e}"))?;
config
.validate()
.map_err(|e| anyhow::anyhow!("Invalid runtime config: {e}"))?;
Ok((config, base_dir))
}
const LEGACY_AGENT_MODEL_DEFAULTS: &[&str] = &["claude-opus-4-7"];
fn model_provider(config: &Config, model: &str) -> Option<Provider> {
config
.model_registry()
.ok()
.and_then(|registry| registry.entry(model).map(|entry| entry.provider))
.and_then(Provider::from_core)
}
fn provider_default_model(config: &Config, provider: Provider) -> Option<String> {
let model = match provider {
Provider::Anthropic => &config.models.anthropic,
Provider::Openai => &config.models.openai,
Provider::Gemini => &config.models.gemini,
Provider::SelfHosted => return None,
};
(!model.is_empty()).then(|| model.clone())
}
fn best_available_default_model(config: &Config) -> String {
[Provider::Openai, Provider::Anthropic, Provider::Gemini]
.into_iter()
.find_map(|provider| provider_default_model(config, provider))
.unwrap_or_else(|| config.agent.model.clone())
}
fn resolve_cli_default_agent_model(config: &Config) -> String {
if LEGACY_AGENT_MODEL_DEFAULTS.contains(&config.agent.model.as_str()) {
return best_available_default_model(config);
}
config.agent.model.clone()
}
fn resolve_provider_constrained_default_model(config: &Config, provider: Provider) -> String {
match model_provider(config, &config.agent.model) {
Some(model_provider) if model_provider == provider => config.agent.model.clone(),
Some(_) => {
provider_default_model(config, provider).unwrap_or_else(|| config.agent.model.clone())
}
None => config.agent.model.clone(),
}
}
fn resolve_cli_effective_model(
config: &Config,
explicit_model: Option<String>,
explicit_provider: Option<Provider>,
auth_binding: Option<&CliAuthBindingSelection>,
) -> String {
if let Some(model) = explicit_model {
return model;
}
if let Some(model) = auth_binding.and_then(|selection| selection.default_model.clone()) {
return model;
}
if let Some(provider) =
explicit_provider.or_else(|| auth_binding.map(|selection| selection.provider))
{
return resolve_provider_constrained_default_model(config, provider);
}
resolve_cli_default_agent_model(config)
}
async fn handle_config_get(
format: ConfigFormat,
with_generation: bool,
scope: &RuntimeScope,
) -> anyhow::Result<()> {
let (store, base_dir) = resolve_config_store(scope).await?;
let runtime =
meerkat_core::ConfigRuntime::new(Arc::clone(&store), base_dir.join("config_state.json"));
let snapshot = runtime
.get()
.await
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
match format {
ConfigFormat::Toml => {
let rendered = toml::to_string_pretty(&snapshot.config)
.map_err(|e| anyhow::anyhow!("Failed to serialize config: {e}"))?;
if with_generation {
println!("# generation = {}", snapshot.generation);
}
println!("{rendered}");
}
ConfigFormat::Json => {
let rendered = if with_generation {
serde_json::to_string_pretty(&ConfigEnvelope::from_snapshot(
snapshot,
ConfigEnvelopePolicy::Public,
))
} else {
serde_json::to_string_pretty(&snapshot.config)
}
.map_err(|e| anyhow::anyhow!("Failed to serialize config: {e}"))?;
println!("{rendered}");
}
}
Ok(())
}
async fn handle_config_set(
file: Option<PathBuf>,
json: Option<String>,
toml_payload: Option<String>,
expected_generation: Option<u64>,
scope: &RuntimeScope,
) -> anyhow::Result<()> {
let config = if let Some(path) = file {
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| anyhow::anyhow!("Failed to read config file: {e}"))?;
match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => serde_json::from_str(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse JSON config: {e}"))?,
_ => toml::from_str(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse TOML config: {e}"))?,
}
} else if let Some(payload) = json {
serde_json::from_str(&payload)
.map_err(|e| anyhow::anyhow!("Failed to parse JSON config: {e}"))?
} else if let Some(payload) = toml_payload {
toml::from_str(&payload).map_err(|e| anyhow::anyhow!("Failed to parse TOML config: {e}"))?
} else {
return Err(anyhow::anyhow!(
"Provide --file, --json, or --toml to set config"
));
};
let (store, base_dir) = resolve_config_store(scope).await?;
let runtime =
meerkat_core::ConfigRuntime::new(Arc::clone(&store), base_dir.join("config_state.json"));
let snapshot = runtime
.set(config, expected_generation)
.await
.map_err(|e| anyhow::anyhow!("Failed to persist config: {e}"))?;
println!("generation={}", snapshot.generation);
Ok(())
}
async fn handle_config_patch(
file: Option<PathBuf>,
json: Option<String>,
expected_generation: Option<u64>,
scope: &RuntimeScope,
) -> anyhow::Result<()> {
let patch_value: serde_json::Value = if let Some(path) = file {
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| anyhow::anyhow!("Failed to read patch file: {e}"))?;
serde_json::from_str(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse JSON patch: {e}"))?
} else if let Some(payload) = json {
serde_json::from_str(&payload)
.map_err(|e| anyhow::anyhow!("Failed to parse JSON patch: {e}"))?
} else {
return Err(anyhow::anyhow!("Provide --file or --json to patch config"));
};
let (store, base_dir) = resolve_config_store(scope).await?;
let runtime =
meerkat_core::ConfigRuntime::new(Arc::clone(&store), base_dir.join("config_state.json"));
let snapshot = runtime
.patch(ConfigDelta(patch_value), expected_generation)
.await
.map_err(|e| anyhow::anyhow!("Failed to patch config: {e}"))?;
println!("generation={}", snapshot.generation);
Ok(())
}
async fn handle_capabilities(scope: &RuntimeScope) -> anyhow::Result<()> {
let (config, _) = load_config(scope).await?;
let response = meerkat::surface::build_capabilities_response(&config);
println!(
"{}",
serde_json::to_string_pretty(&response).unwrap_or_else(|_| "{}".to_string())
);
Ok(())
}
async fn handle_models_catalog(scope: &RuntimeScope) -> anyhow::Result<()> {
let (config, _) = load_config(scope).await?;
let response = meerkat::surface::build_models_catalog_response(&config)
.map_err(|e| anyhow::anyhow!("failed to build model catalog: {e}"))?;
println!(
"{}",
serde_json::to_string_pretty(&response).unwrap_or_else(|_| "{}".to_string())
);
Ok(())
}
fn auth_config_realm_or_default(config_realm_override: Option<&str>) -> String {
config_realm_override.unwrap_or("dev").to_string()
}
async fn handle_auth_command(
command: AuthCommands,
scope: &RuntimeScope,
config_realm_override: Option<&str>,
) -> anyhow::Result<()> {
let (config, _) = load_config(scope).await?;
match command {
AuthCommands::Realms => {
if config.realm.is_empty() {
println!(
"No realms configured. Add a [realm.dev] section to your config \
or continue using env-var auth (ANTHROPIC_API_KEY etc.)."
);
return Ok(());
}
println!("REALM_ID DEFAULT_BINDING BACKENDS AUTH_PROFILES BINDINGS");
for (realm_id, section) in &config.realm {
println!(
"{:<18}{:<20}{:<10}{:<15}{}",
realm_id,
section.default_binding.as_deref().unwrap_or("-"),
section.backend.len(),
section.auth.len(),
section.binding.len(),
);
}
}
AuthCommands::Profiles => {
let realm = auth_config_realm_or_default(config_realm_override);
let section = config.realm.get(&realm).ok_or_else(|| {
anyhow::anyhow!("Unknown realm '{realm}' — check your config file")
})?;
let realm_set = meerkat_core::RealmConnectionSet::from_config(&realm, section)
.map_err(|e| anyhow::anyhow!("Realm config invalid: {e}"))?;
println!("Realm: {realm}");
println!(" Backends:");
for (id, backend) in &realm_set.backends {
println!(
" {}: provider={} backend_kind={} base_url={}",
id,
backend.provider.as_str(),
backend.backend_kind,
backend.base_url.as_deref().unwrap_or("(default)"),
);
}
println!(" Auth profiles:");
for (id, auth) in &realm_set.auth_profiles {
println!(
" {}: provider={} method={} source_kind={}",
id,
auth.provider.as_str(),
auth.auth_method,
source_kind_label(&auth.source),
);
}
println!(" Bindings:");
for (id, b) in &realm_set.bindings {
println!(
" {}: backend_profile={} auth_profile={} default_model={}",
id,
b.backend_profile,
b.auth_profile,
b.default_model.as_deref().unwrap_or("(inherit)"),
);
}
}
AuthCommands::Profile { profile_id } => {
let realm = auth_config_realm_or_default(config_realm_override);
let section = config
.realm
.get(&realm)
.ok_or_else(|| anyhow::anyhow!("Unknown realm '{realm}'"))?;
let realm_set = meerkat_core::RealmConnectionSet::from_config(&realm, section)
.map_err(|e| anyhow::anyhow!("Realm config invalid: {e}"))?;
match realm_set.auth_profiles.get(&profile_id) {
Some(profile) => {
println!(
"{}",
serde_json::to_string_pretty(profile)
.unwrap_or_else(|_| "<serialize error>".into())
);
}
None => {
anyhow::bail!(
"Auth profile '{realm}:{profile_id}' not found in realm '{realm}'",
);
}
}
}
AuthCommands::Test { binding_id } => {
use meerkat_providers::auth_store::{
InMemoryCoordinator, TokenStore, TokenStoreBackend,
};
let realm = auth_config_realm_or_default(config_realm_override);
let section = config
.realm
.get(&realm)
.ok_or_else(|| anyhow::anyhow!("Unknown realm '{realm}'"))?;
let realm_set = meerkat_core::RealmConnectionSet::from_config(&realm, section)
.map_err(|e| anyhow::anyhow!("Realm config invalid: {e}"))?;
let registry = cli_provider_registry();
let store: Arc<dyn TokenStore> = TokenStoreBackend::default_auto()
.map_err(|e| anyhow::anyhow!("Cannot open TokenStore: {e}"))?
.open()
.map_err(|e| anyhow::anyhow!("Cannot open TokenStore: {e}"))?;
let env = meerkat_providers::ResolverEnvironment::with_process_env()
.with_token_store(store)
.with_refresh_coordinator(Arc::new(InMemoryCoordinator::default()))
.with_auth_lease_handle(Arc::clone(&scope.auth_lease));
let auth_binding = meerkat_core::AuthBindingRef {
realm: meerkat_core::RealmId::parse(realm.clone())
.map_err(|e| anyhow::anyhow!("invalid realm id '{realm}': {e}"))?,
binding: meerkat_core::BindingId::parse(binding_id.clone())
.map_err(|e| anyhow::anyhow!("invalid binding id '{binding_id}': {e}"))?,
profile: None,
};
match registry.resolve(&realm_set, &auth_binding, &env).await {
Ok(conn) => {
println!("state: {}", AuthStatusPhase::Valid.as_public_str());
println!("provider: {}", conn.provider.as_str());
println!("backend_profile_id: {}", conn.backend_profile.id);
println!(
"has_credential: {}",
conn.resolved_secret().is_some() || conn.resolved_authorizer().is_some(),
);
}
Err(e) => {
println!("state: error");
println!("error: {e}");
return Err(anyhow::anyhow!("Binding resolution failed: {e}"));
}
}
}
AuthCommands::Status { profile_id } => {
let realm = auth_config_realm_or_default(config_realm_override);
let section = config
.realm
.get(&realm)
.ok_or_else(|| anyhow::anyhow!("Unknown realm '{realm}'"))?;
let realm_set = meerkat_core::RealmConnectionSet::from_config(&realm, section)
.map_err(|e| anyhow::anyhow!("Realm config invalid: {e}"))?;
let Some(profile) = realm_set.auth_profiles.get(&profile_id) else {
anyhow::bail!("Auth profile '{realm}:{profile_id}' not found in realm '{realm}'");
};
println!("profile_id: {}", profile.id);
println!("provider: {}", profile.provider.as_str());
println!("auth_method: {}", profile.auth_method);
println!("source_kind: {}", source_kind_label(&profile.source));
let binding_id = auth_status_binding_id(&realm, &profile_id, &realm_set)?;
println!("binding_id: {binding_id}");
let auth_binding = AuthBindingRef {
realm: meerkat_core::RealmId::parse(realm.clone())
.map_err(|e| anyhow::anyhow!("invalid realm id '{realm}': {e}"))?,
binding: meerkat_core::BindingId::parse(binding_id)
.map_err(|e| anyhow::anyhow!("invalid binding id '{binding_id}': {e}"))?,
profile: None,
};
let token_store: Option<Arc<dyn meerkat_providers::auth_store::TokenStore>> =
if meerkat_providers::auth_store::credential_source_uses_persisted_store(
&profile.source,
) {
meerkat_providers::auth_store::TokenStoreBackend::default_auto()
.and_then(|backend| backend.open())
.ok()
} else {
None
};
let projection = project_cli_auth_status(
scope.auth_lease.as_ref(),
token_store.as_deref(),
&auth_binding,
profile,
chrono::Utc::now(),
)
.await;
println!("state: {}", projection.phase.as_public_str());
if let Some(expires_at) = projection.expires_at {
println!("expires_at: {}", expires_at.to_rfc3339());
}
if projection.phase == AuthStatusPhase::Unknown {
println!("note: no live AuthMachine lease for '{realm}:{binding_id}'.");
}
}
AuthCommands::ProfileDelete { profile_id, yes } => {
let realm = auth_config_realm_or_default(config_realm_override);
let section = config
.realm
.get(&realm)
.ok_or_else(|| anyhow::anyhow!("Unknown realm '{realm}'"))?;
let realm_set = meerkat_core::RealmConnectionSet::from_config(&realm, section)
.map_err(|e| anyhow::anyhow!("Realm config invalid: {e}"))?;
if !realm_set.auth_profiles.contains_key(&profile_id) {
anyhow::bail!("Auth profile '{realm}:{profile_id}' not found in realm '{realm}'");
}
if !yes {
use std::io::{BufRead, Write};
eprint!("Delete persisted credentials for '{realm}:{profile_id}'? [y/N]: ");
std::io::stderr().flush().ok();
let stdin = std::io::stdin();
let line = stdin
.lock()
.lines()
.next()
.ok_or_else(|| anyhow::anyhow!("no confirmation on stdin"))??;
if !matches!(line.trim(), "y" | "Y" | "yes" | "YES") {
eprintln!("cancelled");
return Ok(());
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
{
use meerkat_providers::auth_store::{TokenKey, TokenStoreBackend};
let store = TokenStoreBackend::default_auto()
.map_err(|e| anyhow::anyhow!("Cannot open TokenStore: {e}"))?
.open()
.map_err(|e| anyhow::anyhow!("Cannot open TokenStore: {e}"))?;
let binding_id = auth_status_binding_id(&realm, &profile_id, &realm_set)?;
let key = TokenKey::parse(&realm, binding_id)
.map_err(|e| anyhow::anyhow!("invalid token-key realm/binding: {e}"))?;
let should_clear = match store.load(&key).await {
Ok(present) => present.is_some(),
Err(e) if token_store_load_error_allows_clear(&e) => true,
Err(e) => return Err(anyhow::anyhow!("TokenStore load failed: {e}")),
};
if should_clear {
let auth_binding = meerkat_core::AuthBindingRef {
realm: key.realm.clone(),
binding: key.binding.clone(),
profile: key.profile.clone(),
};
meerkat_core::clear_tokens_and_publish_lifecycle_released(
store.as_ref(),
scope.auth_lease.as_ref(),
&auth_binding,
)
.await
.map_err(|e| anyhow::anyhow!("Token lifecycle clear failed: {e}"))?;
println!("deleted: {}:{}", key.realm.as_str(), key.binding.as_str());
} else {
println!(
"nothing to delete: no persisted credential for '{realm}:{binding_id}'"
);
}
}
#[cfg(not(all(feature = "anthropic", feature = "openai", feature = "gemini")))]
{
anyhow::bail!(
"`rkat auth profile delete` requires the `anthropic`, `openai`, and `gemini` \
features to be enabled at build time."
);
}
}
AuthCommands::Bindings => {
let realm_filter = config_realm_override;
if config.realm.is_empty() {
println!(
"No realms configured. Add a [realm.<id>] section to your config or use the \
env-var auth fallback."
);
return Ok(());
}
println!(
"REALM BINDING BACKEND_PROFILE AUTH_PROFILE DEFAULT_MODEL"
);
for (realm_id, section) in &config.realm {
if let Some(filter) = realm_filter
&& filter != realm_id
{
continue;
}
let realm_set =
match meerkat_core::RealmConnectionSet::from_config(realm_id, section) {
Ok(set) => set,
Err(e) => {
println!("(realm '{realm_id}' config invalid: {e})");
continue;
}
};
for (binding_id, binding) in &realm_set.bindings {
println!(
"{:<19}{:<21}{:<21}{:<21}{}",
realm_id,
binding_id,
binding.backend_profile,
binding.auth_profile,
binding.default_model.as_deref().unwrap_or("(inherit)"),
);
}
}
}
AuthCommands::Login {
provider,
backend,
method,
non_interactive,
secret,
} => {
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
{
if non_interactive {
noninteractive_login(
provider.as_deref(),
backend.as_deref(),
method.as_deref(),
secret.as_deref(),
scope,
)
.await?;
} else {
let _ = (backend, method);
interactive_login(provider.as_deref(), scope).await?;
}
}
#[cfg(not(all(feature = "anthropic", feature = "openai", feature = "gemini")))]
{
let _ = (provider, backend, method, non_interactive, secret);
anyhow::bail!(
"`rkat auth login` requires the `anthropic`, `openai`, and `gemini` \
features to be enabled at build time."
);
}
}
AuthCommands::Logout { profile_id } => {
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
{
interactive_logout(&profile_id, scope).await?;
}
#[cfg(not(all(feature = "anthropic", feature = "openai", feature = "gemini")))]
{
let _ = profile_id;
anyhow::bail!(
"`rkat auth logout` requires the `anthropic`, `openai`, and `gemini` \
features to be enabled at build time."
);
}
}
AuthCommands::Refresh { profile_id } => {
let realm = auth_config_realm_or_default(config_realm_override);
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
{
refresh_auth_profile(&realm, &profile_id, &config, scope).await?;
}
#[cfg(not(all(feature = "anthropic", feature = "openai", feature = "gemini")))]
{
let _ = (realm, profile_id, &config);
anyhow::bail!(
"`rkat auth refresh` requires the `anthropic`, `openai`, and `gemini` \
features to be enabled at build time."
);
}
}
}
Ok(())
}
/// `rkat auth refresh <realm> <profile_id>` handler (deferral §6).
///
/// Forces a refresh of the persisted credential for the given auth
/// profile. For OAuth-backed methods this exchanges the persisted
/// refresh token for a fresh access token and writes the new bundle
/// back to the TokenStore. For `api_key` / `static_bearer` auth
/// methods this is a no-op with a descriptive message.
///
/// Implementation: locates a binding that references the auth profile,
/// runs the canonical `ProviderRuntimeRegistry::resolve` path (which
/// attaches the TokenStore + RefreshCoordinator so refresh side-effects
/// persist), then explicitly calls `lease.refresh(Manual)` to trigger
/// the refresh regardless of proactive-refresh heuristics. Dogma §1:
/// the registry is the canonical resolver — no helper-local refresh.
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
async fn refresh_auth_profile(
realm: &str,
profile_id: &str,
config: &meerkat_core::Config,
scope: &RuntimeScope,
) -> anyhow::Result<()> {
use meerkat_core::auth::AuthRefreshReason;
use meerkat_providers::ResolverEnvironment;
use meerkat_providers::auth_store::{
InMemoryCoordinator, RefreshCoordinator, TokenKey, TokenStore, TokenStoreBackend,
};
use std::sync::Arc as StdArc;
let section = config
.realm
.get(realm)
.ok_or_else(|| anyhow::anyhow!("Unknown realm '{realm}'"))?;
let realm_set = meerkat_core::RealmConnectionSet::from_config(realm, section)
.map_err(|e| anyhow::anyhow!("Realm config invalid: {e}"))?;
let profile = realm_set
.auth_profiles
.get(profile_id)
.ok_or_else(|| anyhow::anyhow!("Auth profile '{realm}:{profile_id}' not found"))?;
// No-op fast paths: refresh is meaningless for non-OAuth methods.
// Dogma §5: typed truth — we branch on auth_method, not folklore.
let is_refreshable = matches!(
profile.auth_method.as_str(),
"managed_chatgpt_oauth"
| "claude_ai_oauth"
| "oauth_to_api_key"
| "google_oauth"
| "code_assist_oauth"
);
if !is_refreshable {
println!(
"profile: {realm}:{profile_id}\n\
auth_method: {}\n\
refresh: no-op\n\
reason: auth_method is not OAuth-backed; credentials don't expire",
profile.auth_method,
);
return Ok(());
}
let binding_id = auth_status_binding_id(realm, profile_id, &realm_set)?.to_string();
// Wire the TokenStore + RefreshCoordinator into the environment so
// the refresh write-back reaches persistent storage.
let store: StdArc<dyn TokenStore> = TokenStoreBackend::default_auto()
.map_err(|e| anyhow::anyhow!("Cannot open TokenStore: {e}"))?
.open()
.map_err(|e| anyhow::anyhow!("Cannot open TokenStore: {e}"))?;
let coord: StdArc<dyn RefreshCoordinator> = StdArc::new(InMemoryCoordinator::default());
// Pre-state for the reported diff.
let key = TokenKey::parse(realm, &binding_id)
.map_err(|e| anyhow::anyhow!("invalid token-key realm/binding: {e}"))?;
let before = store
.load(&key)
.await
.map_err(|e| anyhow::anyhow!("TokenStore load failed: {e}"))?;
if before.is_none() {
println!(
"profile: {realm}:{profile_id}\n\
binding: {realm}:{binding_id}\n\
refresh: skipped\n\
reason: no persisted credential; run `rkat auth login {}` first",
profile.provider.as_str(),
);
return Ok(());
}
let env = ResolverEnvironment::with_process_env()
.with_token_store(store.clone())
.with_refresh_coordinator(coord)
.with_auth_lease_handle(Arc::clone(&scope.auth_lease))
.with_force_refresh(true);
let registry = cli_provider_registry();
let auth_binding = meerkat_core::AuthBindingRef {
realm: meerkat_core::RealmId::parse(realm)
.map_err(|e| anyhow::anyhow!("invalid realm id '{realm}': {e}"))?,
binding: meerkat_core::BindingId::parse(binding_id.clone())
.map_err(|e| anyhow::anyhow!("invalid binding id '{binding_id}': {e}"))?,
profile: None,
};
let connection = registry
.resolve(&realm_set, &auth_binding, &env)
.await
.map_err(|e| anyhow::anyhow!("Binding resolution failed: {e}"))?;
connection
.auth_lease
.refresh(AuthRefreshReason::Manual)
.await
.map_err(|e| anyhow::anyhow!("Refresh failed: {e}"))?;
// Post-state after refresh is observable via the TokenStore (the
// refresh path writes back there).
let after = store
.load(&key)
.await
.map_err(|e| anyhow::anyhow!("TokenStore reload failed: {e}"))?;
let Some(after_tokens) = after.as_ref() else {
anyhow::bail!("Refresh completed but TokenStore no longer has '{realm}:{binding_id}'");
};
let lease_key = meerkat_core::handles::LeaseKey::from_auth_binding(&auth_binding);
let snapshot = scope.auth_lease.snapshot(&lease_key);
if !snapshot.credential_present {
let transition = meerkat_core::publish_token_lifecycle_acquired(
scope.auth_lease.as_ref(),
&auth_binding,
after_tokens,
)
.map_err(|e| anyhow::anyhow!("AuthMachine lifecycle acquire failed: {e}"))?;
let committed =
meerkat_core::mark_tokens_lifecycle_published_for_transition(after_tokens, transition);
if committed != *after_tokens {
store
.save(&key, &committed)
.await
.map_err(|e| anyhow::anyhow!("TokenStore lifecycle marker save failed: {e}"))?;
}
}
println!("profile: {realm}:{profile_id}");
println!("binding: {realm}:{binding_id}");
println!("auth_method: {}", profile.auth_method);
println!("refresh: ok");
if let Some(before) = before.as_ref()
&& let Some(expires_at) = before.expires_at
{
println!("expires_at(before): {}", expires_at.to_rfc3339());
}
if let Some(after) = after.as_ref() {
if let Some(expires_at) = after.expires_at {
println!("expires_at(after): {}", expires_at.to_rfc3339());
}
if let Some(last_refresh) = after.last_refresh {
println!("last_refresh: {}", last_refresh.to_rfc3339());
}
}
Ok(())
}
// ---------------------------------------------------------------------
// Interactive OAuth login — pedagogical UX.
// ---------------------------------------------------------------------
//
// Design goals (per user feedback — "first thing users encounter, has to
// be pedagogical and easy to use"):
//
// 1. Each step is announced BEFORE it runs with a short rationale so
// users know what's happening and why.
// 2. Progress is numbered (Step 1/4, 2/4, ...) so users know how many
// steps remain.
// 3. Colors + unicode glyphs when TTY; plain text otherwise. Honors
// NO_COLOR.
// 4. Pre-flight: warn when the provider's env var is already set, so
// users understand the env-var path wins unless they run with
// `--auth-binding`.
// 5. Provider selection: if no provider argument, present an
// interactive menu with one-line descriptions of each option.
// 6. Specific, actionable error messages (user-denied, timeout, CSRF,
// browser-launch-fail, token-exchange-fail) — each includes a
// clear recovery hint.
// 7. Post-success: TokenStore location, expiry (human-delta), refresh
// status, and concrete copy-paste commands for next steps.
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LoginProvider {
Anthropic,
OpenAi,
Google,
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
impl LoginProvider {
fn parse(raw: &str) -> Option<Self> {
match raw.to_ascii_lowercase().trim() {
"anthropic" | "claude" | "claude.ai" => Some(Self::Anthropic),
"openai" | "chatgpt" => Some(Self::OpenAi),
"google" | "gemini" | "code_assist" | "code-assist" => Some(Self::Google),
_ => None,
}
}
fn display_name(self) -> &'static str {
match self {
Self::Anthropic => "Anthropic (Claude.ai)",
Self::OpenAi => "OpenAI (ChatGPT)",
Self::Google => "Google (Gemini Code Assist)",
}
}
fn one_line(self) -> &'static str {
match self {
Self::Anthropic => "Sign in with your Claude Pro / Max subscription",
Self::OpenAi => "Sign in with your ChatGPT Plus / Pro account",
Self::Google => "Sign in with your Google account (Gemini Code Assist)",
}
}
fn env_var(self) -> &'static str {
match self {
Self::Anthropic => "ANTHROPIC_API_KEY",
Self::OpenAi => "OPENAI_API_KEY",
Self::Google => "GEMINI_API_KEY",
}
}
fn binding_id(self) -> &'static str {
match self {
Self::Anthropic => "anthropic_oauth",
Self::OpenAi => "openai_oauth",
Self::Google => "google_oauth",
}
}
fn config_provider(self) -> &'static str {
match self {
Self::Anthropic => "anthropic",
Self::OpenAi => "openai",
Self::Google => "gemini",
}
}
fn backend_profile_id(self) -> &'static str {
match self {
Self::Anthropic => "anthropic_api",
Self::OpenAi => "openai_chatgpt",
Self::Google => "google_code_assist",
}
}
fn backend_kind(self) -> &'static str {
match self {
Self::Anthropic => "anthropic_api",
Self::OpenAi => "chatgpt_backend",
Self::Google => "google_code_assist",
}
}
fn backend_base_url(self) -> Option<&'static str> {
match self {
Self::Anthropic => None,
Self::OpenAi => Some(
meerkat_core::provider_matrix::openai::OpenAiBackendKind::ChatGptBackend
.default_base_url(),
),
Self::Google => Some(
meerkat_core::provider_matrix::google::GoogleBackendKind::GoogleCodeAssist
.default_base_url(),
),
}
}
fn oauth_auth_method(self) -> &'static str {
match self {
Self::Anthropic => "claude_ai_oauth",
Self::OpenAi => "managed_chatgpt_oauth",
Self::Google => "google_oauth",
}
}
fn oauth_alias(self) -> &'static str {
match self {
Self::Anthropic => "anthropic",
Self::OpenAi => "openai",
Self::Google => "google",
}
}
fn oauth_identity(self) -> meerkat_providers::oauth_flow::OAuthProviderIdentity {
match self {
Self::Anthropic => {
meerkat_providers::oauth_flow::OAuthProviderIdentity::AnthropicClaudeAi
}
Self::OpenAi => meerkat_providers::oauth_flow::OAuthProviderIdentity::OpenAiChatGpt,
Self::Google => meerkat_providers::oauth_flow::OAuthProviderIdentity::GoogleCodeAssist,
}
}
fn callback_path(self) -> &'static str {
match self {
Self::OpenAi => "/auth/callback",
Self::Anthropic | Self::Google => "/callback",
}
}
fn callback_redirect_host(self) -> &'static str {
match self {
Self::Anthropic | Self::OpenAi => "localhost",
Self::Google => "127.0.0.1",
}
}
fn callback_ports(self) -> &'static [u16] {
match self {
Self::OpenAi => &[1455, 1457],
Self::Anthropic | Self::Google => &[0],
}
}
fn sample_model(self) -> &'static str {
meerkat_core::model_profile::catalog::default_model(self.config_provider())
.expect("login provider must have a catalog default model")
}
fn legacy_sample_models(self) -> &'static [&'static str] {
match self {
Self::Anthropic => &["claude-sonnet-4-6", "claude-opus-4-6"],
Self::OpenAi => &["gpt-5.4"],
Self::Google => &["gemini-3.1-flash-lite"],
}
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
const ALL_LOGIN_PROVIDERS: &[LoginProvider] = &[
LoginProvider::Anthropic,
LoginProvider::OpenAi,
LoginProvider::Google,
];
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
const CLI_INTERACTIVE_OAUTH_REALM_ID: &str = "dev";
fn auth_supports_ansi() -> bool {
use std::io::IsTerminal;
std::io::stderr().is_terminal() && std::env::var("NO_COLOR").is_err()
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn auth_bold(s: &str) -> String {
if auth_supports_ansi() {
format!("\x1b[1m{s}\x1b[0m")
} else {
s.to_string()
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn auth_dim(s: &str) -> String {
if auth_supports_ansi() {
format!("\x1b[2m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn auth_green(s: &str) -> String {
if auth_supports_ansi() {
format!("\x1b[32m{s}\x1b[0m")
} else {
s.to_string()
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn auth_yellow(s: &str) -> String {
if auth_supports_ansi() {
format!("\x1b[33m{s}\x1b[0m")
} else {
s.to_string()
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn auth_cyan(s: &str) -> String {
if auth_supports_ansi() {
format!("\x1b[36m{s}\x1b[0m")
} else {
s.to_string()
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn print_step(num: u8, total: u8, text: &str) {
eprintln!(
"\n{} {}",
auth_dim(&format!("Step {num}/{total}")),
auth_bold(text),
);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn print_ok(text: &str) {
eprintln!(" {} {}", auth_green("✓"), text);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn print_warn(text: &str) {
eprintln!(" {} {}", auth_yellow("!"), text);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn print_hint(text: &str) {
eprintln!(" {} {}", auth_dim("hint"), auth_dim(text));
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn prompt_line(label: &str) -> anyhow::Result<String> {
use std::io::Write;
let mut out = std::io::stderr();
write!(out, "{label}")?;
out.flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
Ok(line.trim().to_string())
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn resolve_login_provider(hint: Option<&str>) -> anyhow::Result<LoginProvider> {
if let Some(raw) = hint {
return LoginProvider::parse(raw).ok_or_else(|| {
anyhow::anyhow!("Unknown provider '{raw}'. Supported: anthropic, openai, google.")
});
}
eprintln!();
eprintln!(
"{}",
auth_bold("Which provider do you want to sign in with?")
);
eprintln!();
for (idx, p) in ALL_LOGIN_PROVIDERS.iter().enumerate() {
eprintln!(
" {}) {:<32} {}",
idx + 1,
auth_bold(p.display_name()),
auth_dim(p.one_line()),
);
}
eprintln!();
let answer = prompt_line(&format!(
"Choose {} [1-{}] (default: 1): ",
auth_dim("a number"),
ALL_LOGIN_PROVIDERS.len(),
))?;
let idx = if answer.is_empty() {
0
} else {
answer
.parse::<usize>()
.map_err(|_| anyhow::anyhow!("Invalid selection '{answer}' — please enter a number"))?
.checked_sub(1)
.ok_or_else(|| anyhow::anyhow!("Selection must be 1 or greater"))?
};
ALL_LOGIN_PROVIDERS.get(idx).copied().ok_or_else(|| {
anyhow::anyhow!("Selection out of range (1..={})", ALL_LOGIN_PROVIDERS.len(),)
})
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[derive(Debug)]
struct CliOAuthLoginTarget {
auth_binding: AuthBindingRef,
auth_profile: meerkat_core::AuthProfile,
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn ensure_cli_interactive_oauth_config(provider: LoginProvider, config: &mut Config) -> bool {
let realm_id = CLI_INTERACTIVE_OAUTH_REALM_ID;
let binding_id = provider.binding_id();
let backend_profile_id = provider.backend_profile_id();
let auth_profile_id = binding_id;
let section = config.realm.entry(realm_id.to_string()).or_default();
let mut changed = false;
if let Some(backend) = section.backend.get_mut(backend_profile_id) {
if backend.provider == provider.config_provider()
&& backend.backend_kind == provider.backend_kind()
&& let Some(base_url) = provider.backend_base_url()
{
let should_heal_base_url = backend.base_url.as_deref().is_none_or(str::is_empty)
|| (provider == LoginProvider::OpenAi
&& backend
.base_url
.as_deref()
.map(|url| url.trim_end_matches('/') == "https://chatgpt.com/backend-api")
.unwrap_or(false));
if should_heal_base_url {
backend.base_url = Some(base_url.to_string());
changed = true;
}
}
} else {
section.backend.insert(
backend_profile_id.to_string(),
meerkat_core::BackendProfileConfig {
provider: provider.config_provider().to_string(),
backend_kind: provider.backend_kind().to_string(),
base_url: provider.backend_base_url().map(str::to_string),
options: serde_json::Value::Null,
},
);
changed = true;
}
if !section.auth.contains_key(auth_profile_id) {
section.auth.insert(
auth_profile_id.to_string(),
meerkat_core::AuthProfileConfig {
provider: provider.config_provider().to_string(),
auth_method: provider.oauth_auth_method().to_string(),
source: meerkat_core::CredentialSourceSpec::ManagedStore,
constraints: meerkat_core::AuthConstraints {
allow_interactive_login: true,
..Default::default()
},
metadata_defaults: meerkat_core::AuthMetadataDefaults::default(),
},
);
changed = true;
}
if !section.binding.contains_key(binding_id) {
section.binding.insert(
binding_id.to_string(),
meerkat_core::ProviderBindingConfig {
backend_profile: backend_profile_id.to_string(),
auth_profile: auth_profile_id.to_string(),
default_model: Some(provider.sample_model().to_string()),
policy: meerkat_core::BindingPolicy::default(),
},
);
changed = true;
} else if let Some(binding) = section.binding.get_mut(binding_id)
&& binding.backend_profile == backend_profile_id
&& binding.auth_profile == auth_profile_id
&& binding
.default_model
.as_deref()
.is_some_and(|model| provider.legacy_sample_models().contains(&model))
{
binding.default_model = Some(provider.sample_model().to_string());
changed = true;
}
if section.default_binding.is_none() {
section.default_binding = Some(binding_id.to_string());
changed = true;
}
changed
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn resolve_configured_cli_interactive_oauth_target(
provider: LoginProvider,
config: &Config,
) -> anyhow::Result<CliOAuthLoginTarget> {
let realm_id = CLI_INTERACTIVE_OAUTH_REALM_ID;
let binding_id = provider.binding_id();
let section = config.realm.get(realm_id).ok_or_else(|| {
anyhow::anyhow!(
"OAuth login target '{realm_id}:{binding_id}' is not configured; \
add a [realm.{realm_id}] binding for {} OAuth before running interactive login",
provider.display_name(),
)
})?;
let realm_set = meerkat_core::RealmConnectionSet::from_config(realm_id, section)
.map_err(|e| anyhow::anyhow!("Realm config invalid for '{realm_id}': {e}"))?;
let auth_binding = AuthBindingRef {
realm: meerkat_core::RealmId::parse(realm_id)
.map_err(|e| anyhow::anyhow!("invalid realm id '{realm_id}': {e}"))?,
binding: meerkat_core::BindingId::parse(binding_id)
.map_err(|e| anyhow::anyhow!("invalid binding id '{binding_id}': {e}"))?,
profile: None,
};
let (_, backend_profile, auth_profile) =
realm_set.lookup_auth_binding(&auth_binding).map_err(|e| {
anyhow::anyhow!("OAuth login target '{realm_id}:{binding_id}' invalid: {e}")
})?;
meerkat_providers::oauth_flow::validate_oauth_login_binding(
backend_profile,
auth_profile,
provider.oauth_identity(),
)
.map_err(|e| {
anyhow::anyhow!(
"OAuth login target '{realm_id}:{binding_id}' cannot accept {} OAuth credentials: {e}",
provider.display_name(),
)
})?;
Ok(CliOAuthLoginTarget {
auth_binding,
auth_profile: auth_profile.clone(),
})
}
#[cfg(all(test, feature = "anthropic", feature = "openai", feature = "gemini"))]
fn resolve_cli_interactive_oauth_target(
provider: LoginProvider,
config: &Config,
) -> anyhow::Result<CliOAuthLoginTarget> {
match resolve_configured_cli_interactive_oauth_target(provider, config) {
Ok(target) => Ok(target),
Err(err) => {
let binding_missing = config
.realm
.get(CLI_INTERACTIVE_OAUTH_REALM_ID)
.is_none_or(|section| !section.binding.contains_key(provider.binding_id()));
if !binding_missing {
return Err(err);
}
let mut synthesized = config.clone();
ensure_cli_interactive_oauth_config(provider, &mut synthesized);
resolve_configured_cli_interactive_oauth_target(provider, &synthesized)
}
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn auth_binding_from_token_key(key: &meerkat_providers::auth_store::TokenKey) -> AuthBindingRef {
AuthBindingRef {
realm: key.realm.clone(),
binding: key.binding.clone(),
profile: key.profile.clone(),
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
struct CliPreparedTokenCommitSnapshot {
key: meerkat_providers::auth_store::TokenKey,
lease_key: meerkat_core::handles::LeaseKey,
previous: Option<meerkat_providers::auth_store::PersistedTokens>,
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
struct CliTokenCommitSnapshot {
key: meerkat_providers::auth_store::TokenKey,
lease_key: meerkat_core::handles::LeaseKey,
previous: Option<meerkat_providers::auth_store::PersistedTokens>,
previous_lifecycle: meerkat_core::handles::AuthLeaseSnapshot,
lifecycle_transition: meerkat_core::handles::AuthLeaseTransition,
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
async fn prepare_cli_token_commit_unlocked(
store: &dyn meerkat_providers::auth_store::TokenStore,
auth_binding: &AuthBindingRef,
) -> anyhow::Result<CliPreparedTokenCommitSnapshot> {
let key = meerkat_providers::auth_store::TokenKey::from_auth_binding(auth_binding);
let previous = store
.load(&key)
.await
.map_err(|e| anyhow::anyhow!("TokenStore load failed: {e}"))?;
Ok(CliPreparedTokenCommitSnapshot {
key,
lease_key: meerkat_core::handles::LeaseKey::from_auth_binding(auth_binding),
previous,
})
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
async fn save_cli_tokens_and_publish_lifecycle_commit_unlocked(
store: &dyn meerkat_providers::auth_store::TokenStore,
auth_lease: &dyn meerkat_core::handles::AuthLeaseHandle,
auth_binding: &AuthBindingRef,
tokens: &meerkat_providers::auth_store::PersistedTokens,
mark_for_rehydration: bool,
) -> anyhow::Result<CliTokenCommitSnapshot> {
let key = meerkat_providers::auth_store::TokenKey::from_auth_binding(auth_binding);
let lease_key = meerkat_core::handles::LeaseKey::from_auth_binding(auth_binding);
let previous_lifecycle = auth_lease.snapshot(&lease_key);
let previous = store
.load(&key)
.await
.map_err(|e| anyhow::anyhow!("TokenStore load failed: {e}"))?;
store
.save(&key, tokens)
.await
.map_err(|e| anyhow::anyhow!("Failed to persist tokens: {e}"))?;
let transition = match meerkat_core::publish_token_lifecycle_acquired(
auth_lease,
auth_binding,
tokens,
) {
Ok(transition) => transition,
Err(e) => {
if let Err(rollback_error) =
restore_cli_tokens_after_lifecycle_failure(store, &key, previous.as_ref()).await
{
anyhow::bail!(
"AuthMachine lifecycle acquire failed: {e}; TokenStore rollback failed: {rollback_error}"
);
}
anyhow::bail!("AuthMachine lifecycle acquire failed: {e}");
}
};
let commit = CliTokenCommitSnapshot {
key,
lease_key,
previous,
previous_lifecycle,
lifecycle_transition: transition,
};
if mark_for_rehydration {
mark_cli_token_commit_lifecycle_published_unlocked(store, auth_lease, &commit, tokens)
.await?;
}
Ok(commit)
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
async fn mark_cli_token_commit_lifecycle_published_unlocked(
store: &dyn meerkat_providers::auth_store::TokenStore,
auth_lease: &dyn meerkat_core::handles::AuthLeaseHandle,
commit: &CliTokenCommitSnapshot,
tokens: &meerkat_providers::auth_store::PersistedTokens,
) -> anyhow::Result<()> {
let current_lifecycle = auth_lease.snapshot(&commit.lease_key);
let committed_tokens = if current_lifecycle.credential_present {
meerkat_core::mark_tokens_lifecycle_published_for_snapshot(tokens, ¤t_lifecycle)
} else {
meerkat_core::mark_tokens_lifecycle_published_for_transition(
tokens,
commit.lifecycle_transition,
)
};
if let Err(e) = store.save(&commit.key, &committed_tokens).await {
match rollback_cli_token_commit(store, auth_lease, commit).await {
Ok(()) => anyhow::bail!(
"TokenStore lifecycle marker save failed: {e}; token commit rolled back"
),
Err(rollback_error) => anyhow::bail!(
"TokenStore lifecycle marker save failed: {e}; token commit rollback failed: {rollback_error}"
),
}
}
Ok(())
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
async fn save_prepared_cli_tokens_after_terminal_consume_unlocked(
store: &dyn meerkat_providers::auth_store::TokenStore,
auth_lease: &dyn meerkat_core::handles::AuthLeaseHandle,
auth_binding: &AuthBindingRef,
tokens: &meerkat_providers::auth_store::PersistedTokens,
prepared: CliPreparedTokenCommitSnapshot,
) -> anyhow::Result<()> {
let previous_lifecycle = auth_lease.snapshot(&prepared.lease_key);
let transition =
match meerkat_core::publish_token_lifecycle_acquired(auth_lease, auth_binding, tokens) {
Ok(transition) => transition,
Err(e) => {
anyhow::bail!("AuthMachine lifecycle acquire failed after OAuth consume: {e}")
}
};
let committed_tokens =
meerkat_core::mark_tokens_lifecycle_published_for_transition(tokens, transition);
let commit = CliTokenCommitSnapshot {
key: prepared.key,
lease_key: prepared.lease_key,
previous: prepared.previous,
previous_lifecycle,
lifecycle_transition: transition,
};
if let Err(e) = store.save(&commit.key, &committed_tokens).await {
match rollback_cli_token_commit(store, auth_lease, &commit).await {
Ok(()) => anyhow::bail!(
"TokenStore save failed after OAuth consume: {e}; AuthMachine lifecycle rolled back"
),
Err(rollback_error) => anyhow::bail!(
"TokenStore save failed after OAuth consume: {e}; AuthMachine lifecycle rollback failed: {rollback_error}"
),
}
}
Ok(())
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
async fn save_cli_tokens_and_publish_lifecycle(
store: &dyn meerkat_providers::auth_store::TokenStore,
auth_lease: &dyn meerkat_core::handles::AuthLeaseHandle,
auth_binding: &AuthBindingRef,
tokens: &meerkat_providers::auth_store::PersistedTokens,
) -> anyhow::Result<()> {
let lease_key = meerkat_core::handles::LeaseKey::from_auth_binding(auth_binding);
let _guard = meerkat_core::acquire_auth_login_lifecycle_guard(&lease_key).await;
save_cli_tokens_and_publish_lifecycle_commit_unlocked(
store,
auth_lease,
auth_binding,
tokens,
true,
)
.await?;
Ok(())
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
async fn restore_cli_tokens_after_lifecycle_failure(
store: &dyn meerkat_providers::auth_store::TokenStore,
key: &meerkat_providers::auth_store::TokenKey,
previous: Option<&meerkat_providers::auth_store::PersistedTokens>,
) -> Result<(), meerkat_providers::auth_store::TokenStoreError> {
match previous {
Some(tokens) => store.save(key, tokens).await,
None => store.clear(key).await,
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
async fn rollback_cli_token_commit(
store: &dyn meerkat_providers::auth_store::TokenStore,
auth_lease: &dyn meerkat_core::handles::AuthLeaseHandle,
commit: &CliTokenCommitSnapshot,
) -> Result<(), String> {
match &commit.previous {
Some(previous) => match commit.previous_lifecycle.phase {
Some(phase) if phase != meerkat_core::handles::AuthLeasePhase::Released => {
auth_lease
.release_credential_lifecycle(&commit.lease_key)
.map_err(|e| format!("AuthMachine lifecycle rollback release failed: {e}"))?;
store
.save(&commit.key, previous)
.await
.map_err(|e| format!("TokenStore rollback save failed: {e}"))?;
meerkat_core::restore_token_lifecycle_snapshot(
auth_lease,
&commit.lease_key,
&commit.previous_lifecycle,
Some(previous),
)
.map_err(|e| format!("AuthMachine lifecycle rollback failed: {e}"))?;
let restored_snapshot = auth_lease.snapshot(&commit.lease_key);
if restored_snapshot.credential_present {
let restored_previous =
meerkat_core::mark_tokens_lifecycle_published_for_snapshot(
previous,
&restored_snapshot,
);
store
.save(&commit.key, &restored_previous)
.await
.map_err(|e| format!("TokenStore rollback marker save failed: {e}"))?;
}
}
_ => {
auth_lease
.release_credential_lifecycle(&commit.lease_key)
.map_err(|e| format!("AuthMachine lifecycle rollback release failed: {e}"))?;
store
.save(&commit.key, previous)
.await
.map_err(|e| format!("TokenStore rollback save failed: {e}"))?;
}
},
None => {
auth_lease
.release_credential_lifecycle(&commit.lease_key)
.map_err(|e| format!("AuthMachine lifecycle rollback release failed: {e}"))?;
store
.clear(&commit.key)
.await
.map_err(|e| format!("TokenStore rollback clear failed: {e}"))?;
}
}
Ok(())
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
struct CliBrowserFlowConsume<'a> {
authority: &'a dyn meerkat_providers::oauth_flow::OAuthFlowAuthority,
state: &'a str,
provider: meerkat_providers::oauth_flow::OAuthProviderIdentity,
redirect_uri: &'a str,
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
async fn save_cli_oauth_tokens_and_consume_browser_flow(
store: &dyn meerkat_providers::auth_store::TokenStore,
auth_lease: &dyn meerkat_core::handles::AuthLeaseHandle,
auth_binding: &AuthBindingRef,
tokens: &meerkat_providers::auth_store::PersistedTokens,
flow: CliBrowserFlowConsume<'_>,
) -> anyhow::Result<()> {
flow.authority
.verify(flow.state, auth_binding, flow.provider, flow.redirect_uri)
.map_err(|e| anyhow::anyhow!("oauth state verification failed: {e}"))?;
let lease_key = meerkat_core::handles::LeaseKey::from_auth_binding(auth_binding);
let _guard = meerkat_core::acquire_auth_login_lifecycle_guard(&lease_key).await;
let prepared = prepare_cli_token_commit_unlocked(store, auth_binding).await?;
flow.authority
.consume(flow.state, auth_binding, flow.provider, flow.redirect_uri)
.map_err(|err| anyhow::anyhow!("oauth state terminal consume failed: {err}"))?;
save_prepared_cli_tokens_after_terminal_consume_unlocked(
store,
auth_lease,
auth_binding,
tokens,
prepared,
)
.await?;
Ok(())
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
/// Plan §4d.cli.1: non-interactive login path. Resolves the secret
/// (from `--secret` or stdin), validates against the requested
/// (backend, method) shape, and writes an api_key-style entry into
/// the TokenStore. Intended for CI / scripted provisioning where an
/// OAuth flow can't run.
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
async fn noninteractive_login(
provider_hint: Option<&str>,
backend_hint: Option<&str>,
method_hint: Option<&str>,
secret: Option<&str>,
scope: &RuntimeScope,
) -> anyhow::Result<()> {
use meerkat_providers::auth_store::{
PersistedAuthMode, PersistedTokens, TokenKey, TokenStoreBackend,
};
let provider = provider_hint
.ok_or_else(|| anyhow::anyhow!("--non-interactive requires a positional <provider> arg"))?;
let provider_lc = provider.to_lowercase();
if !matches!(provider_lc.as_str(), "anthropic" | "openai" | "gemini") {
anyhow::bail!("unknown provider '{provider}' — expected anthropic / openai / gemini");
}
let method = method_hint.unwrap_or("api_key");
if method != "api_key" && method != "azure_api_key" && method != "static_bearer" {
anyhow::bail!(
"--non-interactive login supports only --method api_key|azure_api_key|static_bearer; \
OAuth-backed methods (managed_chatgpt_oauth, claude_ai_oauth, google_oauth, \
oauth_to_api_key) require the interactive browser flow"
);
}
let backend =
backend_hint
.map(ToString::to_string)
.unwrap_or_else(|| match provider_lc.as_str() {
"anthropic" => "anthropic_api".to_string(),
"openai" => "openai_api".to_string(),
_ => "google_genai".to_string(),
});
let secret_value = match secret {
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => {
use std::io::BufRead;
eprintln!("Secret for {provider}/{backend}/{method} (reading from stdin):");
let stdin = std::io::stdin();
let line = stdin
.lock()
.lines()
.next()
.ok_or_else(|| anyhow::anyhow!("no secret on stdin"))??;
if line.trim().is_empty() {
anyhow::bail!("empty secret");
}
line.trim().to_string()
}
};
let store = TokenStoreBackend::default_auto()?.open()?;
let binding_id_str = format!("default_{provider_lc}");
let key = TokenKey::parse("dev", &binding_id_str)
.map_err(|e| anyhow::anyhow!("invalid token-key realm/binding: {e}"))?;
let auth_mode = if method == "static_bearer" {
PersistedAuthMode::StaticBearer
} else {
PersistedAuthMode::ApiKey
};
let persisted = PersistedTokens {
auth_mode,
primary_secret: Some(secret_value),
refresh_token: None,
id_token: None,
expires_at: None,
last_refresh: Some(chrono::Utc::now()),
scopes: vec![],
account_id: None,
metadata: serde_json::json!({
"provider": provider_lc,
"backend_kind": backend,
"auth_method": method,
"source": "rkat auth login --non-interactive",
}),
};
let auth_binding = auth_binding_from_token_key(&key);
save_cli_tokens_and_publish_lifecycle(
store.as_ref(),
scope.auth_lease.as_ref(),
&auth_binding,
&persisted,
)
.await?;
println!("ok: wrote api_key for {provider_lc} into TokenStore under dev:default_{provider_lc}");
Ok(())
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
async fn interactive_login(
provider_hint: Option<&str>,
scope: &RuntimeScope,
) -> anyhow::Result<()> {
use std::sync::Arc as StdArc;
use std::time::Duration;
use meerkat_providers::auth_oauth::{
OAuthError, PkcePair, bind_loopback_callback_with_redirect,
};
use meerkat_providers::auth_store::{PersistedTokens, TokenKey, TokenStore, TokenStoreBackend};
// --- Provider selection (interactive if none passed) -----------
let provider = resolve_login_provider(provider_hint)?;
let (config_store, _) = resolve_config_store(scope).await?;
let mut config = config_store
.get()
.await
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
config
.apply_env_overrides()
.map_err(|e| anyhow::anyhow!("Failed to apply env overrides: {e}"))?;
let config_changed = ensure_cli_interactive_oauth_config(provider, &mut config);
let target = resolve_configured_cli_interactive_oauth_target(provider, &config)?;
if config_changed {
config_store
.set(config.clone())
.await
.map_err(|e| anyhow::anyhow!("Failed to persist OAuth login target config: {e}"))?;
}
tracing::debug!(
realm = %target.auth_binding.realm.as_str(),
binding = %target.auth_binding.binding.as_str(),
auth_profile = %target.auth_profile.id,
auth_method = %target.auth_profile.auth_method,
config_provisioned = config_changed,
"validated CLI OAuth login target"
);
let auth_binding = target.auth_binding;
let identity = provider.oauth_identity();
let key = TokenKey::from_auth_binding(&auth_binding);
let cli_cmd = current_cli_command_name();
eprintln!();
eprintln!(
"{}",
auth_bold(&format!("Signing in to {}", provider.display_name())),
);
eprintln!("{}", auth_dim(provider.one_line()));
// --- Pre-flight: env-var conflict warning ----------------------
if std::env::var(provider.env_var())
.ok()
.filter(|v| !v.is_empty())
.is_some()
{
eprintln!();
print_warn(&format!(
"{} is set in your environment.",
provider.env_var(),
));
print_hint(&format!(
"The env-var auth path will continue to handle `{cli_cmd} run` without"
));
print_hint(&format!(
"`--auth-binding`. OAuth tokens are used when you invoke `{cli_cmd}` with"
));
print_hint(&format!("`--auth-binding dev:{}`.", provider.binding_id(),));
}
// --- Step 1: bind loopback callback ---------------------------
print_step(
1,
4,
"Preparing a local callback to receive the authorization code",
);
let pkce = PkcePair::generate_s256();
let pending_callback = bind_loopback_callback_with_redirect(
provider.callback_path(),
provider.callback_redirect_host(),
provider.callback_ports(),
)
.await
.map_err(|e| anyhow::anyhow!("failed to bind loopback callback: {e}"))?;
let redirect_url = pending_callback.redirect_url.clone();
let resolved = meerkat_providers::oauth_flow::resolve_oauth_provider(
provider.oauth_alias(),
&redirect_url,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
debug_assert_eq!(resolved.identity, identity);
let state_token = scope
.oauth_flow_authority
.start(
auth_binding.clone(),
identity,
redirect_url.clone(),
pkce.verifier.secret().clone(),
)
.map_err(|e| anyhow::anyhow!("OAuth flow admission failed: {e}"))?;
let handle = pending_callback.expect_state(state_token.clone());
print_ok(&format!(
"Local callback ready at {}",
auth_cyan(&redirect_url),
));
let endpoints = resolved.endpoints;
let client_secret = resolved.client_secret;
let auth_mode = resolved.auth_mode;
// --- Step 2: open browser --------------------------------------
print_step(2, 4, "Opening your browser to the provider's sign-in page");
let authorize_url = endpoints.authorize_url_with_pkce(&pkce.challenge, &state_token);
let browser_ok = webbrowser::open(&authorize_url).is_ok();
if browser_ok {
print_ok("Browser launched. Complete the sign-in there.");
} else {
print_warn("Could not open your browser automatically.");
eprintln!();
eprintln!(" Copy this URL into a browser manually:");
eprintln!();
eprintln!(" {}", auth_cyan(&authorize_url));
eprintln!();
}
print_hint("If you want to cancel, press Ctrl-C — nothing is saved until step 4.");
// --- Step 3: wait for callback --------------------------------
print_step(
3,
4,
"Waiting for you to finish the sign-in (timeout: 2 minutes)",
);
let outcome = match handle.wait(Duration::from_secs(120)).await {
Ok(o) => o,
Err(OAuthError::Timeout) => {
eprintln!();
eprintln!(
"{} Timed out after 2 minutes waiting for the callback.",
auth_yellow("⚠"),
);
print_hint("Re-run `rkat auth login` and complete the flow in your browser.");
anyhow::bail!("OAuth timeout");
}
Err(OAuthError::UserDenied) => {
eprintln!();
eprintln!(
"{} You denied authorization — nothing was saved.",
auth_yellow("⚠"),
);
print_hint("If that was a mistake, run `rkat auth login` again and approve.");
anyhow::bail!("User denied authorization");
}
Err(OAuthError::StateMismatch) => {
eprintln!();
eprintln!(
"{} Callback state mismatch (possible CSRF or stale browser tab).",
auth_yellow("⚠"),
);
print_hint("Close any open OAuth tabs and run `rkat auth login` again.");
anyhow::bail!("CSRF state mismatch");
}
Err(e) => {
return Err(anyhow::anyhow!("OAuth callback error: {e}"));
}
};
print_ok("Received authorization code from the provider.");
let flow = scope
.oauth_flow_authority
.verify(&outcome.state, &auth_binding, identity, &redirect_url)
.map_err(|e| anyhow::anyhow!("OAuth flow verification failed: {e}"))?;
// --- Step 4: exchange + persist ------------------------------
print_step(4, 4, "Exchanging the code for access + refresh tokens");
let http = reqwest::Client::new();
let result = meerkat_providers::auth_oauth::exchange_authorization_code_with_state(
&http,
&endpoints,
&outcome.code,
&flow.pkce_verifier,
client_secret,
Some(&outcome.state),
)
.await
.map_err(|e| {
eprintln!();
eprintln!("{} Token exchange failed: {e}", auth_yellow("⚠"));
print_hint("Check your network connection and try `rkat auth login` again.");
anyhow::anyhow!("Token exchange failed: {e}")
})?;
let now = chrono::Utc::now();
let expires_at = result
.expires_at_from(now)
.map_err(|e| anyhow::anyhow!("Token expiry conversion failed: {e}"))?;
let has_refresh = result.refresh_token.is_some();
let tokens = PersistedTokens {
auth_mode,
primary_secret: Some(result.access_token),
refresh_token: result.refresh_token,
id_token: result.id_token,
expires_at,
last_refresh: Some(now),
scopes: result
.scope
.as_deref()
.map(|s| s.split_whitespace().map(String::from).collect())
.unwrap_or_default(),
account_id: None,
metadata: serde_json::Value::Null,
};
let store: StdArc<dyn TokenStore> = TokenStoreBackend::default_auto()
.map_err(|e| anyhow::anyhow!("Cannot open TokenStore: {e}"))?
.open()
.map_err(|e| anyhow::anyhow!("Cannot open TokenStore: {e}"))?;
save_cli_oauth_tokens_and_consume_browser_flow(
store.as_ref(),
scope.auth_lease.as_ref(),
&auth_binding,
&tokens,
CliBrowserFlowConsume {
authority: scope.oauth_flow_authority.as_ref(),
state: &outcome.state,
provider: identity,
redirect_uri: &redirect_url,
},
)
.await?;
print_ok("Tokens persisted to the local credentials file (0o600 on Unix).");
// --- Success summary + next steps -----------------------------
let storage_location = dirs::config_dir()
.map(|p| p.join("meerkat").join("credentials").display().to_string())
.unwrap_or_else(|| "(unknown)".to_string());
eprintln!();
eprintln!(
"{}",
auth_green(&format!("Signed in to {}.", provider.display_name())),
);
eprintln!();
eprintln!(
" {} {}:{}",
auth_bold("Profile:"),
key.realm.as_str(),
key.binding.as_str(),
);
eprintln!(" {} {}", auth_bold("Storage:"), storage_location);
if let Some(expiry) = expires_at {
let human_delta = (expiry - chrono::Utc::now()).num_minutes();
eprintln!(
" {} {} {}",
auth_bold("Expires:"),
expiry.format("%Y-%m-%d %H:%M:%S UTC"),
auth_dim(&format!("(in {human_delta} min — will auto-refresh)")),
);
}
if has_refresh {
eprintln!(
" {} enabled (background refresh coordinator handles renewal)",
auth_bold("Refresh:"),
);
}
eprintln!();
eprintln!("{}", auth_bold("Next steps:"));
eprintln!();
eprintln!(
" {}",
auth_cyan(&format!(
"{cli_cmd} auth test --realm dev {}",
provider.binding_id(),
)),
);
eprintln!(
" {}",
auth_dim(" → verify the binding resolves through the provider runtime"),
);
eprintln!();
eprintln!(
" {}",
auth_cyan(&format!(
"{cli_cmd} run --auth-binding dev:{} \"hello\"",
provider.binding_id()
)),
);
eprintln!(
" {}",
auth_dim(" → run through the persisted OAuth credential you just created"),
);
eprintln!();
Ok(())
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn current_cli_command_name() -> String {
std::env::args()
.next()
.unwrap_or_else(|| "rkat".to_string())
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn token_store_load_error_allows_clear(e: &meerkat_providers::auth_store::TokenStoreError) -> bool {
matches!(e, meerkat_providers::auth_store::TokenStoreError::Serde(_))
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
async fn interactive_logout(profile_id: &str, scope: &RuntimeScope) -> anyhow::Result<()> {
use meerkat_providers::auth_store::{TokenKey, TokenStoreBackend};
let store = TokenStoreBackend::default_auto()
.map_err(|e| anyhow::anyhow!("Cannot open TokenStore: {e}"))?
.open()
.map_err(|e| anyhow::anyhow!("Cannot open TokenStore: {e}"))?;
// Wave-c C-12: TokenKey now takes typed atoms; parse the raw
// `profile_id` form at this logout boundary. This is the
// non-AuthBindingRef carve-out `split_once(':')` site explicitly
// documented in `cli_parse.rs` — TokenKey shares the same flat
// `realm:binding` grammar but has no profile component.
let keys = match profile_id.split_once(':') {
Some((realm, binding)) => vec![
TokenKey::parse(realm, binding)
.map_err(|e| anyhow::anyhow!("invalid token-key `{profile_id}`: {e}"))?,
],
None => vec![
TokenKey::parse("dev", profile_id)
.map_err(|e| anyhow::anyhow!("invalid token-key `dev:{profile_id}`: {e}"))?,
],
};
let mut cleared = 0;
for key in keys {
let should_clear = match store.load(&key).await {
Ok(present) => present.is_some(),
Err(e) if token_store_load_error_allows_clear(&e) => true,
Err(e) => return Err(anyhow::anyhow!("TokenStore load failed: {e}")),
};
if should_clear {
let auth_binding = meerkat_core::AuthBindingRef {
realm: key.realm.clone(),
binding: key.binding.clone(),
profile: key.profile.clone(),
};
meerkat_core::clear_tokens_and_publish_lifecycle_released(
store.as_ref(),
scope.auth_lease.as_ref(),
&auth_binding,
)
.await
.map_err(|e| anyhow::anyhow!("Token lifecycle clear failed: {e}"))?;
eprintln!(
"{} Cleared {}:{}",
auth_green("✓"),
key.realm.as_str(),
key.binding.as_str(),
);
cleared += 1;
} else {
eprintln!(
"{} No stored credentials for {}:{}",
auth_dim("·"),
key.realm.as_str(),
key.binding.as_str(),
);
}
}
if cleared == 0 {
anyhow::bail!("No credentials found for profile '{profile_id}'");
}
Ok(())
}
fn source_kind_label(source: &meerkat_core::CredentialSourceSpec) -> &'static str {
match source {
meerkat_core::CredentialSourceSpec::InlineSecret { .. } => "inline_secret",
meerkat_core::CredentialSourceSpec::ManagedStore => "managed_store",
meerkat_core::CredentialSourceSpec::Env { .. } => "env",
meerkat_core::CredentialSourceSpec::ExternalResolver { .. } => "external_resolver",
meerkat_core::CredentialSourceSpec::PlatformDefault => "platform_default",
meerkat_core::CredentialSourceSpec::Command { .. } => "command",
meerkat_core::CredentialSourceSpec::FileDescriptor { .. } => "file_descriptor",
}
}
struct CliAuthStatusProjection {
phase: AuthStatusPhase,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
async fn project_cli_auth_status(
auth_lease: &dyn meerkat_core::handles::AuthLeaseHandle,
token_store: Option<&dyn meerkat_providers::auth_store::TokenStore>,
auth_binding: &AuthBindingRef,
auth_profile: &meerkat_core::AuthProfile,
now: chrono::DateTime<chrono::Utc>,
) -> CliAuthStatusProjection {
let lease_key = meerkat_core::handles::LeaseKey::from_auth_binding(auth_binding);
let mut snapshot = auth_lease.snapshot(&lease_key);
let expected_mode = meerkat_providers::auth_store::persisted_auth_mode_for_auth_method(
&auth_profile.auth_method,
);
let source_uses_store =
meerkat_providers::auth_store::credential_source_uses_persisted_store(&auth_profile.source);
let oauth_mode = expected_mode
.map(meerkat_providers::auth_store::persisted_auth_mode_is_oauth_login)
.unwrap_or(false);
let mut stored = None;
if source_uses_store && let Some(store) = token_store {
let phase = AuthStatusPhase::from_lease_snapshot(now, &snapshot);
if phase == AuthStatusPhase::Unknown
&& let Some(expected_mode) = expected_mode
&& let Ok(Some(rehydrated)) = meerkat_core::rehydrate_marked_oauth_tokens_for_status(
store,
auth_lease,
auth_binding,
expected_mode,
now,
)
.await
{
stored = Some(rehydrated);
snapshot = auth_lease.snapshot(&lease_key);
} else if phase != AuthStatusPhase::Unknown {
stored = store
.load(&meerkat_providers::auth_store::TokenKey::from_auth_binding(
auth_binding,
))
.await
.ok()
.flatten();
}
}
if stored
.as_ref()
.is_some_and(|tokens| Some(tokens.auth_mode) != expected_mode)
{
stored = None;
}
let oauth_source_rejected = expected_mode
.map(|mode| {
meerkat_providers::auth_store::persisted_auth_mode_is_oauth_login(mode)
&& !source_uses_store
})
.unwrap_or(false);
let oauth_store_missing = source_uses_store && oauth_mode && stored.is_none();
let unknown_snapshot;
let marker_projection_snapshot;
let (projection_tokens, projection_snapshot) = if oauth_source_rejected || oauth_store_missing {
unknown_snapshot = meerkat_core::handles::AuthLeaseSnapshot {
phase: None,
expires_at: None,
credential_present: false,
generation: snapshot.generation,
credential_published_at_millis: None,
};
(None, &unknown_snapshot)
} else {
marker_projection_snapshot = stored.as_ref().filter(|_| oauth_mode).and_then(|tokens| {
meerkat_core::oauth_status_projection_snapshot_from_newer_marker(&snapshot, tokens)
});
(
stored.as_ref(),
marker_projection_snapshot.as_ref().unwrap_or(&snapshot),
)
};
let projection =
meerkat_core::project_published_auth_status(now, projection_tokens, projection_snapshot);
CliAuthStatusProjection {
phase: projection.phase,
expires_at: projection.expires_at,
}
}
fn auth_status_binding_id<'a>(
realm: &str,
profile_id: &str,
realm_set: &'a meerkat_core::RealmConnectionSet,
) -> anyhow::Result<&'a str> {
let matches = realm_set
.bindings
.iter()
.filter_map(|(id, binding)| (binding.auth_profile == profile_id).then_some(id.as_str()))
.collect::<Vec<_>>();
if let Some(default_binding) = realm_set.default_binding.as_deref()
&& matches.contains(&default_binding)
{
return Ok(default_binding);
}
match matches.as_slice() {
[binding_id] => Ok(*binding_id),
[] => anyhow::bail!(
"No binding in realm '{realm}' references auth profile '{profile_id}'; \
auth status is binding-scoped because AuthMachine leases are binding-scoped."
),
_ => anyhow::bail!(
"Multiple bindings in realm '{realm}' reference auth profile '{profile_id}' ({}); \
set a default binding for this profile or query a binding-specific status surface.",
matches.join(", ")
),
}
}
fn cli_provider_registry() -> meerkat_providers::ProviderRuntimeRegistry {
#[allow(unused_mut)]
let mut registry = meerkat_providers::ProviderRuntimeRegistry::empty();
#[cfg(feature = "anthropic")]
{
registry = registry.with_runtime(Arc::new(meerkat_anthropic::AnthropicProviderRuntime));
}
#[cfg(feature = "openai")]
{
registry = registry.with_runtime(Arc::new(meerkat_openai::OpenAiProviderRuntime));
registry = registry.with_runtime(Arc::new(meerkat_providers::SelfHostedProviderRuntime));
}
#[cfg(feature = "gemini")]
{
registry = registry.with_runtime(Arc::new(meerkat_gemini::GoogleProviderRuntime));
}
registry
}
const SELF_HOSTED_LEGACY_REALM_ID: &str = "self_hosted_legacy";
struct DoctorSelfHostedProbeConnection {
base_url: String,
bearer_token: Option<String>,
}
fn doctor_self_hosted_registry() -> meerkat_providers::ProviderRuntimeRegistry {
meerkat_providers::ProviderRuntimeRegistry::empty()
.with_runtime(Arc::new(meerkat_providers::SelfHostedProviderRuntime))
}
fn doctor_resolver_environment() -> meerkat_providers::ResolverEnvironment {
let mut env = meerkat_providers::ResolverEnvironment::with_process_env();
#[cfg(not(target_arch = "wasm32"))]
{
if let Ok(backend) = meerkat_providers::auth_store::TokenStoreBackend::default_auto()
&& let Ok(store) = backend.open()
{
env = env.with_token_store(store);
}
env = env.with_refresh_coordinator(Arc::new(
meerkat_providers::auth_store::InMemoryCoordinator::default(),
));
}
env
}
fn doctor_configured_self_hosted_target(
config: &Config,
preferred_realm: &meerkat_core::RealmId,
) -> anyhow::Result<Option<meerkat_core::ResolvedConnectionTarget>> {
if config.realm.contains_key(preferred_realm.as_str()) {
let target = meerkat_core::resolve_realm_binding_target_for_provider(
config,
meerkat_core::Provider::SelfHosted,
Some(preferred_realm),
None,
None,
None,
false,
)
.map_err(|err| {
anyhow::anyhow!(
"selected realm '{}' self_hosted credential binding is unavailable: {err}",
preferred_realm.as_str()
)
})?;
return Ok(Some(target));
}
match meerkat_core::resolve_auth_binding_or_default_for_provider(
config,
meerkat_core::Provider::SelfHosted,
None,
Some(preferred_realm),
false,
) {
Ok(target) => Ok(Some(target)),
Err(_) => Ok(None),
}
}
fn doctor_legacy_self_hosted_binding_id(
server_id: &str,
) -> anyhow::Result<meerkat_core::BindingId> {
if let Ok(binding_id) = meerkat_core::BindingId::parse(server_id.to_string()) {
return Ok(binding_id);
}
let mut hash = 0xcbf29ce484222325_u64;
for byte in server_id.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
let generated = format!("legacy-{hash:016x}");
meerkat_core::BindingId::parse(generated).map_err(|err| {
anyhow::anyhow!(
"failed to derive transient legacy binding id for self-hosted server '{server_id}': {err}"
)
})
}
fn doctor_legacy_self_hosted_connection(
server_id: &str,
server: &meerkat_core::SelfHostedServerConfig,
) -> anyhow::Result<(meerkat_core::RealmConnectionSet, AuthBindingRef)> {
let realm_id = meerkat_core::RealmId::parse(SELF_HOSTED_LEGACY_REALM_ID).map_err(|err| {
anyhow::anyhow!(
"invalid self-hosted legacy realm id '{SELF_HOSTED_LEGACY_REALM_ID}': {err}"
)
})?;
let binding_id = doctor_legacy_self_hosted_binding_id(server_id)?;
let binding_key = binding_id.as_str().to_string();
let (auth_method, source) = match (&server.bearer_token, &server.bearer_token_env) {
(Some(secret), _) => (
"static_bearer".to_string(),
meerkat_core::CredentialSourceSpec::InlineSecret {
secret: secret.clone(),
},
),
(None, Some(env)) => (
"static_bearer".to_string(),
meerkat_core::CredentialSourceSpec::Env {
env: env.clone(),
fallback: Vec::new(),
},
),
(None, None) => (
"none".to_string(),
meerkat_core::CredentialSourceSpec::PlatformDefault,
),
};
let backend = meerkat_core::BackendProfile {
id: server_id.to_string(),
provider: meerkat_core::Provider::SelfHosted,
backend_kind: "self_hosted".to_string(),
base_url: Some(server.base_url.clone()),
options: serde_json::Value::Null,
};
let auth = meerkat_core::AuthProfile {
id: format!("{server_id}_auth"),
provider: meerkat_core::Provider::SelfHosted,
auth_method,
source,
constraints: Default::default(),
metadata_defaults: Default::default(),
};
let binding = meerkat_core::ProviderBinding {
id: binding_key.clone(),
backend_profile: backend.id.clone(),
auth_profile: auth.id.clone(),
default_model: None,
policy: Default::default(),
};
let mut backends = std::collections::BTreeMap::new();
backends.insert(backend.id.clone(), backend);
let mut auth_profiles = std::collections::BTreeMap::new();
auth_profiles.insert(auth.id.clone(), auth);
let mut bindings = std::collections::BTreeMap::new();
bindings.insert(binding.id.clone(), binding);
Ok((
meerkat_core::RealmConnectionSet {
realm_id: SELF_HOSTED_LEGACY_REALM_ID.to_string(),
backends,
auth_profiles,
bindings,
default_binding: Some(binding_key),
},
AuthBindingRef {
realm: realm_id,
binding: binding_id,
profile: None,
},
))
}
async fn resolve_doctor_self_hosted_probe_connection(
config: &Config,
preferred_realm: &meerkat_core::RealmId,
server_id: &str,
server: &meerkat_core::SelfHostedServerConfig,
) -> anyhow::Result<DoctorSelfHostedProbeConnection> {
let (realm, auth_binding) =
if let Some(target) = doctor_configured_self_hosted_target(config, preferred_realm)? {
(target.realm, target.auth_binding)
} else {
doctor_legacy_self_hosted_connection(server_id, server)?
};
let connection = doctor_self_hosted_registry()
.resolve(&realm, &auth_binding, &doctor_resolver_environment())
.await
.map_err(|err| {
anyhow::anyhow!(
"self-hosted credential resolution failed for server '{server_id}' through '{}:{}': {err}",
auth_binding.realm.as_str(),
auth_binding.binding.as_str()
)
})?;
if connection.resolved_authorizer().is_some() {
anyhow::bail!(
"self-hosted server '{server_id}' resolved dynamic authorizer auth, but doctor only supports bearer-token or authless probes"
);
}
let base_url = connection
.backend_profile
.base_url
.clone()
.unwrap_or_else(|| server.base_url.clone());
Ok(DoctorSelfHostedProbeConnection {
base_url: meerkat_core::model_registry::normalize_base_url(&base_url),
bearer_token: connection.resolved_secret(),
})
}
async fn handle_doctor(scope: &RuntimeScope) -> anyhow::Result<()> {
let mut ok = true;
let (config, _) = load_config(scope).await?;
let config_path =
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str())
.config_path;
if config_path.exists() {
println!("ok\tconfig\t{}", config_path.display());
} else {
ok = false;
println!("warn\tconfig\tmissing config at {}", config_path.display());
}
let provider_keys: [(&str, &[&str]); 2] = [
(
"anthropic",
&["RKAT_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"],
),
(
"gemini",
&[
"RKAT_GEMINI_API_KEY",
"GEMINI_API_KEY",
"RKAT_GOOGLE_API_KEY",
"GOOGLE_API_KEY",
],
),
];
println!("{}", doctor_openai_env_default_message(env_var_present));
for (provider, env_keys) in provider_keys {
if let Some(env_key) = env_keys.iter().find(|env_key| env_var_present(env_key)) {
println!("ok\tprovider\t{provider} via {env_key}");
} else {
println!("warn\tprovider\t{provider} missing {}", env_keys.join("/"));
}
}
if config.self_hosted.servers.is_empty() {
println!("ok\tself_hosted\tno self-hosted servers configured");
} else {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| anyhow::anyhow!("failed to build doctor HTTP client: {e}"))?;
for (server_id, server) in &config.self_hosted.servers {
let resolved = match resolve_doctor_self_hosted_probe_connection(
&config,
&scope.locator.realm,
server_id,
server,
)
.await
{
Ok(resolved) => resolved,
Err(err) => {
ok = false;
println!(
"warn\tself_hosted\tserver {server_id} credential resolution failed: {err}"
);
continue;
}
};
let models_url = format!("{}/models", resolved.base_url);
let mut request = http.get(&models_url);
if let Some(token) = resolved.bearer_token {
request = request.bearer_auth(token);
}
match request.send().await {
Ok(response) if response.status().is_success() => {
println!("ok\tself_hosted\tserver {server_id} reachable at {models_url}");
let configured_models: Vec<_> = config
.self_hosted
.models
.iter()
.filter(|(_, model)| model.server == *server_id)
.map(|(alias, model)| (alias.as_str(), model.remote_model.as_str()))
.collect();
match response.json::<serde_json::Value>().await {
Ok(json) => {
let available: std::collections::HashSet<String> = json["data"]
.as_array()
.into_iter()
.flatten()
.filter_map(|entry| entry["id"].as_str().map(ToString::to_string))
.collect();
for (alias, remote_model) in configured_models {
if available.is_empty() {
break;
}
if available.contains(remote_model) {
println!(
"ok\tself_hosted\talias {alias} -> {remote_model} listed by {server_id}"
);
} else {
println!(
"warn\tself_hosted\talias {alias} -> {remote_model} not listed by {server_id}"
);
}
}
}
Err(_) => {
println!(
"warn\tself_hosted\tserver {server_id} did not return a parseable /models payload"
);
}
}
}
Ok(response) => {
ok = false;
println!(
"warn\tself_hosted\tserver {server_id} returned {} from {models_url}",
response.status()
);
}
Err(err) => {
ok = false;
println!(
"warn\tself_hosted\tserver {server_id} unreachable at {models_url}: {err}"
);
}
}
}
}
match meerkat_core::mcp_config::McpConfig::load_from_roots(
scope.context_root.as_deref(),
scope.user_config_root.as_deref(),
)
.await
{
Ok(config) => println!("ok\tmcp\t{} configured server(s)", config.servers.len()),
Err(err) => {
ok = false;
println!("warn\tmcp\t{err}");
}
}
let wasm_pack = TokioCommand::new("wasm-pack")
.arg("--version")
.output()
.await;
match wasm_pack {
Ok(output) if output.status.success() => {
println!("ok\twasm-pack\tavailable");
}
_ => println!("warn\twasm-pack\tnot found (needed for `rkat mob web build`)"),
}
if ok {
println!("ok\tdoctor\tsetup looks good");
Ok(())
} else {
Err(anyhow::anyhow!(
"doctor found issues; review the warnings above"
))
}
}
fn env_var_present(env_key: &str) -> bool {
std::env::var(env_key)
.ok()
.filter(|value| !value.is_empty())
.is_some()
}
fn doctor_openai_env_default_message<F>(mut env_present: F) -> &'static str
where
F: FnMut(&str) -> bool,
{
let public_openai_env_present = ["RKAT_OPENAI_API_KEY", "OPENAI_API_KEY"]
.iter()
.any(|env_key| env_present(env_key));
let azure_key_present = ["RKAT_AZURE_OPENAI_API_KEY", "AZURE_OPENAI_API_KEY"]
.iter()
.any(|env_key| env_present(env_key));
let azure_endpoint_present = ["RKAT_AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_ENDPOINT"]
.iter()
.any(|env_key| env_present(env_key));
let azure_explicit =
env_present("RKAT_AZURE_OPENAI_API_KEY") || env_present("RKAT_AZURE_OPENAI_ENDPOINT");
if azure_key_present && azure_endpoint_present && (azure_explicit || !public_openai_env_present)
{
"ok\tprovider\topenai via AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT"
} else if public_openai_env_present {
"ok\tprovider\topenai via OPENAI_API_KEY"
} else {
"warn\tprovider\topenai missing RKAT_OPENAI_API_KEY/OPENAI_API_KEY or AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT"
}
}
async fn handle_realm_command(command: RealmCommands, scope: &RuntimeScope) -> anyhow::Result<()> {
fn validate_realm_id(realm_id: &str) -> anyhow::Result<()> {
meerkat_core::runtime_bootstrap::validate_explicit_realm_id(realm_id)
.map_err(|e| anyhow::anyhow!("{e}"))
}
match command {
RealmCommands::Current => {
println!("{}", scope.locator.realm.as_str());
Ok(())
}
RealmCommands::List => {
let manifests = meerkat_store::list_realm_manifests_in(&scope.locator.state_root)
.await
.map_err(|e| anyhow::anyhow!("Failed to list realms: {e}"))?;
if manifests.is_empty() {
println!("No realms found.");
return Ok(());
}
println!(
"{:<28} {:<8} {:<14} {:<8} {:<12}",
"REALM", "BACKEND", "ORIGIN", "ACTIVE", "CREATED_AT"
);
println!("{}", "-".repeat(76));
for entry in manifests {
let leases = meerkat_store::inspect_realm_leases_in(
&scope.locator.state_root,
entry.manifest.realm.as_str(),
true,
)
.await
.map_err(|e| anyhow::anyhow!("Failed to inspect leases: {e}"))?;
let origin = entry.manifest.origin.as_str();
println!(
"{:<28} {:<8} {:<14} {:<8} {:<12}",
entry.manifest.realm,
entry.manifest.backend.as_str(),
origin,
leases.active.len(),
entry.manifest.created_at
);
if entry.manifest.origin == meerkat_store::RealmOrigin::LegacyUnknown {
println!(
" note: realm '{}' is legacy/unknown origin and is skipped by --isolated-only prune.",
entry.manifest.realm
);
}
}
Ok(())
}
RealmCommands::Show { realm_id } => {
validate_realm_id(&realm_id)?;
let paths = meerkat_store::realm_paths_in(&scope.locator.state_root, &realm_id);
if !tokio::fs::try_exists(&paths.manifest_path)
.await
.map_err(|e| anyhow::anyhow!("Failed to check manifest path: {e}"))?
{
return Err(anyhow::anyhow!("Realm not found: {realm_id}"));
}
let payload = tokio::fs::read_to_string(&paths.manifest_path)
.await
.map_err(|e| anyhow::anyhow!("Failed to read manifest: {e}"))?;
let manifest: meerkat_store::RealmManifest = serde_json::from_str(&payload)
.map_err(|e| anyhow::anyhow!("Failed to parse manifest: {e}"))?;
let leases =
meerkat_store::inspect_realm_leases_in(&scope.locator.state_root, &realm_id, true)
.await
.map_err(|e| anyhow::anyhow!("Failed to inspect leases: {e}"))?;
println!("realm_id: {}", manifest.realm);
println!("backend: {}", manifest.backend.as_str());
println!("origin: {}", manifest.origin.as_str());
println!("created_at: {}", manifest.created_at);
println!("state_root: {}", scope.locator.state_root.display());
println!("active_leases: {}", leases.active.len());
for lease in leases.active {
println!(
" - instance={} surface={} pid={} heartbeat={}",
lease.instance_id, lease.surface, lease.pid, lease.heartbeat_at
);
}
Ok(())
}
RealmCommands::Create { realm_id, backend } => {
validate_realm_id(&realm_id)?;
let manifest = meerkat_store::ensure_realm_manifest_in(
&scope.locator.state_root,
&realm_id,
backend.map(Into::into),
Some(meerkat_store::RealmOrigin::Explicit),
)
.await
.map_err(|e| anyhow::anyhow!("Failed to create realm: {e}"))?;
println!(
"Created realm '{}' backend={} origin={}",
manifest.realm,
manifest.backend.as_str(),
manifest.origin.as_str()
);
Ok(())
}
RealmCommands::Delete { realm_id, force } => {
validate_realm_id(&realm_id)?;
delete_realm(&scope.locator.state_root, &realm_id, force).await
}
RealmCommands::Prune {
isolated_only,
older_than_hours,
force,
} => {
prune_realms(
&scope.locator.state_root,
isolated_only,
older_than_hours,
force,
)
.await
}
}
}
async fn handle_workgraph_command(
command: WorkGraphCommands,
scope: &RuntimeScope,
) -> anyhow::Result<()> {
let service = open_workgraph_service(scope).await?;
match command {
WorkGraphCommands::List {
namespace,
all_namespaces,
statuses,
labels,
include_terminal,
limit,
json,
} => {
let items = service
.list(meerkat::WorkItemFilter {
realm_id: None,
namespace: parse_work_namespace(namespace)?,
all_namespaces,
statuses: statuses.into_iter().map(Into::into).collect(),
labels,
include_terminal,
limit,
})
.await?;
print_workgraph_items(items, json)
}
WorkGraphCommands::Show {
id,
namespace,
json,
} => {
let item = service
.get(
None,
parse_work_namespace(namespace)?,
meerkat::WorkItemId::new(id)?,
)
.await?;
if json {
println!("{}", serde_json::to_string_pretty(&item)?);
} else {
print_workgraph_item(&item);
}
Ok(())
}
WorkGraphCommands::Ready {
namespace,
labels,
limit,
json,
} => {
let items = service
.ready(meerkat::ReadyWorkFilter {
realm_id: None,
namespace: parse_work_namespace(namespace)?,
labels,
limit,
})
.await?;
print_workgraph_items(items, json)
}
WorkGraphCommands::Snapshot {
namespace,
all_namespaces,
statuses,
labels,
include_terminal,
limit,
json,
} => {
let snapshot = service
.snapshot(meerkat::WorkGraphSnapshotFilter {
realm_id: None,
namespace: parse_work_namespace(namespace)?,
all_namespaces,
statuses: statuses.into_iter().map(Into::into).collect(),
labels,
include_terminal,
limit,
})
.await?;
if json {
println!("{}", serde_json::to_string_pretty(&snapshot)?);
} else {
println!("Realm: {}", snapshot.realm_id);
if let Some(namespace) = &snapshot.namespace {
println!("Namespace: {namespace}");
} else {
println!("Namespace: all");
}
println!("Captured: {}", snapshot.captured_at);
println!(
"Event high-water: {}",
snapshot
.event_high_water_mark
.map(|seq| seq.to_string())
.unwrap_or_else(|| "-".to_string())
);
println!("Items: {}", snapshot.items.len());
println!("Edges: {}", snapshot.edges.len());
println!("Ready: {}", snapshot.ready_item_ids.len());
if !snapshot.ready_item_ids.is_empty() {
println!(
"Ready IDs: {}",
snapshot
.ready_item_ids
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
);
}
}
Ok(())
}
WorkGraphCommands::Events {
namespace,
all_namespaces,
after_seq,
limit,
json,
} => {
let events = service
.events(meerkat::WorkGraphEventFilter {
realm_id: None,
namespace: parse_work_namespace(namespace)?,
all_namespaces,
after_seq,
limit,
})
.await?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({ "events": events }))?
);
} else {
print_workgraph_events(&events);
}
Ok(())
}
}
}
async fn open_workgraph_service(scope: &RuntimeScope) -> anyhow::Result<meerkat::WorkGraphService> {
let (_manifest, persistence) = create_persistence_bundle(scope).await?;
Ok(meerkat::WorkGraphService::with_scope(
persistence.workgraph_store(),
scope.locator.realm.to_string(),
meerkat::WorkNamespace::default(),
))
}
fn parse_work_namespace(
namespace: Option<String>,
) -> anyhow::Result<Option<meerkat::WorkNamespace>> {
namespace
.map(meerkat::WorkNamespace::new)
.transpose()
.map_err(Into::into)
}
fn print_workgraph_items(items: Vec<meerkat::WorkItem>, json: bool) -> anyhow::Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({ "items": items }))?
);
return Ok(());
}
if items.is_empty() {
println!("No WorkGraph items found.");
return Ok(());
}
println!(
"{:<42} {:<12} {:<8} {:<18} TITLE",
"ID", "STATUS", "PRIORITY", "UPDATED"
);
println!("{}", "-".repeat(110));
for item in items {
println!(
"{:<42} {:<12} {:<8} {:<18} {}",
item.id,
work_status_label(item.status),
work_priority_label(item.priority),
item.updated_at.format("%Y-%m-%d %H:%M"),
item.title
);
}
Ok(())
}
fn print_workgraph_item(item: &meerkat::WorkItem) {
println!("id: {}", item.id);
println!("realm_id: {}", item.realm_id);
println!("namespace: {}", item.namespace);
println!("title: {}", item.title);
if let Some(description) = &item.description {
println!("description: {description}");
}
println!("status: {}", work_status_label(item.status));
println!("priority: {}", work_priority_label(item.priority));
println!("revision: {}", item.revision);
println!("created_at: {}", item.created_at);
println!("updated_at: {}", item.updated_at);
if let Some(due_at) = item.due_at {
println!("due_at: {due_at}");
}
if let Some(not_before) = item.not_before {
println!("not_before: {not_before}");
}
if let Some(snoozed_until) = item.snoozed_until {
println!("snoozed_until: {snoozed_until}");
}
if let Some(terminal_at) = item.terminal_at {
println!("terminal_at: {terminal_at}");
}
if !item.labels.is_empty() {
println!(
"labels: {}",
item.labels.iter().cloned().collect::<Vec<_>>().join(", ")
);
}
if let Some(claim) = &item.claim {
println!("claimed_at: {}", claim.claimed_at);
if let Some(lease_expires_at) = claim.lease_expires_at {
println!("lease_expires_at: {lease_expires_at}");
}
}
if !item.external_refs.is_empty() {
println!("external_refs: {}", item.external_refs.len());
}
if !item.evidence_refs.is_empty() {
println!("evidence_refs: {}", item.evidence_refs.len());
}
}
fn print_workgraph_events(events: &[meerkat::WorkGraphEvent]) {
if events.is_empty() {
println!("No WorkGraph events found.");
return;
}
println!(
"{:<8} {:<16} {:<28} {:<18} ITEM",
"SEQ", "KIND", "NAMESPACE", "AT"
);
println!("{}", "-".repeat(110));
for event in events {
println!(
"{:<8} {:<16} {:<28} {:<18} {}",
event
.seq
.map(|seq| seq.to_string())
.unwrap_or_else(|| "-".to_string()),
work_event_kind_label(event.kind),
event.namespace,
event.at.format("%Y-%m-%d %H:%M"),
event
.item_id
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| "-".to_string())
);
}
}
fn work_status_label(status: meerkat::WorkStatus) -> &'static str {
match status {
meerkat::WorkStatus::Open => "open",
meerkat::WorkStatus::InProgress => "in_progress",
meerkat::WorkStatus::Blocked => "blocked",
meerkat::WorkStatus::Completed => "completed",
meerkat::WorkStatus::Cancelled => "cancelled",
meerkat::WorkStatus::Failed => "failed",
}
}
fn work_priority_label(priority: meerkat::WorkPriority) -> &'static str {
match priority {
meerkat::WorkPriority::Low => "low",
meerkat::WorkPriority::Medium => "medium",
meerkat::WorkPriority::High => "high",
}
}
fn work_event_kind_label(kind: meerkat::WorkGraphEventKind) -> &'static str {
match kind {
meerkat::WorkGraphEventKind::Created => "created",
meerkat::WorkGraphEventKind::Updated => "updated",
meerkat::WorkGraphEventKind::Claimed => "claimed",
meerkat::WorkGraphEventKind::Released => "released",
meerkat::WorkGraphEventKind::Blocked => "blocked",
meerkat::WorkGraphEventKind::Closed => "closed",
meerkat::WorkGraphEventKind::Linked => "linked",
meerkat::WorkGraphEventKind::EvidenceAdded => "evidence_added",
}
}
async fn delete_realm(
state_root: &std::path::Path,
realm_id: &str,
force: bool,
) -> anyhow::Result<()> {
let lease_status = meerkat_store::inspect_realm_leases_in(state_root, realm_id, true)
.await
.map_err(|e| anyhow::anyhow!("Failed to inspect realm leases: {e}"))?;
if !lease_status.active.is_empty() && !force {
return Err(anyhow::anyhow!(
"Realm '{}' appears active ({} live lease(s)). Use --force to override.",
realm_id,
lease_status.active.len()
));
}
let paths = meerkat_store::realm_paths_in(state_root, realm_id);
remove_realm_root_with_retries(&paths, force)
.await
.map_err(|e| anyhow::anyhow!("Failed to delete realm '{realm_id}': {e}"))?;
println!("Deleted realm '{realm_id}'");
Ok(())
}
async fn remove_realm_root_with_retries(
paths: &meerkat_store::RealmPaths,
force: bool,
) -> anyhow::Result<()> {
let max_attempts: usize = if force { 12 } else { 1 };
let mut delay = Duration::from_millis(25);
let lease_dir = meerkat_store::realm_lease_dir(paths);
for attempt in 1..=max_attempts {
match tokio::fs::remove_dir_all(&paths.root).await {
Ok(()) => return Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => {
if !force || attempt >= max_attempts {
return Err(err.into());
}
let _ = tokio::fs::remove_dir_all(&lease_dir).await;
tokio::time::sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(1));
}
}
}
unreachable!("retry loop exhausted without returning");
}
async fn prune_realms(
state_root: &std::path::Path,
isolated_only: bool,
older_than_hours: u64,
force: bool,
) -> anyhow::Result<()> {
let outcome = prune_realms_inner(state_root, isolated_only, older_than_hours, force).await?;
println!(
"Prune summary: removed={}, skipped_active={}, skipped_legacy={}, leftovers={}",
outcome.removed,
outcome.skipped_active,
outcome.skipped_legacy,
outcome.leftovers.len()
);
if outcome.skipped_legacy > 0 {
println!(
"note: {} legacy/unknown realm(s) were kept. Use --force to prune them.",
outcome.skipped_legacy
);
}
if !outcome.leftovers.is_empty() {
eprintln!("Leftover realms (partial cleanup):");
for item in &outcome.leftovers {
eprintln!(" - {item}");
}
return Err(anyhow::anyhow!(
"Realm prune completed with partial failures (see leftovers above)."
));
}
Ok(())
}
#[derive(Debug, Default)]
struct PruneOutcome {
removed: usize,
skipped_active: usize,
skipped_legacy: usize,
leftovers: Vec<String>,
}
async fn prune_realms_inner(
state_root: &std::path::Path,
isolated_only: bool,
older_than_hours: u64,
force: bool,
) -> anyhow::Result<PruneOutcome> {
let manifests = meerkat_store::list_realm_manifests_in(state_root)
.await
.map_err(|e| anyhow::anyhow!("Failed to list realms: {e}"))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let threshold_secs = older_than_hours.saturating_mul(3600);
let mut outcome = PruneOutcome::default();
for entry in manifests {
let manifest = entry.manifest;
let created = manifest.created_at.parse::<u64>().unwrap_or(0);
let age_secs = now.saturating_sub(created);
if isolated_only && manifest.origin != meerkat_store::RealmOrigin::Generated {
if manifest.origin == meerkat_store::RealmOrigin::LegacyUnknown {
outcome.skipped_legacy += 1;
}
continue;
}
if manifest.origin == meerkat_store::RealmOrigin::LegacyUnknown && !force {
outcome.skipped_legacy += 1;
continue;
}
if age_secs < threshold_secs {
continue;
}
let lease_status =
meerkat_store::inspect_realm_leases_in(state_root, manifest.realm.as_str(), true)
.await
.map_err(|e| anyhow::anyhow!("Failed to inspect realm leases: {e}"))?;
if !lease_status.active.is_empty() && !force {
outcome.skipped_active += 1;
continue;
}
let paths = meerkat_store::realm_paths_in(state_root, manifest.realm.as_str());
if let Err(err) = remove_realm_root_with_retries(&paths, force).await {
outcome
.leftovers
.push(format!("{} ({})", manifest.realm, err));
continue;
}
outcome.removed += 1;
}
Ok(outcome)
}
/// Create the realm-scoped session store backend.
#[cfg(feature = "session-store")]
async fn create_persistence_bundle(
scope: &RuntimeScope,
) -> anyhow::Result<(meerkat_store::RealmManifest, PersistenceBundle)> {
meerkat::open_realm_persistence_in(
&scope.locator.state_root,
scope.locator.realm.as_str(),
scope.backend_hint(),
Some(scope.origin_hint),
)
.await
.map_err(|e| anyhow::anyhow!("Failed to open realm persistence backend: {e}"))
}
#[cfg(not(feature = "session-store"))]
async fn create_persistence_bundle(
_scope: &RuntimeScope,
) -> anyhow::Result<(meerkat_store::RealmManifest, PersistenceBundle)> {
anyhow::bail!("rkat built without session-store support")
}
fn realm_store_path(manifest: &meerkat_store::RealmManifest, scope: &RuntimeScope) -> PathBuf {
let paths =
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str());
match manifest.backend {
#[cfg(feature = "jsonl-store")]
RealmBackend::Jsonl => paths.sessions_jsonl_dir,
#[cfg(feature = "session-store")]
RealmBackend::Sqlite => paths.root,
#[cfg(not(feature = "jsonl-store"))]
_ => paths.root,
}
}
/// Create MCP tool dispatcher from config files.
///
/// Servers are staged and launched in parallel via `apply_staged()`. When
/// `wait_for_mcp` is true, blocks until all servers finish connecting (or
/// timeout). Otherwise returns immediately and the agent loop picks up
/// completions via `poll_external_updates()`.
#[cfg(feature = "mcp")]
async fn create_mcp_tools(
scope: &RuntimeScope,
wait_for_mcp: bool,
external_surface_handle: Option<Arc<dyn meerkat_core::ExternalToolSurfaceHandle>>,
) -> anyhow::Result<Option<McpRouterAdapter>> {
use meerkat_core::mcp_config::{McpConfig, McpScope};
use meerkat_mcp::{McpConnection, McpRouter};
// Load MCP config with scope info for security warnings
let servers_with_scope = McpConfig::load_with_scopes_from_roots(
scope.context_root.as_deref(),
scope.user_config_root.as_deref(),
)
.await
.map_err(|e| anyhow::anyhow!("MCP config error: {e}"))?;
if servers_with_scope.is_empty() {
return Ok(None);
}
// Warn about project-scoped servers (potential security concern)
let project_servers: Vec<_> = servers_with_scope
.iter()
.filter(|s| s.scope == McpScope::Project)
.collect();
if !project_servers.is_empty() {
eprintln!(
"Loading {} MCP server(s) from project config:",
project_servers.len()
);
for s in &project_servers {
let target = match &s.server.transport {
meerkat_core::mcp_config::McpTransportConfig::Stdio(stdio) => {
if stdio.args.is_empty() {
stdio.command.clone()
} else {
format!("{} {}", stdio.command, stdio.args.join(" "))
}
}
meerkat_core::mcp_config::McpTransportConfig::Http(http) => http.url.clone(),
};
eprintln!(" - {} ({})", s.server.name, target);
}
}
tracing::info!("Loading {} MCP server(s)", servers_with_scope.len());
// Stage all servers for parallel connection
let mut router = match external_surface_handle {
Some(handle) => McpRouter::new_with_surface_handle(handle),
None => McpRouter::new(),
};
for s in &servers_with_scope {
tracing::info!("Staging MCP server: {}", s.server.name);
router
.stage_add(s.server.clone())
.map_err(|e| anyhow::anyhow!("MCP config: {e}"))?;
}
// Apply staged ops — spawns background connection tasks
let result = router
.apply_staged()
.await
.map_err(|e| anyhow::anyhow!("MCP apply error: {e}"))?;
let adapter = McpRouterAdapter::new(router);
if wait_for_mcp && result.pending_count > 0 {
// Compute timeout: max(connect_timeout_secs) + 5s, capped at 60s
let max_server_timeout = servers_with_scope
.iter()
.filter_map(|s| s.server.connect_timeout_secs)
.max()
.unwrap_or(McpConnection::DEFAULT_CONNECT_TIMEOUT_SECS);
let total_timeout = std::time::Duration::from_secs((max_server_timeout as u64 + 5).min(60));
tracing::info!(
"Waiting for {} MCP server(s) to connect (timeout: {}s)...",
result.pending_count,
total_timeout.as_secs()
);
let notices = adapter.wait_until_ready(total_timeout).await;
for notice in ¬ices {
if notice.status_text().starts_with("failed") {
eprintln!(
"Warning: MCP server '{}' failed: {}",
notice.target,
notice.status_text()
);
}
}
}
// Refresh cached tools to include any that connected immediately
adapter
.refresh_tools()
.await
.map_err(|e| anyhow::anyhow!("Failed to refresh MCP tools: {e}"))?;
Ok(Some(adapter))
}
fn resolve_keep_alive(requested: bool) -> anyhow::Result<bool> {
meerkat::surface::resolve_keep_alive(requested).map_err(|e| anyhow::anyhow!(e))
}
/// Load MCP tools as an external tool dispatcher for session build options.
async fn load_mcp_external_tools(
scope: &RuntimeScope,
wait_for_mcp: bool,
external_surface_handle: Option<Arc<dyn meerkat_core::ExternalToolSurfaceHandle>>,
) -> (
Option<Arc<dyn AgentToolDispatcher>>,
Option<Arc<McpRouterAdapter>>,
) {
#[cfg(feature = "mcp")]
{
match create_mcp_tools(scope, wait_for_mcp, external_surface_handle).await {
Ok(Some(adapter)) => {
let adapter = Arc::new(adapter);
let external: Arc<dyn AgentToolDispatcher> = adapter.clone();
(Some(external), Some(adapter))
}
Ok(None) => (None, None),
Err(e) => {
tracing::warn!("Failed to load MCP tools: {}", e);
(None, None)
}
}
}
#[cfg(not(feature = "mcp"))]
{
let _ = wait_for_mcp;
let _ = external_surface_handle;
(None, None)
}
}
#[cfg(not(feature = "mcp"))]
type McpRouterAdapter = ();
/// Gracefully shutdown MCP tools (no-op when MCP is not compiled in or no adapter).
async fn shutdown_mcp(_adapter: &Option<Arc<McpRouterAdapter>>) {
#[cfg(feature = "mcp")]
if let Some(adapter) = _adapter {
adapter.shutdown().await;
}
}
/// CLI runtime executor — delegates to SessionService::start_turn() and synthesizes
/// a structural receipt for the ephemeral runtime driver contract.
/// CLI-side executor that bridges the runtime loop to the session service.
///
/// For ephemeral sessions: delegates to `SessionService::start_turn()` and fabricates
/// a placeholder receipt (no snapshot, no digest).
///
/// For persistent sessions: delegates to `PersistentSessionService::apply_runtime_turn()`
/// which exports the committed session snapshot and real receipt.
struct CliRuntimeExecutor {
service: Arc<dyn meerkat_core::service::SessionService>,
/// Persistent service reference for durable boundary commits.
/// When `Some`, `apply()` uses `apply_runtime_turn()`.
#[cfg(feature = "session-store")]
persistent_service: Option<Arc<meerkat::PersistentSessionService<FactoryAgentBuilder>>>,
session_id: meerkat_core::types::SessionId,
runtime_adapter: Arc<meerkat_runtime::MeerkatMachine>,
event_tx: Option<mpsc::Sender<EventEnvelope<AgentEvent>>>,
}
fn cli_render_context_append_text(
content: &meerkat_core::lifecycle::run_primitive::CoreRenderable,
) -> String {
use meerkat_core::lifecycle::run_primitive::CoreRenderable;
match content {
CoreRenderable::Text { text } => text.clone(),
CoreRenderable::Blocks { blocks } => meerkat_core::types::text_content(blocks),
CoreRenderable::Json { value } => {
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
}
CoreRenderable::Reference { uri, label } => match label {
Some(label) if !label.trim().is_empty() => format!("[Reference] {label} ({uri})"),
_ => format!("[Reference] {uri}"),
},
CoreRenderable::SystemNotice { kind, body, blocks } => {
meerkat_core::types::SystemNoticeMessage::with_blocks(
*kind,
body.clone(),
blocks.clone(),
)
.model_projection_text()
}
_ => String::new(),
}
}
fn cli_terminal_pre_turn_context_appends(
primitive: &meerkat_core::lifecycle::run_primitive::RunPrimitive,
) -> Vec<meerkat_core::PendingSystemContextAppend> {
use meerkat_core::lifecycle::run_primitive::RunPrimitive;
let RunPrimitive::StagedInput(staged) = primitive else {
return Vec::new();
};
if !primitive.is_peer_response_terminal_context_and_run() {
return Vec::new();
}
let accepted_at = meerkat_core::time_compat::SystemTime::now();
staged
.context_appends
.iter()
.map(|append| meerkat_core::PendingSystemContextAppend {
text: cli_render_context_append_text(&append.content),
source: Some(append.key.clone()),
idempotency_key: Some(append.key.clone()),
accepted_at,
})
.collect()
}
struct CliRuntimeBoundaryHandle {
service: Arc<dyn meerkat_core::service::SessionService>,
#[cfg(feature = "session-store")]
persistent_service: Option<Arc<meerkat::PersistentSessionService<FactoryAgentBuilder>>>,
runtime_adapter: Arc<meerkat_runtime::MeerkatMachine>,
session_id: meerkat_core::types::SessionId,
}
#[async_trait::async_trait]
impl meerkat_core::lifecycle::CoreExecutorBoundaryHandle for CliRuntimeBoundaryHandle {
async fn cancel_after_boundary(
&self,
_reason: String,
) -> Result<(), meerkat_core::lifecycle::core_executor::CoreExecutorError> {
#[cfg(feature = "session-store")]
if let Some(persistent) = self.persistent_service.as_ref() {
return persistent
.cancel_after_boundary_with_machine_authority(
&self.session_id,
self.runtime_adapter.session_control_authority(),
)
.await
.or_else(|err| match err {
meerkat::SessionError::NotRunning { .. } => Ok(()),
err => Err(err),
})
.map_err(|err| {
meerkat_core::lifecycle::core_executor::CoreExecutorError::control_failed_runtime(
err.to_string(),
)
});
}
self.service
.cancel_after_boundary(&self.session_id)
.await
.or_else(|err| match err {
meerkat::SessionError::NotRunning { .. } => Ok(()),
err => Err(err),
})
.map_err(|err| {
meerkat_core::lifecycle::core_executor::CoreExecutorError::control_failed_runtime(
err.to_string(),
)
})
}
}
struct CliRuntimeInterruptHandle {
service: Arc<dyn meerkat_core::service::SessionService>,
#[cfg(feature = "session-store")]
persistent_service: Option<Arc<meerkat::PersistentSessionService<FactoryAgentBuilder>>>,
runtime_adapter: Arc<meerkat_runtime::MeerkatMachine>,
session_id: meerkat_core::types::SessionId,
}
#[async_trait::async_trait]
impl meerkat_core::lifecycle::CoreExecutorInterruptHandle for CliRuntimeInterruptHandle {
async fn hard_cancel_current_run(
&self,
_reason: String,
) -> Result<(), meerkat_core::lifecycle::core_executor::CoreExecutorError> {
#[cfg(feature = "session-store")]
if let Some(persistent) = self.persistent_service.as_ref() {
return persistent
.interrupt_with_machine_authority(
&self.session_id,
self.runtime_adapter.session_control_authority(),
)
.await
.or_else(|err| match err {
meerkat::SessionError::NotRunning { .. } => Ok(()),
err => Err(err),
})
.map_err(|err| {
meerkat_core::lifecycle::core_executor::CoreExecutorError::control_failed_runtime(
err.to_string(),
)
});
}
self.service
.interrupt(&self.session_id)
.await
.or_else(|err| match err {
meerkat::SessionError::NotRunning { .. } => Ok(()),
err => Err(err),
})
.map_err(|err| {
meerkat_core::lifecycle::core_executor::CoreExecutorError::control_failed_runtime(
err.to_string(),
)
})
}
}
#[async_trait::async_trait]
impl meerkat_core::lifecycle::CoreExecutor for CliRuntimeExecutor {
fn boundary_handle(
&self,
) -> Option<Arc<dyn meerkat_core::lifecycle::CoreExecutorBoundaryHandle>> {
Some(Arc::new(CliRuntimeBoundaryHandle {
service: Arc::clone(&self.service),
#[cfg(feature = "session-store")]
persistent_service: self.persistent_service.clone(),
runtime_adapter: Arc::clone(&self.runtime_adapter),
session_id: self.session_id.clone(),
}))
}
fn interrupt_handle(
&self,
) -> Option<Arc<dyn meerkat_core::lifecycle::CoreExecutorInterruptHandle>> {
Some(Arc::new(CliRuntimeInterruptHandle {
service: Arc::clone(&self.service),
#[cfg(feature = "session-store")]
persistent_service: self.persistent_service.clone(),
runtime_adapter: Arc::clone(&self.runtime_adapter),
session_id: self.session_id.clone(),
}))
}
async fn apply(
&mut self,
run_id: meerkat_core::lifecycle::RunId,
primitive: meerkat_core::lifecycle::run_primitive::RunPrimitive,
) -> Result<
meerkat_core::lifecycle::core_executor::CoreApplyOutput,
meerkat_core::lifecycle::core_executor::CoreExecutorError,
> {
// Forward the primitive metadata carrier as the single runtime-authored
// source for per-turn policy.
let pre_turn_context_appends = cli_terminal_pre_turn_context_appends(&primitive);
let turn_req = StartTurnRequest {
prompt: primitive.extract_content_input(),
system_prompt: None,
event_tx: self.event_tx.clone(),
runtime: meerkat_core::service::StartTurnRuntimeSemantics::new(
None,
meerkat_core::types::HandlingMode::Queue,
primitive
.turn_metadata()
.and_then(|meta| meta.skill_references.clone()),
primitive
.turn_metadata()
.and_then(|meta| meta.flow_tool_overlay.clone()),
pre_turn_context_appends,
primitive.turn_metadata().cloned(),
)
.with_typed_turn_appends(primitive.typed_turn_appends()),
};
// Persistent path: use apply_runtime_turn for real receipt + snapshot.
#[cfg(feature = "session-store")]
if let Some(ref persistent) = self.persistent_service {
let boundary = match &primitive {
meerkat_core::lifecycle::run_primitive::RunPrimitive::StagedInput(staged) => {
staged.boundary
}
_ => meerkat_core::lifecycle::run_primitive::RunApplyBoundary::Immediate,
};
let output = persistent
.apply_runtime_turn(
&self.session_id,
run_id,
turn_req,
boundary,
primitive.contributing_input_ids().to_vec(),
)
.await
.map_err(
meerkat_core::lifecycle::core_executor::CoreExecutorError::apply_failed_from_session_error,
)?;
return Ok(output);
}
// Ephemeral path: start_turn + placeholder receipt.
let result = self
.service
.start_turn(&self.session_id, turn_req)
.await
.map_err(
meerkat_core::lifecycle::core_executor::CoreExecutorError::apply_failed_from_session_error,
)?;
Ok(
meerkat_core::lifecycle::core_executor::CoreApplyOutput::with_run_result(
meerkat_core::lifecycle::run_receipt::RunBoundaryReceipt {
run_id,
boundary: match &primitive {
meerkat_core::lifecycle::run_primitive::RunPrimitive::StagedInput(
staged,
) => staged.boundary,
_ => meerkat_core::lifecycle::run_primitive::RunApplyBoundary::Immediate,
},
contributing_input_ids: primitive.contributing_input_ids().to_vec(),
conversation_digest: None,
message_count: 0,
sequence: 0,
},
None,
result,
),
)
}
async fn cancel_after_boundary(
&mut self,
_reason: String,
) -> Result<(), meerkat_core::lifecycle::core_executor::CoreExecutorError> {
#[cfg(feature = "session-store")]
if let Some(persistent) = self.persistent_service.as_ref() {
return persistent
.cancel_after_boundary_with_machine_authority(
&self.session_id,
self.runtime_adapter.session_control_authority(),
)
.await
.map_err(|err| {
meerkat_core::lifecycle::core_executor::CoreExecutorError::control_failed_runtime(
err.to_string(),
)
});
}
self.service
.cancel_after_boundary(&self.session_id)
.await
.map_err(|err| {
meerkat_core::lifecycle::core_executor::CoreExecutorError::control_failed_runtime(
err.to_string(),
)
})
}
async fn stop_runtime_executor(
&mut self,
_reason: String,
) -> Result<(), meerkat_core::lifecycle::core_executor::CoreExecutorError> {
Ok(())
}
async fn cleanup_after_runtime_stop_terminalized(
&mut self,
) -> Result<(), meerkat_core::lifecycle::core_executor::CoreExecutorError> {
// Discard live session state via concrete type (not on SessionService trait).
#[cfg(feature = "session-store")]
if let Some(ref persistent) = self.persistent_service {
let _ = persistent.discard_live_session(&self.session_id).await;
}
self.runtime_adapter
.unregister_session(&self.session_id)
.await;
Ok(())
}
}
/// Mob-facing session service wrapper used by CLI `run`/`resume` tool calls.
///
/// Mob-managed meerkats are created through the same in-process session service as
/// the parent CLI agent. Host-mode behavior is backend-driven by mob runtime
/// requests and must not be overridden here.
#[allow(dead_code)]
#[cfg(feature = "mob")]
struct RunMobSessionService {
inner: Arc<EphemeralSessionService<FactoryAgentBuilder>>,
}
#[allow(dead_code)]
#[cfg(feature = "mob")]
impl RunMobSessionService {
fn new(inner: Arc<EphemeralSessionService<FactoryAgentBuilder>>) -> Self {
Self { inner }
}
}
#[async_trait::async_trait]
#[cfg(feature = "mob")]
impl SessionService for RunMobSessionService {
async fn create_session(
&self,
req: CreateSessionRequest,
) -> Result<meerkat_core::types::RunResult, meerkat_core::service::SessionError> {
let model = req.model.clone();
let started = std::time::Instant::now();
tracing::info!(
target: "mob_tools",
"RunMobSessionService::create_session start model={model}"
);
let out = self.inner.create_session(req).await;
match &out {
Ok(result) => tracing::info!(
target: "mob_tools",
"RunMobSessionService::create_session ok session_id={} turns={} elapsed_ms={}",
result.session_id,
result.turns,
started.elapsed().as_millis()
),
Err(err) => tracing::warn!(
target: "mob_tools",
"RunMobSessionService::create_session err elapsed_ms={} err={}",
started.elapsed().as_millis(),
err
),
}
out
}
async fn start_turn(
&self,
id: &SessionId,
req: meerkat_core::service::StartTurnRequest,
) -> Result<meerkat_core::types::RunResult, meerkat_core::service::SessionError> {
let started = std::time::Instant::now();
tracing::info!(
target: "mob_tools",
"RunMobSessionService::start_turn start session_id={} prompt_len={}",
id,
req.prompt.text_content().len()
);
let out = self.inner.start_turn(id, req).await;
match &out {
Ok(result) => tracing::info!(
target: "mob_tools",
"RunMobSessionService::start_turn ok session_id={} turns={} elapsed_ms={}",
result.session_id,
result.turns,
started.elapsed().as_millis()
),
Err(err) => tracing::warn!(
target: "mob_tools",
"RunMobSessionService::start_turn err session_id={} elapsed_ms={} err={}",
id,
started.elapsed().as_millis(),
err
),
}
out
}
async fn interrupt(&self, id: &SessionId) -> Result<(), meerkat_core::service::SessionError> {
tracing::info!(target: "mob_tools", "RunMobSessionService::interrupt session_id={id}");
Err(meerkat_core::service::SessionError::Unsupported(format!(
"interrupt for mob session {id} must route through MeerkatMachine"
)))
}
async fn read(
&self,
id: &SessionId,
) -> Result<meerkat_core::service::SessionView, meerkat_core::service::SessionError> {
self.inner.read(id).await
}
async fn list(
&self,
query: SessionQuery,
) -> Result<Vec<meerkat_core::service::SessionSummary>, meerkat_core::service::SessionError>
{
self.inner.list(query).await
}
async fn archive(&self, id: &SessionId) -> Result<(), meerkat_core::service::SessionError> {
self.inner.archive(id).await
}
}
#[async_trait::async_trait]
#[cfg(feature = "mob")]
impl SessionServiceCommsExt for RunMobSessionService {
async fn comms_runtime(
&self,
session_id: &SessionId,
) -> Option<Arc<dyn meerkat_core::agent::CommsRuntime>> {
self.inner.comms_runtime(session_id).await
}
async fn event_injector(
&self,
session_id: &SessionId,
) -> Option<Arc<dyn meerkat_core::EventInjector>> {
self.inner.event_injector(session_id).await
}
}
#[async_trait::async_trait]
#[cfg(feature = "mob")]
impl meerkat_core::service::SessionServiceControlExt for RunMobSessionService {
async fn append_system_context(
&self,
id: &SessionId,
req: meerkat_core::AppendSystemContextRequest,
) -> Result<
meerkat_core::service::AppendSystemContextResult,
meerkat_core::service::SessionControlError,
> {
self.inner.append_system_context(id, req).await
}
}
#[async_trait::async_trait]
#[cfg(feature = "mob")]
impl meerkat_core::service::SessionServiceHistoryExt for RunMobSessionService {
async fn read_history(
&self,
id: &SessionId,
query: meerkat_core::service::SessionHistoryQuery,
) -> Result<meerkat_core::service::SessionHistoryPage, meerkat_core::service::SessionError>
{
self.inner.read_history(id, query).await
}
}
#[async_trait::async_trait]
#[cfg(feature = "mob")]
impl meerkat_mob::MobSessionService for RunMobSessionService {
async fn subscribe_session_events(
&self,
session_id: &SessionId,
) -> Result<meerkat_core::comms::EventStream, meerkat_core::comms::StreamError> {
let runtime = self.inner.comms_runtime(session_id).await.ok_or_else(|| {
meerkat_core::comms::StreamError::NotFound(format!("session {session_id}"))
})?;
runtime.stream(meerkat_core::comms::StreamScope::Session(
session_id.clone(),
))
}
fn supports_persistent_sessions(&self) -> bool {
// CLI run/resume keeps sessions in-memory, but this path still satisfies
// the mob runtime contract for a single process execution.
true
}
fn runtime_adapter(&self) -> Option<Arc<meerkat_runtime::MeerkatMachine>> {
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::runtime_adapter(
&self.inner,
)
}
async fn interrupt_with_machine_authority(
&self,
session_id: &SessionId,
authority: meerkat_runtime::MachineSessionControlAuthority,
) -> Result<(), meerkat_core::service::SessionError> {
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::interrupt_with_machine_authority(
&self.inner,
session_id,
authority,
)
.await
}
async fn cancel_after_boundary_with_machine_authority(
&self,
session_id: &SessionId,
authority: meerkat_runtime::MachineSessionControlAuthority,
) -> Result<(), meerkat_core::service::SessionError> {
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::cancel_after_boundary_with_machine_authority(
&self.inner,
session_id,
authority,
)
.await
}
async fn archive_with_mob_lifecycle_authority(
&self,
session_id: &SessionId,
) -> Result<(), meerkat_core::service::SessionError> {
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::archive_with_mob_lifecycle_authority(
&self.inner,
session_id,
)
.await
}
async fn execution_snapshot(
&self,
session_id: &SessionId,
) -> Result<Option<meerkat_core::AgentExecutionSnapshot>, meerkat_core::service::SessionError>
{
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::execution_snapshot(
&self.inner,
session_id,
)
.await
}
async fn tool_scope_snapshot(
&self,
session_id: &SessionId,
) -> Result<Option<meerkat_core::ToolScopeSnapshot>, meerkat_core::service::SessionError> {
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::tool_scope_snapshot(
&self.inner,
session_id,
)
.await
}
async fn external_tool_surface_snapshot(
&self,
session_id: &SessionId,
) -> Result<
Option<meerkat_core::ExternalToolSurfaceSnapshot>,
meerkat_core::service::SessionError,
> {
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::external_tool_surface_snapshot(
&self.inner,
session_id,
)
.await
}
async fn peer_ingress_runtime_snapshot(
&self,
session_id: &SessionId,
) -> Result<Option<meerkat_core::PeerIngressRuntimeSnapshot>, meerkat_core::service::SessionError>
{
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::peer_ingress_runtime_snapshot(
&self.inner,
session_id,
)
.await
}
async fn apply_runtime_turn(
&self,
session_id: &SessionId,
run_id: meerkat_core::RunId,
req: meerkat_core::service::StartTurnRequest,
boundary: meerkat_core::lifecycle::run_primitive::RunApplyBoundary,
contributing_input_ids: Vec<meerkat_core::InputId>,
) -> Result<
meerkat_core::lifecycle::core_executor::CoreApplyOutput,
meerkat_core::service::SessionError,
> {
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::apply_runtime_turn(
&self.inner,
session_id,
run_id,
req,
boundary,
contributing_input_ids,
)
.await
}
async fn apply_runtime_context_appends(
&self,
session_id: &SessionId,
run_id: meerkat_core::RunId,
appends: Vec<meerkat_core::PendingSystemContextAppend>,
contributing_input_ids: Vec<meerkat_core::InputId>,
) -> Result<
meerkat_core::lifecycle::core_executor::CoreApplyOutput,
meerkat_core::service::SessionError,
> {
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::apply_runtime_context_appends(
&self.inner,
session_id,
run_id,
appends,
contributing_input_ids,
)
.await
}
async fn apply_runtime_context_appends_with_boundary(
&self,
session_id: &SessionId,
run_id: meerkat_core::RunId,
appends: Vec<meerkat_core::PendingSystemContextAppend>,
boundary: meerkat_core::lifecycle::run_primitive::RunApplyBoundary,
contributing_input_ids: Vec<meerkat_core::InputId>,
) -> Result<
meerkat_core::lifecycle::core_executor::CoreApplyOutput,
meerkat_core::service::SessionError,
> {
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::apply_runtime_context_appends_with_boundary(
&self.inner,
session_id,
run_id,
appends,
boundary,
contributing_input_ids,
)
.await
}
async fn apply_runtime_system_context_for_turn(
&self,
session_id: &SessionId,
appends: Vec<meerkat_core::PendingSystemContextAppend>,
) -> Result<(), meerkat_core::service::SessionError> {
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::apply_runtime_system_context_for_turn(
&self.inner,
session_id,
appends,
)
.await
}
async fn discard_live_session(
&self,
session_id: &SessionId,
) -> Result<(), meerkat_core::service::SessionError> {
<EphemeralSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::discard_live_session(
&self.inner,
session_id,
)
.await
}
async fn session_belongs_to_mob(
&self,
_session_id: &SessionId,
_mob_id: &meerkat_mob::MobId,
) -> bool {
false
}
}
#[cfg(feature = "mob")]
struct RunMobToolsContext {
state: Arc<meerkat_mob_mcp::MobMcpState>,
known_mob_ids: std::collections::BTreeSet<String>,
}
#[cfg(feature = "mob")]
impl RunMobToolsContext {
#[cfg(test)]
fn dispatcher(&self) -> Arc<dyn AgentToolDispatcher> {
Arc::new(meerkat_mob_mcp::MobMcpDispatcher::new(self.state.clone()))
}
async fn persist(&mut self, scope: &RuntimeScope) -> anyhow::Result<()> {
let _lock = acquire_mob_registry_lock(scope).await?;
let mut registry = load_mob_registry(scope).await?;
let active = self.state.mob_list().await;
let active_ids: std::collections::BTreeSet<String> =
active.iter().map(|(id, _)| id.to_string()).collect();
for (mob_id, status) in active {
let mob_id = mob_id.to_string();
registry
.mobs
.entry(mob_id.clone())
.or_insert_with(|| PersistedMob {
definition: None,
status: Some(status.as_str().to_string()),
events: Vec::new(),
runs: std::collections::BTreeMap::new(),
});
sync_mob_events(self.state.as_ref(), &mut registry, &mob_id).await?;
}
// Remove entries that disappeared from this context's previously known set.
// This avoids dropping mobs created by other concurrent CLI processes.
for mob_id in &self.known_mob_ids {
if !active_ids.contains(mob_id) {
registry.mobs.remove(mob_id);
}
}
save_mob_registry(scope, ®istry).await?;
self.known_mob_ids = active_ids;
Ok(())
}
}
#[cfg(feature = "mob")]
async fn prepare_run_mob_tools(
scope: &RuntimeScope,
session_service: Arc<dyn meerkat_mob::MobSessionService>,
) -> anyhow::Result<RunMobToolsContext> {
let _lock = acquire_mob_registry_lock(scope).await?;
let (state, registry) = hydrate_mob_state(
scope,
session_service,
None,
None,
None,
std::collections::BTreeMap::new(),
)
.await?;
let known_mob_ids = registry.mobs.keys().cloned().collect();
Ok(RunMobToolsContext {
state,
known_mob_ids,
})
}
#[cfg(all(feature = "mob", feature = "session-store"))]
async fn prepare_run_mob_tools_from_surface(
scope: &RuntimeScope,
surface: Arc<CliPersistentSurfaceState>,
) -> anyhow::Result<RunMobToolsContext> {
let _lock = acquire_mob_registry_lock(scope).await?;
let state = hydrate_cli_mob_state_cached(
scope,
Arc::clone(&surface.service),
Arc::clone(&surface.runtime_adapter),
Arc::clone(&surface.mob_state_cache),
)
.await?;
let registry = load_mob_registry(scope).await?;
let known_mob_ids = registry.mobs.keys().cloned().collect();
Ok(RunMobToolsContext {
state,
known_mob_ids,
})
}
fn compose_external_tool_dispatchers(
primary: Option<Arc<dyn AgentToolDispatcher>>,
secondary: Option<Arc<dyn AgentToolDispatcher>>,
) -> anyhow::Result<Option<Arc<dyn AgentToolDispatcher>>> {
use std::collections::HashSet;
match (primary, secondary) {
(None, None) => Ok(None),
(Some(dispatcher), None) | (None, Some(dispatcher)) => Ok(Some(dispatcher)),
(Some(a), Some(b)) => {
let primary_names: HashSet<String> =
a.tools().iter().map(|t| t.name.to_string()).collect();
let secondary_tools = b.tools();
let secondary_unique: Vec<String> = secondary_tools
.iter()
.map(|t| t.name.to_string())
.filter(|name| !primary_names.contains(name))
.collect();
if secondary_unique.is_empty() {
return Ok(Some(a));
}
let secondary: Arc<dyn AgentToolDispatcher> =
if secondary_unique.len() == secondary_tools.len() {
b
} else {
Arc::new(meerkat_core::FilteredToolDispatcher::new(
b,
secondary_unique,
))
};
let gateway = meerkat_core::ToolGatewayBuilder::new()
.add_dispatcher(a)
.add_dispatcher(secondary)
.build()
.map_err(|e| anyhow::anyhow!("failed to compose external tools: {e}"))?;
Ok(Some(Arc::new(gateway)))
}
}
}
fn compose_rpc_mob_external_tools(
callback_tools: Arc<dyn AgentToolDispatcher>,
mcp_tools: Option<Arc<dyn AgentToolDispatcher>>,
) -> Option<Arc<dyn AgentToolDispatcher>> {
match compose_external_tool_dispatchers(Some(callback_tools.clone()), mcp_tools) {
Ok(Some(dispatcher)) => Some(dispatcher),
Ok(None) => Some(callback_tools),
Err(error) => {
tracing::warn!(error = %error, "failed to compose RPC mob member external tools");
Some(callback_tools)
}
}
}
/// Build an `EphemeralSessionService` backed by the factory.
#[cfg(test)]
#[derive(Default)]
struct CliServiceBuildDefaults {
default_schedule_tools: Option<Arc<dyn AgentToolDispatcher>>,
image_generation_machine: Option<Arc<meerkat_runtime::MeerkatMachine>>,
default_blob_store: Option<Arc<dyn meerkat_core::BlobStore>>,
default_image_generation_executor: Option<Arc<dyn meerkat_llm_core::ImageGenerationExecutor>>,
}
#[cfg(test)]
fn build_cli_service_with_defaults(
factory: AgentFactory,
config: Config,
defaults: CliServiceBuildDefaults,
) -> EphemeralSessionService<FactoryAgentBuilder> {
let max_sessions = config.max_sessions();
let mut builder = FactoryAgentBuilder::new(factory, config);
if let Some(machine) = defaults.image_generation_machine {
builder = builder.with_image_generation_machine(machine);
}
builder.default_blob_store = defaults.default_blob_store;
builder.default_image_generation_executor = defaults.default_image_generation_executor;
meerkat::surface::set_default_schedule_tools(&builder, defaults.default_schedule_tools);
meerkat::surface::build_embedded_service_from_builder(builder, max_sessions)
}
#[cfg(feature = "session-store")]
fn build_cli_runtime_backed_service_with_defaults(
factory: AgentFactory,
config: Config,
persistence: PersistenceBundle,
default_schedule_tools: Option<Arc<dyn AgentToolDispatcher>>,
) -> (
meerkat::PersistentSessionService<FactoryAgentBuilder>,
Arc<meerkat_runtime::MeerkatMachine>,
) {
let max_sessions = config.max_sessions();
let builder = FactoryAgentBuilder::new(factory, config);
meerkat::surface::set_default_schedule_tools(&builder, default_schedule_tools);
let (service, runtime_adapter) =
meerkat::surface::build_runtime_backed_service(builder, max_sessions, persistence);
(service, runtime_adapter)
}
#[cfg(test)]
fn build_cli_service(
factory: AgentFactory,
config: Config,
default_schedule_tools: Option<Arc<dyn AgentToolDispatcher>>,
) -> EphemeralSessionService<FactoryAgentBuilder> {
build_cli_service_with_defaults(
factory,
config,
CliServiceBuildDefaults {
default_schedule_tools,
..Default::default()
},
)
}
#[cfg(all(feature = "mob", feature = "session-store"))]
async fn build_deploy_mob_session_service(
scope: &RuntimeScope,
config: Config,
) -> anyhow::Result<Arc<dyn meerkat_mob::MobSessionService>> {
let (manifest, persistence) = create_persistence_bundle(scope).await?;
let surface =
get_or_create_cli_persistent_surface_from_bundle(scope, config, manifest, persistence)
.await?;
Ok(Arc::new(MobCliSessionService::new(Arc::clone(
&surface.service,
))))
}
fn session_err_to_anyhow(e: meerkat_core::service::SessionError) -> anyhow::Error {
match e {
meerkat_core::service::SessionError::Agent(agent_err) => anyhow::Error::from(agent_err),
other => anyhow::anyhow!("Session service error: {other}"),
}
}
fn resolve_scoped_session_id(input: &str, scope: &RuntimeScope) -> anyhow::Result<SessionId> {
SessionLocator::resolve_for_realm(input, &scope.locator.realm).map_err(|err| match err {
SessionLocatorError::RealmMismatch { provided, active } => anyhow::anyhow!(
"Session belongs to realm '{provided}', but active realm is '{active}'. Use --realm {provided} or switch to the matching realm before running session commands."
),
other => anyhow::anyhow!("Invalid session locator '{input}': {other}"),
})
}
/// Resolve a session identifier that may be a full UUID, a short handle,
/// or a relative alias (`last`, `~N`).
async fn resolve_flexible_session_id(
input: &str,
scope: &RuntimeScope,
config: &Config,
) -> anyhow::Result<SessionId> {
// Try relative aliases first.
let offset = match input {
"last" | "~" | "~0" => Some(0usize),
s if s.starts_with('~') => {
let n = s[1..].parse::<usize>().map_err(|_| {
anyhow::anyhow!(
"Invalid relative offset '{input}': expected ~N where N is a number"
)
})?;
Some(n)
}
_ => None,
};
if let Some(offset) = offset {
#[cfg(feature = "session-store")]
{
let (service, _runtime_adapter) =
build_cli_persistent_service(scope, config.clone()).await?;
let sessions = service
.list(SessionQuery {
limit: Some(offset + 1),
offset: None,
labels: None,
})
.await
.map_err(|e| anyhow::anyhow!("Failed to list sessions: {e}"))?;
return sessions
.get(offset)
.map(|s| s.session_id.clone())
.ok_or_else(|| {
if offset == 0 {
anyhow::anyhow!("No sessions found in this realm")
} else {
anyhow::anyhow!(
"Only {} sessions exist; ~{} is out of range",
sessions.len(),
offset
)
}
});
}
#[cfg(not(feature = "session-store"))]
{
let _ = (scope, config);
anyhow::bail!("session aliases require rkat built with session-store support");
}
}
// Try exact/locator resolution first. Preserve the error for actionable
// diagnostics (e.g. realm mismatch guidance).
let locator_err = match resolve_scoped_session_id(input, scope) {
Ok(sid) => return Ok(sid),
Err(e) => e,
};
// Only fall through to short-handle matching for bare inputs (no colon =
// not a realm-scoped locator).
if input.contains(':') {
return Err(locator_err);
}
// Try short handle match against all sessions (no limit).
#[cfg(feature = "session-store")]
{
let (service, _runtime_adapter) =
build_cli_persistent_service(scope, config.clone()).await?;
let sessions = service
.list(SessionQuery {
limit: None,
offset: None,
labels: None,
})
.await
.map_err(|e| anyhow::anyhow!("Failed to list sessions: {e}"))?;
let matches = find_session_matches_in_summaries(input, &sessions);
match matches.len() {
0 => Err(anyhow::anyhow!("No session matching '{input}'")),
1 => Ok(matches[0].session_id.clone()),
_ => Err(anyhow::anyhow!(
"{}",
format_ambiguous_session_match_error(input, &matches, scope)
)),
}
}
#[cfg(not(feature = "session-store"))]
{
let _ = config;
Err(locator_err)
}
}
fn find_session_matches_in_summaries<'a>(
input: &str,
sessions: &'a [SessionSummary],
) -> Vec<&'a SessionSummary> {
sessions
.iter()
.filter(|s| {
let id = s.session_id.to_string();
id.starts_with(input) || id.ends_with(input)
})
.collect()
}
fn format_ambiguous_session_match_error(
input: &str,
matches: &[&SessionSummary],
scope: &RuntimeScope,
) -> String {
let mut message = format!(
"Ambiguous session handle '{input}': matches {} sessions. Use `last`, `~N`, a full session id, or a session ref.",
matches.len()
);
let shown = matches.len().min(5);
for summary in matches.iter().take(shown) {
message.push_str(&format!(
"\n - {} (handle {})",
format_session_ref(&scope.locator.realm, &summary.session_id),
short_session_id(&summary.session_id)
));
}
if matches.len() > shown {
message.push_str(&format!("\n ... and {} more", matches.len() - shown));
}
message
}
/// Format a short 8-character session handle for display.
///
/// Session IDs are UUID v7, so their leading characters are time-derived and
/// collide for sessions created near each other. The tail carries more entropy
/// and is therefore a better copy/paste handle for `run --resume`.
fn short_session_id(sid: &SessionId) -> String {
let s = sid.to_string();
let len = s.len();
s[len.saturating_sub(8)..].to_string()
}
fn build_flow_tool_overlay(
allow_tools: Vec<String>,
block_tools: Vec<String>,
) -> Option<TurnToolOverlay> {
if allow_tools.is_empty() && block_tools.is_empty() {
return None;
}
Some(TurnToolOverlay {
allowed_tools: if allow_tools.is_empty() {
None
} else {
Some(allow_tools)
},
blocked_tools: if block_tools.is_empty() {
None
} else {
Some(block_tools)
},
})
}
#[allow(clippy::too_many_arguments)]
async fn run_agent(
prompt: &str,
system_prompt: Option<String>,
model: &str,
provider: Provider,
build_provider_override: Option<Provider>,
model_was_explicit: bool,
provider_was_explicit: bool,
max_tokens: u32,
limits: BudgetLimits,
output: CliOutputSelection,
stream: bool,
stream_policy: Option<stream_renderer::StreamRenderPolicy>,
provider_params: Option<serde_json::Value>,
output_schema: Option<OutputSchema>,
structured_output_retries: u32,
comms_overrides: CommsOverrides,
enable_builtins: bool,
enable_shell: bool,
enable_memory: bool,
enable_workgraph: bool,
enable_mob: bool,
wait_for_mcp: bool,
verbose: bool,
keep_alive: bool,
stdin_events: bool,
line_format: LineFormat,
config: &Config,
preload_skills: Vec<String>,
allow_tools: Vec<String>,
block_tools: Vec<String>,
labels: Vec<(String, String)>,
instructions: Vec<String>,
app_context: Option<String>,
config_base_dir: PathBuf,
hooks_override: HookRunOverrides,
auth_binding: Option<AuthBindingRef>,
scope: &RuntimeScope,
) -> anyhow::Result<()> {
#[cfg(not(feature = "session-store"))]
{
let _ = (
prompt,
system_prompt,
model,
provider,
build_provider_override,
model_was_explicit,
provider_was_explicit,
max_tokens,
limits,
output,
stream,
stream_policy,
provider_params,
output_schema,
structured_output_retries,
comms_overrides,
enable_builtins,
enable_shell,
enable_memory,
enable_workgraph,
enable_mob,
wait_for_mcp,
verbose,
keep_alive,
stdin_events,
line_format,
config,
preload_skills,
allow_tools,
block_tools,
labels,
instructions,
app_context,
config_base_dir,
hooks_override,
auth_binding,
scope,
);
anyhow::bail!("rkat built without session-store support");
}
#[cfg(feature = "session-store")]
{
let keep_alive = resolve_keep_alive(keep_alive)?;
let effective_workgraph = enable_workgraph || config.tools.workgraph_enabled;
let effective_mob = cfg!(feature = "mob") && (enable_mob || config.tools.mob_enabled);
let flow_tool_overlay = build_flow_tool_overlay(allow_tools, block_tools);
// Wave-c C-12: the canonical runtime identity for a skill is
// `SkillKey { source_uuid, skill_name }` (C-1 / C-4 upstream retype).
// CLI `--skill NAME` arguments default to the builtin (inventory)
// source — explicit source-scoped selection is not a CLI surface
// today and would be a separate feature. `SkillName::parse` enforces
// the lowercase-slug rule; we surface parse errors directly to the
// user rather than silently dropping.
let preload_skills = if preload_skills.is_empty() {
None
} else {
let keys: Result<Vec<meerkat_core::skills::SkillKey>, _> = preload_skills
.into_iter()
.map(|raw| {
meerkat_core::skills::SkillName::parse(&raw)
.map(meerkat_core::skills::SkillKey::builtin)
.map_err(|e| anyhow::anyhow!("invalid --skill value `{raw}`: {e}"))
})
.collect();
Some(keys?)
};
let session = Session::new();
let session_id = session.id().clone();
let primary_scope_path = vec![StreamScopeFrame::Primary {
session_id: session_id.to_string(),
}];
// Resolve comms_name for the factory.
// When keep_alive is requested and no explicit name is provided, derive one
// from the session_id so the factory's comms_name requirement is satisfied.
let comms_name = if cfg!(feature = "comms") && !comms_overrides.disabled {
comms_overrides
.name
.clone()
.or_else(|| keep_alive.then(|| format!("cli/{session_id}")))
} else {
None
};
// Build factory with appropriate flags
let project_root = scope.context_root.clone().unwrap_or_else(|| {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
find_project_root(&cwd).unwrap_or(cwd)
});
let (manifest, persistence) = create_persistence_bundle(scope).await?;
let session_store = persistence.session_store();
let mut factory = AgentFactory::new(realm_store_path(&manifest, scope))
.session_store(session_store)
.runtime_root(
meerkat_store::realm_paths_in(
&scope.locator.state_root,
scope.locator.realm.as_str(),
)
.root,
)
.project_root(project_root)
.builtins(enable_builtins)
.shell(enable_shell)
.workgraph(effective_workgraph)
.schedule(config.tools.schedule_enabled);
if let Some(context_root) = scope.context_root.clone() {
factory = factory.context_root(context_root);
}
if let Some(user_root) = scope.user_config_root.clone() {
factory = factory.user_config_root(user_root);
}
#[cfg(feature = "comms")]
let factory = factory.comms(!comms_overrides.disabled);
let context_root = scope
.context_root
.as_deref()
.map_or_else(|| "(none)".to_string(), |path| path.display().to_string());
tracing::info!(
"Using realm: {}, context root: {}, realm root: {}",
scope.locator.realm.as_str(),
context_root,
config_base_dir.display()
);
tracing::info!("Using provider: {:?}, model: {}", provider, model);
// Apply --comms-listen-tcp override to the config
#[allow(unused_mut)]
let mut config = config.clone();
#[cfg(feature = "comms")]
if let Some(ref addr) = comms_overrides.listen_tcp {
config.comms.mode = CommsRuntimeMode::Tcp;
config.comms.address = Some(addr.clone());
}
// Build the parent session service on the runtime-backed persistent
// surface. CLI one-shots must leave the runtime snapshot as durable
// authority so shared RPC/REST realms can read them without trusting
// the compatibility SessionStore projection.
let schedule_service = ScheduleService::new(persistence.schedule_store());
let default_schedule_tools =
Some(Arc::new(ScheduleToolDispatcher::new(schedule_service))
as Arc<dyn AgentToolDispatcher>);
let (service, runtime_adapter) = build_cli_runtime_backed_service_with_defaults(
factory,
config.clone(),
persistence.clone(),
default_schedule_tools,
);
if keep_alive {
eprintln!(
"Running in keep-alive mode{} (Ctrl+C to exit)...",
if verbose { " with verbose output" } else { "" }
);
}
// Wrap in Arc so we can share with the stdin reader task
let service = Arc::new(service);
#[cfg(feature = "mob")]
let mut run_mob_tools = if effective_mob {
let mob_surface = get_or_create_cli_persistent_surface_from_bundle(
scope,
config.clone(),
manifest.clone(),
persistence.clone(),
)
.await?;
Some(prepare_run_mob_tools_from_surface(scope, mob_surface).await?)
} else {
None
};
#[cfg(not(feature = "mob"))]
let mut run_mob_tools: Option<()> = None;
// Prepare epoch-local bindings before MCP startup so the router stages
// and applies external-tool surface lifecycle directly on the
// session-owned MeerkatMachine handle.
let bindings = runtime_adapter
.prepare_bindings(session_id.clone())
.await
.map_err(|e| anyhow::anyhow!("runtime bindings: {e}"))?;
// Load optional MCP tools immediately before external tool composition so
// later early-return windows cannot skip adapter shutdown.
let (mcp_external_tools, mcp_adapter) = load_mcp_external_tools(
scope,
wait_for_mcp,
Some(Arc::clone(bindings.external_tool_surface())),
)
.await;
// Mob tools now flow through mob_tools (factory pattern), not external_tools.
// Only MCP tools remain as external_tools.
let external_tools = mcp_external_tools;
#[cfg(feature = "mob")]
let mob_tools_factory: Option<Arc<dyn meerkat_core::service::MobToolsFactory>> =
run_mob_tools.as_ref().map(|ctx| {
Arc::new(meerkat_mob_mcp::AgentMobToolSurfaceFactory::new(
Arc::clone(&ctx.state),
)) as Arc<dyn meerkat_core::service::MobToolsFactory>
});
#[cfg(not(feature = "mob"))]
let mob_tools_factory: Option<Arc<dyn meerkat_core::service::MobToolsFactory>> = None;
let parsed_app_context = app_context
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|e| anyhow::anyhow!("Invalid --app-context JSON: {e}"))?;
let output_pipeline =
CliOutputPipeline::new(stream, verbose, stream_policy.clone(), primary_scope_path)?;
let mut build = SessionBuildOptions {
provider: build_provider_override.map(Provider::as_core),
self_hosted_server_id: None,
output_schema,
structured_output_retries,
hooks_override,
comms_name: comms_name.clone(),
peer_meta: comms_overrides.peer_meta.clone(),
resume_session: Some(session),
budget_limits: Some(limits),
provider_params,
external_tools,
recoverable_tool_defs: None,
llm_client_override: None,
agent_llm_client_decorator: None,
override_builtins: meerkat_core::ToolCategoryOverride::from_effective(enable_builtins),
override_shell: meerkat_core::ToolCategoryOverride::from_effective(enable_shell),
override_memory: meerkat_core::ToolCategoryOverride::from_effective(enable_memory),
override_schedule: meerkat_core::ToolCategoryOverride::Inherit,
override_workgraph: meerkat_core::ToolCategoryOverride::from_effective(
effective_workgraph,
),
override_mob: meerkat_core::ToolCategoryOverride::Inherit,
override_image_generation: meerkat_core::ToolCategoryOverride::Inherit,
override_web_search: meerkat_core::ToolCategoryOverride::Inherit,
schedule_tools: None,
workgraph_tools: None,
mob_tool_authority_context: None,
preload_skills,
realm_id: Some(scope.locator.realm.as_str().to_owned()),
instance_id: scope.instance_id.clone(),
backend: Some(manifest.backend.as_str().to_string()),
config_generation: None,
keep_alive,
checkpointer: None,
silent_comms_intents: Vec::new(),
max_inline_peer_notifications: None,
app_context: parsed_app_context,
additional_instructions: if instructions.is_empty() {
None
} else {
Some(instructions)
},
initial_metadata_entries: std::collections::BTreeMap::new(),
shell_env: None,
runtime_build_mode: meerkat_core::RuntimeBuildMode::SessionOwned(bindings),
initial_turn_metadata: None,
resume_override_mask: meerkat_core::service::ResumeOverrideMask {
model: model_was_explicit,
provider: provider_was_explicit,
auth_binding: auth_binding.is_some(),
..Default::default()
},
call_timeout_override: Default::default(),
blob_store_override: None,
mob_tools: mob_tools_factory,
auth_binding,
};
build.apply_generated_create_only_mob_operator_access(
meerkat_core::ToolCategoryOverride::from_effective(effective_mob),
);
let parsed_labels = if labels.is_empty() {
None
} else {
Some(std::collections::BTreeMap::from_iter(labels))
};
// Reject reserved mob labels.
meerkat::surface::validate_raw_labels(parsed_labels.as_ref())
.map_err(|e| anyhow::anyhow!(e))?;
// Route through SessionService::create_session()
let create_req = CreateSessionRequest {
model: model.to_string(),
prompt: prompt.to_string().into(),
render_metadata: None,
system_prompt,
max_tokens: Some(max_tokens),
event_tx: output_pipeline.event_sender(),
skill_references: None,
// Always defer — the runtime adapter handles execution.
initial_turn: meerkat_core::service::InitialTurnPolicy::Defer,
deferred_prompt_policy: DeferredPromptPolicy::Discard,
build: Some(build),
labels: parsed_labels,
};
// Warn if --stdin is used without keep-alive (it has no effect)
#[cfg(feature = "comms")]
if stdin_events && !keep_alive {
eprintln!("Warning: --stdin has no effect without keep-alive mode");
}
// `create_session` always defers the initial turn in this path, so we can
// register the runtime executor and start stdin admission before the first
// prompt enters the runtime.
#[cfg(feature = "comms")]
let mut stdin_reader_handle: Option<tokio::task::JoinHandle<()>> = None;
let turn_result = async {
#[cfg(feature = "comms")]
let create_result = service
.create_session(create_req)
.await
.map_err(session_err_to_anyhow)?;
#[cfg(not(feature = "comms"))]
let create_result = {
let _ = stdin_events;
service
.create_session(create_req)
.await
.map_err(session_err_to_anyhow)?
};
// Register executor and route turn through runtime adapter.
let session_id = create_result.session_id.clone();
let executor = Box::new(CliRuntimeExecutor {
service: service.clone() as Arc<dyn meerkat_core::service::SessionService>,
#[cfg(feature = "session-store")]
persistent_service: Some(service.clone()),
session_id: session_id.clone(),
runtime_adapter: runtime_adapter.clone(),
event_tx: output_pipeline.event_sender(),
});
runtime_adapter
.register_session_with_executor(session_id.clone(), executor)
.await;
#[cfg(feature = "comms")]
if stdin_events && keep_alive {
stdin_reader_handle = Some(stdin_events::spawn_stdin_reader(
runtime_adapter.clone(),
session_id.clone(),
match line_format {
LineFormat::Text => stdin_events::StdinLineFormat::Text,
LineFormat::Json => stdin_events::StdinLineFormat::Json,
},
));
}
// Post-wave-a: `keep_alive` is now a typed `KeepAlivePolicy`
// (ttl + mode), not a boolean. The CLI `--keep-alive` flag still
// carries the session-level intent via `update_peer_ingress_context`
// below; this per-turn overlay is not the seam that enables it.
// Until the CLI exposes ttl/mode surface, leave the per-turn
// metadata atom unset.
let _ = keep_alive;
let input = meerkat_runtime::Input::Prompt(meerkat_runtime::PromptInput::new(
prompt.to_string(),
Some(
meerkat_core::lifecycle::run_primitive::RuntimeTurnMetadata {
keep_alive: None,
skill_references: None,
flow_tool_overlay,
additional_instructions: None,
..Default::default()
},
),
));
let (_outcome, handle) = runtime_adapter
.accept_input_with_completion(&session_id, input)
.await
.map_err(|err| anyhow::anyhow!("runtime accept failed: {err}"))?;
// Spawn the comms drain in keep-alive mode so inbound peer interactions are
// routed through the runtime adapter and automatically trigger new turns.
#[cfg(feature = "comms")]
{
let comms_rt = service.comms_runtime(&session_id).await;
runtime_adapter
.update_peer_ingress_context(&session_id, keep_alive, comms_rt)
.await;
}
match handle {
Some(handle) => completion_outcome_to_cli_runtime_turn_result(
handle.wait().await,
&session_id,
&scope.locator.realm,
true,
),
None => {
eprintln!("Warning: duplicate input — already processed");
Ok(CliRuntimeTurnResult::Completed(create_result))
}
}
}
.await;
// In keep-alive mode, block until Ctrl+C after the initial turn completes.
// The runtime adapter, comms drain, and detached wake will inject new turns
// automatically. Without this, the process exits after the first turn.
if keep_alive && matches!(&turn_result, Ok(CliRuntimeTurnResult::Completed(_))) {
eprintln!("Keep-alive: initial turn complete, waiting for events (Ctrl+C to exit)...");
// Block until SIGINT/SIGTERM. The runtime loop, comms drain, and
// detached wake tasks continue running in background tokio tasks.
tokio::signal::ctrl_c()
.await
.map_err(|e| anyhow::anyhow!("signal wait failed: {e}"))?;
eprintln!("\nShutting down...");
}
let result = Box::pin(finalize_cli_runtime_backed_turn(
output_pipeline,
turn_result,
async {
// Abort the comms drain so the CLI can exit cleanly.
#[cfg(feature = "comms")]
{
runtime_adapter.abort_comms_drain(&session_id).await;
}
// Abort stdin reader if it was running.
#[cfg(feature = "comms")]
if let Some(h) = stdin_reader_handle {
h.abort();
}
// Shutdown the session service and MCP connections gracefully.
// Unregister the runtime-backed executor before awaiting stream tasks.
// The adapter owns the boxed executor, and the executor now holds the
// caller stream sender for runtime-backed turns.
runtime_adapter.unregister_session(&session_id).await;
service.shutdown().await;
shutdown_mcp(&mcp_adapter).await;
#[cfg(feature = "mob")]
if let Some(ref mut mob_ctx) = run_mob_tools {
mob_ctx.persist(scope).await?;
}
Ok(())
},
))
.await?;
// Output the result
match result {
CliRuntimeTurnResult::Completed(result) => {
print_completed_run_result(result, &output, stream, scope, false).await?;
}
CliRuntimeTurnResult::CallbackPending(pending) => {
print_cli_callback_pending(&pending, Some(output.format.as_str()))?;
}
}
Ok(())
}
}
#[allow(clippy::too_many_arguments, clippy::large_futures)]
async fn resume_session(
session_id: &str,
mut prompt: String,
system_prompt: Option<String>,
model: Option<String>,
provider: Option<Provider>,
max_tokens: Option<u32>,
output_schema: Option<String>,
skills: Vec<String>,
allow_tools: Vec<String>,
block_tools: Vec<String>,
labels: Vec<(String, String)>,
instructions: Vec<String>,
app_context: Option<String>,
max_duration: Option<String>,
max_tool_calls: Option<usize>,
output: CliOutputSelection,
params: Vec<String>,
provider_params_json: Option<String>,
no_web_search: bool,
stream: bool,
no_stream: bool,
stdin: StdinMode,
line_format: LineFormat,
auth_binding: Option<AuthBindingRef>,
scope: &RuntimeScope,
verbose: bool,
wait_for_mcp: bool,
tools: Option<ToolPreset>,
yolo: bool,
keep_alive: bool,
) -> anyhow::Result<()> {
let stdin = resolve_stdin_mode(stdin);
if matches!(stdin, StdinMode::Blob | StdinMode::Auto) {
prompt = prepend_stdin_blob_context(prompt);
}
resume_session_with_llm_override(
session_id,
&prompt,
system_prompt,
model,
provider,
max_tokens,
output_schema,
HookRunOverrides::default(),
skills,
allow_tools,
block_tools,
labels,
instructions,
app_context,
max_duration,
max_tool_calls,
output,
params,
provider_params_json,
no_web_search,
stream,
no_stream,
stdin,
line_format,
auth_binding,
scope,
None,
verbose,
wait_for_mcp,
tools,
yolo,
keep_alive,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn resume_session_with_llm_override(
session_id: &str,
prompt: &str,
system_prompt: Option<String>,
model: Option<String>,
provider: Option<Provider>,
max_tokens: Option<u32>,
output_schema: Option<String>,
hooks_override: HookRunOverrides,
skills: Vec<String>,
allow_tools: Vec<String>,
block_tools: Vec<String>,
labels: Vec<(String, String)>,
instructions: Vec<String>,
app_context: Option<String>,
max_duration: Option<String>,
max_tool_calls: Option<usize>,
output: CliOutputSelection,
params: Vec<String>,
provider_params_json: Option<String>,
no_web_search: bool,
stream: bool,
no_stream: bool,
stdin: StdinMode,
line_format: LineFormat,
auth_binding: Option<AuthBindingRef>,
scope: &RuntimeScope,
llm_override: Option<Arc<dyn meerkat_client::LlmClient>>,
verbose: bool,
wait_for_mcp: bool,
tools: Option<ToolPreset>,
yolo: bool,
keep_alive: bool,
) -> anyhow::Result<()> {
#[cfg(not(feature = "session-store"))]
{
let _ = (
session_id,
prompt,
system_prompt,
model,
provider,
max_tokens,
output_schema,
hooks_override,
skills,
allow_tools,
block_tools,
labels,
instructions,
app_context,
max_duration,
max_tool_calls,
output,
params,
provider_params_json,
no_web_search,
stream,
no_stream,
stdin,
line_format,
auth_binding,
scope,
llm_override,
verbose,
wait_for_mcp,
tools,
yolo,
keep_alive,
);
anyhow::bail!("resume requires rkat built with session-store support");
}
#[cfg(feature = "session-store")]
{
let resume_started = std::time::Instant::now();
let log_stage = |stage: &str| {
if verbose {
eprintln!(
"[resume][+{:>6.2}s] {stage}",
resume_started.elapsed().as_secs_f32()
);
}
};
log_stage("begin");
log_stage("load_config");
let (config, config_base_dir) = load_config(scope).await?;
let (config, runtime_preload_skills) = resolve_runtime_skills(config, skills).await?;
let has_max_duration = max_duration.is_some();
let has_max_tool_calls = max_tool_calls.is_some();
let duration = max_duration.map(|s| parse_duration(&s)).transpose()?;
let stream = resolve_stream_enabled(stream, no_stream, output.format.streams_by_default())?;
let parsed_params = parse_provider_params(¶ms)?;
let parsed_params_json = parse_provider_params_json(provider_params_json)?;
let mut merged_provider_params = merge_provider_params(parsed_params, parsed_params_json)?;
let parsed_output_schema = output_schema
.as_ref()
.map(|s| parse_output_schema(s))
.transpose()?;
let parsed_app_context = app_context
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|e| anyhow::anyhow!("Invalid --app-context JSON: {e}"))?;
let html_output_request =
resolve_html_output_request(&output, &config, &config_base_dir).await?;
let mut prompt = prompt.to_string();
if let Some(request) = &html_output_request {
append_html_output_instruction(&mut prompt, request);
}
let stdin_events = matches!(stdin, StdinMode::Lines);
// Resolve session identifier (full UUID, short prefix, or relative alias).
log_stage("resolve_session_id");
let session_id = resolve_flexible_session_id(session_id, scope, &config).await?;
let flow_tool_overlay = build_flow_tool_overlay(allow_tools, block_tools);
log_stage("create_session_store");
let (manifest, persistence) = create_persistence_bundle(scope).await?;
let store = persistence.session_store();
log_stage("load_persisted");
let session = store
.load(&session_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to load session: {e}"))?
.ok_or_else(|| anyhow::anyhow!("Session not found: {session_id}"))?;
let message_count = session.messages().len();
let stored_metadata = session
.session_metadata()
.ok_or_else(|| anyhow::anyhow!("persisted session {session_id} is missing metadata"))?;
let build_state = session.build_state().unwrap_or_default();
let mut tooling = stored_metadata.tooling.clone();
let explicit_tooling = if yolo || tools.is_some() {
Some(resolve_tool_preset(tools.unwrap_or(ToolPreset::Safe), yolo))
} else {
None
};
if let Some(resolved) = explicit_tooling {
tooling.builtins =
meerkat_core::ToolCategoryOverride::from_effective(resolved.builtins);
tooling.shell = meerkat_core::ToolCategoryOverride::from_effective(resolved.shell);
tooling.memory = meerkat_core::ToolCategoryOverride::from_effective(resolved.memory);
tooling.workgraph =
meerkat_core::ToolCategoryOverride::from_effective(resolved.workgraph);
tooling.mob = meerkat_core::ToolCategoryOverride::from_effective(resolved.mob);
}
let model_override = if provider.is_some() && model.is_none() {
Some(stored_metadata.model.clone())
} else {
model
};
let model_override_provider = if provider.is_none() {
model_override
.as_deref()
.map(|model_override| resolve_cli_provider(&config, model_override, None))
.transpose()?
} else {
None
};
apply_no_web_search_resume_provider_params(
provider,
model_override_provider,
stored_metadata.provider,
stored_metadata.provider_params.as_ref(),
&mut merged_provider_params,
no_web_search,
)?;
let mut limits = build_state
.budget_limits
.clone()
.unwrap_or_else(|| config.budget_limits());
if let Some(dur) = duration {
limits.max_duration = Some(dur);
}
if let Some(calls) = max_tool_calls {
limits.max_tool_calls = Some(calls);
}
let budget_override = if has_max_duration || has_max_tool_calls {
Some(limits)
} else {
None
};
let keep_alive_override = (keep_alive || stdin_events).then_some(true);
let keep_alive =
resolve_keep_alive(keep_alive_override.unwrap_or(stored_metadata.keep_alive))?;
log_stage("load_mcp_external_tools");
// Build factory with flags restored from stored session metadata
let project_root = scope.context_root.clone().unwrap_or_else(|| {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
find_project_root(&cwd).unwrap_or(cwd)
});
let store_path = persistence
.store_path()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| realm_store_path(&manifest, scope));
let mut factory = AgentFactory::new(store_path)
.session_store(store.clone())
.runtime_root(
meerkat_store::realm_paths_in(
&scope.locator.state_root,
scope.locator.realm.as_str(),
)
.root,
)
.project_root(project_root)
.builtins(tooling.builtins.resolve(config.tools.builtins_enabled))
.shell(tooling.shell.resolve(config.tools.shell_enabled))
.workgraph(tooling.workgraph.resolve(config.tools.workgraph_enabled))
.schedule(config.tools.schedule_enabled);
if let Some(context_root) = scope.context_root.clone() {
factory = factory.context_root(context_root);
}
if let Some(user_root) = scope.user_config_root.clone() {
factory = factory.user_config_root(user_root);
}
#[cfg(feature = "comms")]
let factory =
factory.comms(tooling.comms.resolve(config.tools.comms_enabled) || keep_alive);
log_stage("build_cli_persistent_service");
// Build persistent session service for resume — durable runtime semantics.
let max_sessions = config.max_sessions();
let (persistent_service, resume_adapter) =
meerkat::build_persistent_service_with_runtime_adapter(
factory,
config.clone(),
max_sessions,
persistence.clone(),
);
let service = Arc::new(persistent_service);
log_stage("compose_external_tool_dispatchers");
#[cfg(feature = "mob")]
let mut run_mob_tools = if tooling.mob.resolve(config.tools.mob_enabled) {
log_stage("get_or_create_mob_persistent_service");
let mob_persistent = remember_mob_persistent_service(scope, Arc::clone(&service))?;
let run_mob_service: Arc<dyn meerkat_mob::MobSessionService> =
Arc::new(MobCliSessionService::new(mob_persistent));
Some(prepare_run_mob_tools(scope, run_mob_service).await?)
} else {
None
};
#[cfg(not(feature = "mob"))]
let mut run_mob_tools: Option<()> = None;
// Prepare epoch-local bindings before MCP startup so resumed sessions
// stage and apply MCP surface lifecycle directly on the session-owned
// MeerkatMachine handle.
let resume_bindings = resume_adapter
.prepare_bindings(session_id.clone())
.await
.map_err(|e| anyhow::anyhow!("runtime bindings: {e}"))?;
// Load optional MCP tools immediately before external tool composition so
// later early-return windows cannot skip adapter shutdown.
let (mcp_external_tools, mcp_adapter) = load_mcp_external_tools(
scope,
wait_for_mcp,
Some(Arc::clone(resume_bindings.external_tool_surface())),
)
.await;
// Mob tools now flow through mob_tools (factory pattern), not external_tools.
// Only MCP tools remain as external_tools.
let external_tools = mcp_external_tools;
#[cfg(feature = "mob")]
let mob_tools_factory: Option<Arc<dyn meerkat_core::service::MobToolsFactory>> =
run_mob_tools.as_ref().map(|ctx| {
Arc::new(meerkat_mob_mcp::AgentMobToolSurfaceFactory::new(
Arc::clone(&ctx.state),
)) as Arc<dyn meerkat_core::service::MobToolsFactory>
});
#[cfg(not(feature = "mob"))]
let mob_tools_factory: Option<Arc<dyn meerkat_core::service::MobToolsFactory>> = None;
let output_pipeline = CliOutputPipeline::new(
stream,
verbose,
if stream {
Some(stream_renderer::StreamRenderPolicy::PrimaryOnly)
} else {
None
},
vec![StreamScopeFrame::Primary {
session_id: session_id.to_string(),
}],
)?;
// Wave-c C-12: lift runtime-side preload-skill names into typed
// `SkillKey`s (builtin source) before the SessionBuildOptions
// construction so a parse error surfaces loud on resume instead
// of panicking at the collect site.
let resumed_preload_skills: Option<Vec<meerkat_core::skills::SkillKey>> =
if runtime_preload_skills.is_empty() {
None
} else {
let keys: Result<Vec<_>, _> = runtime_preload_skills
.into_iter()
.map(|raw| {
meerkat_core::skills::SkillName::parse(&raw)
.map(meerkat_core::skills::SkillKey::builtin)
.map_err(|e| {
anyhow::anyhow!("invalid preloaded skill name `{raw}`: {e}")
})
})
.collect();
Some(keys?)
};
let hooks_override =
(hooks_override != HookRunOverrides::default()).then_some(hooks_override);
let recovery_overrides = meerkat_core::session_recovery::SurfaceSessionRecoveryOverrides {
model: model_override,
provider: provider.map(Provider::as_core),
provider_params: merged_provider_params,
max_tokens,
system_prompt,
output_schema: parsed_output_schema,
keep_alive: keep_alive_override,
hooks_override,
budget_limits: budget_override,
override_builtins: explicit_tooling.map(|resolved| resolved.builtins),
override_shell: explicit_tooling.map(|resolved| resolved.shell),
override_memory: explicit_tooling.map(|resolved| resolved.memory),
override_workgraph: explicit_tooling.map(|resolved| resolved.workgraph),
override_mob: explicit_tooling.map(|resolved| resolved.mob),
preload_skills: resumed_preload_skills,
app_context: parsed_app_context,
..Default::default()
};
let recovered = meerkat_core::session_recovery::build_recovered_session(
session,
&recovery_overrides,
meerkat_core::session_recovery::SurfaceSessionRecoveryContext {
realm_id: Some(scope.locator.realm.as_str().to_owned()),
instance_id: scope.instance_id.clone(),
backend: Some(manifest.backend.as_str().to_string()),
config_generation: stored_metadata.config_generation,
..Default::default()
},
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let model = recovered.model;
let system_prompt = recovered.system_prompt;
let max_tokens = recovered.max_tokens;
let keep_alive = resolve_keep_alive(recovered.keep_alive)?;
let mut build = recovered.build;
build.external_tools = external_tools;
build.llm_client_override =
llm_override.map(meerkat::encode_llm_client_override_for_service);
build.runtime_build_mode = meerkat_core::RuntimeBuildMode::SessionOwned(resume_bindings);
if let Some(auth_binding) = auth_binding {
build.auth_binding = Some(auth_binding);
}
build.mob_tools = mob_tools_factory;
let parsed_labels = if labels.is_empty() {
None
} else {
Some(std::collections::BTreeMap::from_iter(labels))
};
meerkat::surface::validate_raw_labels(parsed_labels.as_ref())
.map_err(|e| anyhow::anyhow!(e))?;
tracing::info!(
"Resuming session {} with {} messages (provider: {:?}, model: {})",
session_id,
message_count,
build.provider,
model
);
#[cfg(feature = "comms")]
let mut stdin_reader_handle: Option<tokio::task::JoinHandle<()>> = None;
let turn_result = async {
// Route through SessionService::create_session() with the resumed session
// staged in the build config. The service builds the agent (which picks up
// the resume_session), runs the first turn, and returns RunResult.
log_stage("service.create_session(start)");
let create_result = service
.create_session(CreateSessionRequest {
model,
prompt: prompt.clone().into(),
render_metadata: None,
system_prompt,
max_tokens,
event_tx: output_pipeline.event_sender(),
skill_references: None,
// Always defer — runtime adapter handles execution.
initial_turn: meerkat_core::service::InitialTurnPolicy::Defer,
deferred_prompt_policy: DeferredPromptPolicy::Discard,
build: Some(build),
labels: parsed_labels,
})
.await
.map_err(session_err_to_anyhow)?;
let additional_instructions = if instructions.is_empty() {
None
} else {
Some(instructions)
};
// Route through runtime adapter (same pattern as run command)
let session_id = create_result.session_id.clone();
let executor = Box::new(CliRuntimeExecutor {
service: service.clone() as Arc<dyn meerkat_core::service::SessionService>,
#[cfg(feature = "session-store")]
persistent_service: Some(service.clone()),
session_id: session_id.clone(),
runtime_adapter: resume_adapter.clone(),
event_tx: output_pipeline.event_sender(),
});
resume_adapter
.register_session_with_executor(session_id.clone(), executor)
.await;
#[cfg(feature = "comms")]
if stdin_events && keep_alive {
stdin_reader_handle = Some(stdin_events::spawn_stdin_reader(
resume_adapter.clone(),
session_id.clone(),
match line_format {
LineFormat::Text => stdin_events::StdinLineFormat::Text,
LineFormat::Json => stdin_events::StdinLineFormat::Json,
},
));
}
// Post-wave-a: `keep_alive` is typed `KeepAlivePolicy`, and
// `additional_instructions` is typed `Vec<TurnInstruction>`.
// Session-level keep-alive is still carried via
// `update_peer_ingress_context` below. Per-turn overlay atoms for
// both remain unset until the CLI exposes the typed surface.
let _ = keep_alive;
let additional_instructions = additional_instructions.map(|texts| {
texts
.into_iter()
.map(
|body| meerkat_core::lifecycle::run_primitive::TurnInstruction {
kind: meerkat_core::lifecycle::run_primitive::TurnInstructionKind::User,
body,
},
)
.collect::<Vec<_>>()
});
let input = meerkat_runtime::Input::Prompt(meerkat_runtime::PromptInput::new(
prompt.clone(),
Some(
meerkat_core::lifecycle::run_primitive::RuntimeTurnMetadata {
keep_alive: None,
skill_references: None,
flow_tool_overlay,
additional_instructions,
..Default::default()
},
),
));
let (_outcome, handle) = resume_adapter
.accept_input_with_completion(&session_id, input)
.await
.map_err(|err| anyhow::anyhow!("runtime accept failed: {err}"))?;
// Spawn the comms drain in keep-alive mode so inbound peer interactions are
// routed through the runtime adapter and automatically trigger new turns.
#[cfg(feature = "comms")]
{
let comms_rt = service.comms_runtime(&session_id).await;
resume_adapter
.update_peer_ingress_context(&session_id, keep_alive, comms_rt)
.await;
}
match handle {
Some(handle) => completion_outcome_to_cli_runtime_turn_result(
handle.wait().await,
&session_id,
&scope.locator.realm,
false,
),
None => {
eprintln!("Warning: duplicate input — already processed");
Ok(CliRuntimeTurnResult::Completed(create_result))
}
}
}
.await;
if keep_alive && matches!(&turn_result, Ok(CliRuntimeTurnResult::Completed(_))) {
eprintln!("Keep-alive: resume turn complete, waiting for events (Ctrl+C to exit)...");
tokio::signal::ctrl_c()
.await
.map_err(|e| anyhow::anyhow!("signal wait failed: {e}"))?;
eprintln!("\nShutting down...");
}
let result = Box::pin(finalize_cli_runtime_backed_turn(
output_pipeline,
turn_result,
async {
// The resume turn is complete — abort the comms drain so the CLI can
// return. Same rationale as run_agent: one-shot commands must not block.
#[cfg(feature = "comms")]
{
resume_adapter.abort_comms_drain(&session_id).await;
}
#[cfg(feature = "comms")]
if let Some(h) = stdin_reader_handle {
h.abort();
}
log_stage("service.create_session(done)");
// Shutdown the session service and MCP connections gracefully.
resume_adapter.unregister_session(&session_id).await;
log_stage("service.shutdown");
service.shutdown().await;
log_stage("shutdown_mcp");
shutdown_mcp(&mcp_adapter).await;
log_stage("persist_mob_registry");
#[cfg(feature = "mob")]
if let Some(ref mut mob_ctx) = run_mob_tools {
mob_ctx.persist(scope).await?;
}
Ok(())
},
))
.await?;
// Output the result
log_stage("print_result");
match result {
CliRuntimeTurnResult::Completed(result) => {
print_completed_run_result(result, &output, stream, scope, true).await?;
}
CliRuntimeTurnResult::CallbackPending(pending) => {
print_cli_callback_pending(&pending, Some(output.format.as_str()))?;
}
}
log_stage("done");
Ok(())
}
}
#[cfg(feature = "session-store")]
async fn build_cli_persistent_service(
scope: &RuntimeScope,
config: Config,
) -> anyhow::Result<(
Arc<meerkat::PersistentSessionService<FactoryAgentBuilder>>,
Arc<meerkat_runtime::MeerkatMachine>,
)> {
let (manifest, persistence) = create_persistence_bundle(scope).await?;
build_cli_persistent_service_from_bundle(scope, config, manifest, persistence).await
}
#[cfg(feature = "session-store")]
async fn build_cli_persistent_service_from_bundle(
scope: &RuntimeScope,
config: Config,
manifest: meerkat_store::RealmManifest,
persistence: PersistenceBundle,
) -> anyhow::Result<(
Arc<meerkat::PersistentSessionService<FactoryAgentBuilder>>,
Arc<meerkat_runtime::MeerkatMachine>,
)> {
let surface =
get_or_create_cli_persistent_surface_from_bundle(scope, config, manifest, persistence)
.await?;
Ok((
Arc::clone(&surface.service),
Arc::clone(&surface.runtime_adapter),
))
}
#[cfg(feature = "session-store")]
async fn get_or_create_cli_persistent_surface_from_bundle(
scope: &RuntimeScope,
config: Config,
manifest: meerkat_store::RealmManifest,
persistence: PersistenceBundle,
) -> anyhow::Result<Arc<CliPersistentSurfaceState>> {
if let Some(existing) = cached_cli_persistent_surface(scope)? {
return Ok(existing);
}
let store = persistence.session_store();
let store_path = persistence
.store_path()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| realm_store_path(&manifest, scope));
let project_root = scope.context_root.clone().unwrap_or_else(|| {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
find_project_root(&cwd).unwrap_or(cwd)
});
let mut factory = AgentFactory::new(store_path)
.session_store(store.clone())
.runtime_root(
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str())
.root,
)
.project_root(project_root)
.builtins(config.tools.builtins_enabled)
.shell(config.tools.shell_enabled)
.workgraph(config.tools.workgraph_enabled)
.schedule(config.tools.schedule_enabled);
if let Some(context_root) = scope.context_root.clone() {
factory = factory.context_root(context_root);
}
if let Some(user_root) = scope.user_config_root.clone() {
factory = factory.user_config_root(user_root);
}
let schedule_service = ScheduleService::new(persistence.schedule_store());
let default_schedule_tools = Some(Arc::new(ScheduleToolDispatcher::new(
schedule_service.clone(),
)) as Arc<dyn AgentToolDispatcher>);
let (service, runtime_adapter) = build_cli_runtime_backed_service_with_defaults(
factory,
config,
persistence,
default_schedule_tools,
);
let service = Arc::new(service);
#[cfg(all(feature = "mob", feature = "session-store"))]
let mob_state_cache = Arc::new(CliMobStateCache::default());
let schedule_host = if schedule_host_supported(schedule_service.store().kind()) {
#[cfg(all(feature = "mob", feature = "session-store"))]
let mob_host = build_cli_schedule_mob_host(
scope.clone(),
Arc::clone(&service),
Arc::clone(&runtime_adapter),
Arc::clone(&mob_state_cache),
);
#[cfg(all(not(feature = "mob"), feature = "session-store"))]
let mob_host = build_cli_schedule_mob_host(
scope,
Arc::clone(&service),
Arc::clone(&runtime_adapter),
(),
);
let session_host: Arc<dyn SurfaceScheduleSessionHost> = Arc::new(CliScheduleSessionHost {
service: Arc::clone(&service),
runtime_adapter: Arc::clone(&runtime_adapter),
});
let shared_adapter = Arc::new(SharedScheduleTargetAdapter::new(
schedule_service.clone(),
session_host,
mob_host,
));
Some(spawn_schedule_host(
schedule_service,
shared_adapter,
format!("rkat:{}", scope.locator.realm.as_str()),
))
} else {
None
};
remember_cli_persistent_surface(
scope,
Arc::new(CliPersistentSurfaceState {
service,
runtime_adapter,
#[cfg(all(feature = "mob", feature = "session-store"))]
mob_state_cache,
_schedule_host: schedule_host,
}),
)
}
async fn handle_blob_command(command: BlobCommands, scope: &RuntimeScope) -> anyhow::Result<()> {
match command {
BlobCommands::Get {
blob_id,
output,
json,
} => {
let (_manifest, persistence) = create_persistence_bundle(scope).await?;
let payload = persistence
.blob_store()
.get(&BlobId::new(blob_id))
.await
.map_err(|err| anyhow::anyhow!(err.to_string()))?;
if json {
println!("{}", serde_json::to_string_pretty(&payload)?);
return Ok(());
}
let bytes = BASE64_STANDARD.decode(payload.data.as_bytes())?;
if let Some(path) = output {
tokio::fs::write(path, bytes).await?;
} else {
let mut stdout = tokio::io::stdout();
stdout.write_all(&bytes).await?;
stdout.flush().await?;
}
Ok(())
}
}
}
#[cfg(feature = "session-store")]
type CliPersistentService = meerkat::PersistentSessionService<FactoryAgentBuilder>;
#[cfg(feature = "session-store")]
struct CliPersistentSurfaceState {
service: Arc<CliPersistentService>,
runtime_adapter: Arc<meerkat_runtime::MeerkatMachine>,
#[cfg(all(feature = "mob", feature = "session-store"))]
mob_state_cache: Arc<CliMobStateCache>,
_schedule_host: Option<meerkat::surface::ScheduleHostHandle>,
}
#[cfg(all(feature = "mob", feature = "session-store"))]
#[derive(Default)]
struct CliMobStateCache {
state: tokio::sync::Mutex<Option<Arc<meerkat_mob_mcp::MobMcpState>>>,
}
#[cfg(all(feature = "mob", feature = "session-store"))]
async fn hydrate_cli_mob_state_cached(
scope: &RuntimeScope,
service: Arc<CliPersistentService>,
runtime_adapter: Arc<meerkat_runtime::MeerkatMachine>,
mob_state_cache: Arc<CliMobStateCache>,
) -> anyhow::Result<Arc<meerkat_mob_mcp::MobMcpState>> {
let mut cached = mob_state_cache.state.lock().await;
if let Some(state) = cached.as_ref() {
return Ok(Arc::clone(state));
}
let mob_service: Arc<dyn meerkat_mob::MobSessionService> =
Arc::new(MobCliSessionService::new(service));
let (mob_state, _) = hydrate_mob_state(
scope,
mob_service,
Some(runtime_adapter),
None,
None,
std::collections::BTreeMap::new(),
)
.await?;
*cached = Some(Arc::clone(&mob_state));
Ok(mob_state)
}
#[cfg(all(feature = "mob", feature = "session-store"))]
async fn get_or_hydrate_cli_mob_state(
scope: &RuntimeScope,
service: Arc<CliPersistentService>,
runtime_adapter: Arc<meerkat_runtime::MeerkatMachine>,
mob_state_cache: Arc<CliMobStateCache>,
) -> anyhow::Result<Arc<meerkat_mob_mcp::MobMcpState>> {
let _lock = acquire_mob_registry_lock(scope).await?;
hydrate_cli_mob_state_cached(scope, service, runtime_adapter, mob_state_cache).await
}
#[cfg(all(feature = "mob", feature = "session-store"))]
struct CliScheduleMobHost {
scope: RuntimeScope,
service: Arc<CliPersistentService>,
runtime_adapter: Arc<meerkat_runtime::MeerkatMachine>,
mob_state_cache: Arc<CliMobStateCache>,
}
#[cfg(all(feature = "mob", feature = "session-store"))]
fn build_cli_schedule_mob_host(
scope: RuntimeScope,
service: Arc<CliPersistentService>,
runtime_adapter: Arc<meerkat_runtime::MeerkatMachine>,
mob_state_cache: Arc<CliMobStateCache>,
) -> Arc<dyn SurfaceScheduleMobHost> {
Arc::new(CliScheduleMobHost {
scope,
service,
runtime_adapter,
mob_state_cache,
})
}
#[cfg(all(feature = "mob", feature = "session-store", test))]
fn cli_schedule_mob_host_from_state(
mob_state: Arc<meerkat_mob_mcp::MobMcpState>,
) -> Arc<dyn SurfaceScheduleMobHost> {
Arc::new(meerkat_mob_mcp::MobMcpScheduleHost::new(mob_state))
}
#[cfg(all(feature = "mob", feature = "session-store"))]
#[async_trait::async_trait]
impl SurfaceScheduleMobHost for CliScheduleMobHost {
async fn probe_mob_target(
&self,
binding: &meerkat::MobTargetBinding,
) -> Result<meerkat::TargetProbeOutcome, meerkat::ScheduleDomainError> {
let state = get_or_hydrate_cli_mob_state(
&self.scope,
Arc::clone(&self.service),
Arc::clone(&self.runtime_adapter),
Arc::clone(&self.mob_state_cache),
)
.await
.map_err(|error| meerkat::ScheduleDomainError::ProbeFailed(error.to_string()))?;
meerkat_mob_mcp::MobMcpScheduleHost::new(state)
.probe_mob_target(binding)
.await
}
async fn deliver_mob_target(
&self,
occurrence: &meerkat::Occurrence,
binding: &meerkat::MobTargetBinding,
) -> Result<meerkat::DeliveryDispatch, meerkat::ScheduleDomainError> {
let state = get_or_hydrate_cli_mob_state(
&self.scope,
Arc::clone(&self.service),
Arc::clone(&self.runtime_adapter),
Arc::clone(&self.mob_state_cache),
)
.await
.map_err(|error| meerkat::ScheduleDomainError::Internal(error.to_string()))?;
meerkat_mob_mcp::MobMcpScheduleHost::new(state)
.deliver_mob_target(occurrence, binding)
.await
}
}
#[cfg(all(not(feature = "mob"), feature = "session-store"))]
fn build_cli_schedule_mob_host(
_scope: &RuntimeScope,
_service: Arc<CliPersistentService>,
_runtime_adapter: Arc<meerkat_runtime::MeerkatMachine>,
_mob_state_cache: (),
) -> Arc<dyn SurfaceScheduleMobHost> {
Arc::new(NoopScheduleMobHost::new(
"scheduled mob targets require the mob feature on the CLI host",
))
}
#[cfg(feature = "session-store")]
#[derive(Clone)]
struct CliScheduleSessionHost {
service: Arc<CliPersistentService>,
runtime_adapter: Arc<meerkat_runtime::MeerkatMachine>,
}
const SCHEDULED_PROMPT_VISIBLE_COMPLETION_INSTRUCTION: &str = "This is a scheduled run. After completing any side effects, produce a concise user-visible status line. Do not intentionally return an empty response.";
#[cfg(feature = "session-store")]
impl CliScheduleSessionHost {
fn executor(&self, session_id: SessionId) -> CliRuntimeExecutor {
CliRuntimeExecutor {
service: Arc::clone(&self.service) as Arc<dyn meerkat_core::service::SessionService>,
#[cfg(feature = "session-store")]
persistent_service: Some(Arc::clone(&self.service)),
session_id,
runtime_adapter: Arc::clone(&self.runtime_adapter),
event_tx: None,
}
}
async fn ensure_runtime_session_registered(
&self,
session_id: &SessionId,
) -> Result<(), meerkat::ScheduleDomainError> {
let session_exists = self.service.read(session_id).await.is_ok()
|| self
.service
.load_authoritative_session(session_id)
.await
.map_err(|error| meerkat::ScheduleDomainError::Internal(error.to_string()))?
.is_some();
if !session_exists {
return Err(meerkat::ScheduleDomainError::InvalidSchedule(format!(
"session not found: {session_id}"
)));
}
self.runtime_adapter
.ensure_session_with_executor(
session_id.clone(),
Box::new(self.executor(session_id.clone())),
)
.await;
self.update_peer_ingress_context(session_id).await;
Ok(())
}
async fn update_peer_ingress_context(&self, session_id: &SessionId) {
let keep_alive = self
.service
.load_authoritative_session(session_id)
.await
.ok()
.flatten()
.and_then(|session| {
session
.session_metadata()
.map(|metadata| metadata.keep_alive)
})
.unwrap_or(false);
let comms_rt = self.service.comms_runtime(session_id).await;
self.runtime_adapter
.update_peer_ingress_context(session_id, keep_alive, comms_rt)
.await;
}
}
fn scheduled_skill_keys(
skill_refs: &[meerkat_core::skills::SkillRef],
) -> Result<Option<Vec<meerkat_core::skills::SkillKey>>, meerkat::ScheduleDomainError> {
if skill_refs.is_empty() {
return Ok(None);
}
Ok(Some(
skill_refs
.iter()
.map(|reference| reference.key().clone())
.collect(),
))
}
#[cfg(any(feature = "session-store", test))]
fn materialized_preload_skills(
preload_skills: &[meerkat_core::skills::SkillKey],
) -> Option<Vec<meerkat_core::skills::SkillKey>> {
(!preload_skills.is_empty()).then(|| preload_skills.to_vec())
}
#[cfg(feature = "session-store")]
#[async_trait::async_trait]
impl SurfaceScheduleSessionHost for CliScheduleSessionHost {
async fn probe_session_target(
&self,
binding: &meerkat::SessionTargetBinding,
) -> Result<meerkat::TargetProbeOutcome, meerkat::ScheduleDomainError> {
let Some(session_id) = binding.resolved_session_id() else {
return Ok(meerkat::TargetProbeOutcome::Ready);
};
if let Ok(view) = self.service.read(session_id).await {
return Ok(if view.state.is_active {
meerkat::TargetProbeOutcome::Busy {
detail: Some(format!("session still running: {session_id}")),
}
} else {
meerkat::TargetProbeOutcome::Ready
});
}
let persisted = self
.service
.load_authoritative_session(session_id)
.await
.map_err(|error| meerkat::ScheduleDomainError::Internal(error.to_string()))?;
if let Some(session) = persisted {
let archived = self
.service
.session_archived_by_authority(session_id, &session)
.await
.map_err(|error| meerkat::ScheduleDomainError::Internal(error.to_string()))?;
if !archived {
return Ok(meerkat::TargetProbeOutcome::Ready);
}
}
Ok(meerkat::TargetProbeOutcome::Missing {
detail: Some(format!("session not found: {session_id}")),
})
}
async fn materialize_session(
&self,
create: &meerkat::SessionMaterializationSpec,
prompt_system_prompt: Option<&str>,
) -> Result<SessionId, meerkat::ScheduleDomainError> {
let session = Session::new();
let session_id = session.id().clone();
let bindings = self
.runtime_adapter
.prepare_bindings(session_id.clone())
.await
.map_err(|error| meerkat::ScheduleDomainError::Internal(error.to_string()))?;
let build = SessionBuildOptions {
provider: create.provider,
output_schema: create.output_schema.clone(),
structured_output_retries: create.structured_output_retries,
comms_name: create.comms_name.clone(),
peer_meta: create.peer_meta.clone(),
resume_session: Some(session),
provider_params: create.provider_params.clone(),
preload_skills: materialized_preload_skills(&create.preload_skills),
additional_instructions: (!create.additional_instructions.is_empty())
.then(|| create.additional_instructions.clone()),
realm_id: create.realm_id.clone(),
instance_id: create.instance_id.clone(),
backend: create.backend.clone(),
keep_alive: create.keep_alive,
app_context: create.app_context.clone(),
runtime_build_mode: meerkat_core::RuntimeBuildMode::SessionOwned(bindings),
..SessionBuildOptions::default()
};
let result = self
.service
.create_session(CreateSessionRequest {
model: create.model.clone(),
prompt: "".into(),
render_metadata: None,
system_prompt: prompt_system_prompt
.map(str::to_owned)
.or_else(|| create.system_prompt.clone()),
max_tokens: create.max_tokens,
event_tx: None,
skill_references: None,
initial_turn: meerkat_core::service::InitialTurnPolicy::Defer,
deferred_prompt_policy: DeferredPromptPolicy::Discard,
build: Some(build),
labels: Some(create.labels.clone()),
})
.await
.map_err(|error| meerkat::ScheduleDomainError::Internal(error.to_string()))?;
self.runtime_adapter
.ensure_session_with_executor(
result.session_id.clone(),
Box::new(self.executor(result.session_id.clone())),
)
.await;
self.update_peer_ingress_context(&result.session_id).await;
Ok(result.session_id)
}
async fn deliver_prompt(
&self,
session_id: &SessionId,
occurrence: &meerkat::Occurrence,
dispatch: ScheduledPromptDispatch,
) -> Result<meerkat::DeliveryDispatch, meerkat::ScheduleDomainError> {
self.ensure_runtime_session_registered(session_id).await?;
let mut scheduled_instructions = dispatch.additional_instructions.clone();
scheduled_instructions.push(SCHEDULED_PROMPT_VISIBLE_COMPLETION_INSTRUCTION.to_string());
let mut prompt_input = PromptInput::from_content_input(
dispatch.prompt,
Some(
meerkat_core::lifecycle::run_primitive::RuntimeTurnMetadata {
handling_mode: None,
keep_alive: None,
skill_references: scheduled_skill_keys(&dispatch.skill_refs)?,
flow_tool_overlay: None,
// Post-wave-a: `RuntimeTurnMetadata.additional_instructions`
// is typed `Vec<TurnInstruction>`; project the scheduled
// dispatch's `Vec<String>` into typed instructions with
// `System` kind (scheduled prompts originate from the
// runtime's schedule driver, not the user).
additional_instructions: Some(
scheduled_instructions
.into_iter()
.map(|body| {
meerkat_core::lifecycle::run_primitive::TurnInstruction {
kind: meerkat_core::lifecycle::run_primitive::TurnInstructionKind::System,
body,
}
})
.collect::<Vec<_>>(),
),
model: None,
provider: None,
provider_params: None,
render_metadata: dispatch.render_metadata.clone(),
execution_kind: None,
peer_response_terminal_apply_intent: None,
auth_binding: None,
},
),
);
prompt_input.header.source = InputOrigin::System;
prompt_input.header.idempotency_key = Some(IdempotencyKey::new(
schedule_attempt_idempotency_key(occurrence),
));
prompt_input.header.correlation_id =
Some(CorrelationId::from_uuid(occurrence.occurrence_id.0));
let correlation_id = prompt_input
.header
.correlation_id
.as_ref()
.map(ToString::to_string);
let (outcome, handle) = self
.runtime_adapter
.accept_input_with_completion(session_id, Input::Prompt(prompt_input))
.await
.map_err(|error| meerkat::ScheduleDomainError::Internal(error.to_string()))?;
if let meerkat_runtime::accept::AcceptOutcome::Rejected { reason } = outcome {
return Ok(immediate_delivery_failure(
occurrence,
reason.to_string(),
meerkat::OccurrenceFailureClass::RuntimeRejected,
correlation_id,
dispatch.materialized_session_id,
));
}
Ok(build_dispatch_from_accepted(
occurrence,
AcceptedScheduledInput {
correlation_id,
handle,
},
dispatch.materialized_session_id,
))
}
async fn deliver_event(
&self,
session_id: &SessionId,
occurrence: &meerkat::Occurrence,
event_type: String,
payload: serde_json::Value,
render_metadata: Option<meerkat_core::types::RenderMetadata>,
materialized_session_id: Option<SessionId>,
) -> Result<meerkat::DeliveryDispatch, meerkat::ScheduleDomainError> {
self.ensure_runtime_session_registered(session_id).await?;
let input = Input::ExternalEvent(meerkat_runtime::ExternalEventInput {
header: InputHeader {
id: meerkat_core::lifecycle::InputId::new(),
timestamp: Utc::now(),
source: InputOrigin::External {
source_name: format!("schedule:{}", occurrence.schedule_id),
},
durability: InputDurability::Durable,
visibility: InputVisibility::default(),
idempotency_key: Some(IdempotencyKey::new(schedule_attempt_idempotency_key(
occurrence,
))),
supersession_key: None,
correlation_id: Some(CorrelationId::from_uuid(occurrence.occurrence_id.0)),
},
event_type,
payload,
blocks: None,
handling_mode: meerkat_core::types::HandlingMode::Queue,
render_metadata,
});
let correlation_id = input
.header()
.correlation_id
.as_ref()
.map(ToString::to_string);
let (outcome, handle) = self
.runtime_adapter
.accept_input_with_completion(session_id, input)
.await
.map_err(|error| meerkat::ScheduleDomainError::Internal(error.to_string()))?;
if let meerkat_runtime::accept::AcceptOutcome::Rejected { reason } = outcome {
return Ok(immediate_delivery_failure(
occurrence,
reason.to_string(),
meerkat::OccurrenceFailureClass::RuntimeRejected,
correlation_id,
materialized_session_id,
));
}
Ok(build_dispatch_from_accepted(
occurrence,
AcceptedScheduledInput {
correlation_id,
handle,
},
materialized_session_id,
))
}
}
#[cfg(feature = "session-store")]
fn cli_persistent_surface_cache()
-> &'static Mutex<std::collections::HashMap<String, Arc<CliPersistentSurfaceState>>> {
static CACHE: OnceLock<
Mutex<std::collections::HashMap<String, Arc<CliPersistentSurfaceState>>>,
> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}
#[cfg(feature = "session-store")]
fn cached_cli_persistent_surface(
scope: &RuntimeScope,
) -> anyhow::Result<Option<Arc<CliPersistentSurfaceState>>> {
let key = mob_persistent_service_key(scope);
Ok(cli_persistent_surface_cache()
.lock()
.map_err(|_| anyhow::anyhow!("cli persistent surface cache poisoned"))?
.get(&key)
.cloned())
}
#[cfg(feature = "session-store")]
fn remember_cli_persistent_surface(
scope: &RuntimeScope,
created: Arc<CliPersistentSurfaceState>,
) -> anyhow::Result<Arc<CliPersistentSurfaceState>> {
let key = mob_persistent_service_key(scope);
let mut cache = cli_persistent_surface_cache()
.lock()
.map_err(|_| anyhow::anyhow!("cli persistent surface cache poisoned"))?;
Ok(cache.entry(key).or_insert(created).clone())
}
#[cfg(all(feature = "mob", feature = "session-store"))]
fn mob_persistent_service_cache()
-> &'static Mutex<std::collections::HashMap<String, Weak<CliPersistentService>>> {
static CACHE: OnceLock<Mutex<std::collections::HashMap<String, Weak<CliPersistentService>>>> =
OnceLock::new();
CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}
fn mob_persistent_service_key(scope: &RuntimeScope) -> String {
format!(
"{}::{}",
scope.locator.state_root.display(),
scope.locator.realm.as_str()
)
}
#[cfg(all(feature = "mob", feature = "session-store"))]
fn remember_mob_persistent_service(
scope: &RuntimeScope,
created: Arc<CliPersistentService>,
) -> anyhow::Result<Arc<CliPersistentService>> {
let key = mob_persistent_service_key(scope);
let mut cache = mob_persistent_service_cache()
.lock()
.map_err(|_| anyhow::anyhow!("mob persistent service cache poisoned"))?;
if let Some(existing) = cache.get(&key).and_then(Weak::upgrade) {
Ok(existing)
} else {
cache.insert(key, Arc::downgrade(&created));
Ok(created)
}
}
/// Mob-facing session service wrapper for CLI orchestration.
///
/// Mob actor keep-alive behavior is defined by runtime/backend decisions.
/// This wrapper forwards requests without rewriting keep-alive flags.
#[cfg(all(feature = "mob", feature = "session-store"))]
struct MobCliSessionService {
inner: Arc<meerkat::PersistentSessionService<FactoryAgentBuilder>>,
}
#[cfg(all(feature = "mob", feature = "session-store"))]
impl MobCliSessionService {
fn new(inner: Arc<meerkat::PersistentSessionService<FactoryAgentBuilder>>) -> Self {
Self { inner }
}
}
#[async_trait::async_trait]
#[cfg(all(feature = "mob", feature = "session-store"))]
impl SessionService for MobCliSessionService {
async fn create_session(
&self,
req: CreateSessionRequest,
) -> Result<meerkat_core::types::RunResult, meerkat_core::service::SessionError> {
self.inner.create_session(req).await
}
async fn start_turn(
&self,
id: &SessionId,
req: meerkat_core::service::StartTurnRequest,
) -> Result<meerkat_core::types::RunResult, meerkat_core::service::SessionError> {
self.inner.start_turn(id, req).await
}
async fn interrupt(&self, id: &SessionId) -> Result<(), meerkat_core::service::SessionError> {
Err(meerkat_core::service::SessionError::Unsupported(format!(
"interrupt for mob session {id} must route through MeerkatMachine"
)))
}
async fn read(
&self,
id: &SessionId,
) -> Result<meerkat_core::service::SessionView, meerkat_core::service::SessionError> {
self.inner.read(id).await
}
async fn list(
&self,
query: SessionQuery,
) -> Result<Vec<meerkat_core::service::SessionSummary>, meerkat_core::service::SessionError>
{
// Mob reconciliation requires live comms runtimes in-process; persisted
// snapshots alone are not wire-ready.
let mut summaries = self.inner.list(SessionQuery::default()).await?;
summaries.retain(|summary| summary.is_active);
if let Some(offset) = query.offset {
if offset < summaries.len() {
summaries = summaries.split_off(offset);
} else {
summaries.clear();
}
}
if let Some(limit) = query.limit {
summaries.truncate(limit);
}
Ok(summaries)
}
async fn archive(&self, id: &SessionId) -> Result<(), meerkat_core::service::SessionError> {
self.inner.archive(id).await
}
}
#[async_trait::async_trait]
#[cfg(feature = "mob")]
impl SessionServiceCommsExt for MobCliSessionService {
async fn comms_runtime(
&self,
session_id: &SessionId,
) -> Option<Arc<dyn meerkat_core::agent::CommsRuntime>> {
self.inner.comms_runtime(session_id).await
}
async fn event_injector(
&self,
session_id: &SessionId,
) -> Option<Arc<dyn meerkat_core::EventInjector>> {
self.inner.event_injector(session_id).await
}
}
#[async_trait::async_trait]
#[cfg(feature = "mob")]
impl meerkat_core::service::SessionServiceControlExt for MobCliSessionService {
async fn append_system_context(
&self,
id: &SessionId,
req: meerkat_core::AppendSystemContextRequest,
) -> Result<
meerkat_core::service::AppendSystemContextResult,
meerkat_core::service::SessionControlError,
> {
self.inner.append_system_context(id, req).await
}
}
#[async_trait::async_trait]
#[cfg(feature = "mob")]
impl meerkat_core::service::SessionServiceHistoryExt for MobCliSessionService {
async fn read_history(
&self,
id: &SessionId,
query: meerkat_core::service::SessionHistoryQuery,
) -> Result<meerkat_core::service::SessionHistoryPage, meerkat_core::service::SessionError>
{
self.inner.read_history(id, query).await
}
}
#[async_trait::async_trait]
#[cfg(all(feature = "mob", feature = "session-store"))]
impl meerkat_mob::MobSessionService for MobCliSessionService {
async fn subscribe_session_events(
&self,
session_id: &SessionId,
) -> Result<meerkat_core::comms::EventStream, meerkat_core::comms::StreamError> {
let runtime = self.inner.comms_runtime(session_id).await.ok_or_else(|| {
meerkat_core::comms::StreamError::NotFound(format!("session {session_id}"))
})?;
runtime.stream(meerkat_core::comms::StreamScope::Session(
session_id.clone(),
))
}
fn supports_persistent_sessions(&self) -> bool {
true
}
fn runtime_adapter(&self) -> Option<Arc<meerkat_runtime::MeerkatMachine>> {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::runtime_adapter(
&self.inner,
)
}
async fn interrupt_with_machine_authority(
&self,
session_id: &SessionId,
authority: meerkat_runtime::MachineSessionControlAuthority,
) -> Result<(), meerkat_core::service::SessionError> {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::interrupt_with_machine_authority(
&self.inner,
session_id,
authority,
)
.await
}
async fn cancel_after_boundary_with_machine_authority(
&self,
session_id: &SessionId,
authority: meerkat_runtime::MachineSessionControlAuthority,
) -> Result<(), meerkat_core::service::SessionError> {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::cancel_after_boundary_with_machine_authority(
&self.inner,
session_id,
authority,
)
.await
}
async fn archive_with_mob_lifecycle_authority(
&self,
session_id: &SessionId,
) -> Result<(), meerkat_core::service::SessionError> {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::archive_with_mob_lifecycle_authority(
&self.inner,
session_id,
)
.await
}
async fn load_persisted_session(
&self,
session_id: &SessionId,
) -> Result<Option<Session>, meerkat_core::service::SessionError> {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::load_persisted_session(
&self.inner,
session_id,
)
.await
}
async fn execution_snapshot(
&self,
session_id: &SessionId,
) -> Result<Option<meerkat_core::AgentExecutionSnapshot>, meerkat_core::service::SessionError>
{
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::execution_snapshot(
&self.inner,
session_id,
)
.await
}
async fn tool_scope_snapshot(
&self,
session_id: &SessionId,
) -> Result<Option<meerkat_core::ToolScopeSnapshot>, meerkat_core::service::SessionError> {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::tool_scope_snapshot(
&self.inner,
session_id,
)
.await
}
async fn external_tool_surface_snapshot(
&self,
session_id: &SessionId,
) -> Result<
Option<meerkat_core::ExternalToolSurfaceSnapshot>,
meerkat_core::service::SessionError,
> {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::external_tool_surface_snapshot(
&self.inner,
session_id,
)
.await
}
async fn peer_ingress_runtime_snapshot(
&self,
session_id: &SessionId,
) -> Result<Option<meerkat_core::PeerIngressRuntimeSnapshot>, meerkat_core::service::SessionError>
{
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::peer_ingress_runtime_snapshot(
&self.inner,
session_id,
)
.await
}
async fn apply_runtime_turn(
&self,
session_id: &SessionId,
run_id: meerkat_core::RunId,
req: meerkat_core::service::StartTurnRequest,
boundary: meerkat_core::lifecycle::run_primitive::RunApplyBoundary,
contributing_input_ids: Vec<meerkat_core::InputId>,
) -> Result<
meerkat_core::lifecycle::core_executor::CoreApplyOutput,
meerkat_core::service::SessionError,
> {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::apply_runtime_turn(
&self.inner,
session_id,
run_id,
req,
boundary,
contributing_input_ids,
)
.await
}
async fn apply_runtime_context_appends(
&self,
session_id: &SessionId,
run_id: meerkat_core::RunId,
appends: Vec<meerkat_core::PendingSystemContextAppend>,
contributing_input_ids: Vec<meerkat_core::InputId>,
) -> Result<
meerkat_core::lifecycle::core_executor::CoreApplyOutput,
meerkat_core::service::SessionError,
> {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::apply_runtime_context_appends(
&self.inner,
session_id,
run_id,
appends,
contributing_input_ids,
)
.await
}
async fn apply_runtime_context_appends_with_boundary(
&self,
session_id: &SessionId,
run_id: meerkat_core::RunId,
appends: Vec<meerkat_core::PendingSystemContextAppend>,
boundary: meerkat_core::lifecycle::run_primitive::RunApplyBoundary,
contributing_input_ids: Vec<meerkat_core::InputId>,
) -> Result<
meerkat_core::lifecycle::core_executor::CoreApplyOutput,
meerkat_core::service::SessionError,
> {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::apply_runtime_context_appends_with_boundary(
&self.inner,
session_id,
run_id,
appends,
boundary,
contributing_input_ids,
)
.await
}
async fn apply_runtime_system_context_for_turn(
&self,
session_id: &SessionId,
appends: Vec<meerkat_core::PendingSystemContextAppend>,
) -> Result<(), meerkat_core::service::SessionError> {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::apply_runtime_system_context_for_turn(
&self.inner,
session_id,
appends,
)
.await
}
async fn discard_live_session(
&self,
session_id: &SessionId,
) -> Result<(), meerkat_core::service::SessionError> {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::discard_live_session(
&self.inner,
session_id,
)
.await
}
async fn session_belongs_to_mob(
&self,
session_id: &SessionId,
mob_id: &meerkat_mob::MobId,
) -> bool {
<meerkat::PersistentSessionService<FactoryAgentBuilder> as meerkat_mob::MobSessionService>::session_belongs_to_mob(
&self.inner,
session_id,
mob_id,
)
.await
}
}
/// List sessions from the realm-scoped persistent backend.
async fn list_sessions(
limit: usize,
offset: Option<usize>,
labels: Vec<(String, String)>,
scope: &RuntimeScope,
) -> anyhow::Result<()> {
#[cfg(not(feature = "session-store"))]
{
let _ = (limit, offset, labels, scope);
anyhow::bail!("session listing requires rkat built with session-store support");
}
#[cfg(feature = "session-store")]
{
let (config, _) = load_config(scope).await?;
let (service, _runtime_adapter) = build_cli_persistent_service(scope, config).await?;
let query = SessionQuery {
limit: Some(limit),
offset,
labels: if labels.is_empty() {
None
} else {
Some(std::collections::BTreeMap::from_iter(labels))
},
};
let sessions = service
.list(query)
.await
.map_err(|e| anyhow::anyhow!("Failed to list sessions: {e}"))?;
if sessions.is_empty() {
println!("No sessions found.");
return Ok(());
}
// Check if any session has labels to decide whether to show the LABELS column.
let any_labels = sessions.iter().any(|s| !s.labels.is_empty());
if any_labels {
println!(
"{:<40} {:<72} {:<12} {:<20} {:<20} LABELS",
"ID", "SESSION_REF", "MESSAGES", "CREATED", "UPDATED"
);
println!("{}", "-".repeat(200));
} else {
println!(
"{:<40} {:<72} {:<12} {:<20} {:<20}",
"ID", "SESSION_REF", "MESSAGES", "CREATED", "UPDATED"
);
println!("{}", "-".repeat(170));
}
for meta in sessions {
let created = chrono::DateTime::<chrono::Utc>::from(meta.created_at)
.format("%Y-%m-%d %H:%M")
.to_string();
let updated = chrono::DateTime::<chrono::Utc>::from(meta.updated_at)
.format("%Y-%m-%d %H:%M")
.to_string();
if any_labels {
let label_str: String = meta
.labels
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", ");
println!(
"{:<40} {:<72} {:<12} {:<20} {:<20} {}",
meta.session_id,
format_session_ref(&scope.locator.realm, &meta.session_id),
meta.message_count,
created,
updated,
label_str,
);
} else {
println!(
"{:<40} {:<72} {:<12} {:<20} {:<20}",
meta.session_id,
format_session_ref(&scope.locator.realm, &meta.session_id),
meta.message_count,
created,
updated
);
}
}
Ok(())
}
}
/// Show session details from the realm-scoped persistent backend.
async fn show_session(id: &str, scope: &RuntimeScope) -> anyhow::Result<()> {
#[cfg(not(feature = "session-store"))]
{
let _ = (id, scope);
anyhow::bail!("showing sessions requires rkat built with session-store support");
}
#[cfg(feature = "session-store")]
{
// Parse session locator (<session_id> or <realm_id>:<session_id>).
let session_id = resolve_scoped_session_id(id, scope)?;
let (config, _) = load_config(scope).await?;
let (service, _runtime_adapter) = build_cli_persistent_service(scope, config).await?;
let session = service
.load_authoritative_session(&session_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to load session: {e}"))?
.ok_or_else(|| anyhow::anyhow!("Session not found: {session_id}"))?;
// Print session header
println!("Session: {session_id}");
println!(
"Session Ref: {}",
format_session_ref(&scope.locator.realm, &session_id)
);
println!("Messages: {}", session.messages().len());
println!("Version: {}", session.version());
println!("{}", "=".repeat(60));
// Print each message
for (i, msg) in session.messages().iter().enumerate() {
use meerkat_core::Message;
match msg {
Message::System(s) => {
println!("\n[{}] SYSTEM:", i + 1);
println!(" {}", s.content);
}
Message::SystemNotice(notice) => {
println!("\n[{}] SYSTEM NOTICE ({:?}):", i + 1, notice.kind);
if let Some(body) = notice.body.as_deref() {
println!(" {body}");
}
}
Message::User(u) => {
println!("\n[{}] USER:", i + 1);
println!(" {}", u.text_content());
}
Message::Assistant(a) => {
println!("\n[{}] ASSISTANT:", i + 1);
if !a.content.is_empty() {
// Truncate long responses
let display_text = if a.content.len() > 500 {
format!("{}...", truncate_str(&a.content, 500))
} else {
a.content.clone()
};
println!(" {display_text}");
}
if !a.tool_calls.is_empty() {
println!(
" Tool calls: {:?}",
a.tool_calls.iter().map(|tc| &tc.name).collect::<Vec<_>>()
);
}
}
Message::ToolResults { results, .. } => {
println!("\n[{}] TOOL RESULTS:", i + 1);
for result in results {
let status = if result.is_error { "ERROR" } else { "OK" };
// Truncate long results
let text = result.text_content();
let content = if text.len() > 200 {
format!("{}...", truncate_str(&text, 200))
} else {
text
};
println!(" [{}] {}: {}", status, result.tool_use_id, content);
}
}
Message::BlockAssistant(a) => {
println!("\n[{}] ASSISTANT (blocks):", i + 1);
for block in &a.blocks {
match block {
meerkat_core::AssistantBlock::Text { text, .. } => {
let display_text = if text.len() > 500 {
format!("{}...", truncate_str(text, 500))
} else {
text.clone()
};
println!(" {display_text}");
}
meerkat_core::AssistantBlock::Transcript { text, .. } => {
let display_text = if text.len() > 500 {
format!("{}...", truncate_str(text, 500))
} else {
text.clone()
};
println!(" [transcript] {display_text}");
}
meerkat_core::AssistantBlock::Reasoning { text, .. } => {
let display_text = if text.len() > 200 {
format!("{}...", truncate_str(text, 200))
} else {
text.clone()
};
println!(" [thinking] {display_text}");
}
meerkat_core::AssistantBlock::ToolUse { name, .. } => {
println!(" Tool call: {name}");
}
_ => {} // non_exhaustive
}
}
}
}
}
Ok(())
}
}
/// Delete a session from the realm-scoped persistent backend.
async fn delete_session(id: &str, scope: &RuntimeScope) -> anyhow::Result<()> {
#[cfg(not(feature = "session-store"))]
{
let _ = (id, scope);
anyhow::bail!("deleting sessions requires rkat built with session-store support");
}
#[cfg(feature = "session-store")]
{
// Parse session locator (<session_id> or <realm_id>:<session_id>).
let session_id = resolve_scoped_session_id(id, scope)?;
let (config, _) = load_config(scope).await?;
let (manifest, persistence) = create_persistence_bundle(scope).await?;
let surface = get_or_create_cli_persistent_surface_from_bundle(
scope,
config.clone(),
manifest,
persistence,
)
.await?;
let service = Arc::clone(&surface.service);
#[cfg(feature = "mob")]
{
// Archive and clean up any session-owned mobs.
if config.tools.mob_enabled
&& let Ok(state) = get_or_hydrate_cli_mob_state(
scope,
Arc::clone(&surface.service),
Arc::clone(&surface.runtime_adapter),
Arc::clone(&surface.mob_state_cache),
)
.await
{
meerkat_mob_mcp::archive_session_with_mob_cleanup(
Arc::clone(&service),
Arc::clone(&state),
&session_id,
)
.await
.map_err(|e| anyhow::anyhow!("Failed to delete session: {e}"))?;
} else {
service
.archive_with_machine_protocol(
&session_id,
meerkat::MachineSessionArchiveProtocol::from_machine(
surface.runtime_adapter.as_ref(),
),
)
.await
.map_err(|e| anyhow::anyhow!("Failed to delete session: {e}"))?;
}
}
#[cfg(not(feature = "mob"))]
{
let _ = config;
service
.archive_with_machine_protocol(
&session_id,
meerkat::MachineSessionArchiveProtocol::from_machine(
surface.runtime_adapter.as_ref(),
),
)
.await
.map_err(|e| anyhow::anyhow!("Failed to delete session: {e}"))?;
}
println!("Deleted session: {session_id}");
println!(
"Session Ref: {}",
format_session_ref(&scope.locator.realm, &session_id)
);
Ok(())
}
}
/// Interrupt an in-flight turn for a session.
async fn interrupt_session(id: &str, scope: &RuntimeScope) -> anyhow::Result<()> {
#[cfg(not(feature = "session-store"))]
{
let _ = (id, scope);
anyhow::bail!("interrupting sessions requires rkat built with session-store support");
}
#[cfg(feature = "session-store")]
{
let session_id = resolve_scoped_session_id(id, scope)?;
let (config, _) = load_config(scope).await?;
let (service, runtime_adapter) = build_cli_persistent_service(scope, config).await?;
service
.read(&session_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to interrupt session: {e}"))?;
match runtime_adapter
.hard_cancel_current_run(&session_id, "CLI session interrupt")
.await
{
Ok(()) => {
println!("Interrupted session: {session_id}");
println!(
"Session Ref: {}",
format_session_ref(&scope.locator.realm, &session_id)
);
Ok(())
}
Err(meerkat_runtime::RuntimeDriverError::NotReady { state })
if interrupt_not_ready_is_noop(state) =>
{
println!("Interrupted session: {session_id}");
println!(
"Session Ref: {}",
format_session_ref(&scope.locator.realm, &session_id)
);
Ok(())
}
Err(
meerkat_runtime::RuntimeDriverError::NotReady {
state: meerkat_runtime::RuntimeState::Destroyed,
}
| meerkat_runtime::RuntimeDriverError::Destroyed,
) => {
println!("Interrupted session: {session_id}");
println!(
"Session Ref: {}",
format_session_ref(&scope.locator.realm, &session_id)
);
Ok(())
}
Err(meerkat_runtime::RuntimeDriverError::NotReady { state }) => Err(anyhow::anyhow!(
"Failed to interrupt session: runtime is not interruptible while {state}"
)),
Err(e) => Err(anyhow::anyhow!("Failed to interrupt session: {e}")),
}
}
}
fn interrupt_not_ready_is_noop(state: meerkat_runtime::RuntimeState) -> bool {
matches!(
state,
meerkat_runtime::RuntimeState::Idle | meerkat_runtime::RuntimeState::Attached
)
}
#[cfg(all(feature = "comms", test))]
fn parse_comms_send_payload(
payload_json: &str,
session_id: &SessionId,
) -> anyhow::Result<meerkat_core::comms::CommsCommand> {
let request: meerkat_core::comms::CommsCommandRequest = serde_json::from_str(payload_json)
.map_err(|e| anyhow::anyhow!("Invalid comms JSON payload: {e}"))?;
request
.into_command(session_id)
.map_err(|err| anyhow::anyhow!("Invalid comms command: {err}"))
}
#[cfg(test)]
#[derive(Debug, Clone)]
struct SessionLocateMatch {
state_root: PathBuf,
realm_id: String,
session_id: SessionId,
}
#[cfg(test)]
async fn find_session_matches(
locator_input: &str,
extra_state_roots: &[PathBuf],
scope: &RuntimeScope,
) -> anyhow::Result<Vec<SessionLocateMatch>> {
let locator = SessionLocator::parse(locator_input)
.map_err(|e| anyhow::anyhow!("Invalid session locator '{locator_input}': {e}"))?;
let mut scan_roots = vec![scope.locator.state_root.clone()];
for root in extra_state_roots {
if !scan_roots.iter().any(|existing| existing == root) {
scan_roots.push(root.clone());
}
}
let mut matches: Vec<SessionLocateMatch> = Vec::new();
for root in &scan_roots {
let manifests = meerkat_store::list_realm_manifests_in(root)
.await
.map_err(|e| anyhow::anyhow!("Failed to list realms in '{}': {e}", root.display()))?;
for entry in manifests {
if let Some(target_realm) = locator.realm_id.as_ref()
&& &entry.manifest.realm != target_realm
{
continue;
}
let store = meerkat_store::open_realm_session_store_in(
root,
entry.manifest.realm.as_str(),
Some(entry.manifest.backend),
None,
)
.await
.map_err(|e| {
anyhow::anyhow!(
"Failed to open realm '{}' in '{}': {e}",
entry.manifest.realm,
root.display()
)
})?
.1;
let found = store
.list(meerkat_store::SessionFilter::default())
.await
.map_err(|e| {
anyhow::anyhow!(
"Failed to list sessions in realm '{}' ({}): {e}",
entry.manifest.realm,
root.display()
)
})?
.into_iter()
.any(|meta| meta.id == locator.session_id);
if found {
matches.push(SessionLocateMatch {
state_root: root.clone(),
realm_id: entry.manifest.realm.to_string(),
session_id: locator.session_id.clone(),
});
}
}
}
matches.sort_by(|a, b| {
a.state_root
.cmp(&b.state_root)
.then_with(|| a.realm_id.cmp(&b.realm_id))
});
Ok(matches)
}
async fn persist_cli_config(config: Config, scope: &RuntimeScope) -> anyhow::Result<()> {
let (store, base_dir) = resolve_config_store(scope).await?;
let runtime =
meerkat_core::ConfigRuntime::new(Arc::clone(&store), base_dir.join("config_state.json"));
runtime
.set(config, None)
.await
.map_err(|e| anyhow::anyhow!("Failed to persist config: {e}"))?;
Ok(())
}
#[cfg(feature = "skills")]
async fn resolve_skill_repo_for_config(
raw: &str,
name_override: Option<String>,
) -> anyhow::Result<meerkat_core::skills_config::SkillRepositoryConfig> {
let resolved = resolve_skill_repo_path(raw).await?;
let default_name = resolved.default_name;
let name = name_override.unwrap_or(default_name);
let source_uuid = derive_skill_source_uuid(&resolved.repo_path)?;
Ok(meerkat_core::skills_config::SkillRepositoryConfig {
name,
source_uuid,
transport: meerkat_core::skills_config::SkillRepoTransport::Filesystem {
path: resolved.repo_path.display().to_string(),
},
})
}
#[cfg(feature = "skills")]
async fn repo_matches_selector(
repo: &meerkat_core::skills_config::SkillRepositoryConfig,
selector: &str,
) -> anyhow::Result<bool> {
if repo.name == selector || repo.source_uuid.to_string() == selector {
return Ok(true);
}
if let meerkat_core::skills_config::SkillRepoTransport::Filesystem { path } = &repo.transport
&& looks_like_path(selector)
{
let selector_path = tokio::fs::canonicalize(expand_path(selector)?).await.ok();
let repo_path = tokio::fs::canonicalize(path).await.ok();
if selector_path.is_some() && selector_path == repo_path {
return Ok(true);
}
}
Ok(false)
}
#[cfg(feature = "skills")]
fn skill_source_provenance(
identity: meerkat_core::skills::SourceIdentityRecord,
) -> meerkat_contracts::SkillSourceProvenance {
meerkat_contracts::SkillSourceProvenance { identity }
}
#[cfg(feature = "skills")]
fn skill_source_provenance_for_key(
registry: &meerkat_core::skills::SourceIdentityRegistry,
key: &meerkat_core::skills::SkillKey,
) -> anyhow::Result<meerkat_contracts::SkillSourceProvenance> {
let resolved = registry
.resolve(key)
.map_err(|e| anyhow::anyhow!("Failed to resolve skill source identity for {key}: {e}"))?;
Ok(skill_source_provenance(resolved.source.clone()))
}
#[cfg(feature = "skills")]
fn skill_entry(
entry: &meerkat_core::skills::SkillIntrospectionEntry,
) -> anyhow::Result<meerkat_contracts::SkillEntry> {
let source_identity = entry.source_identity.clone().ok_or_else(|| {
anyhow::anyhow!(
"skill {} missing typed source identity",
entry.descriptor.key
)
})?;
Ok(meerkat_contracts::SkillEntry {
key: entry.descriptor.key.clone(),
name: entry.descriptor.name.clone(),
description: entry.descriptor.description.clone(),
scope: entry.descriptor.scope.to_string(),
source: skill_source_provenance(source_identity),
is_active: entry.is_active,
shadowed_by: entry
.shadowed_by_identity
.clone()
.map(skill_source_provenance),
})
}
/// Handle Skills subcommands
#[cfg(feature = "skills")]
async fn handle_skills_command(
command: SkillsCommands,
scope: &RuntimeScope,
) -> anyhow::Result<()> {
// Wave-c C-12: the canonical runtime identity for a skill is
// `SkillKey { source_uuid, skill_name }` (C-1 / C-4 upstream retype).
use meerkat_core::skills::{SkillFilter, SkillKey, SkillName, SourceUuid};
// Load config from the active realm (not global defaults)
let (config, realm_root) = load_config(scope).await?;
match command {
SkillsCommands::Add { path, name } => {
let mut updated = config.clone();
let repo = resolve_skill_repo_for_config(&path, name).await?;
if updated.skills.repositories.iter().any(|existing| {
existing.name == repo.name || existing.source_uuid == repo.source_uuid
}) {
return Err(anyhow::anyhow!(
"Skill source '{}' is already configured",
repo.name
));
}
updated.skills.enabled = true;
updated.skills.repositories.push(repo.clone());
persist_cli_config(updated, scope).await?;
let transport = match &repo.transport {
meerkat_core::skills_config::SkillRepoTransport::Filesystem { path } => {
path.as_str()
}
other => {
return Err(anyhow::anyhow!(
"Unsupported skill source transport: {other:?}"
));
}
};
println!("Added skill source '{}' -> {}", repo.name, transport);
return Ok(());
}
SkillsCommands::Remove { selector } => {
let mut updated = config.clone();
let before = updated.skills.repositories.len();
let mut kept = Vec::with_capacity(before);
let mut removed = Vec::new();
for repo in updated.skills.repositories {
if repo_matches_selector(&repo, &selector).await? {
removed.push(repo.name.clone());
} else {
kept.push(repo);
}
}
if removed.is_empty() {
return Err(anyhow::anyhow!(
"No configured skill source matched '{selector}'"
));
}
updated.skills.repositories = kept;
persist_cli_config(updated, scope).await?;
println!("Removed skill source(s): {}", removed.join(", "));
return Ok(());
}
SkillsCommands::Get { selector, json } => {
let mut found = None;
for repo in &config.skills.repositories {
if repo_matches_selector(repo, &selector).await? {
found = Some(repo.clone());
break;
}
}
let repo = found.ok_or_else(|| {
anyhow::anyhow!("No configured skill source matched '{selector}'")
})?;
if json {
println!("{}", serde_json::to_string_pretty(&repo)?);
} else {
println!("Name: {}", repo.name);
println!("Source UUID: {}", repo.source_uuid);
match repo.transport {
meerkat_core::skills_config::SkillRepoTransport::Filesystem { path } => {
println!("Path: {path}");
}
other => {
println!("Transport: {other:?}");
}
}
}
return Ok(());
}
SkillsCommands::List { .. } | SkillsCommands::Inspect { .. } => {}
}
let factory = {
let mut f = meerkat::AgentFactory::new(realm_root.clone()).runtime_root(realm_root);
if let Some(ref root) = scope.context_root {
f = f.context_root(root.clone());
}
if let Some(ref root) = scope.user_config_root {
f = f.user_config_root(root.clone());
}
f
};
let skill_runtime = factory.build_skill_runtime(&config).await?;
let skill_runtime = match skill_runtime {
Some(rt) => rt,
None => {
eprintln!("Skills are not enabled. Check your config.");
return Ok(());
}
};
match command {
SkillsCommands::List { json } => {
let entries = skill_runtime
.list_all_with_provenance(&SkillFilter::default())
.await
.map_err(|e| anyhow::anyhow!("Failed to list skills: {e}"))?;
if json {
let wire = entries
.iter()
.map(skill_entry)
.collect::<anyhow::Result<Vec<_>>>()?;
println!("{}", serde_json::to_string_pretty(&wire)?);
} else {
// Fixed-width table: NAME, SOURCE_UUID, SCOPE, STATUS
println!(
"{:<40} {:<36} {:<10} STATUS",
"NAME", "SOURCE_UUID", "SCOPE"
);
println!("{}", "-".repeat(80));
for entry in &entries {
let status = if entry.is_active {
"active".to_string()
} else {
format!(
"shadowed by {}",
entry.shadowed_by.as_deref().unwrap_or("?")
)
};
println!(
"{:<40} {:<36} {:<10} {}",
entry.descriptor.key.skill_name.as_str(),
entry.descriptor.key.source_uuid,
entry.descriptor.scope,
status,
);
}
println!("\n{} skill(s) total", entries.len());
}
}
SkillsCommands::Inspect {
skill_name,
source_uuid,
json,
} => {
let skill_name = SkillName::parse(skill_name.as_str())
.map_err(|e| anyhow::anyhow!("invalid skill name `{skill_name}`: {e}"))?;
let source_uuid = SourceUuid::parse(source_uuid.as_str())
.map_err(|e| anyhow::anyhow!("invalid source UUID `{source_uuid}`: {e}"))?;
let key = SkillKey::new(source_uuid, skill_name);
let doc = skill_runtime
.load_from_source(&key, None)
.await
.map_err(|e| anyhow::anyhow!("Failed to inspect skill: {e}"))?;
if json {
let registry = config
.skills
.build_source_identity_registry()
.map_err(|e| {
anyhow::anyhow!("Failed to build skill source identity registry: {e}")
})?;
let wire = meerkat_contracts::SkillInspectResponse {
key: doc.descriptor.key.clone(),
name: doc.descriptor.name.clone(),
description: doc.descriptor.description.clone(),
scope: doc.descriptor.scope.to_string(),
source: skill_source_provenance_for_key(®istry, &doc.descriptor.key)?,
body: doc.body,
};
println!("{}", serde_json::to_string_pretty(&wire)?);
} else {
println!("Source UUID: {}", doc.descriptor.key.source_uuid);
println!("Skill Name: {}", doc.descriptor.key.skill_name);
println!("Name: {}", doc.descriptor.name);
println!("Description: {}", doc.descriptor.description);
println!("Scope: {}", doc.descriptor.scope);
if !doc.descriptor.source_name.is_empty() {
println!("Source: {}", doc.descriptor.source_name);
}
println!();
println!("{}", doc.body);
}
}
SkillsCommands::Add { .. } | SkillsCommands::Remove { .. } | SkillsCommands::Get { .. } => {
}
}
Ok(())
}
/// Handle MCP subcommands
#[cfg(feature = "mcp")]
async fn handle_mcp_command(command: McpCommands) -> anyhow::Result<()> {
match command {
McpCommands::Add {
name,
transport,
scope,
url,
headers,
env,
command,
} => {
let transport = transport.map(|t| match t {
CliTransport::Stdio => McpTransportKind::Stdio,
CliTransport::Http => McpTransportKind::StreamableHttp,
CliTransport::Sse => McpTransportKind::Sse,
});
mcp::add_server(
name,
transport,
url,
headers,
command,
env,
matches!(scope, CliMcpScope::Project | CliMcpScope::Local),
)
.await
}
McpCommands::Remove { name, scope } => {
let scope = scope.map(|s| match s {
CliMcpScope::User => McpScope::User,
CliMcpScope::Project | CliMcpScope::Local => McpScope::Project,
});
mcp::remove_server(name, scope).await
}
McpCommands::List { scope, json } => {
let scope = scope.map(|s| match s {
CliMcpScope::User => McpScope::User,
CliMcpScope::Project | CliMcpScope::Local => McpScope::Project,
});
mcp::list_servers(scope, json).await
}
McpCommands::Get { name, scope, json } => {
let scope = scope.map(|s| match s {
CliMcpScope::User => McpScope::User,
CliMcpScope::Project | CliMcpScope::Local => McpScope::Project,
});
mcp::get_server(name, scope, json).await
}
}
}
#[cfg(feature = "mob")]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
struct PersistedMobRegistry {
mobs: std::collections::BTreeMap<String, PersistedMob>,
}
#[cfg(feature = "mob")]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct PersistedMob {
/// Legacy fallback for old registry entries written before events were persisted.
#[serde(default)]
definition: Option<MobDefinition>,
#[serde(default)]
status: Option<String>,
#[serde(default)]
events: Vec<meerkat_mob::MobEvent>,
#[serde(default)]
runs: std::collections::BTreeMap<String, meerkat_mob::MobRun>,
}
#[cfg(feature = "mob")]
fn mob_registry_path(scope: &RuntimeScope) -> PathBuf {
let paths =
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str());
paths.root.join("mob_registry.json")
}
#[cfg(feature = "mob")]
fn mob_registry_lock_path(scope: &RuntimeScope) -> PathBuf {
let paths =
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str());
paths.root.join("mob_registry.lock")
}
#[cfg(feature = "mob")]
struct MobRegistryLock {
#[cfg(unix)]
_lock: nix::fcntl::Flock<std::fs::File>,
#[cfg(not(unix))]
_lock: std::fs::File,
}
#[cfg(feature = "mob")]
async fn acquire_mob_registry_lock(scope: &RuntimeScope) -> anyhow::Result<MobRegistryLock> {
let path = mob_registry_lock_path(scope);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
anyhow::anyhow!(
"failed to create mob registry lock directory '{}': {e}",
parent.display()
)
})?;
}
let lock_path = path.clone();
#[cfg(unix)]
let lock_file = {
tokio::time::timeout(
Duration::from_secs(30),
tokio::task::spawn_blocking(
move || -> anyhow::Result<nix::fcntl::Flock<std::fs::File>> {
use std::io::{Seek, SeekFrom, Write};
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.map_err(|e| {
anyhow::anyhow!(
"failed to open mob registry lock '{}': {e}",
lock_path.display()
)
})?;
let mut file =
nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive)
.map_err(|(_file, e)| {
anyhow::anyhow!(
"failed to acquire mob registry lock '{}': {e}",
lock_path.display()
)
})?;
file.set_len(0).map_err(|e| {
anyhow::anyhow!(
"failed to reset mob registry lock '{}': {e}",
lock_path.display()
)
})?;
file.seek(SeekFrom::Start(0)).map_err(|e| {
anyhow::anyhow!(
"failed to seek mob registry lock '{}': {e}",
lock_path.display()
)
})?;
writeln!(file, "{}", std::process::id()).map_err(|e| {
anyhow::anyhow!(
"failed to write mob registry lock owner '{}': {e}",
lock_path.display()
)
})?;
file.flush().map_err(|e| {
anyhow::anyhow!(
"failed to flush mob registry lock '{}': {e}",
lock_path.display()
)
})?;
Ok(file)
},
),
)
.await
.map_err(|_| {
anyhow::anyhow!(
"timed out waiting for mob registry lock '{}'",
path.display()
)
})?
.map_err(|e| anyhow::anyhow!("mob registry lock task failed: {e}"))??
};
#[cfg(not(unix))]
let lock_file = {
// On Windows, use a simple open-for-write as a best-effort advisory lock.
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.map_err(|e| {
anyhow::anyhow!(
"failed to open mob registry lock '{}': {e}",
lock_path.display()
)
})?
};
Ok(MobRegistryLock { _lock: lock_file })
}
#[cfg(feature = "mob")]
async fn load_mob_registry(scope: &RuntimeScope) -> anyhow::Result<PersistedMobRegistry> {
let path = mob_registry_path(scope);
if !path.exists() {
return Ok(PersistedMobRegistry::default());
}
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| anyhow::anyhow!("failed to read mob registry '{}': {e}", path.display()))?;
let parsed = serde_json::from_str::<PersistedMobRegistry>(&content)
.map_err(|e| anyhow::anyhow!("failed to parse mob registry '{}': {e}", path.display()))?;
Ok(parsed)
}
#[cfg(feature = "mob")]
async fn save_mob_registry(
scope: &RuntimeScope,
registry: &PersistedMobRegistry,
) -> anyhow::Result<()> {
let path = mob_registry_path(scope);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
anyhow::anyhow!(
"failed to create mob registry directory '{}': {e}",
parent.display()
)
})?;
}
let content = serde_json::to_string_pretty(registry)
.map_err(|e| anyhow::anyhow!("failed to encode mob registry: {e}"))?;
let tmp_path = path.with_extension(format!(
"json.tmp.{}.{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
tokio::fs::write(&tmp_path, content).await.map_err(|e| {
anyhow::anyhow!(
"failed to write temp mob registry '{}': {e}",
tmp_path.display()
)
})?;
tokio::fs::rename(&tmp_path, &path)
.await
.map_err(|e| anyhow::anyhow!("failed to commit mob registry '{}': {e}", path.display()))
}
#[cfg(feature = "mob")]
async fn sync_mob_events(
state: &meerkat_mob_mcp::MobMcpState,
registry: &mut PersistedMobRegistry,
mob_id: &str,
) -> anyhow::Result<()> {
let mob = registry
.mobs
.get_mut(mob_id)
.ok_or_else(|| anyhow::anyhow!("mob not found in persisted registry: {mob_id}"))?;
mob.events = state
.mob_events(&meerkat_mob::MobId::from(mob_id.to_string()), 0, usize::MAX)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
mob.status = Some(
state
.mob_status(&meerkat_mob::MobId::from(mob_id.to_string()))
.await
.map_err(|e| anyhow::anyhow!("{e}"))?
.as_str()
.to_string(),
);
refresh_persisted_run_snapshots(state, mob_id, mob).await?;
Ok(())
}
#[cfg(feature = "mob")]
async fn persist_mob_handle_snapshot(
scope: &RuntimeScope,
session_service: Arc<dyn meerkat_mob::MobSessionService>,
handle: &meerkat_mob::MobHandle,
definition: Option<meerkat_mob::MobDefinition>,
) -> anyhow::Result<()> {
let _lock = acquire_mob_registry_lock(scope).await?;
let mut registry = load_mob_registry(scope).await?;
let mob_id = handle.mob_id().to_string();
let current_status = handle
.status()
.await
.map_err(|e| anyhow::anyhow!("read mob status: {e}"))?
.as_str()
.to_string();
let entry = registry
.mobs
.entry(mob_id.clone())
.or_insert_with(|| PersistedMob {
definition: definition.clone(),
status: Some(current_status),
events: Vec::new(),
runs: std::collections::BTreeMap::new(),
});
if entry.definition.is_none() {
entry.definition = definition;
}
let state = Arc::new(meerkat_mob_mcp::MobMcpState::new(session_service));
state
.mob_insert_handle(handle.mob_id().clone(), handle.clone())
.await;
sync_mob_events(state.as_ref(), &mut registry, &mob_id).await?;
save_mob_registry(scope, ®istry).await
}
#[cfg(feature = "mob")]
async fn refresh_persisted_run_snapshots(
state: &meerkat_mob_mcp::MobMcpState,
mob_id: &str,
mob: &mut PersistedMob,
) -> anyhow::Result<()> {
let mut refreshed = std::collections::BTreeMap::new();
for (run_id, cached_run) in std::mem::take(&mut mob.runs) {
let parsed = match run_id.parse::<RunId>() {
Ok(run_id) => run_id,
Err(_) => {
refreshed.insert(run_id, cached_run);
continue;
}
};
let live = state
.mob_flow_status(&meerkat_mob::MobId::from(mob_id.to_string()), parsed)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
match live {
Some(run) => {
refreshed.insert(run.run_id.to_string(), run);
}
None => {
if cached_run.status().is_terminal() {
refreshed.insert(run_id, cached_run);
}
}
}
}
mob.runs = refreshed;
Ok(())
}
#[cfg(feature = "mob")]
fn cache_run_snapshot(
registry: &mut PersistedMobRegistry,
mob_id: &str,
run: meerkat_mob::MobRun,
) -> anyhow::Result<()> {
let mob = registry
.mobs
.get_mut(mob_id)
.ok_or_else(|| anyhow::anyhow!("mob not found in persisted registry: {mob_id}"))?;
mob.runs.insert(run.run_id.to_string(), run);
Ok(())
}
#[cfg(feature = "mob")]
fn cached_run_snapshot(
registry: &PersistedMobRegistry,
mob_id: &str,
run_id: &str,
) -> Option<meerkat_mob::MobRun> {
registry
.mobs
.get(mob_id)
.and_then(|mob| mob.runs.get(run_id))
.filter(|run| run.status().is_terminal())
.cloned()
}
#[cfg(feature = "mob")]
fn parse_mob_state(value: &str) -> Option<meerkat_mob::MobState> {
match value {
"Creating" => Some(meerkat_mob::MobState::Creating),
"Running" => Some(meerkat_mob::MobState::Running),
"Stopped" => Some(meerkat_mob::MobState::Stopped),
"Completed" => Some(meerkat_mob::MobState::Completed),
"Destroyed" => Some(meerkat_mob::MobState::Destroyed),
_ => None,
}
}
#[cfg(feature = "mob")]
type LlmClientProvider =
Arc<dyn Fn() -> Option<Arc<dyn meerkat_client::LlmClient>> + Send + Sync + 'static>;
#[cfg(feature = "mob")]
async fn hydrate_mob_state(
scope: &RuntimeScope,
session_service: Arc<dyn meerkat_mob::MobSessionService>,
runtime_adapter: Option<Arc<meerkat_runtime::MeerkatMachine>>,
default_llm_client_provider: Option<LlmClientProvider>,
external_tools_provider: Option<meerkat_mob::ExternalToolsProvider>,
seeded_handles: std::collections::BTreeMap<String, meerkat_mob::MobHandle>,
) -> anyhow::Result<(Arc<meerkat_mob_mcp::MobMcpState>, PersistedMobRegistry)> {
let registry = load_mob_registry(scope).await?;
let runtime_adapter = runtime_adapter.or_else(|| session_service.runtime_adapter());
let state = Arc::new(
meerkat_mob_mcp::MobMcpState::new_with_runtime_adapter(
session_service.clone(),
runtime_adapter.clone(),
)
.with_default_llm_client_provider(default_llm_client_provider)
.with_external_tools_provider(external_tools_provider.clone()),
);
for (mob_id, handle) in &seeded_handles {
state
.mob_insert_handle(meerkat_mob::MobId::from(mob_id.clone()), handle.clone())
.await;
}
for (mob_id, persisted) in ®istry.mobs {
if seeded_handles.contains_key(mob_id) {
continue;
}
let storage = meerkat_mob::MobStorage::in_memory();
if persisted.events.is_empty() {
let definition = persisted.definition.clone().ok_or_else(|| {
anyhow::anyhow!(
"mob registry entry '{mob_id}' has no persisted events and no legacy definition"
)
})?;
storage
.events
.append(meerkat_mob::NewMobEvent {
mob_id: definition.id.clone(),
timestamp: None,
kind: meerkat_mob::MobEventKind::MobCreated {
definition: Box::new(definition),
},
})
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
} else {
for event in &persisted.events {
storage
.events
.append(meerkat_mob::NewMobEvent {
mob_id: event.mob_id.clone(),
timestamp: Some(event.timestamp),
kind: event.kind.clone(),
})
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
}
for run in persisted.runs.values() {
if !run.status().is_terminal() {
continue;
}
storage
.runs
.create_run(run.clone())
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
let mut builder = meerkat_mob::MobBuilder::for_resume(storage)
.with_session_service(session_service.clone())
.with_default_external_tools_provider(external_tools_provider.clone())
.notify_orchestrator_on_resume(false);
if let Some(adapter) = runtime_adapter.clone() {
builder = builder.with_runtime_adapter(adapter);
}
let handle = builder.resume().await.map_err(|e| anyhow::anyhow!("{e}"))?;
let created = handle.mob_id().clone();
if created.as_str() != mob_id {
return Err(anyhow::anyhow!(
"mob registry id mismatch: key='{mob_id}' definition='{created}'"
));
}
if let Some(target_status) = persisted.status.as_deref().and_then(parse_mob_state) {
let current = handle
.status()
.await
.map_err(|e| anyhow::anyhow!("read mob status: {e}"))?;
match (current, target_status) {
(meerkat_mob::MobState::Running, meerkat_mob::MobState::Stopped) => {
handle.stop().await.map_err(|e| anyhow::anyhow!("{e}"))?;
}
(meerkat_mob::MobState::Stopped, meerkat_mob::MobState::Running) => {
handle.resume().await.map_err(|e| anyhow::anyhow!("{e}"))?;
}
(
meerkat_mob::MobState::Running | meerkat_mob::MobState::Stopped,
meerkat_mob::MobState::Completed,
) => {
handle
.complete()
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
_ => {}
}
}
state.mob_insert_handle(created, handle).await;
}
Ok((state, registry))
}
#[cfg(feature = "mob")]
fn parse_run_flow_params(raw_params: Option<String>) -> anyhow::Result<serde_json::Value> {
match raw_params {
Some(raw) => {
let params: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| anyhow::anyhow!("invalid --params JSON: {e}"))?;
if !params.is_object() {
return Err(anyhow::anyhow!("invalid --params JSON: expected an object"));
}
Ok(params)
}
None => Ok(serde_json::json!({})),
}
}
#[cfg(feature = "mob")]
fn render_flow_status_json(run: Option<meerkat_mob::MobRun>) -> anyhow::Result<String> {
serde_json::to_string(&run).map_err(|e| anyhow::anyhow!("failed to encode flow status: {e}"))
}
#[cfg(feature = "mob")]
fn helper_result_json_value(
mob_id: &str,
output: &Option<String>,
tokens_used: u64,
agent_identity: &meerkat_mob::AgentIdentity,
) -> serde_json::Value {
serde_json::json!({
"output": output,
"tokens_used": tokens_used,
"agent_identity": agent_identity.as_str(),
"member_ref": meerkat_contracts::WireMemberRef::encode(
mob_id,
agent_identity.as_str(),
),
})
}
#[cfg(feature = "mob")]
fn render_helper_result_json(
mob_id: &str,
result: &meerkat_mob::HelperResult,
) -> anyhow::Result<String> {
serde_json::to_string_pretty(&helper_result_json_value(
mob_id,
&result.output,
result.tokens_used,
&result.agent_identity,
))
.map_err(|e| anyhow::anyhow!("failed to encode helper result: {e}"))
}
#[cfg(feature = "mob")]
async fn wait_for_terminal_flow_run(
state: &meerkat_mob_mcp::MobMcpState,
mob_id: &str,
run_id: &RunId,
) -> anyhow::Result<meerkat_mob::MobRun> {
loop {
let Some(run) = state
.mob_flow_status(
&meerkat_mob::MobId::from(mob_id.to_string()),
run_id.clone(),
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?
else {
return Err(anyhow::anyhow!(
"run '{run_id}' disappeared before reaching terminal state"
));
};
if run.status().is_terminal() {
return Ok(run);
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
}
#[cfg(feature = "mob")]
async fn handle_mob_command(command: MobCommands, scope: &RuntimeScope) -> anyhow::Result<()> {
if let MobCommands::Pack {
dir,
output,
sign,
signer_id,
} = &command
{
let signing = match (sign.as_deref(), signer_id.as_deref()) {
(Some(key_path), Some(id)) => Some(meerkat_mob_pack::pack::SigningRequest {
signer_id: id,
key_path,
}),
(None, None) => None,
// clap `requires` already enforces both-or-neither at parse time.
_ => unreachable!("clap enforces --sign and --signer-id together"),
};
println!("{}", execute_mob_pack(dir, output, signing).await?);
return Ok(());
}
if let MobCommands::Inspect { pack } = &command {
println!("{}", execute_mob_inspect(pack).await?);
return Ok(());
}
if let MobCommands::Validate { pack, trust_policy } = &command {
println!(
"{}",
execute_mob_validate(scope, pack, *trust_policy).await?
);
return Ok(());
}
if let MobCommands::Deploy {
pack,
prompt,
model,
max_total_tokens,
max_duration,
max_tool_calls,
trust_policy,
surface,
} = &command
{
let parsed_max_duration = max_duration
.as_ref()
.map(|raw| parse_duration(raw))
.transpose()
.map_err(|err| anyhow::anyhow!("invalid --max-duration value: {err}"))?;
let deploy_overrides = CliOverrides {
model: model.clone(),
max_tokens: *max_total_tokens,
max_duration: parsed_max_duration,
max_tool_calls: *max_tool_calls,
override_config: None,
};
println!(
"{}",
Box::pin(execute_mob_deploy(
scope,
pack,
prompt,
*trust_policy,
*surface,
deploy_overrides,
))
.await?
);
return Ok(());
}
if let MobCommands::Web {
command:
MobWebCommands::Build {
pack,
output,
trust_policy,
},
} = &command
{
println!(
"{}",
execute_mob_web_build(scope, pack, output, *trust_policy).await?
);
return Ok(());
}
let _lock = acquire_mob_registry_lock(scope).await?;
let (config, _) = load_config(scope).await?;
let (manifest, persistence) = create_persistence_bundle(scope).await?;
let surface =
get_or_create_cli_persistent_surface_from_bundle(scope, config, manifest, persistence)
.await?;
let state = hydrate_cli_mob_state_cached(
scope,
Arc::clone(&surface.service),
Arc::clone(&surface.runtime_adapter),
Arc::clone(&surface.mob_state_cache),
)
.await?;
let mut registry = load_mob_registry(scope).await?;
let result = match command {
MobCommands::RunFlow {
mob_id,
flow,
params,
stream,
no_stream,
} => {
let stream = resolve_stream_enabled(stream, no_stream, true)?;
let stream_policy = if stream {
Some(stream_renderer::StreamRenderPolicy::PrimaryOnly)
} else {
None
};
let activation_params = parse_run_flow_params(params)?;
let (scoped_event_tx, stream_task) = if let Some(policy) = stream_policy {
let (tx, rx) = mpsc::channel::<ScopedAgentEvent>(200);
let task = spawn_scoped_event_handler(rx, policy, false);
(Some(tx), Some(task))
} else {
(None, None)
};
let run_id = state
.mob_run_flow_with_stream(
&meerkat_mob::MobId::from(mob_id.clone()),
FlowId::from(flow),
activation_params,
scoped_event_tx.clone(),
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let run = wait_for_terminal_flow_run(state.as_ref(), &mob_id, &run_id).await?;
cache_run_snapshot(&mut registry, &mob_id, run)?;
sync_mob_events(state.as_ref(), &mut registry, &mob_id).await?;
save_mob_registry(scope, ®istry).await?;
drop(scoped_event_tx);
if let Some(task) = stream_task {
let summary = task
.await
.map_err(|e| anyhow::anyhow!("stream renderer task failed: {e}"))?;
println!();
if let Some(focus) = summary.focus_requested
&& !summary.focus_seen
{
let discovered = if summary.discovered_scopes.is_empty() {
"<none>".to_string()
} else {
summary.discovered_scopes.join(", ")
};
return Err(anyhow::anyhow!(
"stream focus '{focus}' did not match any emitted scope (discovered scopes: {discovered})"
));
}
}
println!("{run_id}");
Ok(())
}
MobCommands::FlowStatus { mob_id, run_id } => {
let parsed_run_id = run_id
.parse::<RunId>()
.map_err(|e| anyhow::anyhow!("invalid run_id '{run_id}': {e}"))?;
let run = state
.mob_flow_status(&meerkat_mob::MobId::from(mob_id.clone()), parsed_run_id)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let resolved = match run {
Some(run) => {
cache_run_snapshot(&mut registry, &mob_id, run.clone())?;
Some(run)
}
None => cached_run_snapshot(®istry, &mob_id, &run_id),
};
sync_mob_events(state.as_ref(), &mut registry, &mob_id).await?;
save_mob_registry(scope, ®istry).await?;
println!("{}", render_flow_status_json(resolved)?);
Ok(())
}
MobCommands::SpawnHelper {
mob_id,
prompt,
agent_identity,
profile,
json,
} => {
let mid = meerkat_mob::AgentIdentity::from(agent_identity.unwrap_or_else(|| {
format!(
"helper-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
)
}));
let mut options = meerkat_mob::HelperOptions::default();
if let Some(p) = profile {
options.role_name = Some(meerkat_mob::ProfileName::from(p));
}
let result = state
.mob_spawn_helper(
&meerkat_mob::MobId::from(mob_id.clone()),
mid,
prompt,
options,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
sync_mob_events(state.as_ref(), &mut registry, &mob_id).await?;
save_mob_registry(scope, ®istry).await?;
if json {
println!("{}", render_helper_result_json(&mob_id, &result)?);
} else if let Some(output) = &result.output {
println!("{output}");
}
Ok(())
}
MobCommands::ForkHelper {
mob_id,
source_member,
prompt,
agent_identity,
profile,
fork_context,
last_messages,
json,
} => {
let mid = meerkat_mob::AgentIdentity::from(agent_identity.unwrap_or_else(|| {
format!(
"fork-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
)
}));
let source_id = meerkat_mob::AgentIdentity::from(source_member);
let ctx = match fork_context.as_str() {
"last-messages" => {
let count = last_messages.unwrap_or(10);
meerkat_mob::ForkContext::LastMessages { count }
}
_ => meerkat_mob::ForkContext::FullHistory,
};
let mut options = meerkat_mob::HelperOptions::default();
if let Some(p) = profile {
options.role_name = Some(meerkat_mob::ProfileName::from(p));
}
let result = state
.mob_fork_helper(
&meerkat_mob::MobId::from(mob_id.clone()),
&source_id,
mid,
prompt,
ctx,
options,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
sync_mob_events(state.as_ref(), &mut registry, &mob_id).await?;
save_mob_registry(scope, ®istry).await?;
if json {
println!("{}", render_helper_result_json(&mob_id, &result)?);
} else if let Some(output) = &result.output {
println!("{output}");
}
Ok(())
}
MobCommands::MemberStatus {
mob_id,
agent_identity,
json,
} => {
let snapshot = state
.mob_member_status(
&meerkat_mob::MobId::from(mob_id),
&meerkat_mob::AgentIdentity::from(agent_identity),
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": format!("{:?}", snapshot.status),
"output_preview": snapshot.output_preview,
"tokens_used": snapshot.tokens_used,
"is_final": snapshot.is_final,
"error": snapshot.error,
}))?
);
} else {
println!("status: {:?}", snapshot.status);
println!("tokens_used: {}", snapshot.tokens_used);
println!("is_final: {}", snapshot.is_final);
if let Some(preview) = &snapshot.output_preview {
println!("output: {preview}");
}
if let Some(error) = &snapshot.error {
println!("error: {error}");
}
}
Ok(())
}
MobCommands::ForceCancel {
mob_id,
agent_identity,
} => {
state
.mob_force_cancel(
&meerkat_mob::MobId::from(mob_id.clone()),
meerkat_mob::AgentIdentity::from(agent_identity),
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
sync_mob_events(state.as_ref(), &mut registry, &mob_id).await?;
save_mob_registry(scope, ®istry).await?;
println!("cancelled");
Ok(())
}
MobCommands::Respawn {
mob_id,
agent_identity,
initial_message,
} => {
let receipt = state
.mob_respawn(
&meerkat_mob::MobId::from(mob_id.clone()),
meerkat_mob::AgentIdentity::from(agent_identity),
initial_message.map(meerkat_core::ContentInput::from),
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
sync_mob_events(state.as_ref(), &mut registry, &mob_id).await?;
save_mob_registry(scope, ®istry).await?;
println!(
"{}",
serde_json::json!({
"status": "completed",
"receipt": receipt,
})
);
Ok(())
}
MobCommands::WaitKickoff {
mob_id,
member_ids,
timeout_ms,
json,
} => {
let member_ids = (!member_ids.is_empty()).then(|| {
member_ids
.into_iter()
.map(|id| meerkat_mob::AgentIdentity::from(id.as_str()))
.collect::<Vec<_>>()
});
let members = state
.mob_wait_kickoff(
&meerkat_mob::MobId::from(mob_id.clone()),
member_ids,
timeout_ms,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
sync_mob_events(state.as_ref(), &mut registry, &mob_id).await?;
save_mob_registry(scope, ®istry).await?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({ "members": members }))?
);
} else {
for member in members {
println!(
"{}\tstatus={:?}\tis_final={}",
member.agent_identity, member.snapshot.status, member.snapshot.is_final
);
}
}
Ok(())
}
MobCommands::Pack { .. }
| MobCommands::Inspect { .. }
| MobCommands::Validate { .. }
| MobCommands::Deploy { .. }
| MobCommands::Web { .. } => {
unreachable!(
"pack/inspect/validate/deploy/web handled before runtime mob state initialization"
)
}
};
drop(state);
result
}
#[cfg(feature = "mob")]
fn format_inspect_output(info: &meerkat_mob_pack::pack::InspectResult) -> String {
let mut out = String::new();
out.push_str(&format!("name\t{}\n", info.name));
out.push_str(&format!("version\t{}\n", info.version));
out.push_str(&format!("file_count\t{}\n", info.file_count));
out.push_str(&format!("digest\t{}\n", info.digest));
for file in &info.files {
out.push_str(&format!("file\t{file}\n"));
}
out
}
#[cfg(feature = "mob")]
async fn execute_mob_pack(
dir: &std::path::Path,
output: &std::path::Path,
signing: Option<meerkat_mob_pack::pack::SigningRequest<'_>>,
) -> anyhow::Result<String> {
let result = pack_directory_with_excludes(dir, signing, &[output])
.map_err(|err| anyhow::anyhow!("mob pack failed: {err}"))?;
tokio::fs::write(output, &result.archive_bytes)
.await
.map_err(|err| anyhow::anyhow!("failed writing archive '{}': {err}", output.display()))?;
Ok(result.digest.to_string())
}
#[cfg(feature = "mob")]
async fn execute_mob_inspect(pack: &std::path::Path) -> anyhow::Result<String> {
let bytes = tokio::fs::read(pack)
.await
.map_err(|err| anyhow::anyhow!("failed reading pack '{}': {err}", pack.display()))?;
let info = inspect_archive_bytes(&bytes)
.map_err(|err| anyhow::anyhow!("mob inspect failed: {err}"))?;
Ok(format_inspect_output(&info))
}
#[cfg(feature = "mob")]
struct VerifiedMobpack {
bytes: Vec<u8>,
archive: MobpackArchive,
digest: meerkat_mob_pack::digest::MobpackDigest,
trust_warnings: Vec<String>,
}
#[cfg(feature = "mob")]
async fn load_verified_mobpack(
scope: &RuntimeScope,
pack: &std::path::Path,
cli_trust_policy: Option<TrustPolicyArg>,
action: &'static str,
) -> anyhow::Result<VerifiedMobpack> {
let bytes = tokio::fs::read(pack)
.await
.map_err(|err| anyhow::anyhow!("failed reading pack '{}': {err}", pack.display()))?;
let files = extract_targz_safe(&bytes).map_err(|err| anyhow::anyhow!("{action}: {err}"))?;
let archive = MobpackArchive::from_extracted_files(&files)
.map_err(|err| anyhow::anyhow!("{action}: {err}"))?;
let config_trust = read_config_trust_policy(scope)?;
let trust_policy = resolve_trust_policy(
cli_trust_policy,
|key| std::env::var(key).ok(),
config_trust,
)?;
let trusted_signers = load_trusted_signers(
&user_trust_store_path(scope),
&project_trust_store_path(scope),
)
.map_err(|err| anyhow::anyhow!("{action}: {err}"))?;
let trust_verification = verify_extracted_pack_trust(&files, trust_policy, &trusted_signers)
.map_err(|err| anyhow::anyhow!("{action}: {err}"))?;
Ok(VerifiedMobpack {
bytes,
archive,
digest: trust_verification.digest,
trust_warnings: trust_verification.warnings,
})
}
#[cfg(feature = "mob")]
async fn execute_mob_validate(
scope: &RuntimeScope,
pack: &std::path::Path,
cli_trust_policy: Option<TrustPolicyArg>,
) -> anyhow::Result<String> {
let verified =
load_verified_mobpack(scope, pack, cli_trust_policy, "mob validate failed").await?;
let mut rendered = format!("valid\t{}", verified.digest);
for warning in verified.trust_warnings {
rendered.push_str(&format!("\nwarning\t{warning}"));
}
Ok(rendered)
}
#[cfg(feature = "mob")]
async fn execute_mob_web_build(
scope: &RuntimeScope,
pack: &std::path::Path,
output: &std::path::Path,
cli_trust_policy: Option<TrustPolicyArg>,
) -> anyhow::Result<String> {
let verified =
load_verified_mobpack(scope, pack, cli_trust_policy, "mob web build failed").await?;
if let Some(requires) = &verified.archive.manifest.requires {
for cap in &requires.capabilities {
if matches!(cap.as_str(), "shell" | "mcp_stdio" | "process_spawn") {
anyhow::bail!("forbidden capability '{cap}' is not allowed for web builds");
}
}
}
tokio::fs::create_dir_all(output).await.map_err(|err| {
anyhow::anyhow!("failed creating web output '{}': {err}", output.display())
})?;
tokio::fs::write(output.join("mobpack.bin"), &verified.bytes)
.await
.map_err(|err| anyhow::anyhow!("failed writing mobpack.bin: {err}"))?;
tokio::fs::write(
output.join("manifest.web.toml"),
toml::to_string(&verified.archive.manifest)
.map_err(|err| anyhow::anyhow!("failed encoding web manifest: {err}"))?,
)
.await
.map_err(|err| anyhow::anyhow!("failed writing manifest.web.toml: {err}"))?;
tokio::fs::write(
output.join("index.html"),
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Meerkat Mob</title></head><body><script type=\"module\" src=\"./runtime.js\"></script></body></html>\n",
)
.await
.map_err(|err| anyhow::anyhow!("failed writing index.html: {err}"))?;
tokio::fs::write(
output.join("runtime.js"),
"export const mobpackUrl = './mobpack.bin';\nexport const wasmUrl = './runtime_bg.wasm';\n",
)
.await
.map_err(|err| anyhow::anyhow!("failed writing runtime.js: {err}"))?;
tokio::fs::write(output.join("runtime_bg.wasm"), [])
.await
.map_err(|err| anyhow::anyhow!("failed writing runtime_bg.wasm: {err}"))?;
let mut rendered = format!("web\t{}", output.display());
for warning in verified.trust_warnings {
rendered.push_str(&format!("\nwarning\t{warning}"));
}
Ok(rendered)
}
#[cfg(feature = "mob")]
async fn execute_mob_deploy(
scope: &RuntimeScope,
pack: &std::path::Path,
prompt: &str,
cli_trust_policy: Option<TrustPolicyArg>,
surface: DeploySurfaceArg,
cli_overrides: CliOverrides,
) -> anyhow::Result<String> {
Box::pin(execute_mob_deploy_internal(
scope,
pack,
prompt,
DeployInvocation {
cli_trust_policy,
surface,
cli_overrides,
rpc_io: None,
config_observer: None,
},
))
.await
}
#[cfg(feature = "mob")]
type RpcDeployIo = (
Box<dyn AsyncBufRead + Send + Unpin>,
Box<dyn AsyncWrite + Send + Unpin>,
);
#[cfg(feature = "mob")]
type DeployConfigObserver = Arc<dyn Fn(&Config) + Send + Sync>;
#[cfg(feature = "mob")]
struct DeployInvocation {
cli_trust_policy: Option<TrustPolicyArg>,
surface: DeploySurfaceArg,
cli_overrides: CliOverrides,
rpc_io: Option<RpcDeployIo>,
config_observer: Option<DeployConfigObserver>,
}
#[cfg(feature = "mob")]
async fn execute_mob_deploy_internal(
scope: &RuntimeScope,
pack: &std::path::Path,
prompt: &str,
invocation: DeployInvocation,
) -> anyhow::Result<String> {
let VerifiedMobpack {
archive,
trust_warnings: warnings,
..
} = load_verified_mobpack(
scope,
pack,
invocation.cli_trust_policy,
"mob deploy failed",
)
.await?;
validate_required_capabilities(&archive.manifest, &runtime_capabilities(invocation.surface))
.map_err(|err| anyhow::anyhow!("mob deploy failed: {err}"))?;
let effective_config = load_deploy_config_with_pack_defaults(
scope,
archive.config.get("config/defaults.toml"),
invocation.cli_overrides,
)?;
if let Some(observer) = invocation.config_observer.as_ref() {
observer(&effective_config);
}
let deployed_mob_id = if matches!(invocation.surface, DeploySurfaceArg::Rpc) {
let (reader, writer): RpcDeployIo = invocation.rpc_io.unwrap_or_else(|| {
(
Box::new(BufReader::new(tokio::io::stdin())),
Box::new(tokio::io::stdout()),
)
});
Box::pin(run_rpc_surface(
scope,
effective_config,
&archive,
prompt,
reader,
writer,
))
.await?
} else {
let session_service =
build_deploy_mob_session_service(scope, effective_config.clone()).await?;
let mut builder = meerkat_mob::MobBuilder::from_mobpack(
archive.definition.clone(),
archive.skills.clone(),
meerkat_mob::MobStorage::in_memory(),
)
.map_err(|err| anyhow::anyhow!("mob deploy failed: {err}"))?
.with_session_service(session_service.clone());
if let Some(adapter) = session_service.runtime_adapter() {
builder = builder.with_runtime_adapter(adapter);
}
let handle = builder
.create()
.await
.map_err(|err| anyhow::anyhow!("mob deploy failed: {err}"))?;
if let Some(orchestrator) = &archive.definition.orchestrator {
let roster = handle.roster().await;
if let Some(entry) = roster.by_profile(&orchestrator.profile).next() {
handle
.member(&entry.agent_identity)
.await
.map_err(|err| anyhow::anyhow!("mob deploy failed: {err}"))?
.send(prompt.to_string(), meerkat_core::types::HandlingMode::Queue)
.await
.map_err(|err| anyhow::anyhow!("mob deploy failed: {err}"))?;
}
}
handle.mob_id().to_string()
};
let mut rendered = format!(
"deployed\tmob={}\tsurface={}\tprompt_bytes={}",
deployed_mob_id,
match invocation.surface {
DeploySurfaceArg::Cli => "cli",
DeploySurfaceArg::Rpc => "rpc",
},
prompt.len()
);
for warning in warnings {
rendered.push_str(&format!("\nwarning\t{warning}"));
}
Ok(rendered)
}
#[cfg(feature = "mob")]
fn resolve_trust_policy<F>(
cli_policy: Option<TrustPolicyArg>,
mut env_lookup: F,
config_policy: Option<TrustPolicy>,
) -> anyhow::Result<TrustPolicy>
where
F: FnMut(&str) -> Option<String>,
{
if let Some(policy) = cli_policy {
return Ok(policy.into());
}
if let Some(raw_env) = env_lookup("RKAT_TRUST_POLICY") {
return parse_trust_policy(raw_env.trim())
.ok_or_else(|| anyhow::anyhow!("invalid RKAT_TRUST_POLICY value '{raw_env}'"));
}
if let Some(policy) = config_policy {
return Ok(policy);
}
Ok(TrustPolicy::Permissive)
}
#[cfg(feature = "mob")]
fn parse_trust_policy(raw: &str) -> Option<TrustPolicy> {
match raw.to_ascii_lowercase().as_str() {
"permissive" => Some(TrustPolicy::Permissive),
"strict" => Some(TrustPolicy::Strict),
_ => None,
}
}
#[cfg(feature = "mob")]
fn read_config_trust_policy(scope: &RuntimeScope) -> anyhow::Result<Option<TrustPolicy>> {
let config_path =
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str())
.config_path;
if !config_path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&config_path).map_err(|err| {
anyhow::anyhow!("failed reading config '{}': {err}", config_path.display())
})?;
let value: toml::Value =
toml::from_str(&content).map_err(|err| anyhow::anyhow!("invalid config TOML: {err}"))?;
let Some(policy_raw) = value
.get("trust")
.and_then(|v| v.get("policy"))
.and_then(toml::Value::as_str)
else {
return Ok(None);
};
parse_trust_policy(policy_raw)
.ok_or_else(|| anyhow::anyhow!("invalid trust.policy value '{policy_raw}'"))
.map(Some)
}
#[cfg(feature = "mob")]
fn load_deploy_config_with_pack_defaults(
scope: &RuntimeScope,
pack_defaults_toml: Option<&Vec<u8>>,
cli_overrides: CliOverrides,
) -> anyhow::Result<Config> {
let mut config = Config::default();
if let Some(pack_defaults_toml) = pack_defaults_toml {
apply_toml_delta_layer(&mut config, pack_defaults_toml, "config/defaults.toml")?;
}
let config_path =
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str())
.config_path;
if config_path.exists() {
let file_bytes = std::fs::read(&config_path).map_err(|err| {
anyhow::anyhow!("failed reading config '{}': {err}", config_path.display())
})?;
apply_toml_delta_layer(&mut config, &file_bytes, &config_path.display().to_string())?;
}
config
.apply_env_overrides()
.map_err(|err| anyhow::anyhow!("failed applying env overrides: {err}"))?;
config.apply_cli_overrides(cli_overrides);
Ok(config)
}
#[cfg(feature = "mob")]
fn apply_toml_delta_layer(
config: &mut Config,
toml_bytes: &[u8],
source_label: &str,
) -> anyhow::Result<()> {
let text = std::str::from_utf8(toml_bytes)
.map_err(|err| anyhow::anyhow!("invalid UTF-8 in {source_label}: {err}"))?;
config
.merge_toml_str(text)
.map_err(|err| anyhow::anyhow!("invalid TOML in {source_label}: {err}"))
}
#[cfg(feature = "mob")]
fn user_trust_store_path(scope: &RuntimeScope) -> PathBuf {
scope
.user_config_root
.clone()
.unwrap_or_else(|| PathBuf::from("."))
.join(".rkat")
.join("trusted-signers.toml")
}
#[cfg(feature = "mob")]
fn project_trust_store_path(scope: &RuntimeScope) -> PathBuf {
scope
.context_root
.clone()
.unwrap_or_else(|| PathBuf::from("."))
.join(".rkat")
.join("trusted-signers.toml")
}
#[cfg(feature = "mob")]
fn runtime_capabilities(surface: DeploySurfaceArg) -> std::collections::BTreeSet<String> {
let mut caps = std::collections::BTreeSet::from([
"core".to_string(),
"skills".to_string(),
"hooks".to_string(),
]);
#[cfg(feature = "comms")]
caps.insert("comms".to_string());
#[cfg(feature = "mcp")]
caps.insert("mcp".to_string());
if matches!(surface, DeploySurfaceArg::Rpc) {
caps.insert("rpc".to_string());
}
caps
}
#[cfg(feature = "mob")]
fn validate_required_capabilities(
manifest: &meerkat_mob_pack::manifest::MobpackManifest,
runtime_caps: &std::collections::BTreeSet<String>,
) -> Result<(), meerkat_mob_pack::validate::PackValidationError> {
if let Some(requires) = &manifest.requires {
for required in &requires.capabilities {
if !runtime_caps.contains(required) {
return Err(
meerkat_mob_pack::validate::PackValidationError::CapabilityMismatch(
required.clone(),
),
);
}
}
}
Ok(())
}
#[cfg(feature = "mob")]
async fn run_rpc_surface<R, W>(
scope: &RuntimeScope,
config: Config,
archive: &MobpackArchive,
prompt: &str,
reader: R,
writer: W,
) -> anyhow::Result<String>
where
R: AsyncBufRead + Unpin,
// RPC-host: deploy_mob inline-hosts an RpcServer for the deployed mob session.
// `TransportWriter` is the trait bound carrying the RPC-wire write half
// (writes framed `RpcResponse`/`RpcNotification` JSON-RPC envelopes — see
// `meerkat-rpc/src/transport.rs`). Lifting this would require duplicating
// the RPC wire framer; this site legitimately owns the RPC-host role.
W: meerkat_rpc::transport::TransportWriter,
{
let (manifest, persistence) = create_persistence_bundle(scope).await?;
let session_store = persistence.session_store();
let paths =
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str());
let base_store: Arc<dyn ConfigStore> =
Arc::new(FileConfigStore::new(paths.config_path.clone()));
let tagged = meerkat_core::TaggedConfigStore::new(
base_store,
meerkat_core::ConfigStoreMetadata {
realm_id: Some(scope.locator.realm.as_str().to_owned()),
instance_id: scope.instance_id.clone(),
backend: Some(manifest.backend.as_str().to_string()),
resolved_paths: Some(meerkat_core::ConfigResolvedPaths {
root: paths.root.display().to_string(),
manifest_path: paths.manifest_path.display().to_string(),
config_path: paths.config_path.display().to_string(),
sessions_sqlite_path: Some(paths.sessions_sqlite_path.display().to_string()),
sessions_jsonl_dir: paths.sessions_jsonl_dir.display().to_string(),
}),
},
);
let config_store: Arc<dyn ConfigStore> = Arc::new(tagged);
let project_root = scope.context_root.clone().unwrap_or_else(|| {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
find_project_root(&cwd).unwrap_or(cwd)
});
let mut factory = AgentFactory::new(realm_store_path(&manifest, scope))
.session_store(session_store.clone())
.runtime_root(paths.root.clone())
.project_root(project_root)
.builtins(config.tools.builtins_enabled)
.shell(config.tools.shell_enabled)
.workgraph(config.tools.workgraph_enabled)
.memory(true);
if let Some(context_root) = scope.context_root.clone() {
factory = factory.context_root(context_root);
}
if let Some(user_root) = scope.user_config_root.clone() {
factory = factory.user_config_root(user_root);
}
let skill_runtime = factory.build_skill_runtime(&config).await?;
let config_runtime = Arc::new(meerkat_core::ConfigRuntime::new(
Arc::clone(&config_store),
paths.root.join("config_state.json"),
));
let max_sessions = config.max_sessions();
// RPC-host: this `SessionRuntime` is the backing runtime for the inline
// RpcServer constructed below at `RpcServer::new_with_skill_runtime_and_mob_state`.
// `SessionRuntime::new_with_config_store` is the RPC-host constructor —
// it owns the JSON-RPC dispatch loop, callback channel, and notification
// fan-out. `NotificationSink` (next line) wraps `mpsc::Sender<RpcNotification>`
// (RPC wire type — see `meerkat-rpc/src/router.rs`). Both are RPC-host
// contracts and not lift candidates.
let mut runtime = meerkat_rpc::session_runtime::SessionRuntime::new_with_config_store(
factory,
config.clone(),
Arc::clone(&config_store),
max_sessions,
persistence,
meerkat_rpc::router::NotificationSink::noop(),
);
let session_service = runtime.session_service();
let runtime_adapter = runtime.runtime_adapter();
let default_user_root = std::env::var_os("HOME").map(std::path::PathBuf::from);
let identity_registry = meerkat::session_runtime::runtime_state::build_skill_identity_registry(
&config,
scope.context_root.as_deref(),
scope
.user_config_root
.as_deref()
.or(default_user_root.as_deref()),
)
.map_err(|err| anyhow::anyhow!("failed to build skill identity registry: {err}"))?;
runtime.set_skill_identity_roots(
scope.context_root.clone(),
scope.user_config_root.clone().or(default_user_root),
);
runtime.set_skill_identity_registry(identity_registry);
runtime.set_config_runtime(config_runtime);
// Capture the builder's mob tools slot so we can set the factory AFTER
// hydration (using the same MobMcpState that the router will use for cleanup).
let mob_tools_slot = Arc::clone(&runtime.builder_mob_tools_slot);
// Set realm context before Arc-wrapping (requires &mut self).
// The mob_id is known from the definition before the handle is created.
let deployed_mob_id = archive.definition.id.to_string();
runtime.set_realm_context(
Some(scope.locator.realm.clone()),
scope
.instance_id
.clone()
.or_else(|| Some(format!("mobpack:{deployed_mob_id}"))),
Some(manifest.backend.as_str().to_string()),
);
let runtime = Arc::new(runtime);
// Pre-initialize the callback channel so the ExternalToolsProvider closure
// can read callback_request_tx() during mob creation and resume.
let callback_rx = runtime.init_callback_channel();
let (mcp_external_tools, _mcp_adapter_guard) =
load_mcp_external_tools(scope, false, None).await;
let external_tools_provider: Option<meerkat_mob::ExternalToolsProvider> = Some(Arc::new({
let runtime = runtime.clone();
let mcp_external_tools = mcp_external_tools.clone();
move || {
let tx = runtime.callback_request_tx()?;
// RPC-host: `CallbackToolDispatcher::new` takes a callback request
// channel typed as `mpsc::Sender<(RpcRequest, oneshot::Sender<RpcResponse>)>`
// plus an `RpcId` counter (see `meerkat-rpc/src/callback_dispatcher.rs`).
// Both are JSON-RPC wire types — the dispatcher round-trips tool
// calls back through the hosted RpcServer's connected client.
// Lifting would require rebuilding the JSON-RPC request/response
// pair on a non-RPC channel; this site legitimately owns the
// RPC-host role.
let callback_tools: Arc<dyn meerkat_core::AgentToolDispatcher> = Arc::new(
meerkat_rpc::callback_dispatcher::CallbackToolDispatcher::new(
runtime.registered_tools(),
tx,
runtime.callback_id_counter(),
vec![],
),
);
compose_rpc_mob_external_tools(callback_tools, mcp_external_tools.clone())
}
}));
let mut builder = meerkat_mob::MobBuilder::from_mobpack(
archive.definition.clone(),
archive.skills.clone(),
meerkat_mob::MobStorage::in_memory(),
)
.map_err(|err| anyhow::anyhow!("mob deploy failed: {err}"))?
.with_session_service(session_service.clone())
.with_default_external_tools_provider(external_tools_provider.clone());
builder = builder.with_runtime_adapter(runtime_adapter.clone());
let handle = builder
.create()
.await
.map_err(|err| anyhow::anyhow!("mob deploy failed: {err}"))?;
if let Some(orchestrator) = &archive.definition.orchestrator {
let roster = handle.roster().await;
if let Some(entry) = roster.by_profile(&orchestrator.profile).next() {
handle
.member(&entry.agent_identity)
.await
.map_err(|err| anyhow::anyhow!("mob deploy failed: {err}"))?
.send(prompt.to_string(), meerkat_core::types::HandlingMode::Queue)
.await
.map_err(|err| anyhow::anyhow!("mob deploy failed: {err}"))?;
}
}
persist_mob_handle_snapshot(
scope,
session_service.clone(),
&handle,
Some(archive.definition.clone()),
)
.await?;
let default_llm_client_provider = Some(Arc::new({
let runtime = runtime.clone();
move || runtime.default_llm_client()
})
as Arc<dyn Fn() -> Option<Arc<dyn meerkat_client::LlmClient>> + Send + Sync + 'static>);
let seeded_handles = std::collections::BTreeMap::from([(deployed_mob_id.clone(), handle)]);
let (mob_state, _) = hydrate_mob_state(
scope,
session_service,
Some(runtime_adapter),
default_llm_client_provider,
external_tools_provider,
seeded_handles,
)
.await?;
// Set mob tools factory using the SAME hydrated state the router will use.
// This ensures agent-created mobs (via delegate/mob_create) live in the
// same registry that archive cleanup scans.
*mob_tools_slot
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(
meerkat_mob_mcp::AgentMobToolSurfaceFactory::new(Arc::clone(&mob_state)),
)
as Arc<dyn meerkat_core::service::MobToolsFactory>);
// RPC-host: this is the canonical RPC-host marker for `deploy_mob` —
// the function inline-hosts a full `RpcServer` over the supplied
// reader/writer for the lifetime of the deployed mob session.
// `RpcServer::new_with_skill_runtime_and_mob_state` owns the JSON-RPC
// method router (`meerkat-rpc/src/server.rs`), wires `RpcRequest` →
// method dispatch and emits `RpcNotification`s back to the connected
// client. By definition this cannot be lifted — `deploy_mob`'s job
// *is* to be an RPC host.
let mut server = meerkat_rpc::server::RpcServer::new_with_skill_runtime_and_mob_state(
reader,
writer,
runtime,
config_store,
skill_runtime,
mob_state,
callback_rx,
);
server
.run()
.await
.map_err(|err| anyhow::anyhow!("rpc server failed: {err}"))?;
Ok(deployed_mob_id)
}
/// LLM Provider selection
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, Default)]
pub enum Provider {
/// Anthropic Claude models
#[default]
Anthropic,
/// OpenAI GPT models
Openai,
/// Google Gemini models
Gemini,
/// Self-hosted models registered in config
SelfHosted,
}
impl Provider {
/// Infer provider from the built-in model catalog.
/// Returns None for uncatalogued models; self-hosted aliases resolve
/// through `Config::model_registry()` in `resolve_cli_provider`.
pub fn infer_from_model(model: &str) -> Option<Self> {
meerkat_core::Provider::infer_from_model(model).and_then(Provider::from_core)
}
/// Convert to string for storage in session metadata
pub fn as_str(&self) -> &'static str {
match self {
Provider::Anthropic => "anthropic",
Provider::Openai => "openai",
Provider::Gemini => "gemini",
Provider::SelfHosted => "self_hosted",
}
}
/// Convert to the core Provider enum.
pub fn as_core(self) -> meerkat_core::Provider {
match self {
Provider::Anthropic => meerkat_core::Provider::Anthropic,
Provider::Openai => meerkat_core::Provider::OpenAI,
Provider::Gemini => meerkat_core::Provider::Gemini,
Provider::SelfHosted => meerkat_core::Provider::SelfHosted,
}
}
/// Convert from the core Provider enum.
pub fn from_core(provider: meerkat_core::Provider) -> Option<Self> {
match provider {
meerkat_core::Provider::Anthropic => Some(Provider::Anthropic),
meerkat_core::Provider::OpenAI => Some(Provider::Openai),
meerkat_core::Provider::Gemini => Some(Provider::Gemini),
meerkat_core::Provider::SelfHosted => Some(Provider::SelfHosted),
meerkat_core::Provider::Other => None,
}
}
/// Parse from string (for restoring from session metadata)
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"anthropic" => Some(Provider::Anthropic),
"openai" => Some(Provider::Openai),
"gemini" => Some(Provider::Gemini),
_ => None,
}
}
}
fn resolve_cli_provider(
config: &Config,
model: &str,
explicit: Option<Provider>,
) -> anyhow::Result<Provider> {
if let Some(provider) = explicit {
if let Some(reason) = config.model_registry().ok().and_then(|registry| {
registry.provider_override_mismatch_reason(provider.as_core(), model)
}) {
anyhow::bail!(reason);
}
return Ok(provider);
}
if let Some(provider) = config
.model_registry()
.ok()
.and_then(|registry| registry.entry(model).map(|entry| entry.provider))
.and_then(Provider::from_core)
.or_else(|| Provider::infer_from_model(model))
{
return Ok(provider);
}
Err(anyhow::anyhow!(
"Cannot infer provider from model '{model}'. Use --provider or register a self-hosted model alias."
))
}
struct CliAuthBindingSelection {
provider: Provider,
default_model: Option<String>,
}
fn resolve_cli_auth_binding_selection(
config: &Config,
auth_binding: &AuthBindingRef,
) -> anyhow::Result<CliAuthBindingSelection> {
let realm_id = auth_binding.realm.as_str();
let section = config
.realm
.get(realm_id)
.ok_or_else(|| anyhow::anyhow!("Unknown realm '{realm_id}'"))?;
let realm_set = meerkat_core::RealmConnectionSet::from_config(realm_id, section)
.map_err(|e| anyhow::anyhow!("Realm config invalid for '{realm_id}': {e}"))?;
let (binding, backend, _auth) = realm_set.lookup_auth_binding(auth_binding).map_err(|e| {
anyhow::anyhow!(
"Auth binding '{}:{}' invalid: {e}",
auth_binding.realm.as_str(),
auth_binding.binding.as_str()
)
})?;
let provider = Provider::from_core(backend.provider).ok_or_else(|| {
anyhow::anyhow!(
"Auth binding '{}:{}' resolves unsupported provider '{}'",
auth_binding.realm.as_str(),
auth_binding.binding.as_str(),
backend.provider.as_str()
)
})?;
Ok(CliAuthBindingSelection {
provider,
default_model: binding.default_model.clone(),
})
}
fn resolve_cli_provider_with_auth_binding(
config: &Config,
model: &str,
explicit: Option<Provider>,
auth_binding: Option<&CliAuthBindingSelection>,
) -> anyhow::Result<Provider> {
if let Some(provider) = explicit {
let resolved = resolve_cli_provider(config, model, Some(provider))?;
if let Some(selection) = auth_binding
&& selection.provider != resolved
{
anyhow::bail!(
"--auth-binding selects provider '{}', but --provider selected '{}'",
selection.provider.as_str(),
resolved.as_str()
);
}
return Ok(resolved);
}
if let Some(selection) = auth_binding {
if let Some(reason) = config.model_registry().ok().and_then(|registry| {
registry.provider_override_mismatch_reason(selection.provider.as_core(), model)
}) {
anyhow::bail!(
"--auth-binding selects provider '{}', but {reason}",
selection.provider.as_str()
);
}
return Ok(selection.provider);
}
resolve_cli_provider(config, model, None)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use async_trait::async_trait;
use futures::stream;
use meerkat_client::{LlmClient, LlmDoneOutcome, LlmError, LlmEvent, LlmRequest};
use meerkat_core::agent::CommsRuntime as CoreCommsRuntime;
use meerkat_core::comms::{CommsCommand, SendError, SendReceipt, TrustedPeerDescriptor};
use meerkat_core::error::ToolError;
use meerkat_core::interaction::{InteractionId, PeerInputCandidate};
use meerkat_core::service::{
SessionError, SessionInfo, SessionSummary, SessionUsage, SessionView, StartTurnRequest,
};
use meerkat_core::types::{RunResult, Usage};
use meerkat_core::{ToolCallView, ToolDef, ToolDispatchOutcome, ToolResult};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;
use tokio::sync::RwLock;
fn hooks_override_fixture_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test-fixtures/hooks/run_override.json")
}
fn fixture_skill_key(name: &str) -> meerkat_core::skills::SkillKey {
let skill_name = meerkat_core::skills::SkillName::parse(name)
.expect("fixture skill name should be valid");
meerkat_core::skills::SkillKey::new(meerkat_core::skills::SourceUuid::builtin(), skill_name)
}
#[cfg(feature = "session-store")]
fn sqlite_session_store(temp: &tempfile::TempDir) -> Arc<dyn meerkat::SessionStore> {
Arc::new(
meerkat::SqliteSessionStore::open(temp.path().join("sessions.sqlite"))
.expect("sqlite session store should open"),
)
}
#[test]
fn materialized_preload_skills_preserves_typed_skill_keys() {
let key = fixture_skill_key("email");
assert_eq!(
materialized_preload_skills(std::slice::from_ref(&key)),
Some(vec![key])
);
}
#[test]
fn materialized_preload_skills_leaves_empty_preload_unset() {
let preload_skills: Vec<meerkat_core::skills::SkillKey> = Vec::new();
assert_eq!(materialized_preload_skills(&preload_skills), None);
}
#[test]
fn cli_context_system_notice_projects_via_typed_notice() {
let blocks = vec![meerkat_core::types::SystemNoticeBlock::Comms {
kind: "response_terminal".to_string(),
direction: meerkat_core::types::SystemNoticeDirection::Incoming,
peer: None,
request_id: Some("req-1".to_string()),
intent: Some("checksum_token".to_string()),
status: Some("completed".to_string()),
summary: Some("Peer terminal response".to_string()),
payload: None,
content: Vec::new(),
}];
let content = meerkat_core::lifecycle::run_primitive::CoreRenderable::SystemNotice {
kind: meerkat_core::types::SystemNoticeKind::Comms,
body: Some("Peer terminal response context".to_string()),
blocks: blocks.clone(),
};
assert_eq!(
cli_render_context_append_text(&content),
meerkat_core::types::SystemNoticeMessage::with_blocks(
meerkat_core::types::SystemNoticeKind::Comms,
Some("Peer terminal response context".to_string()),
blocks,
)
.model_projection_text()
);
}
#[test]
fn interrupt_not_ready_noop_is_only_idle_or_attached() {
assert!(interrupt_not_ready_is_noop(
meerkat_runtime::RuntimeState::Idle
));
assert!(interrupt_not_ready_is_noop(
meerkat_runtime::RuntimeState::Attached
));
assert!(!interrupt_not_ready_is_noop(
meerkat_runtime::RuntimeState::Destroyed
));
assert!(!interrupt_not_ready_is_noop(
meerkat_runtime::RuntimeState::Retired
));
assert!(!interrupt_not_ready_is_noop(
meerkat_runtime::RuntimeState::Stopped
));
}
fn test_scope(state_root: PathBuf, realm_id: &str) -> RuntimeScope {
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
let (auth_lease, oauth_flow_authority) = new_cli_auth_handles();
#[cfg(not(all(feature = "anthropic", feature = "openai", feature = "gemini")))]
let auth_lease = new_cli_auth_lease();
RuntimeScope {
locator: RealmLocator {
state_root,
realm: meerkat_core::connection::RealmId::parse(realm_id)
.expect("test realm id parses"),
},
instance_id: None,
backend_hint: Some(RealmBackend::Sqlite),
origin_hint: RealmOrigin::Explicit,
context_root: None,
user_config_root: None,
auth_lease,
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
oauth_flow_authority,
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[tokio::test]
async fn test_cli_login_save_boundary_publishes_binding_scoped_auth_lease() {
use meerkat_core::handles::{AuthLeaseHandle, AuthLeasePhase, LeaseKey};
use meerkat_providers::auth_store::{
EphemeralTokenStore, PersistedAuthMode, PersistedTokens, TokenKey, TokenStore,
};
let store = EphemeralTokenStore::new();
let auth_lease = meerkat_runtime::RuntimeAuthLeaseHandle::new();
let auth_binding = meerkat_core::AuthBindingRef {
realm: meerkat_core::RealmId::parse("dev").expect("realm id parses"),
binding: meerkat_core::BindingId::parse("default_openai").expect("binding id parses"),
profile: None,
};
let tokens = PersistedTokens {
auth_mode: PersistedAuthMode::ApiKey,
primary_secret: Some("sk-test".into()),
refresh_token: None,
id_token: None,
expires_at: None,
last_refresh: Some(chrono::Utc::now()),
scopes: Vec::new(),
account_id: None,
metadata: serde_json::Value::Null,
};
save_cli_tokens_and_publish_lifecycle(&store, &auth_lease, &auth_binding, &tokens)
.await
.expect("login save boundary should publish lease lifecycle");
let lease_key = LeaseKey::from_auth_binding(&auth_binding);
assert_eq!(
auth_lease.snapshot(&lease_key).phase,
Some(AuthLeasePhase::Valid),
"CLI login must acquire the binding-scoped AuthMachine lease that status reads"
);
assert!(
store
.load(&TokenKey::from_auth_binding(&auth_binding))
.await
.expect("token load succeeds")
.is_some(),
"login save boundary should still persist token material"
);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[test]
fn test_cli_interactive_login_synthesized_oauth_config_is_toml_serializable() {
let mut config = Config::default();
assert!(ensure_cli_interactive_oauth_config(
LoginProvider::OpenAi,
&mut config
));
let rendered = toml::to_string_pretty(&config)
.expect("first-time interactive OAuth config must serialize as TOML");
let reparsed: Config =
toml::from_str(&rendered).expect("serialized OAuth config must parse back");
let target =
resolve_configured_cli_interactive_oauth_target(LoginProvider::OpenAi, &reparsed)
.expect("reparsed OAuth login target must remain valid");
assert_eq!(target.auth_binding.realm.as_str(), "dev");
assert_eq!(target.auth_binding.binding.as_str(), "openai_oauth");
assert_eq!(target.auth_profile.auth_method, "managed_chatgpt_oauth");
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[test]
fn test_cli_interactive_oauth_config_uses_current_provider_default_models() {
let mut config = Config::default();
assert!(ensure_cli_interactive_oauth_config(
LoginProvider::Anthropic,
&mut config
));
assert!(ensure_cli_interactive_oauth_config(
LoginProvider::OpenAi,
&mut config
));
let realm = config.realm.get("dev").expect("dev realm");
assert_eq!(
realm
.binding
.get("anthropic_oauth")
.and_then(|binding| binding.default_model.as_deref()),
meerkat_core::model_profile::catalog::default_model("anthropic")
);
assert_eq!(
realm
.binding
.get("openai_oauth")
.and_then(|binding| binding.default_model.as_deref()),
meerkat_core::model_profile::catalog::default_model("openai")
);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[test]
fn test_cli_interactive_oauth_config_heals_legacy_provider_default_models() {
let mut config = Config::default();
assert!(ensure_cli_interactive_oauth_config(
LoginProvider::Anthropic,
&mut config
));
assert!(ensure_cli_interactive_oauth_config(
LoginProvider::OpenAi,
&mut config
));
let realm = config.realm.get_mut("dev").expect("dev realm");
realm
.binding
.get_mut("anthropic_oauth")
.expect("anthropic oauth binding")
.default_model = Some("claude-sonnet-4-6".to_string());
realm
.binding
.get_mut("openai_oauth")
.expect("openai oauth binding")
.default_model = Some("gpt-5.4".to_string());
assert!(ensure_cli_interactive_oauth_config(
LoginProvider::Anthropic,
&mut config
));
assert!(ensure_cli_interactive_oauth_config(
LoginProvider::OpenAi,
&mut config
));
let realm = config.realm.get("dev").expect("dev realm");
assert_eq!(
realm
.binding
.get("anthropic_oauth")
.and_then(|binding| binding.default_model.as_deref()),
meerkat_core::model_profile::catalog::default_model("anthropic")
);
assert_eq!(
realm
.binding
.get("openai_oauth")
.and_then(|binding| binding.default_model.as_deref()),
meerkat_core::model_profile::catalog::default_model("openai")
);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[test]
fn test_cli_interactive_google_oauth_config_includes_code_assist_base_url() {
let mut config = Config::default();
assert!(ensure_cli_interactive_oauth_config(
LoginProvider::Google,
&mut config
));
let backend = config
.realm
.get("dev")
.expect("dev realm")
.backend
.get("google_code_assist")
.expect("google code assist backend");
assert_eq!(
backend.base_url.as_deref(),
Some("https://cloudcode-pa.googleapis.com")
);
assert!(
!ensure_cli_interactive_oauth_config(LoginProvider::Google, &mut config),
"complete synthesized config should not be rewritten"
);
config
.realm
.get_mut("dev")
.expect("dev realm")
.backend
.get_mut("google_code_assist")
.expect("google code assist backend")
.base_url = None;
assert!(
ensure_cli_interactive_oauth_config(LoginProvider::Google, &mut config),
"legacy synthesized Google OAuth config without base_url should be healed"
);
let backend = config
.realm
.get("dev")
.expect("dev realm")
.backend
.get("google_code_assist")
.expect("google code assist backend");
assert_eq!(
backend.base_url.as_deref(),
Some("https://cloudcode-pa.googleapis.com")
);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[test]
fn test_cli_interactive_openai_oauth_config_uses_codex_backend_base_url() {
let mut config = Config::default();
assert!(ensure_cli_interactive_oauth_config(
LoginProvider::OpenAi,
&mut config
));
let backend = config
.realm
.get("dev")
.expect("dev realm")
.backend
.get("openai_chatgpt")
.expect("openai chatgpt backend");
assert_eq!(
backend.base_url.as_deref(),
Some("https://chatgpt.com/backend-api/codex")
);
config
.realm
.get_mut("dev")
.expect("dev realm")
.backend
.get_mut("openai_chatgpt")
.expect("openai chatgpt backend")
.base_url = Some("https://chatgpt.com/backend-api".into());
assert!(
ensure_cli_interactive_oauth_config(LoginProvider::OpenAi, &mut config),
"legacy ChatGPT backend URL should be healed to the Codex endpoint"
);
let backend = config
.realm
.get("dev")
.expect("dev realm")
.backend
.get("openai_chatgpt")
.expect("openai chatgpt backend");
assert_eq!(
backend.base_url.as_deref(),
Some("https://chatgpt.com/backend-api/codex")
);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[test]
fn test_cli_interactive_anthropic_oauth_uses_localhost_redirect_host() {
assert_eq!(
LoginProvider::Anthropic.callback_redirect_host(),
"localhost"
);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn openai_oauth_login_config(
auth_method: &str,
source: meerkat_core::CredentialSourceSpec,
) -> Config {
let mut config = Config::default();
let mut section = meerkat_core::RealmConfigSection::default();
section.backend.insert(
"openai_chatgpt".into(),
meerkat_core::BackendProfileConfig {
provider: "openai".into(),
backend_kind: "chatgpt_backend".into(),
base_url: None,
options: serde_json::Value::Null,
},
);
section.auth.insert(
"openai_oauth".into(),
meerkat_core::AuthProfileConfig {
provider: "openai".into(),
auth_method: auth_method.into(),
source,
constraints: meerkat_core::AuthConstraints::default(),
metadata_defaults: meerkat_core::AuthMetadataDefaults::default(),
},
);
section.binding.insert(
"openai_oauth".into(),
meerkat_core::ProviderBindingConfig {
backend_profile: "openai_chatgpt".into(),
auth_profile: "openai_oauth".into(),
default_model: None,
policy: meerkat_core::BindingPolicy::default(),
},
);
section.default_binding = Some("openai_oauth".into());
config.realm.insert("dev".into(), section);
config
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[test]
fn test_cli_interactive_login_resolves_configured_oauth_binding() {
let config = openai_oauth_login_config(
"managed_chatgpt_oauth",
meerkat_core::CredentialSourceSpec::ManagedStore,
);
let target = resolve_cli_interactive_oauth_target(LoginProvider::OpenAi, &config)
.expect("configured OAuth binding resolves");
assert_eq!(target.auth_binding.realm.as_str(), "dev");
assert_eq!(target.auth_binding.binding.as_str(), "openai_oauth");
assert_eq!(target.auth_profile.auth_method, "managed_chatgpt_oauth");
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[test]
fn test_cli_interactive_login_allows_first_time_default_oauth_binding() {
let config = Config::default();
let target = resolve_cli_interactive_oauth_target(LoginProvider::OpenAi, &config)
.expect("fresh config should synthesize the default OpenAI OAuth login target");
assert_eq!(target.auth_binding.realm.as_str(), "dev");
assert_eq!(target.auth_binding.binding.as_str(), "openai_oauth");
assert_eq!(target.auth_profile.auth_method, "managed_chatgpt_oauth");
assert_eq!(
target.auth_profile.source,
meerkat_core::CredentialSourceSpec::ManagedStore
);
assert!(target.auth_profile.constraints.allow_interactive_login);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[test]
fn test_cli_interactive_login_rejects_same_provider_non_oauth_binding() {
let config =
openai_oauth_login_config("api_key", meerkat_core::CredentialSourceSpec::ManagedStore);
let err = resolve_cli_interactive_oauth_target(LoginProvider::OpenAi, &config)
.expect_err("api_key binding must not accept OAuth login tokens");
assert!(err.to_string().contains("auth_method 'api_key'"));
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[test]
fn test_cli_interactive_login_rejects_oauth_auth_with_wrong_backend_kind() {
let mut config = openai_oauth_login_config(
"managed_chatgpt_oauth",
meerkat_core::CredentialSourceSpec::ManagedStore,
);
config
.realm
.get_mut("dev")
.expect("dev realm exists")
.backend
.get_mut("openai_chatgpt")
.expect("default backend exists")
.backend_kind = "openai_api".into();
let err = resolve_cli_interactive_oauth_target(LoginProvider::OpenAi, &config)
.expect_err("OAuth login must reject unsupported backend/auth combinations");
assert!(err.to_string().contains("backend_kind 'openai_api'"));
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[test]
fn test_cli_interactive_login_rejects_non_store_oauth_binding() {
let config = openai_oauth_login_config(
"managed_chatgpt_oauth",
meerkat_core::CredentialSourceSpec::Env {
env: "OPENAI_API_KEY".into(),
fallback: Vec::new(),
},
);
let err = resolve_cli_interactive_oauth_target(LoginProvider::OpenAi, &config)
.expect_err("env-source binding must not accept persisted OAuth login tokens");
assert!(err.to_string().contains("source 'env'"));
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn openai_auth_binding() -> AuthBindingRef {
AuthBindingRef {
realm: meerkat_core::RealmId::parse("dev").expect("realm id parses"),
binding: meerkat_core::BindingId::parse("openai_oauth").expect("binding id parses"),
profile: None,
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
fn openai_oauth_tokens() -> meerkat_providers::auth_store::PersistedTokens {
meerkat_providers::auth_store::PersistedTokens {
auth_mode: meerkat_providers::auth_store::PersistedAuthMode::ChatgptOauth,
primary_secret: Some("access-token".into()),
refresh_token: Some("refresh-token".into()),
id_token: None,
expires_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
last_refresh: Some(chrono::Utc::now()),
scopes: vec!["openid".into()],
account_id: None,
metadata: serde_json::Value::Null,
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[tokio::test]
async fn test_cli_auth_status_does_not_rehydrate_marked_oauth_token_after_restart() {
use meerkat_core::handles::{AuthLeaseHandle, LeaseKey};
use meerkat_providers::auth_store::{EphemeralTokenStore, TokenKey, TokenStore};
let store = EphemeralTokenStore::new();
let auth_lease = meerkat_runtime::RuntimeAuthLeaseHandle::new();
let auth_binding = openai_auth_binding();
let tokens = openai_oauth_tokens();
store
.save(
&TokenKey::from_auth_binding(&auth_binding),
&meerkat_core::mark_tokens_lifecycle_published_for_generation(&tokens, 1),
)
.await
.expect("token save succeeds");
let config = openai_oauth_login_config(
"managed_chatgpt_oauth",
meerkat_core::CredentialSourceSpec::ManagedStore,
);
let section = config.realm.get("dev").expect("dev realm exists");
let realm = meerkat_core::RealmConnectionSet::from_config("dev", section)
.expect("realm config parses");
let (_, _, auth_profile) = realm
.lookup_auth_binding(&auth_binding)
.expect("binding resolves");
let projection = project_cli_auth_status(
&auth_lease,
Some(&store as &dyn TokenStore),
&auth_binding,
auth_profile,
chrono::Utc::now(),
)
.await;
assert_eq!(projection.phase, AuthStatusPhase::Unknown);
assert!(projection.expires_at.is_none());
let snapshot = auth_lease.snapshot(&LeaseKey::from_auth_binding(&auth_binding));
assert_eq!(snapshot.phase, None);
assert!(!snapshot.credential_present);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[tokio::test]
async fn test_cli_auth_status_hides_non_store_oauth_lifecycle() {
use meerkat_core::handles::{AuthLeaseHandle, LeaseKey};
use meerkat_providers::auth_store::TokenStore;
let auth_lease = meerkat_runtime::RuntimeAuthLeaseHandle::new();
let auth_binding = openai_auth_binding();
auth_lease
.acquire_lease(
&LeaseKey::from_auth_binding(&auth_binding),
(chrono::Utc::now() + chrono::Duration::minutes(30)).timestamp() as u64,
)
.expect("lease acquire succeeds");
let config = openai_oauth_login_config(
"managed_chatgpt_oauth",
meerkat_core::CredentialSourceSpec::Env {
env: "OPENAI_API_KEY".into(),
fallback: Vec::new(),
},
);
let section = config.realm.get("dev").expect("dev realm exists");
let realm = meerkat_core::RealmConnectionSet::from_config("dev", section)
.expect("realm config parses");
let (_, _, auth_profile) = realm
.lookup_auth_binding(&auth_binding)
.expect("binding resolves");
let projection = project_cli_auth_status(
&auth_lease,
None::<&dyn TokenStore>,
&auth_binding,
auth_profile,
chrono::Utc::now(),
)
.await;
assert_eq!(projection.phase, AuthStatusPhase::Unknown);
assert!(projection.expires_at.is_none());
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[tokio::test]
async fn test_cli_oauth_login_save_consumes_runtime_browser_flow() {
use meerkat_core::handles::{AuthLeaseHandle, AuthLeasePhase, LeaseKey};
use meerkat_providers::auth_store::{EphemeralTokenStore, TokenKey, TokenStore};
use meerkat_providers::oauth_flow::OAuthFlowAuthority;
let auth_lease = Arc::new(meerkat_runtime::RuntimeAuthLeaseHandle::new());
let authority = meerkat_runtime::handles::RuntimeOAuthFlowHandle::new_with_auth_lease(
std::time::Duration::from_secs(60),
Arc::clone(&auth_lease),
);
let store = EphemeralTokenStore::new();
let auth_binding = openai_auth_binding();
let redirect_uri = "http://127.0.0.1:12345/callback";
let provider = meerkat_providers::oauth_flow::OAuthProviderIdentity::OpenAiChatGpt;
let state = authority
.start(
auth_binding.clone(),
provider,
redirect_uri.to_string(),
"pkce-verifier".into(),
)
.expect("authority admits browser flow");
save_cli_oauth_tokens_and_consume_browser_flow(
&store,
auth_lease.as_ref(),
&auth_binding,
&openai_oauth_tokens(),
CliBrowserFlowConsume {
authority: &authority,
state: &state,
provider,
redirect_uri,
},
)
.await
.expect("CLI OAuth save consumes terminal flow");
let second_consume = authority.consume(&state, &auth_binding, provider, redirect_uri);
assert!(
second_consume.is_err(),
"consumed runtime browser flow must not remain callable: {second_consume:?}"
);
assert_eq!(
auth_lease
.snapshot(&LeaseKey::from_auth_binding(&auth_binding))
.phase,
Some(AuthLeasePhase::Valid)
);
let stored = store
.load(&TokenKey::from_auth_binding(&auth_binding))
.await
.expect("token load succeeds")
.expect("token should be persisted");
let marker = meerkat_core::tokens_lifecycle_publication(&stored)
.expect("CLI OAuth login should stamp durable lifecycle marker");
let snapshot = auth_lease.snapshot(&LeaseKey::from_auth_binding(&auth_binding));
assert_eq!(marker.generation, Some(snapshot.generation));
assert_eq!(
marker.credential_published_at_millis,
snapshot.credential_published_at_millis
);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
struct RejectCliBrowserConsumeAuthority;
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
impl meerkat_providers::oauth_flow::OAuthFlowAuthority for RejectCliBrowserConsumeAuthority {
fn start(
&self,
_target: AuthBindingRef,
_provider: meerkat_providers::oauth_flow::OAuthProviderIdentity,
_redirect_uri: String,
_pkce_verifier: String,
) -> Result<String, meerkat_providers::oauth_flow::OAuthFlowError> {
unreachable!("rollback test starts no browser flow")
}
fn verify(
&self,
_state: &str,
target: &AuthBindingRef,
provider: meerkat_providers::oauth_flow::OAuthProviderIdentity,
redirect_uri: &str,
) -> Result<
meerkat_providers::oauth_flow::OAuthFlowRecord,
meerkat_providers::oauth_flow::OAuthFlowError,
> {
Ok(meerkat_providers::oauth_flow::OAuthFlowRecord {
target: target.clone(),
provider,
redirect_uri: redirect_uri.to_string(),
pkce_verifier: "pkce-verifier".into(),
created_at: std::time::Instant::now(),
})
}
fn consume(
&self,
_state: &str,
_target: &AuthBindingRef,
_provider: meerkat_providers::oauth_flow::OAuthProviderIdentity,
_redirect_uri: &str,
) -> Result<
meerkat_providers::oauth_flow::OAuthFlowRecord,
meerkat_providers::oauth_flow::OAuthFlowError,
> {
Err(
meerkat_providers::oauth_flow::OAuthFlowError::LifecycleRejected {
operation: "consume_oauth_browser_flow",
detail: "test rejection".into(),
},
)
}
fn admit_device_code(
&self,
_target: AuthBindingRef,
_provider: meerkat_providers::oauth_flow::OAuthProviderIdentity,
_device_code: String,
_expires_in: std::time::Duration,
) -> Result<(), meerkat_providers::oauth_flow::OAuthFlowError> {
unreachable!("rollback test does not admit device flows")
}
fn verify_device_code(
&self,
_device_code: &str,
_target: &AuthBindingRef,
_provider: meerkat_providers::oauth_flow::OAuthProviderIdentity,
) -> Result<
meerkat_providers::oauth_flow::OAuthDeviceFlowRecord,
meerkat_providers::oauth_flow::OAuthFlowError,
> {
unreachable!("rollback test does not verify device flows")
}
fn begin_device_code_poll(
&self,
_device_code: &str,
_target: &AuthBindingRef,
_provider: meerkat_providers::oauth_flow::OAuthProviderIdentity,
) -> Result<
meerkat_providers::oauth_flow::OAuthDevicePollLease,
meerkat_providers::oauth_flow::OAuthFlowError,
> {
unreachable!("rollback test does not poll device flows")
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
struct SaveCountingCliTokenStore {
inner: meerkat_providers::auth_store::EphemeralTokenStore,
save_count: std::sync::atomic::AtomicUsize,
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
impl SaveCountingCliTokenStore {
fn new() -> Self {
Self {
inner: meerkat_providers::auth_store::EphemeralTokenStore::new(),
save_count: std::sync::atomic::AtomicUsize::new(0),
}
}
fn save_count(&self) -> usize {
self.save_count.load(std::sync::atomic::Ordering::SeqCst)
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[async_trait]
impl meerkat_providers::auth_store::TokenStore for SaveCountingCliTokenStore {
async fn load(
&self,
key: &meerkat_providers::auth_store::TokenKey,
) -> Result<
Option<meerkat_providers::auth_store::PersistedTokens>,
meerkat_providers::auth_store::TokenStoreError,
> {
self.inner.load(key).await
}
async fn save(
&self,
key: &meerkat_providers::auth_store::TokenKey,
tokens: &meerkat_providers::auth_store::PersistedTokens,
) -> Result<(), meerkat_providers::auth_store::TokenStoreError> {
self.save_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.inner.save(key, tokens).await
}
async fn clear(
&self,
key: &meerkat_providers::auth_store::TokenKey,
) -> Result<(), meerkat_providers::auth_store::TokenStoreError> {
self.inner.clear(key).await
}
async fn list(
&self,
) -> Result<
Vec<meerkat_providers::auth_store::TokenKey>,
meerkat_providers::auth_store::TokenStoreError,
> {
self.inner.list().await
}
fn backend_name(&self) -> &'static str {
"save_counting_cli"
}
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[tokio::test]
async fn test_cli_oauth_login_consume_failure_does_not_save_before_durable_claim() {
use meerkat_core::handles::{AuthLeaseHandle, AuthLeasePhase, LeaseKey};
use meerkat_providers::auth_store::{TokenKey, TokenStore};
let auth_lease = meerkat_runtime::RuntimeAuthLeaseHandle::new();
let store = SaveCountingCliTokenStore::new();
let auth_binding = openai_auth_binding();
let provider = meerkat_providers::oauth_flow::OAuthProviderIdentity::OpenAiChatGpt;
let err = save_cli_oauth_tokens_and_consume_browser_flow(
&store,
&auth_lease,
&auth_binding,
&openai_oauth_tokens(),
CliBrowserFlowConsume {
authority: &RejectCliBrowserConsumeAuthority,
state: "state",
provider,
redirect_uri: "http://127.0.0.1:12345/callback",
},
)
.await
.expect_err("terminal consume failure must fail the CLI login");
assert!(err.to_string().contains("consume_oauth_browser_flow"));
assert_eq!(
store.save_count(),
0,
"token material must not be saved before winning the durable OAuth consume claim"
);
assert!(
store
.load(&TokenKey::from_auth_binding(&auth_binding))
.await
.expect("token load succeeds")
.is_none(),
"failed terminal consume must remove newly saved token material"
);
assert_ne!(
auth_lease
.snapshot(&LeaseKey::from_auth_binding(&auth_binding))
.phase,
Some(AuthLeasePhase::Valid),
"failed terminal consume must not leave the newly acquired credential lifecycle valid"
);
}
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
#[test]
fn test_interactive_oauth_login_uses_runtime_flow_authority_for_browser_state() {
let source = include_str!("main.rs");
let start = source
.find("async fn interactive_login(")
.expect("interactive_login exists");
let end = source[start..]
.find("fn token_store_load_error_allows_clear")
.map(|offset| start + offset)
.expect("interactive_login successor exists");
let interactive_login_source = &source[start..end];
assert!(
interactive_login_source.contains(".oauth_flow_authority\n .start("),
"CLI interactive OAuth login must admit browser state through the runtime OAuth flow authority"
);
assert!(
!interactive_login_source.contains("let state_token = format!("),
"CLI interactive OAuth login must not mint helper-local browser state outside AuthMachine authority"
);
}
async fn serve_one_models_request_requiring_bearer_token(
expected_token: &'static str,
) -> (String, tokio::task::JoinHandle<Option<String>>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("test HTTP listener binds");
let base_url = format!("http://{}", listener.local_addr().expect("listener addr"));
let handle = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("doctor connects");
let mut buffer = vec![0_u8; 4096];
let bytes = socket.read(&mut buffer).await.expect("request reads");
let request = String::from_utf8_lossy(&buffer[..bytes]);
let auth_header = request
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("authorization")
.then(|| value.trim())
})
.map(ToOwned::to_owned);
let body = r#"{"data":[{"id":"gemma"}]}"#;
let status = if auth_header.as_deref() == Some(expected_token) {
"200 OK"
} else {
"401 Unauthorized"
};
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
socket
.write_all(response.as_bytes())
.await
.expect("response writes");
auth_header
});
(base_url, handle)
}
fn doctor_openai_message_for_env(keys: &[&str]) -> &'static str {
doctor_openai_env_default_message(|key| keys.contains(&key))
}
#[test]
fn doctor_openai_env_default_reports_rkat_azure_override_before_public_openai() {
let message = doctor_openai_message_for_env(&[
"OPENAI_API_KEY",
"RKAT_AZURE_OPENAI_API_KEY",
"RKAT_AZURE_OPENAI_ENDPOINT",
]);
assert_eq!(
message,
"ok\tprovider\topenai via AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT"
);
}
#[test]
fn doctor_openai_env_default_keeps_public_openai_before_plain_azure_env() {
let message = doctor_openai_message_for_env(&[
"OPENAI_API_KEY",
"AZURE_OPENAI_API_KEY",
"AZURE_OPENAI_ENDPOINT",
]);
assert_eq!(message, "ok\tprovider\topenai via OPENAI_API_KEY");
}
#[tokio::test]
async fn doctor_self_hosted_reuses_realm_auth_binding_for_legacy_server_probe() {
let temp = tempfile::tempdir().expect("tempdir");
let state_root = temp.path().join("state");
let scope = test_scope(state_root.clone(), "dev");
let paths = meerkat_store::realm_paths_in(&state_root, "dev");
tokio::fs::create_dir_all(paths.config_path.parent().expect("config dir"))
.await
.expect("config dir created");
let (base_url, server) =
serve_one_models_request_requiring_bearer_token("Bearer realm-token").await;
let config = format!(
r#"
[self_hosted.servers.local]
transport = "openai_compatible"
base_url = "{base_url}"
api_style = "chat_completions"
[self_hosted.models.gemma-local]
server = "local"
remote_model = "gemma"
display_name = "Gemma Local"
family = "gemma"
tier = "supported"
vision = false
image_tool_results = false
inline_video = false
supports_temperature = true
supports_thinking = false
supports_reasoning = false
[realm.dev]
default_binding = "local"
[realm.dev.backend.local]
provider = "self_hosted"
backend_kind = "self_hosted"
base_url = "{base_url}"
[realm.dev.auth.local_auth]
provider = "self_hosted"
auth_method = "static_bearer"
source = {{ kind = "inline_secret", secret = "realm-token" }}
[realm.dev.binding.local]
backend_profile = "local"
auth_profile = "local_auth"
default_model = "gemma"
"#
);
tokio::fs::write(&paths.config_path, config)
.await
.expect("config writes");
let result = handle_doctor(&scope).await;
let observed_auth = tokio::time::timeout(Duration::from_secs(2), server)
.await
.expect("doctor should probe the self-hosted server")
.expect("server task joins");
assert_eq!(observed_auth.as_deref(), Some("Bearer realm-token"));
assert!(
result.is_ok(),
"doctor must resolve self-hosted credentials through realm auth: {result:?}"
);
}
#[tokio::test]
async fn test_cli_output_pipeline_shutdown_drops_stream_sender_before_awaiting_tasks() {
let pipeline = CliOutputPipeline::new(
true,
false,
Some(stream_renderer::StreamRenderPolicy::PrimaryOnly),
vec![StreamScopeFrame::Primary {
session_id: "test-session".to_string(),
}],
)
.expect("stream pipeline should build");
tokio::time::timeout(
Duration::from_secs(2),
pipeline.shutdown_after(async { Ok(()) }),
)
.await
.expect("stream pipeline shutdown should not deadlock")
.expect("stream pipeline shutdown should succeed");
}
#[tokio::test]
async fn test_cli_output_pipeline_shutdown_handles_verbose_mode() {
let pipeline = CliOutputPipeline::new(false, true, None, Vec::new())
.expect("verbose pipeline should build");
tokio::time::timeout(
Duration::from_secs(2),
pipeline.shutdown_after(async { Ok(()) }),
)
.await
.expect("verbose pipeline shutdown should not deadlock")
.expect("verbose pipeline shutdown should succeed");
}
#[tokio::test]
async fn test_cli_output_pipeline_shutdown_releases_runtime_executor_sender_clones() {
let session_id = SessionId::new();
let pipeline = CliOutputPipeline::new(
true,
false,
Some(stream_renderer::StreamRenderPolicy::PrimaryOnly),
vec![StreamScopeFrame::Primary {
session_id: session_id.to_string(),
}],
)
.expect("stream pipeline should build");
let runtime_adapter = Arc::new(meerkat_runtime::MeerkatMachine::ephemeral());
let service: Arc<dyn meerkat_core::service::SessionService> =
Arc::new(CapturingEventTurnService::new(session_id.clone()));
let executor = Box::new(CliRuntimeExecutor {
service,
#[cfg(feature = "session-store")]
persistent_service: None,
session_id: session_id.clone(),
runtime_adapter: runtime_adapter.clone(),
event_tx: pipeline.event_sender(),
});
runtime_adapter
.register_session_with_executor(session_id.clone(), executor)
.await;
Box::pin(tokio::time::timeout(
Duration::from_secs(2),
pipeline.shutdown_after(async {
runtime_adapter.unregister_session(&session_id).await;
Ok(())
}),
))
.await
.expect("shutdown should finish once runtime executor is unregistered")
.expect("shutdown should succeed");
}
#[tokio::test]
async fn test_cli_output_pipeline_shutdown_still_joins_stream_tasks_when_cleanup_fails() {
let pipeline = CliOutputPipeline::new(
true,
false,
Some(stream_renderer::StreamRenderPolicy::PrimaryOnly),
vec![StreamScopeFrame::Primary {
session_id: "test-session".to_string(),
}],
)
.expect("stream pipeline should build");
let err = tokio::time::timeout(
Duration::from_secs(2),
pipeline.shutdown_after(async { Err(anyhow::anyhow!("synthetic cleanup failure")) }),
)
.await
.expect("shutdown should not deadlock when cleanup fails")
.expect_err("shutdown should preserve the cleanup failure");
assert!(
err.to_string().contains("synthetic cleanup failure"),
"shutdown should return the original cleanup failure"
);
}
#[tokio::test]
async fn test_cli_runtime_turn_failure_still_releases_runtime_executor_sender_clones() {
let session_id = SessionId::new();
let pipeline = CliOutputPipeline::new(
true,
false,
Some(stream_renderer::StreamRenderPolicy::PrimaryOnly),
vec![StreamScopeFrame::Primary {
session_id: session_id.to_string(),
}],
)
.expect("stream pipeline should build");
let runtime_adapter = Arc::new(meerkat_runtime::MeerkatMachine::ephemeral());
let service: Arc<dyn meerkat_core::service::SessionService> =
Arc::new(CapturingEventTurnService::new(session_id.clone()));
let executor = Box::new(CliRuntimeExecutor {
service,
#[cfg(feature = "session-store")]
persistent_service: None,
session_id: session_id.clone(),
runtime_adapter: runtime_adapter.clone(),
event_tx: pipeline.event_sender(),
});
runtime_adapter
.register_session_with_executor(session_id.clone(), executor)
.await;
let err = Box::pin(tokio::time::timeout(
Duration::from_secs(2),
finalize_cli_runtime_backed_turn(
pipeline,
Err::<(), _>(anyhow::anyhow!("turn abandoned: synthetic failure")),
async {
runtime_adapter.unregister_session(&session_id).await;
Ok(())
},
),
))
.await
.expect("failure-path shutdown should not deadlock")
.expect_err("finalizer should preserve the original turn failure");
assert!(
err.to_string()
.contains("turn abandoned: synthetic failure"),
"failure-path finalizer should return the original turn error"
);
}
#[test]
fn completion_outcome_to_cli_runtime_turn_result_surfaces_callback_pending_payload() {
let session_id = SessionId::new();
let realm = meerkat_core::connection::RealmId::parse("test-realm")
.expect("test-realm is a valid realm id");
let result = completion_outcome_to_cli_runtime_turn_result(
meerkat_runtime::completion::CompletionOutcome::CallbackPending {
tool_name: "external_mock".into(),
args: serde_json::json!({ "value": "browser" }),
},
&session_id,
&realm,
true,
)
.expect("callback pending should surface as resumable CLI metadata");
assert!(
matches!(result, CliRuntimeTurnResult::CallbackPending(_)),
"expected callback pending CLI turn result"
);
let CliRuntimeTurnResult::CallbackPending(pending) = result else {
return;
};
assert_eq!(pending.session_id, session_id);
assert_eq!(pending.session_ref, format_session_ref(&realm, &session_id));
assert!(pending.session_created);
assert!(pending.resumable);
assert_eq!(pending.tool_name, "external_mock");
assert_eq!(pending.args, serde_json::json!({ "value": "browser" }));
assert_eq!(
callback_pending_json_value(&pending)["status"],
"pending_tool_call"
);
}
struct StaticDispatcher {
tools: Arc<[Arc<ToolDef>]>,
}
impl StaticDispatcher {
fn new(name: &str) -> Self {
let tool = Arc::new(ToolDef {
name: name.into(),
description: format!("tool {name}"),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
provenance: None,
});
Self {
tools: Arc::from([tool]),
}
}
}
#[async_trait]
impl AgentToolDispatcher for StaticDispatcher {
fn tools(&self) -> Arc<[Arc<ToolDef>]> {
self.tools.clone()
}
async fn dispatch(&self, call: ToolCallView<'_>) -> Result<ToolDispatchOutcome, ToolError> {
Err(ToolError::not_found(call.name))
}
}
struct EchoDispatcher {
tools: Arc<[Arc<ToolDef>]>,
content: String,
}
impl EchoDispatcher {
fn new(name: &str, content: &str) -> Self {
let tool = Arc::new(ToolDef {
name: name.into(),
description: format!("tool {name}"),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
provenance: None,
});
Self {
tools: Arc::from([tool]),
content: content.to_string(),
}
}
}
#[async_trait]
impl AgentToolDispatcher for EchoDispatcher {
fn tools(&self) -> Arc<[Arc<ToolDef>]> {
self.tools.clone()
}
async fn dispatch(&self, call: ToolCallView<'_>) -> Result<ToolDispatchOutcome, ToolError> {
if self.tools.iter().any(|tool| tool.name == call.name) {
return Ok(
ToolResult::new(call.id.to_string(), self.content.clone(), false).into(),
);
}
Err(ToolError::not_found(call.name))
}
}
struct CapturingLlmClient {
captured_tool_names: Arc<Mutex<Vec<String>>>,
captured_system_prompt: Arc<Mutex<Option<String>>>,
}
impl CapturingLlmClient {
fn new(
captured_tool_names: Arc<Mutex<Vec<String>>>,
captured_system_prompt: Arc<Mutex<Option<String>>>,
) -> Self {
Self {
captured_tool_names,
captured_system_prompt,
}
}
}
#[async_trait]
impl LlmClient for CapturingLlmClient {
fn project_replay_messages(
&self,
messages: &[meerkat_core::Message],
) -> Result<Vec<meerkat_core::Message>, meerkat_client::LlmError> {
Ok(messages.to_vec())
}
fn stream<'a>(
&'a self,
request: &'a LlmRequest,
) -> Pin<Box<dyn futures::Stream<Item = Result<LlmEvent, LlmError>> + Send + 'a>> {
let names: Vec<String> = request
.tools
.iter()
.map(|tool| tool.name.to_string())
.collect();
let mut captured = self
.captured_tool_names
.lock()
.expect("captured tool mutex should not be poisoned");
*captured = names;
let system_prompt = request.messages.iter().find_map(|message| match message {
meerkat_core::Message::System(system) => Some(system.content.clone()),
_ => None,
});
let mut captured_prompt = self
.captured_system_prompt
.lock()
.expect("captured prompt mutex should not be poisoned");
*captured_prompt = system_prompt;
Box::pin(stream::iter(vec![
Ok(LlmEvent::TextDelta {
delta: "ok".to_string(),
meta: None,
}),
Ok(LlmEvent::Done {
outcome: LlmDoneOutcome::Success {
stop_reason: meerkat_core::StopReason::EndTurn,
},
}),
]))
}
fn provider(&self) -> &'static str {
"mock"
}
async fn health_check(&self) -> Result<(), LlmError> {
Ok(())
}
}
struct FakeImageGenerationExecutor;
#[async_trait]
impl meerkat_llm_core::ImageGenerationExecutor for FakeImageGenerationExecutor {
async fn execute_image_generation(
&self,
_request: meerkat_llm_core::ProviderImageGenerationRequest,
) -> Result<meerkat_llm_core::ProviderImageGenerationOutput, meerkat_llm_core::LlmError>
{
Err(meerkat_llm_core::LlmError::InvalidRequest {
message: "fake image generation executor should not be called".to_string(),
})
}
}
struct TestCommsRuntime {
key: String,
trusted: RwLock<HashSet<String>>,
notify: Arc<tokio::sync::Notify>,
}
impl TestCommsRuntime {
fn new(name: &str) -> Self {
let _ = name;
Self {
key: meerkat_core::comms::PeerId::new().to_string(),
trusted: RwLock::new(HashSet::new()),
notify: Arc::new(tokio::sync::Notify::new()),
}
}
}
#[async_trait]
impl CoreCommsRuntime for TestCommsRuntime {
fn public_key(&self) -> Option<String> {
Some(self.key.clone())
}
async fn add_trusted_peer(&self, peer: TrustedPeerDescriptor) -> Result<(), SendError> {
self.trusted.write().await.insert(peer.peer_id.to_string());
Ok(())
}
async fn add_private_trusted_peer(
&self,
_peer: TrustedPeerDescriptor,
) -> Result<(), SendError> {
Ok(())
}
async fn remove_trusted_peer(&self, peer_id: &str) -> Result<bool, SendError> {
Ok(self.trusted.write().await.remove(peer_id))
}
async fn send(&self, _cmd: CommsCommand) -> Result<SendReceipt, SendError> {
let interaction_id: InteractionId =
serde_json::from_str("\"00000000-0000-0000-0000-000000000000\"")
.expect("interaction id literal should parse");
Ok(SendReceipt::InputAccepted {
interaction_id,
stream_reserved: false,
})
}
async fn drain_messages(&self) -> Vec<String> {
Vec::new()
}
fn inbox_notify(&self) -> Arc<tokio::sync::Notify> {
self.notify.clone()
}
async fn drain_peer_input_candidates(&self) -> Vec<PeerInputCandidate> {
Vec::new()
}
}
struct TestMobSessionService {
sessions: RwLock<HashMap<SessionId, Arc<TestCommsRuntime>>>,
counter: std::sync::atomic::AtomicU64,
}
impl TestMobSessionService {
fn new() -> Self {
Self {
sessions: RwLock::new(HashMap::new()),
counter: std::sync::atomic::AtomicU64::new(0),
}
}
}
#[async_trait]
impl SessionService for TestMobSessionService {
async fn create_session(
&self,
req: CreateSessionRequest,
) -> Result<RunResult, SessionError> {
let sid = req
.build
.as_ref()
.and_then(|build| build.resume_session.as_ref())
.map(|session| session.id().clone())
.unwrap_or_default();
let n = self
.counter
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let name = req
.build
.and_then(|b| b.comms_name)
.unwrap_or_else(|| format!("test-session-{n}"));
self.sessions
.write()
.await
.insert(sid.clone(), Arc::new(TestCommsRuntime::new(&name)));
Ok(RunResult {
text: "ok".to_string(),
session_id: sid,
usage: Usage::default(),
turns: 1,
tool_calls: 0,
terminal_cause_kind: None,
structured_output: None,
extraction_error: None,
schema_warnings: None,
skill_diagnostics: None,
})
}
async fn start_turn(
&self,
id: &SessionId,
_req: StartTurnRequest,
) -> Result<RunResult, SessionError> {
if !self.sessions.read().await.contains_key(id) {
return Err(SessionError::NotFound { id: id.clone() });
}
Ok(RunResult {
text: "ok".to_string(),
session_id: id.clone(),
usage: Usage::default(),
turns: 1,
tool_calls: 0,
terminal_cause_kind: None,
structured_output: None,
extraction_error: None,
schema_warnings: None,
skill_diagnostics: None,
})
}
async fn interrupt(&self, _id: &SessionId) -> Result<(), SessionError> {
Ok(())
}
async fn read(&self, id: &SessionId) -> Result<SessionView, SessionError> {
if !self.sessions.read().await.contains_key(id) {
return Err(SessionError::NotFound { id: id.clone() });
}
Ok(SessionView {
state: SessionInfo {
session_id: id.clone(),
created_at: std::time::SystemTime::now(),
updated_at: std::time::SystemTime::now(),
message_count: 0,
is_active: false,
model: "claude-sonnet-4-5".to_string(),
provider: meerkat_core::Provider::Anthropic,
last_assistant_text: None,
labels: Default::default(),
},
billing: SessionUsage {
total_tokens: 0,
usage: Usage::default(),
},
})
}
async fn list(&self, _query: SessionQuery) -> Result<Vec<SessionSummary>, SessionError> {
Ok(self
.sessions
.read()
.await
.keys()
.map(|id| SessionSummary {
session_id: id.clone(),
created_at: std::time::SystemTime::now(),
updated_at: std::time::SystemTime::now(),
message_count: 0,
total_tokens: 0,
is_active: false,
labels: Default::default(),
})
.collect())
}
async fn archive(&self, id: &SessionId) -> Result<(), SessionError> {
self.sessions.write().await.remove(id);
Ok(())
}
}
#[async_trait]
impl SessionServiceCommsExt for TestMobSessionService {
async fn comms_runtime(&self, session_id: &SessionId) -> Option<Arc<dyn CoreCommsRuntime>> {
self.sessions
.read()
.await
.get(session_id)
.map(|session| session.clone() as Arc<dyn CoreCommsRuntime>)
}
async fn event_injector(
&self,
_session_id: &SessionId,
) -> Option<Arc<dyn meerkat_core::EventInjector>> {
None
}
}
#[async_trait]
impl meerkat_core::service::SessionServiceControlExt for TestMobSessionService {
async fn append_system_context(
&self,
id: &SessionId,
_req: meerkat_core::AppendSystemContextRequest,
) -> Result<
meerkat_core::service::AppendSystemContextResult,
meerkat_core::service::SessionControlError,
> {
if !self.sessions.read().await.contains_key(id) {
return Err(meerkat_core::SessionError::NotFound { id: id.clone() }.into());
}
Ok(meerkat_core::service::AppendSystemContextResult {
status: meerkat_core::service::AppendSystemContextStatus::Staged,
})
}
}
#[async_trait]
impl meerkat_core::service::SessionServiceHistoryExt for TestMobSessionService {
async fn read_history(
&self,
id: &SessionId,
query: meerkat_core::service::SessionHistoryQuery,
) -> Result<meerkat_core::service::SessionHistoryPage, meerkat_core::service::SessionError>
{
if !self.sessions.read().await.contains_key(id) {
return Err(SessionError::NotFound { id: id.clone() });
}
Ok(meerkat_core::service::SessionHistoryPage::from_messages(
id.clone(),
&[],
query,
))
}
}
#[cfg(feature = "mob")]
#[async_trait]
impl meerkat_mob::MobSessionService for TestMobSessionService {
fn supports_persistent_sessions(&self) -> bool {
true
}
fn runtime_adapter(&self) -> Option<Arc<meerkat_runtime::MeerkatMachine>> {
Some(Arc::new(meerkat_runtime::MeerkatMachine::ephemeral()))
}
async fn session_belongs_to_mob(
&self,
_session_id: &SessionId,
_mob_id: &meerkat_mob::MobId,
) -> bool {
true
}
}
struct CapturingEventTurnService {
session_id: SessionId,
saw_event_tx: std::sync::atomic::AtomicBool,
pre_turn_context_appends: Mutex<Vec<meerkat_core::PendingSystemContextAppend>>,
}
impl CapturingEventTurnService {
fn new(session_id: SessionId) -> Self {
Self {
session_id,
saw_event_tx: std::sync::atomic::AtomicBool::new(false),
pre_turn_context_appends: Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl SessionService for CapturingEventTurnService {
async fn create_session(
&self,
_req: CreateSessionRequest,
) -> Result<RunResult, SessionError> {
Ok(RunResult {
text: String::new(),
session_id: self.session_id.clone(),
usage: Usage::default(),
turns: 0,
tool_calls: 0,
terminal_cause_kind: None,
structured_output: None,
extraction_error: None,
schema_warnings: None,
skill_diagnostics: None,
})
}
async fn start_turn(
&self,
id: &SessionId,
req: StartTurnRequest,
) -> Result<RunResult, SessionError> {
if id != &self.session_id {
return Err(SessionError::NotFound { id: id.clone() });
}
*self
.pre_turn_context_appends
.lock()
.expect("pre-turn context appends lock poisoned") =
req.runtime.pre_turn_context_appends;
if let Some(tx) = req.event_tx {
self.saw_event_tx
.store(true, std::sync::atomic::Ordering::Release);
let _ = tx
.send(EventEnvelope::new(
"capturing-turn-service",
1,
None,
AgentEvent::TextDelta {
delta: "streamed".to_string(),
},
))
.await;
}
Ok(RunResult {
text: "streamed".to_string(),
session_id: self.session_id.clone(),
usage: Usage::default(),
turns: 1,
tool_calls: 0,
terminal_cause_kind: None,
structured_output: None,
extraction_error: None,
schema_warnings: None,
skill_diagnostics: None,
})
}
async fn interrupt(&self, _id: &SessionId) -> Result<(), SessionError> {
Ok(())
}
async fn read(&self, id: &SessionId) -> Result<SessionView, SessionError> {
if id != &self.session_id {
return Err(SessionError::NotFound { id: id.clone() });
}
Ok(SessionView {
state: SessionInfo {
session_id: id.clone(),
created_at: std::time::SystemTime::now(),
updated_at: std::time::SystemTime::now(),
message_count: 0,
is_active: false,
model: "claude-sonnet-4-5".to_string(),
provider: meerkat_core::Provider::Anthropic,
last_assistant_text: None,
labels: Default::default(),
},
billing: SessionUsage {
total_tokens: 0,
usage: Usage::default(),
},
})
}
async fn list(&self, _query: SessionQuery) -> Result<Vec<SessionSummary>, SessionError> {
Ok(vec![SessionSummary {
session_id: self.session_id.clone(),
created_at: std::time::SystemTime::now(),
updated_at: std::time::SystemTime::now(),
message_count: 0,
total_tokens: 0,
is_active: false,
labels: Default::default(),
}])
}
async fn archive(&self, _id: &SessionId) -> Result<(), SessionError> {
Ok(())
}
}
#[test]
fn test_resolve_keep_alive_roundtrip() {
#[cfg(feature = "comms")]
assert!(resolve_keep_alive(true).expect("keep_alive should be enabled"));
#[cfg(not(feature = "comms"))]
assert!(resolve_keep_alive(true).is_err());
assert!(!resolve_keep_alive(false).expect("keep_alive should be disabled"));
}
#[test]
fn test_resolve_tool_preset_full_and_yolo_enable_mob_tools() {
let full = resolve_tool_preset(ToolPreset::Full, false);
assert!(full.builtins);
assert!(full.shell);
assert!(full.memory);
assert!(full.workgraph);
#[cfg(feature = "mob")]
assert!(full.mob);
#[cfg(not(feature = "mob"))]
assert!(!full.mob);
let yolo = resolve_tool_preset(ToolPreset::Safe, true);
assert!(yolo.shell);
assert!(yolo.memory);
#[cfg(feature = "mob")]
assert!(yolo.mob);
#[cfg(not(feature = "mob"))]
assert!(!yolo.mob);
}
#[test]
fn test_apply_yolo_tooling_override_promotes_resume_tooling_to_full() {
let mut tooling = meerkat_core::SessionTooling {
builtins: meerkat_core::ToolCategoryOverride::Disable,
shell: meerkat_core::ToolCategoryOverride::Disable,
comms: meerkat_core::ToolCategoryOverride::Enable,
mob: meerkat_core::ToolCategoryOverride::Disable,
memory: meerkat_core::ToolCategoryOverride::Disable,
workgraph: meerkat_core::ToolCategoryOverride::Disable,
image_generation: meerkat_core::ToolCategoryOverride::Disable,
web_search: meerkat_core::ToolCategoryOverride::Disable,
active_skills: None,
};
apply_yolo_tooling_override(&mut tooling);
assert_eq!(tooling.builtins, meerkat_core::ToolCategoryOverride::Enable);
assert_eq!(tooling.shell, meerkat_core::ToolCategoryOverride::Enable);
assert_eq!(tooling.memory, meerkat_core::ToolCategoryOverride::Enable);
assert_eq!(
tooling.workgraph,
meerkat_core::ToolCategoryOverride::Enable
);
#[cfg(feature = "mob")]
assert_eq!(tooling.mob, meerkat_core::ToolCategoryOverride::Enable);
#[cfg(not(feature = "mob"))]
assert_eq!(tooling.mob, meerkat_core::ToolCategoryOverride::Disable);
assert_eq!(tooling.comms, meerkat_core::ToolCategoryOverride::Enable);
}
async fn run_resume_probe_error(
scope_name: &str,
tools: Option<ToolPreset>,
yolo: bool,
stdin: StdinMode,
line_format: LineFormat,
auth_binding: Option<meerkat_core::AuthBindingRef>,
) -> String {
let temp = tempfile::tempdir().expect("temp dir");
let state_root = temp.path().join("state");
let scope = test_scope(state_root, scope_name);
handle_run_command(
"Continue.".to_string(),
Some("last".to_string()),
None,
None,
None,
None,
None,
None,
CliOutputFormat::Text,
false,
false,
false,
false,
None,
None,
false,
true,
false,
Vec::new(),
None,
None,
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
None,
tools,
yolo,
false,
false,
false,
stdin,
line_format,
auth_binding,
&scope,
)
.await
.expect_err("resume probe should fail in the expected layer")
.to_string()
}
#[tokio::test]
async fn test_run_resume_yolo_reaches_resume_path() {
let message = run_resume_probe_error(
"resume-yolo",
None,
true,
StdinMode::Off,
LineFormat::Text,
None,
)
.await;
assert!(
!message.contains("create-only") && !message.contains("--yolo"),
"`--yolo` should be allowed through run --resume; got: {message}"
);
}
#[tokio::test]
async fn test_run_resume_tools_full_reaches_resume_path() {
let message = run_resume_probe_error(
"resume-tools-full",
Some(ToolPreset::Full),
false,
StdinMode::Off,
LineFormat::Text,
None,
)
.await;
assert!(
!message.contains("create-only") && !message.contains("--tools"),
"`--tools full` should be allowed through run --resume; got: {message}"
);
}
#[tokio::test]
async fn test_run_resume_session_shaping_flags_reach_resume_path() {
let stdin_lines_err = run_resume_probe_error(
"resume-stdin-lines",
None,
false,
StdinMode::Lines,
LineFormat::Text,
None,
)
.await;
assert!(
!stdin_lines_err.contains("create-only") && !stdin_lines_err.contains("--stdin lines"),
"`--stdin lines` should be allowed through run --resume; got: {stdin_lines_err}"
);
let line_format_err = run_resume_probe_error(
"resume-line-format",
None,
false,
StdinMode::Off,
LineFormat::Json,
None,
)
.await;
assert!(
!line_format_err.contains("create-only")
&& !line_format_err.contains("--line-format json"),
"`--line-format json` should be allowed through run --resume; got: {line_format_err}"
);
let auth_binding = meerkat_core::AuthBindingRef {
realm: meerkat_core::RealmId::parse("test").expect("valid realm"),
binding: meerkat_core::BindingId::parse("default").expect("valid binding"),
profile: None,
};
let auth_binding_err = run_resume_probe_error(
"resume-auth-binding",
None,
false,
StdinMode::Off,
LineFormat::Text,
Some(auth_binding),
)
.await;
assert!(
!auth_binding_err.contains("create-only")
&& !auth_binding_err.contains("--auth-binding"),
"`--auth-binding` should be allowed through run --resume; got: {auth_binding_err}"
);
}
#[test]
fn test_resolve_stream_enabled_defaults_are_explicit() {
assert!(!resolve_stream_enabled(false, false, false).expect("json default"));
assert!(resolve_stream_enabled(true, false, false).expect("explicit stream"));
assert!(!resolve_stream_enabled(false, true, true).expect("explicit no-stream"));
}
#[test]
fn test_resolve_html_output_selection_aliases_and_conflicts() {
let html = resolve_cli_output_selection(
CliOutputFormat::Text,
false,
true,
false,
false,
Some("review".to_string()),
None,
)
.expect("html alias should resolve");
assert_eq!(html.format, CliOutputFormat::Html);
assert_eq!(html.html_template.as_deref(), Some("review"));
assert!(!html.open_in_browser);
let browser = resolve_cli_output_selection(
CliOutputFormat::Text,
false,
false,
true,
false,
None,
None,
)
.expect("browser alias should resolve");
assert_eq!(browser.format, CliOutputFormat::Html);
assert!(browser.open_in_browser);
let conflict = resolve_cli_output_selection(
CliOutputFormat::Json,
false,
true,
false,
false,
None,
None,
)
.expect_err("html and json output must conflict");
assert!(conflict.to_string().contains("--html"));
}
#[test]
fn test_normalize_html_document_strips_common_markdown_fence() {
let normalized =
normalize_html_document("```html\n<!doctype html><html><body>ok</body></html>\n```")
.expect("fenced html should normalize");
assert!(normalized.starts_with("<!doctype html>"));
assert!(normalize_html_document("plain text").is_err());
}
#[test]
fn test_html_presentation_config_parses_template_aliases() {
let config: Config = toml::from_str(
r#"
[presentation.html]
default_template = "dense"
[presentation.html.templates.dense]
body = "Render this as a dense technical artifact."
"#,
)
.expect("presentation config should parse");
assert_eq!(config.presentation.html.default_template, "dense");
assert_eq!(
config
.presentation
.html
.templates
.get("dense")
.and_then(|template| template.body.as_deref()),
Some("Render this as a dense technical artifact.")
);
}
#[tokio::test]
async fn test_write_html_output_artifact_uses_realm_presentation_dir() {
let temp = tempfile::tempdir().expect("temp dir");
let scope = test_scope(temp.path().join("state"), "html-realm");
let result = RunResult {
text: "<!doctype html><html><body>artifact</body></html>".to_string(),
session_id: SessionId::new(),
usage: Usage::default(),
turns: 1,
tool_calls: 0,
terminal_cause_kind: None,
structured_output: None,
extraction_error: None,
schema_warnings: None,
skill_diagnostics: None,
};
let path = write_html_output_artifact(&scope, &result)
.await
.expect("artifact should write");
assert_eq!(path.extension().and_then(|ext| ext.to_str()), Some("html"));
assert!(path.to_string_lossy().contains("presentation/html"));
let saved = tokio::fs::read_to_string(path)
.await
.expect("artifact should be readable");
assert!(saved.contains("artifact"));
}
#[tokio::test]
async fn test_write_html_output_artifact_uses_unique_paths() {
let temp = tempfile::tempdir().expect("temp dir");
let scope = test_scope(temp.path().join("state"), "html-realm");
let result = RunResult {
text: "<!doctype html><html><body>artifact</body></html>".to_string(),
session_id: SessionId::new(),
usage: Usage::default(),
turns: 1,
tool_calls: 0,
terminal_cause_kind: None,
structured_output: None,
extraction_error: None,
schema_warnings: None,
skill_diagnostics: None,
};
let first = write_html_output_artifact(&scope, &result)
.await
.expect("first artifact should write");
let second = write_html_output_artifact(&scope, &result)
.await
.expect("second artifact should write");
assert_ne!(first, second);
assert!(tokio::fs::try_exists(first).await.expect("first exists"));
assert!(tokio::fs::try_exists(second).await.expect("second exists"));
}
#[test]
fn test_file_url_for_path_uses_platform_file_url() {
let temp = tempfile::tempdir().expect("temp dir");
let path = temp.path().join("artifact with spaces.html");
std::fs::write(&path, "<!doctype html><html></html>").expect("write file");
let file_url = file_url_for_path(&path).expect("file URL should convert");
let parsed = url::Url::parse(&file_url).expect("file URL should parse");
assert_eq!(parsed.scheme(), "file");
assert!(file_url.contains("artifact%20with%20spaces.html"));
assert_eq!(
parsed.to_file_path().expect("file URL should map to path"),
std::fs::canonicalize(path).expect("path should canonicalize")
);
}
#[tokio::test]
async fn test_cli_runtime_executor_forwards_stream_events_to_runtime_backed_turns() {
let session_id = SessionId::new();
let service = Arc::new(CapturingEventTurnService::new(session_id.clone()));
let runtime_adapter = Arc::new(meerkat_runtime::MeerkatMachine::ephemeral());
let (event_tx, mut event_rx) = mpsc::channel::<EventEnvelope<AgentEvent>>(8);
let mut executor = CliRuntimeExecutor {
service: service.clone(),
#[cfg(feature = "session-store")]
persistent_service: None,
session_id: session_id.clone(),
runtime_adapter,
event_tx: Some(event_tx),
};
let primitive = meerkat_core::lifecycle::run_primitive::RunPrimitive::ImmediateAppend(
meerkat_core::lifecycle::run_primitive::ConversationAppend {
role: meerkat_core::lifecycle::run_primitive::ConversationAppendRole::User,
content: meerkat_core::lifecycle::run_primitive::CoreRenderable::Text {
text: "hello".to_string(),
},
},
);
let run_id = meerkat_core::lifecycle::RunId::new();
let output = meerkat_core::lifecycle::CoreExecutor::apply(&mut executor, run_id, primitive)
.await
.expect("runtime-backed CLI turn should succeed");
let Some(meerkat_core::lifecycle::core_executor::CoreApplyTerminal::RunResult(result)) =
output.terminal
else {
panic!("executor should return terminal run result");
};
assert_eq!(result.text, "streamed");
let envelope = tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
.await
.expect("stream event should arrive without timing out")
.expect("stream event channel should remain open");
assert!(matches!(
envelope.payload,
AgentEvent::TextDelta { ref delta } if delta == "streamed"
));
assert!(
service
.saw_event_tx
.load(std::sync::atomic::Ordering::Acquire),
"runtime-backed executor must forward the caller event sender"
);
}
#[tokio::test]
async fn test_cli_runtime_executor_forwards_terminal_peer_response_context() {
use meerkat_core::lifecycle::InputId;
use meerkat_core::lifecycle::run_primitive::{
ConversationContextAppend, CoreRenderable, PeerResponseTerminalApplyIntent,
RunApplyBoundary, RuntimeExecutionKind, RuntimeTurnMetadata, StagedRunInput,
};
let session_id = SessionId::new();
let service = Arc::new(CapturingEventTurnService::new(session_id.clone()));
let runtime_adapter = Arc::new(meerkat_runtime::MeerkatMachine::ephemeral());
let mut executor = CliRuntimeExecutor {
service: service.clone(),
#[cfg(feature = "session-store")]
persistent_service: None,
session_id,
runtime_adapter,
event_tx: None,
};
let append_key = "peer_response_terminal:cli-peer:req-1";
let primitive =
meerkat_core::lifecycle::run_primitive::RunPrimitive::StagedInput(StagedRunInput {
boundary: RunApplyBoundary::RunStart,
appends: Vec::new(),
context_appends: vec![ConversationContextAppend {
key: append_key.to_string(),
content: CoreRenderable::Text {
text: "terminal peer response token: ash twelve".to_string(),
},
}],
contributing_input_ids: vec![InputId::new()],
turn_metadata: Some(RuntimeTurnMetadata {
execution_kind: Some(RuntimeExecutionKind::ContentTurn),
peer_response_terminal_apply_intent: Some(
PeerResponseTerminalApplyIntent::AppendContextAndRun,
),
..Default::default()
}),
});
meerkat_core::lifecycle::CoreExecutor::apply(
&mut executor,
meerkat_core::lifecycle::RunId::new(),
primitive,
)
.await
.expect("terminal peer-response CLI turn should succeed");
let appends = service
.pre_turn_context_appends
.lock()
.expect("pre-turn context appends lock poisoned");
assert_eq!(appends.len(), 1);
assert_eq!(appends[0].source.as_deref(), Some(append_key));
assert!(
appends[0].text.contains("ash twelve"),
"terminal peer-response context must reach the CLI start_turn request"
);
}
#[test]
fn test_run_cli_surface_parses_current_flags() {
let cli = Cli::try_parse_from([
"rkat",
"run",
"hello",
"-t",
"workspace",
"--yolo",
"--param",
"temperature=0.2",
"--params-json",
r#"{"reasoning":{"effort":"high"}}"#,
"--no-web-search",
"--schema",
"./schema.json",
"--json",
"--stdin",
"lines",
"--line-format",
"json",
"--allow-tool",
"search",
"--block-tool",
"shell",
])
.expect("run should parse");
match cli.command {
Commands::Run {
prompt,
tools,
yolo,
#[cfg(feature = "skills")]
skills,
params,
provider_params_json,
no_web_search,
output_schema,
json,
stdin,
line_format,
allow_tools,
block_tools,
..
} => {
assert_eq!(prompt, "hello");
assert!(matches!(tools, Some(ToolPreset::Workspace)));
assert!(yolo);
#[cfg(feature = "skills")]
assert!(skills.is_empty());
assert_eq!(params, vec!["temperature=0.2"]);
assert_eq!(
provider_params_json.as_deref(),
Some(r#"{"reasoning":{"effort":"high"}}"#)
);
assert!(no_web_search);
assert_eq!(output_schema.as_deref(), Some("./schema.json"));
assert!(json);
assert!(matches!(stdin, StdinMode::Lines));
assert!(matches!(line_format, LineFormat::Json));
assert_eq!(allow_tools, vec!["search"]);
assert_eq!(block_tools, vec!["shell"]);
}
_ => unreachable!("expected run command"),
}
}
#[test]
fn test_run_cli_surface_parses_html_flags() {
let cli = Cli::try_parse_from([
"rkat",
"run",
"explain the architecture",
"--output",
"html",
"--open-in-browser",
"--html-template-file",
"./report-template.md",
])
.expect("run html flags should parse");
match cli.command {
Commands::Run {
output,
open_in_browser,
html_template_file,
..
} => {
assert_eq!(output, CliOutputFormat::Html);
assert!(open_in_browser);
assert_eq!(
html_template_file.as_deref(),
Some(Path::new("./report-template.md"))
);
}
_ => unreachable!("expected run command"),
}
}
#[test]
fn test_run_cli_surface_does_not_add_full_flag_alias() {
let err = match Cli::try_parse_from(["rkat", "run", "hello", "--full"]) {
Ok(_) => panic!("run should not accept a new --full alias"),
Err(err) => err,
};
assert!(
err.to_string().contains("unexpected argument '--full'")
|| err.to_string().contains("Found argument '--full'"),
"unexpected parse error for --full: {err}"
);
}
#[test]
fn test_workgraph_cli_surface_is_not_defaulted_to_run() {
let normalized = normalize_cli_args([
"rkat".into(),
"workgraph".into(),
"snapshot".into(),
"--json".into(),
]);
assert_eq!(
normalized,
vec!["rkat", "workgraph", "snapshot", "--json"]
.into_iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
);
let cli = Cli::try_parse_from(["rkat", "workgraph", "snapshot", "--json"])
.expect("workgraph snapshot should parse");
match cli.command {
Commands::WorkGraph {
command: WorkGraphCommands::Snapshot { json, .. },
} => assert!(json),
_ => unreachable!("expected workgraph snapshot command"),
}
}
#[test]
fn test_auth_realm_option_does_not_select_runtime_realm() {
let cli = Cli::try_parse_from(["rkat", "auth", "test", "--realm", "dev", "google_oauth"])
.expect("auth test should parse");
assert_eq!(cli.realm.as_deref(), Some("dev"));
let scope = resolve_runtime_scope_with_realm(&cli, None)
.expect("auth command should resolve workspace runtime scope");
assert_ne!(
scope.locator.realm.as_str(),
"dev",
"auth command --realm is a config selector and must not override RuntimeScope"
);
match cli.command {
Commands::Auth {
command: AuthCommands::Test { binding_id },
} => assert_eq!(binding_id, "google_oauth"),
_ => unreachable!("expected auth test command"),
}
let cli = Cli::try_parse_from(["rkat", "auth", "test", "google_oauth"])
.expect("auth test should parse with default config realm");
assert!(cli.realm.is_none());
match cli.command {
Commands::Auth {
command: AuthCommands::Test { binding_id },
} => assert_eq!(binding_id, "google_oauth"),
_ => unreachable!("expected auth test command"),
}
}
#[test]
fn test_keep_alive_is_independent_of_stdin_lines() {
// Regression: --keep-alive must be a separate flag from --stdin lines.
// They are distinct concerns: keep-alive is runtime behavior (stay alive,
// wake on events), stdin lines is an input mode. You can have either
// without the other.
// --keep-alive without --stdin lines
let cli = Cli::try_parse_from(["rkat", "run", "hello", "--keep-alive"])
.expect("--keep-alive should parse");
match cli.command {
Commands::Run {
keep_alive, stdin, ..
} => {
assert!(keep_alive, "--keep-alive flag must be true");
assert!(
matches!(stdin, StdinMode::Auto),
"stdin must default to auto when --stdin is not specified"
);
}
_ => unreachable!(),
}
// --stdin lines without --keep-alive
let cli = Cli::try_parse_from(["rkat", "run", "hello", "--stdin", "lines"])
.expect("--stdin lines should parse");
match cli.command {
Commands::Run {
keep_alive, stdin, ..
} => {
assert!(
!keep_alive,
"--keep-alive must not be implicitly set by --stdin lines at the arg level"
);
assert!(matches!(stdin, StdinMode::Lines));
}
_ => unreachable!(),
}
// Both together
let cli = Cli::try_parse_from(["rkat", "run", "hello", "--keep-alive", "--stdin", "lines"])
.expect("both flags should parse");
match cli.command {
Commands::Run {
keep_alive, stdin, ..
} => {
assert!(keep_alive);
assert!(matches!(stdin, StdinMode::Lines));
}
_ => unreachable!(),
}
}
#[cfg(feature = "skills")]
#[test]
fn test_run_resume_parses_current_flags() {
let resume = Cli::try_parse_from([
"rkat",
"run",
"--resume",
"last",
"keep going",
"--stdin",
"blob",
"--line-format",
"text",
"--stream",
"--allow-tool",
"search",
"--skill",
"legacy/skill",
])
.expect("run --resume should parse");
match resume.command {
Commands::Run {
resume,
prompt,
stdin,
line_format,
stream,
allow_tools,
skills,
..
} => {
assert_eq!(resume.as_deref(), Some("last"));
assert_eq!(prompt, "keep going");
assert!(matches!(stdin, StdinMode::Blob));
assert!(matches!(line_format, LineFormat::Text));
assert!(stream);
assert_eq!(allow_tools, vec!["search"]);
assert_eq!(skills, vec!["legacy/skill"]);
}
_ => unreachable!("expected run"),
}
}
#[test]
fn test_help_command_parses_question_and_prompt_payload() {
let cli = Cli::try_parse_from([
"rkat",
"help",
"How do I add an MCP server?",
"--prompt",
"Write a match-3 game",
"--plan-execution",
"--model",
"claude-sonnet-4-5",
"--provider",
"anthropic",
])
.expect("help command should parse");
match cli.command {
Commands::Help {
question,
prompt,
plan_execution,
model,
provider,
..
} => {
assert_eq!(question, "How do I add an MCP server?");
assert_eq!(prompt.as_deref(), Some("Write a match-3 game"));
assert!(plan_execution);
assert_eq!(model.as_deref(), Some("claude-sonnet-4-5"));
assert_eq!(provider, Some(Provider::Anthropic));
}
_ => unreachable!("expected help command"),
}
}
#[test]
fn test_default_trace_filter_is_quiet_unless_run_verbose() {
let cli = Cli::try_parse_from(normalize_cli_args(["rkat", "run", "hello"].map(Into::into)))
.expect("run should parse");
assert_eq!(default_trace_filter(&cli), DEFAULT_TRACE_FILTER);
let cli = Cli::try_parse_from(normalize_cli_args(
["rkat", "run", "--verbose", "hello"].map(Into::into),
))
.expect("run --verbose should parse");
assert_eq!(default_trace_filter(&cli), VERBOSE_TRACE_FILTER);
let cli = Cli::try_parse_from(normalize_cli_args(["rkat", "-v", "hello"].map(Into::into)))
.expect("prompt shortcut with -v should parse as run --verbose");
assert_eq!(default_trace_filter(&cli), VERBOSE_TRACE_FILTER);
let cli = Cli::try_parse_from(normalize_cli_args(["rkat", "models"].map(Into::into)))
.expect("models should parse");
assert_eq!(default_trace_filter(&cli), DEFAULT_TRACE_FILTER);
}
#[test]
fn test_normalize_cli_args() {
let args = normalize_cli_args(["rkat", "-h"].map(Into::into));
assert_eq!(
args,
vec!["rkat", "-h"]
.into_iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
);
let args = normalize_cli_args(["rkat", "hello world"].map(Into::into));
assert_eq!(
args,
vec!["rkat", "run", "hello world"]
.into_iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
);
let args = normalize_cli_args(["rkat", "--realm", "test", "hello"].map(Into::into));
assert_eq!(args[3], std::ffi::OsString::from("run"));
assert_eq!(args[4], std::ffi::OsString::from("hello"));
let args = normalize_cli_args(["rkat", "--isolated", "doctor"].map(Into::into));
assert_eq!(
args,
vec!["rkat", "--isolated", "doctor"]
.into_iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
);
let args = normalize_cli_args(["rkat", "--resume", "last", "hello"].map(Into::into));
assert_eq!(
args,
vec!["rkat", "run", "--resume", "last", "hello"]
.into_iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
);
let args = normalize_cli_args(["rkat", "--resume", "hello"].map(Into::into));
assert_eq!(
args,
vec!["rkat", "run", "--resume", "last", "hello"]
.into_iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
);
let args = normalize_cli_args(["rkat", "init"].map(Into::into));
assert_eq!(
args,
vec!["rkat", "init"]
.into_iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
);
let args = normalize_cli_args(["rkat", "resume", "last", "hello"].map(Into::into));
assert_eq!(
args,
vec!["rkat", "resume", "last", "hello"]
.into_iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
);
let args = normalize_cli_args(["rkat", "continue", "hello"].map(Into::into));
assert_eq!(
args,
vec!["rkat", "continue", "hello"]
.into_iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
);
let args = normalize_cli_args(["rkat", "models", "catalog"].map(Into::into));
assert_eq!(
args,
vec!["rkat", "models", "catalog"]
.into_iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
);
let args = normalize_cli_args(["rkat", "--resume", "~2"].map(Into::into));
assert_eq!(
args,
vec!["rkat", "run", "--resume", "~2"]
.into_iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
);
let args = normalize_cli_args(["rkat", "--resume", "019c8b99"].map(Into::into));
assert_eq!(
args,
vec!["rkat", "run", "--resume", "019c8b99"]
.into_iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
);
}
#[test]
fn test_session_list_parses_current_flags() {
let cli = Cli::try_parse_from([
"rkat", "session", "list", "--limit", "10", "--label", "env=dev",
])
.expect("session list should parse");
match cli.command {
Commands::Sessions {
command: SessionCommands::List { limit, labels, .. },
} => {
assert_eq!(limit, 10);
assert_eq!(labels, vec![("env".to_string(), "dev".to_string())]);
}
_ => unreachable!("expected session list command"),
}
}
#[test]
fn test_canonical_commands_parse() {
let session_cli =
Cli::try_parse_from(["rkat", "session", "list"]).expect("session should parse");
match session_cli.command {
Commands::Sessions {
command: SessionCommands::List { .. },
} => {}
_ => unreachable!("expected session list command"),
}
let realm_cli = Cli::try_parse_from(["rkat", "realm", "list"]).expect("realm should parse");
match realm_cli.command {
Commands::Realms {
command: RealmCommands::List,
} => {}
_ => unreachable!("expected realm list command"),
}
let models_cli = Cli::try_parse_from(["rkat", "models"]).expect("models should parse");
match models_cli.command {
Commands::Models => {}
_ => unreachable!("expected models command"),
}
}
#[test]
fn test_legacy_command_names_no_longer_parse() {
assert!(Cli::try_parse_from(["rkat", "sessions", "list"]).is_err());
assert!(Cli::try_parse_from(["rkat", "realms", "list"]).is_err());
assert!(Cli::try_parse_from(["rkat", "skills", "list"]).is_err());
assert!(Cli::try_parse_from(["rkat", "resume", "last", "hello"]).is_err());
assert!(Cli::try_parse_from(["rkat", "continue", "hello"]).is_err());
assert!(Cli::try_parse_from(["rkat", "models", "catalog"]).is_err());
}
#[cfg(feature = "skills")]
#[tokio::test]
async fn test_skill_path_helpers_use_same_source_uuid_and_skill_dir_path() {
let temp = tempfile::tempdir().expect("tempdir");
let skill_dir = temp.path().join("demo-skill");
tokio::fs::create_dir_all(&skill_dir)
.await
.expect("skill dir");
tokio::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: demo-skill\ndescription: demo\n---\n\n# demo\n",
)
.await
.expect("skill file");
let skill_arg = skill_dir.to_string_lossy().to_string();
let (runtime_repo, runtime_skill_id) = resolve_runtime_skill_path(&skill_arg)
.await
.expect("runtime repo");
let config_repo = resolve_skill_repo_for_config(&skill_arg, None)
.await
.expect("config repo");
assert_eq!(runtime_skill_id, "demo-skill");
assert_eq!(runtime_repo.source_uuid, config_repo.source_uuid);
let filesystem_paths = match (&runtime_repo.transport, &config_repo.transport) {
(
meerkat_core::skills_config::SkillRepoTransport::Filesystem { path: runtime_path },
meerkat_core::skills_config::SkillRepoTransport::Filesystem { path: config_path },
) => Some((runtime_path, config_path)),
_ => None,
};
assert!(
filesystem_paths.is_some(),
"expected filesystem transports, got runtime={:?} config={:?}",
runtime_repo.transport,
config_repo.transport
);
if let Some((runtime_path, config_path)) = filesystem_paths {
assert_eq!(runtime_path, config_path);
}
}
#[cfg(feature = "skills")]
#[test]
fn test_skill_entry_uses_canonical_source_identity_records() {
use meerkat_core::skills::{
SkillDescriptor, SkillIntrospectionEntry, SkillKey, SkillName, SkillScope,
SourceIdentityRecord, SourceIdentityStatus, SourceTransportKind, SourceUuid,
};
let source_uuid =
SourceUuid::parse("11111111-1111-4111-8111-111111111111").expect("source uuid");
let shadow_uuid =
SourceUuid::parse("22222222-2222-4222-8222-222222222222").expect("shadow uuid");
let key = SkillKey::new(
source_uuid.clone(),
SkillName::parse("demo-skill").expect("skill name"),
);
let mut descriptor = SkillDescriptor::new(key, "Demo Skill", "Demo description");
descriptor.scope = SkillScope::Project;
descriptor.source_name = "canonical-source".to_string();
let source_identity = SourceIdentityRecord {
source_uuid,
display_name: "canonical-source".to_string(),
transport_kind: SourceTransportKind::Git,
fingerprint: "repo-canonical-source".to_string(),
status: SourceIdentityStatus::Retired,
};
let shadow_identity = SourceIdentityRecord {
source_uuid: shadow_uuid.clone(),
display_name: "shadow-source".to_string(),
transport_kind: SourceTransportKind::Http,
fingerprint: "repo-shadow-source".to_string(),
status: SourceIdentityStatus::Disabled,
};
let entry = SkillIntrospectionEntry {
descriptor,
source_identity: Some(source_identity.clone()),
shadowed_by: Some("shadow-source".to_string()),
shadowed_by_identity: Some(shadow_identity.clone()),
shadowed_by_source_uuid: Some(shadow_uuid),
is_active: false,
};
let wire = skill_entry(&entry).expect("skill entry");
assert_eq!(wire.source.identity, source_identity);
assert_eq!(
wire.shadowed_by.expect("shadowed by").identity,
shadow_identity
);
}
#[test]
fn test_build_flow_tool_overlay_empty_is_none() {
assert!(build_flow_tool_overlay(Vec::new(), Vec::new()).is_none());
}
#[test]
fn test_build_flow_tool_overlay_populates_lists() {
let overlay = build_flow_tool_overlay(
vec!["search".to_string(), "read_file".to_string()],
vec!["shell".to_string()],
)
.expect("overlay should be present");
assert_eq!(
overlay.allowed_tools,
Some(vec!["search".to_string(), "read_file".to_string()])
);
assert_eq!(overlay.blocked_tools, Some(vec!["shell".to_string()]));
}
#[cfg(feature = "comms")]
#[test]
fn test_parse_comms_send_payload_input_defaults() -> Result<(), Box<dyn std::error::Error>> {
let session_id = SessionId::new();
let cmd = parse_comms_send_payload(r#"{"kind":"input","body":"hello"}"#, &session_id)
.expect("input payload should parse");
let CommsCommand::Input {
session_id: parsed_id,
body,
source,
allow_self_session,
blocks: _,
handling_mode: _,
stream: _,
} = cmd
else {
return Err("unexpected command parsed for input payload".into());
};
assert_eq!(parsed_id, session_id);
assert_eq!(body, "hello");
assert_eq!(source, meerkat_core::comms::InputSource::Rpc);
assert!(!allow_self_session);
Ok(())
}
#[cfg(feature = "comms")]
#[test]
fn test_parse_comms_send_payload_peer_request_accepts_reserve_interaction_stream() {
let session_id = SessionId::new();
let to = uuid::Uuid::new_v4();
let payload = format!(
r#"{{"kind":"peer_request","to":"{to}","intent":"help","params":{{"topic":"x"}},"handling_mode":"queue","stream":"reserve_interaction"}}"#,
);
let cmd = parse_comms_send_payload(&payload, &session_id)
.expect("peer request reserve_interaction stream should be accepted");
assert!(
matches!(cmd, meerkat_core::comms::CommsCommand::PeerRequest { .. }),
"unexpected command parsed for peer request payload: {cmd:?}"
);
let (stream, handling_mode) = match cmd {
meerkat_core::comms::CommsCommand::PeerRequest {
stream,
handling_mode,
..
} => (stream, handling_mode),
_ => unreachable!("asserted above"),
};
assert_eq!(
stream,
meerkat_core::comms::InputStreamMode::ReserveInteraction
);
assert_eq!(handling_mode, meerkat_core::HandlingMode::Queue);
}
#[cfg(feature = "comms")]
#[test]
fn test_parse_comms_send_payload_rejects_invalid_json() {
let session_id = SessionId::new();
let err = parse_comms_send_payload("{not-json}", &session_id)
.expect_err("invalid json must be rejected");
assert!(err.to_string().contains("Invalid comms JSON payload"));
}
#[cfg(feature = "comms")]
#[test]
fn test_parse_comms_send_payload_rejects_invalid_command_shape() {
let session_id = SessionId::new();
let to = uuid::Uuid::new_v4();
let payload = format!(r#"{{"kind":"peer_request","to":"{to}"}}"#);
let err = parse_comms_send_payload(&payload, &session_id)
.expect_err("missing intent should be rejected");
// Missing required `intent` field is rejected at the typed-serde
// boundary, not a runtime string match.
assert!(err.to_string().contains("Invalid comms JSON payload"));
assert!(err.to_string().contains("intent"));
}
#[cfg(feature = "mob")]
#[test]
fn test_mob_run_flow_short_flags_parse() {
let cli = Cli::try_parse_from(["rkat", "mob", "run-flow", "mob-1", "--flow", "f1", "-s"])
.expect("mob run-flow should parse");
match cli.command {
Commands::Mob {
command:
MobCommands::RunFlow {
mob_id,
flow,
stream,
..
},
} => {
assert_eq!(mob_id, "mob-1");
assert_eq!(flow, "f1");
assert!(stream);
}
_ => unreachable!("expected mob run-flow command"),
}
}
#[cfg(feature = "mob")]
#[test]
fn test_cli_mob_pack_command_parses() {
let cli = Cli::try_parse_from([
"rkat",
"mob",
"pack",
"./example-mob",
"-o",
"./out.mobpack",
"--sign",
"./signing.key",
"--signer-id",
"team@example.com",
])
.expect("mob pack command should parse");
match cli.command {
Commands::Mob {
command:
MobCommands::Pack {
dir,
output,
sign,
signer_id,
},
} => {
assert_eq!(dir, PathBuf::from("./example-mob"));
assert_eq!(output, PathBuf::from("./out.mobpack"));
assert_eq!(sign, Some(PathBuf::from("./signing.key")));
assert_eq!(signer_id.as_deref(), Some("team@example.com"));
}
_ => unreachable!("expected mob pack command"),
}
}
#[cfg(feature = "mob")]
#[test]
fn test_cli_mob_pack_requires_signer_id_with_sign() {
let Err(err) = Cli::try_parse_from([
"rkat",
"mob",
"pack",
"./example-mob",
"-o",
"./out.mobpack",
"--sign",
"./signing.key",
]) else {
panic!("--sign without --signer-id should be rejected");
};
let rendered = err.to_string();
assert!(
rendered.contains("signer-id") || rendered.contains("signer_id"),
"expected signer-id requirement error, got: {rendered}"
);
}
#[cfg(feature = "mob")]
#[test]
fn test_cli_mob_pack_requires_sign_with_signer_id() {
let Err(err) = Cli::try_parse_from([
"rkat",
"mob",
"pack",
"./example-mob",
"-o",
"./out.mobpack",
"--signer-id",
"team@example.com",
]) else {
panic!("--signer-id without --sign should be rejected");
};
let rendered = err.to_string();
assert!(
rendered.contains("sign"),
"expected sign requirement error, got: {rendered}"
);
}
#[cfg(feature = "mob")]
#[test]
fn test_cli_mob_deploy_command_parses() {
let cli = Cli::try_parse_from([
"rkat",
"mob",
"deploy",
"./fixture.mobpack",
"hello",
"--trust-policy",
"strict",
])
.expect("mob deploy command should parse");
match cli.command {
Commands::Mob {
command:
MobCommands::Deploy {
pack,
prompt,
model,
max_total_tokens,
max_duration,
max_tool_calls,
trust_policy,
surface,
},
} => {
assert_eq!(pack, PathBuf::from("./fixture.mobpack"));
assert_eq!(prompt, "hello");
assert_eq!(model, None);
assert_eq!(max_total_tokens, None);
assert_eq!(max_duration, None);
assert_eq!(max_tool_calls, None);
assert_eq!(trust_policy, Some(TrustPolicyArg::Strict));
assert_eq!(surface, DeploySurfaceArg::Cli);
}
_ => unreachable!("expected mob deploy command"),
}
}
#[cfg(feature = "mob")]
#[test]
fn test_cli_mob_deploy_surface_flag_parses() {
let cli = Cli::try_parse_from([
"rkat",
"mob",
"deploy",
"./fixture.mobpack",
"hello",
"--surface",
"rpc",
])
.expect("mob deploy --surface should parse");
match cli.command {
Commands::Mob {
command: MobCommands::Deploy { surface, .. },
} => {
assert_eq!(surface, DeploySurfaceArg::Rpc);
}
_ => unreachable!("expected mob deploy command"),
}
}
#[cfg(feature = "mcp")]
#[test]
fn test_cli_mcp_add_and_remove_parse_local_config_surface() {
let add = Cli::try_parse_from([
"rkat",
"mcp",
"add",
"filesystem",
"--scope",
"project",
"--transport",
"stdio",
"--",
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
".",
])
.expect("mcp add should parse");
match add.command {
Commands::Mcp {
command:
McpCommands::Add {
name,
scope,
transport,
command,
..
},
} => {
assert_eq!(name, "filesystem");
assert!(matches!(scope, CliMcpScope::Project));
assert!(matches!(transport, Some(CliTransport::Stdio)));
assert_eq!(
command,
vec!["npx", "-y", "@modelcontextprotocol/server-filesystem", "."]
);
}
_ => unreachable!("expected mcp add"),
}
let remove =
Cli::try_parse_from(["rkat", "mcp", "remove", "filesystem", "--scope", "project"])
.expect("mcp remove should parse");
match remove.command {
Commands::Mcp {
command: McpCommands::Remove { name, scope },
} => {
assert_eq!(name, "filesystem");
assert!(matches!(scope, Some(CliMcpScope::Project)));
}
_ => unreachable!("expected mcp remove"),
}
}
#[test]
fn test_help_snapshots_cover_current_public_surface() {
use clap::CommandFactory;
fn render_help(mut command: clap::Command) -> String {
let mut out = Vec::new();
command
.write_long_help(&mut out)
.expect("help should render");
String::from_utf8(out).expect("utf-8 help")
}
let top = render_help(Cli::command());
assert!(top.contains("Usage: rkat [OPTIONS] <PROMPT>"));
assert!(top.contains("cat story.txt | rkat \"summarize the story\""));
assert!(top.contains("rkat help \"How do I add an mcp server?\""));
assert!(top.contains("tail -f app.log | rkat run --stdin lines"));
assert!(top.contains("help"));
let run = render_help(Cli::command().find_subcommand("run").unwrap().clone());
assert!(run.contains("--tools <TOOLS>"));
assert!(run.contains("--yolo"));
assert!(run.contains("--stdin <STDIN>"));
assert!(run.contains("piped stdin is read as blob context"));
let help = render_help(Cli::command().find_subcommand("help").unwrap().clone());
assert!(help.contains("--prompt <PROMPT>"));
assert!(help.contains("--plan-execution"));
assert!(help.contains("How do I add an mcp server"));
#[cfg(feature = "mob")]
{
let mob = render_help(Cli::command().find_subcommand("mob").unwrap().clone());
assert!(mob.contains("pack"));
assert!(mob.contains("deploy"));
assert!(mob.contains("run-flow"));
assert!(mob.contains("flow-status"));
assert!(!mob.contains("prefabs"));
assert!(!mob.contains("create"));
// spawn-helper and fork-helper are user-facing; raw "spawn" is not
assert!(mob.contains("spawn-helper"));
assert!(mob.contains("fork-helper"));
assert!(mob.contains("member-status"));
assert!(mob.contains("force-cancel"));
assert!(mob.contains("respawn"));
assert!(mob.contains("wait-kickoff"));
}
}
#[cfg(feature = "mob")]
#[test]
fn test_cli_mob_wait_kickoff_command_parses() {
let cli = Cli::try_parse_from([
"rkat",
"mob",
"wait-kickoff",
"mob-a",
"--member",
"a-1",
"--member",
"a-2",
"--timeout-ms",
"2500",
"--json",
])
.expect("mob wait-kickoff command should parse");
match cli.command {
Commands::Mob {
command:
MobCommands::WaitKickoff {
mob_id,
member_ids,
timeout_ms,
json,
},
} => {
assert_eq!(mob_id, "mob-a");
assert_eq!(member_ids, vec!["a-1".to_string(), "a-2".to_string()]);
assert_eq!(timeout_ms, Some(2500));
assert!(json);
}
_ => unreachable!("expected mob wait-kickoff command"),
}
}
#[cfg(feature = "mob")]
#[test]
fn test_cli_mob_deploy_override_flags_parse() {
let cli = Cli::try_parse_from([
"rkat",
"mob",
"deploy",
"./fixture.mobpack",
"hello",
"--model",
"gpt-5-mini",
"--max-total-tokens",
"9999",
"--max-duration",
"5m",
"--max-tool-calls",
"7",
])
.expect("mob deploy overrides should parse");
match cli.command {
Commands::Mob {
command:
MobCommands::Deploy {
model,
max_total_tokens,
max_duration,
max_tool_calls,
..
},
} => {
assert_eq!(model.as_deref(), Some("gpt-5-mini"));
assert_eq!(max_total_tokens, Some(9999));
assert_eq!(max_duration.as_deref(), Some("5m"));
assert_eq!(max_tool_calls, Some(7));
}
_ => unreachable!("expected mob deploy command"),
}
}
#[cfg(feature = "mob")]
#[test]
fn test_cli_mob_web_build_command_parses() {
let cli = Cli::try_parse_from([
"rkat",
"mob",
"web",
"build",
"./fixture.mobpack",
"-o",
"./web-out",
])
.expect("mob web build command should parse");
match cli.command {
Commands::Mob {
command:
MobCommands::Web {
command:
MobWebCommands::Build {
pack,
output,
trust_policy,
},
},
} => {
assert_eq!(pack, PathBuf::from("./fixture.mobpack"));
assert_eq!(output, PathBuf::from("./web-out"));
assert_eq!(trust_policy, None);
}
_ => unreachable!("expected mob web build command"),
}
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_mob_pack_command_wires_archive_writer_and_digest_output() {
let temp = tempfile::tempdir().expect("tempdir");
let mob_dir = create_mobpack_fixture_dir(temp.path());
let output = temp.path().join("fixture.mobpack");
let digest = execute_mob_pack(&mob_dir, &output, None)
.await
.expect("mob pack command should succeed");
let archive_bytes = tokio::fs::read(&output)
.await
.expect("archive should be written");
assert!(
!archive_bytes.is_empty(),
"pack command should write a non-empty archive"
);
let recomputed = compute_archive_digest(&archive_bytes).expect("digest should compute");
assert_eq!(digest, recomputed.to_string());
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_mob_pack_output_inside_source_root_is_deterministic() {
let temp = tempfile::tempdir().expect("tempdir");
let mob_dir = create_mobpack_fixture_dir(temp.path());
let output = mob_dir.join("out.mobpack");
let first_digest = execute_mob_pack(&mob_dir, &output, None)
.await
.expect("first pack should succeed");
let first_bytes = tokio::fs::read(&output).await.expect("first archive bytes");
let second_digest = execute_mob_pack(&mob_dir, &output, None)
.await
.expect("second pack should succeed");
let second_bytes = tokio::fs::read(&output)
.await
.expect("second archive bytes");
assert_eq!(first_digest, second_digest);
assert_eq!(first_bytes, second_bytes);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_mob_inspect_output_includes_metadata_and_digest() {
let temp = tempfile::tempdir().expect("tempdir");
let mob_dir = create_mobpack_fixture_dir(temp.path());
let output = temp.path().join("fixture.mobpack");
execute_mob_pack(&mob_dir, &output, None)
.await
.expect("pack before inspect");
let inspect_output = execute_mob_inspect(&output)
.await
.expect("mob inspect should succeed");
let archive_bytes = tokio::fs::read(&output).await.expect("read packed archive");
let digest = compute_archive_digest(&archive_bytes).expect("compute digest");
assert!(
inspect_output.contains("name\tfixture"),
"inspect should include manifest name"
);
assert!(
inspect_output.contains("version\t1.0.0"),
"inspect should include manifest version"
);
assert!(
inspect_output.contains("file_count\t"),
"inspect should include file count"
);
assert!(
inspect_output.contains(&format!("digest\t{digest}")),
"inspect should include computed digest"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_mob_validate_success_and_failure_behaviors() {
let temp = tempfile::tempdir().expect("tempdir");
let scope = test_scope_with_context(temp.path().to_path_buf());
let mob_dir = create_mobpack_fixture_dir(temp.path());
let valid_pack = temp.path().join("valid.mobpack");
execute_mob_pack(&mob_dir, &valid_pack, None)
.await
.expect("pack before validate");
let ok_output = execute_mob_validate(&scope, &valid_pack, None)
.await
.expect("validate should succeed");
assert!(
ok_output.starts_with("valid\t"),
"validate success should report digest"
);
assert!(
ok_output.contains("warning\tunsigned pack accepted in permissive mode"),
"validate should surface trust verification warnings: {ok_output}"
);
let strict_err = execute_mob_validate(&scope, &valid_pack, Some(TrustPolicyArg::Strict))
.await
.expect_err("strict validate should reject unsigned packs");
assert!(
strict_err.to_string().contains("unsigned pack"),
"strict validate should fail through trust verification: {strict_err}"
);
let web_out = temp.path().join("web-out");
let web_err =
execute_mob_web_build(&scope, &valid_pack, &web_out, Some(TrustPolicyArg::Strict))
.await
.expect_err("strict web build should reject unsigned packs");
assert!(
web_err.to_string().contains("unsigned pack"),
"web build should share the trust verification seam: {web_err}"
);
let invalid_pack = temp.path().join("invalid.mobpack");
let invalid_bytes =
meerkat_mob_pack::targz::create_targz(&std::collections::BTreeMap::from([(
"manifest.toml".to_string(),
b"[mobpack]\nname = \"fixture\"\nversion = \"1.0.0\"\n".to_vec(),
)]))
.expect("create invalid archive");
tokio::fs::write(&invalid_pack, invalid_bytes)
.await
.expect("write invalid archive");
let err = execute_mob_validate(&scope, &invalid_pack, None)
.await
.expect_err("validate should fail when definition.json is missing");
assert!(
err.to_string().contains("definition.json is missing"),
"validate failure should mention missing definition: {err}"
);
}
#[cfg(feature = "mob")]
#[cfg(feature = "mob")]
#[test]
fn test_trust_policy_resolution_precedence() {
let from_cli = resolve_trust_policy(
Some(TrustPolicyArg::Strict),
|_| Some("permissive".to_string()),
Some(TrustPolicy::Permissive),
)
.expect("cli should win");
assert_eq!(from_cli, TrustPolicy::Strict);
let from_env = resolve_trust_policy(
None,
|_| Some("strict".to_string()),
Some(TrustPolicy::Permissive),
)
.expect("env should win when cli missing");
assert_eq!(from_env, TrustPolicy::Strict);
let from_config = resolve_trust_policy(None, |_| None, Some(TrustPolicy::Strict))
.expect("config should win when cli/env missing");
assert_eq!(from_config, TrustPolicy::Strict);
let from_default = resolve_trust_policy(None, |_| None, None).expect("default permissive");
assert_eq!(from_default, TrustPolicy::Permissive);
}
#[cfg(feature = "mob")]
#[test]
fn test_pack_config_merges_with_runtime_config() {
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let config_path =
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str())
.config_path;
std::fs::create_dir_all(config_path.parent().expect("config parent"))
.expect("mkdir config");
std::fs::write(&config_path, "[agent]\nmax_tokens_per_turn = 1234\n")
.expect("write config");
let pack_defaults = br"
[agent]
max_tokens_per_turn = 100
[tools]
mob_enabled = true
"
.to_vec();
let merged = load_deploy_config_with_pack_defaults(
&scope,
Some(&pack_defaults),
CliOverrides::default(),
)
.expect("pack defaults should merge");
assert_eq!(
merged.agent.max_tokens_per_turn, 1234,
"file config should override pack defaults"
);
assert!(
merged.tools.mob_enabled,
"pack defaults should apply when higher-priority layers omit field"
);
}
#[cfg(feature = "mob")]
#[test]
fn test_pack_config_explicit_runtime_default_is_not_clobbered() {
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let config_path =
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str())
.config_path;
std::fs::create_dir_all(config_path.parent().expect("config parent"))
.expect("mkdir config");
std::fs::write(&config_path, "[tools]\nmob_enabled = false\n").expect("write config");
let pack_defaults = br"
[tools]
mob_enabled = true
"
.to_vec();
let merged = load_deploy_config_with_pack_defaults(
&scope,
Some(&pack_defaults),
CliOverrides::default(),
)
.expect("merge should succeed");
assert!(
!merged.tools.mob_enabled,
"explicit file value equal to default should not be clobbered by pack defaults"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_deploy_rejects_missing_capability() {
let temp = tempfile::tempdir().expect("tempdir");
let scope = test_scope_with_context(temp.path().to_path_buf());
let mob_dir = temp.path().join("cap-mob");
std::fs::create_dir_all(mob_dir.join("skills")).expect("skills");
std::fs::write(
mob_dir.join("manifest.toml"),
r#"[mobpack]
name = "cap"
version = "1.0.0"
[requires]
capabilities = ["definitely_missing_capability"]
"#,
)
.expect("manifest");
std::fs::write(mob_dir.join("definition.json"), br#"{"id":"cap-mob"}"#).expect("def");
std::fs::write(mob_dir.join("skills").join("review.md"), "# Review\n").expect("skill");
let pack_out = temp.path().join("cap.mobpack");
execute_mob_pack(&mob_dir, &pack_out, None)
.await
.expect("pack succeeds");
let err = execute_mob_deploy(
&scope,
&pack_out,
"hello",
None,
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect_err("missing capability should reject deploy");
assert!(
err.to_string()
.contains("required capability missing: definitely_missing_capability"),
"unexpected error: {err}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_deploy_cli_success_runs_full_pipeline() {
let temp = tempfile::tempdir().expect("tempdir");
let scope = test_scope_with_context(temp.path().to_path_buf());
let mob_dir = create_mobpack_fixture_dir(temp.path());
let pack_out = temp.path().join("ok.mobpack");
execute_mob_pack(&mob_dir, &pack_out, None)
.await
.expect("pack succeeds");
let output = execute_mob_deploy(
&scope,
&pack_out,
"hello",
None,
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect("deploy should succeed");
assert!(
output.contains("deployed\tmob="),
"unexpected output: {output}"
);
assert!(
output.contains("surface=cli"),
"unexpected output: {output}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_deploy_surfaces_missing_packed_skill_path_error() {
let temp = tempfile::tempdir().expect("tempdir");
let scope = test_scope_with_context(temp.path().to_path_buf());
let pack_out = temp.path().join("missing-skill.mobpack");
let archive_bytes = meerkat_mob_pack::targz::create_targz(&std::collections::BTreeMap::from([
(
"manifest.toml".to_string(),
b"[mobpack]\nname = \"fixture\"\nversion = \"1.0.0\"\n".to_vec(),
),
(
"definition.json".to_string(),
br#"{"id":"fixture-mob","skills":{"review":{"source":"path","path":"skills/missing.md"}}}"#.to_vec(),
),
("skills/review.md".to_string(), b"# Review\n".to_vec()),
]))
.expect("create archive");
tokio::fs::write(&pack_out, archive_bytes)
.await
.expect("write archive");
let err = execute_mob_deploy(
&scope,
&pack_out,
"hello",
None,
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect_err("missing packed skill path should fail");
assert!(
err.to_string().contains(
"mobpack skill path 'skills/missing.md' for 'review' missing from archive"
),
"unexpected error: {err}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_deploy_surfaces_invalid_utf8_skill_bytes_error() {
let temp = tempfile::tempdir().expect("tempdir");
let scope = test_scope_with_context(temp.path().to_path_buf());
let pack_out = temp.path().join("invalid-utf8-skill.mobpack");
let archive_bytes = meerkat_mob_pack::targz::create_targz(&std::collections::BTreeMap::from([
(
"manifest.toml".to_string(),
b"[mobpack]\nname = \"fixture\"\nversion = \"1.0.0\"\n".to_vec(),
),
(
"definition.json".to_string(),
br#"{"id":"fixture-mob","skills":{"review":{"source":"path","path":"skills/review.md"}}}"#.to_vec(),
),
("skills/review.md".to_string(), vec![0xff, 0xfe, 0xfd]),
]))
.expect("create archive");
tokio::fs::write(&pack_out, archive_bytes)
.await
.expect("write archive");
let err = execute_mob_deploy(
&scope,
&pack_out,
"hello",
None,
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect_err("invalid UTF-8 skill bytes should fail");
assert!(
err.to_string()
.contains("mobpack skill path 'skills/review.md' for 'review' is not valid UTF-8"),
"unexpected error: {err}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_mob_deploy_rpc_surface_executes_deploy_path() {
use tokio::io::AsyncWriteExt;
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let mob_dir = create_mobpack_fixture_dir(temp.path());
let pack_out = temp.path().join("rpc.mobpack");
execute_mob_pack(&mob_dir, &pack_out, None)
.await
.expect("pack succeeds");
let (mut client_in, server_in) = tokio::io::duplex(1024);
let (server_out, _client_out) = tokio::io::duplex(1024);
let scope_for_deploy = scope.clone();
let pack_for_deploy = pack_out.clone();
let mut deploy_task = tokio::spawn(async move {
Box::pin(execute_mob_deploy_internal(
&scope_for_deploy,
&pack_for_deploy,
"hello",
DeployInvocation {
cli_trust_policy: None,
surface: DeploySurfaceArg::Rpc,
cli_overrides: CliOverrides::default(),
rpc_io: Some((Box::new(BufReader::new(server_in)), Box::new(server_out))),
config_observer: None,
},
))
.await
});
let output =
match tokio::time::timeout(std::time::Duration::from_millis(120), &mut deploy_task)
.await
{
Err(_) => {
client_in.shutdown().await.expect("shutdown input");
deploy_task
.await
.expect("deploy task join")
.expect("deploy should exit after rpc input shutdown")
}
Ok(joined) => joined
.expect("deploy task join")
.expect("deploy rpc surface should either stay alive or complete cleanly"),
};
assert!(
output.contains("deployed\tmob="),
"unexpected output: {output}"
);
assert!(
output.contains("surface=rpc"),
"unexpected output: {output}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_mob_deploy_strict_unsigned_rejects_at_cli_boundary() {
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let mob_dir = create_mobpack_fixture_dir(temp.path());
let pack_out = temp.path().join("unsigned.mobpack");
execute_mob_pack(&mob_dir, &pack_out, None)
.await
.expect("pack succeeds");
let err = execute_mob_deploy(
&scope,
&pack_out,
"hello",
Some(TrustPolicyArg::Strict),
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect_err("strict mode must reject unsigned pack");
assert!(
err.to_string()
.contains("unsigned pack rejected in strict trust mode"),
"unexpected error: {err}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_mob_deploy_permissive_unsigned_warns_and_proceeds() {
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let mob_dir = create_mobpack_fixture_dir(temp.path());
let pack_out = temp.path().join("unsigned.mobpack");
execute_mob_pack(&mob_dir, &pack_out, None)
.await
.expect("pack succeeds");
let output = execute_mob_deploy(
&scope,
&pack_out,
"hello",
Some(TrustPolicyArg::Permissive),
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect("permissive mode should allow unsigned pack");
assert!(
output.contains("deployed\tmob="),
"unexpected output: {output}"
);
assert!(
output.contains("warning\tunsigned pack accepted in permissive mode"),
"unexpected output: {output}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_mob_deploy_strict_signed_path_succeeds_with_trusted_signer() {
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let mob_dir = create_mobpack_fixture_dir(temp.path());
let pack_out = temp.path().join("signed.mobpack");
let signing_key = temp.path().join("signing.key");
std::fs::write(
&signing_key,
"0707070707070707070707070707070707070707070707070707070707070707",
)
.expect("write signing key");
execute_mob_pack(
&mob_dir,
&pack_out,
Some(meerkat_mob_pack::pack::SigningRequest {
signer_id: "ci-test",
key_path: &signing_key,
}),
)
.await
.expect("signed pack succeeds");
let archive_bytes = tokio::fs::read(&pack_out).await.expect("read archive");
let files = extract_targz_safe(&archive_bytes).expect("extract archive");
let signature_text =
std::str::from_utf8(files.get("signature.toml").expect("signature.toml"))
.expect("utf8");
let signature_value: toml::Value = toml::from_str(signature_text).expect("parse signature");
let signer_id = signature_value
.get("signer_id")
.and_then(toml::Value::as_str)
.expect("signer_id");
let public_key = signature_value
.get("public_key")
.and_then(toml::Value::as_str)
.expect("public_key");
let trust_path = project_trust_store_path(&scope);
std::fs::create_dir_all(trust_path.parent().expect("trust parent")).expect("trust dir");
std::fs::write(
&trust_path,
format!("[signers]\n{signer_id} = \"{public_key}\"\n"),
)
.expect("write trusted signers");
let output = execute_mob_deploy(
&scope,
&pack_out,
"hello",
Some(TrustPolicyArg::Strict),
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect("strict trusted signed deploy should succeed");
assert!(
output.contains("deployed\tmob="),
"unexpected output: {output}"
);
assert!(
!output.contains("\nwarning\t"),
"strict trusted signed deploy should not warn: {output}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_e2e_pack_and_deploy() {
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let mob_dir = create_mobpack_fixture_dir_with_skill_path(temp.path());
let pack_out = temp.path().join("e2e-pack-deploy.mobpack");
execute_mob_pack(&mob_dir, &pack_out, None)
.await
.expect("pack should succeed");
let output = execute_mob_deploy(
&scope,
&pack_out,
"hello",
None,
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect("deploy should succeed");
assert!(
output.contains("deployed\tmob=fixture-mob-with-skill\tsurface=cli"),
"deploy should use packed definition id and complete CLI deploy path: {output}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_e2e_signed_deploy_strict() {
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let mob_dir = create_mobpack_fixture_dir(temp.path());
let pack_out = temp.path().join("e2e-signed.mobpack");
let signing_key = temp.path().join("ci-signer.key");
std::fs::write(
&signing_key,
"0707070707070707070707070707070707070707070707070707070707070707",
)
.expect("write signing key");
execute_mob_pack(
&mob_dir,
&pack_out,
Some(meerkat_mob_pack::pack::SigningRequest {
signer_id: "ci-test",
key_path: &signing_key,
}),
)
.await
.expect("signed pack should succeed");
let archive_bytes = tokio::fs::read(&pack_out)
.await
.expect("read signed archive");
let files = extract_targz_safe(&archive_bytes).expect("extract archive");
let signature_toml = files
.get("signature.toml")
.expect("signature.toml should exist");
let signature_value: toml::Value =
toml::from_str(std::str::from_utf8(signature_toml).expect("utf8"))
.expect("parse signature");
let signer_id = signature_value
.get("signer_id")
.and_then(toml::Value::as_str)
.expect("signer id")
.to_string();
let public_key = signature_value
.get("public_key")
.and_then(toml::Value::as_str)
.expect("public key")
.to_string();
let trust_path = project_trust_store_path(&scope);
std::fs::create_dir_all(trust_path.parent().expect("trust parent")).expect("trust dir");
std::fs::write(
&trust_path,
format!("[signers]\n{signer_id} = \"{public_key}\"\n"),
)
.expect("write trust store");
let output = execute_mob_deploy(
&scope,
&pack_out,
"hello",
Some(TrustPolicyArg::Strict),
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect("strict signed deploy should succeed");
assert!(
output.contains("deployed\tmob="),
"unexpected output: {output}"
);
assert!(
!output.contains("\nwarning\t"),
"strict trusted signed deploy should not warn: {output}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_e2e_unsigned_strict_rejected() {
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let mob_dir = create_mobpack_fixture_dir(temp.path());
let pack_out = temp.path().join("e2e-unsigned.mobpack");
execute_mob_pack(&mob_dir, &pack_out, None)
.await
.expect("pack should succeed");
let err = execute_mob_deploy(
&scope,
&pack_out,
"hello",
Some(TrustPolicyArg::Strict),
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect_err("strict mode must reject unsigned pack");
assert!(
err.to_string()
.contains("unsigned pack rejected in strict trust mode"),
"unexpected error: {err}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_e2e_unknown_signer_strict_rejected() {
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let mob_dir = create_mobpack_fixture_dir(temp.path());
let pack_out = temp.path().join("e2e-unknown-signer.mobpack");
let signing_key = temp.path().join("unknown.key");
std::fs::write(
&signing_key,
"0505050505050505050505050505050505050505050505050505050505050505",
)
.expect("write signing key");
execute_mob_pack(
&mob_dir,
&pack_out,
Some(meerkat_mob_pack::pack::SigningRequest {
signer_id: "ci-test",
key_path: &signing_key,
}),
)
.await
.expect("signed pack should succeed");
let err = execute_mob_deploy(
&scope,
&pack_out,
"hello",
Some(TrustPolicyArg::Strict),
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect_err("strict mode must reject unknown signer");
assert!(
err.to_string().contains("unknown signer"),
"unexpected error: {err}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_e2e_tampered_content_strict_rejected() {
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let mob_dir = create_mobpack_fixture_dir(temp.path());
let signed_pack = temp.path().join("e2e-tampered-source.mobpack");
let tampered_pack = temp.path().join("e2e-tampered.mobpack");
let signing_key = temp.path().join("trusted.key");
std::fs::write(
&signing_key,
"0404040404040404040404040404040404040404040404040404040404040404",
)
.expect("write signing key");
execute_mob_pack(
&mob_dir,
&signed_pack,
Some(meerkat_mob_pack::pack::SigningRequest {
signer_id: "ci-test",
key_path: &signing_key,
}),
)
.await
.expect("signed pack should succeed");
let signed_bytes = tokio::fs::read(&signed_pack)
.await
.expect("read signed pack");
let mut files = extract_targz_safe(&signed_bytes).expect("extract signed pack");
let signature_toml = files
.get("signature.toml")
.expect("signature.toml should exist")
.clone();
let signature_value: toml::Value =
toml::from_str(std::str::from_utf8(&signature_toml).expect("utf8"))
.expect("parse signature");
let signer_id = signature_value
.get("signer_id")
.and_then(toml::Value::as_str)
.expect("signer id")
.to_string();
let public_key = signature_value
.get("public_key")
.and_then(toml::Value::as_str)
.expect("public key")
.to_string();
files.insert("skills/review.md".to_string(), b"# tampered\n".to_vec());
let tampered_bytes =
meerkat_mob_pack::targz::create_targz(&files).expect("tampered archive");
tokio::fs::write(&tampered_pack, tampered_bytes)
.await
.expect("write tampered archive");
let trust_path = project_trust_store_path(&scope);
std::fs::create_dir_all(trust_path.parent().expect("trust parent")).expect("trust dir");
std::fs::write(
&trust_path,
format!("[signers]\n{signer_id} = \"{public_key}\"\n"),
)
.expect("write trust store");
let err = execute_mob_deploy(
&scope,
&tampered_pack,
"hello",
Some(TrustPolicyArg::Strict),
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect_err("strict mode must reject tampered content");
assert!(
err.to_string()
.contains("signature digest does not match archive content digest"),
"unexpected error: {err}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_e2e_bad_signature_permissive_rejected() {
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let mob_dir = create_mobpack_fixture_dir(temp.path());
let signed_pack = temp.path().join("e2e-bad-signature-source.mobpack");
let bad_signature_pack = temp.path().join("e2e-bad-signature.mobpack");
let signing_key = temp.path().join("bad-signature.key");
std::fs::write(
&signing_key,
"0606060606060606060606060606060606060606060606060606060606060606",
)
.expect("write signing key");
execute_mob_pack(
&mob_dir,
&signed_pack,
Some(meerkat_mob_pack::pack::SigningRequest {
signer_id: "ci-test",
key_path: &signing_key,
}),
)
.await
.expect("signed pack should succeed");
let signed_bytes = tokio::fs::read(&signed_pack)
.await
.expect("read signed pack");
let mut files = extract_targz_safe(&signed_bytes).expect("extract signed pack");
let signature_toml = files
.get("signature.toml")
.expect("signature.toml should exist");
let mut signature_value: toml::Value =
toml::from_str(std::str::from_utf8(signature_toml).expect("utf8"))
.expect("parse signature");
let mut encoded_signature = signature_value
.get("signature")
.and_then(toml::Value::as_str)
.expect("signature")
.to_string();
let first = encoded_signature
.chars()
.next()
.expect("signature should not be empty");
let replacement = if first == '0' { '1' } else { '0' };
encoded_signature.replace_range(0..1, &replacement.to_string());
signature_value["signature"] = toml::Value::String(encoded_signature);
files.insert(
"signature.toml".to_string(),
toml::to_string(&signature_value)
.expect("serialize signature")
.into_bytes(),
);
let bad_sig_bytes =
meerkat_mob_pack::targz::create_targz(&files).expect("bad signature archive");
tokio::fs::write(&bad_signature_pack, bad_sig_bytes)
.await
.expect("write archive");
let err = execute_mob_deploy(
&scope,
&bad_signature_pack,
"hello",
Some(TrustPolicyArg::Permissive),
DeploySurfaceArg::Cli,
CliOverrides::default(),
)
.await
.expect_err("permissive mode must reject invalid signatures when present");
assert!(
err.to_string().contains("signature is invalid"),
"unexpected error: {err}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_e2e_inspect_output() {
let temp = tempfile::tempdir().expect("tempdir");
let mob_dir = create_mobpack_fixture_dir(temp.path());
let output = temp.path().join("e2e-inspect.mobpack");
execute_mob_pack(&mob_dir, &output, None)
.await
.expect("pack should succeed");
let inspect_output = execute_mob_inspect(&output)
.await
.expect("inspect should succeed");
let archive_bytes = tokio::fs::read(&output).await.expect("read archive");
let digest = compute_archive_digest(&archive_bytes).expect("compute digest");
assert!(
inspect_output.contains("name\tfixture"),
"inspect output should include name: {inspect_output}"
);
assert!(
inspect_output.contains("version\t1.0.0"),
"inspect output should include version: {inspect_output}"
);
assert!(
inspect_output.contains(&format!("digest\t{digest}")),
"inspect output should include digest: {inspect_output}"
);
assert!(
inspect_output.contains("file\tskills/review.md"),
"inspect output should list packed files: {inspect_output}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_e2e_validate_missing_definition() {
let temp = tempfile::tempdir().expect("tempdir");
let scope = test_scope_with_context(temp.path().to_path_buf());
let invalid_pack = temp.path().join("missing-definition.mobpack");
let archive = meerkat_mob_pack::targz::create_targz(&std::collections::BTreeMap::from([(
"manifest.toml".to_string(),
b"[mobpack]\nname = \"fixture\"\nversion = \"1.0.0\"\n".to_vec(),
)]))
.expect("create archive");
tokio::fs::write(&invalid_pack, archive)
.await
.expect("write archive");
let err = execute_mob_validate(&scope, &invalid_pack, None)
.await
.expect_err("validate should reject missing definition");
assert!(
err.to_string().contains("definition.json is missing"),
"unexpected error: {err}"
);
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_mob_deploy_pack_config_precedence_proof() {
let temp = tempfile::tempdir().expect("tempdir");
let mut scope = test_scope_with_context(temp.path().to_path_buf());
scope.user_config_root = Some(temp.path().to_path_buf());
let config_path =
meerkat_store::realm_paths_in(&scope.locator.state_root, scope.locator.realm.as_str())
.config_path;
std::fs::create_dir_all(config_path.parent().expect("config parent"))
.expect("mkdir config");
std::fs::write(&config_path, "[tools]\nmob_enabled = false\n").expect("write config");
let mob_dir = create_mobpack_fixture_dir(temp.path());
std::fs::create_dir_all(mob_dir.join("config")).expect("config dir");
std::fs::write(
mob_dir.join("config").join("defaults.toml"),
"[tools]\nmob_enabled = true\n",
)
.expect("write pack defaults");
let pack_out = temp.path().join("config-proof.mobpack");
execute_mob_pack(&mob_dir, &pack_out, None)
.await
.expect("pack succeeds");
let observed = Arc::new(Mutex::new(None::<(bool, String)>));
let observer: Arc<dyn Fn(&Config) + Send + Sync> = Arc::new({
let observed = Arc::clone(&observed);
move |config: &Config| {
*observed.lock().expect("lock observed") =
Some((config.tools.mob_enabled, config.agent.model.clone()));
}
});
let output = Box::pin(execute_mob_deploy_internal(
&scope,
&pack_out,
"hello",
DeployInvocation {
cli_trust_policy: None,
surface: DeploySurfaceArg::Cli,
cli_overrides: CliOverrides {
model: Some("override-model-for-deploy".to_string()),
max_tokens: None,
max_duration: None,
max_tool_calls: None,
override_config: None,
},
rpc_io: None,
config_observer: Some(observer),
},
))
.await
.expect("deploy succeeds");
assert!(
output.contains("deployed\tmob="),
"unexpected output: {output}"
);
assert_eq!(
*observed.lock().expect("lock observed"),
Some((false, "override-model-for-deploy".to_string())),
"file config must override pack defaults and CLI overrides must still win at deploy boundary"
);
}
#[test]
fn test_parse_provider_params_single() -> Result<(), Box<dyn std::error::Error>> {
let params = vec!["reasoning_effort=high".to_string()];
let result = parse_provider_params(¶ms)?;
let json = result.ok_or("missing result")?;
assert_eq!(json["reasoning_effort"], "high");
Ok(())
}
#[test]
fn test_no_web_search_provider_param_uses_provider_native_key()
-> Result<(), Box<dyn std::error::Error>> {
let openai = apply_no_web_search_provider_param(
Provider::Openai,
Some(serde_json::json!({
"reasoning_effort": "high",
"web_search": {"type": "web_search"}
})),
true,
)?
.expect("OpenAI opt-out params");
assert_eq!(openai["reasoning_effort"], "high");
assert!(
openai
.get("web_search")
.is_some_and(serde_json::Value::is_null)
);
let gemini = apply_no_web_search_provider_param(Provider::Gemini, None, true)?
.expect("Gemini opt-out params");
assert!(
gemini
.get("google_search")
.is_some_and(serde_json::Value::is_null)
);
assert!(gemini.get("web_search").is_none());
let self_hosted = apply_no_web_search_provider_param(Provider::SelfHosted, None, true)?;
assert!(self_hosted.is_none());
Ok(())
}
#[test]
fn test_no_web_search_resume_params_preserve_resume_precedence()
-> Result<(), Box<dyn std::error::Error>> {
let stored = serde_json::json!({
"temperature": 0.1,
"web_search": {"type": "web_search"}
});
let mut inherited = None;
apply_no_web_search_resume_provider_params(
None,
None,
meerkat_core::Provider::OpenAI,
Some(&stored),
&mut inherited,
true,
)?;
let inherited = inherited.expect("stored params plus opt-out");
assert_eq!(inherited["temperature"], 0.1);
assert!(
inherited
.get("web_search")
.is_some_and(serde_json::Value::is_null)
);
let mut explicit = Some(serde_json::json!({"reasoning_effort": "high"}));
apply_no_web_search_resume_provider_params(
None,
None,
meerkat_core::Provider::OpenAI,
Some(&stored),
&mut explicit,
true,
)?;
let explicit = explicit.expect("explicit params plus opt-out");
assert_eq!(explicit["reasoning_effort"], "high");
assert!(explicit.get("temperature").is_none());
assert!(
explicit
.get("web_search")
.is_some_and(serde_json::Value::is_null)
);
let mut model_override = Some(serde_json::json!({"top_p": 0.8}));
apply_no_web_search_resume_provider_params(
None,
Some(Provider::Gemini),
meerkat_core::Provider::OpenAI,
Some(&stored),
&mut model_override,
true,
)?;
let model_override = model_override.expect("model override params plus opt-out");
assert_eq!(model_override["top_p"], 0.8);
assert!(model_override.get("web_search").is_none());
assert!(
model_override
.get("google_search")
.is_some_and(serde_json::Value::is_null)
);
Ok(())
}
fn create_mobpack_fixture_dir(base: &std::path::Path) -> PathBuf {
let mob_dir = base.join("fixture-mob");
std::fs::create_dir_all(mob_dir.join("skills")).expect("create skills dir");
std::fs::create_dir_all(mob_dir.join("hooks")).expect("create hooks dir");
std::fs::write(
mob_dir.join("manifest.toml"),
"[mobpack]\nname = \"fixture\"\nversion = \"1.0.0\"\n",
)
.expect("write manifest");
std::fs::write(
mob_dir.join("definition.json"),
br#"{
"id":"fixture-mob",
"orchestrator":{"profile":"lead"},
"profiles":{
"lead":{
"model":"claude-sonnet-4-5",
"skills":[],
"tools":{"comms":true},
"peer_description":"Lead",
"external_addressable":true
}
},
"skills":{}
}"#,
)
.expect("write definition");
std::fs::write(mob_dir.join("skills").join("review.md"), "# Review\n")
.expect("write skill");
std::fs::write(
mob_dir.join("hooks").join("run.sh"),
"#!/bin/sh\necho run\n",
)
.expect("write hook");
mob_dir
}
fn create_mobpack_fixture_dir_with_skill_path(base: &std::path::Path) -> PathBuf {
let mob_dir = base.join("fixture-mob-with-skill");
std::fs::create_dir_all(mob_dir.join("skills")).expect("create skills dir");
std::fs::create_dir_all(mob_dir.join("hooks")).expect("create hooks dir");
std::fs::write(
mob_dir.join("manifest.toml"),
"[mobpack]\nname = \"fixture\"\nversion = \"1.0.0\"\n",
)
.expect("write manifest");
std::fs::write(
mob_dir.join("definition.json"),
br#"{
"id":"fixture-mob-with-skill",
"orchestrator":{"profile":"lead"},
"profiles":{
"lead":{
"model":"claude-sonnet-4-5",
"skills":["review"],
"tools":{"comms":true},
"peer_description":"Lead",
"external_addressable":true
}
},
"skills":{
"review":{"source":"path","path":"skills/review.md"}
}
}"#,
)
.expect("write definition");
std::fs::write(mob_dir.join("skills").join("review.md"), "# Review\n")
.expect("write skill");
std::fs::write(
mob_dir.join("hooks").join("run.sh"),
"#!/bin/sh\necho run\n",
)
.expect("write hook");
mob_dir
}
#[test]
fn test_compose_external_tool_dispatchers_merges_two_sources() {
let a: Arc<dyn AgentToolDispatcher> = Arc::new(StaticDispatcher::new("alpha_tool"));
let b: Arc<dyn AgentToolDispatcher> = Arc::new(StaticDispatcher::new("beta_tool"));
let merged = compose_external_tool_dispatchers(Some(a), Some(b))
.expect("compose should succeed")
.expect("merged dispatcher should be present");
let names: std::collections::BTreeSet<String> =
merged.tools().iter().map(|t| t.name.to_string()).collect();
assert!(names.contains("alpha_tool"));
assert!(names.contains("beta_tool"));
}
#[test]
fn test_rpc_mob_external_tools_include_callback_and_mcp_dispatchers() {
let callback_tools: Arc<dyn AgentToolDispatcher> =
Arc::new(StaticDispatcher::new("callback_tool"));
let mcp_tools: Arc<dyn AgentToolDispatcher> =
Arc::new(StaticDispatcher::new("linear_add_comment"));
let merged = compose_rpc_mob_external_tools(callback_tools, Some(mcp_tools))
.expect("RPC mob members should keep callback tools even when MCP tools are present");
let names: std::collections::BTreeSet<String> =
merged.tools().iter().map(|t| t.name.to_string()).collect();
assert!(
names.contains("callback_tool"),
"MobKit callback tools must remain visible for RPC mob members"
);
assert!(
names.contains("linear_add_comment"),
"configured MCP tools must be visible to RPC mob members"
);
}
#[cfg(feature = "mob")]
#[test]
fn test_mob_tools_available_for_composition() {
let mob_dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(
meerkat_mob_mcp::MobMcpDispatcher::new(meerkat_mob_mcp::MobMcpState::new_in_memory()),
);
let composed = compose_external_tool_dispatchers(None, Some(mob_dispatcher))
.expect("compose should succeed")
.expect("mob dispatcher should be present");
let names: std::collections::BTreeSet<String> = composed
.tools()
.iter()
.map(|t| t.name.to_string())
.collect();
assert!(names.contains("mob_create"));
assert!(names.contains("mob_spawn_member"));
}
#[tokio::test]
async fn test_compose_external_tool_dispatchers_prefers_primary_on_name_collision() {
let primary: Arc<dyn AgentToolDispatcher> =
Arc::new(EchoDispatcher::new("mob_list", "primary"));
let secondary: Arc<dyn AgentToolDispatcher> =
Arc::new(EchoDispatcher::new("mob_list", "secondary"));
let merged = compose_external_tool_dispatchers(Some(primary), Some(secondary))
.expect("compose should succeed")
.expect("merged dispatcher should be present");
let names: Vec<String> = merged.tools().iter().map(|t| t.name.to_string()).collect();
assert_eq!(names, vec!["mob_list".to_string()]);
let args =
serde_json::value::RawValue::from_string("{}".to_string()).expect("valid raw args");
let result = merged
.dispatch(ToolCallView {
id: "call-1",
name: "mob_list",
args: &args,
})
.await
.expect("dispatch should succeed");
assert_eq!(result.result.text_content(), "primary");
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_run_session_build_wires_mob_tools_into_llm_request() {
let temp = tempfile::tempdir().expect("tempdir must be created");
let factory = AgentFactory::new(temp.path().join("sessions"))
.builtins(true)
.mob(true);
let service = Arc::new(build_cli_service(factory, Config::default(), None));
// Create mob tools factory (new pattern: factory instead of external_tools)
let mob_service: Arc<dyn meerkat_mob::MobSessionService> =
Arc::new(RunMobSessionService::new(service.clone()));
let mob_state = Arc::new(meerkat_mob_mcp::MobMcpState::new(mob_service));
let mob_factory: Arc<dyn meerkat_core::service::MobToolsFactory> =
Arc::new(meerkat_mob_mcp::AgentMobToolSurfaceFactory::new(mob_state));
let captured_tool_names = Arc::new(Mutex::new(Vec::<String>::new()));
let captured_system_prompt = Arc::new(Mutex::new(None::<String>));
let llm_override: Arc<dyn LlmClient> = Arc::new(CapturingLlmClient::new(
captured_tool_names.clone(),
captured_system_prompt.clone(),
));
let mut build = SessionBuildOptions {
mob_tools: Some(mob_factory),
llm_client_override: Some(meerkat::encode_llm_client_override_for_service(
llm_override,
)),
..SessionBuildOptions::default()
};
build.apply_generated_create_only_mob_operator_access(
meerkat_core::ToolCategoryOverride::Enable,
);
let req = CreateSessionRequest {
model: "claude-sonnet-4-5".to_string(),
prompt: "list tools".to_string().into(),
render_metadata: None,
system_prompt: None,
max_tokens: Some(32),
event_tx: None,
skill_references: None,
initial_turn: meerkat_core::service::InitialTurnPolicy::RunImmediately,
deferred_prompt_policy: DeferredPromptPolicy::Discard,
build: Some(build),
labels: None,
};
service
.create_session(req)
.await
.expect("session should run with llm override");
let names: std::collections::BTreeSet<String> = captured_tool_names
.lock()
.expect("captured tool mutex should not be poisoned")
.iter()
.cloned()
.collect();
// Agent mob tools use different tool names than MobMcpDispatcher
assert!(names.contains("delegate"));
assert!(names.contains("mob_create"));
assert!(names.contains("mob_list"));
let system_prompt = captured_system_prompt
.lock()
.expect("captured prompt mutex should not be poisoned")
.clone()
.expect("system prompt must be captured");
assert!(system_prompt.contains("mob_list"));
assert!(system_prompt.contains("mob_create"));
assert!(system_prompt.contains("delegate"));
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_run_mob_session_service_forwards_runtime_apply() {
let temp = tempfile::tempdir().expect("tempdir must be created");
let factory = AgentFactory::new(temp.path().join("sessions"))
.builtins(false)
.shell(false);
let service = Arc::new(build_cli_service(factory, Config::default(), None));
let wrapper = RunMobSessionService::new(Arc::clone(&service));
let llm_override: Arc<dyn LlmClient> = Arc::new(CapturingLlmClient::new(
Arc::new(Mutex::new(Vec::new())),
Arc::new(Mutex::new(None)),
));
let created = wrapper
.create_session(CreateSessionRequest {
model: "gpt-5.4".to_string(),
prompt: "seed".to_string().into(),
render_metadata: None,
system_prompt: None,
max_tokens: Some(32),
event_tx: None,
skill_references: None,
initial_turn: meerkat_core::service::InitialTurnPolicy::Defer,
deferred_prompt_policy: DeferredPromptPolicy::Discard,
build: Some(SessionBuildOptions {
llm_client_override: Some(meerkat::encode_llm_client_override_for_service(
llm_override,
)),
..SessionBuildOptions::default()
}),
labels: None,
})
.await
.expect("deferred run mob session should be created");
let expected_tool_scope = service
.tool_scope_snapshot(&created.session_id)
.await
.expect("inner service should read tool scope");
let wrapper_tool_scope =
<RunMobSessionService as meerkat_mob::MobSessionService>::tool_scope_snapshot(
&wrapper,
&created.session_id,
)
.await
.expect("run mob wrapper should forward tool scope snapshots");
assert_eq!(wrapper_tool_scope.is_some(), expected_tool_scope.is_some());
let expected_execution = service
.execution_snapshot(&created.session_id)
.await
.expect("inner service should read execution snapshot");
let wrapper_execution =
<RunMobSessionService as meerkat_mob::MobSessionService>::execution_snapshot(
&wrapper,
&created.session_id,
)
.await
.expect("run mob wrapper should forward execution snapshots");
assert_eq!(wrapper_execution.is_some(), expected_execution.is_some());
let output = <RunMobSessionService as meerkat_mob::MobSessionService>::apply_runtime_turn(
&wrapper,
&created.session_id,
meerkat_core::RunId::new(),
StartTurnRequest {
prompt: "delegate kickoff".to_string().into(),
system_prompt: None,
event_tx: None,
runtime: {
let turn_metadata = meerkat_runtime::runtime_stamped_prompt_turn_metadata(None);
let handling_mode = turn_metadata
.handling_mode
.unwrap_or(meerkat_core::types::HandlingMode::Queue);
meerkat_core::service::StartTurnRuntimeSemantics::new(
None,
handling_mode,
None,
None,
Vec::new(),
Some(turn_metadata),
)
},
},
meerkat_core::lifecycle::run_primitive::RunApplyBoundary::Immediate,
vec![],
)
.await
.expect("run mob wrapper should forward runtime-backed apply");
match output.terminal {
Some(meerkat_core::lifecycle::core_executor::CoreApplyTerminal::RunResult(
run_result,
)) => assert_eq!(run_result.text, "ok"),
other => panic!("expected terminal run result, got {other:?}"),
}
}
#[cfg(all(feature = "mob", feature = "session-store"))]
#[tokio::test]
async fn test_mob_cli_session_service_forwards_runtime_apply() {
let temp = tempfile::tempdir().expect("tempdir must be created");
let session_store = sqlite_session_store(&temp);
let persistence = PersistenceBundle::new(
Arc::clone(&session_store),
Some(Arc::new(meerkat_runtime::InMemoryRuntimeStore::new())),
Arc::new(meerkat_store::MemoryBlobStore::default()),
);
let factory = AgentFactory::new(temp.path().join("sessions"))
.session_store(session_store)
.builtins(false)
.shell(false);
let (service, runtime_adapter) = build_cli_runtime_backed_service_with_defaults(
factory,
Config::default(),
persistence,
None,
);
let service = Arc::new(service);
let wrapper = MobCliSessionService::new(Arc::clone(&service));
let llm_override: Arc<dyn LlmClient> = Arc::new(CapturingLlmClient::new(
Arc::new(Mutex::new(Vec::new())),
Arc::new(Mutex::new(None)),
));
let session = Session::new();
let session_id = session.id().clone();
let bindings = runtime_adapter
.prepare_bindings(session_id.clone())
.await
.expect("runtime bindings should be prepared");
let created = service
.create_session(CreateSessionRequest {
model: "gpt-5.4".to_string(),
prompt: "seed".to_string().into(),
render_metadata: None,
system_prompt: None,
max_tokens: Some(32),
event_tx: None,
skill_references: None,
initial_turn: meerkat_core::service::InitialTurnPolicy::Defer,
deferred_prompt_policy: DeferredPromptPolicy::Discard,
build: Some(SessionBuildOptions {
resume_session: Some(session),
runtime_build_mode: meerkat_core::RuntimeBuildMode::SessionOwned(bindings),
llm_client_override: Some(meerkat::encode_llm_client_override_for_service(
llm_override,
)),
..SessionBuildOptions::default()
}),
labels: None,
})
.await
.expect("deferred persistent mob session should be created");
let expected_tool_scope = service
.tool_scope_snapshot(&created.session_id)
.await
.expect("inner service should read tool scope");
let wrapper_tool_scope =
<MobCliSessionService as meerkat_mob::MobSessionService>::tool_scope_snapshot(
&wrapper,
&created.session_id,
)
.await
.expect("persistent mob wrapper should forward tool scope snapshots");
assert_eq!(wrapper_tool_scope.is_some(), expected_tool_scope.is_some());
let expected_execution = service
.execution_snapshot(&created.session_id)
.await
.expect("inner service should read execution snapshot");
let wrapper_execution =
<MobCliSessionService as meerkat_mob::MobSessionService>::execution_snapshot(
&wrapper,
&created.session_id,
)
.await
.expect("persistent mob wrapper should forward execution snapshots");
assert_eq!(wrapper_execution.is_some(), expected_execution.is_some());
let output = <MobCliSessionService as meerkat_mob::MobSessionService>::apply_runtime_turn(
&wrapper,
&created.session_id,
meerkat_core::RunId::new(),
StartTurnRequest {
prompt: "delegate kickoff".to_string().into(),
system_prompt: None,
event_tx: None,
runtime: {
let turn_metadata = meerkat_runtime::runtime_stamped_prompt_turn_metadata(None);
let handling_mode = turn_metadata
.handling_mode
.unwrap_or(meerkat_core::types::HandlingMode::Queue);
meerkat_core::service::StartTurnRuntimeSemantics::new(
None,
handling_mode,
None,
None,
Vec::new(),
Some(turn_metadata),
)
},
},
meerkat_core::lifecycle::run_primitive::RunApplyBoundary::Immediate,
vec![],
)
.await
.expect("persistent mob wrapper should forward runtime-backed apply");
match output.terminal {
Some(meerkat_core::lifecycle::core_executor::CoreApplyTerminal::RunResult(
run_result,
)) => assert_eq!(run_result.text, "ok"),
other => panic!("expected terminal run result, got {other:?}"),
}
}
#[cfg(all(feature = "mob", feature = "session-store"))]
#[tokio::test]
async fn test_cli_schedule_mob_host_uses_mob_adapter_when_available() {
let host = cli_schedule_mob_host_from_state(meerkat_mob_mcp::MobMcpState::new_in_memory());
let binding = meerkat::MobTargetBinding::Member {
mob_id: "ops".to_string(),
member_id: "deploy-monitor".to_string(),
action: meerkat::ScheduledMobAction::Send {
content: "Check deploy state.".to_string().into(),
render_metadata: None,
},
};
let outcome = host
.probe_mob_target(&binding)
.await
.expect("mob probe should be delegated to the mob adapter");
let meerkat::TargetProbeOutcome::Missing { detail } = outcome else {
panic!("empty in-memory mob state should report missing mob, got {outcome:?}");
};
let detail = detail.expect("missing detail");
assert!(
!detail.contains("not enabled in the CLI host")
&& !detail.contains("require the mob feature"),
"CLI mob-enabled schedule host should not use the no-op fallback: {detail}"
);
}
#[tokio::test]
async fn test_run_session_build_wires_schedule_tools_into_initial_llm_request() {
let temp = tempfile::tempdir().expect("tempdir must be created");
let factory = AgentFactory::new(temp.path().join("sessions"))
.builtins(true)
.schedule(true);
let schedule_service =
ScheduleService::new(Arc::new(meerkat::MemoryScheduleStore::default()));
let default_schedule_tools =
Some(Arc::new(ScheduleToolDispatcher::new(schedule_service))
as Arc<dyn AgentToolDispatcher>);
let service = Arc::new(build_cli_service(
factory,
Config::default(),
default_schedule_tools,
));
let captured_tool_names = Arc::new(Mutex::new(Vec::<String>::new()));
let captured_system_prompt = Arc::new(Mutex::new(None::<String>));
let llm_override: Arc<dyn LlmClient> = Arc::new(CapturingLlmClient::new(
captured_tool_names.clone(),
captured_system_prompt,
));
let req = CreateSessionRequest {
model: "gpt-5.4".to_string(),
prompt: "list tools".to_string().into(),
render_metadata: None,
system_prompt: None,
max_tokens: Some(32),
event_tx: None,
skill_references: None,
initial_turn: meerkat_core::service::InitialTurnPolicy::RunImmediately,
deferred_prompt_policy: DeferredPromptPolicy::Discard,
build: Some(SessionBuildOptions {
llm_client_override: Some(meerkat::encode_llm_client_override_for_service(
llm_override,
)),
..SessionBuildOptions::default()
}),
labels: None,
};
service
.create_session(req)
.await
.expect("session should run with llm override");
let names: std::collections::BTreeSet<String> = captured_tool_names
.lock()
.expect("captured tool mutex should not be poisoned")
.iter()
.cloned()
.collect();
assert!(names.contains("meerkat_schedule_create"));
assert!(names.contains("meerkat_schedule_list"));
assert!(names.contains("meerkat_schedule_occurrences"));
}
#[cfg(feature = "session-store")]
#[tokio::test]
async fn test_cli_runtime_backed_service_preserves_persistence_oauth_authority() {
let temp = tempfile::tempdir().expect("tempdir must be created");
let session_store = sqlite_session_store(&temp);
let runtime_store: Arc<dyn meerkat_runtime::RuntimeStore> =
Arc::new(meerkat_runtime::InMemoryRuntimeStore::new());
let persistence = PersistenceBundle::new(
Arc::clone(&session_store),
Some(runtime_store),
Arc::new(meerkat_store::MemoryBlobStore::default()),
);
let persistence_adapter = persistence.runtime_adapter();
let auth_binding = meerkat_core::AuthBindingRef {
realm: meerkat_core::RealmId::parse("dev").expect("realm id parses"),
binding: meerkat_core::BindingId::parse("default_openai").expect("binding id parses"),
profile: None,
};
let provider = meerkat_providers::oauth_flow::OAuthProviderIdentity::OpenAiChatGpt;
let redirect_uri = "http://127.0.0.1:1455/callback";
let state = persistence_adapter
.oauth_flow_authority()
.start(
auth_binding.clone(),
provider,
redirect_uri.to_string(),
"cli-persistence-verifier".to_string(),
)
.expect("persistence authority should admit OAuth flow before CLI surface build");
let factory = AgentFactory::new(temp.path().join("sessions"))
.session_store(session_store)
.builtins(false)
.shell(false);
let (_service, runtime_adapter) = build_cli_runtime_backed_service_with_defaults(
factory,
Config::default(),
persistence,
None,
);
let flow = runtime_adapter
.oauth_flow_authority()
.consume(&state, &auth_binding, provider, redirect_uri)
.expect("CLI service construction must preserve PersistenceBundle OAuth authority");
assert_eq!(flow.pkce_verifier, "cli-persistence-verifier");
}
#[cfg(feature = "session-store")]
#[tokio::test]
async fn test_cli_runtime_backed_service_persists_authority_snapshot_for_one_shot() {
let temp = tempfile::tempdir().expect("tempdir must be created");
let session_store = sqlite_session_store(&temp);
let runtime_store: Arc<dyn meerkat_runtime::RuntimeStore> =
Arc::new(meerkat_runtime::InMemoryRuntimeStore::new());
let persistence = PersistenceBundle::new(
Arc::clone(&session_store),
Some(Arc::clone(&runtime_store)),
Arc::new(meerkat_store::MemoryBlobStore::default()),
);
let factory = AgentFactory::new(temp.path().join("sessions"))
.session_store(session_store)
.builtins(false)
.shell(false);
let (service, runtime_adapter) = build_cli_runtime_backed_service_with_defaults(
factory,
Config::default(),
persistence,
None,
);
let auth_lease = runtime_adapter.auth_lease_handle();
let session = Session::new();
let session_id = session.id().clone();
let bindings = runtime_adapter
.prepare_bindings(session_id.clone())
.await
.expect("runtime bindings should be prepared");
let auth_binding = meerkat_core::AuthBindingRef {
realm: meerkat_core::RealmId::parse("dev").expect("realm id parses"),
binding: meerkat_core::BindingId::parse("default_openai").expect("binding id parses"),
profile: None,
};
let lease_key = meerkat_core::handles::LeaseKey::from_auth_binding(&auth_binding);
bindings
.auth_lease()
.acquire_lease(&lease_key, u64::MAX)
.expect("runtime bindings should publish into the bundle auth lease");
let snapshot = auth_lease.snapshot(&lease_key);
assert_eq!(
snapshot.phase,
Some(meerkat_core::handles::AuthLeasePhase::Valid),
"CLI runtime-backed surfaces must use the PersistenceBundle auth lease authority"
);
let llm_override: Arc<dyn LlmClient> = Arc::new(CapturingLlmClient::new(
Arc::new(Mutex::new(Vec::new())),
Arc::new(Mutex::new(None)),
));
let created = service
.create_session(CreateSessionRequest {
model: "gpt-5.4".to_string(),
prompt: "seed".to_string().into(),
render_metadata: None,
system_prompt: None,
max_tokens: Some(32),
event_tx: None,
skill_references: None,
initial_turn: meerkat_core::service::InitialTurnPolicy::Defer,
deferred_prompt_policy: DeferredPromptPolicy::Discard,
build: Some(SessionBuildOptions {
resume_session: Some(session),
runtime_build_mode: meerkat_core::RuntimeBuildMode::SessionOwned(bindings),
llm_client_override: Some(meerkat::encode_llm_client_override_for_service(
llm_override,
)),
..SessionBuildOptions::default()
}),
labels: None,
})
.await
.expect("runtime-backed CLI service should create deferred session");
assert_eq!(created.session_id, session_id);
let runtime_id = meerkat_runtime::LogicalRuntimeId::for_session(&session_id);
assert!(
runtime_store
.load_session_snapshot(&runtime_id)
.await
.expect("runtime snapshot load should succeed")
.is_some(),
"runtime-backed CLI one-shot service must write runtime authority"
);
assert!(
service
.load_authoritative_session(&session_id)
.await
.expect("authoritative load should succeed")
.is_some(),
"authoritative reads must resolve through the runtime snapshot"
);
}
#[cfg(feature = "session-store")]
#[tokio::test]
async fn test_cli_interrupt_destroyed_noop_ignores_persisted_stopped_projection() {
let temp = tempfile::tempdir().expect("tempdir must be created");
let session_store = sqlite_session_store(&temp);
let runtime_store: Arc<dyn meerkat_runtime::RuntimeStore> =
Arc::new(meerkat_runtime::InMemoryRuntimeStore::new());
let persistence = PersistenceBundle::new(
Arc::clone(&session_store),
Some(Arc::clone(&runtime_store)),
Arc::new(meerkat_store::MemoryBlobStore::default()),
);
let factory = AgentFactory::new(temp.path().join("sessions"))
.session_store(session_store)
.builtins(false)
.shell(false);
let (service, runtime_adapter) = build_cli_runtime_backed_service_with_defaults(
factory,
Config::default(),
persistence,
None,
);
let service = Arc::new(service);
let llm_override: Arc<dyn LlmClient> = Arc::new(CapturingLlmClient::new(
Arc::new(Mutex::new(Vec::new())),
Arc::new(Mutex::new(None)),
));
let created = service
.create_session(CreateSessionRequest {
model: "gpt-5.4".to_string(),
prompt: "seed".to_string().into(),
render_metadata: None,
system_prompt: None,
max_tokens: Some(32),
event_tx: None,
skill_references: None,
initial_turn: meerkat_core::service::InitialTurnPolicy::Defer,
deferred_prompt_policy: DeferredPromptPolicy::Discard,
build: Some(SessionBuildOptions {
llm_client_override: Some(meerkat::encode_llm_client_override_for_service(
llm_override,
)),
..Default::default()
}),
labels: None,
})
.await
.expect("runtime-backed CLI service should create deferred session");
runtime_adapter
.register_session(created.session_id.clone())
.await;
runtime_adapter
.stop_runtime_executor(&created.session_id, "seed stopped projection")
.await
.expect("runtime state should persist");
runtime_adapter
.unregister_session(&created.session_id)
.await;
assert_eq!(
service
.persisted_runtime_state(&created.session_id)
.await
.expect("runtime-state projection load should succeed"),
Some(meerkat_runtime::RuntimeState::Stopped)
);
}
#[tokio::test]
async fn test_run_session_build_wires_generate_image_for_runtime_owned_one_shot() {
let temp = tempfile::tempdir().expect("tempdir must be created");
let runtime_adapter = Arc::new(meerkat_runtime::MeerkatMachine::ephemeral());
let factory = AgentFactory::new(temp.path().join("sessions")).builtins(true);
let service = Arc::new(build_cli_service_with_defaults(
factory,
Config::default(),
CliServiceBuildDefaults {
image_generation_machine: Some(runtime_adapter.clone()),
default_blob_store: Some(Arc::new(meerkat_store::MemoryBlobStore::default())),
default_image_generation_executor: Some(Arc::new(FakeImageGenerationExecutor)),
..Default::default()
},
));
let captured_tool_names = Arc::new(Mutex::new(Vec::<String>::new()));
let captured_system_prompt = Arc::new(Mutex::new(None::<String>));
let llm_override: Arc<dyn LlmClient> = Arc::new(CapturingLlmClient::new(
captured_tool_names.clone(),
captured_system_prompt,
));
let session = Session::new();
let session_id = session.id().clone();
let bindings = runtime_adapter
.prepare_bindings(session_id)
.await
.expect("runtime bindings should be prepared");
let req = CreateSessionRequest {
model: "gpt-5.4".to_string(),
prompt: "list tools".to_string().into(),
render_metadata: None,
system_prompt: None,
max_tokens: Some(32),
event_tx: None,
skill_references: None,
initial_turn: meerkat_core::service::InitialTurnPolicy::RunImmediately,
deferred_prompt_policy: DeferredPromptPolicy::Discard,
build: Some(SessionBuildOptions {
resume_session: Some(session),
runtime_build_mode: meerkat_core::RuntimeBuildMode::SessionOwned(bindings),
initial_turn_metadata: Some(meerkat_runtime::runtime_stamped_prompt_turn_metadata(
None,
)),
llm_client_override: Some(meerkat::encode_llm_client_override_for_service(
llm_override,
)),
..SessionBuildOptions::default()
}),
labels: None,
};
service
.create_session(req)
.await
.expect("session should run with llm override");
let names: std::collections::BTreeSet<String> = captured_tool_names
.lock()
.expect("captured tool mutex should not be poisoned")
.iter()
.cloned()
.collect();
assert!(names.contains("generate_image"));
}
#[tokio::test]
async fn test_run_session_build_keeps_generate_image_visible_without_executor() {
let temp = tempfile::tempdir().expect("tempdir must be created");
let runtime_adapter = Arc::new(meerkat_runtime::MeerkatMachine::ephemeral());
let factory = AgentFactory::new(temp.path().join("sessions")).builtins(true);
let service = Arc::new(build_cli_service_with_defaults(
factory,
Config::default(),
CliServiceBuildDefaults {
image_generation_machine: Some(runtime_adapter.clone()),
default_blob_store: Some(Arc::new(meerkat_store::MemoryBlobStore::default())),
..Default::default()
},
));
let captured_tool_names = Arc::new(Mutex::new(Vec::<String>::new()));
let captured_system_prompt = Arc::new(Mutex::new(None::<String>));
let llm_override: Arc<dyn LlmClient> = Arc::new(CapturingLlmClient::new(
captured_tool_names.clone(),
captured_system_prompt,
));
let session = Session::new();
let session_id = session.id().clone();
let bindings = runtime_adapter
.prepare_bindings(session_id)
.await
.expect("runtime bindings should be prepared");
let req = CreateSessionRequest {
model: "gpt-5.4".to_string(),
prompt: "list tools".to_string().into(),
render_metadata: None,
system_prompt: None,
max_tokens: Some(32),
event_tx: None,
skill_references: None,
initial_turn: meerkat_core::service::InitialTurnPolicy::RunImmediately,
deferred_prompt_policy: DeferredPromptPolicy::Discard,
build: Some(SessionBuildOptions {
resume_session: Some(session),
runtime_build_mode: meerkat_core::RuntimeBuildMode::SessionOwned(bindings),
initial_turn_metadata: Some(meerkat_runtime::runtime_stamped_prompt_turn_metadata(
None,
)),
llm_client_override: Some(meerkat::encode_llm_client_override_for_service(
llm_override,
)),
..SessionBuildOptions::default()
}),
labels: None,
};
service
.create_session(req)
.await
.expect("session should run with llm override");
let names: std::collections::BTreeSet<String> = captured_tool_names
.lock()
.expect("captured tool mutex should not be poisoned")
.iter()
.cloned()
.collect();
assert!(
names.contains("generate_image"),
"generate_image should remain visible even when image credentials are unavailable"
);
}
#[cfg(feature = "mob")]
async fn call_tool_json(
dispatcher: &Arc<dyn AgentToolDispatcher>,
tool_use_id: &str,
name: &str,
args: serde_json::Value,
) -> serde_json::Value {
let raw =
serde_json::value::RawValue::from_string(args.to_string()).expect("valid raw args");
let out = dispatcher
.dispatch(ToolCallView {
id: tool_use_id,
name,
args: &raw,
})
.await
.expect("tool dispatch should succeed");
assert!(
!out.result.is_error,
"tool returned error: {}",
out.result.text_content()
);
serde_json::from_str(&out.result.text_content()).expect("tool content should be valid json")
}
#[cfg(feature = "mob")]
#[test]
fn test_helper_json_uses_member_ref_not_binding_atoms() {
let identity = meerkat_mob::AgentIdentity::from("helper-json");
let output = Some("done".to_string());
let value = helper_result_json_value("mob-json", &output, 7, &identity);
assert_eq!(value["agent_identity"], "helper-json");
assert_eq!(value["tokens_used"], 7);
assert_eq!(value["output"], "done");
assert!(value.get("agent_runtime_id").is_none());
assert!(value.get("fence_token").is_none());
let member_ref = value["member_ref"]
.as_str()
.expect("helper json should include member_ref");
let member_ref: meerkat_contracts::WireMemberRef =
serde_json::from_value(serde_json::Value::String(member_ref.to_string()))
.expect("member_ref should deserialize");
let (mob_id, member_id) = member_ref.decode().expect("member_ref should decode");
assert_eq!(mob_id, "mob-json");
assert_eq!(member_id, "helper-json");
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_run_mob_tools_persist_across_context_rebuild() {
let temp = tempfile::tempdir().expect("tempdir must be created");
let scope = test_scope_with_context(temp.path().to_path_buf());
let mob_service_a: Arc<dyn meerkat_mob::MobSessionService> =
Arc::new(TestMobSessionService::new());
let mut ctx_a = prepare_run_mob_tools(&scope, mob_service_a)
.await
.expect("first mob tools context should initialize");
let dispatcher_a = ctx_a.dispatcher();
let created = call_tool_json(
&dispatcher_a,
"t-create",
"mob_create",
serde_json::json!({"definition":{"id":"test_mob","orchestrator":{"profile":"lead"},"profiles":{"lead":{"model":"claude-opus-4-6","external_addressable":true,"tools":{"comms":true}},"worker":{"model":"claude-sonnet-4-6","tools":{"comms":true}}}}}),
)
.await;
let mob_id = created["mob_id"]
.as_str()
.expect("mob_create should return mob_id")
.to_string();
call_tool_json(
&dispatcher_a,
"t-spawn-a",
"mob_spawn_member",
serde_json::json!({
"mob_id": mob_id,
"specs": [{"profile": "lead", "agent_identity": "lead-1", "runtime_mode": "turn_driven"}]
}),
)
.await;
ctx_a
.persist(&scope)
.await
.expect("first context should persist mob registry");
drop(dispatcher_a);
drop(ctx_a);
// Simulate a fresh CLI process by rebuilding session service + tools context.
let mob_service_b: Arc<dyn meerkat_mob::MobSessionService> =
Arc::new(TestMobSessionService::new());
let mut ctx_b = prepare_run_mob_tools(&scope, mob_service_b)
.await
.expect("second mob tools context should initialize");
let dispatcher_b = ctx_b.dispatcher();
let status = call_tool_json(
&dispatcher_b,
"t-status",
"mob_list",
serde_json::json!({"mob_id": mob_id}),
)
.await;
assert_eq!(status["status"].as_str(), Some("Running"));
call_tool_json(
&dispatcher_b,
"t-spawn-b",
"mob_spawn_member",
serde_json::json!({
"mob_id": created["mob_id"].as_str().expect("mob id"),
"specs": [{"profile": "worker", "agent_identity": "worker-1", "runtime_mode": "turn_driven"}]
}),
)
.await;
ctx_b
.persist(&scope)
.await
.expect("second context should persist registry updates");
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_run_mob_tools_runtime_mode_turn_driven_surface_wiring() {
let temp = tempfile::tempdir().expect("tempdir must be created");
let scope = test_scope_with_context(temp.path().to_path_buf());
let mob_service: Arc<dyn meerkat_mob::MobSessionService> =
Arc::new(TestMobSessionService::new());
let ctx = prepare_run_mob_tools(&scope, mob_service)
.await
.expect("mob tools context should initialize");
let dispatcher = ctx.dispatcher();
let created = call_tool_json(
&dispatcher,
"t-create-runtime",
"mob_create",
serde_json::json!({"definition":{"id":"test_mob","orchestrator":{"profile":"lead"},"profiles":{"lead":{"model":"claude-opus-4-6","external_addressable":true,"tools":{"comms":true}},"worker":{"model":"claude-sonnet-4-6","tools":{"comms":true}}}}}),
)
.await;
let mob_id = created["mob_id"].as_str().expect("mob id").to_string();
call_tool_json(
&dispatcher,
"t-spawn-turn",
"mob_spawn_member",
serde_json::json!({
"mob_id": mob_id,
"specs": [{"profile": "lead", "agent_identity": "lead-turn", "runtime_mode": "turn_driven"}]
}),
)
.await;
let listed = call_tool_json(
&dispatcher,
"t-list-runtime",
"mob_list_members",
serde_json::json!({"mob_id": mob_id}),
)
.await;
let members = listed["members"].as_array().cloned().unwrap_or_default();
let lead_mode = members
.iter()
.find(|m| m["agent_identity"] == "lead-turn")
.and_then(|m| m["runtime_mode"].as_str());
assert_eq!(lead_mode, Some("turn_driven"));
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_run_mob_tools_persist_destroy_removes_registry_entry() {
let temp = tempfile::tempdir().expect("tempdir must be created");
let scope = test_scope_with_context(temp.path().to_path_buf());
let mob_service: Arc<dyn meerkat_mob::MobSessionService> =
Arc::new(TestMobSessionService::new());
let mut ctx = prepare_run_mob_tools(&scope, mob_service)
.await
.expect("mob tools context should initialize");
let dispatcher = ctx.dispatcher();
let created = call_tool_json(
&dispatcher,
"t-create",
"mob_create",
serde_json::json!({"definition":{"id":"test_mob","profiles":{"worker":{"model":"claude-sonnet-4-6","tools":{"comms":true}}}}}),
)
.await;
let mob_id = created["mob_id"]
.as_str()
.expect("mob_create should return mob_id")
.to_string();
call_tool_json(
&dispatcher,
"t-destroy",
"mob_lifecycle",
serde_json::json!({"mob_id": mob_id, "action": "destroy"}),
)
.await;
ctx.persist(&scope)
.await
.expect("context should persist registry updates");
let registry = load_mob_registry(&scope)
.await
.expect("registry should load");
assert!(
registry.mobs.is_empty(),
"destroyed mob should be removed from persisted registry"
);
}
#[cfg(feature = "jsonl-store")]
#[test]
fn test_parse_provider_params_multiple() -> Result<(), Box<dyn std::error::Error>> {
let params = vec![
"reasoning_effort=high".to_string(),
"seed=42".to_string(),
"custom_flag=true".to_string(),
];
let result = parse_provider_params(¶ms)?;
let json = result.ok_or("missing result")?;
assert_eq!(json["reasoning_effort"], "high");
assert_eq!(json["seed"], "42");
assert_eq!(json["custom_flag"], "true");
Ok(())
}
#[test]
fn test_parse_provider_params_empty() -> Result<(), Box<dyn std::error::Error>> {
let params: Vec<String> = vec![];
let result = parse_provider_params(¶ms)?;
assert!(result.is_none());
Ok(())
}
#[test]
fn test_parse_provider_params_invalid_no_equals() {
let params = vec!["invalid_param".to_string()];
let result = parse_provider_params(¶ms);
assert!(result.is_err());
}
#[test]
fn test_parse_provider_params_value_with_equals() -> Result<(), Box<dyn std::error::Error>> {
let params = vec!["key=value=with=equals".to_string()];
let result = parse_provider_params(¶ms)?;
let json = result.ok_or("missing result")?;
assert_eq!(json["key"], "value=with=equals");
Ok(())
}
#[test]
fn test_parse_provider_params_empty_value() -> Result<(), Box<dyn std::error::Error>> {
let params = vec!["key=".to_string()];
let result = parse_provider_params(¶ms)?;
let json = result.ok_or("missing result")?;
assert_eq!(json["key"], "");
Ok(())
}
#[test]
fn test_parse_hook_overrides_from_file_matches_fixture() {
let overrides = parse_hook_run_overrides(Some(hooks_override_fixture_path()), None)
.expect("fixture hook override must parse");
assert_eq!(
overrides.disable,
vec![meerkat_core::HookId::new("global_observer")]
);
assert_eq!(overrides.entries.len(), 2);
assert_eq!(
overrides.entries[0].point,
meerkat_core::HookPoint::PreToolExecution
);
assert_eq!(
overrides.entries[1].mode,
meerkat_core::HookExecutionMode::Background
);
}
#[test]
fn test_parse_hook_overrides_from_inline_json_matches_file() {
let fixture_path = hooks_override_fixture_path();
let fixture = std::fs::read_to_string(&fixture_path).expect("fixture must exist");
let from_file = parse_hook_run_overrides(Some(fixture_path), None)
.expect("fixture hook override must parse from file");
let from_json = parse_hook_run_overrides(None, Some(fixture))
.expect("fixture hook override must parse from inline json");
assert_eq!(from_json, from_file);
}
#[test]
fn test_default_cli_state_root_is_project_local_rkat_realms() {
let root = PathBuf::from("/tmp/example-project");
assert_eq!(
default_cli_state_root(&root),
PathBuf::from("/tmp/example-project/.rkat/realms")
);
}
#[test]
fn test_resolve_runtime_scope_uses_context_local_state_root_by_default() {
let cli = Cli::try_parse_from([
"rkat",
"--context-root",
"/tmp/example-project",
"run",
"hello",
])
.expect("cli should parse");
let scope =
resolve_runtime_scope_with_realm(&cli, cli.realm.clone()).expect("scope resolves");
assert_eq!(
scope.locator.state_root,
PathBuf::from("/tmp/example-project/.rkat/realms")
);
}
#[test]
fn test_resolve_runtime_scope_respects_explicit_state_root() {
let cli = Cli::try_parse_from([
"rkat",
"--context-root",
"/tmp/example-project",
"--state-root",
"/tmp/custom-realms",
"run",
"hello",
])
.expect("cli should parse");
let scope =
resolve_runtime_scope_with_realm(&cli, cli.realm.clone()).expect("scope resolves");
assert_eq!(
scope.locator.state_root,
PathBuf::from("/tmp/custom-realms")
);
}
#[test]
fn test_resolve_cli_default_agent_model_heals_legacy_builtin_default() {
let mut config = Config::default();
config.agent.model = "claude-opus-4-7".to_string();
config.models.openai = "gpt-5.5-custom".to_string();
assert_eq!(resolve_cli_default_agent_model(&config), "gpt-5.5-custom");
}
#[test]
fn test_resolve_cli_default_agent_model_falls_back_by_best_available_priority() {
let mut config = Config::default();
config.agent.model = "claude-opus-4-7".to_string();
config.models.openai.clear();
assert_eq!(
resolve_cli_default_agent_model(&config),
config.models.anthropic
);
config.models.anthropic.clear();
assert_eq!(
resolve_cli_default_agent_model(&config),
config.models.gemini
);
}
#[test]
fn test_resolve_cli_default_agent_model_preserves_custom_model() {
let mut config = Config::default();
config.agent.model = "claude-sonnet-4-6".to_string();
assert_eq!(
resolve_cli_default_agent_model(&config),
"claude-sonnet-4-6"
);
}
#[test]
fn test_resolve_cli_effective_model_keeps_legacy_default_when_provider_matches() {
let mut config = Config::default();
config.agent.model = "claude-opus-4-7".to_string();
assert_eq!(
resolve_cli_effective_model(&config, None, Some(Provider::Anthropic), None),
"claude-opus-4-7"
);
}
#[test]
fn test_resolve_cli_effective_model_uses_constrained_provider_default_on_mismatch() {
let config = Config::default();
assert_eq!(
resolve_cli_effective_model(&config, None, Some(Provider::Anthropic), None),
config.models.anthropic
);
}
#[test]
fn test_resolve_cli_effective_model_uses_auth_binding_provider_without_binding_default() {
let mut config = Config::default();
config.agent.model = "claude-opus-4-7".to_string();
let selection = CliAuthBindingSelection {
provider: Provider::Anthropic,
default_model: None,
};
assert_eq!(
resolve_cli_effective_model(&config, None, None, Some(&selection)),
"claude-opus-4-7"
);
}
#[test]
fn test_resolve_cli_effective_model_uses_binding_default_before_provider_default() {
let config = Config::default();
let selection = CliAuthBindingSelection {
provider: Provider::Anthropic,
default_model: Some("claude-sonnet-4-6".to_string()),
};
assert_eq!(
resolve_cli_effective_model(&config, None, None, Some(&selection)),
"claude-sonnet-4-6"
);
}
#[test]
fn test_resolve_cli_effective_model_uses_gemini_default_for_gemini_binding_mismatch() {
let mut config = Config::default();
config.agent.model = "claude-opus-4-7".to_string();
let selection = CliAuthBindingSelection {
provider: Provider::Gemini,
default_model: None,
};
assert_eq!(
resolve_cli_effective_model(&config, None, None, Some(&selection)),
config.models.gemini
);
}
#[test]
fn test_infer_provider_anthropic() {
assert_eq!(
Provider::infer_from_model("claude-opus-4-7"),
Some(Provider::Anthropic)
);
assert_eq!(
Provider::infer_from_model("claude-sonnet-4-6"),
Some(Provider::Anthropic)
);
assert_eq!(
Provider::infer_from_model("claude-haiku-4-5-20251001"),
Some(Provider::Anthropic)
);
assert_eq!(
Provider::infer_from_model("claude-haiku-4-5"),
Some(Provider::Anthropic)
);
assert_eq!(Provider::infer_from_model("Claude-3-Opus"), None);
}
#[test]
fn test_infer_provider_openai() {
assert_eq!(
Provider::infer_from_model("gpt-5.5"),
Some(Provider::Openai)
);
assert_eq!(
Provider::infer_from_model("gpt-5.4"),
Some(Provider::Openai)
);
assert_eq!(
Provider::infer_from_model("gpt-5.3-codex"),
Some(Provider::Openai)
);
assert_eq!(
Provider::infer_from_model("gpt-realtime-2"),
Some(Provider::Openai)
);
assert_eq!(Provider::infer_from_model("gpt-4"), None);
assert_eq!(Provider::infer_from_model("GPT-4"), None);
}
#[test]
fn test_infer_provider_gemini() {
assert_eq!(
Provider::infer_from_model("gemini-3-flash-preview"),
Some(Provider::Gemini)
);
assert_eq!(
Provider::infer_from_model("gemini-3.1-pro-preview"),
Some(Provider::Gemini)
);
assert_eq!(
Provider::infer_from_model("gemini-3.1-flash-lite-preview"),
Some(Provider::Gemini)
);
assert_eq!(Provider::infer_from_model("gemini-pro"), None);
assert_eq!(Provider::infer_from_model("Gemini-Pro"), None);
}
#[test]
fn test_infer_provider_unknown() {
assert_eq!(Provider::infer_from_model("llama-3"), None);
assert_eq!(Provider::infer_from_model("mistral-7b"), None);
assert_eq!(Provider::infer_from_model("custom-model"), None);
assert_eq!(Provider::infer_from_model(""), None);
}
#[test]
fn test_resolve_cli_provider_prefers_self_hosted_registry_alias() {
let mut config = Config::default();
config
.merge_toml_str(
r#"
[self_hosted.servers.ollama]
transport = "openai_compatible"
base_url = "http://127.0.0.1:11434"
api_style = "chat_completions"
[self_hosted.models.gemma-4-e2b]
server = "ollama"
remote_model = "gemma4:e2b"
display_name = "Gemma 4 E2B"
family = "gemma"
tier = "supported"
context_window = 128000
max_output_tokens = 8192
vision = true
image_tool_results = true
inline_video = false
supports_temperature = true
supports_thinking = true
supports_reasoning = true
"#,
)
.expect("valid self-hosted config");
assert_eq!(
resolve_cli_provider(&config, "gemma-4-e2b", None).expect("self-hosted alias resolves"),
Provider::SelfHosted
);
}
#[test]
fn test_resolve_cli_provider_rejects_uncatalogued_model_without_provider() {
let config = Config::default();
let error = resolve_cli_provider(&config, "gpt-4", None)
.expect_err("uncatalogued model must not silently choose a provider");
assert!(error.to_string().contains("Cannot infer provider"));
}
#[test]
fn test_resolve_cli_provider_rejects_explicit_provider_contradicting_catalog_owner() {
let config = Config::default();
let error = resolve_cli_provider(&config, "gpt-5.4", Some(Provider::Anthropic))
.expect_err("explicit provider must match catalog ownership");
assert!(
error
.to_string()
.contains("registered for provider 'openai'")
&& error.to_string().contains("not provider 'anthropic'")
&& error.to_string().contains("gpt-5.4"),
"error should identify the rejected provider/model pair: {error}"
);
}
#[test]
fn test_resolve_cli_provider_uses_auth_binding_provider() {
let mut config = Config::default();
config.agent.model = "claude-opus-4-7".to_string();
let mut section = meerkat_core::RealmConfigSection::default();
section.backend.insert(
"google_code_assist".to_string(),
meerkat_core::BackendProfileConfig {
provider: "gemini".to_string(),
backend_kind: "google_code_assist".to_string(),
base_url: None,
options: serde_json::Value::Null,
},
);
section.auth.insert(
"google_oauth".to_string(),
meerkat_core::AuthProfileConfig {
provider: "gemini".to_string(),
auth_method: "google_oauth".to_string(),
source: meerkat_core::CredentialSourceSpec::ManagedStore,
constraints: meerkat_core::AuthConstraints::default(),
metadata_defaults: meerkat_core::AuthMetadataDefaults::default(),
},
);
section.binding.insert(
"google_oauth".to_string(),
meerkat_core::ProviderBindingConfig {
backend_profile: "google_code_assist".to_string(),
auth_profile: "google_oauth".to_string(),
default_model: Some("gemini-3.1-flash-lite-preview".to_string()),
policy: meerkat_core::BindingPolicy::default(),
},
);
config.realm.insert("dev".to_string(), section);
let auth_binding = AuthBindingRef {
realm: meerkat_core::RealmId::parse("dev").expect("valid realm"),
binding: meerkat_core::BindingId::parse("google_oauth").expect("valid binding"),
profile: None,
};
let selection = resolve_cli_auth_binding_selection(&config, &auth_binding)
.expect("auth binding resolves");
assert_eq!(selection.provider, Provider::Gemini);
assert_eq!(
selection.default_model.as_deref(),
Some("gemini-3.1-flash-lite-preview")
);
assert_eq!(
resolve_cli_provider_with_auth_binding(
&config,
selection.default_model.as_deref().unwrap(),
None,
Some(&selection),
)
.expect("binding provider wins"),
Provider::Gemini
);
}
#[test]
fn test_resolve_cli_provider_rejects_explicit_provider_mismatching_auth_binding() {
let selection = CliAuthBindingSelection {
provider: Provider::Gemini,
default_model: Some("gemini-3.1-flash-lite-preview".to_string()),
};
let error = resolve_cli_provider_with_auth_binding(
&Config::default(),
"custom-provider-model",
Some(Provider::Anthropic),
Some(&selection),
)
.expect_err("explicit provider must match auth binding provider");
assert!(
error
.to_string()
.contains("--auth-binding selects provider")
);
}
#[cfg(feature = "comms")]
#[test]
fn test_comms_tool_dispatcher_provides_comms_tools() {
use meerkat_comms::Inbox;
use meerkat_comms::agent::CommsToolDispatcher;
use meerkat_comms::{CommsConfig, Keypair, TrustedPeers};
use meerkat_core::AgentToolDispatcher;
use parking_lot::RwLock;
// Create mock comms infrastructure
let keypair = Keypair::generate();
let trusted_peers = TrustedPeers::new();
let trusted_peers = std::sync::Arc::new(RwLock::new(trusted_peers));
let (_inbox, inbox_sender) = Inbox::new();
let router = std::sync::Arc::new(meerkat_comms::Router::with_shared_peers(
keypair,
trusted_peers.clone(),
CommsConfig::default(),
inbox_sender,
true,
));
// Create CommsToolDispatcher with no inner dispatcher
let dispatcher = CommsToolDispatcher::new(router, trusted_peers);
// Runtime-less dispatchers expose transport-only comms tools.
let tools = dispatcher.tools();
let tool_names: Vec<_> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(
tool_names.contains(&"send_message"),
"expected send_message tool, got: {tool_names:?}"
);
assert!(
!tool_names.contains(&"send_request"),
"send_request requires runtime command authority, got: {tool_names:?}"
);
assert!(
!tool_names.contains(&"send_response"),
"send_response requires runtime command authority, got: {tool_names:?}"
);
assert!(tool_names.contains(&"peers"));
}
#[tokio::test]
async fn test_prune_inner_rejects_unsupported_redb_backend() {
let temp = tempfile::tempdir().expect("tempdir");
let state_root = temp.path().join("realms");
let realm_id = "legacy-skip";
let paths = meerkat_store::realm_paths_in(&state_root, realm_id);
tokio::fs::create_dir_all(&paths.root)
.await
.expect("create root");
let manifest = serde_json::json!({
"realm_id": realm_id,
"backend": "redb",
"created_at": "1"
});
tokio::fs::write(
&paths.manifest_path,
serde_json::to_vec_pretty(&manifest).expect("serialize manifest"),
)
.await
.expect("write manifest");
let result = prune_realms_inner(&state_root, true, 0, false).await;
assert!(
result.is_err(),
"redb backend must be rejected as unsupported"
);
let error = result.unwrap_err().to_string();
assert!(
error.contains("unsupported") || error.contains("redb"),
"error should mention unsupported backend: {error}"
);
}
#[tokio::test]
async fn test_delete_realm_blocks_when_active_without_force() {
let temp = tempfile::tempdir().expect("tempdir");
let state_root = temp.path().join("realms");
let realm_id = "active-realm";
let _manifest = meerkat_store::ensure_realm_manifest_in(
&state_root,
realm_id,
Some(meerkat_store::RealmBackend::Sqlite),
Some(meerkat_store::RealmOrigin::Generated),
)
.await
.expect("create manifest");
let lease =
meerkat_store::start_realm_lease_in(&state_root, realm_id, Some("instance"), "rpc")
.await
.expect("start lease");
let blocked = delete_realm(&state_root, realm_id, false).await;
assert!(
blocked.is_err(),
"delete should block while realm is active"
);
let forced = delete_realm(&state_root, realm_id, true).await;
assert!(forced.is_ok(), "delete --force should proceed");
// Lease shutdown should be no-op after forced deletion.
lease.shutdown().await;
}
#[cfg(unix)]
#[tokio::test]
async fn test_prune_inner_reports_leftovers_on_partial_failure() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("tempdir");
let state_root = temp.path().join("realms");
let realm_id = "partial-failure";
let _manifest = meerkat_store::ensure_realm_manifest_in(
&state_root,
realm_id,
Some(meerkat_store::RealmBackend::Sqlite),
Some(meerkat_store::RealmOrigin::Generated),
)
.await
.expect("create manifest");
let perms = std::fs::Permissions::from_mode(0o555);
std::fs::set_permissions(&state_root, perms).expect("set read-only root");
let outcome = prune_realms_inner(&state_root, true, 0, false)
.await
.expect("prune outcome");
assert_eq!(outcome.removed, 0);
assert_eq!(outcome.leftovers.len(), 1);
let restore = std::fs::Permissions::from_mode(0o755);
let _ = std::fs::set_permissions(&state_root, restore);
}
#[test]
fn test_resolve_scoped_session_id_accepts_session_ref_in_active_realm() {
let sid = SessionId::new();
let scope = test_scope(PathBuf::from("/tmp/realms"), "team-alpha");
let resolved = resolve_scoped_session_id(&format!("team-alpha:{sid}"), &scope)
.expect("session_ref in active realm should resolve");
assert_eq!(resolved, sid);
}
#[test]
fn test_resolve_scoped_session_id_rejects_realm_mismatch() {
let sid = SessionId::new();
let scope = test_scope(PathBuf::from("/tmp/realms"), "team-alpha");
let err = resolve_scoped_session_id(&format!("other-realm:{sid}"), &scope)
.expect_err("mismatched realm should fail");
assert!(err.to_string().contains("active realm is 'team-alpha'"));
}
#[test]
fn test_short_session_id_uses_uuid_tail() {
let sid =
SessionId::parse("019e2136-0000-7000-8000-00000000abcd").expect("valid uuid literal");
assert_eq!(short_session_id(&sid), "0000abcd");
}
#[test]
fn test_session_match_accepts_tail_handle_without_losing_legacy_prefix() {
let first =
SessionId::parse("019e2136-0000-7000-8000-00000000aaaa").expect("valid uuid literal");
let second =
SessionId::parse("019e2136-0000-7000-8000-00000000bbbb").expect("valid uuid literal");
let sessions = vec![
SessionSummary {
session_id: first,
created_at: std::time::SystemTime::now(),
updated_at: std::time::SystemTime::now(),
message_count: 0,
total_tokens: 0,
is_active: false,
labels: Default::default(),
},
SessionSummary {
session_id: second.clone(),
created_at: std::time::SystemTime::now(),
updated_at: std::time::SystemTime::now(),
message_count: 0,
total_tokens: 0,
is_active: false,
labels: Default::default(),
},
];
let legacy_prefix_matches = find_session_matches_in_summaries("019e2136", &sessions);
assert_eq!(
legacy_prefix_matches.len(),
2,
"UUIDv7 leading handles should be treated as ambiguous"
);
let tail_matches = find_session_matches_in_summaries("0000bbbb", &sessions);
assert_eq!(tail_matches.len(), 1);
assert_eq!(tail_matches[0].session_id, second);
}
#[test]
fn test_ambiguous_session_match_error_lists_copyable_refs() {
let first =
SessionId::parse("019e2136-0000-7000-8000-00000000aaaa").expect("valid uuid literal");
let second =
SessionId::parse("019e2136-0000-7000-8000-00000000bbbb").expect("valid uuid literal");
let sessions = vec![
SessionSummary {
session_id: first,
created_at: std::time::SystemTime::now(),
updated_at: std::time::SystemTime::now(),
message_count: 0,
total_tokens: 0,
is_active: false,
labels: Default::default(),
},
SessionSummary {
session_id: second,
created_at: std::time::SystemTime::now(),
updated_at: std::time::SystemTime::now(),
message_count: 0,
total_tokens: 0,
is_active: false,
labels: Default::default(),
},
];
let matches = find_session_matches_in_summaries("019e2136", &sessions);
let scope = test_scope(PathBuf::from("/tmp/realms"), "team-alpha");
let message = format_ambiguous_session_match_error("019e2136", &matches, &scope);
assert!(message.contains("Ambiguous session handle '019e2136'"));
assert!(message.contains("team-alpha:019e2136-0000-7000-8000-00000000aaaa"));
assert!(message.contains("team-alpha:019e2136-0000-7000-8000-00000000bbbb"));
assert!(message.contains("handle 0000aaaa"));
assert!(message.contains("handle 0000bbbb"));
}
#[tokio::test]
async fn test_find_session_matches_returns_all_matching_realms_in_scope_root() {
let temp = tempfile::tempdir().expect("tempdir");
let state_root = temp.path().join("realms");
let sid = SessionId::new();
for realm_id in ["realm-a", "realm-b"] {
let (_manifest, store) = meerkat_store::open_realm_session_store_in(
&state_root,
realm_id,
Some(RealmBackend::Sqlite),
Some(RealmOrigin::Explicit),
)
.await
.expect("open store");
store
.save(&Session::with_id(sid.clone()))
.await
.expect("save session");
}
let scope = test_scope(state_root.clone(), "realm-a");
let matches = find_session_matches(&sid.to_string(), &[], &scope)
.await
.expect("find matches");
assert_eq!(matches.len(), 2);
assert_eq!(matches[0].state_root, state_root);
assert_eq!(matches[1].state_root, state_root);
assert_eq!(matches[0].session_id, sid);
assert_eq!(matches[1].session_id, sid);
}
fn test_scope_with_context(root: PathBuf) -> RuntimeScope {
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
let (auth_lease, oauth_flow_authority) = new_cli_auth_handles();
#[cfg(not(all(feature = "anthropic", feature = "openai", feature = "gemini")))]
let auth_lease = new_cli_auth_lease();
RuntimeScope {
locator: RealmLocator {
state_root: root.clone(),
realm: meerkat_core::connection::RealmId::parse("test-realm")
.expect("test realm id parses"),
},
instance_id: None,
backend_hint: Some(RealmBackend::Sqlite),
origin_hint: RealmOrigin::Explicit,
context_root: Some(root),
user_config_root: None,
auth_lease,
#[cfg(all(feature = "anthropic", feature = "openai", feature = "gemini"))]
oauth_flow_authority,
}
}
#[cfg(feature = "mob")]
#[tokio::test]
async fn test_prepare_run_mob_tools_does_not_hold_registry_lock_for_context_lifetime() {
let temp = tempfile::tempdir().expect("tempdir");
let scope = test_scope_with_context(temp.path().to_path_buf());
let mob_service_a: Arc<dyn meerkat_mob::MobSessionService> =
Arc::new(TestMobSessionService::new());
let _ctx_a = prepare_run_mob_tools(&scope, mob_service_a)
.await
.expect("first context should initialize");
let mob_service_b: Arc<dyn meerkat_mob::MobSessionService> =
Arc::new(TestMobSessionService::new());
let _ctx_b = tokio::time::timeout(
Duration::from_secs(2),
prepare_run_mob_tools(&scope, mob_service_b),
)
.await
.expect("second context should not block on long-held registry lock")
.expect("second context should initialize");
}
#[cfg(feature = "mob")]
#[test]
fn test_parse_run_flow_params_accepts_object_and_rejects_non_object() {
assert_eq!(
parse_run_flow_params(None).expect("default params"),
serde_json::json!({})
);
assert_eq!(
parse_run_flow_params(Some(r#"{"a":1}"#.to_string())).expect("object params"),
serde_json::json!({"a":1})
);
let err = parse_run_flow_params(Some(r#"["x"]"#.to_string()))
.expect_err("non-object params should fail");
assert!(
err.to_string().contains("expected an object"),
"error should explain object requirement: {err}"
);
}
#[cfg(feature = "mob")]
#[test]
fn test_render_flow_status_json_outputs_json_or_null() {
let run = meerkat_mob::MobRun {
run_id: RunId::new(),
mob_id: meerkat_mob::MobId::from("flow-mob"),
flow_id: FlowId::from("demo"),
status: meerkat_mob::MobRunStatus::Running,
activation_params: serde_json::json!({"ticket":"REQ-019"}),
created_at: chrono::Utc::now(),
completed_at: None,
step_ledger: Vec::new(),
failure_ledger: Vec::new(),
flow_state: Default::default(),
flow_authority_inputs: Vec::new(),
frames: std::collections::BTreeMap::new(),
loops: std::collections::BTreeMap::new(),
loop_iteration_ledger: Vec::new(),
schema_version: 4,
root_step_outputs: Default::default(),
loop_iteration_outputs: Default::default(),
};
let run_json = render_flow_status_json(Some(run)).expect("encode run json");
let decoded: serde_json::Value =
serde_json::from_str(&run_json).expect("decode run json payload");
assert_eq!(decoded["flow_id"], "demo");
let null_json = render_flow_status_json(None).expect("encode null json");
assert_eq!(null_json, "null");
}
#[cfg(feature = "mob")]
#[test]
fn test_cached_run_snapshot_returns_only_terminal_runs() {
let completed_id = RunId::new();
let running_id = RunId::new();
let now = chrono::Utc::now();
let mut registry = PersistedMobRegistry::default();
registry.mobs.insert(
"flow-mob".to_string(),
PersistedMob {
definition: None,
status: Some("Running".to_string()),
events: Vec::new(),
runs: std::collections::BTreeMap::from([
(
completed_id.to_string(),
meerkat_mob::MobRun {
run_id: completed_id.clone(),
mob_id: meerkat_mob::MobId::from("flow-mob"),
flow_id: FlowId::from("demo"),
status: meerkat_mob::MobRunStatus::Completed,
activation_params: serde_json::json!({}),
created_at: now,
completed_at: Some(now),
step_ledger: Vec::new(),
failure_ledger: Vec::new(),
flow_state: Default::default(),
flow_authority_inputs: Vec::new(),
frames: std::collections::BTreeMap::new(),
loops: std::collections::BTreeMap::new(),
loop_iteration_ledger: Vec::new(),
schema_version: 4,
root_step_outputs: Default::default(),
loop_iteration_outputs: Default::default(),
},
),
(
running_id.to_string(),
meerkat_mob::MobRun {
run_id: running_id.clone(),
mob_id: meerkat_mob::MobId::from("flow-mob"),
flow_id: FlowId::from("demo"),
status: meerkat_mob::MobRunStatus::Running,
activation_params: serde_json::json!({}),
created_at: now,
completed_at: None,
step_ledger: Vec::new(),
failure_ledger: Vec::new(),
flow_state: Default::default(),
flow_authority_inputs: Vec::new(),
frames: std::collections::BTreeMap::new(),
loops: std::collections::BTreeMap::new(),
loop_iteration_ledger: Vec::new(),
schema_version: 4,
root_step_outputs: Default::default(),
loop_iteration_outputs: Default::default(),
},
),
]),
},
);
let completed = cached_run_snapshot(®istry, "flow-mob", &completed_id.to_string());
let running = cached_run_snapshot(®istry, "flow-mob", &running_id.to_string());
assert!(completed.is_some(), "terminal cached run should resolve");
assert!(
running.is_none(),
"non-terminal cached run must never be treated as authoritative"
);
}
#[test]
fn test_json_output_payload_includes_skill_diagnostics_field() {
let result = RunResult {
text: "ok".to_string(),
session_id: SessionId::new(),
usage: Usage::default(),
turns: 1,
tool_calls: 0,
terminal_cause_kind: None,
structured_output: None,
extraction_error: None,
schema_warnings: None,
skill_diagnostics: Some(meerkat_core::skills::SkillRuntimeDiagnostics {
source_health: meerkat_core::skills::SourceHealthSnapshot {
state: meerkat_core::skills::SourceHealthState::Degraded,
invalid_ratio: 0.2,
invalid_count: 1,
total_count: 5,
failure_streak: 3,
handshake_failed: false,
},
quarantined: vec![],
}),
};
let json = serde_json::json!({
"text": result.text,
"session_id": result.session_id.to_string(),
"turns": result.turns,
"tool_calls": result.tool_calls,
"usage": {
"input_tokens": result.usage.input_tokens,
"output_tokens": result.usage.output_tokens,
},
"structured_output": result.structured_output,
"extraction_error": result.extraction_error,
"schema_warnings": result.schema_warnings,
"skill_diagnostics": result.skill_diagnostics,
});
assert_eq!(
json["skill_diagnostics"]["source_health"]["state"],
"degraded"
);
}
}