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