Skip to main content

harn_cli/
lib.rs

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