Skip to main content

pi/cli/
entry.rs

1//! Process entry: `entry::run(args, io) -> ExitCode`.
2//!
3//! This is the substantive entry point that `main.rs` calls. It owns the tokio
4//! runtime construction, drives the [`bootstrap::run_bootstrap`] pipeline, and
5//! dispatches the resolved mode through [`modes::run::run_mode_default`].
6//!
7//! [`Io::real`] constructs the real production surface: a [`RuntimeFactory`]
8//! backed by [`create_agent_session_services`] and [`create_agent_session_from_services`]
9//! plus [`AgentSessionRuntime`], a [`PackageHandler`] backed by [`PackageManager`],
10//! and a [`DefaultDispatcher`] with concrete print/json binding and
11//! integrator-injected RPC/interactive closures.
12//!
13//! For tests, [`Io::custom`] accepts any combination of injected fakes so the
14//! full pipeline runs without touching the real terminal, network, or
15//! filesystem.
16//!
17//! The `main.rs` is one line:
18//!
19//! ```ignore
20//! fn main() -> std::process::ExitCode {
21//!     pi::cli::entry::run(std::env::args().skip(1).collect(), pi::cli::entry::Io::real())
22//! }
23//! ```
24
25use std::collections::BTreeMap;
26use std::io::{self, Read};
27use std::path::{Path, PathBuf};
28use std::pin::Pin;
29use std::process::ExitCode;
30use std::sync::Arc;
31
32use futures::future::BoxFuture;
33use futures::stream::BoxStream;
34use pi_ai::{AssistantMessageEvent, Context, Model, Provider, ProviderError, StreamOptions};
35
36use crate::cli::bootstrap::{
37    BootstrapInputs, BootstrapIo, BootstrapOutcome, RuntimeFactory, RuntimeFactoryOptions,
38    RuntimeHandle, run_bootstrap,
39};
40use crate::cli::package_manager_cli::{ListedPackage, ListedScope, PackageHandler, PackageOutput};
41use crate::core::agent_session_runtime::{
42    AgentSessionRuntime, AgentSessionRuntimeServices, CreateAgentSessionRuntimeFactory,
43    CreateAgentSessionRuntimeOptions, CreateAgentSessionRuntimeResult,
44};
45use crate::core::agent_session_services::{
46    AgentSessionRuntimeDiagnostic, AgentSessionServices, AgentSessionServicesError,
47    CreateAgentSessionResult, CreateAgentSessionServicesOptions, ExtensionFlagValue,
48    create_agent_session_from_services, create_agent_session_services_with_trust,
49};
50use crate::core::config::get_agent_dir;
51use crate::core::model_resolver::{
52    ResolveCliModelOptions, ResolveCliModelResult, ResolveModelScopeResult, resolve_cli_model,
53    resolve_model_scope_with_diagnostics,
54};
55use crate::core::model_runtime::ModelRuntime;
56use crate::core::package_manager::{PackageManager, PackageManagerOptions, Scope};
57use crate::core::resources::ResourceLoader;
58use crate::core::settings::{SettingsManager, SettingsManagerCreateOptions};
59use crate::core::system_prompt::{BuildSystemPromptOptions, build_system_prompt};
60use crate::core::trust::{
61    ProjectTrustStore, ResolveProjectTrustedOptions, resolve_project_trusted,
62};
63use crate::modes::interactive::runtime::{InteractiveRuntimeOptions, run_interactive_mode};
64use crate::modes::rpc::server::run_rpc_mode;
65use crate::modes::run::{DefaultDispatcher, run_mode_default};
66
67/// Injectable process I/O and product runtime dependencies.
68pub struct Io {
69    /// Bootstrap environment and terminal operations.
70    pub bootstrap_io: Arc<dyn BootstrapIo>,
71    /// Runtime factory.
72    pub factory: Arc<dyn RuntimeFactory>,
73    /// Package-command handler.
74    pub package_handler: Arc<dyn PackageHandler>,
75    /// Package-command output sink.
76    pub package_output: Arc<dyn PackageOutput>,
77    /// Mode dispatcher (concrete so runners can be replaced).
78    pub dispatcher: Arc<DefaultDispatcher>,
79}
80
81impl Io {
82    /// Production wiring backed by real product services.
83    ///
84    /// Constructs a [`RuntimeFactory`] that chains
85    /// `create_agent_session_services` → `create_agent_session_from_services` →
86    /// `AgentSessionRuntime`, a [`PackageHandler`] backed by [`PackageManager`],
87    /// and a [`DefaultDispatcher`] with concrete print/json binding.
88    ///
89    /// RPC and interactive closures are injected by the caller when those mode
90    /// runners are available (the RPC server and interactive TUI are built by
91    /// sibling slices). Use [`Io::with_rpc`] / [`Io::with_interactive`] to
92    /// inject them.
93    #[must_use]
94    pub fn real() -> Self {
95        let dispatcher = DefaultDispatcher::new()
96            .with_interactive(|dispatched, runtime| {
97                Box::pin(async move {
98                    let hardware_cursor = {
99                        let session = runtime.session();
100                        session.lock_settings().get_show_hardware_cursor()
101                    };
102                    let options = InteractiveRuntimeOptions {
103                        initial_message: dispatched.initial_message,
104                        initial_images: dispatched.initial_images,
105                        remaining_messages: dispatched.remaining_messages,
106                        migrations: dispatched.migrations,
107                        hardware_cursor,
108                        ..InteractiveRuntimeOptions::default()
109                    };
110                    run_interactive_mode(runtime, options)
111                        .await
112                        .map_err(|e| format!("interactive: {e}"))
113                })
114            })
115            .with_rpc(|_dispatched, runtime| {
116                Box::pin(async move {
117                    let code = run_rpc_mode(runtime).await;
118                    Ok(u8::try_from(code).unwrap_or(1))
119                })
120            });
121        Self {
122            bootstrap_io: Arc::new(RealBootstrapIo),
123            factory: Arc::new(RealRuntimeFactory),
124            package_handler: Arc::new(RealPackageHandler::new(false)),
125            package_output: Arc::new(ProductOutputSink),
126            dispatcher: Arc::new(dispatcher),
127        }
128    }
129
130    /// Inject the RPC mode runner.
131    #[must_use]
132    pub fn with_rpc<F>(mut self, f: F) -> Self
133    where
134        F: Fn(
135                crate::cli::bootstrap::Dispatched,
136                Arc<AgentSessionRuntime>,
137            ) -> BoxFuture<'static, Result<u8, String>>
138            + Send
139            + Sync
140            + 'static,
141    {
142        let dispatcher = Arc::try_unwrap(self.dispatcher).unwrap_or_else(|arc| DefaultDispatcher {
143            rpc: arc.rpc.clone(),
144            interactive: arc.interactive.clone(),
145        });
146        self.dispatcher = Arc::new(dispatcher.with_rpc(f));
147        self
148    }
149
150    /// Inject the interactive mode runner.
151    #[must_use]
152    pub fn with_interactive<F>(mut self, f: F) -> Self
153    where
154        F: Fn(
155                crate::cli::bootstrap::Dispatched,
156                Arc<AgentSessionRuntime>,
157            ) -> BoxFuture<'static, Result<u8, String>>
158            + Send
159            + Sync
160            + 'static,
161    {
162        let dispatcher = Arc::try_unwrap(self.dispatcher).unwrap_or_else(|arc| DefaultDispatcher {
163            rpc: arc.rpc.clone(),
164            interactive: arc.interactive.clone(),
165        });
166        self.dispatcher = Arc::new(dispatcher.with_interactive(f));
167        self
168    }
169
170    /// Test/custom wiring with full injection.
171    #[must_use]
172    pub fn custom(
173        bootstrap_io: Arc<dyn BootstrapIo>,
174        factory: Arc<dyn RuntimeFactory>,
175        package_handler: Arc<dyn PackageHandler>,
176        package_output: Arc<dyn PackageOutput>,
177        dispatcher: Arc<DefaultDispatcher>,
178    ) -> Self {
179        Self {
180            bootstrap_io,
181            factory,
182            package_handler,
183            package_output,
184            dispatcher,
185        }
186    }
187}
188
189/// Entry point. Synchronous; constructs a tokio multi-thread runtime and
190/// blocks on the bootstrap + dispatch pipeline.
191#[must_use]
192pub fn run(args: Vec<String>, io: Io) -> ExitCode {
193    let runtime = match tokio::runtime::Builder::new_multi_thread()
194        .enable_all()
195        .build()
196    {
197        Ok(rt) => rt,
198        Err(err) => {
199            crate::core::output_guard::ProductOutput::writeln(&format!(
200                "Error: failed to start runtime: {err}"
201            ));
202            return ExitCode::from(1);
203        }
204    };
205    let result = runtime.block_on(async move { run_pipeline(args, &io).await });
206    drop(runtime);
207    result
208}
209
210/// Async pipeline: bootstrap → dispatch. Public so tests with their own tokio
211/// runtime can drive it without the sync wrapper.
212pub async fn run_pipeline(args: Vec<String>, io: &Io) -> ExitCode {
213    let parsed = crate::cli::args::parse_args(&args);
214    let offline = parsed.offline
215        || io
216            .bootstrap_io
217            .env("PI_OFFLINE")
218            .as_deref()
219            .is_some_and(|value| {
220                matches!(
221                    value.trim().to_ascii_lowercase().as_str(),
222                    "1" | "true" | "yes" | "on"
223                )
224            });
225    io.package_handler.set_offline(offline);
226
227    let outcome = run_bootstrap(BootstrapInputs {
228        args,
229        io: io.bootstrap_io.as_ref(),
230        factory: io.factory.as_ref(),
231        package_handler: io.package_handler.as_ref(),
232        package_output: io.package_output.as_ref(),
233    })
234    .await;
235
236    match outcome {
237        BootstrapOutcome::Exit { code, drain_quirk } => {
238            let _ = drain_quirk;
239            ExitCode::from(code)
240        }
241        BootstrapOutcome::Dispatch(dispatched) => {
242            run_mode_default(dispatched, io.dispatcher.as_ref()).await
243        }
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Real process I/O
249// ---------------------------------------------------------------------------
250
251struct RealBootstrapIo;
252
253impl BootstrapIo for RealBootstrapIo {
254    fn env(&self, key: &str) -> Option<String> {
255        std::env::var(key).ok()
256    }
257    fn set_env(&self, _key: &str, _value: &str) {
258        // `unsafe_code = "forbid"` prevents `std::env::set_var`. The bootstrap
259        // captures `--offline` locally and threads it through the factory.
260        // Callers that need PI_OFFLINE in the process env set it before launch.
261    }
262    fn cwd(&self) -> PathBuf {
263        std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
264    }
265    fn stdin_is_tty(&self) -> bool {
266        io::IsTerminal::is_terminal(&io::stdin())
267    }
268    fn stdout_is_tty(&self) -> bool {
269        io::IsTerminal::is_terminal(&io::stdout())
270    }
271    fn read_piped_stdin<'a>(
272        &'a self,
273    ) -> Pin<Box<dyn Future<Output = io::Result<Option<String>>> + Send + 'a>> {
274        Box::pin(async move {
275            if self.stdin_is_tty() {
276                return Ok(None);
277            }
278            let mut buf = String::new();
279            let stdin = io::stdin();
280            let mut handle = stdin.lock();
281            handle.read_to_string(&mut buf)?;
282            let trimmed = buf.trim();
283            if trimmed.is_empty() {
284                Ok(None)
285            } else {
286                Ok(Some(trimmed.to_owned()))
287            }
288        })
289    }
290    fn write_stdout(&self, line: &str) {
291        crate::core::output_guard::ProductOutput::writeln(line);
292    }
293    fn write_stderr(&self, line: &str) {
294        use std::io::Write;
295        let mut stderr = std::io::stderr().lock();
296        let _ = stderr.write_all(line.as_bytes());
297        let _ = stderr.write_all(b"\n");
298    }
299}
300
301// ---------------------------------------------------------------------------
302// Real runtime factory
303// ---------------------------------------------------------------------------
304
305#[derive(Clone)]
306struct RuntimeProvider(ModelRuntime);
307
308impl Provider for RuntimeProvider {
309    fn stream(
310        &self,
311        model: &Model,
312        context: Context,
313        options: StreamOptions,
314    ) -> BoxStream<'static, Result<AssistantMessageEvent, ProviderError>> {
315        self.0.stream_simple(model.clone(), context, options)
316    }
317}
318fn build_builtin_tools(
319    cwd: &Path,
320    settings: &SettingsManager,
321    model: Option<Model>,
322) -> Vec<Arc<dyn pi_agent::AgentTool>> {
323    use crate::core::tools::{
324        bash::create_bash_tool,
325        edit::EditTool,
326        find::FindTool,
327        grep::GrepTool,
328        ls::LsTool,
329        read::{ReadTool, ReadToolOptions},
330        write::WriteTool,
331    };
332
333    vec![
334        Arc::new(ReadTool::with_options(ReadToolOptions {
335            cwd: cwd.to_path_buf(),
336            auto_resize_images: settings.get_image_auto_resize(),
337            model,
338        })),
339        create_bash_tool(cwd.to_path_buf()),
340        Arc::new(EditTool::new(cwd)),
341        Arc::new(WriteTool::new(cwd)),
342        Arc::new(GrepTool::new(cwd)),
343        Arc::new(FindTool::new(cwd)),
344        Arc::new(LsTool::new(cwd)),
345    ]
346}
347
348fn thinking_level_from_str(level: &str) -> Option<pi_ai::ModelThinkingLevel> {
349    match level {
350        "off" => Some(pi_ai::ModelThinkingLevel::Off),
351        "minimal" => Some(pi_ai::ModelThinkingLevel::Minimal),
352        "low" => Some(pi_ai::ModelThinkingLevel::Low),
353        "medium" => Some(pi_ai::ModelThinkingLevel::Medium),
354        "high" => Some(pi_ai::ModelThinkingLevel::High),
355        "xhigh" => Some(pi_ai::ModelThinkingLevel::Xhigh),
356        _ => None,
357    }
358}
359
360struct SessionBuildOptions {
361    cwd: String,
362    session_manager: crate::core::sessions::SessionManager,
363    settings_manager: SettingsManager,
364    session_result: CreateAgentSessionResult,
365    tools: Vec<Arc<dyn pi_agent::AgentTool>>,
366    messages: Vec<pi_agent::AgentMessage>,
367    system_prompt: String,
368    skills: Vec<crate::core::resources::skills::Skill>,
369    prompt_templates: Vec<crate::core::resources::prompts::PromptTemplate>,
370}
371
372struct BuiltSession {
373    session: Arc<crate::core::agent_session::AgentSession>,
374    diagnostics: Vec<AgentSessionRuntimeDiagnostic>,
375    model_fallback_message: Option<String>,
376}
377
378fn build_session(options: SessionBuildOptions) -> Result<BuiltSession, String> {
379    let session_result = options.session_result;
380    let host_runner = session_result.extension_runner.clone();
381    let trait_runner = host_runner
382        .clone()
383        .map(|runner| runner as Arc<dyn crate::core::agent_session::ExtensionRunner>);
384    let config = crate::core::agent_session::AgentSessionConfig {
385        agent: None,
386        provider: Some(Arc::new(RuntimeProvider(
387            session_result.model_runtime.clone(),
388        ))),
389        session_manager: options.session_manager,
390        settings_manager: options.settings_manager,
391        cwd: options.cwd,
392        scoped_models: session_result
393            .scoped_models
394            .iter()
395            .map(|scoped| crate::core::agent_session::ScopedModel {
396                model: scoped.model.clone(),
397                thinking_level: scoped.thinking_level,
398            })
399            .collect(),
400        initial_active_tool_names: Some(session_result.initial_active_tool_names),
401        allowed_tool_names: session_result.allowed_tool_names,
402        excluded_tool_names: session_result.excluded_tool_names,
403        model: session_result.model,
404        thinking_level: session_result.thinking_level,
405        system_prompt: options.system_prompt,
406        tools: options.tools,
407        messages: options.messages,
408        extension_runner: trait_runner,
409        host_extension_runner: host_runner,
410        model_runtime: Some(Arc::new(session_result.model_runtime)),
411        compaction_stream_override: None,
412        skills: options.skills,
413        prompt_templates: options.prompt_templates,
414        resource_loader: Some(session_result.resource_loader),
415        session_start_event: session_result.session_start_event,
416        base_config: None,
417    };
418    let session =
419        crate::core::agent_session::AgentSession::new(config).map_err(|error| error.to_string())?;
420    Ok(BuiltSession {
421        session,
422        diagnostics: session_result.diagnostics,
423        model_fallback_message: session_result.model_fallback_message,
424    })
425}
426
427struct SessionResources {
428    skills: Vec<crate::core::resources::skills::Skill>,
429    prompt_templates: Vec<crate::core::resources::prompts::PromptTemplate>,
430    context_files: Vec<crate::core::resources::AgentsFile>,
431    custom_prompt: Option<String>,
432    append_prompt: Option<String>,
433}
434
435fn session_resources(loader: &crate::core::resources::DefaultResourceLoader) -> SessionResources {
436    SessionResources {
437        skills: loader.get_skills().0.to_vec(),
438        prompt_templates: loader.get_prompts().0.to_vec(),
439        context_files: loader.get_agents_files().to_vec(),
440        custom_prompt: loader.get_system_prompt().map(str::to_owned),
441        append_prompt: (!loader.get_append_system_prompt().is_empty())
442            .then(|| loader.get_append_system_prompt().join("\n\n")),
443    }
444}
445
446struct RestoredSession {
447    has_existing_session: bool,
448    saved_session_model: Option<(String, String)>,
449    saved_thinking_level: Option<pi_ai::ModelThinkingLevel>,
450    messages: Vec<pi_agent::AgentMessage>,
451}
452
453fn restore_session(context: crate::core::sessions::SessionContext) -> RestoredSession {
454    RestoredSession {
455        has_existing_session: !context.messages.is_empty(),
456        saved_session_model: context.model.map(|model| (model.provider, model.model_id)),
457        saved_thinking_level: thinking_level_from_str(&context.thinking_level),
458        messages: context.messages,
459    }
460}
461
462fn extension_flag_values(args: &crate::cli::args::Args) -> BTreeMap<String, ExtensionFlagValue> {
463    args.unknown_flags
464        .iter()
465        .map(|(key, value)| {
466            let value = match value {
467                crate::cli::args::FlagValue::Bool => ExtensionFlagValue::Bool(true),
468                crate::cli::args::FlagValue::Str(value) => ExtensionFlagValue::Str(value.clone()),
469            };
470            (key.clone(), value)
471        })
472        .collect()
473}
474
475fn no_tools_mode(
476    args: &crate::cli::args::Args,
477) -> Option<crate::core::agent_session_services::NoToolsMode> {
478    if args.no_tools {
479        Some(crate::core::agent_session_services::NoToolsMode::All)
480    } else if args.no_builtin_tools {
481        Some(crate::core::agent_session_services::NoToolsMode::Builtin)
482    } else {
483        None
484    }
485}
486
487async fn create_runtime_services(
488    cwd: &str,
489    agent_dir: &str,
490    args: &crate::cli::args::Args,
491) -> Result<AgentSessionServices, String> {
492    create_agent_session_services_with_trust(
493        CreateAgentSessionServicesOptions {
494            cwd: PathBuf::from(cwd),
495            agent_dir: Some(PathBuf::from(agent_dir)),
496            extension_flag_values: Some(extension_flag_values(args)),
497            resource_loader_options: Some(
498                crate::core::agent_session_services::ResourceLoaderServiceOptions {
499                    no_extensions: args.no_extensions,
500                    no_skills: args.no_skills,
501                    no_prompt_templates: args.no_prompt_templates,
502                    no_themes: args.no_themes,
503                    no_context_files: args.no_context_files,
504                    system_prompt: args.system_prompt.clone(),
505                    append_system_prompt: (!args.append_system_prompt.is_empty())
506                        .then(|| args.append_system_prompt.clone()),
507                    additional_extension_paths: args.extensions.clone(),
508                    ..Default::default()
509                },
510            ),
511            ..Default::default()
512        },
513        args.project_trust_override,
514    )
515    .await
516    .map_err(|error| error.to_string())
517}
518
519struct ResolvedModels {
520    cli: ResolveCliModelResult,
521    scope: ResolveModelScopeResult,
522    diagnostics: Vec<AgentSessionRuntimeDiagnostic>,
523}
524
525async fn resolve_models(args: &crate::cli::args::Args, runtime: &ModelRuntime) -> ResolvedModels {
526    let cli = resolve_cli_model(ResolveCliModelOptions {
527        cli_provider: args.provider.as_deref(),
528        cli_model: args.model.as_deref(),
529        cli_thinking: args.thinking,
530        model_runtime: runtime,
531    });
532    let mut diagnostics = Vec::new();
533    if let Some(error) = &cli.error {
534        diagnostics.push(AgentSessionRuntimeDiagnostic::error(error.clone()));
535    }
536    if let Some(warning) = &cli.warning {
537        diagnostics.push(AgentSessionRuntimeDiagnostic::warning(warning.clone()));
538    }
539    let scope = resolve_model_scope_with_diagnostics(&args.models, runtime).await;
540    diagnostics.extend(
541        scope
542            .diagnostics
543            .iter()
544            .map(|diagnostic| AgentSessionRuntimeDiagnostic::warning(diagnostic.message.clone())),
545    );
546    ResolvedModels {
547        cli,
548        scope,
549        diagnostics,
550    }
551}
552async fn apply_cli_api_key(
553    api_key: Option<&str>,
554    selected_model: Option<&Model>,
555    runtime: &ModelRuntime,
556    diagnostics: &mut Vec<AgentSessionRuntimeDiagnostic>,
557) -> Result<(), String> {
558    let Some(api_key) = api_key else {
559        return Ok(());
560    };
561    let Some(model) = selected_model else {
562        diagnostics.push(AgentSessionRuntimeDiagnostic::error(
563            "--api-key requires a model to be specified via --model, --provider/--model, or --models",
564        ));
565        return Ok(());
566    };
567
568    runtime
569        .set_runtime_api_key(&model.provider, api_key)
570        .await
571        .map_err(|error| error.to_string())?;
572    runtime
573        .get_available(None)
574        .await
575        .map_err(|error| error.to_string())?;
576    Ok(())
577}
578
579/// Build the settings manager, built-in tools, and base system prompt that
580/// `build_session` consumes together. Pulled out of `RealRuntimeFactory::create`
581/// to keep the entry pipeline under the strict `too_many_lines` ceiling.
582fn build_session_inputs(
583    cwd: &str,
584    agent_dir: &str,
585    project_trusted: bool,
586    model: Option<Model>,
587    initial_active_tool_names: Vec<String>,
588    resources: &SessionResources,
589) -> (SettingsManager, Vec<Arc<dyn pi_agent::AgentTool>>, String) {
590    let settings_manager = SettingsManager::create(
591        cwd,
592        Some(agent_dir),
593        SettingsManagerCreateOptions::default().project_trusted(project_trusted),
594    );
595    let tools = build_builtin_tools(Path::new(cwd), &settings_manager, model);
596    let system_prompt = build_system_prompt(&BuildSystemPromptOptions {
597        custom_prompt: resources.custom_prompt.clone(),
598        selected_tools: Some(initial_active_tool_names),
599        tool_snippets: None,
600        prompt_guidelines: None,
601        append: resources.append_prompt.clone(),
602        cwd: cwd.to_owned(),
603        context_files: Some(resources.context_files.clone()),
604        skills: Some(resources.skills.clone()),
605    });
606    (settings_manager, tools, system_prompt)
607}
608/// Runtime factory backed by real product services.
609struct RealRuntimeFactory;
610
611impl RuntimeFactory for RealRuntimeFactory {
612    fn create(
613        &self,
614        options: RuntimeFactoryOptions,
615    ) -> BoxFuture<'_, Result<RuntimeHandle, String>> {
616        Box::pin(async move {
617            let cwd = options.cwd.clone();
618            let agent_dir = options.agent_dir.clone();
619            let parsed = options.parsed;
620            let session_context = options
621                .session_manager
622                .build_session_context()
623                .map_err(|error| error.to_string())?;
624            let RestoredSession {
625                has_existing_session,
626                saved_session_model,
627                saved_thinking_level,
628                messages: existing_messages,
629            } = restore_session(session_context);
630
631            let services = create_runtime_services(&cwd, &agent_dir, &parsed).await?;
632            let project_trusted = services.settings_manager().is_project_trusted();
633
634            // Services refresh registers extension providers before model resolution.
635            let ResolvedModels {
636                cli: cli_resolved,
637                scope,
638                diagnostics: mut pre_session_diagnostics,
639            } = resolve_models(&parsed, &services.model_runtime).await;
640
641            let resources = session_resources(&services.resource_loader);
642            let no_tools = no_tools_mode(&parsed);
643
644            let thinking_level = parsed
645                .thinking
646                .or(cli_resolved.thinking_level)
647                .or(saved_thinking_level);
648
649            // Resolve model + tool config into a session result.
650            let mut session_result = create_agent_session_from_services(
651                crate::core::agent_session_services::CreateAgentSessionFromServicesOptions {
652                    services,
653                    model: cli_resolved.model.clone(),
654                    thinking_level,
655                    scoped_models: scope.scoped_models,
656                    tools: if parsed.tools.is_empty() {
657                        None
658                    } else {
659                        Some(parsed.tools.clone())
660                    },
661                    exclude_tools: if parsed.exclude_tools.is_empty() {
662                        None
663                    } else {
664                        Some(parsed.exclude_tools.clone())
665                    },
666                    no_tools,
667                    session_start_event: None,
668                    saved_session_model,
669                    has_existing_session,
670                },
671            )
672            .await
673            .map_err(|e: AgentSessionServicesError| format!("{e}"))?;
674            apply_cli_api_key(
675                parsed.api_key.as_deref(),
676                session_result.model.as_ref(),
677                &session_result.model_runtime,
678                &mut pre_session_diagnostics,
679            )
680            .await?;
681            session_result
682                .diagnostics
683                .splice(0..0, pre_session_diagnostics);
684
685            let (settings_manager, tools, system_prompt) = build_session_inputs(
686                &cwd,
687                &agent_dir,
688                project_trusted,
689                session_result.model.clone(),
690                session_result.initial_active_tool_names.clone(),
691                &resources,
692            );
693
694            let built = build_session(SessionBuildOptions {
695                cwd: cwd.clone(),
696                session_manager: options.session_manager,
697                settings_manager,
698                session_result,
699                tools,
700                messages: existing_messages,
701                system_prompt,
702                skills: resources.skills,
703                prompt_templates: resources.prompt_templates,
704            })?;
705            let runtime = AgentSessionRuntime::new(
706                built.session,
707                AgentSessionRuntimeServices {
708                    cwd: PathBuf::from(&cwd),
709                    agent_dir: PathBuf::from(&agent_dir),
710                },
711                Arc::new(RealReplacementFactory {
712                    project_trust_override: parsed.project_trust_override,
713                }),
714                built.diagnostics,
715                built.model_fallback_message,
716            );
717
718            Ok(RuntimeHandle {
719                runtime: Arc::new(runtime),
720            })
721        })
722    }
723
724    fn supports_interactive(&self) -> bool {
725        true
726    }
727}
728
729/// Replacement factory for runtime swap operations (new/switch/fork).
730#[derive(Clone)]
731struct RealReplacementFactory {
732    project_trust_override: Option<bool>,
733}
734
735impl CreateAgentSessionRuntimeFactory for RealReplacementFactory {
736    fn create(
737        &self,
738        options: CreateAgentSessionRuntimeOptions,
739    ) -> BoxFuture<
740        '_,
741        Result<
742            CreateAgentSessionRuntimeResult,
743            crate::core::agent_session_runtime::AgentSessionRuntimeError,
744        >,
745    > {
746        let cwd = options.cwd.clone();
747        let agent_dir = options.agent_dir.clone();
748        let session_context = options
749            .session_manager
750            .build_session_context()
751            .map_err(|error| {
752                crate::core::agent_session_runtime::AgentSessionRuntimeError::Factory(
753                    error.to_string(),
754                )
755            });
756        Box::pin(async move {
757            let session_context = session_context?;
758            let RestoredSession {
759                has_existing_session,
760                saved_session_model,
761                saved_thinking_level: thinking_level,
762                messages: existing_messages,
763            } = restore_session(session_context);
764            let services = create_agent_session_services_with_trust(
765                CreateAgentSessionServicesOptions {
766                    cwd: PathBuf::from(&cwd),
767                    agent_dir: Some(PathBuf::from(&agent_dir)),
768                    ..Default::default()
769                },
770                self.project_trust_override,
771            )
772            .await
773            .map_err(|e| {
774                crate::core::agent_session_runtime::AgentSessionRuntimeError::Factory(format!(
775                    "{e}"
776                ))
777            })?;
778            let project_trusted = services.settings_manager().is_project_trusted();
779            let resources = session_resources(&services.resource_loader);
780
781            let session_result = create_agent_session_from_services(
782                crate::core::agent_session_services::CreateAgentSessionFromServicesOptions {
783                    services,
784                    model: None,
785                    thinking_level,
786                    scoped_models: Vec::new(),
787                    tools: None,
788                    exclude_tools: None,
789                    no_tools: None,
790                    session_start_event: Some(crate::core::agent_session::SessionStartEvent {
791                        reason: options.start_reason,
792                        previous_session_file: options.previous_session_file.clone(),
793                    }),
794                    saved_session_model,
795                    has_existing_session,
796                },
797            )
798            .await
799            .map_err(|e| {
800                crate::core::agent_session_runtime::AgentSessionRuntimeError::Factory(format!(
801                    "{e}"
802                ))
803            })?;
804
805            let built = assemble_replacement_session(
806                &cwd,
807                &agent_dir,
808                project_trusted,
809                options.session_manager,
810                session_result,
811                resources,
812                existing_messages,
813            )
814            .map_err(crate::core::agent_session_runtime::AgentSessionRuntimeError::Factory)?;
815
816            Ok(CreateAgentSessionRuntimeResult {
817                session: built.session,
818                services: AgentSessionRuntimeServices {
819                    cwd: PathBuf::from(&cwd),
820                    agent_dir: PathBuf::from(&agent_dir),
821                },
822                diagnostics: built.diagnostics,
823                model_fallback_message: built.model_fallback_message,
824            })
825        })
826    }
827}
828
829/// Assemble the replacement session inputs (settings, tools, system prompt)
830/// and build the session. Pulled out of [`RealReplacementFactory::create`] to
831/// keep it under the strict `too_many_lines` ceiling.
832fn assemble_replacement_session(
833    cwd: &str,
834    agent_dir: &str,
835    project_trusted: bool,
836    session_manager: crate::core::sessions::SessionManager,
837    session_result: CreateAgentSessionResult,
838    resources: SessionResources,
839    existing_messages: Vec<pi_agent::AgentMessage>,
840) -> Result<BuiltSession, String> {
841    let SessionResources {
842        skills,
843        prompt_templates,
844        context_files,
845        custom_prompt,
846        append_prompt,
847    } = resources;
848    let settings_manager = SettingsManager::create(
849        cwd,
850        Some(agent_dir),
851        SettingsManagerCreateOptions::default().project_trusted(project_trusted),
852    );
853    let tools = build_builtin_tools(
854        Path::new(cwd),
855        &settings_manager,
856        session_result.model.clone(),
857    );
858    let system_prompt = build_system_prompt(&BuildSystemPromptOptions {
859        custom_prompt,
860        selected_tools: Some(session_result.initial_active_tool_names.clone()),
861        tool_snippets: None,
862        prompt_guidelines: None,
863        append: append_prompt,
864        cwd: cwd.to_owned(),
865        context_files: Some(context_files),
866        skills: Some(skills.clone()),
867    });
868    build_session(SessionBuildOptions {
869        cwd: cwd.to_owned(),
870        session_manager,
871        settings_manager,
872        session_result,
873        tools,
874        messages: existing_messages,
875        system_prompt,
876        skills,
877        prompt_templates,
878    })
879}
880
881// ---------------------------------------------------------------------------
882// Real package handler
883// ---------------------------------------------------------------------------
884
885/// Package handler backed by real [`PackageManager`] + [`SettingsManager`].
886struct RealPackageHandler {
887    cwd: PathBuf,
888    agent_dir: PathBuf,
889    offline: std::sync::atomic::AtomicBool,
890    project_trust_override: std::sync::Mutex<Option<bool>>,
891}
892
893impl RealPackageHandler {
894    fn new(offline: bool) -> Self {
895        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
896        let agent_dir = get_agent_dir();
897        Self {
898            cwd,
899            agent_dir,
900            offline: std::sync::atomic::AtomicBool::new(offline),
901            project_trust_override: std::sync::Mutex::new(None),
902        }
903    }
904
905    fn build_settings(&self, trusted: bool) -> SettingsManager {
906        SettingsManager::create(
907            &self.cwd,
908            Some(&self.agent_dir),
909            SettingsManagerCreateOptions::default().project_trusted(trusted),
910        )
911    }
912
913    fn resolved_project_trusted(&self) -> bool {
914        let global_settings = self.build_settings(false);
915        let trust_override = *self
916            .project_trust_override
917            .lock()
918            .unwrap_or_else(std::sync::PoisonError::into_inner);
919        resolve_project_trusted(ResolveProjectTrustedOptions {
920            cwd: self.cwd.clone(),
921            trust_store: &ProjectTrustStore::new(&self.agent_dir),
922            trust_override,
923            default_project_trust: global_settings.get_default_project_trust(),
924            extension_hook: None,
925            ui: None,
926            on_extension_error: None,
927        })
928        .unwrap_or(false)
929    }
930
931    fn build_package_manager(&self) -> PackageManager {
932        PackageManager::with_offline(
933            PackageManager::new(PackageManagerOptions::new(&self.cwd, &self.agent_dir)),
934            self.offline.load(std::sync::atomic::Ordering::Relaxed),
935        )
936    }
937
938    fn scope(local: bool) -> Scope {
939        if local { Scope::Project } else { Scope::User }
940    }
941}
942
943impl PackageHandler for RealPackageHandler {
944    fn set_offline(&self, offline: bool) {
945        self.offline
946            .store(offline, std::sync::atomic::Ordering::Relaxed);
947    }
948
949    fn set_project_trust_override(&self, trust_override: Option<bool>) {
950        *self
951            .project_trust_override
952            .lock()
953            .unwrap_or_else(std::sync::PoisonError::into_inner) = trust_override;
954    }
955
956    fn install(&self, source: &str, local: bool) -> Result<(), String> {
957        let mut settings = self.build_settings(local && self.resolved_project_trusted());
958        let pm = self.build_package_manager();
959        pm.install_and_persist(&mut settings, source, Self::scope(local))
960            .map_err(|e| format!("{e}"))
961    }
962
963    fn remove(&self, source: &str, local: bool) -> Result<bool, String> {
964        let mut settings = self.build_settings(local && self.resolved_project_trusted());
965        let pm = self.build_package_manager();
966        pm.remove_and_persist(&mut settings, source, Self::scope(local))
967            .map_err(|e| format!("{e}"))
968    }
969
970    fn list(&self) -> Result<Vec<ListedPackage>, String> {
971        let settings = self.build_settings(self.resolved_project_trusted());
972        let pm = self.build_package_manager();
973        let configured = pm
974            .list_configured_packages(&settings)
975            .map_err(|e| format!("{e}"))?;
976        Ok(configured
977            .into_iter()
978            .map(|pkg| ListedPackage {
979                display: if pkg.filtered {
980                    format!("{} (filtered)", pkg.source)
981                } else {
982                    pkg.source.clone()
983                },
984                installed_path: pkg.installed_path.map(|p| p.to_string_lossy().into_owned()),
985                scope: match pkg.scope {
986                    Scope::User => ListedScope::User,
987                    Scope::Project => ListedScope::Project,
988                },
989            })
990            .collect())
991    }
992
993    fn is_project_trusted(&self) -> bool {
994        self.resolved_project_trusted()
995    }
996
997    fn refresh_models(&self) -> Result<(), String> {
998        if self.offline.load(std::sync::atomic::Ordering::Relaxed) {
999            return Err("Cannot refresh model catalogs while offline".to_owned());
1000        }
1001        let agent_dir = self.agent_dir.clone();
1002        std::thread::spawn(move || {
1003            tokio::runtime::Builder::new_current_thread()
1004                .enable_all()
1005                .build()
1006                .map_err(|error| format!("Failed to start model refresh runtime: {error}"))?
1007                .block_on(async move {
1008                    let runtime = ModelRuntime::create(
1009                        crate::core::model_runtime::CreateModelRuntimeOptions {
1010                            auth_path: Some(agent_dir.join("auth.json")),
1011                            models_path: Some(agent_dir.join("models.json")),
1012                            models_store_path: Some(agent_dir.join("models-store.json")),
1013                            allow_model_network: Some(true),
1014                            ..Default::default()
1015                        },
1016                    )
1017                    .await
1018                    .map_err(|error| error.to_string())?;
1019                    runtime
1020                        .refresh(crate::core::model_runtime::ModelsRefreshOptions {
1021                            allow_network: Some(true),
1022                        })
1023                        .await
1024                        .map(|_| ())
1025                        .map_err(|error| error.to_string())
1026                })
1027        })
1028        .join()
1029        .map_err(|_| "Model refresh worker panicked".to_owned())?
1030    }
1031
1032    fn update_extensions(&self, source: Option<&str>) -> Result<(), String> {
1033        let settings = self.build_settings(self.resolved_project_trusted());
1034        let pm = self.build_package_manager();
1035        pm.update_extensions(&settings, source)
1036            .map_err(|e| format!("{e}"))
1037    }
1038
1039    fn update_self(&self, _force: bool) -> Result<bool, String> {
1040        Err(
1041            "Self-update is not supported by this build; install the new release with your package manager"
1042                .to_owned(),
1043        )
1044    }
1045}
1046
1047// ---------------------------------------------------------------------------
1048// Real package output
1049// ---------------------------------------------------------------------------
1050
1051struct ProductOutputSink;
1052
1053impl PackageOutput for ProductOutputSink {
1054    fn status(&self, line: &str) {
1055        crate::core::output_guard::ProductOutput::writeln(line);
1056    }
1057    fn status_dim(&self, line: &str) {
1058        crate::core::output_guard::ProductOutput::writeln(line);
1059    }
1060    fn success(&self, line: &str) {
1061        crate::core::output_guard::ProductOutput::writeln(line);
1062    }
1063    fn error(&self, line: &str) {
1064        use std::io::Write;
1065        let mut stderr = std::io::stderr().lock();
1066        let _ = stderr.write_all(line.as_bytes());
1067        let _ = stderr.write_all(b"\n");
1068    }
1069}
1070
1071// ---------------------------------------------------------------------------
1072// Tests
1073// ---------------------------------------------------------------------------
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078    use futures::FutureExt;
1079    use std::sync::Mutex;
1080
1081    #[derive(Default)]
1082    struct FakeIo {
1083        env: Mutex<std::collections::HashMap<String, String>>,
1084        stdout: Mutex<Vec<String>>,
1085        stderr: Mutex<Vec<String>>,
1086    }
1087
1088    impl BootstrapIo for FakeIo {
1089        fn env(&self, key: &str) -> Option<String> {
1090            self.env
1091                .lock()
1092                .unwrap_or_else(std::sync::PoisonError::into_inner)
1093                .get(key)
1094                .cloned()
1095        }
1096        fn set_env(&self, key: &str, value: &str) {
1097            self.env
1098                .lock()
1099                .unwrap_or_else(std::sync::PoisonError::into_inner)
1100                .insert(key.to_owned(), value.to_owned());
1101        }
1102        fn cwd(&self) -> PathBuf {
1103            std::env::temp_dir()
1104        }
1105        fn stdin_is_tty(&self) -> bool {
1106            true
1107        }
1108        fn stdout_is_tty(&self) -> bool {
1109            true
1110        }
1111        fn read_piped_stdin<'a>(
1112            &'a self,
1113        ) -> Pin<Box<dyn Future<Output = io::Result<Option<String>>> + Send + 'a>> {
1114            Box::pin(async move { Ok(None) })
1115        }
1116        fn write_stdout(&self, line: &str) {
1117            self.stdout
1118                .lock()
1119                .unwrap_or_else(std::sync::PoisonError::into_inner)
1120                .push(line.to_owned());
1121        }
1122        fn write_stderr(&self, line: &str) {
1123            self.stderr
1124                .lock()
1125                .unwrap_or_else(std::sync::PoisonError::into_inner)
1126                .push(line.to_owned());
1127        }
1128    }
1129
1130    #[tokio::test]
1131    async fn api_key_without_selected_model_emits_reference_diagnostic() -> Result<(), String> {
1132        let runtime = ModelRuntime::create_in_memory()
1133            .await
1134            .map_err(|error| format!("failed to create in-memory model runtime: {error}"))?;
1135        let mut diagnostics = Vec::new();
1136
1137        apply_cli_api_key(Some("sk-test"), None, &runtime, &mut diagnostics)
1138            .await
1139            .map_err(|error| {
1140                format!("missing model should be diagnostic, not factory failure: {error}")
1141            })?;
1142
1143        assert_eq!(diagnostics.len(), 1);
1144        assert_eq!(
1145            diagnostics[0].message,
1146            "--api-key requires a model to be specified via --model, --provider/--model, or --models"
1147        );
1148        Ok(())
1149    }
1150
1151    #[tokio::test]
1152    async fn api_key_configures_provider_of_explicit_and_embedded_model_selection()
1153    -> Result<(), String> {
1154        for embedded_provider in [false, true] {
1155            let runtime = ModelRuntime::create_in_memory()
1156                .await
1157                .map_err(|error| format!("failed to create in-memory model runtime: {error}"))?;
1158            let catalog = runtime.get_models(None);
1159            let Some(catalog_model) = catalog.first() else {
1160                return Err("built-in model catalog is empty".to_owned());
1161            };
1162            let model_reference = if embedded_provider {
1163                format!("{}/{}", catalog_model.provider, catalog_model.id)
1164            } else {
1165                catalog_model.id.clone()
1166            };
1167            let resolved = resolve_cli_model(ResolveCliModelOptions {
1168                cli_provider: (!embedded_provider).then_some(catalog_model.provider.as_str()),
1169                cli_model: Some(&model_reference),
1170                cli_thinking: None,
1171                model_runtime: &runtime,
1172            });
1173            let Some(selected) = resolved.model else {
1174                return Err("CLI model did not resolve".to_owned());
1175            };
1176            let unselected_provider = catalog
1177                .iter()
1178                .find(|model| model.provider != selected.provider)
1179                .map(|model| model.provider.clone());
1180            let mut diagnostics = Vec::new();
1181
1182            apply_cli_api_key(
1183                Some("sk-selected-provider"),
1184                Some(&selected),
1185                &runtime,
1186                &mut diagnostics,
1187            )
1188            .await
1189            .map_err(|error| format!("runtime API key installation failed: {error}"))?;
1190
1191            assert!(diagnostics.is_empty());
1192            assert!(runtime.has_configured_auth(&selected.provider));
1193            if let Some(unselected_provider) = unselected_provider {
1194                assert!(
1195                    !runtime.has_configured_auth(&unselected_provider),
1196                    "key must not be stored under an unselected CLI provider"
1197                );
1198            }
1199        }
1200        Ok(())
1201    }
1202
1203    /// Version short-circuit works with a fake factory (never reaches runtime).
1204    #[tokio::test]
1205    async fn run_pipeline_version_exits_zero() {
1206        let io_state = Arc::new(FakeIo::default());
1207        let factory = Arc::new(FakeRuntimeFactory);
1208        let handler = Arc::new(FakePackageHandler::default());
1209        let output = Arc::new(FakePackageOutput::default());
1210        let dispatcher = Arc::new(DefaultDispatcher::new());
1211        let io = Io::custom(io_state, factory, handler, output, dispatcher);
1212        let result = run_pipeline(vec!["--version".to_owned()], &io).await;
1213        assert_eq!(result, ExitCode::from(0));
1214    }
1215
1216    #[tokio::test]
1217    async fn run_pipeline_unknown_flag_exits_one() {
1218        let io_state = Arc::new(FakeIo::default());
1219        let factory = Arc::new(FakeRuntimeFactory);
1220        let handler = Arc::new(FakePackageHandler::default());
1221        let output = Arc::new(FakePackageOutput::default());
1222        let dispatcher = Arc::new(DefaultDispatcher::new());
1223        let io = Io::custom(io_state, factory, handler, output, dispatcher);
1224        let result = run_pipeline(vec!["-Z".to_owned()], &io).await;
1225        assert_eq!(result, ExitCode::from(1));
1226    }
1227
1228    #[tokio::test]
1229    async fn offline_is_applied_before_package_short_circuit() {
1230        let io_state = Arc::new(FakeIo::default());
1231        let factory = Arc::new(FakeRuntimeFactory);
1232        let handler = Arc::new(FakePackageHandler::default());
1233        let output = Arc::new(FakePackageOutput::default());
1234        let dispatcher = Arc::new(DefaultDispatcher::new());
1235        let io = Io::custom(io_state, factory, handler.clone(), output, dispatcher);
1236
1237        let result = run_pipeline(
1238            vec![
1239                "install".to_owned(),
1240                "example".to_owned(),
1241                "--offline".to_owned(),
1242            ],
1243            &io,
1244        )
1245        .await;
1246
1247        assert_eq!(result, ExitCode::from(0));
1248        assert!(handler.offline.load(std::sync::atomic::Ordering::Relaxed));
1249    }
1250
1251    #[test]
1252    fn unavailable_real_updates_fail_honestly() {
1253        let handler = RealPackageHandler::new(true);
1254        assert_eq!(
1255            handler.refresh_models(),
1256            Err("Cannot refresh model catalogs while offline".to_owned())
1257        );
1258        let error = handler.update_self(false).err();
1259        assert!(
1260            error
1261                .as_deref()
1262                .is_some_and(|error| error.contains("not supported by this build")),
1263            "self-update must fail honestly without an engine: {error:?}"
1264        );
1265    }
1266
1267    // Fake factory that errors with a stable string.
1268    struct FakeRuntimeFactory;
1269    impl RuntimeFactory for FakeRuntimeFactory {
1270        fn create(
1271            &self,
1272            _options: RuntimeFactoryOptions,
1273        ) -> BoxFuture<'_, Result<RuntimeHandle, String>> {
1274            async { Err("__fake_factory__".to_owned()) }.boxed()
1275        }
1276    }
1277
1278    #[derive(Default)]
1279    struct FakePackageHandler {
1280        offline: std::sync::atomic::AtomicBool,
1281    }
1282    impl PackageHandler for FakePackageHandler {
1283        fn set_offline(&self, offline: bool) {
1284            self.offline
1285                .store(offline, std::sync::atomic::Ordering::Relaxed);
1286        }
1287        fn install(&self, _s: &str, _l: bool) -> Result<(), String> {
1288            Ok(())
1289        }
1290        fn remove(&self, _s: &str, _l: bool) -> Result<bool, String> {
1291            Ok(true)
1292        }
1293        fn list(&self) -> Result<Vec<ListedPackage>, String> {
1294            Ok(Vec::new())
1295        }
1296        fn is_project_trusted(&self) -> bool {
1297            true
1298        }
1299        fn refresh_models(&self) -> Result<(), String> {
1300            Ok(())
1301        }
1302        fn update_extensions(&self, _s: Option<&str>) -> Result<(), String> {
1303            Ok(())
1304        }
1305        fn update_self(&self, _f: bool) -> Result<bool, String> {
1306            Ok(false)
1307        }
1308    }
1309
1310    #[derive(Default)]
1311    struct FakePackageOutput {
1312        lines: Mutex<Vec<String>>,
1313    }
1314
1315    impl PackageOutput for FakePackageOutput {
1316        fn status(&self, line: &str) {
1317            self.lines
1318                .lock()
1319                .unwrap_or_else(std::sync::PoisonError::into_inner)
1320                .push(line.to_owned());
1321        }
1322        fn status_dim(&self, line: &str) {
1323            self.status(line);
1324        }
1325        fn success(&self, line: &str) {
1326            self.status(line);
1327        }
1328        fn error(&self, line: &str) {
1329            self.status(line);
1330        }
1331    }
1332}