1use crate::config::ResolvedConfig;
2use crate::core::output::OutputFormat;
3use crate::native::{NativeCommandCatalogEntry, NativeCommandRegistry};
4use crate::repl;
5use clap::Parser;
6use miette::{Result, WrapErr, miette};
7
8use crate::guide::{GuideSection, GuideSectionKind, GuideView, HelpLevel};
9use crate::ui::RenderSettings;
10use crate::ui::messages::MessageLevel;
11use std::borrow::Cow;
12use std::ffi::OsString;
13use std::time::Instant;
14
15use super::help;
16use super::help::help_level;
17use crate::app::logging::{bootstrap_logging_config, init_developer_logging};
18use crate::app::sink::{StdIoUiSink, UiSink};
19use crate::app::{AppClients, AppState, AuthState, UiState};
20use crate::cli::Cli;
21use crate::cli::invocation::{InvocationOptions, extend_with_invocation_help, scan_cli_argv};
22use crate::plugin::{CommandCatalogEntry, PluginDispatchError};
23
24pub(crate) use super::bootstrap::{
25 RuntimeConfigRequest, prepare_startup_host, resolve_runtime_config,
26};
27pub(crate) use super::command_output::run_cli_command_with_ui;
28pub(crate) use super::dispatch::{
29 DispatchPlan, RunAction, build_dispatch_plan, ensure_builtin_visible_for,
30 ensure_dispatch_visibility, ensure_plugin_visible_for, normalize_cli_profile,
31 normalize_profile_override,
32};
33use super::external::run_external_command;
34pub(crate) use super::external::run_external_command_with_help_renderer;
35#[cfg(test)]
36pub(crate) use super::repl_lifecycle::rebuild_repl_state;
37pub(crate) use super::timing::{TimingSummary, format_timing_badge, right_align_timing_line};
38pub(crate) use crate::plugin::config::{
39 PluginConfigEntry, PluginConfigScope, plugin_config_entries,
40};
41#[cfg(test)]
42pub(crate) use crate::plugin::config::{
43 collect_plugin_config_env, config_value_to_plugin_env, plugin_config_env_name,
44};
45
46pub(crate) const CMD_PLUGINS: &str = "plugins";
47pub(crate) const CMD_DOCTOR: &str = "doctor";
48pub(crate) const CMD_CONFIG: &str = "config";
49pub(crate) const CMD_THEME: &str = "theme";
50pub(crate) const CMD_HISTORY: &str = "history";
51pub(crate) const CMD_INTRO: &str = "intro";
52pub(crate) const CMD_HELP: &str = "help";
53pub(crate) const CMD_LIST: &str = "list";
54pub(crate) const CMD_SHOW: &str = "show";
55pub(crate) const CMD_USE: &str = "use";
56pub const EXIT_CODE_ERROR: i32 = 1;
57pub const EXIT_CODE_USAGE: i32 = 2;
58pub const EXIT_CODE_CONFIG: i32 = 3;
59pub const EXIT_CODE_PLUGIN: i32 = 4;
60pub(crate) const DEFAULT_REPL_PROMPT: &str = "āā{user}@{domain} {indicator}\nā°ā{profile}> ";
61pub(crate) const CURRENT_TERMINAL_SENTINEL: &str = "__current__";
62pub(crate) const REPL_SHELLABLE_COMMANDS: [&str; 5] = ["nh", "mreg", "ldap", "vm", "orch"];
63
64#[derive(Debug, Clone)]
65pub(crate) struct ReplCommandSpec {
66 pub(crate) name: Cow<'static, str>,
67 pub(crate) supports_dsl: bool,
68}
69
70#[derive(Debug, Clone)]
71pub(crate) struct ResolvedInvocation {
72 pub(crate) ui: UiState,
73 pub(crate) plugin_provider: Option<String>,
74 pub(crate) help_level: HelpLevel,
75}
76
77struct PreparedHostRun {
78 state: AppState,
79 dispatch: DispatchPlan,
80 invocation_ui: ResolvedInvocation,
81}
82
83#[derive(Debug)]
84struct ContextError<E> {
85 context: &'static str,
86 source: E,
87}
88
89#[derive(Clone, Copy)]
90struct KnownErrorChain<'a> {
91 clap: Option<&'a clap::Error>,
92 config: Option<&'a crate::config::ConfigError>,
93 plugin: Option<&'a PluginDispatchError>,
94}
95
96impl<'a> KnownErrorChain<'a> {
97 fn inspect(err: &'a miette::Report) -> Self {
98 Self {
99 clap: find_error_in_chain::<clap::Error>(err),
100 config: find_error_in_chain::<crate::config::ConfigError>(err),
101 plugin: find_error_in_chain::<PluginDispatchError>(err),
102 }
103 }
104}
105
106impl<E> std::fmt::Display for ContextError<E>
107where
108 E: std::error::Error + Send + Sync + 'static,
109{
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 write!(f, "{}", self.context)
112 }
113}
114
115impl<E> std::error::Error for ContextError<E>
116where
117 E: std::error::Error + Send + Sync + 'static,
118{
119 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
120 Some(&self.source)
121 }
122}
123
124impl<E> miette::Diagnostic for ContextError<E> where E: std::error::Error + Send + Sync + 'static {}
125
126pub fn run_from<I, T>(args: I) -> Result<i32>
131where
132 I: IntoIterator<Item = T>,
133 T: Into<std::ffi::OsString> + Clone,
134{
135 let mut sink = StdIoUiSink;
136 run_from_with_sink(args, &mut sink)
137}
138
139pub(crate) fn run_from_with_sink<I, T>(args: I, sink: &mut dyn UiSink) -> Result<i32>
140where
141 I: IntoIterator<Item = T>,
142 T: Into<std::ffi::OsString> + Clone,
143{
144 run_from_with_sink_and_app(args, sink, &super::AppDefinition::default())
145}
146
147pub(crate) fn run_from_with_sink_and_app<I, T>(
148 args: I,
149 sink: &mut dyn UiSink,
150 app: &super::AppDefinition,
151) -> Result<i32>
152where
153 I: IntoIterator<Item = T>,
154 T: Into<std::ffi::OsString> + Clone,
155{
156 let argv = args.into_iter().map(Into::into).collect::<Vec<OsString>>();
157 init_developer_logging(bootstrap_logging_config(&argv));
158 let scanned = scan_cli_argv(&argv)?;
159 match Cli::try_parse_from(scanned.argv.iter().cloned()) {
160 Ok(cli) => run(cli, scanned.invocation, sink, app),
161 Err(err) => handle_clap_parse_error(&argv, err, sink, app),
162 }
163}
164
165fn handle_clap_parse_error(
166 args: &[OsString],
167 err: clap::Error,
168 sink: &mut dyn UiSink,
169 app: &super::AppDefinition,
170) -> Result<i32> {
171 match err.kind() {
172 clap::error::ErrorKind::DisplayHelp => {
173 let help_context = help::render_settings_for_help(args, &app.product_defaults);
174 let mut body = GuideView::from_text(&err.to_string());
175 extend_with_invocation_help(&mut body, help_context.help_level);
176 add_native_command_help(&mut body, &app.native_commands);
177 let filtered = body.filtered_for_help_level(help_context.help_level);
178 let rendered = crate::ui::render_structured_output_with_source_guide(
179 &filtered.to_output_result(),
180 Some(&filtered),
181 &help_context.settings,
182 help_context.layout,
183 );
184 sink.write_stdout(&rendered);
185 Ok(0)
186 }
187 clap::error::ErrorKind::DisplayVersion => {
188 sink.write_stdout(&err.to_string());
189 Ok(0)
190 }
191 _ => Err(report_std_error_with_context(
192 err,
193 "failed to parse CLI arguments",
194 )),
195 }
196}
197
198fn run(
201 mut cli: Cli,
202 invocation: InvocationOptions,
203 sink: &mut dyn UiSink,
204 app: &super::AppDefinition,
205) -> Result<i32> {
206 let run_started = Instant::now();
207 if invocation.cache {
208 return Err(miette!(
209 "`--cache` is only available inside the interactive REPL"
210 ));
211 }
212
213 let PreparedHostRun {
214 mut state,
215 dispatch,
216 invocation_ui,
217 } = prepare_host_run(&mut cli, &invocation, app, run_started)?;
218
219 let action_started = Instant::now();
220 let is_repl = matches!(dispatch.action, RunAction::Repl);
221 let action = dispatch.action;
222 let result = match action {
223 RunAction::Repl => {
224 state.runtime.ui = invocation_ui.ui.clone();
225 repl::run_plugin_repl(&mut state)
226 }
227 RunAction::External(tokens) => run_external_command(
228 &mut state.runtime,
229 &mut state.session,
230 &state.clients,
231 &tokens,
232 &invocation_ui,
233 )
234 .and_then(|result| {
235 run_cli_command_with_ui(
236 state.runtime.config.resolved(),
237 &invocation_ui.ui,
238 result,
239 sink,
240 )
241 }),
242 action => {
243 let Some(command) = action.into_builtin_command() else {
244 return Err(miette!(
245 "internal error: non-builtin run action reached builtin dispatch"
246 ));
247 };
248 super::run_cli_builtin_command_parts(
249 &mut state.runtime,
250 &mut state.session,
251 &state.clients,
252 &invocation_ui,
253 command,
254 sink,
255 )
256 }
257 };
258
259 if !is_repl && invocation_ui.ui.debug_verbosity > 0 {
260 let total = run_started.elapsed();
261 let startup = action_started.saturating_duration_since(run_started);
262 let command = total.saturating_sub(startup);
263 let footer = right_align_timing_line(
264 TimingSummary {
265 total,
266 parse: if invocation_ui.ui.debug_verbosity >= 3 {
267 Some(startup)
268 } else {
269 None
270 },
271 execute: if invocation_ui.ui.debug_verbosity >= 3 {
272 Some(command)
273 } else {
274 None
275 },
276 render: None,
277 },
278 invocation_ui.ui.debug_verbosity,
279 &invocation_ui.ui.render_settings.resolve_render_settings(),
280 );
281 if !footer.is_empty() {
282 sink.write_stderr(&footer);
283 }
284 }
285
286 result
287}
288
289fn prepare_host_run(
294 cli: &mut Cli,
295 invocation: &InvocationOptions,
296 app: &super::AppDefinition,
297 run_started: Instant,
298) -> Result<PreparedHostRun> {
299 let normalized_profile = normalize_cli_profile(cli);
300 let runtime_load = cli.runtime_load_options();
301 let initial_config = resolve_runtime_config(
302 RuntimeConfigRequest::new(normalized_profile.clone(), Some("cli"))
303 .with_runtime_load(runtime_load)
304 .with_product_defaults(app.product_defaults.clone()),
305 )
306 .wrap_err("failed to resolve initial config for startup")?;
307 let known_profiles = initial_config.known_profiles().clone();
308 let dispatch = build_dispatch_plan(cli, &known_profiles)?;
309 tracing::debug!(
310 action = ?dispatch.action,
311 profile_override = ?dispatch.profile_override,
312 known_profiles = known_profiles.len(),
313 "built dispatch plan"
314 );
315
316 let terminal_kind = dispatch.action.terminal_kind();
317 let prepared = prepare_startup_host(
318 cli,
319 dispatch.profile_override.clone(),
320 terminal_kind,
321 run_started,
322 &app.product_defaults,
323 )?;
324 let mut state = crate::app::AppStateBuilder::from_host_inputs(
325 prepared.runtime_context,
326 prepared.config,
327 prepared.host_inputs,
328 )
329 .with_launch(prepared.launch_context)
330 .with_native_commands(app.native_commands.clone())
331 .build();
332 state
333 .runtime
334 .set_product_defaults(app.product_defaults.clone());
335 ensure_dispatch_visibility(&state.runtime.auth, &dispatch.action)?;
336 let invocation_ui = resolve_invocation_ui(
337 state.runtime.config.resolved(),
338 &state.runtime.ui,
339 invocation,
340 );
341 super::assembly::apply_runtime_side_effects(
342 state.runtime.config.resolved(),
343 invocation_ui.ui.debug_verbosity,
344 &state.runtime.themes,
345 );
346 tracing::debug!(
347 debug_count = invocation_ui.ui.debug_verbosity,
348 "developer logging initialized"
349 );
350 tracing::info!(
351 profile = %state.runtime.config.resolved().active_profile(),
352 terminal = %state.runtime.context.terminal_kind().as_config_terminal(),
353 action = ?dispatch.action,
354 plugin_timeout_ms = super::plugin_process_timeout(state.runtime.config.resolved()).as_millis(),
355 "osp session initialized"
356 );
357
358 Ok(PreparedHostRun {
359 state,
360 dispatch,
361 invocation_ui,
362 })
363}
364
365pub(crate) fn authorized_command_catalog_for(
366 auth: &AuthState,
367 clients: &AppClients,
368) -> Result<Vec<CommandCatalogEntry>> {
369 let mut all = clients.plugins().command_catalog();
370 all.extend(
371 clients
372 .native_commands()
373 .catalog()
374 .into_iter()
375 .map(native_catalog_entry_to_command_catalog_entry),
376 );
377 all.sort_by(|left, right| left.name.cmp(&right.name));
378 Ok(all
379 .into_iter()
380 .filter(|entry| auth.is_external_command_visible(&entry.name))
381 .collect())
382}
383
384pub(crate) fn resolve_invocation_ui(
385 config: &ResolvedConfig,
386 ui: &UiState,
387 invocation: &InvocationOptions,
388) -> ResolvedInvocation {
389 let mut render_settings = ui.render_settings.clone();
390 render_settings.format_explicit = invocation.format.is_some();
391 if let Some(format) = invocation.format {
392 render_settings.format = format;
393 }
394 if let Some(mode) = invocation.mode {
395 render_settings.mode = mode;
396 }
397 if let Some(color) = invocation.color {
398 render_settings.color = color;
399 }
400 if let Some(unicode) = invocation.unicode {
401 render_settings.unicode = unicode;
402 }
403
404 ResolvedInvocation {
405 ui: UiState::new(
406 render_settings,
407 crate::ui::messages::adjust_verbosity(
408 ui.message_verbosity,
409 invocation.verbose,
410 invocation.quiet,
411 ),
412 if invocation.debug > 0 {
413 invocation.debug.min(3)
414 } else {
415 ui.debug_verbosity
416 },
417 ),
418 plugin_provider: invocation.plugin_provider.clone(),
419 help_level: help_level(config, invocation.verbose, invocation.quiet),
420 }
421}
422
423pub(crate) fn ensure_command_supports_dsl(spec: &ReplCommandSpec, stages: &[String]) -> Result<()> {
424 if stages.is_empty() || spec.supports_dsl {
425 return Ok(());
426 }
427
428 Err(miette!(
429 "`{}` does not support DSL pipeline stages",
430 spec.name
431 ))
432}
433
434pub(crate) fn enrich_dispatch_error(err: PluginDispatchError) -> miette::Report {
435 report_std_error_with_context(err, "plugin command failed")
436}
437
438pub fn classify_exit_code(err: &miette::Report) -> i32 {
440 let known = KnownErrorChain::inspect(err);
441 if known.clap.is_some() {
442 EXIT_CODE_USAGE
443 } else if known.config.is_some() {
444 EXIT_CODE_CONFIG
445 } else if known.plugin.is_some() {
446 EXIT_CODE_PLUGIN
447 } else {
448 EXIT_CODE_ERROR
449 }
450}
451
452pub fn render_report_message(err: &miette::Report, verbosity: MessageLevel) -> String {
456 if verbosity >= MessageLevel::Trace {
457 return format!("{err:?}");
458 }
459
460 let known = KnownErrorChain::inspect(err);
461 let mut message = base_error_message(err, &known);
462
463 if verbosity >= MessageLevel::Info {
464 let mut next: Option<&(dyn std::error::Error + 'static)> = Some(err.as_ref());
465 while let Some(source) = next {
466 let source_text = source.to_string();
467 if !source_text.is_empty() && !message.contains(&source_text) {
468 message.push_str(": ");
469 message.push_str(&source_text);
470 }
471 next = source.source();
472 }
473 }
474
475 if verbosity >= MessageLevel::Success
476 && let Some(hint) = known_error_hint(&known)
477 && !message.contains(hint)
478 {
479 message.push_str("\nHint: ");
480 message.push_str(hint);
481 }
482
483 message
484}
485
486fn known_error_hint(known: &KnownErrorChain<'_>) -> Option<&'static str> {
487 if let Some(plugin_err) = known.plugin {
488 return Some(match plugin_err {
489 PluginDispatchError::CommandNotFound { .. } => {
490 "run `osp plugins list` and set --plugin-dir or OSP_PLUGIN_PATH"
491 }
492 PluginDispatchError::CommandAmbiguous { .. } => {
493 "rerun with --plugin-provider <plugin-id> or persist a default with `osp plugins select-provider <command> <plugin-id>`"
494 }
495 PluginDispatchError::ProviderNotFound { .. } => {
496 "pick one of the available providers from `osp plugins commands` or `osp plugins doctor`"
497 }
498 PluginDispatchError::ExecuteFailed { .. } => {
499 "verify the plugin executable exists and is executable"
500 }
501 PluginDispatchError::TimedOut { .. } => {
502 "increase extensions.plugins.timeout_ms or inspect the plugin executable"
503 }
504 PluginDispatchError::NonZeroExit { .. } => {
505 "inspect the plugin stderr output or rerun with -v/-vv for more context"
506 }
507 PluginDispatchError::InvalidJsonResponse { .. }
508 | PluginDispatchError::InvalidResponsePayload { .. } => {
509 "inspect the plugin response contract and stderr output"
510 }
511 });
512 }
513
514 if let Some(config_err) = known.config {
515 return Some(match config_err {
516 crate::config::ConfigError::UnknownProfile { .. } => {
517 "run `osp config explain profile.default` or choose a known profile"
518 }
519 crate::config::ConfigError::InsecureSecretsPermissions { .. } => {
520 "restrict the secrets file permissions to 0600"
521 }
522 _ => "run `osp config explain <key>` to inspect config provenance",
523 });
524 }
525
526 if known.clap.is_some() {
527 return Some("use --help to inspect accepted flags and subcommands");
528 }
529
530 None
531}
532
533fn base_error_message(err: &miette::Report, known: &KnownErrorChain<'_>) -> String {
534 if let Some(plugin_err) = known.plugin {
535 return plugin_err.to_string();
536 }
537
538 if let Some(config_err) = known.config {
539 return config_err.to_string();
540 }
541
542 if let Some(clap_err) = known.clap {
543 return clap_err.to_string();
544 }
545
546 let outer = err.to_string();
547 let mut deepest_source = None;
548 let mut current = err.source();
549 while let Some(source) = current {
550 let text = source.to_string();
551 if !text.is_empty() {
552 deepest_source = Some(text);
553 }
554 current = source.source();
555 }
556
557 match deepest_source {
558 Some(source) if outer.starts_with("failed to ") || outer.starts_with("unable to ") => {
561 source
562 }
563 _ => outer,
564 }
565}
566
567pub(crate) fn report_std_error_with_context<E>(err: E, context: &'static str) -> miette::Report
568where
569 E: std::error::Error + Send + Sync + 'static,
570{
571 miette::Report::new(ContextError {
572 context,
573 source: err,
574 })
575}
576
577fn find_error_in_chain<E>(err: &miette::Report) -> Option<&E>
578where
579 E: std::error::Error + 'static,
580{
581 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err.as_ref());
582 while let Some(source) = current {
583 if let Some(found) = source.downcast_ref::<E>() {
584 return Some(found);
585 }
586 current = source.source();
587 }
588 None
589}
590
591fn native_catalog_entry_to_command_catalog_entry(
592 entry: NativeCommandCatalogEntry,
593) -> CommandCatalogEntry {
594 CommandCatalogEntry {
595 name: entry.name,
596 about: entry.about,
597 auth: entry.auth,
598 subcommands: entry.subcommands,
599 completion: entry.completion,
600 provider: None,
601 providers: Vec::new(),
602 conflicted: false,
603 requires_selection: false,
604 selected_explicitly: false,
605 source: None,
606 }
607}
608
609fn add_native_command_help(view: &mut GuideView, native_commands: &NativeCommandRegistry) {
610 let catalog = native_commands.catalog();
611 if catalog.is_empty() {
612 return;
613 }
614
615 let mut section = GuideSection::new("Native integrations", GuideSectionKind::Custom);
616 for entry in catalog {
617 section = section.entry(entry.name, entry.about.trim());
618 }
619 view.sections.push(section);
620}
621
622pub(crate) fn resolve_render_settings_with_hint(
623 settings: &RenderSettings,
624 format_hint: Option<OutputFormat>,
625) -> RenderSettings {
626 if matches!(settings.format, OutputFormat::Auto)
627 && let Some(format) = format_hint
628 {
629 let mut effective = settings.clone();
630 effective.format = format;
631 return effective;
632 }
633 settings.clone()
634}