Skip to main content

osp_cli/app/
host.rs

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
126/// Runs the top-level CLI entrypoint from an argv-like iterator.
127///
128/// This is the library-friendly wrapper around the binary entrypoint and
129/// returns the process exit code that should be reported to the caller.
130pub 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
198// Keep the top-level CLI entrypoint readable as a table of contents:
199// normalize input -> bootstrap runtime state -> hand off to the selected mode.
200fn 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
289// Startup is phase-based:
290// 1. bootstrap just enough config to understand profiles and dispatch
291// 2. assemble the runtime/session layer for the chosen action
292// 3. apply startup-time side effects before handing off to execution
293fn 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
438/// Maps a reported error to the CLI exit code family used by OSP.
439pub 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
452/// Renders a user-facing error message for the requested message verbosity.
453///
454/// Higher verbosity levels include more source-chain detail and may append a hint.
455pub 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        // Context wrappers are still useful for verbose output, but at default
559        // message levels users usually need the concrete actionable cause.
560        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}