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