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