1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub enum AppMode {
60 Interactive,
62 Print,
64 Json,
66 Rpc,
68}
69
70impl AppMode {
71 #[must_use]
73 pub const fn is_json(self) -> bool {
74 matches!(self, Self::Json)
75 }
76
77 #[must_use]
79 pub const fn is_rpc(self) -> bool {
80 matches!(self, Self::Rpc)
81 }
82
83 #[must_use]
85 pub const fn is_interactive(self) -> bool {
86 matches!(self, Self::Interactive)
87 }
88}
89
90#[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#[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#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct FlagValidationError {
129 pub message: String,
131}
132
133pub 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
165pub 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
201pub 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#[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
232pub trait BootstrapIo: Send + Sync {
242 fn env(&self, key: &str) -> Option<String>;
244 fn set_env(&self, key: &str, value: &str);
246 fn cwd(&self) -> PathBuf;
248 fn stdin_is_tty(&self) -> bool;
250 fn stdout_is_tty(&self) -> bool;
252 fn read_piped_stdin<'a>(
254 &'a self,
255 ) -> Pin<Box<dyn Future<Output = io::Result<Option<String>>> + Send + 'a>>;
256 fn write_stdout(&self, line: &str);
258 fn write_stderr(&self, line: &str);
260}
261
262pub struct RuntimeFactoryOptions {
268 pub cwd: String,
270 pub agent_dir: String,
272 pub session_manager: SessionManager,
274 pub parsed: Args,
276}
277
278pub struct RuntimeHandle {
280 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
292pub trait RuntimeFactory: Send + Sync {
300 fn create(
306 &self,
307 options: RuntimeFactoryOptions,
308 ) -> BoxFuture<'_, Result<RuntimeHandle, String>>;
309
310 fn supports_interactive(&self) -> bool {
315 true
316 }
317}
318
319#[derive(Debug)]
325pub enum BootstrapOutcome {
326 Exit {
328 code: u8,
330 drain_quirk: bool,
332 },
333 Dispatch(Dispatched),
335}
336
337#[derive(Debug)]
339pub struct Dispatched {
340 pub mode: AppMode,
342 pub handle: RuntimeHandle,
344 pub initial_message: Option<String>,
346 pub initial_images: Vec<pi_ai::ImageContent>,
348 pub remaining_messages: Vec<String>,
350 pub migrations: MigrationResult,
352}
353
354pub struct BootstrapInputs<'a> {
360 pub args: Vec<String>,
362 pub io: &'a dyn BootstrapIo,
364 pub factory: &'a dyn RuntimeFactory,
366 pub package_handler: &'a dyn PackageHandler,
368 pub package_output: &'a dyn PackageOutput,
370}
371
372pub 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
765fn 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
776fn resolve_agent_dir(_io: &dyn BootstrapIo) -> String {
783 crate::core::config::get_agent_dir()
784 .to_string_lossy()
785 .into_owned()
786}
787
788fn 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
801async 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#[derive(Clone, Debug)]
878enum ResolvedSession {
879 Path(String),
880 NotFound(String),
881}
882
883async 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
908async 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
947fn 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
961fn 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
991async 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
1039fn 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 #[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 #[derive(Default)]
1178 struct FakeFactory {
1179 calls: Mutex<Vec<String>>,
1180 supports_interactive: bool,
1181 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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 assert_extension_flag(&flags[2], "fallback-flag", false, None, "/plugins/fallback");
2110 assert_extension_flag(&flags[3], "legacy-flag", true, None, "<extension>");
2112
2113 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 assert!(
2128 lock_recover(&host_errors).is_empty(),
2129 "fake host reported errors: {:?}",
2130 lock_recover(&host_errors).clone()
2131 );
2132 Ok(())
2133 }
2134}