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