Skip to main content

pi/cli/
bootstrap.rs

1//! CLI bootstrap: argv → resolved mode + runtime handle, or short-circuit exit.
2//!
3//! Ports the ordered pipeline of `.references/pi/packages/coding-agent/src/
4//! main.ts` into a testable orchestrator. Everything that touches the process
5//! (env vars, TTY status, stdin, stdout/stderr) flows through an injected
6//! [`BootstrapIo`] trait; everything that builds the agent-session runtime
7//! flows through an injected [`RuntimeFactory`] trait. Pure helpers
8//! ([`resolve_app_mode`], validators) are public so the integrator and tests
9//! can drive them directly.
10//!
11//! # Pipeline order (matches `main.ts:472-858`)
12//!
13//! 1. Reset timings (no-op here; the timings module is a sibling concern).
14//! 2. Offline mode: `args.contains("--offline") || PI_OFFLINE` → set
15//!    `PI_OFFLINE=1`, `PI_SKIP_VERSION_CHECK=1`.
16//! 3. Package/config short-circuit (`pi install …`, `pi config …`).
17//! 4. `parse_args` + report diagnostics (errors exit 1, warnings continue).
18//! 5. `--version` → print version, exit 0.
19//! 6. `--export` → export session file, exit 0/1.
20//! 7. `resolve_app_mode` + `take_over_stdout` (unless interactive or plain
21//!    runtime metadata command).
22//! 8. RPC + `@file` guard.
23//! 9. Fork / session-id flag validation.
24//! 10. `run_migrations` (records `migrated_providers` + `deprecation_warnings`).
25//! 11. Startup settings + first-time setup (interactive only — gated, the UI
26//!     surface itself is a sibling slice).
27//! 12. Session-dir resolution + `create_session_manager`.
28//! 13. `--name` append + validation.
29//! 14. Trust store + resource paths.
30//! 15. Build runtime via factory.
31//! 16. `--help` (with extension flags) → exit 0.
32//! 17. `--list-models` → exit 0.
33//! 18. Read piped stdin (skip RPC); demote interactive→print when stdin present.
34//! 19. Prepare initial message.
35//! 20. Report runtime diagnostics (errors exit 1, with extension hint when
36//!     relevant).
37//! 21. Non-interactive requires `session.model`, else no-models message + exit 1.
38//! 22. `PI_STARTUP_BENCHMARK` guard.
39//! 23. Return [`BootstrapOutcome::Dispatch`] with the resolved mode + handle.
40
41use std::io;
42use std::path::{Path, PathBuf};
43use std::pin::Pin;
44
45use futures::future::BoxFuture;
46
47use crate::cli::args::{Args, DiagnosticLevel, ListModels, Mode};
48use crate::cli::package_manager_cli::{self, DispatchPlatform, PackageHandler, PackageOutput};
49use crate::core::agent_session_runtime::AgentSessionRuntime;
50use crate::core::config::{ENV_SESSION_DIR, VERSION, expand_tilde_path};
51use crate::core::migrations::{self, MigrationResult};
52use crate::core::output_guard::{self, ProductOutput};
53use crate::core::sessions::SessionManager;
54
55/// Resolved application mode.
56///
57/// Mirrors TypeScript `AppMode = interactive | print | json | rpc`.
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub enum AppMode {
60    /// Interactive TUI session.
61    Interactive,
62    /// Single-shot text print.
63    Print,
64    /// JSONL event print.
65    Json,
66    /// Headless JSONL RPC server.
67    Rpc,
68}
69
70impl AppMode {
71    /// Returns `true` for the JSON output mode.
72    #[must_use]
73    pub const fn is_json(self) -> bool {
74        matches!(self, Self::Json)
75    }
76
77    /// Returns `true` for the RPC server mode.
78    #[must_use]
79    pub const fn is_rpc(self) -> bool {
80        matches!(self, Self::Rpc)
81    }
82
83    /// Returns `true` for the interactive TUI mode.
84    #[must_use]
85    pub const fn is_interactive(self) -> bool {
86        matches!(self, Self::Interactive)
87    }
88}
89
90/// Resolve the application mode from parsed args and TTY status.
91///
92/// Mirrors `resolveAppMode` (`main.ts:99-110`):
93/// - `--mode rpc` → RPC (highest precedence, forces non-interactive).
94/// - `--mode json` → JSON.
95/// - `--print` or non-TTY stdin/stdout → Print.
96/// - Otherwise → Interactive.
97#[must_use]
98pub fn resolve_app_mode(parsed: &Args, stdin_is_tty: bool, stdout_is_tty: bool) -> AppMode {
99    match parsed.mode {
100        Some(Mode::Rpc) => AppMode::Rpc,
101        Some(Mode::Json) => AppMode::Json,
102        Some(Mode::Text) | None => {
103            if parsed.print || !stdin_is_tty || !stdout_is_tty {
104                AppMode::Print
105            } else {
106                AppMode::Interactive
107            }
108        }
109    }
110}
111
112/// Whether `--help`/`--list-models` should run without taking over stdout.
113///
114/// Mirrors `isPlainRuntimeMetadataCommand` (`main.ts:116-118`): true when the
115/// user did not pass `--print` or an explicit `--mode` and is asking for
116/// `--help` or `--list-models`. These commands print to stdout directly
117/// without the protocol takeover so the output reaches the user even when the
118/// runtime is built (e.g. to discover extension flags).
119#[must_use]
120pub fn is_plain_runtime_metadata_command(parsed: &Args) -> bool {
121    !parsed.print
122        && parsed.mode.is_none()
123        && (parsed.help || !matches!(parsed.list_models, ListModels::None))
124}
125
126/// Error returned by [`validate_fork_flags`] / [`validate_session_id_flags`].
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct FlagValidationError {
129    /// Verbatim message to surface on stderr (prefixed with `Error: `).
130    pub message: String,
131}
132
133/// `--fork` cannot combine with `--session`/`--continue`/`--resume`/`--no-session`.
134///
135/// Mirrors `validateForkFlags` (`main.ts:204-218`).
136///
137/// # Errors
138/// Returns an error when `--fork` is combined with any incompatible session flag.
139pub fn validate_fork_flags(parsed: &Args) -> Result<(), FlagValidationError> {
140    let Some(_fork) = parsed.fork.as_ref() else {
141        return Ok(());
142    };
143    let mut conflicts = Vec::new();
144    if parsed.session.is_some() {
145        conflicts.push("--session");
146    }
147    if parsed.r#continue {
148        conflicts.push("--continue");
149    }
150    if parsed.resume {
151        conflicts.push("--resume");
152    }
153    if parsed.no_session {
154        conflicts.push("--no-session");
155    }
156    if conflicts.is_empty() {
157        Ok(())
158    } else {
159        Err(FlagValidationError {
160            message: format!("--fork cannot be combined with {}", conflicts.join(", ")),
161        })
162    }
163}
164
165/// `--session-id` cannot combine with `--session`/`--continue`/`--resume`.
166///
167/// Mirrors `validateSessionIdFlags` (`main.ts:220-241`). The ID syntax check
168/// itself is delegated to `SessionManager`/`assert_valid_session_id` at
169/// session-construction time; this validator only covers the CLI-level
170/// conflicts.
171///
172/// # Errors
173/// Returns an error when `--session-id` is combined with `--session`,
174/// `--continue`, or `--resume`.
175pub fn validate_session_id_flags(parsed: &Args) -> Result<(), FlagValidationError> {
176    let Some(_id) = parsed.session_id.as_ref() else {
177        return Ok(());
178    };
179    let mut conflicts = Vec::new();
180    if parsed.session.is_some() {
181        conflicts.push("--session");
182    }
183    if parsed.r#continue {
184        conflicts.push("--continue");
185    }
186    if parsed.resume {
187        conflicts.push("--resume");
188    }
189    if conflicts.is_empty() {
190        Ok(())
191    } else {
192        Err(FlagValidationError {
193            message: format!(
194                "--session-id cannot be combined with {}",
195                conflicts.join(", ")
196            ),
197        })
198    }
199}
200
201/// Validate `--name`/`-n` post-parse.
202///
203/// The parser records an empty value verbatim (`--name ""` → `name = Some("")`).
204/// `main.ts:590-597` rejects empty/whitespace-only names with exit 1 before
205/// appending to session info.
206///
207/// # Errors
208/// Returns an error when `--name` is present but empty or whitespace-only.
209pub fn validate_name(parsed: &Args) -> Result<(), FlagValidationError> {
210    if let Some(name) = parsed.name.as_ref()
211        && name.trim().is_empty()
212    {
213        return Err(FlagValidationError {
214            message: "--name requires a non-empty value".to_owned(),
215        });
216    }
217    Ok(())
218}
219
220/// Truthy env-flag test matching `isTruthyEnvFlag` (`main.ts:94-97`).
221#[must_use]
222pub fn is_truthy_env_flag(value: Option<&str>) -> bool {
223    match value {
224        Some(v) => {
225            let lower = v.to_ascii_lowercase();
226            v == "1" || lower == "true" || lower == "yes"
227        }
228        None => false,
229    }
230}
231
232// ---------------------------------------------------------------------------
233// I/O injection
234// ---------------------------------------------------------------------------
235
236/// Process-level I/O surface the bootstrap needs.
237///
238/// All methods are sync except [`Self::read_piped_stdin`]. Implementations are
239/// expected to be cheap to clone (`Arc`-backed) so the bootstrap can hold them
240/// across `.await` points.
241pub trait BootstrapIo: Send + Sync {
242    /// Read an environment variable.
243    fn env(&self, key: &str) -> Option<String>;
244    /// Set an environment variable for this process.
245    fn set_env(&self, key: &str, value: &str);
246    /// Process working directory.
247    fn cwd(&self) -> PathBuf;
248    /// Whether stdin is attached to a TTY.
249    fn stdin_is_tty(&self) -> bool;
250    /// Whether stdout is attached to a TTY.
251    fn stdout_is_tty(&self) -> bool;
252    /// Read piped stdin to EOF, returning `None` when empty/whitespace-only.
253    fn read_piped_stdin<'a>(
254        &'a self,
255    ) -> Pin<Box<dyn Future<Output = io::Result<Option<String>>> + Send + 'a>>;
256    /// Write a line to product stdout (newline appended).
257    fn write_stdout(&self, line: &str);
258    /// Write a line to product stderr (newline appended).
259    fn write_stderr(&self, line: &str);
260}
261
262// ---------------------------------------------------------------------------
263// Runtime factory injection
264// ---------------------------------------------------------------------------
265
266/// Inputs to [`RuntimeFactory::create`].
267pub struct RuntimeFactoryOptions {
268    /// Effective working directory.
269    pub cwd: String,
270    /// Agent config directory.
271    pub agent_dir: String,
272    /// Session manager bound to the chosen session.
273    pub session_manager: SessionManager,
274    /// Parsed CLI args (for extension flags, model overrides, tool gates).
275    pub parsed: Args,
276}
277
278/// Result of a successful runtime construction.
279pub struct RuntimeHandle {
280    /// The runtime owning the current session.
281    pub runtime: std::sync::Arc<AgentSessionRuntime>,
282}
283
284impl std::fmt::Debug for RuntimeHandle {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.debug_struct("RuntimeHandle")
287            .field("runtime", &"<AgentSessionRuntime>")
288            .finish()
289    }
290}
291
292/// Injected factory that turns a session manager + CLI args into a live
293/// [`AgentSessionRuntime`].
294///
295/// Implementations wrap `create_agent_session_services` +
296/// `create_agent_session_from_services` + `AgentSessionRuntime::new`. A fake
297/// implementation in tests returns a lightweight runtime without network or
298/// filesystem access.
299pub trait RuntimeFactory: Send + Sync {
300    /// Build the runtime.
301    ///
302    /// # Errors
303    /// Implementation-defined; the error string is surfaced verbatim prefixed
304    /// with `Error: `.
305    fn create(
306        &self,
307        options: RuntimeFactoryOptions,
308    ) -> BoxFuture<'_, Result<RuntimeHandle, String>>;
309
310    /// Whether the factory has a usable interactive TUI runtime. The default
311    /// is `true`; a minimal/fake factory returns `false` so the bootstrap
312    /// short-circuits interactive runs with an actionable error instead of
313    /// launching an empty TUI.
314    fn supports_interactive(&self) -> bool {
315        true
316    }
317}
318
319// ---------------------------------------------------------------------------
320// Outcome
321// ---------------------------------------------------------------------------
322
323/// Result of running the bootstrap pipeline.
324#[derive(Debug)]
325pub enum BootstrapOutcome {
326    /// Short-circuit: process should exit with this code and no further work.
327    Exit {
328        /// Process exit code.
329        code: u8,
330        /// Win32 `pi update` success must drain naturally.
331        drain_quirk: bool,
332    },
333    /// Continue to mode dispatch.
334    Dispatch(Dispatched),
335}
336
337/// A bootstrap that reached the dispatch stage.
338#[derive(Debug)]
339pub struct Dispatched {
340    /// Resolved application mode.
341    pub mode: AppMode,
342    /// Live runtime handle.
343    pub handle: RuntimeHandle,
344    /// Initial message assembled from stdin + `@file` + first CLI message.
345    pub initial_message: Option<String>,
346    /// Images attached to the initial message.
347    pub initial_images: Vec<pi_ai::ImageContent>,
348    /// Remaining CLI messages for follow-up prompts.
349    pub remaining_messages: Vec<String>,
350    /// Migration result (carried into interactive mode for changelog display).
351    pub migrations: MigrationResult,
352}
353
354// ---------------------------------------------------------------------------
355// Bootstrap driver
356// ---------------------------------------------------------------------------
357
358/// Inputs to [`run_bootstrap`].
359pub struct BootstrapInputs<'a> {
360    /// argv after the program name.
361    pub args: Vec<String>,
362    /// Process I/O surface.
363    pub io: &'a dyn BootstrapIo,
364    /// Runtime factory.
365    pub factory: &'a dyn RuntimeFactory,
366    /// Package-command handler (real or fake).
367    pub package_handler: &'a dyn PackageHandler,
368    /// Package-command output sink.
369    pub package_output: &'a dyn PackageOutput,
370}
371
372/// Run the bootstrap pipeline.
373///
374/// This is the pure-logic orchestration layer; all process interaction is
375/// routed through `inputs.io` and side-effecting package operations through
376/// `inputs.package_handler`.
377pub async fn run_bootstrap(inputs: BootstrapInputs<'_>) -> BootstrapOutcome {
378    let parsed = match initialize_bootstrap(&inputs) {
379        Ok(parsed) => parsed,
380        Err(exit) => return exit.into_outcome(),
381    };
382    let prepared = match prepare_session(&inputs, parsed).await {
383        Ok(prepared) => prepared,
384        Err(exit) => return exit.into_outcome(),
385    };
386    let (state, handle) = match create_runtime(&inputs, prepared).await {
387        Ok(runtime) => runtime,
388        Err(exit) => return exit.into_outcome(),
389    };
390    if let Some(outcome) = handle_runtime_metadata(inputs.io, &state, &handle).await {
391        return outcome;
392    }
393    finish_bootstrap(inputs.io, state, handle).await
394}
395
396type BootstrapStep<T> = Result<T, BootstrapExit>;
397
398struct PreparedSession {
399    parsed: Args,
400    app_mode: AppMode,
401    should_take_over_stdout: bool,
402    cwd: String,
403    migrations: MigrationResult,
404    agent_dir: String,
405    session_manager: SessionManager,
406}
407
408struct BootstrapState {
409    parsed: Args,
410    app_mode: AppMode,
411    should_take_over_stdout: bool,
412    cwd: String,
413    migrations: MigrationResult,
414}
415
416#[derive(Clone, Copy)]
417struct BootstrapExit {
418    code: u8,
419    drain_quirk: bool,
420}
421
422impl BootstrapExit {
423    fn into_outcome(self) -> BootstrapOutcome {
424        exit(self.code, self.drain_quirk)
425    }
426}
427
428fn exit(code: u8, drain_quirk: bool) -> BootstrapOutcome {
429    BootstrapOutcome::Exit { code, drain_quirk }
430}
431
432fn stop(code: u8, drain_quirk: bool) -> BootstrapExit {
433    BootstrapExit { code, drain_quirk }
434}
435
436fn fail(io: &dyn BootstrapIo, restore_stdout: bool, message: &str) -> BootstrapExit {
437    io.write_stderr(message);
438    if restore_stdout {
439        output_guard::restore_stdout();
440    }
441    stop(1, false)
442}
443
444fn initialize_bootstrap(inputs: &BootstrapInputs<'_>) -> BootstrapStep<Args> {
445    let mut parsed = crate::cli::args::parse_args(&inputs.args);
446    let offline_mode = parsed.offline || is_truthy_env_flag(inputs.io.env("PI_OFFLINE").as_deref());
447    parsed.offline = offline_mode;
448    if offline_mode {
449        inputs.io.set_env("PI_OFFLINE", "1");
450        inputs.io.set_env("PI_SKIP_VERSION_CHECK", "1");
451    }
452
453    if let Some(outcome) = package_manager_cli::handle_package_command(
454        &inputs.args,
455        inputs.package_handler,
456        inputs.package_output,
457        current_platform(),
458    ) {
459        return Err(stop(outcome.exit_code, outcome.drain_quirk));
460    }
461    if let Some(outcome) = package_manager_cli::handle_config_command(
462        &inputs.args,
463        inputs.package_output,
464        inputs.io.stdin_is_tty(),
465        inputs.io.stdout_is_tty(),
466    ) {
467        return Err(stop(outcome.exit_code, outcome.drain_quirk));
468    }
469
470    let mut parsed = crate::cli::args::parse_args(&inputs.args);
471    parsed.offline = offline_mode;
472    report_diagnostics(&parsed, inputs.io);
473    if parsed
474        .diagnostics
475        .iter()
476        .any(|diagnostic| diagnostic.level == DiagnosticLevel::Error)
477    {
478        return Err(stop(1, false));
479    }
480    if parsed.version {
481        inputs.io.write_stdout(VERSION);
482        return Err(stop(0, false));
483    }
484    if let Some(export_path) = parsed.export.as_ref()
485        && let Err(message) = run_export(export_path, parsed.messages.first())
486    {
487        inputs.io.write_stderr(&format!("Error: {message}"));
488        return Err(stop(1, false));
489    }
490    if parsed.export.is_some() {
491        return Err(stop(0, false));
492    }
493    Ok(parsed)
494}
495
496async fn prepare_session(
497    inputs: &BootstrapInputs<'_>,
498    parsed: Args,
499) -> BootstrapStep<PreparedSession> {
500    let app_mode = resolve_app_mode(&parsed, inputs.io.stdin_is_tty(), inputs.io.stdout_is_tty());
501    let plain_metadata = is_plain_runtime_metadata_command(&parsed);
502    let should_take_over_stdout = !app_mode.is_interactive() && !plain_metadata && !parsed.help;
503    if should_take_over_stdout && let Err(err) = output_guard::take_over_stdout() {
504        return Err(fail(inputs.io, false, &format!("Error: {err}")));
505    }
506    if app_mode.is_rpc() && !parsed.file_args.is_empty() {
507        return Err(fail(
508            inputs.io,
509            should_take_over_stdout,
510            "Error: @file arguments are not supported in RPC mode",
511        ));
512    }
513    let flag_error = validate_fork_flags(&parsed)
514        .err()
515        .or_else(|| validate_session_id_flags(&parsed).err());
516    if let Some(err) = flag_error {
517        return Err(fail(
518            inputs.io,
519            should_take_over_stdout,
520            &format!("Error: {}", err.message),
521        ));
522    }
523
524    let cwd = inputs.io.cwd().to_string_lossy().into_owned();
525    let migrations = migrations::run_migrations(Path::new(&cwd));
526    let agent_dir = resolve_agent_dir(inputs.io);
527    let session_dir = resolve_session_dir(&parsed, inputs.io);
528    let mut session_manager =
529        build_session_manager(&parsed, &cwd, session_dir.as_deref(), app_mode)
530            .await
531            .map_err(|message| {
532                fail(
533                    inputs.io,
534                    should_take_over_stdout,
535                    &format!("Error: {message}"),
536                )
537            })?;
538
539    if let Some(name) = parsed.name.as_ref() {
540        let trimmed = name.trim();
541        if trimmed.is_empty() {
542            return Err(fail(
543                inputs.io,
544                should_take_over_stdout,
545                "Error: --name requires a non-empty value",
546            ));
547        }
548        session_manager
549            .append_session_info(trimmed)
550            .map_err(|err| fail(inputs.io, should_take_over_stdout, &format!("Error: {err}")))?;
551    }
552
553    Ok(PreparedSession {
554        parsed,
555        app_mode,
556        should_take_over_stdout,
557        cwd,
558        migrations,
559        agent_dir,
560        session_manager,
561    })
562}
563
564async fn create_runtime(
565    inputs: &BootstrapInputs<'_>,
566    prepared: PreparedSession,
567) -> BootstrapStep<(BootstrapState, RuntimeHandle)> {
568    let PreparedSession {
569        parsed,
570        app_mode,
571        should_take_over_stdout,
572        cwd,
573        migrations,
574        agent_dir,
575        session_manager,
576    } = prepared;
577    let handle = inputs
578        .factory
579        .create(RuntimeFactoryOptions {
580            cwd: session_manager.get_cwd().to_owned(),
581            agent_dir,
582            session_manager,
583            parsed: parsed.clone(),
584        })
585        .await
586        .map_err(|message| {
587            fail(
588                inputs.io,
589                should_take_over_stdout,
590                &format!("Error: {message}"),
591            )
592        })?;
593
594    let diagnostics = handle.runtime.diagnostics();
595    let has_error = diagnostics.iter().any(|diagnostic| {
596        diagnostic.kind
597            == crate::core::agent_session_services::AgentSessionRuntimeDiagnosticKind::Error
598    });
599    let has_extension_load_error = diagnostics.iter().any(|diagnostic| {
600        diagnostic.kind
601            == crate::core::agent_session_services::AgentSessionRuntimeDiagnosticKind::Error
602            && diagnostic.message.contains("Failed to load extension")
603    });
604    for diagnostic in &diagnostics {
605        let label = match diagnostic.kind {
606            crate::core::agent_session_services::AgentSessionRuntimeDiagnosticKind::Info => "Info",
607            crate::core::agent_session_services::AgentSessionRuntimeDiagnosticKind::Warning => {
608                "Warning"
609            }
610            crate::core::agent_session_services::AgentSessionRuntimeDiagnosticKind::Error => {
611                "Error"
612            }
613        };
614        inputs
615            .io
616            .write_stderr(&format!("{label}: {}", diagnostic.message));
617    }
618    if has_error {
619        if has_extension_load_error {
620            inputs
621                .io
622                .write_stderr("Hint: Start without extensions using \"pi -ne\".");
623        }
624        if should_take_over_stdout {
625            output_guard::restore_stdout();
626        }
627        return Err(stop(1, false));
628    }
629
630    Ok((
631        BootstrapState {
632            parsed,
633            app_mode,
634            should_take_over_stdout,
635            cwd,
636            migrations,
637        },
638        handle,
639    ))
640}
641
642async fn handle_runtime_metadata(
643    io: &dyn BootstrapIo,
644    state: &BootstrapState,
645    handle: &RuntimeHandle,
646) -> Option<BootstrapOutcome> {
647    if state.parsed.help {
648        let extension_flags: Vec<crate::cli::help::ExtensionFlagHelp> =
649            collect_extension_flags(&handle.runtime);
650        let text = crate::cli::help::format_help(
651            Some(&extension_flags),
652            crate::cli::help::HelpStyle { styled: false },
653        );
654        io.write_stdout(&text);
655        if state.should_take_over_stdout {
656            output_guard::restore_stdout();
657        }
658        return Some(exit(0, false));
659    }
660    if matches!(state.parsed.list_models, ListModels::None) {
661        return None;
662    }
663    if state.should_take_over_stdout {
664        output_guard::restore_stdout();
665    }
666    let pattern = match &state.parsed.list_models {
667        ListModels::Search(search) => Some(search.to_lowercase()),
668        ListModels::All | ListModels::None => None,
669    };
670    let model_runtime = handle.runtime.session().model_runtime_handle();
671    if let Some(model_runtime) = model_runtime
672        && let Ok(models) = model_runtime.get_available(None).await
673    {
674        let mut rows: Vec<(String, String, String)> = Vec::new();
675        for model in &models {
676            if let Some(search) = &pattern
677                && !model.provider.to_lowercase().contains(search)
678                && !model.id.to_lowercase().contains(search)
679            {
680                continue;
681            }
682            rows.push((model.provider.clone(), model.id.clone(), model.name.clone()));
683        }
684        rows.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
685        for (provider, id, name) in &rows {
686            io.write_stdout(&format!("{provider}/{id} {name}"));
687        }
688        if let Some(message) = model_runtime.get_error() {
689            io.write_stderr(&format!("Warning: {message}"));
690        }
691    }
692    Some(exit(0, false))
693}
694
695async fn finish_bootstrap(
696    io: &dyn BootstrapIo,
697    mut state: BootstrapState,
698    handle: RuntimeHandle,
699) -> BootstrapOutcome {
700    let stdin_content = if state.app_mode.is_rpc() {
701        None
702    } else {
703        match io.read_piped_stdin().await {
704            Ok(content) => content,
705            Err(err) => {
706                return fail(
707                    io,
708                    state.should_take_over_stdout,
709                    &format!("Error: failed to read stdin: {err}"),
710                )
711                .into_outcome();
712            }
713        }
714    };
715    if stdin_content.is_some() && state.app_mode.is_interactive() {
716        state.app_mode = AppMode::Print;
717    }
718    let (initial_message, initial_images, remaining_messages) = match prepare_initial_message(
719        &mut state.parsed,
720        stdin_content.as_deref(),
721        &handle.runtime,
722        &state.cwd,
723    )
724    .await
725    {
726        Ok(prepared) => prepared,
727        Err(err) => {
728            return fail(io, state.should_take_over_stdout, &format!("Error: {err}"))
729                .into_outcome();
730        }
731    };
732
733    if !state.app_mode.is_interactive() {
734        let session = handle.runtime.session();
735        if session.model().provider == "unknown" {
736            return fail(
737                io,
738                state.should_take_over_stdout,
739                &crate::core::agent_session_services::format_no_models_available_message(),
740            )
741            .into_outcome();
742        }
743    }
744    if is_truthy_env_flag(io.env("PI_STARTUP_BENCHMARK").as_deref())
745        && !state.app_mode.is_interactive()
746    {
747        return fail(
748            io,
749            state.should_take_over_stdout,
750            "Error: PI_STARTUP_BENCHMARK only supports interactive mode",
751        )
752        .into_outcome();
753    }
754
755    BootstrapOutcome::Dispatch(Dispatched {
756        mode: state.app_mode,
757        handle,
758        initial_message,
759        initial_images,
760        remaining_messages,
761        migrations: state.migrations,
762    })
763}
764
765/// Report parse diagnostics to stderr (no exit decision here).
766fn report_diagnostics(parsed: &Args, io: &dyn BootstrapIo) {
767    for d in &parsed.diagnostics {
768        let label = match d.level {
769            DiagnosticLevel::Error => "Error",
770            DiagnosticLevel::Warning => "Warning",
771        };
772        io.write_stderr(&format!("{label}: {}", d.message));
773    }
774}
775
776/// Resolve the agent dir from process env (`PI_CODING_AGENT_DIR`) or home.
777///
778/// Reads the real process environment via [`crate::core::config::get_agent_dir`]
779/// so the path matches what the settings/session managers resolve
780/// independently. Tests that need a specific agent dir set the env var
781/// before invoking the bootstrap.
782fn resolve_agent_dir(_io: &dyn BootstrapIo) -> String {
783    crate::core::config::get_agent_dir()
784        .to_string_lossy()
785        .into_owned()
786}
787
788/// Resolve the session directory from `--session-dir`, env, or settings.
789fn resolve_session_dir(parsed: &Args, io: &dyn BootstrapIo) -> Option<String> {
790    if let Some(dir) = parsed.session_dir.as_ref() {
791        let normalized =
792            crate::core::config::normalize_path(dir, crate::core::config::PathInputOptions::new());
793        return Some(normalized.to_string_lossy().into_owned());
794    }
795    if let Some(env_dir) = io.env(ENV_SESSION_DIR) {
796        return Some(expand_tilde_path(&env_dir).to_string_lossy().into_owned());
797    }
798    None
799}
800
801/// Build the [`SessionManager`] using the same branching as
802/// `createSessionManager` (`main.ts:263-354`).
803///
804/// The interactive `--resume`/global-search paths require UI selectors owned
805/// by a sibling slice; for now those paths fall back to an in-memory manager
806/// with the requested id so the runtime still builds. The integrator wires the
807/// real selector when the TUI lands.
808async fn build_session_manager(
809    parsed: &Args,
810    cwd: &str,
811    session_dir: Option<&str>,
812    app_mode: AppMode,
813) -> Result<SessionManager, String> {
814    let id_opt = parsed.session_id.as_deref();
815
816    if parsed.no_session || parsed.help || !matches!(parsed.list_models, ListModels::None) {
817        return SessionManager::in_memory(Some(cwd), id_opt.map(sessions_id_opt))
818            .map_err(|e| e.to_string());
819    }
820
821    if let Some(fork_arg) = parsed.fork.as_deref() {
822        if let Some(id) = id_opt
823            && find_local_session_by_exact_id(id, cwd, session_dir)
824                .await
825                .is_some()
826        {
827            return Err(format!("Session already exists with id '{id}'"));
828        }
829        let resolved = resolve_session_path(fork_arg, cwd, session_dir).await;
830        let path = match resolved {
831            ResolvedSession::Path(path) => path,
832            ResolvedSession::NotFound(arg) => {
833                return Err(format!("No session found matching '{arg}'"));
834            }
835        };
836        return SessionManager::fork_from(&path, cwd, session_dir, id_opt.map(sessions_id_opt))
837            .map_err(|e| e.to_string());
838    }
839
840    if let Some(session_arg) = parsed.session.as_deref() {
841        let resolved = resolve_session_path(session_arg, cwd, session_dir).await;
842        let path = match resolved {
843            ResolvedSession::Path(path) => path,
844            ResolvedSession::NotFound(arg) => {
845                return Err(format!("No session found matching '{arg}'"));
846            }
847        };
848        return SessionManager::open(&path, session_dir, None).map_err(|e| e.to_string());
849    }
850
851    if parsed.resume {
852        let _ = app_mode;
853        return SessionManager::continue_recent(cwd, session_dir).map_err(|e| e.to_string());
854    }
855
856    if parsed.r#continue {
857        return SessionManager::continue_recent(cwd, session_dir).map_err(|e| e.to_string());
858    }
859
860    if let Some(id) = id_opt
861        && let Some(path) = find_local_session_by_exact_id(id, cwd, session_dir).await
862    {
863        return SessionManager::open(&path, session_dir, None).map_err(|e| e.to_string());
864    }
865
866    SessionManager::create(cwd, session_dir, id_opt.map(sessions_id_opt)).map_err(|e| e.to_string())
867}
868
869fn sessions_id_opt(id: &str) -> crate::core::sessions::NewSessionOptions {
870    crate::core::sessions::NewSessionOptions {
871        id: Some(id.to_owned()),
872        parent_session: None,
873    }
874}
875
876/// Result of resolving a session argument to a file path.
877#[derive(Clone, Debug)]
878enum ResolvedSession {
879    Path(String),
880    NotFound(String),
881}
882
883/// Resolve a session argument to a file path.
884///
885/// Path-like arguments retain explicit filesystem semantics. Bare arguments
886/// must identify an existing session in the current project.
887async fn resolve_session_path(arg: &str, cwd: &str, session_dir: Option<&str>) -> ResolvedSession {
888    if arg.contains('/') || arg.contains('\\') || has_jsonl_extension(arg) {
889        let resolved = crate::core::config::resolve_path_with(
890            arg,
891            Path::new(cwd),
892            crate::core::config::PathInputOptions::new(),
893        );
894        return ResolvedSession::Path(resolved.to_string_lossy().into_owned());
895    }
896    match find_local_session_by_exact_id(arg, cwd, session_dir).await {
897        Some(path) => ResolvedSession::Path(path),
898        None => ResolvedSession::NotFound(arg.to_owned()),
899    }
900}
901
902fn has_jsonl_extension(path: &str) -> bool {
903    path.as_bytes()
904        .get(path.len().saturating_sub(6)..)
905        .is_some_and(|extension| extension.eq_ignore_ascii_case(b".jsonl"))
906}
907
908/// Match a session ID exactly in the current project.
909///
910/// `list_sessions_for_cwd` is async, so this helper is async too.
911async fn find_local_session_by_exact_id(
912    session_id: &str,
913    cwd: &str,
914    session_dir: Option<&str>,
915) -> Option<String> {
916    find_local_session_by_exact_id_in_agent_dir(
917        session_id,
918        cwd,
919        session_dir,
920        &crate::core::config::get_agent_dir(),
921    )
922    .await
923}
924
925async fn find_local_session_by_exact_id_in_agent_dir(
926    session_id: &str,
927    cwd: &str,
928    session_dir: Option<&str>,
929    agent_dir: &Path,
930) -> Option<String> {
931    let default_dir = crate::core::sessions::default_session_dir_path(cwd, agent_dir);
932    let session_dir = session_dir.map_or_else(
933        || PathBuf::from(&default_dir),
934        |dir| {
935            crate::core::config::normalize_path(dir, crate::core::config::PathInputOptions::new())
936        },
937    );
938    let filter_cwd = session_dir != Path::new(&default_dir);
939    let sessions =
940        crate::core::sessions::list_sessions_for_cwd(cwd, &session_dir, filter_cwd, None).await;
941    sessions
942        .into_iter()
943        .find(|session| session.id.as_deref() == Some(session_id))
944        .map(|session| session.path)
945}
946
947/// Run `--export` via the shared export engine.
948fn run_export(input_path: &str, output_path: Option<&String>) -> Result<(), String> {
949    let result = crate::core::export_html::export_from_file(
950        input_path,
951        crate::core::export_html::ExportOptions {
952            output_path: output_path.map(PathBuf::from),
953            ..Default::default()
954        },
955    )
956    .map_err(|e| e.to_string())?;
957    ProductOutput::writeln(&format!("Exported to: {result}"));
958    Ok(())
959}
960
961/// Collect extension CLI flag metadata from the runtime's resource loader.
962///
963/// Returns an empty list until the resource loader exposes its flag map; the
964/// `--help` block still renders the canonical options.
965fn collect_extension_flags(
966    runtime: &std::sync::Arc<AgentSessionRuntime>,
967) -> Vec<crate::cli::help::ExtensionFlagHelp> {
968    let session = runtime.session();
969    let Some(host_runner) = session.host_extension_runner() else {
970        return Vec::new();
971    };
972    let registry = host_runner.registry();
973    registry
974        .flags()
975        .iter()
976        .map(|flag| {
977            let extension_path = match flag.extension_path.as_deref() {
978                Some(path) if !path.is_empty() => path.to_owned(),
979                _ => "<extension>".to_owned(),
980            };
981            crate::cli::help::ExtensionFlagHelp {
982                name: flag.name.clone(),
983                description: flag.description.clone(),
984                takes_value: matches!(flag.kind, pi_ext::adapters::FlagKind::String),
985                extension_path,
986            }
987        })
988        .collect()
989}
990
991/// Prepare the initial message and split remaining CLI messages.
992///
993/// Expands `@file` arguments through the shared image pipeline using the
994/// runtime's `images.autoResize` setting, then merges with stdin and the
995/// first CLI message in the same join order as the TypeScript reference.
996async fn prepare_initial_message(
997    parsed: &mut Args,
998    stdin_content: Option<&str>,
999    runtime: &std::sync::Arc<AgentSessionRuntime>,
1000    cwd: &str,
1001) -> Result<(Option<String>, Vec<pi_ai::ImageContent>, Vec<String>), String> {
1002    if parsed.file_args.is_empty() {
1003        let prompt = crate::modes::print::build_initial_message(
1004            &mut parsed.messages,
1005            stdin_content,
1006            None,
1007            Vec::new(),
1008        );
1009        return Ok((
1010            prompt.initial_message,
1011            prompt.initial_images,
1012            parsed.messages.clone(),
1013        ));
1014    }
1015    let session = runtime.session();
1016    let auto_resize = session.lock_settings().get_image_auto_resize();
1017    let files = crate::modes::print::process_file_arguments(
1018        &parsed.file_args,
1019        cwd,
1020        crate::modes::print::ProcessFileOptions {
1021            auto_resize_images: auto_resize,
1022        },
1023    )
1024    .await
1025    .map_err(|err| err.to_string())?;
1026    let prompt = crate::modes::print::build_initial_message(
1027        &mut parsed.messages,
1028        stdin_content,
1029        Some(&files.text),
1030        files.images,
1031    );
1032    Ok((
1033        prompt.initial_message,
1034        prompt.initial_images,
1035        parsed.messages.clone(),
1036    ))
1037}
1038
1039/// Detect the current dispatch platform.
1040fn current_platform() -> DispatchPlatform {
1041    if cfg!(target_os = "windows") {
1042        DispatchPlatform::Windows
1043    } else {
1044        DispatchPlatform::Unix
1045    }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050    use super::*;
1051    use std::sync::Arc;
1052    use std::sync::{Mutex, MutexGuard};
1053
1054    fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
1055        match mutex.lock() {
1056            Ok(guard) => guard,
1057            Err(poisoned) => poisoned.into_inner(),
1058        }
1059    }
1060
1061    fn validation_error(
1062        result: Result<(), FlagValidationError>,
1063        context: &str,
1064    ) -> Result<FlagValidationError, String> {
1065        match result {
1066            Ok(()) => Err(format!("{context}: validation unexpectedly succeeded")),
1067            Err(error) => Ok(error),
1068        }
1069    }
1070
1071    fn assert_exit_code(outcome: BootstrapOutcome, expected: u8) -> Result<(), String> {
1072        match outcome {
1073            BootstrapOutcome::Exit { code, .. } => {
1074                assert_eq!(code, expected);
1075                Ok(())
1076            }
1077            BootstrapOutcome::Dispatch(dispatched) => Err(format!(
1078                "expected Exit with code {expected}, got Dispatch({:?})",
1079                dispatched.mode
1080            )),
1081        }
1082    }
1083
1084    fn args_vec(tokens: &[&str]) -> Vec<String> {
1085        tokens.iter().map(|s| (*s).to_owned()).collect()
1086    }
1087
1088    fn write_session_header(
1089        path: &Path,
1090        id: &str,
1091        cwd: &str,
1092    ) -> Result<(), Box<dyn std::error::Error>> {
1093        let header = crate::core::sessions::SessionHeader::new(
1094            id.to_owned(),
1095            "2026-01-01T00:00:00.000Z".to_owned(),
1096            cwd.to_owned(),
1097            None,
1098        );
1099        std::fs::write(path, format!("{}\n", serde_json::to_string(&header)?))?;
1100        Ok(())
1101    }
1102
1103    /// In-memory I/O surface for tests.
1104    #[derive(Default)]
1105    struct FakeIo {
1106        env: Mutex<std::collections::HashMap<String, String>>,
1107        stdout: Mutex<Vec<String>>,
1108        stderr: Mutex<Vec<String>>,
1109        stdin_payload: Option<String>,
1110        stdin_is_tty: bool,
1111        stdout_is_tty: bool,
1112        cwd: PathBuf,
1113    }
1114
1115    impl BootstrapIo for Arc<FakeIo> {
1116        fn env(&self, key: &str) -> Option<String> {
1117            lock_recover(&self.env).get(key).cloned()
1118        }
1119        fn set_env(&self, key: &str, value: &str) {
1120            lock_recover(&self.env).insert(key.to_owned(), value.to_owned());
1121        }
1122        fn cwd(&self) -> PathBuf {
1123            self.cwd.clone()
1124        }
1125        fn stdin_is_tty(&self) -> bool {
1126            self.stdin_is_tty
1127        }
1128        fn stdout_is_tty(&self) -> bool {
1129            self.stdout_is_tty
1130        }
1131        fn read_piped_stdin<'a>(
1132            &'a self,
1133        ) -> Pin<Box<dyn Future<Output = io::Result<Option<String>>> + Send + 'a>> {
1134            Box::pin(async move { Ok(self.stdin_payload.clone()) })
1135        }
1136        fn write_stdout(&self, line: &str) {
1137            lock_recover(&self.stdout).push(line.to_owned());
1138        }
1139        fn write_stderr(&self, line: &str) {
1140            lock_recover(&self.stderr).push(line.to_owned());
1141        }
1142    }
1143
1144    impl FakeIo {
1145        fn new() -> Self {
1146            let mut env = std::collections::HashMap::new();
1147            env.insert(
1148                "PI_CODING_AGENT_DIR".to_owned(),
1149                std::env::temp_dir()
1150                    .join("pi-bootstrap-test")
1151                    .to_string_lossy()
1152                    .into_owned(),
1153            );
1154            Self {
1155                env: Mutex::new(env),
1156                stdout: Mutex::new(Vec::new()),
1157                stderr: Mutex::new(Vec::new()),
1158                stdin_payload: None,
1159                stdin_is_tty: true,
1160                stdout_is_tty: true,
1161                cwd: std::env::temp_dir(),
1162            }
1163        }
1164
1165        fn stdout_lines(&self) -> Vec<String> {
1166            lock_recover(&self.stdout).clone()
1167        }
1168
1169        fn stderr_lines(&self) -> Vec<String> {
1170            lock_recover(&self.stderr).clone()
1171        }
1172    }
1173
1174    /// Fake runtime factory. Defaults to returning a fixed error so short-circuit
1175    /// tests can assert factory failures; set `succeed = true` when a real
1176    /// runtime handle is required (e.g. `PI_STARTUP_BENCHMARK` guard).
1177    #[derive(Default)]
1178    struct FakeFactory {
1179        calls: Mutex<Vec<String>>,
1180        supports_interactive: bool,
1181        /// When true, build a lightweight runtime with a non-`unknown` model.
1182        succeed: bool,
1183    }
1184
1185    #[derive(Clone)]
1186    struct StubProvider;
1187
1188    impl pi_ai::Provider for StubProvider {
1189        fn stream(
1190            &self,
1191            _model: &pi_ai::Model,
1192            _context: pi_ai::Context,
1193            _options: pi_ai::StreamOptions,
1194        ) -> futures::stream::BoxStream<
1195            'static,
1196            Result<pi_ai::AssistantMessageEvent, pi_ai::ProviderError>,
1197        > {
1198            Box::pin(futures::stream::empty())
1199        }
1200    }
1201
1202    fn fake_runtime_model() -> pi_ai::Model {
1203        pi_ai::Model {
1204            id: "m".to_owned(),
1205            name: "m".to_owned(),
1206            api: "test-api".to_owned(),
1207            provider: "test-provider".to_owned(),
1208            base_url: String::new(),
1209            reasoning: false,
1210            thinking_level_map: None,
1211            input: vec![pi_ai::ModelInput::Text],
1212            cost: pi_ai::ModelCost::default(),
1213            context_window: 8_192,
1214            max_tokens: 1_024,
1215            headers: None,
1216            compat: None,
1217            extra: std::collections::BTreeMap::new(),
1218        }
1219    }
1220
1221    #[derive(Default)]
1222    struct StubRuntimeFactory;
1223
1224    impl crate::core::agent_session_runtime::CreateAgentSessionRuntimeFactory for StubRuntimeFactory {
1225        fn create(
1226            &self,
1227            _options: crate::core::agent_session_runtime::CreateAgentSessionRuntimeOptions,
1228        ) -> BoxFuture<
1229            '_,
1230            Result<
1231                crate::core::agent_session_runtime::CreateAgentSessionRuntimeResult,
1232                crate::core::agent_session_runtime::AgentSessionRuntimeError,
1233            >,
1234        > {
1235            Box::pin(async {
1236                Err(
1237                    crate::core::agent_session_runtime::AgentSessionRuntimeError::Factory(
1238                        "stub runtime factory is not expected to create replacements".to_owned(),
1239                    ),
1240                )
1241            })
1242        }
1243    }
1244
1245    impl RuntimeFactory for Arc<FakeFactory> {
1246        fn create(
1247            &self,
1248            options: RuntimeFactoryOptions,
1249        ) -> BoxFuture<'_, Result<RuntimeHandle, String>> {
1250            lock_recover(&self.calls).push(format!(
1251                "create:{}:{}",
1252                options.cwd,
1253                options.session_manager.get_session_id()
1254            ));
1255            let succeed = self.succeed;
1256            Box::pin(async move {
1257                if !succeed {
1258                    return Err("__fake_factory_unavailable__".to_owned());
1259                }
1260                let config = crate::core::agent_session::AgentSessionConfig::test_config(
1261                    Arc::new(StubProvider),
1262                    fake_runtime_model(),
1263                )
1264                .map_err(|e| e.to_string())?;
1265                let session = crate::core::agent_session::AgentSession::new(config)
1266                    .map_err(|e| e.to_string())?;
1267                let runtime = AgentSessionRuntime::new(
1268                    session,
1269                    crate::core::agent_session_runtime::AgentSessionRuntimeServices {
1270                        cwd: PathBuf::from(options.cwd),
1271                        agent_dir: PathBuf::from(options.agent_dir),
1272                    },
1273                    Arc::new(StubRuntimeFactory),
1274                    Vec::new(),
1275                    None,
1276                );
1277                Ok(RuntimeHandle {
1278                    runtime: Arc::new(runtime),
1279                })
1280            })
1281        }
1282        fn supports_interactive(&self) -> bool {
1283            self.supports_interactive
1284        }
1285    }
1286
1287    #[derive(Default, Clone)]
1288    struct CapturedOutput {
1289        inner: Arc<Mutex<CapturedInner>>,
1290    }
1291
1292    #[derive(Default)]
1293    struct CapturedInner {
1294        status: Vec<String>,
1295        status_dim: Vec<String>,
1296        success: Vec<String>,
1297        error: Vec<String>,
1298    }
1299
1300    impl PackageOutput for CapturedOutput {
1301        fn status(&self, line: &str) {
1302            lock_recover(&self.inner).status.push(line.to_owned());
1303        }
1304        fn status_dim(&self, line: &str) {
1305            lock_recover(&self.inner).status_dim.push(line.to_owned());
1306        }
1307        fn success(&self, line: &str) {
1308            lock_recover(&self.inner).success.push(line.to_owned());
1309        }
1310        fn error(&self, line: &str) {
1311            lock_recover(&self.inner).error.push(line.to_owned());
1312        }
1313    }
1314
1315    #[derive(Default, Clone)]
1316    struct FakePackageHandler {
1317        trusted: bool,
1318        calls: Arc<Mutex<Vec<String>>>,
1319    }
1320
1321    impl PackageHandler for FakePackageHandler {
1322        fn install(&self, source: &str, local: bool) -> Result<(), String> {
1323            lock_recover(&self.calls).push(format!("install:{source}:{local}"));
1324            Ok(())
1325        }
1326        fn remove(&self, source: &str, local: bool) -> Result<bool, String> {
1327            lock_recover(&self.calls).push(format!("remove:{source}:{local}"));
1328            Ok(true)
1329        }
1330        fn list(&self) -> Result<Vec<package_manager_cli::ListedPackage>, String> {
1331            lock_recover(&self.calls).push("list".to_owned());
1332            Ok(Vec::new())
1333        }
1334        fn is_project_trusted(&self) -> bool {
1335            self.trusted
1336        }
1337        fn refresh_models(&self) -> Result<(), String> {
1338            Ok(())
1339        }
1340        fn update_extensions(&self, source: Option<&str>) -> Result<(), String> {
1341            lock_recover(&self.calls).push(format!("ext:{source:?}"));
1342            Ok(())
1343        }
1344        fn update_self(&self, force: bool) -> Result<bool, String> {
1345            lock_recover(&self.calls).push(format!("self:{force}"));
1346            Ok(false)
1347        }
1348    }
1349
1350    // ----- resolve_app_mode tests -----------------------------------------
1351
1352    #[test]
1353    fn resolve_app_mode_rpc_forces_non_interactive() {
1354        let parsed = crate::cli::args::parse_args(&args_vec(&["--mode", "rpc"]));
1355        assert_eq!(resolve_app_mode(&parsed, true, true), AppMode::Rpc);
1356    }
1357
1358    #[test]
1359    fn resolve_app_mode_json_forces_non_interactive() {
1360        let parsed = crate::cli::args::parse_args(&args_vec(&["--mode", "json"]));
1361        assert_eq!(resolve_app_mode(&parsed, true, true), AppMode::Json);
1362    }
1363
1364    #[test]
1365    fn resolve_app_mode_text_with_dual_tty_is_interactive() {
1366        let parsed = crate::cli::args::parse_args(&args_vec(&["--mode", "text"]));
1367        assert_eq!(resolve_app_mode(&parsed, true, true), AppMode::Interactive);
1368    }
1369
1370    #[test]
1371    fn resolve_app_mode_print_flag_demotes() {
1372        let parsed = crate::cli::args::parse_args(&args_vec(&["--print"]));
1373        assert_eq!(resolve_app_mode(&parsed, true, true), AppMode::Print);
1374    }
1375
1376    #[test]
1377    fn resolve_app_mode_non_tty_stdin_demotes() {
1378        let parsed = crate::cli::args::parse_args(&args_vec(&[]));
1379        assert_eq!(resolve_app_mode(&parsed, false, true), AppMode::Print);
1380    }
1381
1382    #[test]
1383    fn resolve_app_mode_non_tty_stdout_demotes() {
1384        let parsed = crate::cli::args::parse_args(&args_vec(&[]));
1385        assert_eq!(resolve_app_mode(&parsed, true, false), AppMode::Print);
1386    }
1387
1388    // ----- is_plain_runtime_metadata_command tests -----------------------
1389
1390    #[test]
1391    fn plain_metadata_for_help_without_mode() {
1392        let parsed = crate::cli::args::parse_args(&args_vec(&["--help"]));
1393        assert!(is_plain_runtime_metadata_command(&parsed));
1394    }
1395
1396    #[test]
1397    fn plain_metadata_for_list_models_without_mode() {
1398        let parsed = crate::cli::args::parse_args(&args_vec(&["--list-models"]));
1399        assert!(is_plain_runtime_metadata_command(&parsed));
1400    }
1401
1402    #[test]
1403    fn plain_metadata_false_when_print_set() {
1404        let parsed = crate::cli::args::parse_args(&args_vec(&["--print", "--help"]));
1405        assert!(!is_plain_runtime_metadata_command(&parsed));
1406    }
1407
1408    #[test]
1409    fn plain_metadata_false_when_mode_set() {
1410        let parsed = crate::cli::args::parse_args(&args_vec(&["--mode", "json", "--help"]));
1411        assert!(!is_plain_runtime_metadata_command(&parsed));
1412    }
1413
1414    // ----- validators ------------------------------------------------------
1415
1416    #[test]
1417    fn fork_conflicts_with_session() -> Result<(), String> {
1418        let parsed = crate::cli::args::parse_args(&args_vec(&["--fork", "abc", "--session", "x"]));
1419        let err = validation_error(validate_fork_flags(&parsed), "fork with session")?;
1420        assert!(
1421            err.message
1422                .contains("--fork cannot be combined with --session")
1423        );
1424        Ok(())
1425    }
1426
1427    #[test]
1428    fn fork_conflicts_with_continue_and_resume() -> Result<(), String> {
1429        let parsed =
1430            crate::cli::args::parse_args(&args_vec(&["--fork", "abc", "--continue", "--resume"]));
1431        let err = validation_error(
1432            validate_fork_flags(&parsed),
1433            "fork with continue and resume",
1434        )?;
1435        assert!(err.message.contains("--continue, --resume"));
1436        Ok(())
1437    }
1438
1439    #[test]
1440    fn fork_alone_is_ok() {
1441        let parsed = crate::cli::args::parse_args(&args_vec(&["--fork", "abc"]));
1442        assert!(validate_fork_flags(&parsed).is_ok());
1443    }
1444
1445    #[test]
1446    fn session_id_conflicts_with_continue() -> Result<(), String> {
1447        let parsed =
1448            crate::cli::args::parse_args(&args_vec(&["--session-id", "xyz", "--continue"]));
1449        let err = validation_error(
1450            validate_session_id_flags(&parsed),
1451            "session id with continue",
1452        )?;
1453        assert!(
1454            err.message
1455                .contains("--session-id cannot be combined with --continue")
1456        );
1457        Ok(())
1458    }
1459
1460    #[test]
1461    fn session_id_alone_is_ok() {
1462        let parsed = crate::cli::args::parse_args(&args_vec(&["--session-id", "xyz"]));
1463        assert!(validate_session_id_flags(&parsed).is_ok());
1464    }
1465
1466    #[test]
1467    fn name_rejects_empty_value() -> Result<(), String> {
1468        let parsed = crate::cli::args::parse_args(&args_vec(&["--name", ""]));
1469        let err = validation_error(validate_name(&parsed), "empty name")?;
1470        assert_eq!(err.message, "--name requires a non-empty value");
1471        Ok(())
1472    }
1473
1474    #[test]
1475    fn name_rejects_whitespace_only_value() {
1476        let parsed = crate::cli::args::parse_args(&args_vec(&["--name", "   "]));
1477        assert!(validate_name(&parsed).is_err());
1478    }
1479
1480    #[test]
1481    fn name_accepts_non_empty_value() {
1482        let parsed = crate::cli::args::parse_args(&args_vec(&["--name", "my session"]));
1483        assert!(validate_name(&parsed).is_ok());
1484    }
1485
1486    // ----- is_truthy_env_flag ---------------------------------------------
1487
1488    #[test]
1489    fn truthy_env_flag_values() {
1490        assert!(!is_truthy_env_flag(None));
1491        assert!(is_truthy_env_flag(Some("1")));
1492        assert!(is_truthy_env_flag(Some("true")));
1493        assert!(is_truthy_env_flag(Some("TRUE")));
1494        assert!(is_truthy_env_flag(Some("yes")));
1495        assert!(!is_truthy_env_flag(Some("0")));
1496        assert!(!is_truthy_env_flag(Some("false")));
1497        assert!(!is_truthy_env_flag(Some("")));
1498    }
1499
1500    // ----- bootstrap pipeline tests ---------------------------------------
1501
1502    #[tokio::test]
1503    async fn default_session_lookup_uses_agent_project_directory()
1504    -> Result<(), Box<dyn std::error::Error>> {
1505        let root = tempfile::tempdir()?;
1506        let cwd = root.path().join("project");
1507        let agent_dir = root.path().join("agent");
1508        std::fs::create_dir_all(&cwd)?;
1509        let session_dir = crate::core::sessions::default_session_dir_path(
1510            cwd.to_str().ok_or("cwd utf8")?,
1511            &agent_dir,
1512        );
1513        std::fs::create_dir_all(&session_dir)?;
1514        let session_file = Path::new(&session_dir).join("known.jsonl");
1515        write_session_header(
1516            &session_file,
1517            "known-session",
1518            cwd.to_str().ok_or("cwd utf8")?,
1519        )?;
1520
1521        let found = find_local_session_by_exact_id_in_agent_dir(
1522            "known-session",
1523            cwd.to_str().ok_or("cwd utf8")?,
1524            None,
1525            &agent_dir,
1526        )
1527        .await;
1528
1529        assert_eq!(found.as_deref(), session_file.to_str());
1530        Ok(())
1531    }
1532
1533    #[tokio::test]
1534    async fn unknown_bare_session_id_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
1535        let root = tempfile::tempdir()?;
1536        let cwd = root.path().join("project");
1537        let sessions = root.path().join("sessions");
1538        std::fs::create_dir_all(&cwd)?;
1539        std::fs::create_dir_all(&sessions)?;
1540        let parsed = crate::cli::args::parse_args(&args_vec(&["--session", "missing-session"]));
1541
1542        let Err(error) = build_session_manager(
1543            &parsed,
1544            cwd.to_str().ok_or("cwd utf8")?,
1545            Some(sessions.to_str().ok_or("sessions utf8")?),
1546            AppMode::Print,
1547        )
1548        .await
1549        else {
1550            return Err("unknown bare ID unexpectedly succeeded".into());
1551        };
1552
1553        assert_eq!(error, "No session found matching 'missing-session'");
1554        assert!(!cwd.join("missing-session").exists());
1555        Ok(())
1556    }
1557
1558    #[tokio::test]
1559    async fn explicit_missing_session_path_is_preserved() -> Result<(), Box<dyn std::error::Error>>
1560    {
1561        let root = tempfile::tempdir()?;
1562        let cwd = root.path().join("project");
1563        let sessions = root.path().join("sessions");
1564        std::fs::create_dir_all(&cwd)?;
1565        std::fs::create_dir_all(&sessions)?;
1566        let explicit = cwd.join("new-session.jsonl");
1567        let parsed = crate::cli::args::parse_args(&args_vec(&[
1568            "--session",
1569            explicit.to_str().ok_or("explicit utf8")?,
1570        ]));
1571
1572        let manager = build_session_manager(
1573            &parsed,
1574            cwd.to_str().ok_or("cwd utf8")?,
1575            Some(sessions.to_str().ok_or("sessions utf8")?),
1576            AppMode::Print,
1577        )
1578        .await?;
1579
1580        assert_eq!(manager.get_session_file(), explicit.to_str());
1581        Ok(())
1582    }
1583
1584    #[tokio::test]
1585    async fn fork_rejects_existing_session_id() -> Result<(), Box<dyn std::error::Error>> {
1586        let root = tempfile::tempdir()?;
1587        let cwd = root.path().join("project");
1588        let sessions = root.path().join("sessions");
1589        std::fs::create_dir_all(&cwd)?;
1590        std::fs::create_dir_all(&sessions)?;
1591        let cwd_str = cwd.to_str().ok_or("cwd utf8")?;
1592        let existing = sessions.join("existing.jsonl");
1593        let source = sessions.join("source.jsonl");
1594        write_session_header(&existing, "duplicate-id", cwd_str)?;
1595        write_session_header(&source, "source-id", cwd_str)?;
1596        let parsed = crate::cli::args::parse_args(&args_vec(&[
1597            "--fork",
1598            source.to_str().ok_or("source utf8")?,
1599            "--session-id",
1600            "duplicate-id",
1601        ]));
1602
1603        let Err(error) = build_session_manager(
1604            &parsed,
1605            cwd_str,
1606            Some(sessions.to_str().ok_or("sessions utf8")?),
1607            AppMode::Print,
1608        )
1609        .await
1610        else {
1611            return Err("fork target collision unexpectedly succeeded".into());
1612        };
1613
1614        assert_eq!(error, "Session already exists with id 'duplicate-id'");
1615        Ok(())
1616    }
1617
1618    #[tokio::test]
1619    async fn bootstrap_version_short_circuit() -> Result<(), String> {
1620        let io = Arc::new(FakeIo::new());
1621        let factory = Arc::new(FakeFactory::default());
1622        let pkg_out = CapturedOutput::default();
1623        let pkg = FakePackageHandler::default();
1624        let outcome = run_bootstrap(BootstrapInputs {
1625            args: args_vec(&["--version"]),
1626            io: &io,
1627            factory: &factory,
1628            package_handler: &pkg,
1629            package_output: &pkg_out,
1630        })
1631        .await;
1632        assert_exit_code(outcome, 0)?;
1633        let stdout = io.stdout_lines();
1634        assert!(!stdout.is_empty());
1635        assert!(stdout[0].chars().any(|c| c.is_ascii_digit()));
1636        assert!(lock_recover(&factory.calls).is_empty());
1637        Ok(())
1638    }
1639
1640    #[tokio::test]
1641    async fn bootstrap_help_short_circuit_before_factory() -> Result<(), String> {
1642        // --help should NOT reach the factory (parsed but no runtime needed
1643        // when help is the plain-metadata short-circuit at exit-time). Note:
1644        // the bootstrap currently builds the runtime THEN renders help with
1645        // extension flags, matching main.ts. So the factory IS called. This
1646        // test documents the behavior.
1647        let io = Arc::new(FakeIo::new());
1648        let factory = Arc::new(FakeFactory::default());
1649        let pkg_out = CapturedOutput::default();
1650        let pkg = FakePackageHandler::default();
1651        let outcome = run_bootstrap(BootstrapInputs {
1652            args: args_vec(&["--help"]),
1653            io: &io,
1654            factory: &factory,
1655            package_handler: &pkg,
1656            package_output: &pkg_out,
1657        })
1658        .await;
1659        // The fake factory returns an error, so the bootstrap exits 1 with
1660        // the error message. The point of this test is to document that the
1661        // factory is reached when help is requested (so extension flags can
1662        // be discovered).
1663        assert_exit_code(outcome, 1)?;
1664        assert!(!lock_recover(&factory.calls).is_empty());
1665        Ok(())
1666    }
1667
1668    #[tokio::test]
1669    async fn bootstrap_parse_error_exits_one() -> Result<(), String> {
1670        let io = Arc::new(FakeIo::new());
1671        let factory = Arc::new(FakeFactory::default());
1672        let pkg_out = CapturedOutput::default();
1673        let pkg = FakePackageHandler::default();
1674        let outcome = run_bootstrap(BootstrapInputs {
1675            args: args_vec(&["--name"]),
1676            io: &io,
1677            factory: &factory,
1678            package_handler: &pkg,
1679            package_output: &pkg_out,
1680        })
1681        .await;
1682        assert_exit_code(outcome, 1)?;
1683        let stderr = io.stderr_lines();
1684        assert!(stderr.iter().any(|s| s.contains("--name requires a value")));
1685        assert!(lock_recover(&factory.calls).is_empty());
1686        Ok(())
1687    }
1688
1689    #[tokio::test]
1690    async fn bootstrap_thinking_warning_does_not_exit() -> Result<(), String> {
1691        // A warning diagnostic should not short-circuit; the bootstrap should
1692        // continue past the parse-diagnostics step. The fake factory then
1693        // returns an error, so we still exit 1 — but the warning must appear
1694        // on stderr first.
1695        let io = Arc::new(FakeIo::new());
1696        let factory = Arc::new(FakeFactory::default());
1697        let pkg_out = CapturedOutput::default();
1698        let pkg = FakePackageHandler::default();
1699        let outcome = run_bootstrap(BootstrapInputs {
1700            args: args_vec(&["--thinking", "bogus"]),
1701            io: &io,
1702            factory: &factory,
1703            package_handler: &pkg,
1704            package_output: &pkg_out,
1705        })
1706        .await;
1707        assert_exit_code(outcome, 1)?;
1708        let stderr = io.stderr_lines();
1709        assert!(stderr.iter().any(|s| s.contains("Invalid thinking level")));
1710        Ok(())
1711    }
1712
1713    #[tokio::test]
1714    async fn bootstrap_rpc_with_file_arg_exits_one() -> Result<(), String> {
1715        let io = Arc::new(FakeIo::new());
1716        let factory = Arc::new(FakeFactory::default());
1717        let pkg_out = CapturedOutput::default();
1718        let pkg = FakePackageHandler::default();
1719        let outcome = run_bootstrap(BootstrapInputs {
1720            args: args_vec(&["--mode", "rpc", "@file.txt"]),
1721            io: &io,
1722            factory: &factory,
1723            package_handler: &pkg,
1724            package_output: &pkg_out,
1725        })
1726        .await;
1727        assert_exit_code(outcome, 1)?;
1728        let stderr = io.stderr_lines();
1729        assert!(
1730            stderr
1731                .iter()
1732                .any(|s| s.contains("@file arguments are not supported in RPC mode"))
1733        );
1734        Ok(())
1735    }
1736
1737    #[tokio::test]
1738    async fn bootstrap_missing_file_arg_exits_one() -> Result<(), String> {
1739        let io = Arc::new(FakeIo::new());
1740        let factory = Arc::new(FakeFactory {
1741            succeed: true,
1742            ..FakeFactory::default()
1743        });
1744        let pkg_out = CapturedOutput::default();
1745        let pkg = FakePackageHandler::default();
1746        let outcome = run_bootstrap(BootstrapInputs {
1747            args: args_vec(&["--print", "--no-session", "@missing-file-arg.txt"]),
1748            io: &io,
1749            factory: &factory,
1750            package_handler: &pkg,
1751            package_output: &pkg_out,
1752        })
1753        .await;
1754
1755        assert_exit_code(outcome, 1)?;
1756        assert!(
1757            io.stderr_lines()
1758                .iter()
1759                .any(|line| line.contains("missing-file-arg.txt"))
1760        );
1761        Ok(())
1762    }
1763
1764    #[tokio::test]
1765    async fn bootstrap_fork_conflict_exits_one() -> Result<(), String> {
1766        let io = Arc::new(FakeIo::new());
1767        let factory = Arc::new(FakeFactory::default());
1768        let pkg_out = CapturedOutput::default();
1769        let pkg = FakePackageHandler::default();
1770        let outcome = run_bootstrap(BootstrapInputs {
1771            args: args_vec(&["--fork", "abc", "--continue"]),
1772            io: &io,
1773            factory: &factory,
1774            package_handler: &pkg,
1775            package_output: &pkg_out,
1776        })
1777        .await;
1778        assert_exit_code(outcome, 1)?;
1779        let stderr = io.stderr_lines();
1780        assert!(
1781            stderr
1782                .iter()
1783                .any(|s| s.contains("--fork cannot be combined"))
1784        );
1785        Ok(())
1786    }
1787
1788    #[tokio::test]
1789    async fn bootstrap_package_command_short_circuits() -> Result<(), String> {
1790        let io = Arc::new(FakeIo::new());
1791        let factory = Arc::new(FakeFactory::default());
1792        let pkg_out = CapturedOutput::default();
1793        let pkg = FakePackageHandler::default();
1794        let outcome = run_bootstrap(BootstrapInputs {
1795            args: args_vec(&["list"]),
1796            io: &io,
1797            factory: &factory,
1798            package_handler: &pkg,
1799            package_output: &pkg_out,
1800        })
1801        .await;
1802        assert_exit_code(outcome, 0)?;
1803        assert!(lock_recover(&factory.calls).is_empty());
1804        assert!(!lock_recover(&pkg.calls).is_empty());
1805        Ok(())
1806    }
1807
1808    #[tokio::test]
1809    async fn bootstrap_sets_offline_env() {
1810        let io = Arc::new(FakeIo::new());
1811        let factory = Arc::new(FakeFactory::default());
1812        let pkg_out = CapturedOutput::default();
1813        let pkg = FakePackageHandler::default();
1814        let _ = run_bootstrap(BootstrapInputs {
1815            args: args_vec(&["--version", "--offline"]),
1816            io: &io,
1817            factory: &factory,
1818            package_handler: &pkg,
1819            package_output: &pkg_out,
1820        })
1821        .await;
1822        let env = lock_recover(&io.env);
1823        assert_eq!(env.get("PI_OFFLINE").map(String::as_str), Some("1"));
1824        assert_eq!(
1825            env.get("PI_SKIP_VERSION_CHECK").map(String::as_str),
1826            Some("1")
1827        );
1828    }
1829
1830    #[tokio::test]
1831    async fn bootstrap_export_missing_file_exits_one() -> Result<(), String> {
1832        let io = Arc::new(FakeIo::new());
1833        let factory = Arc::new(FakeFactory::default());
1834        let pkg_out = CapturedOutput::default();
1835        let pkg = FakePackageHandler::default();
1836        let outcome = run_bootstrap(BootstrapInputs {
1837            args: args_vec(&["--export", "/nonexistent/path/missing.jsonl"]),
1838            io: &io,
1839            factory: &factory,
1840            package_handler: &pkg,
1841            package_output: &pkg_out,
1842        })
1843        .await;
1844        assert_exit_code(outcome, 1)?;
1845        let stderr = io.stderr_lines();
1846        assert!(stderr.iter().any(|s| s.contains("Error: File not found")));
1847        Ok(())
1848    }
1849
1850    #[tokio::test]
1851    async fn bootstrap_unknown_short_flag_is_parse_error() -> Result<(), String> {
1852        let io = Arc::new(FakeIo::new());
1853        let factory = Arc::new(FakeFactory::default());
1854        let pkg_out = CapturedOutput::default();
1855        let pkg = FakePackageHandler::default();
1856        let outcome = run_bootstrap(BootstrapInputs {
1857            args: args_vec(&["-Z"]),
1858            io: &io,
1859            factory: &factory,
1860            package_handler: &pkg,
1861            package_output: &pkg_out,
1862        })
1863        .await;
1864        assert_exit_code(outcome, 1)?;
1865        let stderr = io.stderr_lines();
1866        assert!(stderr.iter().any(|s| s.contains("Unknown option: -Z")));
1867        Ok(())
1868    }
1869
1870    #[tokio::test]
1871    async fn bootstrap_factory_error_surfaces_verbatim() -> Result<(), String> {
1872        let io = Arc::new(FakeIo::new());
1873        let factory = Arc::new(FakeFactory::default());
1874        let pkg_out = CapturedOutput::default();
1875        let pkg = FakePackageHandler::default();
1876        let outcome = run_bootstrap(BootstrapInputs {
1877            args: args_vec(&["--no-session", "hello"]),
1878            io: &io,
1879            factory: &factory,
1880            package_handler: &pkg,
1881            package_output: &pkg_out,
1882        })
1883        .await;
1884        assert_exit_code(outcome, 1)?;
1885        let stderr = io.stderr_lines();
1886        assert!(
1887            stderr
1888                .iter()
1889                .any(|s| s.contains("__fake_factory_unavailable__"))
1890        );
1891        Ok(())
1892    }
1893
1894    #[tokio::test]
1895    async fn bootstrap_pi_startup_benchmark_guard() -> Result<(), String> {
1896        // PI_STARTUP_BENCHMARK is set, mode is print (because stdin not TTY
1897        // would also demote). Use --print to force print mode cleanly.
1898        let io_state = FakeIo::new();
1899        lock_recover(&io_state.env).insert("PI_STARTUP_BENCHMARK".to_owned(), "1".to_owned());
1900        let io = Arc::new(io_state);
1901        let factory = Arc::new(FakeFactory {
1902            succeed: true,
1903            ..FakeFactory::default()
1904        });
1905        let pkg_out = CapturedOutput::default();
1906        let pkg = FakePackageHandler::default();
1907        let outcome = run_bootstrap(BootstrapInputs {
1908            args: args_vec(&["--print", "hello"]),
1909            io: &io,
1910            factory: &factory,
1911            package_handler: &pkg,
1912            package_output: &pkg_out,
1913        })
1914        .await;
1915        assert_exit_code(outcome, 1)?;
1916        let stderr = io.stderr_lines();
1917        assert!(
1918            stderr
1919                .iter()
1920                .any(|s| s.contains("PI_STARTUP_BENCHMARK only supports interactive mode"))
1921        );
1922        Ok(())
1923    }
1924
1925    /// Flag snapshot the fake host serves for `extensions.load`.
1926    fn fake_flag_snapshot() -> serde_json::Value {
1927        serde_json::json!({
1928            "flags": [
1929                {
1930                    "name": "verbose-log",
1931                    "type": "boolean",
1932                    "description": "Enable verbose logging",
1933                    "extensionPath": "/plugins/logger"
1934                },
1935                {
1936                    "name": "api-url",
1937                    "type": "string",
1938                    "description": "Custom API URL",
1939                    "extensionPath": "/plugins/api"
1940                },
1941                {
1942                    "name": "fallback-flag",
1943                    "type": "boolean",
1944                    "extensionPath": "/plugins/fallback"
1945                },
1946                {
1947                    "name": "legacy-flag",
1948                    "type": "string"
1949                }
1950            ]
1951        })
1952    }
1953
1954    /// Build a test runtime, optionally binding a host extension runner.
1955    /// Propagates typed construction errors instead of panicking.
1956    fn build_test_runtime(
1957        runner: Option<Arc<crate::core::extension_host::HostExtensionRunner>>,
1958    ) -> Result<Arc<AgentSessionRuntime>, String> {
1959        let cwd = std::env::current_dir().map_err(|e| format!("current_dir: {e}"))?;
1960        let mut config = crate::core::agent_session::AgentSessionConfig::test_config(
1961            Arc::new(StubProvider),
1962            fake_runtime_model(),
1963        )
1964        .map_err(|e| format!("test_config: {e}"))?;
1965        config.host_extension_runner = runner;
1966        let session = crate::core::agent_session::AgentSession::new(config)
1967            .map_err(|e| format!("AgentSession::new: {e}"))?;
1968        Ok(Arc::new(AgentSessionRuntime::new(
1969            session,
1970            crate::core::agent_session_runtime::AgentSessionRuntimeServices {
1971                cwd: cwd.clone(),
1972                agent_dir: cwd,
1973            },
1974            Arc::new(StubRuntimeFactory),
1975            Vec::new(),
1976            None,
1977        )))
1978    }
1979
1980    /// Answer one fake-host request line, propagating encode/serialization
1981    /// errors instead of unwrapping.
1982    async fn serve_fake_host_line(
1983        line: &str,
1984        snapshot: &serde_json::Value,
1985        writer: &mut tokio::io::DuplexStream,
1986    ) -> Result<(), String> {
1987        use tokio::io::AsyncWriteExt;
1988        let req =
1989            pi_ext::protocol::decode_frame_str(line).map_err(|e| format!("decode request: {e}"))?;
1990        let payload = if req.method == "hello" {
1991            serde_json::to_value(pi_ext::protocol::HelloAck::local())
1992                .map_err(|e| format!("encode hello: {e}"))?
1993        } else if req.method == "extensions.load" {
1994            snapshot.clone()
1995        } else {
1996            serde_json::Value::Object(serde_json::Map::new())
1997        };
1998        let resp = pi_ext::protocol::Frame {
1999            id: req.id,
2000            kind: pi_ext::protocol::FrameKind::Res,
2001            method: req.method,
2002            payload,
2003        };
2004        let bytes =
2005            pi_ext::protocol::encode_frame(&resp).map_err(|e| format!("encode frame: {e}"))?;
2006        writer
2007            .write_all(&bytes)
2008            .await
2009            .map_err(|e| format!("write frame: {e}"))?;
2010        writer
2011            .flush()
2012            .await
2013            .map_err(|e| format!("flush frame: {e}"))?;
2014        Ok(())
2015    }
2016
2017    /// Spawn a fake extension host over a duplex pair. Encode/serialization
2018    /// failures are recorded into `errors` (never silently swallowed) and the
2019    /// task exits so the client observes EOF.
2020    fn spawn_fake_extension_host(
2021        host_from_client: tokio::io::DuplexStream,
2022        host_to_client: tokio::io::DuplexStream,
2023        snapshot: serde_json::Value,
2024        errors: Arc<Mutex<Vec<String>>>,
2025    ) -> tokio::task::JoinHandle<()> {
2026        use tokio::io::AsyncBufReadExt;
2027        tokio::spawn(async move {
2028            let mut reader = tokio::io::BufReader::new(host_from_client);
2029            let mut writer = host_to_client;
2030            let mut line = String::new();
2031            loop {
2032                line.clear();
2033                match reader.read_line(&mut line).await {
2034                    Ok(0) | Err(_) => break,
2035                    Ok(_) => {}
2036                }
2037                if let Err(message) = serve_fake_host_line(&line, &snapshot, &mut writer).await {
2038                    lock_recover(&errors).push(message);
2039                    break;
2040                }
2041            }
2042        })
2043    }
2044
2045    fn assert_extension_flag(
2046        flag: &crate::cli::help::ExtensionFlagHelp,
2047        name: &str,
2048        takes_value: bool,
2049        description: Option<&str>,
2050        extension_path: &str,
2051    ) {
2052        assert_eq!(flag.name, name);
2053        assert_eq!(flag.takes_value, takes_value);
2054        assert_eq!(flag.description.as_deref(), description);
2055        assert_eq!(flag.extension_path, extension_path);
2056    }
2057
2058    #[tokio::test]
2059    async fn test_collect_extension_flags() -> Result<(), String> {
2060        // 1. No runner → empty flag list.
2061        let runtime = build_test_runtime(None)?;
2062        assert!(
2063            collect_extension_flags(&runtime).is_empty(),
2064            "expected empty flags when no runner is present"
2065        );
2066
2067        // 2. Wire a fake host serving boolean/string flags with varied metadata.
2068        let (client_to_host, host_from_client) = tokio::io::duplex(64 * 1024);
2069        let (host_to_client, client_from_host) = tokio::io::duplex(64 * 1024);
2070        let (client_err, _host_err) = tokio::io::duplex(4096);
2071        let client = Arc::new(pi_ext::client::HostClient::connect_boxed(
2072            Box::new(client_to_host),
2073            Box::new(client_from_host),
2074            Box::new(client_err),
2075            None,
2076        ));
2077        let host_errors: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2078        let _host = spawn_fake_extension_host(
2079            host_from_client,
2080            host_to_client,
2081            fake_flag_snapshot(),
2082            Arc::clone(&host_errors),
2083        );
2084
2085        let runner = crate::core::extension_host::HostExtensionRunner::connect(client, vec![])
2086            .await
2087            .map_err(|e| format!("HostExtensionRunner::connect: {e}"))?;
2088
2089        let runtime_with_runner = build_test_runtime(Some(runner))?;
2090        let flags = collect_extension_flags(&runtime_with_runner);
2091        assert_eq!(flags.len(), 4, "expected four registered flags");
2092
2093        // Order, description, takes_value, and extension_path mapping.
2094        assert_extension_flag(
2095            &flags[0],
2096            "verbose-log",
2097            false,
2098            Some("Enable verbose logging"),
2099            "/plugins/logger",
2100        );
2101        assert_extension_flag(
2102            &flags[1],
2103            "api-url",
2104            true,
2105            Some("Custom API URL"),
2106            "/plugins/api",
2107        );
2108        // No description → falls back to extension_path in help rendering.
2109        assert_extension_flag(&flags[2], "fallback-flag", false, None, "/plugins/fallback");
2110        // No description and no extensionPath → maps to "<extension>".
2111        assert_extension_flag(&flags[3], "legacy-flag", true, None, "<extension>");
2112
2113        // Help rendering surfaces the description-fallback source.
2114        let help_text = crate::cli::help::format_help(
2115            Some(&flags),
2116            crate::cli::help::HelpStyle { styled: false },
2117        );
2118        assert!(
2119            help_text.contains("Registered by /plugins/fallback"),
2120            "help missing fallback-path registration line"
2121        );
2122        assert!(
2123            help_text.contains("Registered by <extension>"),
2124            "help missing generic-extension registration line"
2125        );
2126        // The fake host must not have silently hidden any encode/serialization failure.
2127        assert!(
2128            lock_recover(&host_errors).is_empty(),
2129            "fake host reported errors: {:?}",
2130            lock_recover(&host_errors).clone()
2131        );
2132        Ok(())
2133    }
2134}