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