Skip to main content

harn_cli/
lib.rs

1#![recursion_limit = "256"]
2
3pub mod acp;
4mod bootstrap;
5pub mod cli;
6mod cli_bytecode;
7pub mod commands;
8mod compiler_context;
9#[doc(hidden)]
10pub mod dispatch;
11pub mod env_guard;
12pub mod format;
13pub mod json_envelope;
14mod net;
15pub mod package;
16mod provider_bootstrap;
17mod runtime;
18pub mod skill_loader;
19pub mod skill_provenance;
20pub mod test_report;
21pub mod test_runner;
22pub mod test_timing;
23#[doc(hidden)]
24pub mod tests;
25mod typecheck_imports;
26pub use commands::dispatch_explain::DISPATCH_AUDIT_SCHEMA_VERSION;
27pub(crate) use compiler_context::{
28    compiler_for_source, compiler_with_imported_enum_candidates,
29    ensure_builtin_signatures_installed, imported_enum_candidates_for_source,
30};
31pub use harn_skills::{get_embedded_skill, list_embedded_skills, EmbeddedSkill, SkillFrontmatter};
32
33use clap::{error::ErrorKind, CommandFactory, Parser as ClapParser};
34use std::path::{Path, PathBuf};
35use std::sync::{Arc, Once};
36use std::{env, fs, panic, process, thread};
37
38use cli::{
39    refresh_provider_catalog_if_requested, Cli, Command, CompletionShell, EvalCommand,
40    GuardCommand, MergeCaptainCommand, MergeCaptainMockCommand, ModelInfoArgs,
41    PackageArtifactsCommand, PackageCacheCommand, PackageCommand, PackageScaffoldCommand,
42    PgCommand, ProviderCatalogCommand, ProviderCommand, RunsCommand, SkillCommand, SkillKeyCommand,
43    SkillTrustCommand, TimeCommand, ToolCommand,
44};
45use harn_lexer::Lexer;
46use harn_modules::project_config;
47use harn_parser::{DiagnosticSeverity, Parser, TypeChecker};
48use runtime::{build_cli_runtime, cli_runtime_mode, CliRuntimeMode};
49
50pub const CLI_RUNTIME_STACK_SIZE: usize = 16 * 1024 * 1024;
51static BROKEN_PIPE_PANIC_HOOK: Once = Once::new();
52
53#[cfg(feature = "hostlib")]
54pub(crate) fn install_default_hostlib(vm: &mut harn_vm::Vm) {
55    let _ = harn_hostlib::install_default(vm);
56    // The `rules` capability lives in its own crate (it depends on
57    // `harn-rules`, which depends on `harn-hostlib`, so it can't ship inside
58    // `install_default`). Wire it in alongside the defaults.
59    harn_rules_hostlib::install(vm);
60}
61
62#[cfg(not(feature = "hostlib"))]
63pub(crate) fn install_default_hostlib(_vm: &mut harn_vm::Vm) {}
64
65/// Entry point used by `src/main.rs`. Hosts the CLI runtime thread and
66/// drives the async dispatcher in `async_main`.
67pub fn run() {
68    install_broken_pipe_panic_hook();
69    harn_vm::initialize_runtime_assets();
70    let raw_args = normalize_serve_args(bootstrap::args_after_pre_runtime_command());
71    // Defeat rlib dead-code stripping of `#[harn_builtin]`-emitted statics
72    // (linkme issue #36). Without this touch the linker can drop every
73    // builtin's distributed-slice entry, leaving `ALL_BUILTIN_DEFS` empty
74    // and surfacing as a swarm of `HARN-NAM-002` errors at first call.
75    harn_vm::stdlib::force_link();
76
77    ensure_builtin_signatures_installed();
78
79    let runtime_mode = cli_runtime_mode(&raw_args);
80
81    let handle = thread::Builder::new()
82        .name("harn-cli".to_string())
83        .stack_size(CLI_RUNTIME_STACK_SIZE)
84        .spawn(move || {
85            let runtime = build_cli_runtime(runtime_mode);
86            runtime.block_on(async_main(raw_args, runtime_mode));
87            // Drain any queued OTLP exports while the tokio runtime
88            // is still alive. The auto-registered `OtelSink` uses a
89            // batch processor with `runtime::Tokio`; if we let the
90            // runtime drop before this call, in-flight spans never
91            // reach the configured collector. No-op when OTel is not
92            // configured.
93            if let Err(error) = harn_vm::events::shutdown_otel_sink() {
94                eprintln!("[harn] OTel exporter shutdown failed: {error}");
95            }
96        })
97        .unwrap_or_else(|error| {
98            eprintln!("failed to start CLI runtime thread: {error}");
99            process::exit(1);
100        });
101
102    if let Err(payload) = handle.join() {
103        if runtime::is_broken_pipe_panic_payload(payload.as_ref()) {
104            process::exit(0);
105        }
106        std::panic::resume_unwind(payload);
107    }
108}
109
110fn install_broken_pipe_panic_hook() {
111    BROKEN_PIPE_PANIC_HOOK.call_once(|| {
112        let previous = panic::take_hook();
113        panic::set_hook(Box::new(move |info| {
114            if runtime::is_broken_pipe_panic_payload(info.payload()) {
115                return;
116            }
117            previous(info);
118        }));
119    });
120}
121
122#[allow(clippy::large_stack_frames)] // dispatch entrypoint owns full Args + per-feature locals.
123async fn async_main(raw_args: Vec<String>, runtime_mode: CliRuntimeMode) {
124    // Install the OTLP exporter sink before any subcommand runs so a
125    // 20+ minute autonomous session has spans streaming to the
126    // configured collector from the first turn. When neither
127    // `HARN_OTEL_ENDPOINT` nor `OTEL_EXPORTER_OTLP_ENDPOINT` is set
128    // this is a no-op. A misconfigured endpoint logs and continues —
129    // local observability is opt-in and must never fail the run.
130    if runtime_mode.enables_tokio_io() {
131        if let Err(error) = harn_vm::events::install_otel_sink_from_env() {
132            eprintln!("[harn] OTel exporter disabled: {error}");
133        }
134    }
135
136    if raw_args.len() == 2 && raw_args[1].ends_with(".harn") {
137        provider_bootstrap::maybe_seed_ollama_for_run_file(Path::new(&raw_args[1]), false, false)
138            .await;
139        commands::run::run_file(
140            &raw_args[1],
141            false,
142            std::collections::HashSet::new(),
143            Vec::new(),
144            commands::run::CliLlmMockMode::Off,
145            None,
146            commands::run::RunProfileOptions::default(),
147        )
148        .await;
149        return;
150    }
151
152    let cli = match Cli::try_parse_from(&raw_args) {
153        Ok(cli) => cli,
154        Err(error) => {
155            if matches!(
156                error.kind(),
157                ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
158            ) {
159                error.exit();
160            }
161            error.exit();
162        }
163    };
164
165    if cli.json_schemas {
166        commands::json_schemas::run(cli.schema_command.as_deref());
167        return;
168    }
169
170    let Some(subcommand) = cli.command else {
171        // `arg_required_else_help` already shows help when no args are
172        // supplied. We only land here if a top-level flag (e.g. a
173        // future `--version` long flag) parsed without a subcommand.
174        let mut cmd = Cli::command();
175        cmd.print_help().ok();
176        return;
177    };
178    match subcommand {
179        Command::Version(args) => {
180            let exit = run_version(args).await;
181            if exit != 0 {
182                process::exit(exit);
183            }
184        }
185        Command::Upgrade(args) => {
186            if let Err(error) = commands::upgrade::run(args).await {
187                eprintln!("error: {error}");
188                process::exit(1);
189            }
190        }
191        Command::Dap(_) => run_dap_adapter(),
192        Command::ConformanceHelper(args) => {
193            if let Err(error) = commands::conformance_helper::run(args).await {
194                eprintln!("error: {error}");
195                process::exit(1);
196            }
197        }
198        Command::Skill(args) => match args.command {
199            SkillCommand::List(list) => commands::skills::run_list(&list),
200            SkillCommand::Get(get) => commands::skills::run_get(&get),
201            SkillCommand::Dump(dump) => commands::skills::run_dump(&dump),
202            SkillCommand::Resolved(resolved) => commands::skills::run_resolved(&resolved),
203            SkillCommand::Inspect(inspect) => commands::skills::run_inspect(&inspect),
204            SkillCommand::Match(matcher) => commands::skills::run_match(&matcher),
205            SkillCommand::Install(install) => commands::skills::run_install(&install),
206            SkillCommand::New(new_args) => commands::skills::run_new(&new_args),
207            SkillCommand::Key(key_args) => match key_args.command {
208                SkillKeyCommand::Generate(generate) => commands::skill::run_key_generate(&generate),
209            },
210            SkillCommand::Sign(sign) => commands::skill::run_sign(&sign),
211            SkillCommand::Endorse(endorse) => commands::skill::run_endorse(&endorse),
212            SkillCommand::Verify(verify) => commands::skill::run_verify(&verify),
213            SkillCommand::WhoSigned(who_signed) => {
214                commands::skill::run_who_signed(&who_signed).await;
215            }
216            SkillCommand::Trust(trust_args) => match trust_args.command {
217                SkillTrustCommand::Add(add) => commands::skill::run_trust_add(&add),
218                SkillTrustCommand::List(list) => commands::skill::run_trust_list(&list),
219            },
220        },
221        Command::Guard(args) => match args.command {
222            GuardCommand::List(list_args) => commands::guard::run_list(&list_args),
223            GuardCommand::Install(install_args) => {
224                commands::guard::run_install(&install_args).await;
225            }
226            GuardCommand::Status(status_args) => commands::guard::run_status(&status_args),
227            GuardCommand::Remove(remove_args) => commands::guard::run_remove(&remove_args),
228        },
229        Command::Run(args) => {
230            let _operator_approval_guard = args.install_operator_approval_grant();
231            if !args.explain_cost {
232                match (args.eval.as_deref(), args.file.as_deref()) {
233                    (Some(code), None) => {
234                        provider_bootstrap::maybe_seed_ollama_for_inline(
235                            code,
236                            args.yes,
237                            args.llm_mock.is_some(),
238                        )
239                        .await;
240                    }
241                    (None, Some(file)) => {
242                        provider_bootstrap::maybe_seed_ollama_for_run_file(
243                            Path::new(file),
244                            args.yes,
245                            args.llm_mock.is_some(),
246                        )
247                        .await;
248                    }
249                    _ => {}
250                }
251            }
252            let denied =
253                commands::run::build_denied_builtins(args.deny.as_deref(), args.allow.as_deref());
254            let llm_mock_mode = if let Some(path) = args.llm_mock.as_ref() {
255                commands::run::CliLlmMockMode::Replay {
256                    fixture_path: PathBuf::from(path),
257                }
258            } else if let Some(path) = args.llm_mock_record.as_ref() {
259                commands::run::CliLlmMockMode::Record {
260                    fixture_path: PathBuf::from(path),
261                }
262            } else {
263                commands::run::CliLlmMockMode::Off
264            };
265            let attestation = args.attest.then(|| commands::run::RunAttestationOptions {
266                receipt_out: args.receipt_out.as_ref().map(PathBuf::from),
267                agent_id: args.attest_agent.clone(),
268            });
269            let profile_options = run_profile_options(&args.profile);
270            let sandbox_options = commands::run::sandbox::run_sandbox_options_from_args(&args);
271            let json_options = args
272                .json
273                .then_some(commands::run::RunJsonOptions { quiet: args.quiet });
274            let aux_options = commands::run::run_aux_options_from_args(&args);
275            let control_options = commands::run::run_control_options_from_args(&args);
276            let harnpack_options = commands::run::harnpack::HarnpackRunOptions {
277                allow_unsigned: args.allow_unsigned,
278                dry_run_verify: args.dry_run_verify,
279            };
280            if let Some(resume_target) = args.resume.as_deref() {
281                let exit_code = commands::run::run_resume_with_skill_dirs(
282                    resume_target,
283                    args.trace,
284                    denied,
285                    args.argv.clone(),
286                    args.skill_dir.clone(),
287                    llm_mock_mode,
288                    attestation,
289                    profile_options,
290                    sandbox_options.clone(),
291                    json_options,
292                    aux_options,
293                    control_options,
294                )
295                .await;
296                runtime::exit_on_error(exit_code);
297                return;
298            }
299
300            if args.as_job {
301                run_as_job(&args).await;
302                return;
303            }
304
305            match (args.eval.as_deref(), args.file.as_deref()) {
306                (Some(code), None) => {
307                    if args.allow_unsigned || args.dry_run_verify {
308                        command_error(
309                            "`--allow-unsigned` and `--dry-run-verify` apply to `.harnpack` inputs; \
310                             they cannot be combined with `-e`",
311                        );
312                    }
313                    let (wrapped, tmp) = commands::run::prepare_eval_temp_file(code)
314                        .unwrap_or_else(|e| command_error(&e));
315                    let tmp_path: PathBuf = tmp.path().to_path_buf();
316                    if let Err(error) = fs::write(&tmp_path, &wrapped) {
317                        drop(tmp);
318                        command_error(&format!("failed to write temp file for -e: {error}"));
319                    }
320                    let tmp_str = tmp_path.to_string_lossy().into_owned();
321                    let exit_code = if args.explain_cost {
322                        commands::run::run_explain_cost_file_with_skill_dirs(&tmp_str)
323                    } else {
324                        commands::run::run_file_with_skill_dirs(
325                            &tmp_str,
326                            args.trace,
327                            denied,
328                            args.argv.clone(),
329                            args.skill_dir.clone(),
330                            llm_mock_mode.clone(),
331                            attestation.clone(),
332                            profile_options.clone(),
333                            sandbox_options.clone(),
334                            json_options.clone(),
335                            aux_options.clone(),
336                            control_options.clone(),
337                            harnpack_options.clone(),
338                        )
339                        .await
340                    };
341                    drop(tmp);
342                    runtime::exit_on_error(exit_code);
343                }
344                (None, Some(file)) => {
345                    let exit_code = if args.explain_cost {
346                        commands::run::run_explain_cost_file_with_skill_dirs(file)
347                    } else {
348                        commands::run::run_file_with_skill_dirs(
349                            file,
350                            args.trace,
351                            denied,
352                            args.argv.clone(),
353                            args.skill_dir.clone(),
354                            llm_mock_mode,
355                            attestation,
356                            profile_options,
357                            sandbox_options,
358                            json_options,
359                            aux_options,
360                            control_options,
361                            harnpack_options,
362                        )
363                        .await
364                    };
365                    runtime::exit_on_error(exit_code);
366                }
367                (Some(_), Some(_)) => command_error(
368                    "`harn run` accepts either `-e <code>` or `<file.harn>`, not both",
369                ),
370                (None, None) => command_error(
371                    "`harn run` requires `--resume <snapshot>`, `-e <code>`, or `<file.harn>`",
372                ),
373            }
374        }
375        Command::Check(args) => commands::check::run_check_command(args),
376        Command::Parse(args) => {
377            if let Err(error) = commands::parse_tokens::run_parse(&args) {
378                command_error(&error);
379            }
380        }
381        Command::Tokens(args) => {
382            if let Err(error) = commands::parse_tokens::run_tokens(&args) {
383                command_error(&error);
384            }
385        }
386        Command::Config(args) => {
387            if let Err(error) = commands::config_cmd::run(args).await {
388                command_error(&error);
389            }
390        }
391        Command::Explain(args) => {
392            let code = commands::explain::run_explain(&args).await;
393            if code != 0 {
394                process::exit(code);
395            }
396        }
397        Command::Fix(args) => {
398            if let Err(error) = commands::fix::run(&args) {
399                if error.is_partial_failure() {
400                    eprintln!("error: {}", error.message());
401                    process::exit(1);
402                }
403                command_error(error.message());
404            }
405        }
406        Command::Contracts(args) => {
407            commands::contracts::handle_contracts_command(args).await;
408        }
409        Command::Connect(args) => {
410            commands::connect::run_connect(*args).await;
411        }
412        Command::Lint(args) => {
413            let targets: Vec<&str> = args.targets.iter().map(String::as_str).collect();
414            let (files, prompt_files) = commands::check::collect_lint_targets(&targets);
415            if files.is_empty() && prompt_files.is_empty() {
416                if args.json {
417                    print_lint_error(
418                        "no_lint_targets",
419                        "no .harn, .harn.txt, or .harn.prompt files found under the given target(s)",
420                    );
421                }
422                command_error(
423                    "no .harn, .harn.txt, or .harn.prompt files found under the given target(s)",
424                );
425            }
426            if args.json {
427                let outcome = commands::check::run_lint_json(
428                    &files,
429                    commands::check::LintJsonOptions {
430                        strict: args.strict,
431                        require_file_header: args.require_file_header,
432                        require_public_api_types: args.require_public_api_types,
433                    },
434                )
435                .await;
436                println!("{}", json_envelope::to_string_pretty(&outcome.envelope));
437                runtime::exit_on_error(outcome.exit_code);
438                return;
439            }
440            let mut analysis = harn_parser::analysis::AnalysisDatabase::new();
441            let module_graph =
442                commands::check::build_module_graph_and_seed_analysis(&files, &mut analysis);
443            let cross_file_imports = commands::check::collect_cross_file_imports(&module_graph);
444            // `.harn`-authored custom lint rules (#2850) run in a sandboxed VM,
445            // so they're computed once here in the async handler and merged into
446            // each file's diagnostics below. Empty (near-zero cost) when the
447            // project declares no `*.lint.harn` rules.
448            let script_rule_diags = commands::check::run_project_script_rules(&files).await;
449            let script_diags_for = |file: &std::path::Path| -> &[harn_lint::LintDiagnostic] {
450                script_rule_diags
451                    .get(file)
452                    .map(Vec::as_slice)
453                    .unwrap_or(&[])
454            };
455            if args.fix {
456                let mut should_fail = false;
457                for file in &files {
458                    let mut config = package::load_check_config(Some(file));
459                    let mut lint_config = commands::check::load_harn_lint_config(file);
460                    lint_config.require_file_header |= args.require_file_header;
461                    lint_config.require_public_api_types |= args.require_public_api_types;
462                    commands::check::apply_loaded_harn_lint_config(&lint_config, &mut config);
463                    let outcome = commands::check::lint_fix_file(
464                        &mut analysis,
465                        file,
466                        &config,
467                        &cross_file_imports,
468                        &module_graph,
469                        &lint_config,
470                    );
471                    should_fail |= outcome.should_fail(config.strict || args.strict);
472                }
473                for file in &prompt_files {
474                    let lint_config = commands::check::load_harn_lint_config(file);
475                    let config = package::load_check_config(Some(file));
476                    // The template lint rules don't carry autofix
477                    // edits yet (intentionally — see
478                    // `template_provider_identity::make_diagnostic`),
479                    // so `--fix` is equivalent to a regular run.
480                    let outcome = commands::check::lint_prompt_file_inner(
481                        file,
482                        lint_config.template_variant_branch_threshold,
483                        &lint_config.disabled,
484                    );
485                    should_fail |= outcome.should_fail(config.strict || args.strict);
486                }
487                // Autofix does not suppress residual failures: unfixable
488                // error-level diagnostics (and warnings under `--strict`) must
489                // still fail the exit code exactly like the plain lint path, so
490                // CI/pre-commit hooks running `--fix` never pass green over a
491                // real error.
492                if should_fail {
493                    process::exit(1);
494                }
495            } else {
496                let mut should_fail = false;
497                let mut total_findings = 0usize;
498                let mut total_fixable = 0usize;
499                for file in &files {
500                    let mut config = package::load_check_config(Some(file));
501                    let mut lint_config = commands::check::load_harn_lint_config(file);
502                    lint_config.require_file_header |= args.require_file_header;
503                    lint_config.require_public_api_types |= args.require_public_api_types;
504                    commands::check::apply_loaded_harn_lint_config(&lint_config, &mut config);
505                    let outcome = commands::check::lint_file_inner(
506                        &mut analysis,
507                        file,
508                        &config,
509                        &cross_file_imports,
510                        &module_graph,
511                        &lint_config,
512                        script_diags_for(file.as_path()),
513                    );
514                    total_findings += outcome.findings;
515                    total_fixable += outcome.fixable;
516                    should_fail |= outcome.should_fail(config.strict || args.strict);
517                }
518                for file in &prompt_files {
519                    let lint_config = commands::check::load_harn_lint_config(file);
520                    let config = package::load_check_config(Some(file));
521                    let outcome = commands::check::lint_prompt_file_inner(
522                        file,
523                        lint_config.template_variant_branch_threshold,
524                        &lint_config.disabled,
525                    );
526                    total_findings += outcome.findings;
527                    total_fixable += outcome.fixable;
528                    should_fail |= outcome.should_fail(config.strict || args.strict);
529                }
530                // ESLint-style hint: when findings are auto-fixable, point the
531                // user at `--fix`. Emphasized when *every* finding is fixable.
532                if total_fixable > 0 {
533                    if total_fixable == total_findings {
534                        eprintln!(
535                            "\nAll {total_fixable} finding(s) are auto-fixable — run `harn lint --fix` to apply them."
536                        );
537                    } else {
538                        eprintln!(
539                            "\n{total_fixable} of {total_findings} finding(s) are auto-fixable — run `harn lint --fix` to apply them."
540                        );
541                    }
542                }
543                if should_fail {
544                    process::exit(1);
545                }
546            }
547        }
548        Command::Fmt(args) => {
549            let targets: Vec<&str> = args.targets.iter().map(String::as_str).collect();
550            // Anchor config resolution on the first target; CLI flags
551            // always win over harn.toml values.
552            let anchor = targets.first().map(Path::new).unwrap_or(Path::new("."));
553            let loaded = match project_config::load_for_path(anchor) {
554                Ok(c) => c,
555                Err(e) => {
556                    eprintln!("warning: {e}");
557                    project_config::HarnConfig::default()
558                }
559            };
560            let mut opts = harn_fmt::FmtOptions::default();
561            if let Some(w) = loaded.fmt.line_width {
562                opts.line_width = w;
563            }
564            if let Some(w) = loaded.fmt.separator_width {
565                opts.separator_width = w;
566            }
567            if let Some(w) = args.line_width {
568                opts.line_width = w;
569            }
570            if let Some(w) = args.separator_width {
571                opts.separator_width = w;
572            }
573            let mode = commands::check::FmtMode::from_check_flag(args.check);
574            if args.json {
575                let envelope = commands::check::fmt_targets_json(&targets, mode, &opts);
576                let failed = !envelope.ok;
577                println!("{}", json_envelope::to_string_pretty(&envelope));
578                if failed {
579                    process::exit(1);
580                }
581            } else {
582                commands::check::fmt_targets(&targets, mode, &opts);
583            }
584        }
585        Command::Test(args) => commands::test::run_command(args).await,
586        Command::Init(args) => {
587            commands::init::init_project(args.name.as_deref(), args.template).await;
588        }
589        Command::New(args) => match commands::init::resolve_new_args(&args) {
590            Ok((name, template)) => commands::init::init_project(name.as_deref(), template).await,
591            Err(error) => {
592                eprintln!("error: {error}");
593                process::exit(1);
594            }
595        },
596        Command::Doctor(args) => {
597            commands::doctor::run_doctor_with_options(commands::doctor::DoctorOptions {
598                json: args.json,
599                check_providers: args.check_providers,
600                check_targets: args.check_targets,
601            })
602            .await;
603        }
604        Command::Host(args) => {
605            let exit = commands::host::run(args).await;
606            if exit != 0 {
607                process::exit(exit);
608            }
609        }
610        Command::Models(args) => commands::models::run(args).await,
611        Command::Local(args) => commands::local::run(args).await,
612        Command::Provider(args) => match args.command {
613            ProviderCommand::Capabilities(capabilities) => {
614                commands::provider_capabilities::run_or_exit(capabilities);
615            }
616            ProviderCommand::Catalog(catalog) => match catalog.command {
617                ProviderCatalogCommand::Refresh(refresh) => {
618                    if let Err(error) = commands::providers::run_refresh(&refresh).await {
619                        command_error(&error);
620                    }
621                }
622                ProviderCatalogCommand::Validate(validate) => {
623                    if let Err(error) = commands::providers::run_validate(&validate) {
624                        command_error(&error);
625                    }
626                }
627                ProviderCatalogCommand::Generate(generate) => {
628                    if let Err(error) = commands::providers::run_generate(&generate) {
629                        command_error(&error);
630                    }
631                }
632                ProviderCatalogCommand::Export(export) => {
633                    if let Err(error) = commands::providers::run_export(&export) {
634                        command_error(&error);
635                    }
636                }
637                ProviderCatalogCommand::Matrix(matrix) => {
638                    if let Err(error) = commands::providers::run_matrix(&matrix) {
639                        command_error(&error);
640                    }
641                }
642                ProviderCatalogCommand::Support(support) => {
643                    if let Err(error) = commands::provider_support::run(&support) {
644                        command_error(&error);
645                    }
646                }
647                ProviderCatalogCommand::Recommend(recommend) => {
648                    if let Err(error) = commands::providers::run_recommend(&recommend).await {
649                        command_error(&error);
650                    }
651                }
652                ProviderCatalogCommand::Show(show) => {
653                    refresh_provider_catalog_if_requested(&show).await;
654                    let exit_code = dispatch_provider_catalog(show.available_only).await;
655                    runtime::exit_on_error(exit_code);
656                }
657            },
658            ProviderCommand::Ready(ready) => {
659                run_provider_ready(
660                    &ready.provider,
661                    ready.model.as_deref(),
662                    ready.base_url.as_deref(),
663                    ready.json,
664                )
665                .await;
666            }
667            ProviderCommand::Probe(probe) => commands::provider::run_provider_probe(probe).await,
668            ProviderCommand::ToolProbe(args) => commands::provider::run_tool_probe(args).await,
669            ProviderCommand::ToolCalibrate(args) => commands::run_tool_calibrate(args).await,
670            ProviderCommand::ToolProbeAudit(args) => commands::providers::run_audit(args),
671            ProviderCommand::ToolScorecard(tool_scorecard) => {
672                commands::provider::run_provider_tool_scorecard(tool_scorecard).await;
673            }
674            ProviderCommand::CacheProbe(cache_probe) => {
675                commands::provider::run_provider_cache_probe(cache_probe).await;
676            }
677            ProviderCommand::DispatchExplain(explain) => commands::dispatch_explain::run(&explain),
678            ProviderCommand::DispatchAudit(audit) => commands::dispatch_explain::run_audit(&audit),
679            ProviderCommand::Limits(limits) => {
680                commands::provider_limits::run(&limits);
681            }
682        },
683        Command::Scan(args) => commands::scan::run(args).await,
684        Command::Codemod(args) => commands::codemod::run(args).await,
685        Command::Rule(args) => commands::rule::run(args).await,
686        Command::Try(args) => commands::try_cmd::run(args).await,
687        Command::Quickstart(args) => {
688            if let Err(error) = commands::quickstart::run_quickstart(&args).await {
689                command_error(&error);
690            }
691        }
692        Command::Demo(args) => {
693            let code = commands::demo::run(args).await;
694            if code != 0 {
695                process::exit(code);
696            }
697        }
698        Command::Serve(args) => commands::serve::run_command(args.command).await,
699        Command::Connector(args) => {
700            if let Err(error) = commands::connector::handle_connector_command(args).await {
701                eprintln!("error: {error}");
702                process::exit(1);
703            }
704        }
705        Command::Mcp(args) => commands::mcp::handle_mcp_command(&args.command).await,
706        Command::Watch(args) => {
707            let denied =
708                commands::run::build_denied_builtins(args.deny.as_deref(), args.allow.as_deref());
709            commands::run::run_watch(&args.file, denied).await;
710        }
711        Command::Dev(args) => {
712            commands::dev::run(args).await;
713        }
714        Command::Portal(args) => {
715            commands::portal::run_portal(
716                &args.dir,
717                args.manifest,
718                args.persona_state_dir,
719                &args.host,
720                args.port,
721                args.open,
722                args.allow_remote_launch,
723            )
724            .await;
725        }
726        Command::Trigger(args) => {
727            if let Err(error) = commands::trigger::handle(args).await {
728                eprintln!("error: {error}");
729                process::exit(1);
730            }
731        }
732        Command::Graph(args) => {
733            let code = commands::graph::run(args).await;
734            if code != 0 {
735                process::exit(code);
736            }
737        }
738        Command::Doc(args) => {
739            let code = commands::doc::run(&args.path, args.output.as_deref());
740            if code != 0 {
741                process::exit(code);
742            }
743        }
744        Command::Routes(args) => {
745            let code = commands::routes::run(args).await;
746            if code != 0 {
747                process::exit(code);
748            }
749        }
750        Command::Usage(args) => {
751            let code = commands::usage::run(args).await;
752            if code != 0 {
753                process::exit(code);
754            }
755        }
756        Command::Flow(args) => match commands::flow::run_flow(&args) {
757            Ok(code) => {
758                if code != 0 {
759                    process::exit(code);
760                }
761            }
762            Err(error) => command_error(&error),
763        },
764        Command::Canon(args) => commands::canon::run(args).await,
765        Command::Workflow(args) => match commands::workflow::handle(args) {
766            Ok(code) => {
767                if code != 0 {
768                    process::exit(code);
769                }
770            }
771            Err(error) => command_error(&error),
772        },
773        Command::Supervisor(args) => {
774            if let Err(error) = commands::supervisor::handle(args).await {
775                eprintln!("error: {error}");
776                process::exit(1);
777            }
778        }
779        Command::Trace(args) => {
780            if let Err(error) = commands::trace::handle(args).await {
781                eprintln!("error: {error}");
782                process::exit(1);
783            }
784        }
785        Command::Crystallize(args) => {
786            if let Err(error) = commands::crystallize::run(args) {
787                eprintln!("error: {error}");
788                process::exit(1);
789            }
790        }
791        Command::Trust(args) => {
792            if let Err(error) = commands::trust::handle(args).await {
793                eprintln!("error: {error}");
794                process::exit(1);
795            }
796        }
797        Command::Verify(args) => {
798            if let Err(error) = verify_provenance_receipt(&args.receipt, args.json) {
799                eprintln!("error: {error}");
800                process::exit(1);
801            }
802        }
803        Command::Completion(args) => print_completions(args.shell),
804        Command::Orchestrator(args) => {
805            if let Err(error) = commands::orchestrator::handle(args).await {
806                eprintln!("error: {error}");
807                process::exit(1);
808            }
809        }
810        Command::Playground(args) => {
811            provider_bootstrap::maybe_seed_ollama_for_playground(
812                Path::new(&args.host),
813                Path::new(&args.script),
814                args.yes,
815                args.llm.is_some(),
816                args.llm_mock.is_some(),
817            )
818            .await;
819            let llm_mock_mode = if let Some(path) = args.llm_mock.as_ref() {
820                commands::run::CliLlmMockMode::Replay {
821                    fixture_path: PathBuf::from(path),
822                }
823            } else if let Some(path) = args.llm_mock_record.as_ref() {
824                commands::run::CliLlmMockMode::Record {
825                    fixture_path: PathBuf::from(path),
826                }
827            } else {
828                commands::run::CliLlmMockMode::Off
829            };
830            if let Err(error) = commands::playground::run_command(args, llm_mock_mode).await {
831                eprint!("{error}");
832                process::exit(1);
833            }
834        }
835        Command::Runs(args) => match args.command {
836            RunsCommand::Inspect(inspect) => {
837                inspect_run_record(&inspect.path, inspect.compare.as_deref());
838            }
839            RunsCommand::View(view) => {
840                cli::print_runs_view(&view.path, view.session, view.json);
841            }
842        },
843        Command::Session(args) => commands::session::run(args),
844        Command::Replay(args) => {
845            let exit = commands::replay::run(args);
846            if exit != 0 {
847                process::exit(exit);
848            }
849        }
850        Command::Eval(args) => match args.command {
851            Some(EvalCommand::CodingAgent(coding_agent_args)) => {
852                let code = commands::eval_coding_agent::run(coding_agent_args).await;
853                if code != 0 {
854                    process::exit(code);
855                }
856            }
857            Some(EvalCommand::Context(context_args)) => {
858                let code = commands::eval_context::run(context_args).await;
859                if code != 0 {
860                    process::exit(code);
861                }
862            }
863            Some(EvalCommand::Prompt(prompt_args)) => {
864                let code = commands::eval_prompt::run(prompt_args).await;
865                if code != 0 {
866                    process::exit(code);
867                }
868            }
869            Some(EvalCommand::SkillGate(skill_gate_args)) => {
870                let code = commands::eval_skill_gate::run(skill_gate_args).await;
871                if code != 0 {
872                    process::exit(code);
873                }
874            }
875            Some(EvalCommand::ScopeTriage(scope_args)) => {
876                process::exit(commands::eval_scope_triage::run(scope_args).await)
877            }
878            Some(EvalCommand::ToolCalls(tool_calls_args)) => {
879                let code = commands::eval_tool_calls::run(tool_calls_args).await;
880                if code != 0 {
881                    process::exit(code);
882                }
883            }
884            None => {
885                let Some(path) = args.path else {
886                    eprintln!("error: `harn eval` requires a path or a subcommand (e.g. `prompt`).\nSee `harn eval --help`.");
887                    process::exit(2);
888                };
889                let llm_mock_mode = if let Some(path) = args.llm_mock.as_ref() {
890                    commands::run::CliLlmMockMode::Replay {
891                        fixture_path: PathBuf::from(path),
892                    }
893                } else if let Some(path) = args.llm_mock_record.as_ref() {
894                    commands::run::CliLlmMockMode::Record {
895                        fixture_path: PathBuf::from(path),
896                    }
897                } else {
898                    commands::run::CliLlmMockMode::Off
899                };
900                eval_run_record(
901                    &path,
902                    args.compare.as_deref(),
903                    args.structural_experiment.as_deref(),
904                    &args.argv,
905                    &llm_mock_mode,
906                );
907            }
908        },
909        Command::Repl => commands::repl::run_repl().await,
910        Command::Bench(args) => commands::bench::run(args).await,
911        Command::Precompile(args) => commands::precompile::run(args).await,
912        Command::Pack(args) => commands::pack::run(args),
913        Command::TestBench(args) => commands::test_bench::run(args.command).await,
914        Command::Viz(args) => commands::viz::run_viz(&args.file, args.output.as_deref()),
915        Command::Install(args) => package::install_packages(
916            args.frozen || args.locked || args.offline,
917            args.refetch.as_deref(),
918            args.offline,
919            args.json,
920        ),
921        Command::Add(args) => package::add_package_with_registry(
922            &args.name_or_spec,
923            args.alias.as_deref(),
924            args.git.as_deref(),
925            args.tag.as_deref(),
926            args.rev.as_deref(),
927            args.branch.as_deref(),
928            args.path.as_deref(),
929            args.registry.as_deref(),
930        ),
931        Command::Update(args) => {
932            package::update_packages(args.alias.as_deref(), args.all, args.json);
933        }
934        Command::Remove(args) => package::remove_package(&args.alias),
935        Command::Lock => package::lock_packages(),
936        Command::Package(args) => match args.command {
937            PackageCommand::List(list) => package::list_packages(list.json),
938            PackageCommand::Doctor(doctor) => package::doctor_packages(doctor.json),
939            PackageCommand::Search(search) => package::search_package_registry(
940                search.query.as_deref(),
941                search.registry.as_deref(),
942                search.json,
943            ),
944            PackageCommand::Info(info) => {
945                package::show_package_registry_info(
946                    &info.name,
947                    info.registry.as_deref(),
948                    info.json,
949                );
950            }
951            PackageCommand::Check(check) => {
952                package::check_package(check.package.as_deref(), check.json);
953            }
954            PackageCommand::Pack(pack) => package::pack_package(
955                pack.package.as_deref(),
956                pack.output.as_deref(),
957                pack.dry_run,
958                pack.json,
959            ),
960            PackageCommand::Docs(docs) => package::generate_package_docs(
961                docs.package.as_deref(),
962                docs.output.as_deref(),
963                docs.check,
964            ),
965            PackageCommand::Cache(cache) => match cache.command {
966                PackageCacheCommand::List => package::list_package_cache(),
967                PackageCacheCommand::Clean(clean) => package::clean_package_cache(clean.all),
968                PackageCacheCommand::Verify(verify) => {
969                    package::verify_package_cache(verify.materialized);
970                }
971            },
972            PackageCommand::Outdated(args) => package::outdated_packages(
973                args.refresh,
974                args.remote,
975                args.registry.as_deref(),
976                args.json,
977            ),
978            PackageCommand::Audit(args) => {
979                package::audit_packages(
980                    args.registry.as_deref(),
981                    args.skip_materialized,
982                    args.json,
983                );
984            }
985            PackageCommand::Artifacts(args) => match args.command {
986                PackageArtifactsCommand::Manifest(manifest) => {
987                    package::artifacts_manifest(manifest.output.as_deref());
988                }
989                PackageArtifactsCommand::Check(check) => {
990                    package::artifacts_check(&check.manifest, check.json);
991                }
992            },
993            PackageCommand::Scaffold(args) => match args.command {
994                PackageScaffoldCommand::Openapi(openapi) => {
995                    if let Err(error) = commands::package_scaffold::run_openapi(&openapi).await {
996                        eprintln!("error: {error}");
997                        process::exit(1);
998                    }
999                }
1000            },
1001        },
1002        Command::Publish(args) => package::publish_package(
1003            args.package.as_deref(),
1004            args.dry_run,
1005            &args.remote,
1006            &args.index_repo,
1007            &args.index_path,
1008            args.registry_name.as_deref(),
1009            args.skip_index_pr,
1010            args.registry.as_deref(),
1011            args.json,
1012        ),
1013        Command::MergeCaptain(args) => match args.command {
1014            MergeCaptainCommand::Run(run) => {
1015                let code = commands::merge_captain::run_driver(&run);
1016                if code != 0 {
1017                    process::exit(code);
1018                }
1019            }
1020            MergeCaptainCommand::Ladder(ladder) => {
1021                let code = commands::merge_captain::run_ladder(&ladder);
1022                if code != 0 {
1023                    process::exit(code);
1024                }
1025            }
1026            MergeCaptainCommand::Iterate(iterate) => {
1027                let code = commands::merge_captain::run_iterate(&iterate);
1028                if code != 0 {
1029                    process::exit(code);
1030                }
1031            }
1032            MergeCaptainCommand::Audit(audit) => {
1033                let code = commands::merge_captain::run_audit(&audit);
1034                if code != 0 {
1035                    process::exit(code);
1036                }
1037            }
1038            MergeCaptainCommand::Mock(mock) => {
1039                let code = match mock {
1040                    MergeCaptainMockCommand::Init(args) => {
1041                        commands::merge_captain_mock::run_init(&args)
1042                    }
1043                    MergeCaptainMockCommand::Step(args) => {
1044                        commands::merge_captain_mock::run_step(&args)
1045                    }
1046                    MergeCaptainMockCommand::Status(args) => {
1047                        commands::merge_captain_mock::run_status(&args)
1048                    }
1049                    MergeCaptainMockCommand::Serve(args) => {
1050                        commands::merge_captain_mock::run_serve(&args).await
1051                    }
1052                    MergeCaptainMockCommand::Cleanup(args) => {
1053                        commands::merge_captain_mock::run_cleanup(&args)
1054                    }
1055                    MergeCaptainMockCommand::Scenarios => {
1056                        commands::merge_captain_mock::run_scenarios()
1057                    }
1058                };
1059                if code != 0 {
1060                    process::exit(code);
1061                }
1062            }
1063        },
1064        Command::Pg(args) => match args.command {
1065            PgCommand::Codegen(codegen) => {
1066                let code = commands::pg_codegen::run(&codegen);
1067                if code != 0 {
1068                    process::exit(code);
1069                }
1070            }
1071        },
1072        Command::Persona(args) => {
1073            if let Err(error) = commands::persona_dispatch::run(args).await {
1074                eprintln!("error: {error}");
1075                process::exit(1);
1076            }
1077        }
1078        Command::Tool(args) => match args.command {
1079            ToolCommand::New(new_args) => {
1080                if let Err(error) = commands::tool::run_new(&new_args).await {
1081                    eprintln!("error: {error}");
1082                    process::exit(1);
1083                }
1084            }
1085        },
1086        Command::DumpHighlightKeywords(args) => {
1087            commands::dump_highlight_keywords::run(&args.output, args.check);
1088        }
1089        Command::DumpTriggerQuickref(args) => {
1090            commands::dump_trigger_quickref::run(&args.output, args.check);
1091        }
1092        Command::DumpConnectorMatrix(args) => {
1093            commands::check::connector_matrix::run_docs(&args.output, &args.sources, args.check);
1094        }
1095        Command::DumpProtocolArtifacts(args) => {
1096            commands::dump_protocol_artifacts::run(
1097                &args.output_dir,
1098                args.check,
1099                args.artifact_version.as_deref(),
1100            );
1101        }
1102        Command::ConnectorSchemaCodegen(args) => {
1103            let code = commands::connector_schema_codegen::run(&args);
1104            if code != 0 {
1105                process::exit(code);
1106            }
1107        }
1108        Command::Time(args) => match args.command {
1109            TimeCommand::Run(time_args) => commands::time::run(time_args).await,
1110        },
1111    }
1112}
1113
1114fn run_profile_options(args: &cli::ProfileArgs) -> commands::run::RunProfileOptions {
1115    commands::run::RunProfileOptions {
1116        text: args.text,
1117        json_path: args.json_path.clone(),
1118    }
1119}
1120
1121fn print_completions(shell: CompletionShell) {
1122    let mut command = Cli::command();
1123    let shell = clap_complete::Shell::from(shell);
1124    clap_complete::generate(shell, &mut command, "harn", &mut std::io::stdout());
1125}
1126
1127/// Back-compat shim for the legacy `harn serve [flags] agent.harn` shape,
1128/// which predates the explicit transport subcommands and defaulted to
1129/// A2A. When the token after `serve` is not a known transport subcommand
1130/// (nor a help flag), assume the legacy shape and inject `a2a`.
1131///
1132/// The set of transports is read from the clap command tree rather than
1133/// hard-coded, so a newly added transport (e.g. `site`) is recognized
1134/// automatically instead of being silently rewritten to `a2a`.
1135fn normalize_serve_args(mut raw_args: Vec<String>) -> Vec<String> {
1136    if raw_args.len() > 2 && raw_args.get(1).is_some_and(|arg| arg == "serve") {
1137        let token = raw_args.get(2).map(String::as_str).unwrap_or_default();
1138        let is_explicit = token == "-h"
1139            || token == "--help"
1140            || serve_subcommand_names().iter().any(|name| name == token);
1141        if !is_explicit {
1142            raw_args.insert(2, "a2a".to_string());
1143        }
1144    }
1145    raw_args
1146}
1147
1148/// Names of the transport subcommands clap knows under `harn serve`.
1149fn serve_subcommand_names() -> Vec<String> {
1150    use clap::CommandFactory;
1151    Cli::command()
1152        .find_subcommand("serve")
1153        .map(|serve| {
1154            serve
1155                .get_subcommands()
1156                .map(|sub| sub.get_name().to_string())
1157                .collect()
1158        })
1159        .unwrap_or_default()
1160}
1161
1162/// Schema version for `harn version --json`. Bump when the data shape
1163/// changes; new optional fields can be added freely.
1164pub(crate) const VERSION_SCHEMA_VERSION: u32 = 1;
1165
1166/// Launch the Harn debug adapter (DAP) over stdio for `harn dap`.
1167///
1168/// The adapter runs its own blocking stdio read loop and builds a fresh Tokio
1169/// runtime per VM step, so it must not execute inside this CLI's Tokio runtime
1170/// — nesting `Runtime::new()` inside an entered runtime panics. We hand it a
1171/// dedicated OS thread with no entered runtime context (the same clean footing
1172/// the `harn-dap` multi-call alias gets from `main`) and block until the
1173/// client disconnects. Reuses `harn_dap::run` verbatim; no duplicated server.
1174fn run_dap_adapter() {
1175    thread::Builder::new()
1176        .name("harn-dap".to_string())
1177        .spawn(harn_dap::run)
1178        .expect("spawn harn-dap adapter thread")
1179        .join()
1180        .expect("harn-dap adapter thread panicked");
1181}
1182
1183async fn run_version(args: cli::VersionArgs) -> i32 {
1184    let _name = env_guard::ScopedEnvVar::set("HARN_BUILD_NAME", env!("CARGO_PKG_NAME"));
1185    let _version = env_guard::ScopedEnvVar::set("HARN_BUILD_VERSION", env!("CARGO_PKG_VERSION"));
1186    let _description =
1187        env_guard::ScopedEnvVar::set("HARN_BUILD_DESCRIPTION", env!("CARGO_PKG_DESCRIPTION"));
1188    let _revision =
1189        env_guard::ScopedEnvVar::set("HARN_BUILD_REVISION", env!("HARN_BUILD_REVISION"));
1190    let argv = if args.json {
1191        vec!["--json".to_string()]
1192    } else {
1193        Vec::new()
1194    };
1195    dispatch::dispatch_to_embedded_script("version", argv, args.json).await
1196}
1197
1198pub(crate) async fn print_model_info(args: &ModelInfoArgs) -> bool {
1199    let resolved = harn_vm::llm_config::resolve_model_info(&args.model);
1200    let api_key_result = harn_vm::llm::resolve_api_key(&resolved.provider);
1201    let api_key_set = api_key_result.is_ok();
1202    let api_key = api_key_result.unwrap_or_default();
1203    let context_window =
1204        harn_vm::llm::fetch_provider_max_context(&resolved.provider, &resolved.id, &api_key).await;
1205    let readiness = local_provider_readiness(&resolved.provider, &resolved.id, &api_key).await;
1206    let catalog = harn_vm::llm_config::model_catalog_entry(&resolved.id);
1207    let runtime_context_window = catalog
1208        .as_ref()
1209        .and_then(|entry| entry.runtime_context_window);
1210    let capabilities = harn_vm::llm::capabilities::lookup(&resolved.provider, &resolved.id);
1211    let batch_api =
1212        harn_vm::llm_config::effective_batch_api_supported(&resolved.provider, &capabilities);
1213    let mut payload = serde_json::json!({
1214        "alias": args.model,
1215        "id": resolved.id,
1216        "provider": resolved.provider,
1217        "resolved_alias": resolved.alias,
1218        "tool_format": resolved.tool_format,
1219        "tier": resolved.tier,
1220        "api_key_set": api_key_set,
1221        "context_window": context_window,
1222        "runtime_context_window": runtime_context_window,
1223        "readiness": readiness,
1224        "catalog": catalog,
1225        "capabilities": {
1226            "native_tools": capabilities.native_tools,
1227            "defer_loading": capabilities.defer_loading,
1228            "tool_search": capabilities.tool_search,
1229            "max_tools": capabilities.max_tools,
1230            "prompt_caching": capabilities.prompt_caching,
1231            "batch_api": batch_api,
1232            "batch_wire_format": if batch_api { capabilities.batch_wire_format } else { None },
1233            "batch_input_mode": if batch_api { capabilities.batch_input_mode } else { None },
1234            "batch_discount_percent": if batch_api { capabilities.batch_discount_percent } else { None },
1235            "batch_turnaround_hours": if batch_api { capabilities.batch_turnaround_hours } else { None },
1236            "vision": capabilities.vision,
1237            "vision_supported": capabilities.vision_supported,
1238            "audio": capabilities.audio,
1239            "pdf": capabilities.pdf,
1240            "files_api_supported": capabilities.files_api_supported,
1241            "json_schema": capabilities.json_schema,
1242            "prefers_xml_scaffolding": capabilities.prefers_xml_scaffolding,
1243            "prefers_markdown_scaffolding": capabilities.prefers_markdown_scaffolding,
1244            "structured_output_mode": capabilities.structured_output_mode,
1245            "supports_assistant_prefill": capabilities.supports_assistant_prefill,
1246            "prefers_role_developer": capabilities.prefers_role_developer,
1247            "prefers_xml_tools": capabilities.prefers_xml_tools,
1248            "thinking": !capabilities.thinking_modes.is_empty(),
1249            "thinking_block_style": capabilities.thinking_block_style,
1250            "thinking_modes": capabilities.thinking_modes,
1251            "interleaved_thinking_supported": capabilities.interleaved_thinking_supported,
1252            "anthropic_beta_features": capabilities.anthropic_beta_features,
1253            "preserve_thinking": capabilities.preserve_thinking,
1254            "server_parser": capabilities.server_parser,
1255            "honors_chat_template_kwargs": capabilities.honors_chat_template_kwargs,
1256            "recommended_endpoint": capabilities.recommended_endpoint,
1257            "text_tool_wire_format_supported": capabilities.text_tool_wire_format_supported,
1258            "preferred_tool_format": capabilities.preferred_tool_format,
1259            "tool_mode_parity": capabilities.tool_mode_parity,
1260            "tool_mode_parity_notes": capabilities.tool_mode_parity_notes,
1261        },
1262        "qc_default_model": harn_vm::llm_config::qc_default_model(&resolved.provider),
1263    });
1264
1265    let should_verify = args.verify || args.warm;
1266    let mut ok = true;
1267    if should_verify {
1268        if commands::local::runtime::uses_ollama_wire_protocol(&resolved.provider) {
1269            let mut readiness = harn_vm::llm::OllamaReadinessOptions::new(resolved.id.clone());
1270            readiness.warm = args.warm;
1271            readiness.observe_loaded = true;
1272            readiness.keep_alive = args
1273                .keep_alive
1274                .as_deref()
1275                .and_then(harn_vm::llm::normalize_ollama_keep_alive);
1276            let result = harn_vm::llm::ollama_readiness(readiness).await;
1277            ok = result.valid;
1278            payload["readiness"] = serde_json::to_value(&result).unwrap_or_else(|error| {
1279                serde_json::json!({
1280                    "valid": false,
1281                    "status": "serialization_error",
1282                    "message": format!("failed to serialize readiness result: {error}"),
1283                })
1284            });
1285        } else {
1286            ok = false;
1287            payload["readiness"] = serde_json::json!({
1288                "valid": false,
1289                "status": "unsupported_provider",
1290                "message": format!(
1291                    "models info --verify is only supported for models whose local runtime declares the Ollama API protocol; resolved provider is '{}'",
1292                    resolved.provider
1293                ),
1294                "provider": resolved.provider,
1295            });
1296        }
1297    }
1298
1299    println!(
1300        "{}",
1301        serde_json::to_string(&payload).unwrap_or_else(|error| {
1302            command_error(&format!("failed to serialize model info: {error}"))
1303        })
1304    );
1305    ok
1306}
1307
1308async fn local_provider_readiness(
1309    provider: &str,
1310    model: &str,
1311    api_key: &str,
1312) -> Option<serde_json::Value> {
1313    let def = harn_vm::llm_config::provider_config(provider)?;
1314    if def.auth_style != "none" || !harn_vm::llm::supports_model_readiness_probe(&def) {
1315        return None;
1316    }
1317    let readiness = harn_vm::llm::readiness::probe_provider_readiness_with_options(
1318        provider,
1319        harn_vm::llm::readiness::ProviderReadinessOptions {
1320            requested_model: Some(model),
1321            base_url_override: None,
1322            api_key_override: Some(api_key),
1323        },
1324    )
1325    .await;
1326    Some(serde_json::to_value(readiness).unwrap_or_else(|error| {
1327        serde_json::json!({
1328            "ok": false,
1329            "status": "bad_response",
1330            "message": format!("failed to serialize readiness result: {error}"),
1331            "provider": provider,
1332        })
1333    }))
1334}
1335
1336fn build_provider_catalog_payload(available_only: bool) -> serde_json::Value {
1337    let provider_names = if available_only {
1338        harn_vm::llm::available_provider_names()
1339    } else {
1340        harn_vm::llm_config::provider_names()
1341    };
1342    let providers: Vec<_> = provider_names
1343        .into_iter()
1344        .filter_map(|name| {
1345            harn_vm::llm_config::provider_config(&name).map(|def| {
1346                serde_json::json!({
1347                    "name": name,
1348                    "display_name": def.display_name,
1349                    "icon": def.icon,
1350                    "base_url": harn_vm::llm_config::resolve_base_url(&def),
1351                    "base_url_env": def.base_url_env,
1352                    "region_env": def.region_env,
1353                    "regions": def.regions,
1354                    "auth_style": def.auth_style,
1355                    "auth_envs": harn_vm::llm_config::auth_env_names(&def.auth_env),
1356                    "auth_available": harn_vm::llm::provider_auth_status(&name).available,
1357                    "features": def.features,
1358                    "cost_per_1k_in": def.cost_per_1k_in,
1359                    "cost_per_1k_out": def.cost_per_1k_out,
1360                    "latency_p50_ms": def.latency_p50_ms,
1361                    "performance": def.performance,
1362                })
1363            })
1364        })
1365        .collect();
1366    let models: Vec<_> = harn_vm::llm_config::model_catalog_entries()
1367        .into_iter()
1368        .map(|(id, model)| {
1369            serde_json::json!({
1370                "id": id,
1371                "name": model.name,
1372                "provider": model.provider,
1373                "context_window": model.context_window,
1374                "runtime_context_window": model.runtime_context_window,
1375                "stream_timeout": model.stream_timeout,
1376                "capabilities": model.capabilities,
1377                "pricing": model.pricing,
1378                "performance": model.performance,
1379            })
1380        })
1381        .collect();
1382    let aliases: Vec<_> = harn_vm::llm_config::alias_entries()
1383        .into_iter()
1384        .map(|(name, alias)| {
1385            serde_json::json!({
1386                "name": name,
1387                "id": alias.id,
1388                "provider": alias.provider,
1389                "tool_format": alias.tool_format,
1390                "tool_calling": harn_vm::llm_config::alias_tool_calling_entry(&name),
1391            })
1392        })
1393        .collect();
1394    let routing_routes = harn_vm::provider_catalog::artifact().routing_routes;
1395    serde_json::json!({
1396        "providers": providers,
1397        "known_model_names": harn_vm::llm_config::known_model_names(),
1398        "available_providers": harn_vm::llm::available_provider_names(),
1399        "aliases": aliases,
1400        "models": models,
1401        "routing_routes": routing_routes,
1402        "qc_defaults": harn_vm::llm_config::qc_defaults(),
1403    })
1404}
1405
1406/// Dispatch shim for `harn provider catalog show`. Aggregation stays in
1407/// Rust (the script can't reach `llm_config` for the catalog walk);
1408/// the .harn renderer in `stdlib/cli/providers/catalog.harn` only
1409/// re-emits the JSON envelope.
1410///
1411/// Lock keeps concurrent in-process callers from racing on the global
1412/// env var the dispatch wedge reads — same pattern as the other
1413/// partial-port commands (see harn#2305 / #2309).
1414async fn dispatch_provider_catalog(available_only: bool) -> i32 {
1415    static DISPATCH_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1416    let payload = build_provider_catalog_payload(available_only);
1417    let payload_json = match serde_json::to_string(&payload) {
1418        Ok(json) => json,
1419        Err(error) => {
1420            eprintln!("error: failed to serialise provider catalog payload: {error}");
1421            return 1;
1422        }
1423    };
1424    let _guard = DISPATCH_LOCK.lock().await;
1425    let _payload_guard =
1426        crate::env_guard::ScopedEnvVar::set("HARN_PROVIDER_CATALOG_PAYLOAD_JSON", &payload_json);
1427    // `--available-only` doesn't enable JSON; the catalog dump is JSON-
1428    // only on both impls, but pass `true` so the dispatch wedge sets
1429    // HARN_OUTPUT_JSON for symmetry with peer scripts.
1430    crate::dispatch::dispatch_to_embedded_script("providers/catalog", Vec::new(), true).await
1431}
1432
1433async fn run_provider_ready(
1434    provider: &str,
1435    model: Option<&str>,
1436    base_url: Option<&str>,
1437    json: bool,
1438) {
1439    let readiness =
1440        harn_vm::llm::readiness::probe_provider_readiness(provider, model, base_url).await;
1441    if json {
1442        match serde_json::to_string_pretty(&readiness) {
1443            Ok(payload) => println!("{payload}"),
1444            Err(error) => command_error(&format!("failed to serialize readiness result: {error}")),
1445        }
1446    } else if readiness.ok {
1447        println!("{}", readiness.message);
1448    } else {
1449        eprintln!("{}", readiness.message);
1450    }
1451    if !readiness.ok {
1452        process::exit(1);
1453    }
1454}
1455
1456pub(crate) fn command_error(message: &str) -> ! {
1457    Cli::command()
1458        .error(ErrorKind::ValueValidation, message)
1459        .exit()
1460}
1461
1462/// Run a `.harn` `@job` once against a JSON request and print the report JSON.
1463///
1464/// Lowering through the trigger dispatcher keeps retry, DLQ, budget, and
1465/// cancellation behavior aligned with long-running worker execution.
1466async fn run_as_job(args: &cli::RunArgs) {
1467    let Some(file) = args.file.as_deref() else {
1468        command_error("`--as-job` requires a `.harn` file path");
1469    };
1470    let Some(job) = args.job.as_deref() else {
1471        command_error("`--as-job` requires `--job <name>`");
1472    };
1473    let Some(request) = args.request.as_deref() else {
1474        command_error("`--as-job` requires `--request <path>`");
1475    };
1476
1477    let script_path = PathBuf::from(file);
1478    let job = job.to_string();
1479    let request = request.to_path_buf();
1480    let result_out = args.result_out.clone();
1481
1482    // Pin the whole job run to the current thread: the trigger registry
1483    // and dispatcher state are thread-local, so the register → resolve →
1484    // dispatch sequence must not migrate across the multi-thread runtime's
1485    // workers between awaits. `LocalSet::run_until` keeps it on one thread
1486    // and also backs any `spawn_local` the VM performs.
1487    let local = tokio::task::LocalSet::new();
1488    let outcome = local
1489        .run_until(async move {
1490            harn_serve::run_job_from_files(
1491                &script_path,
1492                &job,
1493                &request,
1494                result_out.as_deref(),
1495                false,
1496            )
1497            .await
1498        })
1499        .await;
1500
1501    match outcome {
1502        Ok((outcome, rendered)) => {
1503            println!("{rendered}");
1504            if !outcome.succeeded() {
1505                process::exit(1);
1506            }
1507        }
1508        Err(error) => {
1509            eprintln!("[harn] job failed: {}", error.message());
1510            process::exit(1);
1511        }
1512    }
1513}
1514
1515fn print_check_error(code: &str, message: &str) -> ! {
1516    let envelope: json_envelope::JsonEnvelope<commands::check::CheckReport> =
1517        json_envelope::JsonEnvelope::err(commands::check::CHECK_SCHEMA_VERSION, code, message);
1518    println!("{}", json_envelope::to_string_pretty(&envelope));
1519    process::exit(1);
1520}
1521
1522fn print_lint_error(code: &str, message: &str) -> ! {
1523    let envelope: json_envelope::JsonEnvelope<commands::check::LintReport> =
1524        json_envelope::JsonEnvelope::err(commands::check::LINT_SCHEMA_VERSION, code, message);
1525    println!("{}", json_envelope::to_string_pretty(&envelope));
1526    process::exit(1);
1527}
1528
1529fn verify_provenance_receipt(path: &str, json: bool) -> Result<(), String> {
1530    let raw =
1531        fs::read_to_string(path).map_err(|error| format!("failed to read {path}: {error}"))?;
1532    let receipt: harn_vm::ProvenanceReceipt = serde_json::from_str(&raw)
1533        .map_err(|error| format!("failed to parse provenance receipt {path}: {error}"))?;
1534    let report = harn_vm::verify_receipt(&receipt);
1535    if json {
1536        println!(
1537            "{}",
1538            serde_json::to_string_pretty(&report).map_err(|error| error.to_string())?
1539        );
1540    } else if report.verified {
1541        println!(
1542            "verified receipt={} events={} receipt_hash={} event_root_hash={}",
1543            report.receipt_id.unwrap_or_else(|| "-".to_string()),
1544            report.event_count,
1545            report.receipt_hash.unwrap_or_else(|| "-".to_string()),
1546            report.event_root_hash.unwrap_or_else(|| "-".to_string())
1547        );
1548    } else {
1549        println!(
1550            "failed receipt={} events={}",
1551            report.receipt_id.unwrap_or_else(|| "-".to_string()),
1552            report.event_count
1553        );
1554        for error in &report.errors {
1555            println!("  {error}");
1556        }
1557        return Err("provenance receipt verification failed".to_string());
1558    }
1559    Ok(())
1560}
1561
1562pub(crate) fn load_run_record_or_exit(path: &Path) -> harn_vm::orchestration::RunRecord {
1563    match harn_vm::orchestration::load_run_record(path) {
1564        Ok(run) => run,
1565        Err(error) => {
1566            eprintln!("Failed to load run record: {error}");
1567            process::exit(1);
1568        }
1569    }
1570}
1571
1572fn load_eval_suite_manifest_or_exit(path: &Path) -> harn_vm::orchestration::EvalSuiteManifest {
1573    harn_vm::orchestration::load_eval_suite_manifest(path).unwrap_or_else(|error| {
1574        eprintln!("Failed to load eval manifest {}: {error}", path.display());
1575        process::exit(1);
1576    })
1577}
1578
1579fn load_eval_pack_manifest_or_exit(path: &Path) -> harn_vm::orchestration::EvalPackManifest {
1580    harn_vm::orchestration::load_eval_pack_manifest(path).unwrap_or_else(|error| {
1581        eprintln!("Failed to load eval pack {}: {error}", path.display());
1582        process::exit(1);
1583    })
1584}
1585
1586fn load_persona_eval_ladder_manifest_or_exit(
1587    path: &Path,
1588) -> harn_vm::orchestration::PersonaEvalLadderManifest {
1589    harn_vm::orchestration::load_persona_eval_ladder_manifest(path).unwrap_or_else(|error| {
1590        eprintln!(
1591            "Failed to load persona eval ladder {}: {error}",
1592            path.display()
1593        );
1594        process::exit(1);
1595    })
1596}
1597
1598fn file_looks_like_eval_manifest(path: &Path) -> bool {
1599    if path.file_name().and_then(|name| name.to_str()) == Some("harn.eval.toml") {
1600        return true;
1601    }
1602    if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
1603        let Ok(content) = fs::read_to_string(path) else {
1604            return false;
1605        };
1606        return toml::from_str::<harn_vm::orchestration::EvalPackManifest>(&content)
1607            .is_ok_and(|manifest| !manifest.cases.is_empty() || !manifest.ladders.is_empty());
1608    }
1609    let Ok(content) = fs::read_to_string(path) else {
1610        return false;
1611    };
1612    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1613        return false;
1614    };
1615    json.get("_type").and_then(|value| value.as_str()) == Some("eval_suite_manifest")
1616        || json.get("cases").is_some()
1617}
1618
1619fn file_looks_like_eval_pack_manifest(path: &Path) -> bool {
1620    if path.file_name().and_then(|name| name.to_str()) == Some("harn.eval.toml") {
1621        return true;
1622    }
1623    if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
1624        return file_looks_like_eval_manifest(path);
1625    }
1626    let Ok(content) = fs::read_to_string(path) else {
1627        return false;
1628    };
1629    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1630        return false;
1631    };
1632    json.get("version").is_some()
1633        && (json.get("cases").is_some() || json.get("ladders").is_some())
1634        && json.get("_type").and_then(|value| value.as_str()) != Some("eval_suite_manifest")
1635}
1636
1637fn file_looks_like_persona_eval_ladder_manifest(path: &Path) -> bool {
1638    let Ok(content) = fs::read_to_string(path) else {
1639        return false;
1640    };
1641    if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
1642        let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1643            return false;
1644        };
1645        return json.get("_type").and_then(|value| value.as_str())
1646            == Some("persona_eval_ladder_manifest")
1647            || json.get("timeout_tiers").is_some()
1648            || json.get("timeout-tiers").is_some();
1649    }
1650    toml::from_str::<harn_vm::orchestration::PersonaEvalLadderManifest>(&content).is_ok_and(
1651        |manifest| {
1652            manifest
1653                .type_name
1654                .eq_ignore_ascii_case("persona_eval_ladder_manifest")
1655                || (!manifest.timeout_tiers.is_empty() && manifest.backend.path.is_some())
1656        },
1657    )
1658}
1659
1660pub(crate) fn collect_run_record_paths(path: &str) -> Vec<PathBuf> {
1661    let path = Path::new(path);
1662    if path.is_file() {
1663        return vec![path.to_path_buf()];
1664    }
1665    if path.is_dir() {
1666        let mut entries: Vec<PathBuf> = fs::read_dir(path)
1667            .unwrap_or_else(|error| {
1668                eprintln!("Failed to read run directory {}: {error}", path.display());
1669                process::exit(1);
1670            })
1671            .filter_map(|entry| entry.ok().map(|entry| entry.path()))
1672            .filter(|entry| entry.extension().and_then(|ext| ext.to_str()) == Some("json"))
1673            .collect();
1674        entries.sort();
1675        return entries;
1676    }
1677    eprintln!("Run path does not exist: {}", path.display());
1678    process::exit(1);
1679}
1680
1681fn print_run_diff(diff: &harn_vm::orchestration::RunDiffReport) {
1682    println!(
1683        "Diff: {} -> {} [{} -> {}]",
1684        diff.left_run_id, diff.right_run_id, diff.left_status, diff.right_status
1685    );
1686    println!("Identical: {}", diff.identical);
1687    println!("Stage diffs: {}", diff.stage_diffs.len());
1688    println!("Tool diffs: {}", diff.tool_diffs.len());
1689    println!("Observability diffs: {}", diff.observability_diffs.len());
1690    println!("Transition delta: {}", diff.transition_count_delta);
1691    println!("Artifact delta: {}", diff.artifact_count_delta);
1692    println!("Checkpoint delta: {}", diff.checkpoint_count_delta);
1693    for stage in &diff.stage_diffs {
1694        println!("- {} [{}]", stage.node_id, stage.change);
1695        for detail in &stage.details {
1696            println!("  {detail}");
1697        }
1698    }
1699    for tool in &diff.tool_diffs {
1700        println!("- tool {} [{}]", tool.tool_name, tool.args_hash);
1701        println!("  left: {:?}", tool.left_result);
1702        println!("  right: {:?}", tool.right_result);
1703    }
1704    for item in &diff.observability_diffs {
1705        println!("- {} [{}]", item.label, item.section);
1706        for detail in &item.details {
1707            println!("  {detail}");
1708        }
1709    }
1710}
1711
1712fn inspect_run_record(path: &str, compare: Option<&str>) {
1713    let run = load_run_record_or_exit(Path::new(path));
1714    println!("Run: {}", run.id);
1715    println!(
1716        "Workflow: {}",
1717        run.workflow_name
1718            .clone()
1719            .unwrap_or_else(|| run.workflow_id.clone())
1720    );
1721    println!("Status: {}", run.status);
1722    println!("Task: {}", run.task);
1723    println!("Stages: {}", run.stages.len());
1724    println!("Artifacts: {}", run.artifacts.len());
1725    println!("Transitions: {}", run.transitions.len());
1726    println!("Checkpoints: {}", run.checkpoints.len());
1727    println!("HITL questions: {}", run.hitl_questions.len());
1728    if let Some(observability) = &run.observability {
1729        println!("Planner rounds: {}", observability.planner_rounds.len());
1730        println!("Research facts: {}", observability.research_fact_count);
1731        println!("Workers: {}", observability.worker_lineage.len());
1732        println!(
1733            "Action graph: {} nodes / {} edges",
1734            observability.action_graph_nodes.len(),
1735            observability.action_graph_edges.len()
1736        );
1737        println!(
1738            "Transcript pointers: {}",
1739            observability.transcript_pointers.len()
1740        );
1741        println!("Daemon events: {}", observability.daemon_events.len());
1742    }
1743    if let Some(parent_worker_id) = run
1744        .metadata
1745        .get("parent_worker_id")
1746        .and_then(|value| value.as_str())
1747    {
1748        println!("Parent worker: {parent_worker_id}");
1749    }
1750    if let Some(parent_stage_id) = run
1751        .metadata
1752        .get("parent_stage_id")
1753        .and_then(|value| value.as_str())
1754    {
1755        println!("Parent stage: {parent_stage_id}");
1756    }
1757    if run
1758        .metadata
1759        .get("delegated")
1760        .and_then(|value| value.as_bool())
1761        .unwrap_or(false)
1762    {
1763        println!("Delegated: true");
1764    }
1765    println!(
1766        "Pending nodes: {}",
1767        if run.pending_nodes.is_empty() {
1768            "-".to_string()
1769        } else {
1770            run.pending_nodes.join(", ")
1771        }
1772    );
1773    println!(
1774        "Replay fixture: {}",
1775        if run.replay_fixture.is_some() {
1776            "embedded"
1777        } else {
1778            "derived"
1779        }
1780    );
1781    for stage in &run.stages {
1782        let worker = stage.metadata.get("worker");
1783        let worker_suffix = worker
1784            .and_then(|value| value.get("name"))
1785            .and_then(|value| value.as_str())
1786            .map(|name| format!(" worker={name}"))
1787            .unwrap_or_default();
1788        println!(
1789            "- {} [{}] status={} outcome={} branch={}{}",
1790            stage.node_id,
1791            stage.kind,
1792            stage.status,
1793            stage.outcome,
1794            stage.branch.clone().unwrap_or_else(|| "-".to_string()),
1795            worker_suffix,
1796        );
1797        if let Some(worker) = worker {
1798            if let Some(worker_id) = worker.get("id").and_then(|value| value.as_str()) {
1799                println!("  worker_id: {worker_id}");
1800            }
1801            if let Some(child_run_id) = worker.get("child_run_id").and_then(|value| value.as_str())
1802            {
1803                println!("  child_run_id: {child_run_id}");
1804            }
1805            if let Some(child_run_path) = worker
1806                .get("child_run_path")
1807                .and_then(|value| value.as_str())
1808            {
1809                println!("  child_run_path: {child_run_path}");
1810            }
1811        }
1812    }
1813    if let Some(observability) = &run.observability {
1814        for round in &observability.planner_rounds {
1815            println!(
1816                "- planner {} iterations={} llm_calls={} tools={} research_facts={}",
1817                round.node_id,
1818                round.iteration_count,
1819                round.llm_call_count,
1820                round.tool_execution_count,
1821                round.research_facts.len()
1822            );
1823        }
1824        for pointer in &observability.transcript_pointers {
1825            println!(
1826                "- transcript {} [{}] available={} {}",
1827                pointer.label,
1828                pointer.kind,
1829                pointer.available,
1830                pointer
1831                    .path
1832                    .clone()
1833                    .unwrap_or_else(|| pointer.location.clone())
1834            );
1835        }
1836        for event in &observability.daemon_events {
1837            println!(
1838                "- daemon {} [{:?}] at {}",
1839                event.name, event.kind, event.timestamp
1840            );
1841            println!("  id: {}", event.daemon_id);
1842            println!("  persist_path: {}", event.persist_path);
1843            if let Some(summary) = &event.payload_summary {
1844                println!("  payload: {summary}");
1845            }
1846        }
1847    }
1848    if let Some(compare_path) = compare {
1849        let baseline = load_run_record_or_exit(Path::new(compare_path));
1850        print_run_diff(&harn_vm::orchestration::diff_run_records(&baseline, &run));
1851    }
1852}
1853
1854fn eval_run_record(
1855    path: &str,
1856    compare: Option<&str>,
1857    structural_experiment: Option<&str>,
1858    argv: &[String],
1859    llm_mock_mode: &commands::run::CliLlmMockMode,
1860) {
1861    if let Some(experiment) = structural_experiment {
1862        let path_buf = PathBuf::from(path);
1863        if !path_buf.is_file() || path_buf.extension().and_then(|ext| ext.to_str()) != Some("harn")
1864        {
1865            eprintln!(
1866                "--structural-experiment currently requires a .harn pipeline path, got {path}"
1867            );
1868            process::exit(1);
1869        }
1870        if compare.is_some() {
1871            eprintln!("--compare cannot be combined with --structural-experiment");
1872            process::exit(1);
1873        }
1874        if matches!(llm_mock_mode, commands::run::CliLlmMockMode::Record { .. }) {
1875            eprintln!("--llm-mock-record cannot be combined with --structural-experiment");
1876            process::exit(1);
1877        }
1878        let path_buf = fs::canonicalize(&path_buf).unwrap_or_else(|error| {
1879            command_error(&format!(
1880                "failed to canonicalize structural eval pipeline {}: {error}",
1881                path_buf.display()
1882            ))
1883        });
1884        run_structural_experiment_eval(&path_buf, experiment, argv, llm_mock_mode);
1885        return;
1886    }
1887
1888    let path_buf = PathBuf::from(path);
1889    if path_buf.is_file() && file_looks_like_persona_eval_ladder_manifest(&path_buf) {
1890        if compare.is_some() {
1891            eprintln!("--compare is not supported with persona eval ladder manifests");
1892            process::exit(1);
1893        }
1894        let manifest = load_persona_eval_ladder_manifest_or_exit(&path_buf);
1895        let report =
1896            harn_vm::orchestration::run_persona_eval_ladder(&manifest).unwrap_or_else(|error| {
1897                eprintln!(
1898                    "Failed to evaluate persona eval ladder {}: {error}",
1899                    path_buf.display()
1900                );
1901                process::exit(1);
1902            });
1903        print_persona_ladder_report(&report);
1904        if !report.pass {
1905            process::exit(1);
1906        }
1907        return;
1908    }
1909
1910    if path_buf.is_file() && file_looks_like_eval_pack_manifest(&path_buf) {
1911        if compare.is_some() {
1912            eprintln!("--compare is not supported with eval pack manifests");
1913            process::exit(1);
1914        }
1915        let manifest = load_eval_pack_manifest_or_exit(&path_buf);
1916        let report = harn_vm::orchestration::evaluate_eval_pack_manifest_resumable(&manifest, None)
1917            .unwrap_or_else(|error| {
1918                eprintln!(
1919                    "Failed to evaluate eval pack {}: {error}",
1920                    path_buf.display()
1921                );
1922                process::exit(1);
1923            });
1924        print_eval_pack_report(&report);
1925        if !report.pass {
1926            process::exit(1);
1927        }
1928        return;
1929    }
1930
1931    if path_buf.is_file() && file_looks_like_eval_manifest(&path_buf) {
1932        if compare.is_some() {
1933            eprintln!("--compare is not supported with eval suite manifests");
1934            process::exit(1);
1935        }
1936        let manifest = load_eval_suite_manifest_or_exit(&path_buf);
1937        let suite = harn_vm::orchestration::evaluate_run_suite_manifest(&manifest).unwrap_or_else(
1938            |error| {
1939                eprintln!(
1940                    "Failed to evaluate manifest {}: {error}",
1941                    path_buf.display()
1942                );
1943                process::exit(1);
1944            },
1945        );
1946        println!(
1947            "{} {} passed, {} failed, {} total",
1948            if suite.pass { "PASS" } else { "FAIL" },
1949            suite.passed,
1950            suite.failed,
1951            suite.total
1952        );
1953        for case in &suite.cases {
1954            println!(
1955                "- {} [{}] {}",
1956                case.label.clone().unwrap_or_else(|| case.run_id.clone()),
1957                case.workflow_id,
1958                if case.pass { "PASS" } else { "FAIL" }
1959            );
1960            if let Some(path) = &case.source_path {
1961                println!("  path: {path}");
1962            }
1963            if let Some(comparison) = &case.comparison {
1964                println!("  baseline identical: {}", comparison.identical);
1965                if !comparison.identical {
1966                    println!(
1967                        "  baseline status: {} -> {}",
1968                        comparison.left_status, comparison.right_status
1969                    );
1970                }
1971            }
1972            for failure in &case.failures {
1973                println!("  {failure}");
1974            }
1975        }
1976        if !suite.pass {
1977            process::exit(1);
1978        }
1979        return;
1980    }
1981
1982    let paths = collect_run_record_paths(path);
1983    if paths.len() > 1 {
1984        let mut cases = Vec::new();
1985        for path in &paths {
1986            let run = load_run_record_or_exit(path);
1987            let fixture = run
1988                .replay_fixture
1989                .clone()
1990                .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(&run));
1991            cases.push((run, fixture, Some(path.display().to_string())));
1992        }
1993        let suite = harn_vm::orchestration::evaluate_run_suite(cases);
1994        println!(
1995            "{} {} passed, {} failed, {} total",
1996            if suite.pass { "PASS" } else { "FAIL" },
1997            suite.passed,
1998            suite.failed,
1999            suite.total
2000        );
2001        for case in &suite.cases {
2002            println!(
2003                "- {} [{}] {}",
2004                case.run_id,
2005                case.workflow_id,
2006                if case.pass { "PASS" } else { "FAIL" }
2007            );
2008            if let Some(path) = &case.source_path {
2009                println!("  path: {path}");
2010            }
2011            if let Some(comparison) = &case.comparison {
2012                println!("  baseline identical: {}", comparison.identical);
2013            }
2014            for failure in &case.failures {
2015                println!("  {failure}");
2016            }
2017        }
2018        if !suite.pass {
2019            process::exit(1);
2020        }
2021        return;
2022    }
2023
2024    let run = load_run_record_or_exit(&paths[0]);
2025    let fixture = run
2026        .replay_fixture
2027        .clone()
2028        .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(&run));
2029    let report = harn_vm::orchestration::evaluate_run_against_fixture(&run, &fixture);
2030    println!("{}", if report.pass { "PASS" } else { "FAIL" });
2031    println!("Stages: {}", report.stage_count);
2032    if let Some(compare_path) = compare {
2033        let baseline = load_run_record_or_exit(Path::new(compare_path));
2034        print_run_diff(&harn_vm::orchestration::diff_run_records(&baseline, &run));
2035    }
2036    if !report.failures.is_empty() {
2037        for failure in &report.failures {
2038            println!("- {failure}");
2039        }
2040    }
2041    if !report.pass {
2042        process::exit(1);
2043    }
2044}
2045
2046fn print_eval_pack_report(report: &harn_vm::orchestration::EvalPackReport) {
2047    println!(
2048        "{} {} passed, {} blocking failed, {} warning, {} informational, {} total",
2049        if report.pass { "PASS" } else { "FAIL" },
2050        report.passed,
2051        report.blocking_failed,
2052        report.warning_failed,
2053        report.informational_failed,
2054        report.total
2055    );
2056    for case in &report.cases {
2057        println!(
2058            "- {} [{}] {} ({})",
2059            case.label,
2060            case.workflow_id,
2061            if case.pass { "PASS" } else { "FAIL" },
2062            case.severity
2063        );
2064        if let Some(path) = &case.source_path {
2065            println!("  path: {path}");
2066        }
2067        if let Some(comparison) = &case.comparison {
2068            println!("  baseline identical: {}", comparison.identical);
2069            if !comparison.identical {
2070                println!(
2071                    "  baseline status: {} -> {}",
2072                    comparison.left_status, comparison.right_status
2073                );
2074            }
2075        }
2076        for failure in &case.failures {
2077            println!("  {failure}");
2078        }
2079        for warning in &case.warnings {
2080            println!("  warning: {warning}");
2081        }
2082        for item in &case.informational {
2083            println!("  info: {item}");
2084        }
2085    }
2086    for ladder in &report.ladders {
2087        println!(
2088            "- ladder {} [{}] {} ({}) first_correct={}/{}",
2089            ladder.id,
2090            ladder.persona,
2091            if ladder.pass { "PASS" } else { "FAIL" },
2092            ladder.severity,
2093            ladder.first_correct_route.as_deref().unwrap_or("<none>"),
2094            ladder.first_correct_tier.as_deref().unwrap_or("<none>")
2095        );
2096        println!("  artifacts: {}", ladder.artifact_root);
2097        for tier in &ladder.tiers {
2098            println!(
2099                "  - {} [{}] {} tools={} models={} latency={}ms cost=${:.6}",
2100                tier.timeout_tier,
2101                tier.route_id,
2102                tier.outcome,
2103                tier.tool_calls,
2104                tier.model_calls,
2105                tier.latency_ms,
2106                tier.cost_usd
2107            );
2108            for reason in &tier.degradation_reasons {
2109                println!("    {reason}");
2110            }
2111        }
2112    }
2113}
2114
2115fn print_persona_ladder_report(report: &harn_vm::orchestration::PersonaEvalLadderReport) {
2116    println!(
2117        "{} ladder {} passed, {} degraded/looped, {} total",
2118        if report.pass { "PASS" } else { "FAIL" },
2119        report.passed,
2120        report.failed,
2121        report.total
2122    );
2123    println!(
2124        "first_correct: {}/{}",
2125        report.first_correct_route.as_deref().unwrap_or("<none>"),
2126        report.first_correct_tier.as_deref().unwrap_or("<none>")
2127    );
2128    println!("artifacts: {}", report.artifact_root);
2129    for tier in &report.tiers {
2130        println!(
2131            "- {} [{}] {} tools={} models={} latency={}ms cost=${:.6}",
2132            tier.timeout_tier,
2133            tier.route_id,
2134            tier.outcome,
2135            tier.tool_calls,
2136            tier.model_calls,
2137            tier.latency_ms,
2138            tier.cost_usd
2139        );
2140        for reason in &tier.degradation_reasons {
2141            println!("  {reason}");
2142        }
2143    }
2144}
2145
2146pub(crate) fn run_package_evals() {
2147    let paths = package::load_package_eval_pack_paths(None).unwrap_or_else(|error| {
2148        eprintln!("{error}");
2149        process::exit(1);
2150    });
2151    let mut all_pass = true;
2152    for path in &paths {
2153        println!("Eval pack: {}", path.display());
2154        let manifest = load_eval_pack_manifest_or_exit(path);
2155        let report = harn_vm::orchestration::evaluate_eval_pack_manifest_resumable(&manifest, None)
2156            .unwrap_or_else(|error| {
2157                eprintln!("Failed to evaluate eval pack {}: {error}", path.display());
2158                process::exit(1);
2159            });
2160        print_eval_pack_report(&report);
2161        all_pass &= report.pass;
2162    }
2163    if !all_pass {
2164        process::exit(1);
2165    }
2166}
2167
2168fn run_structural_experiment_eval(
2169    path: &Path,
2170    experiment: &str,
2171    argv: &[String],
2172    llm_mock_mode: &commands::run::CliLlmMockMode,
2173) {
2174    let baseline_dir = tempfile::Builder::new()
2175        .prefix("harn-eval-baseline-")
2176        .tempdir()
2177        .unwrap_or_else(|error| {
2178            command_error(&format!("failed to create baseline tempdir: {error}"))
2179        });
2180    let variant_dir = tempfile::Builder::new()
2181        .prefix("harn-eval-variant-")
2182        .tempdir()
2183        .unwrap_or_else(|error| {
2184            command_error(&format!("failed to create variant tempdir: {error}"))
2185        });
2186
2187    let baseline = spawn_eval_pipeline_run(path, baseline_dir.path(), None, argv, llm_mock_mode);
2188    if !baseline.status.success() {
2189        relay_subprocess_failure("baseline", &baseline);
2190    }
2191
2192    let variant = spawn_eval_pipeline_run(
2193        path,
2194        variant_dir.path(),
2195        Some(experiment),
2196        argv,
2197        llm_mock_mode,
2198    );
2199    if !variant.status.success() {
2200        relay_subprocess_failure("variant", &variant);
2201    }
2202
2203    let baseline_runs = collect_structural_eval_runs(baseline_dir.path());
2204    let variant_runs = collect_structural_eval_runs(variant_dir.path());
2205    if baseline_runs.is_empty() || variant_runs.is_empty() {
2206        eprintln!(
2207            "structural eval expected workflow run records under {} and {}, but one side was empty",
2208            baseline_dir.path().display(),
2209            variant_dir.path().display()
2210        );
2211        process::exit(1);
2212    }
2213    if baseline_runs.len() != variant_runs.len() {
2214        eprintln!(
2215            "structural eval produced different run counts: baseline={} variant={}",
2216            baseline_runs.len(),
2217            variant_runs.len()
2218        );
2219        process::exit(1);
2220    }
2221
2222    let mut baseline_ok = 0usize;
2223    let mut variant_ok = 0usize;
2224    let mut any_failures = false;
2225
2226    println!("Structural experiment: {experiment}");
2227    println!("Cases: {}", baseline_runs.len());
2228    for (baseline_run, variant_run) in baseline_runs.iter().zip(variant_runs.iter()) {
2229        let baseline_fixture = baseline_run
2230            .replay_fixture
2231            .clone()
2232            .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(baseline_run));
2233        let variant_fixture = variant_run
2234            .replay_fixture
2235            .clone()
2236            .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(variant_run));
2237        let baseline_report =
2238            harn_vm::orchestration::evaluate_run_against_fixture(baseline_run, &baseline_fixture);
2239        let variant_report =
2240            harn_vm::orchestration::evaluate_run_against_fixture(variant_run, &variant_fixture);
2241        let diff = harn_vm::orchestration::diff_run_records(baseline_run, variant_run);
2242        if baseline_report.pass {
2243            baseline_ok += 1;
2244        }
2245        if variant_report.pass {
2246            variant_ok += 1;
2247        }
2248        any_failures |= !baseline_report.pass || !variant_report.pass;
2249        println!(
2250            "- {} [{}]",
2251            variant_run
2252                .workflow_name
2253                .clone()
2254                .unwrap_or_else(|| variant_run.workflow_id.clone()),
2255            variant_run.task
2256        );
2257        println!(
2258            "  baseline: {}",
2259            if baseline_report.pass { "PASS" } else { "FAIL" }
2260        );
2261        for failure in &baseline_report.failures {
2262            println!("    {failure}");
2263        }
2264        println!(
2265            "  variant: {}",
2266            if variant_report.pass { "PASS" } else { "FAIL" }
2267        );
2268        for failure in &variant_report.failures {
2269            println!("    {failure}");
2270        }
2271        println!("  diff identical: {}", diff.identical);
2272        println!("  stage diffs: {}", diff.stage_diffs.len());
2273        println!("  tool diffs: {}", diff.tool_diffs.len());
2274        println!("  observability diffs: {}", diff.observability_diffs.len());
2275    }
2276
2277    println!("Baseline {} / {} passed", baseline_ok, baseline_runs.len());
2278    println!("Variant {} / {} passed", variant_ok, variant_runs.len());
2279
2280    if any_failures {
2281        process::exit(1);
2282    }
2283}
2284
2285fn spawn_eval_pipeline_run(
2286    path: &Path,
2287    run_dir: &Path,
2288    structural_experiment: Option<&str>,
2289    argv: &[String],
2290    llm_mock_mode: &commands::run::CliLlmMockMode,
2291) -> std::process::Output {
2292    let exe = env::current_exe().unwrap_or_else(|error| {
2293        command_error(&format!("failed to resolve current executable: {error}"))
2294    });
2295    let mut command = std::process::Command::new(exe);
2296    command.current_dir(path.parent().unwrap_or_else(|| Path::new(".")));
2297    command.arg("run");
2298    match llm_mock_mode {
2299        commands::run::CliLlmMockMode::Off => {}
2300        commands::run::CliLlmMockMode::Replay { fixture_path } => {
2301            command
2302                .arg("--llm-mock")
2303                .arg(absolute_cli_path(fixture_path));
2304        }
2305        commands::run::CliLlmMockMode::Record { fixture_path } => {
2306            command
2307                .arg("--llm-mock-record")
2308                .arg(absolute_cli_path(fixture_path));
2309        }
2310    }
2311    command.arg(path);
2312    if !argv.is_empty() {
2313        command.arg("--");
2314        command.args(argv);
2315    }
2316    command.env(harn_vm::runtime_paths::HARN_RUN_DIR_ENV, run_dir);
2317    if let Some(experiment) = structural_experiment {
2318        command.env("HARN_STRUCTURAL_EXPERIMENT", experiment);
2319    }
2320    command.output().unwrap_or_else(|error| {
2321        command_error(&format!(
2322            "failed to spawn `harn run {}` for structural eval: {error}",
2323            path.display()
2324        ))
2325    })
2326}
2327
2328fn absolute_cli_path(path: &Path) -> PathBuf {
2329    if path.is_absolute() {
2330        return path.to_path_buf();
2331    }
2332    env::current_dir()
2333        .unwrap_or_else(|_| PathBuf::from("."))
2334        .join(path)
2335}
2336
2337fn relay_subprocess_failure(label: &str, output: &std::process::Output) -> ! {
2338    let stdout = String::from_utf8_lossy(&output.stdout);
2339    let stderr = String::from_utf8_lossy(&output.stderr);
2340    if !stdout.trim().is_empty() {
2341        eprintln!("[{label}] stdout:\n{stdout}");
2342    }
2343    if !stderr.trim().is_empty() {
2344        eprintln!("[{label}] stderr:\n{stderr}");
2345    }
2346    process::exit(output.status.code().unwrap_or(1));
2347}
2348
2349fn collect_structural_eval_runs(dir: &Path) -> Vec<harn_vm::orchestration::RunRecord> {
2350    let mut paths: Vec<PathBuf> = fs::read_dir(dir)
2351        .unwrap_or_else(|error| {
2352            command_error(&format!(
2353                "failed to read structural eval run dir {}: {error}",
2354                dir.display()
2355            ))
2356        })
2357        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
2358        .filter(|entry| entry.extension().and_then(|ext| ext.to_str()) == Some("json"))
2359        .collect();
2360    paths.sort();
2361    let mut runs: Vec<_> = paths
2362        .iter()
2363        .map(|path| load_run_record_or_exit(path))
2364        .collect();
2365    runs.sort_by(|left, right| {
2366        (
2367            left.started_at.as_str(),
2368            left.workflow_id.as_str(),
2369            left.task.as_str(),
2370        )
2371            .cmp(&(
2372                right.started_at.as_str(),
2373                right.workflow_id.as_str(),
2374                right.task.as_str(),
2375            ))
2376    });
2377    runs
2378}
2379
2380/// Exits on error.
2381pub(crate) fn parse_source_file(path: &str) -> (String, Vec<harn_parser::SNode>) {
2382    ensure_builtin_signatures_installed();
2383
2384    let source = match fs::read_to_string(path) {
2385        Ok(s) => s,
2386        Err(e) => {
2387            eprintln!("Error reading {path}: {e}");
2388            process::exit(1);
2389        }
2390    };
2391
2392    let mut lexer = Lexer::new(&source);
2393    let tokens = match lexer.tokenize() {
2394        Ok(t) => t,
2395        Err(e) => {
2396            let diagnostic = harn_parser::diagnostic::render_diagnostic_with_code(
2397                &source,
2398                path,
2399                &error_span_from_lex(&e),
2400                "error",
2401                harn_parser::diagnostic::lexer_error_code(&e),
2402                &e.to_string(),
2403                Some("here"),
2404                None,
2405            );
2406            eprint!("{diagnostic}");
2407            process::exit(1);
2408        }
2409    };
2410
2411    let mut parser = Parser::new(tokens);
2412    let program = match parser.parse() {
2413        Ok(p) => p,
2414        Err(err) => {
2415            if parser.all_errors().is_empty() {
2416                let span = error_span_from_parse(&err);
2417                let diagnostic = harn_parser::diagnostic::render_diagnostic_with_code(
2418                    &source,
2419                    path,
2420                    &span,
2421                    "error",
2422                    harn_parser::diagnostic::parser_error_code(&err),
2423                    &harn_parser::diagnostic::parser_error_message(&err),
2424                    Some(harn_parser::diagnostic::parser_error_label(&err)),
2425                    harn_parser::diagnostic::parser_error_help(&err),
2426                );
2427                eprint!("{diagnostic}");
2428            } else {
2429                for e in parser.all_errors() {
2430                    let span = error_span_from_parse(e);
2431                    let diagnostic = harn_parser::diagnostic::render_diagnostic_with_code(
2432                        &source,
2433                        path,
2434                        &span,
2435                        "error",
2436                        harn_parser::diagnostic::parser_error_code(e),
2437                        &harn_parser::diagnostic::parser_error_message(e),
2438                        Some(harn_parser::diagnostic::parser_error_label(e)),
2439                        harn_parser::diagnostic::parser_error_help(e),
2440                    );
2441                    eprint!("{diagnostic}");
2442                }
2443            }
2444            process::exit(1);
2445        }
2446    };
2447
2448    (source, program)
2449}
2450
2451fn error_span_from_lex(e: &harn_lexer::LexerError) -> harn_lexer::Span {
2452    match e {
2453        harn_lexer::LexerError::UnexpectedCharacter(_, span)
2454        | harn_lexer::LexerError::UnterminatedString(span)
2455        | harn_lexer::LexerError::IntegerLiteralOutOfRange(_, span)
2456        | harn_lexer::LexerError::UnterminatedBlockComment(span) => *span,
2457    }
2458}
2459
2460fn error_span_from_parse(e: &harn_parser::ParserError) -> harn_lexer::Span {
2461    match e {
2462        harn_parser::ParserError::Unexpected { span, .. } => *span,
2463        harn_parser::ParserError::UnexpectedEof { span, .. } => *span,
2464    }
2465}
2466
2467/// The pipeline stage at which an `execute_*` call failed. Callers (the
2468/// conformance harness, the REPL) use this to label a failure accurately
2469/// instead of calling every failure a "runtime error" — a parse, typecheck,
2470/// or compile failure never reaches the VM.
2471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2472pub(crate) enum ExecStage {
2473    Parse,
2474    Typecheck,
2475    Compile,
2476    Runtime,
2477}
2478
2479impl ExecStage {
2480    /// Human-facing label for this stage, e.g. `"type error"`.
2481    pub(crate) fn label(self) -> &'static str {
2482        match self {
2483            ExecStage::Parse => "parse error",
2484            ExecStage::Typecheck => "type error",
2485            ExecStage::Compile => "compile error",
2486            ExecStage::Runtime => "runtime error",
2487        }
2488    }
2489}
2490
2491/// An `execute_*` failure tagged with the stage it came from. `Display`
2492/// renders only the bare message (matching the historical `String` error),
2493/// so callers that just print `{e}` are unaffected; the `stage` is available
2494/// for callers that want to label the failure.
2495#[derive(Debug, Clone)]
2496pub(crate) struct ExecError {
2497    pub(crate) stage: ExecStage,
2498    pub(crate) message: String,
2499}
2500
2501impl ExecError {
2502    fn new(stage: ExecStage, message: impl Into<String>) -> Self {
2503        Self {
2504            stage,
2505            message: message.into(),
2506        }
2507    }
2508}
2509
2510impl std::fmt::Display for ExecError {
2511    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2512        f.write_str(&self.message)
2513    }
2514}
2515
2516/// Used by REPL and conformance tests.
2517pub(crate) async fn execute(source: &str, source_path: Option<&Path>) -> Result<String, ExecError> {
2518    execute_with_skill_dirs(source, source_path, &[]).await
2519}
2520
2521pub(crate) async fn execute_with_skill_dirs(
2522    source: &str,
2523    source_path: Option<&Path>,
2524    cli_skill_dirs: &[PathBuf],
2525) -> Result<String, ExecError> {
2526    execute_with_skill_dirs_and_optional_harness(source, source_path, cli_skill_dirs, None).await
2527}
2528
2529pub(crate) async fn execute_with_skill_dirs_and_harness(
2530    source: &str,
2531    source_path: Option<&Path>,
2532    cli_skill_dirs: &[PathBuf],
2533    harness: harn_vm::Harness,
2534) -> Result<String, ExecError> {
2535    execute_with_skill_dirs_and_optional_harness(source, source_path, cli_skill_dirs, Some(harness))
2536        .await
2537}
2538
2539async fn execute_with_skill_dirs_and_optional_harness(
2540    source: &str,
2541    source_path: Option<&Path>,
2542    cli_skill_dirs: &[PathBuf],
2543    harness: Option<harn_vm::Harness>,
2544) -> Result<String, ExecError> {
2545    let mut lexer = Lexer::new(source);
2546    let tokens = lexer
2547        .tokenize()
2548        .map_err(|e| ExecError::new(ExecStage::Parse, e.to_string()))?;
2549    let mut parser = Parser::new(tokens);
2550    let program = parser
2551        .parse()
2552        .map_err(|e| ExecError::new(ExecStage::Parse, e.to_string()))?;
2553
2554    // Static cross-module resolution: when executed from a file, derive the
2555    // import graph so `execute` catches undefined calls at typecheck time.
2556    // The REPL / `-e` path invokes this without `source_path`, where there
2557    // is no importing file context; we fall back to no-imports checking.
2558    let mut checker = TypeChecker::new();
2559    if let Some(path) = source_path {
2560        checker = crate::typecheck_imports::checker_with_resolved_imports(checker, path);
2561    }
2562    let type_diagnostics = checker.check(&program);
2563    let mut warning_lines = Vec::new();
2564    for diag in &type_diagnostics {
2565        match diag.severity {
2566            DiagnosticSeverity::Error => {
2567                return Err(ExecError::new(ExecStage::Typecheck, diag.message.clone()))
2568            }
2569            DiagnosticSeverity::Warning => {
2570                warning_lines.push(format!("warning: {}", diag.message));
2571            }
2572        }
2573    }
2574
2575    let compiler = source_path
2576        .map(|path| compiler_for_source(path, source))
2577        .unwrap_or_default();
2578    let chunk = compiler
2579        .compile(&program)
2580        .map_err(|e| ExecError::new(ExecStage::Compile, e.to_string()))?;
2581
2582    let local = tokio::task::LocalSet::new();
2583    local
2584        .run_until(async {
2585            let mut vm = harn_vm::Vm::new();
2586            harn_vm::register_vm_stdlib(&mut vm);
2587            install_default_hostlib(&mut vm);
2588            let source_parent = source_path
2589                .and_then(|p| p.parent())
2590                .unwrap_or(std::path::Path::new("."));
2591            let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
2592            let store_base = project_root.as_deref().unwrap_or(source_parent);
2593            let execution_cwd = std::env::current_dir()
2594                .unwrap_or_else(|_| std::path::PathBuf::from("."))
2595                .to_string_lossy()
2596                .into_owned();
2597            let source_dir = source_parent.to_string_lossy().into_owned();
2598            if source_path.is_some_and(is_conformance_path) {
2599                harn_vm::event_log::install_memory_for_current_thread(64);
2600            }
2601            harn_vm::register_store_builtins(&mut vm, store_base);
2602            harn_vm::register_metadata_builtins(&mut vm, store_base);
2603            let pipeline_name = source_path
2604                .and_then(|p| p.file_stem())
2605                .and_then(|s| s.to_str())
2606                .unwrap_or("default");
2607            harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
2608            harn_vm::stdlib::process::set_thread_execution_context(Some(
2609                harn_vm::orchestration::RunExecutionRecord {
2610                    cwd: Some(execution_cwd),
2611                    project_root: project_root
2612                        .as_ref()
2613                        .map(|root| root.to_string_lossy().into_owned()),
2614                    source_dir: Some(source_dir),
2615                    env: std::collections::BTreeMap::new(),
2616                    adapter: None,
2617                    repo_path: None,
2618                    worktree_path: None,
2619                    branch: None,
2620                    base_ref: None,
2621                    cleanup: None,
2622                    grants: Vec::new(),
2623                },
2624            ));
2625            if let Some(ref root) = project_root {
2626                vm.set_project_root(root);
2627            }
2628            if let Some(path) = source_path {
2629                if let Some(parent) = path.parent() {
2630                    if !parent.as_os_str().is_empty() {
2631                        vm.set_source_dir(parent);
2632                    }
2633                }
2634            }
2635            // Conformance tests land here via `run_conformance_tests`; for
2636            // `skill_fs_*` fixtures to see the bundled `skills/` folder
2637            // we run the same layered discovery as `harn run`.
2638            let loaded = skill_loader::load_skills(&skill_loader::SkillLoaderInputs {
2639                cli_dirs: cli_skill_dirs.to_vec(),
2640                source_path: source_path.map(Path::to_path_buf),
2641            });
2642            skill_loader::emit_loader_warnings(&loaded.loader_warnings);
2643            skill_loader::install_skills_global(&mut vm, &loaded);
2644            let runtime_harness = match harness {
2645                Some(harness) => harness,
2646                None => {
2647                    let resolved = match source_path {
2648                        Some(path) => default_harness_for_manifest_or_base_dir(path, store_base),
2649                        None => default_harness_for_base_dir(store_base),
2650                    };
2651                    resolved.map_err(|error| {
2652                        ExecError::new(
2653                            ExecStage::Runtime,
2654                            format!("failed to configure harness secret provider: {error}"),
2655                        )
2656                    })
2657                }?,
2658            };
2659            vm.set_harness(runtime_harness);
2660            if let Some(path) = source_path {
2661                let extensions = package::load_runtime_extensions(path);
2662                package::install_runtime_extensions(&extensions);
2663                package::install_manifest_triggers(&mut vm, &extensions)
2664                    .await
2665                    .map_err(|error| {
2666                        ExecError::new(
2667                            ExecStage::Runtime,
2668                            format!("failed to install manifest triggers: {error}"),
2669                        )
2670                    })?;
2671                package::install_manifest_hooks(&mut vm, &extensions)
2672                    .await
2673                    .map_err(|error| {
2674                        ExecError::new(
2675                            ExecStage::Runtime,
2676                            format!("failed to install manifest hooks: {error}"),
2677                        )
2678                    })?;
2679            }
2680            let _event_log = harn_vm::event_log::active_event_log()
2681                .unwrap_or_else(|| harn_vm::event_log::install_memory_for_current_thread(64));
2682            let connector_clients_installed =
2683                should_install_default_connector_clients(source, source_path);
2684            if connector_clients_installed {
2685                install_default_connector_clients(store_base)
2686                    .await
2687                    .map_err(|error| {
2688                        ExecError::new(
2689                            ExecStage::Runtime,
2690                            format!("failed to initialize connector clients: {error}"),
2691                        )
2692                    })?;
2693            }
2694            let execution_result = vm
2695                .execute(&chunk)
2696                .await
2697                .map_err(|e| ExecError::new(ExecStage::Runtime, e.to_string()));
2698            harn_vm::egress::reset_egress_policy_for_host();
2699            if connector_clients_installed {
2700                harn_vm::clear_active_connector_clients();
2701            }
2702            harn_vm::stdlib::process::set_thread_execution_context(None);
2703            execution_result?;
2704            let mut output = String::new();
2705            for wl in &warning_lines {
2706                output.push_str(wl);
2707                output.push('\n');
2708            }
2709            output.push_str(vm.output());
2710            Ok(output)
2711        })
2712        .await
2713}
2714
2715fn should_install_default_connector_clients(source: &str, source_path: Option<&Path>) -> bool {
2716    if !source_path.is_some_and(is_conformance_path) {
2717        return true;
2718    }
2719    source.contains("connector_call")
2720        || source.contains("std/connectors")
2721        || source.contains("connectors/")
2722}
2723
2724fn is_conformance_path(path: &Path) -> bool {
2725    path.components()
2726        .any(|component| component.as_os_str() == "conformance")
2727}
2728
2729async fn install_default_connector_clients(base_dir: &Path) -> Result<(), String> {
2730    let event_log = harn_vm::event_log::active_event_log()
2731        .unwrap_or_else(|| harn_vm::event_log::install_memory_for_current_thread(64));
2732    let secret_namespace = connector_secret_namespace(base_dir);
2733    let secrets: Arc<dyn harn_vm::secrets::SecretProvider> = Arc::new(
2734        harn_vm::secrets::configured_default_chain(secret_namespace)
2735            .map_err(|error| format!("failed to configure secret providers: {error}"))?,
2736    );
2737
2738    let registry = harn_vm::ConnectorRegistry::default();
2739    let metrics = Arc::new(harn_vm::MetricsRegistry::default());
2740    let inbox = Arc::new(
2741        harn_vm::InboxIndex::new(event_log.clone(), metrics.clone())
2742            .await
2743            .map_err(|error| error.to_string())?,
2744    );
2745    registry
2746        .init_all(harn_vm::ConnectorCtx {
2747            event_log,
2748            secrets,
2749            inbox,
2750            metrics,
2751            rate_limiter: Arc::new(harn_vm::RateLimiterFactory::default()),
2752        })
2753        .await
2754        .map_err(|error| error.to_string())?;
2755    let clients = registry.client_map().await;
2756    harn_vm::install_active_connector_clients(clients);
2757    Ok(())
2758}
2759
2760fn connector_secret_namespace(base_dir: &Path) -> String {
2761    match std::env::var("HARN_SECRET_NAMESPACE") {
2762        Ok(namespace) if !namespace.trim().is_empty() => namespace,
2763        _ => {
2764            let leaf = base_dir
2765                .file_name()
2766                .and_then(|name| name.to_str())
2767                .filter(|name| !name.is_empty())
2768                .unwrap_or("workspace");
2769            format!("harn/{leaf}")
2770        }
2771    }
2772}
2773
2774pub(crate) fn default_harness_for_base_dir(base_dir: &Path) -> Result<harn_vm::Harness, String> {
2775    let secret_namespace = connector_secret_namespace(base_dir);
2776    default_harness_for_secret_namespace(secret_namespace)
2777}
2778
2779pub(crate) fn default_harness_for_manifest_or_base_dir(
2780    source_path: &Path,
2781    base_dir: &Path,
2782) -> Result<harn_vm::Harness, String> {
2783    let secret_namespace = package::find_nearest_manifest_dir(source_path)
2784        .map(|manifest_dir| connector_secret_namespace(&manifest_dir))
2785        .unwrap_or_else(|| connector_secret_namespace(base_dir));
2786    default_harness_for_secret_namespace(secret_namespace)
2787}
2788
2789fn default_harness_for_secret_namespace(
2790    secret_namespace: String,
2791) -> Result<harn_vm::Harness, String> {
2792    let secret_provider = Arc::new(
2793        harn_vm::secrets::configured_default_chain(secret_namespace)
2794            .map_err(|error| error.to_string())?,
2795    );
2796    Ok(harn_vm::Harness::real().with_secret_provider(secret_provider))
2797}
2798
2799#[cfg(test)]
2800mod main_tests {
2801    use super::{
2802        normalize_serve_args, serve_subcommand_names, should_install_default_connector_clients,
2803    };
2804    use std::path::Path;
2805
2806    #[test]
2807    fn normalize_serve_args_inserts_a2a_for_legacy_shape() {
2808        let args = normalize_serve_args(vec![
2809            "harn".to_string(),
2810            "serve".to_string(),
2811            "--port".to_string(),
2812            "3000".to_string(),
2813            "agent.harn".to_string(),
2814        ]);
2815        assert_eq!(
2816            args,
2817            vec![
2818                "harn".to_string(),
2819                "serve".to_string(),
2820                "a2a".to_string(),
2821                "--port".to_string(),
2822                "3000".to_string(),
2823                "agent.harn".to_string(),
2824            ]
2825        );
2826    }
2827
2828    #[test]
2829    fn normalize_serve_args_preserves_explicit_subcommands() {
2830        // Every transport clap knows must pass through untouched — a new
2831        // transport that the shim failed to recognize would be rewritten
2832        // to `a2a` and mis-parsed (the `site` regression that motivated
2833        // deriving the list from clap rather than hard-coding it).
2834        for transport in serve_subcommand_names() {
2835            let args = normalize_serve_args(vec![
2836                "harn".to_string(),
2837                "serve".to_string(),
2838                transport.clone(),
2839                "server.harn".to_string(),
2840            ]);
2841            assert_eq!(
2842                args,
2843                vec![
2844                    "harn".to_string(),
2845                    "serve".to_string(),
2846                    transport.clone(),
2847                    "server.harn".to_string(),
2848                ],
2849                "transport `{transport}` should not be rewritten",
2850            );
2851        }
2852    }
2853
2854    #[test]
2855    fn normalize_serve_args_recognizes_site_subcommand() {
2856        let args = normalize_serve_args(vec![
2857            "harn".to_string(),
2858            "serve".to_string(),
2859            "site".to_string(),
2860            "server.harn".to_string(),
2861        ]);
2862        assert_eq!(args.get(2).map(String::as_str), Some("site"));
2863    }
2864
2865    #[test]
2866    fn conformance_skips_connector_clients_unless_fixture_uses_connectors() {
2867        let path = Path::new("conformance/tests/language/basic.harn");
2868        assert!(!should_install_default_connector_clients(
2869            "__io_println(1)",
2870            Some(path)
2871        ));
2872        assert!(!should_install_default_connector_clients(
2873            "trust_graph_verify_chain()",
2874            Some(path)
2875        ));
2876        assert!(should_install_default_connector_clients(
2877            "import { post_message } from \"std/connectors/slack\"",
2878            Some(path)
2879        ));
2880        assert!(should_install_default_connector_clients(
2881            "__io_println(1)",
2882            Some(Path::new("examples/demo.harn"))
2883        ));
2884    }
2885}