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            let exit = commands::replay::run(args);
1126            if exit != 0 {
1127                process::exit(exit);
1128            }
1129        }
1130        Command::Eval(args) => match args.command {
1131            Some(EvalCommand::CodingAgent(coding_agent_args)) => {
1132                let code = commands::eval_coding_agent::run(coding_agent_args).await;
1133                if code != 0 {
1134                    process::exit(code);
1135                }
1136            }
1137            Some(EvalCommand::Context(context_args)) => {
1138                let code = commands::eval_context::run(context_args).await;
1139                if code != 0 {
1140                    process::exit(code);
1141                }
1142            }
1143            Some(EvalCommand::Prompt(prompt_args)) => {
1144                let code = commands::eval_prompt::run(prompt_args).await;
1145                if code != 0 {
1146                    process::exit(code);
1147                }
1148            }
1149            Some(EvalCommand::ScopeTriage(scope_args)) => {
1150                process::exit(commands::eval_scope_triage::run(scope_args).await)
1151            }
1152            Some(EvalCommand::ToolCalls(tool_calls_args)) => {
1153                let code = commands::eval_tool_calls::run(tool_calls_args).await;
1154                if code != 0 {
1155                    process::exit(code);
1156                }
1157            }
1158            None => {
1159                let Some(path) = args.path else {
1160                    eprintln!("error: `harn eval` requires a path or a subcommand (e.g. `prompt`).\nSee `harn eval --help`.");
1161                    process::exit(2);
1162                };
1163                let llm_mock_mode = if let Some(path) = args.llm_mock.as_ref() {
1164                    commands::run::CliLlmMockMode::Replay {
1165                        fixture_path: PathBuf::from(path),
1166                    }
1167                } else if let Some(path) = args.llm_mock_record.as_ref() {
1168                    commands::run::CliLlmMockMode::Record {
1169                        fixture_path: PathBuf::from(path),
1170                    }
1171                } else {
1172                    commands::run::CliLlmMockMode::Off
1173                };
1174                eval_run_record(
1175                    &path,
1176                    args.compare.as_deref(),
1177                    args.structural_experiment.as_deref(),
1178                    &args.argv,
1179                    &llm_mock_mode,
1180                );
1181            }
1182        },
1183        Command::Repl => commands::repl::run_repl().await,
1184        Command::Bench(args) => commands::bench::run(args).await,
1185        Command::Precompile(args) => commands::precompile::run(args).await,
1186        Command::Pack(args) => commands::pack::run(args),
1187        Command::TestBench(args) => commands::test_bench::run(args.command).await,
1188        Command::Viz(args) => commands::viz::run_viz(&args.file, args.output.as_deref()),
1189        Command::Install(args) => package::install_packages(
1190            args.frozen || args.locked || args.offline,
1191            args.refetch.as_deref(),
1192            args.offline,
1193            args.json,
1194        ),
1195        Command::Add(args) => package::add_package_with_registry(
1196            &args.name_or_spec,
1197            args.alias.as_deref(),
1198            args.git.as_deref(),
1199            args.tag.as_deref(),
1200            args.rev.as_deref(),
1201            args.branch.as_deref(),
1202            args.path.as_deref(),
1203            args.registry.as_deref(),
1204        ),
1205        Command::Update(args) => {
1206            package::update_packages(args.alias.as_deref(), args.all, args.json);
1207        }
1208        Command::Remove(args) => package::remove_package(&args.alias),
1209        Command::Lock => package::lock_packages(),
1210        Command::Package(args) => match args.command {
1211            PackageCommand::List(list) => package::list_packages(list.json),
1212            PackageCommand::Doctor(doctor) => package::doctor_packages(doctor.json),
1213            PackageCommand::Search(search) => package::search_package_registry(
1214                search.query.as_deref(),
1215                search.registry.as_deref(),
1216                search.json,
1217            ),
1218            PackageCommand::Info(info) => {
1219                package::show_package_registry_info(
1220                    &info.name,
1221                    info.registry.as_deref(),
1222                    info.json,
1223                );
1224            }
1225            PackageCommand::Check(check) => {
1226                package::check_package(check.package.as_deref(), check.json);
1227            }
1228            PackageCommand::Pack(pack) => package::pack_package(
1229                pack.package.as_deref(),
1230                pack.output.as_deref(),
1231                pack.dry_run,
1232                pack.json,
1233            ),
1234            PackageCommand::Docs(docs) => package::generate_package_docs(
1235                docs.package.as_deref(),
1236                docs.output.as_deref(),
1237                docs.check,
1238            ),
1239            PackageCommand::Cache(cache) => match cache.command {
1240                PackageCacheCommand::List => package::list_package_cache(),
1241                PackageCacheCommand::Clean(clean) => package::clean_package_cache(clean.all),
1242                PackageCacheCommand::Verify(verify) => {
1243                    package::verify_package_cache(verify.materialized);
1244                }
1245            },
1246            PackageCommand::Outdated(args) => package::outdated_packages(
1247                args.refresh,
1248                args.remote,
1249                args.registry.as_deref(),
1250                args.json,
1251            ),
1252            PackageCommand::Audit(args) => {
1253                package::audit_packages(
1254                    args.registry.as_deref(),
1255                    args.skip_materialized,
1256                    args.json,
1257                );
1258            }
1259            PackageCommand::Artifacts(args) => match args.command {
1260                PackageArtifactsCommand::Manifest(manifest) => {
1261                    package::artifacts_manifest(manifest.output.as_deref());
1262                }
1263                PackageArtifactsCommand::Check(check) => {
1264                    package::artifacts_check(&check.manifest, check.json);
1265                }
1266            },
1267            PackageCommand::Scaffold(args) => match args.command {
1268                PackageScaffoldCommand::Openapi(openapi) => {
1269                    if let Err(error) = commands::package_scaffold::run_openapi(&openapi).await {
1270                        eprintln!("error: {error}");
1271                        process::exit(1);
1272                    }
1273                }
1274            },
1275        },
1276        Command::Publish(args) => package::publish_package(
1277            args.package.as_deref(),
1278            args.dry_run,
1279            &args.remote,
1280            &args.index_repo,
1281            &args.index_path,
1282            args.registry_name.as_deref(),
1283            args.skip_index_pr,
1284            args.registry.as_deref(),
1285            args.json,
1286        ),
1287        Command::MergeCaptain(args) => match args.command {
1288            MergeCaptainCommand::Run(run) => {
1289                let code = commands::merge_captain::run_driver(&run);
1290                if code != 0 {
1291                    process::exit(code);
1292                }
1293            }
1294            MergeCaptainCommand::Ladder(ladder) => {
1295                let code = commands::merge_captain::run_ladder(&ladder);
1296                if code != 0 {
1297                    process::exit(code);
1298                }
1299            }
1300            MergeCaptainCommand::Iterate(iterate) => {
1301                let code = commands::merge_captain::run_iterate(&iterate);
1302                if code != 0 {
1303                    process::exit(code);
1304                }
1305            }
1306            MergeCaptainCommand::Audit(audit) => {
1307                let code = commands::merge_captain::run_audit(&audit);
1308                if code != 0 {
1309                    process::exit(code);
1310                }
1311            }
1312            MergeCaptainCommand::Mock(mock) => {
1313                let code = match mock {
1314                    MergeCaptainMockCommand::Init(args) => {
1315                        commands::merge_captain_mock::run_init(&args)
1316                    }
1317                    MergeCaptainMockCommand::Step(args) => {
1318                        commands::merge_captain_mock::run_step(&args)
1319                    }
1320                    MergeCaptainMockCommand::Status(args) => {
1321                        commands::merge_captain_mock::run_status(&args)
1322                    }
1323                    MergeCaptainMockCommand::Serve(args) => {
1324                        commands::merge_captain_mock::run_serve(&args).await
1325                    }
1326                    MergeCaptainMockCommand::Cleanup(args) => {
1327                        commands::merge_captain_mock::run_cleanup(&args)
1328                    }
1329                    MergeCaptainMockCommand::Scenarios => {
1330                        commands::merge_captain_mock::run_scenarios()
1331                    }
1332                };
1333                if code != 0 {
1334                    process::exit(code);
1335                }
1336            }
1337        },
1338        Command::Persona(args) => match args.command {
1339            PersonaCommand::New(new) => {
1340                if let Err(error) = commands::persona_scaffold::run_new(&new) {
1341                    eprintln!("error: {error}");
1342                    process::exit(1);
1343                }
1344            }
1345            PersonaCommand::Doctor(doctor) => {
1346                if let Err(error) =
1347                    commands::persona_doctor::run_doctor(args.manifest.as_deref(), &doctor).await
1348                {
1349                    eprintln!("error: {error}");
1350                    process::exit(1);
1351                }
1352            }
1353            PersonaCommand::Check(check) => {
1354                commands::persona::run_check(args.manifest.as_deref(), &check);
1355            }
1356            PersonaCommand::List(list) => {
1357                commands::persona::run_list(args.manifest.as_deref(), &list);
1358            }
1359            PersonaCommand::Inspect(inspect) => {
1360                commands::persona::run_inspect(args.manifest.as_deref(), &inspect);
1361            }
1362            PersonaCommand::Status(status) => {
1363                if let Err(error) = commands::persona::run_status(
1364                    args.manifest.as_deref(),
1365                    &args.state_dir,
1366                    &status,
1367                )
1368                .await
1369                {
1370                    eprintln!("error: {error}");
1371                    process::exit(1);
1372                }
1373            }
1374            PersonaCommand::Pause(control) => {
1375                if let Err(error) = commands::persona::run_pause(
1376                    args.manifest.as_deref(),
1377                    &args.state_dir,
1378                    &control,
1379                )
1380                .await
1381                {
1382                    eprintln!("error: {error}");
1383                    process::exit(1);
1384                }
1385            }
1386            PersonaCommand::Resume(control) => {
1387                if let Err(error) = commands::persona::run_resume(
1388                    args.manifest.as_deref(),
1389                    &args.state_dir,
1390                    &control,
1391                )
1392                .await
1393                {
1394                    eprintln!("error: {error}");
1395                    process::exit(1);
1396                }
1397            }
1398            PersonaCommand::Disable(control) => {
1399                if let Err(error) = commands::persona::run_disable(
1400                    args.manifest.as_deref(),
1401                    &args.state_dir,
1402                    &control,
1403                )
1404                .await
1405                {
1406                    eprintln!("error: {error}");
1407                    process::exit(1);
1408                }
1409            }
1410            PersonaCommand::Tick(tick) => {
1411                if let Err(error) =
1412                    commands::persona::run_tick(args.manifest.as_deref(), &args.state_dir, &tick)
1413                        .await
1414                {
1415                    eprintln!("error: {error}");
1416                    process::exit(1);
1417                }
1418            }
1419            PersonaCommand::Trigger(trigger) => {
1420                if let Err(error) = commands::persona::run_trigger(
1421                    args.manifest.as_deref(),
1422                    &args.state_dir,
1423                    &trigger,
1424                )
1425                .await
1426                {
1427                    eprintln!("error: {error}");
1428                    process::exit(1);
1429                }
1430            }
1431            PersonaCommand::Spend(spend) => {
1432                if let Err(error) =
1433                    commands::persona::run_spend(args.manifest.as_deref(), &args.state_dir, &spend)
1434                        .await
1435                {
1436                    eprintln!("error: {error}");
1437                    process::exit(1);
1438                }
1439            }
1440            PersonaCommand::Supervision(supervision) => match supervision.command {
1441                PersonaSupervisionCommand::Tail(tail) => {
1442                    if let Err(error) = commands::persona_supervision::run_tail(
1443                        args.manifest.as_deref(),
1444                        &args.state_dir,
1445                        &tail,
1446                    )
1447                    .await
1448                    {
1449                        eprintln!("error: {error}");
1450                        process::exit(1);
1451                    }
1452                }
1453            },
1454        },
1455        Command::ModelInfo(args) => {
1456            if !print_model_info(&args).await {
1457                process::exit(1);
1458            }
1459        }
1460        Command::ProviderCatalog(args) => {
1461            if std::env::var("HARN_CLI_IMPL").as_deref() == Ok("rust") {
1462                print_provider_catalog(args.available_only);
1463            } else {
1464                let exit_code = dispatch_provider_catalog(args.available_only).await;
1465                if exit_code != 0 {
1466                    process::exit(exit_code);
1467                }
1468            }
1469        }
1470        Command::ProviderReady(args) => {
1471            run_provider_ready(
1472                &args.provider,
1473                args.model.as_deref(),
1474                args.base_url.as_deref(),
1475                args.json,
1476            )
1477            .await;
1478        }
1479        Command::ProviderProbe(args) => commands::provider::run_provider_probe(args).await,
1480        Command::ProviderToolProbe(args) => commands::provider::run_provider_tool_probe(args).await,
1481        Command::Skills(args) => match args.command {
1482            SkillsCommand::List(list) => commands::skills::run_list(&list),
1483            SkillsCommand::Get(get) => commands::skills::run_get(&get),
1484            SkillsCommand::Dump(dump) => commands::skills::run_dump(&dump),
1485            SkillsCommand::Resolved(resolved) => commands::skills::run_resolved(&resolved),
1486            SkillsCommand::Inspect(inspect) => commands::skills::run_inspect(&inspect),
1487            SkillsCommand::Match(matcher) => commands::skills::run_match(&matcher),
1488            SkillsCommand::Install(install) => commands::skills::run_install(&install),
1489            SkillsCommand::New(new_args) => commands::skills::run_new(&new_args),
1490        },
1491        Command::Tool(args) => match args.command {
1492            ToolCommand::New(new_args) => {
1493                if let Err(error) = commands::tool::run_new(&new_args).await {
1494                    eprintln!("error: {error}");
1495                    process::exit(1);
1496                }
1497            }
1498        },
1499        Command::DumpHighlightKeywords(args) => {
1500            commands::dump_highlight_keywords::run(&args.output, args.check);
1501        }
1502        Command::DumpTriggerQuickref(args) => {
1503            commands::dump_trigger_quickref::run(&args.output, args.check);
1504        }
1505        Command::DumpConnectorMatrix(args) => {
1506            commands::check::connector_matrix::run_docs(&args.output, &args.sources, args.check);
1507        }
1508        Command::DumpProtocolArtifacts(args) => {
1509            commands::dump_protocol_artifacts::run(&args.output_dir, args.check);
1510        }
1511        Command::Time(args) => match args.command {
1512            TimeCommand::Run(time_args) => commands::time::run(time_args).await,
1513        },
1514    }
1515}
1516
1517fn run_profile_options(args: &cli::ProfileArgs) -> commands::run::RunProfileOptions {
1518    commands::run::RunProfileOptions {
1519        text: args.text,
1520        json_path: args.json_path.clone(),
1521    }
1522}
1523
1524fn print_completions(shell: CompletionShell) {
1525    let mut command = Cli::command();
1526    let shell = clap_complete::Shell::from(shell);
1527    clap_complete::generate(shell, &mut command, "harn", &mut std::io::stdout());
1528}
1529
1530fn normalize_serve_args(mut raw_args: Vec<String>) -> Vec<String> {
1531    if raw_args.len() > 2
1532        && raw_args.get(1).is_some_and(|arg| arg == "serve")
1533        && !matches!(
1534            raw_args.get(2).map(String::as_str),
1535            Some("acp" | "a2a" | "api" | "mcp" | "-h" | "--help")
1536        )
1537    {
1538        raw_args.insert(2, "a2a".to_string());
1539    }
1540    raw_args
1541}
1542
1543fn print_version() {
1544    println!(
1545        r"
1546 ╱▔▔╲
1547 ╱    ╲    harn v{}
1548 │ ◆  │    the agent harness language
1549 │    │
1550 ╰──╯╱
1551   ╱╱
1552",
1553        env!("CARGO_PKG_VERSION")
1554    );
1555}
1556
1557/// Schema version for `harn version --json`. Bump when the data shape
1558/// changes; new optional fields can be added freely.
1559pub(crate) const VERSION_SCHEMA_VERSION: u32 = 1;
1560
1561#[derive(serde::Serialize)]
1562struct VersionInfo {
1563    name: &'static str,
1564    version: &'static str,
1565    description: &'static str,
1566}
1567
1568fn print_version_json() {
1569    let payload = VersionInfo {
1570        name: env!("CARGO_PKG_NAME"),
1571        version: env!("CARGO_PKG_VERSION"),
1572        description: env!("CARGO_PKG_DESCRIPTION"),
1573    };
1574    let envelope = json_envelope::JsonEnvelope::ok(VERSION_SCHEMA_VERSION, payload);
1575    println!("{}", json_envelope::to_string_pretty(&envelope));
1576}
1577
1578/// Run `harn version`. Dispatches to the embedded `.harn` script by
1579/// default; set `HARN_CLI_IMPL=rust` to keep the legacy Rust handlers
1580/// (used by the parity-snapshot harness to compare both impls).
1581async fn run_version(args: cli::VersionArgs) -> i32 {
1582    if std::env::var("HARN_CLI_IMPL").as_deref() == Ok("rust") {
1583        if args.json {
1584            print_version_json();
1585        } else {
1586            print_version();
1587        }
1588        return 0;
1589    }
1590    // Build-time constants travel to the script via scoped env vars
1591    // rather than a new builtin — the script reads them with
1592    // `env_or("HARN_BUILD_VERSION", "unknown")`.
1593    let _name = env_guard::ScopedEnvVar::set("HARN_BUILD_NAME", env!("CARGO_PKG_NAME"));
1594    let _version = env_guard::ScopedEnvVar::set("HARN_BUILD_VERSION", env!("CARGO_PKG_VERSION"));
1595    let _description =
1596        env_guard::ScopedEnvVar::set("HARN_BUILD_DESCRIPTION", env!("CARGO_PKG_DESCRIPTION"));
1597    let argv = if args.json {
1598        vec!["--json".to_string()]
1599    } else {
1600        Vec::new()
1601    };
1602    dispatch::dispatch_to_embedded_script("version", argv, args.json).await
1603}
1604
1605async fn print_model_info(args: &ModelInfoArgs) -> bool {
1606    let resolved = harn_vm::llm_config::resolve_model_info(&args.model);
1607    let api_key_result = harn_vm::llm::resolve_api_key(&resolved.provider);
1608    let api_key_set = api_key_result.is_ok();
1609    let api_key = api_key_result.unwrap_or_default();
1610    let context_window =
1611        harn_vm::llm::fetch_provider_max_context(&resolved.provider, &resolved.id, &api_key).await;
1612    let readiness = local_openai_readiness(&resolved.provider, &resolved.id, &api_key).await;
1613    let catalog = harn_vm::llm_config::model_catalog_entry(&resolved.id);
1614    let runtime_context_window = catalog
1615        .as_ref()
1616        .and_then(|entry| entry.runtime_context_window);
1617    let capabilities = harn_vm::llm::capabilities::lookup(&resolved.provider, &resolved.id);
1618    let mut payload = serde_json::json!({
1619        "alias": args.model,
1620        "id": resolved.id,
1621        "provider": resolved.provider,
1622        "resolved_alias": resolved.alias,
1623        "tool_format": resolved.tool_format,
1624        "tier": resolved.tier,
1625        "api_key_set": api_key_set,
1626        "context_window": context_window,
1627        "runtime_context_window": runtime_context_window,
1628        "readiness": readiness,
1629        "catalog": catalog,
1630        "capabilities": {
1631            "native_tools": capabilities.native_tools,
1632            "defer_loading": capabilities.defer_loading,
1633            "tool_search": capabilities.tool_search,
1634            "max_tools": capabilities.max_tools,
1635            "prompt_caching": capabilities.prompt_caching,
1636            "vision": capabilities.vision,
1637            "vision_supported": capabilities.vision_supported,
1638            "audio": capabilities.audio,
1639            "pdf": capabilities.pdf,
1640            "files_api_supported": capabilities.files_api_supported,
1641            "json_schema": capabilities.json_schema,
1642            "prefers_xml_scaffolding": capabilities.prefers_xml_scaffolding,
1643            "prefers_markdown_scaffolding": capabilities.prefers_markdown_scaffolding,
1644            "structured_output_mode": capabilities.structured_output_mode,
1645            "supports_assistant_prefill": capabilities.supports_assistant_prefill,
1646            "prefers_role_developer": capabilities.prefers_role_developer,
1647            "prefers_xml_tools": capabilities.prefers_xml_tools,
1648            "thinking": !capabilities.thinking_modes.is_empty(),
1649            "thinking_block_style": capabilities.thinking_block_style,
1650            "thinking_modes": capabilities.thinking_modes,
1651            "interleaved_thinking_supported": capabilities.interleaved_thinking_supported,
1652            "anthropic_beta_features": capabilities.anthropic_beta_features,
1653            "preserve_thinking": capabilities.preserve_thinking,
1654            "server_parser": capabilities.server_parser,
1655            "honors_chat_template_kwargs": capabilities.honors_chat_template_kwargs,
1656            "recommended_endpoint": capabilities.recommended_endpoint,
1657            "text_tool_wire_format_supported": capabilities.text_tool_wire_format_supported,
1658            "preferred_tool_format": capabilities.preferred_tool_format,
1659            "tool_mode_parity": capabilities.tool_mode_parity,
1660            "tool_mode_parity_notes": capabilities.tool_mode_parity_notes,
1661        },
1662        "qc_default_model": harn_vm::llm_config::qc_default_model(&resolved.provider),
1663    });
1664
1665    let should_verify = args.verify || args.warm;
1666    let mut ok = true;
1667    if should_verify {
1668        if resolved.provider == "ollama" {
1669            let mut readiness = harn_vm::llm::OllamaReadinessOptions::new(resolved.id.clone());
1670            readiness.warm = args.warm;
1671            readiness.observe_loaded = true;
1672            readiness.keep_alive = args
1673                .keep_alive
1674                .as_deref()
1675                .and_then(harn_vm::llm::normalize_ollama_keep_alive);
1676            let result = harn_vm::llm::ollama_readiness(readiness).await;
1677            ok = result.valid;
1678            payload["readiness"] = serde_json::to_value(&result).unwrap_or_else(|error| {
1679                serde_json::json!({
1680                    "valid": false,
1681                    "status": "serialization_error",
1682                    "message": format!("failed to serialize readiness result: {error}"),
1683                })
1684            });
1685        } else {
1686            ok = false;
1687            payload["readiness"] = serde_json::json!({
1688                "valid": false,
1689                "status": "unsupported_provider",
1690                "message": format!(
1691                    "model-info --verify is only supported for Ollama models; resolved provider is '{}'",
1692                    resolved.provider
1693                ),
1694                "provider": resolved.provider,
1695            });
1696        }
1697    }
1698
1699    println!(
1700        "{}",
1701        serde_json::to_string(&payload).unwrap_or_else(|error| {
1702            command_error(&format!("failed to serialize model info: {error}"))
1703        })
1704    );
1705    ok
1706}
1707
1708async fn local_openai_readiness(
1709    provider: &str,
1710    model: &str,
1711    api_key: &str,
1712) -> Option<serde_json::Value> {
1713    let def = harn_vm::llm_config::provider_config(provider)?;
1714    if def.auth_style != "none" || !harn_vm::llm::supports_model_readiness_probe(&def) {
1715        return None;
1716    }
1717    let readiness = harn_vm::llm::probe_openai_compatible_model(provider, model, api_key).await;
1718    Some(serde_json::json!({
1719        "valid": readiness.valid,
1720        "category": readiness.category,
1721        "message": readiness.message,
1722        "provider": readiness.provider,
1723        "model": readiness.model,
1724        "url": readiness.url,
1725        "status": readiness.status,
1726        "available_models": readiness.available_models,
1727    }))
1728}
1729
1730fn build_provider_catalog_payload(available_only: bool) -> serde_json::Value {
1731    let provider_names = if available_only {
1732        harn_vm::llm_config::available_provider_names()
1733    } else {
1734        harn_vm::llm_config::provider_names()
1735    };
1736    let providers: Vec<_> = provider_names
1737        .into_iter()
1738        .filter_map(|name| {
1739            harn_vm::llm_config::provider_config(&name).map(|def| {
1740                serde_json::json!({
1741                    "name": name,
1742                    "display_name": def.display_name,
1743                    "icon": def.icon,
1744                    "base_url": harn_vm::llm_config::resolve_base_url(&def),
1745                    "base_url_env": def.base_url_env,
1746                    "auth_style": def.auth_style,
1747                    "auth_envs": harn_vm::llm_config::auth_env_names(&def.auth_env),
1748                    "auth_available": harn_vm::llm_config::provider_key_available(&name),
1749                    "features": def.features,
1750                    "cost_per_1k_in": def.cost_per_1k_in,
1751                    "cost_per_1k_out": def.cost_per_1k_out,
1752                    "latency_p50_ms": def.latency_p50_ms,
1753                })
1754            })
1755        })
1756        .collect();
1757    let models: Vec<_> = harn_vm::llm_config::model_catalog_entries()
1758        .into_iter()
1759        .map(|(id, model)| {
1760            serde_json::json!({
1761                "id": id,
1762                "name": model.name,
1763                "provider": model.provider,
1764                "context_window": model.context_window,
1765                "runtime_context_window": model.runtime_context_window,
1766                "stream_timeout": model.stream_timeout,
1767                "capabilities": model.capabilities,
1768                "pricing": model.pricing,
1769            })
1770        })
1771        .collect();
1772    let aliases: Vec<_> = harn_vm::llm_config::alias_entries()
1773        .into_iter()
1774        .map(|(name, alias)| {
1775            serde_json::json!({
1776                "name": name,
1777                "id": alias.id,
1778                "provider": alias.provider,
1779                "tool_format": alias.tool_format,
1780                "tool_calling": harn_vm::llm_config::alias_tool_calling_entry(&name),
1781            })
1782        })
1783        .collect();
1784    serde_json::json!({
1785        "providers": providers,
1786        "known_model_names": harn_vm::llm_config::known_model_names(),
1787        "available_providers": harn_vm::llm_config::available_provider_names(),
1788        "aliases": aliases,
1789        "models": models,
1790        "qc_defaults": harn_vm::llm_config::qc_defaults(),
1791    })
1792}
1793
1794fn print_provider_catalog(available_only: bool) {
1795    let payload = build_provider_catalog_payload(available_only);
1796    println!(
1797        "{}",
1798        serde_json::to_string(&payload).unwrap_or_else(|error| {
1799            command_error(&format!("failed to serialize provider catalog: {error}"))
1800        })
1801    );
1802}
1803
1804/// Dispatch shim for `harn provider-catalog`. Aggregation stays in
1805/// Rust (the script can't reach `llm_config` for the catalog walk);
1806/// the .harn renderer in `stdlib/cli/providers/catalog.harn` only
1807/// re-emits the JSON envelope.
1808///
1809/// Lock keeps concurrent in-process callers from racing on the global
1810/// env var the dispatch wedge reads — same pattern as the other
1811/// partial-port commands (see harn#2305 / #2309).
1812async fn dispatch_provider_catalog(available_only: bool) -> i32 {
1813    static DISPATCH_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1814    let payload = build_provider_catalog_payload(available_only);
1815    let payload_json = match serde_json::to_string(&payload) {
1816        Ok(json) => json,
1817        Err(error) => {
1818            eprintln!("error: failed to serialise provider catalog payload: {error}");
1819            return 1;
1820        }
1821    };
1822    let _guard = DISPATCH_LOCK.lock().await;
1823    let _payload_guard =
1824        crate::env_guard::ScopedEnvVar::set("HARN_PROVIDER_CATALOG_PAYLOAD_JSON", &payload_json);
1825    // `--available-only` doesn't enable JSON; the catalog dump is JSON-
1826    // only on both impls, but pass `true` so the dispatch wedge sets
1827    // HARN_OUTPUT_JSON for symmetry with peer scripts.
1828    crate::dispatch::dispatch_to_embedded_script("providers/catalog", Vec::new(), true).await
1829}
1830
1831async fn run_provider_ready(
1832    provider: &str,
1833    model: Option<&str>,
1834    base_url: Option<&str>,
1835    json: bool,
1836) {
1837    let readiness =
1838        harn_vm::llm::readiness::probe_provider_readiness(provider, model, base_url).await;
1839    if json {
1840        match serde_json::to_string_pretty(&readiness) {
1841            Ok(payload) => println!("{payload}"),
1842            Err(error) => command_error(&format!("failed to serialize readiness result: {error}")),
1843        }
1844    } else if readiness.ok {
1845        println!("{}", readiness.message);
1846    } else {
1847        eprintln!("{}", readiness.message);
1848    }
1849    if !readiness.ok {
1850        process::exit(1);
1851    }
1852}
1853
1854fn command_error(message: &str) -> ! {
1855    Cli::command()
1856        .error(ErrorKind::ValueValidation, message)
1857        .exit()
1858}
1859
1860fn print_check_error(code: &str, message: &str) -> ! {
1861    let envelope: json_envelope::JsonEnvelope<commands::check::CheckReport> =
1862        json_envelope::JsonEnvelope::err(commands::check::CHECK_SCHEMA_VERSION, code, message);
1863    println!("{}", json_envelope::to_string_pretty(&envelope));
1864    process::exit(1);
1865}
1866
1867fn print_lint_error(code: &str, message: &str) -> ! {
1868    let envelope: json_envelope::JsonEnvelope<commands::check::LintReport> =
1869        json_envelope::JsonEnvelope::err(commands::check::LINT_SCHEMA_VERSION, code, message);
1870    println!("{}", json_envelope::to_string_pretty(&envelope));
1871    process::exit(1);
1872}
1873
1874fn verify_provenance_receipt(path: &str, json: bool) -> Result<(), String> {
1875    let raw =
1876        fs::read_to_string(path).map_err(|error| format!("failed to read {path}: {error}"))?;
1877    let receipt: harn_vm::ProvenanceReceipt = serde_json::from_str(&raw)
1878        .map_err(|error| format!("failed to parse provenance receipt {path}: {error}"))?;
1879    let report = harn_vm::verify_receipt(&receipt);
1880    if json {
1881        println!(
1882            "{}",
1883            serde_json::to_string_pretty(&report).map_err(|error| error.to_string())?
1884        );
1885    } else if report.verified {
1886        println!(
1887            "verified receipt={} events={} receipt_hash={} event_root_hash={}",
1888            report.receipt_id.unwrap_or_else(|| "-".to_string()),
1889            report.event_count,
1890            report.receipt_hash.unwrap_or_else(|| "-".to_string()),
1891            report.event_root_hash.unwrap_or_else(|| "-".to_string())
1892        );
1893    } else {
1894        println!(
1895            "failed receipt={} events={}",
1896            report.receipt_id.unwrap_or_else(|| "-".to_string()),
1897            report.event_count
1898        );
1899        for error in &report.errors {
1900            println!("  {error}");
1901        }
1902        return Err("provenance receipt verification failed".to_string());
1903    }
1904    Ok(())
1905}
1906
1907fn load_run_record_or_exit(path: &Path) -> harn_vm::orchestration::RunRecord {
1908    match harn_vm::orchestration::load_run_record(path) {
1909        Ok(run) => run,
1910        Err(error) => {
1911            eprintln!("Failed to load run record: {error}");
1912            process::exit(1);
1913        }
1914    }
1915}
1916
1917fn load_eval_suite_manifest_or_exit(path: &Path) -> harn_vm::orchestration::EvalSuiteManifest {
1918    harn_vm::orchestration::load_eval_suite_manifest(path).unwrap_or_else(|error| {
1919        eprintln!("Failed to load eval manifest {}: {error}", path.display());
1920        process::exit(1);
1921    })
1922}
1923
1924fn load_eval_pack_manifest_or_exit(path: &Path) -> harn_vm::orchestration::EvalPackManifest {
1925    harn_vm::orchestration::load_eval_pack_manifest(path).unwrap_or_else(|error| {
1926        eprintln!("Failed to load eval pack {}: {error}", path.display());
1927        process::exit(1);
1928    })
1929}
1930
1931fn load_persona_eval_ladder_manifest_or_exit(
1932    path: &Path,
1933) -> harn_vm::orchestration::PersonaEvalLadderManifest {
1934    harn_vm::orchestration::load_persona_eval_ladder_manifest(path).unwrap_or_else(|error| {
1935        eprintln!(
1936            "Failed to load persona eval ladder {}: {error}",
1937            path.display()
1938        );
1939        process::exit(1);
1940    })
1941}
1942
1943fn file_looks_like_eval_manifest(path: &Path) -> bool {
1944    if path.file_name().and_then(|name| name.to_str()) == Some("harn.eval.toml") {
1945        return true;
1946    }
1947    if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
1948        let Ok(content) = fs::read_to_string(path) else {
1949            return false;
1950        };
1951        return toml::from_str::<harn_vm::orchestration::EvalPackManifest>(&content)
1952            .is_ok_and(|manifest| !manifest.cases.is_empty() || !manifest.ladders.is_empty());
1953    }
1954    let Ok(content) = fs::read_to_string(path) else {
1955        return false;
1956    };
1957    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1958        return false;
1959    };
1960    json.get("_type").and_then(|value| value.as_str()) == Some("eval_suite_manifest")
1961        || json.get("cases").is_some()
1962}
1963
1964fn file_looks_like_eval_pack_manifest(path: &Path) -> bool {
1965    if path.file_name().and_then(|name| name.to_str()) == Some("harn.eval.toml") {
1966        return true;
1967    }
1968    if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
1969        return file_looks_like_eval_manifest(path);
1970    }
1971    let Ok(content) = fs::read_to_string(path) else {
1972        return false;
1973    };
1974    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1975        return false;
1976    };
1977    json.get("version").is_some()
1978        && (json.get("cases").is_some() || json.get("ladders").is_some())
1979        && json.get("_type").and_then(|value| value.as_str()) != Some("eval_suite_manifest")
1980}
1981
1982fn file_looks_like_persona_eval_ladder_manifest(path: &Path) -> bool {
1983    let Ok(content) = fs::read_to_string(path) else {
1984        return false;
1985    };
1986    if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
1987        let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1988            return false;
1989        };
1990        return json.get("_type").and_then(|value| value.as_str())
1991            == Some("persona_eval_ladder_manifest")
1992            || json.get("timeout_tiers").is_some()
1993            || json.get("timeout-tiers").is_some();
1994    }
1995    toml::from_str::<harn_vm::orchestration::PersonaEvalLadderManifest>(&content).is_ok_and(
1996        |manifest| {
1997            manifest
1998                .type_name
1999                .eq_ignore_ascii_case("persona_eval_ladder_manifest")
2000                || (!manifest.timeout_tiers.is_empty() && manifest.backend.path.is_some())
2001        },
2002    )
2003}
2004
2005fn collect_run_record_paths(path: &str) -> Vec<PathBuf> {
2006    let path = Path::new(path);
2007    if path.is_file() {
2008        return vec![path.to_path_buf()];
2009    }
2010    if path.is_dir() {
2011        let mut entries: Vec<PathBuf> = fs::read_dir(path)
2012            .unwrap_or_else(|error| {
2013                eprintln!("Failed to read run directory {}: {error}", path.display());
2014                process::exit(1);
2015            })
2016            .filter_map(|entry| entry.ok().map(|entry| entry.path()))
2017            .filter(|entry| entry.extension().and_then(|ext| ext.to_str()) == Some("json"))
2018            .collect();
2019        entries.sort();
2020        return entries;
2021    }
2022    eprintln!("Run path does not exist: {}", path.display());
2023    process::exit(1);
2024}
2025
2026fn print_run_diff(diff: &harn_vm::orchestration::RunDiffReport) {
2027    println!(
2028        "Diff: {} -> {} [{} -> {}]",
2029        diff.left_run_id, diff.right_run_id, diff.left_status, diff.right_status
2030    );
2031    println!("Identical: {}", diff.identical);
2032    println!("Stage diffs: {}", diff.stage_diffs.len());
2033    println!("Tool diffs: {}", diff.tool_diffs.len());
2034    println!("Observability diffs: {}", diff.observability_diffs.len());
2035    println!("Transition delta: {}", diff.transition_count_delta);
2036    println!("Artifact delta: {}", diff.artifact_count_delta);
2037    println!("Checkpoint delta: {}", diff.checkpoint_count_delta);
2038    for stage in &diff.stage_diffs {
2039        println!("- {} [{}]", stage.node_id, stage.change);
2040        for detail in &stage.details {
2041            println!("  {detail}");
2042        }
2043    }
2044    for tool in &diff.tool_diffs {
2045        println!("- tool {} [{}]", tool.tool_name, tool.args_hash);
2046        println!("  left: {:?}", tool.left_result);
2047        println!("  right: {:?}", tool.right_result);
2048    }
2049    for item in &diff.observability_diffs {
2050        println!("- {} [{}]", item.label, item.section);
2051        for detail in &item.details {
2052            println!("  {detail}");
2053        }
2054    }
2055}
2056
2057fn inspect_run_record(path: &str, compare: Option<&str>) {
2058    let run = load_run_record_or_exit(Path::new(path));
2059    println!("Run: {}", run.id);
2060    println!(
2061        "Workflow: {}",
2062        run.workflow_name
2063            .clone()
2064            .unwrap_or_else(|| run.workflow_id.clone())
2065    );
2066    println!("Status: {}", run.status);
2067    println!("Task: {}", run.task);
2068    println!("Stages: {}", run.stages.len());
2069    println!("Artifacts: {}", run.artifacts.len());
2070    println!("Transitions: {}", run.transitions.len());
2071    println!("Checkpoints: {}", run.checkpoints.len());
2072    println!("HITL questions: {}", run.hitl_questions.len());
2073    if let Some(observability) = &run.observability {
2074        println!("Planner rounds: {}", observability.planner_rounds.len());
2075        println!("Research facts: {}", observability.research_fact_count);
2076        println!("Workers: {}", observability.worker_lineage.len());
2077        println!(
2078            "Action graph: {} nodes / {} edges",
2079            observability.action_graph_nodes.len(),
2080            observability.action_graph_edges.len()
2081        );
2082        println!(
2083            "Transcript pointers: {}",
2084            observability.transcript_pointers.len()
2085        );
2086        println!("Daemon events: {}", observability.daemon_events.len());
2087    }
2088    if let Some(parent_worker_id) = run
2089        .metadata
2090        .get("parent_worker_id")
2091        .and_then(|value| value.as_str())
2092    {
2093        println!("Parent worker: {parent_worker_id}");
2094    }
2095    if let Some(parent_stage_id) = run
2096        .metadata
2097        .get("parent_stage_id")
2098        .and_then(|value| value.as_str())
2099    {
2100        println!("Parent stage: {parent_stage_id}");
2101    }
2102    if run
2103        .metadata
2104        .get("delegated")
2105        .and_then(|value| value.as_bool())
2106        .unwrap_or(false)
2107    {
2108        println!("Delegated: true");
2109    }
2110    println!(
2111        "Pending nodes: {}",
2112        if run.pending_nodes.is_empty() {
2113            "-".to_string()
2114        } else {
2115            run.pending_nodes.join(", ")
2116        }
2117    );
2118    println!(
2119        "Replay fixture: {}",
2120        if run.replay_fixture.is_some() {
2121            "embedded"
2122        } else {
2123            "derived"
2124        }
2125    );
2126    for stage in &run.stages {
2127        let worker = stage.metadata.get("worker");
2128        let worker_suffix = worker
2129            .and_then(|value| value.get("name"))
2130            .and_then(|value| value.as_str())
2131            .map(|name| format!(" worker={name}"))
2132            .unwrap_or_default();
2133        println!(
2134            "- {} [{}] status={} outcome={} branch={}{}",
2135            stage.node_id,
2136            stage.kind,
2137            stage.status,
2138            stage.outcome,
2139            stage.branch.clone().unwrap_or_else(|| "-".to_string()),
2140            worker_suffix,
2141        );
2142        if let Some(worker) = worker {
2143            if let Some(worker_id) = worker.get("id").and_then(|value| value.as_str()) {
2144                println!("  worker_id: {worker_id}");
2145            }
2146            if let Some(child_run_id) = worker.get("child_run_id").and_then(|value| value.as_str())
2147            {
2148                println!("  child_run_id: {child_run_id}");
2149            }
2150            if let Some(child_run_path) = worker
2151                .get("child_run_path")
2152                .and_then(|value| value.as_str())
2153            {
2154                println!("  child_run_path: {child_run_path}");
2155            }
2156        }
2157    }
2158    if let Some(observability) = &run.observability {
2159        for round in &observability.planner_rounds {
2160            println!(
2161                "- planner {} iterations={} llm_calls={} tools={} research_facts={}",
2162                round.node_id,
2163                round.iteration_count,
2164                round.llm_call_count,
2165                round.tool_execution_count,
2166                round.research_facts.len()
2167            );
2168        }
2169        for pointer in &observability.transcript_pointers {
2170            println!(
2171                "- transcript {} [{}] available={} {}",
2172                pointer.label,
2173                pointer.kind,
2174                pointer.available,
2175                pointer
2176                    .path
2177                    .clone()
2178                    .unwrap_or_else(|| pointer.location.clone())
2179            );
2180        }
2181        for event in &observability.daemon_events {
2182            println!(
2183                "- daemon {} [{:?}] at {}",
2184                event.name, event.kind, event.timestamp
2185            );
2186            println!("  id: {}", event.daemon_id);
2187            println!("  persist_path: {}", event.persist_path);
2188            if let Some(summary) = &event.payload_summary {
2189                println!("  payload: {summary}");
2190            }
2191        }
2192    }
2193    if let Some(compare_path) = compare {
2194        let baseline = load_run_record_or_exit(Path::new(compare_path));
2195        print_run_diff(&harn_vm::orchestration::diff_run_records(&baseline, &run));
2196    }
2197}
2198
2199fn eval_run_record(
2200    path: &str,
2201    compare: Option<&str>,
2202    structural_experiment: Option<&str>,
2203    argv: &[String],
2204    llm_mock_mode: &commands::run::CliLlmMockMode,
2205) {
2206    if let Some(experiment) = structural_experiment {
2207        let path_buf = PathBuf::from(path);
2208        if !path_buf.is_file() || path_buf.extension().and_then(|ext| ext.to_str()) != Some("harn")
2209        {
2210            eprintln!(
2211                "--structural-experiment currently requires a .harn pipeline path, got {path}"
2212            );
2213            process::exit(1);
2214        }
2215        if compare.is_some() {
2216            eprintln!("--compare cannot be combined with --structural-experiment");
2217            process::exit(1);
2218        }
2219        if matches!(llm_mock_mode, commands::run::CliLlmMockMode::Record { .. }) {
2220            eprintln!("--llm-mock-record cannot be combined with --structural-experiment");
2221            process::exit(1);
2222        }
2223        let path_buf = fs::canonicalize(&path_buf).unwrap_or_else(|error| {
2224            command_error(&format!(
2225                "failed to canonicalize structural eval pipeline {}: {error}",
2226                path_buf.display()
2227            ))
2228        });
2229        run_structural_experiment_eval(&path_buf, experiment, argv, llm_mock_mode);
2230        return;
2231    }
2232
2233    let path_buf = PathBuf::from(path);
2234    if path_buf.is_file() && file_looks_like_persona_eval_ladder_manifest(&path_buf) {
2235        if compare.is_some() {
2236            eprintln!("--compare is not supported with persona eval ladder manifests");
2237            process::exit(1);
2238        }
2239        let manifest = load_persona_eval_ladder_manifest_or_exit(&path_buf);
2240        let report =
2241            harn_vm::orchestration::run_persona_eval_ladder(&manifest).unwrap_or_else(|error| {
2242                eprintln!(
2243                    "Failed to evaluate persona eval ladder {}: {error}",
2244                    path_buf.display()
2245                );
2246                process::exit(1);
2247            });
2248        print_persona_ladder_report(&report);
2249        if !report.pass {
2250            process::exit(1);
2251        }
2252        return;
2253    }
2254
2255    if path_buf.is_file() && file_looks_like_eval_pack_manifest(&path_buf) {
2256        if compare.is_some() {
2257            eprintln!("--compare is not supported with eval pack manifests");
2258            process::exit(1);
2259        }
2260        let manifest = load_eval_pack_manifest_or_exit(&path_buf);
2261        let report = harn_vm::orchestration::evaluate_eval_pack_manifest(&manifest).unwrap_or_else(
2262            |error| {
2263                eprintln!(
2264                    "Failed to evaluate eval pack {}: {error}",
2265                    path_buf.display()
2266                );
2267                process::exit(1);
2268            },
2269        );
2270        print_eval_pack_report(&report);
2271        if !report.pass {
2272            process::exit(1);
2273        }
2274        return;
2275    }
2276
2277    if path_buf.is_file() && file_looks_like_eval_manifest(&path_buf) {
2278        if compare.is_some() {
2279            eprintln!("--compare is not supported with eval suite manifests");
2280            process::exit(1);
2281        }
2282        let manifest = load_eval_suite_manifest_or_exit(&path_buf);
2283        let suite = harn_vm::orchestration::evaluate_run_suite_manifest(&manifest).unwrap_or_else(
2284            |error| {
2285                eprintln!(
2286                    "Failed to evaluate manifest {}: {error}",
2287                    path_buf.display()
2288                );
2289                process::exit(1);
2290            },
2291        );
2292        println!(
2293            "{} {} passed, {} failed, {} total",
2294            if suite.pass { "PASS" } else { "FAIL" },
2295            suite.passed,
2296            suite.failed,
2297            suite.total
2298        );
2299        for case in &suite.cases {
2300            println!(
2301                "- {} [{}] {}",
2302                case.label.clone().unwrap_or_else(|| case.run_id.clone()),
2303                case.workflow_id,
2304                if case.pass { "PASS" } else { "FAIL" }
2305            );
2306            if let Some(path) = &case.source_path {
2307                println!("  path: {path}");
2308            }
2309            if let Some(comparison) = &case.comparison {
2310                println!("  baseline identical: {}", comparison.identical);
2311                if !comparison.identical {
2312                    println!(
2313                        "  baseline status: {} -> {}",
2314                        comparison.left_status, comparison.right_status
2315                    );
2316                }
2317            }
2318            for failure in &case.failures {
2319                println!("  {failure}");
2320            }
2321        }
2322        if !suite.pass {
2323            process::exit(1);
2324        }
2325        return;
2326    }
2327
2328    let paths = collect_run_record_paths(path);
2329    if paths.len() > 1 {
2330        let mut cases = Vec::new();
2331        for path in &paths {
2332            let run = load_run_record_or_exit(path);
2333            let fixture = run
2334                .replay_fixture
2335                .clone()
2336                .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(&run));
2337            cases.push((run, fixture, Some(path.display().to_string())));
2338        }
2339        let suite = harn_vm::orchestration::evaluate_run_suite(cases);
2340        println!(
2341            "{} {} passed, {} failed, {} total",
2342            if suite.pass { "PASS" } else { "FAIL" },
2343            suite.passed,
2344            suite.failed,
2345            suite.total
2346        );
2347        for case in &suite.cases {
2348            println!(
2349                "- {} [{}] {}",
2350                case.run_id,
2351                case.workflow_id,
2352                if case.pass { "PASS" } else { "FAIL" }
2353            );
2354            if let Some(path) = &case.source_path {
2355                println!("  path: {path}");
2356            }
2357            if let Some(comparison) = &case.comparison {
2358                println!("  baseline identical: {}", comparison.identical);
2359            }
2360            for failure in &case.failures {
2361                println!("  {failure}");
2362            }
2363        }
2364        if !suite.pass {
2365            process::exit(1);
2366        }
2367        return;
2368    }
2369
2370    let run = load_run_record_or_exit(&paths[0]);
2371    let fixture = run
2372        .replay_fixture
2373        .clone()
2374        .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(&run));
2375    let report = harn_vm::orchestration::evaluate_run_against_fixture(&run, &fixture);
2376    println!("{}", if report.pass { "PASS" } else { "FAIL" });
2377    println!("Stages: {}", report.stage_count);
2378    if let Some(compare_path) = compare {
2379        let baseline = load_run_record_or_exit(Path::new(compare_path));
2380        print_run_diff(&harn_vm::orchestration::diff_run_records(&baseline, &run));
2381    }
2382    if !report.failures.is_empty() {
2383        for failure in &report.failures {
2384            println!("- {failure}");
2385        }
2386    }
2387    if !report.pass {
2388        process::exit(1);
2389    }
2390}
2391
2392fn print_eval_pack_report(report: &harn_vm::orchestration::EvalPackReport) {
2393    println!(
2394        "{} {} passed, {} blocking failed, {} warning, {} informational, {} total",
2395        if report.pass { "PASS" } else { "FAIL" },
2396        report.passed,
2397        report.blocking_failed,
2398        report.warning_failed,
2399        report.informational_failed,
2400        report.total
2401    );
2402    for case in &report.cases {
2403        println!(
2404            "- {} [{}] {} ({})",
2405            case.label,
2406            case.workflow_id,
2407            if case.pass { "PASS" } else { "FAIL" },
2408            case.severity
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            if !comparison.identical {
2416                println!(
2417                    "  baseline status: {} -> {}",
2418                    comparison.left_status, comparison.right_status
2419                );
2420            }
2421        }
2422        for failure in &case.failures {
2423            println!("  {failure}");
2424        }
2425        for warning in &case.warnings {
2426            println!("  warning: {warning}");
2427        }
2428        for item in &case.informational {
2429            println!("  info: {item}");
2430        }
2431    }
2432    for ladder in &report.ladders {
2433        println!(
2434            "- ladder {} [{}] {} ({}) first_correct={}/{}",
2435            ladder.id,
2436            ladder.persona,
2437            if ladder.pass { "PASS" } else { "FAIL" },
2438            ladder.severity,
2439            ladder.first_correct_route.as_deref().unwrap_or("<none>"),
2440            ladder.first_correct_tier.as_deref().unwrap_or("<none>")
2441        );
2442        println!("  artifacts: {}", ladder.artifact_root);
2443        for tier in &ladder.tiers {
2444            println!(
2445                "  - {} [{}] {} tools={} models={} latency={}ms cost=${:.6}",
2446                tier.timeout_tier,
2447                tier.route_id,
2448                tier.outcome,
2449                tier.tool_calls,
2450                tier.model_calls,
2451                tier.latency_ms,
2452                tier.cost_usd
2453            );
2454            for reason in &tier.degradation_reasons {
2455                println!("    {reason}");
2456            }
2457        }
2458    }
2459}
2460
2461fn print_persona_ladder_report(report: &harn_vm::orchestration::PersonaEvalLadderReport) {
2462    println!(
2463        "{} ladder {} passed, {} degraded/looped, {} total",
2464        if report.pass { "PASS" } else { "FAIL" },
2465        report.passed,
2466        report.failed,
2467        report.total
2468    );
2469    println!(
2470        "first_correct: {}/{}",
2471        report.first_correct_route.as_deref().unwrap_or("<none>"),
2472        report.first_correct_tier.as_deref().unwrap_or("<none>")
2473    );
2474    println!("artifacts: {}", report.artifact_root);
2475    for tier in &report.tiers {
2476        println!(
2477            "- {} [{}] {} tools={} models={} latency={}ms cost=${:.6}",
2478            tier.timeout_tier,
2479            tier.route_id,
2480            tier.outcome,
2481            tier.tool_calls,
2482            tier.model_calls,
2483            tier.latency_ms,
2484            tier.cost_usd
2485        );
2486        for reason in &tier.degradation_reasons {
2487            println!("  {reason}");
2488        }
2489    }
2490}
2491
2492fn run_package_evals() {
2493    let paths = package::load_package_eval_pack_paths(None).unwrap_or_else(|error| {
2494        eprintln!("{error}");
2495        process::exit(1);
2496    });
2497    let mut all_pass = true;
2498    for path in &paths {
2499        println!("Eval pack: {}", path.display());
2500        let manifest = load_eval_pack_manifest_or_exit(path);
2501        let report = harn_vm::orchestration::evaluate_eval_pack_manifest(&manifest).unwrap_or_else(
2502            |error| {
2503                eprintln!("Failed to evaluate eval pack {}: {error}", path.display());
2504                process::exit(1);
2505            },
2506        );
2507        print_eval_pack_report(&report);
2508        all_pass &= report.pass;
2509    }
2510    if !all_pass {
2511        process::exit(1);
2512    }
2513}
2514
2515fn run_structural_experiment_eval(
2516    path: &Path,
2517    experiment: &str,
2518    argv: &[String],
2519    llm_mock_mode: &commands::run::CliLlmMockMode,
2520) {
2521    let baseline_dir = tempfile::Builder::new()
2522        .prefix("harn-eval-baseline-")
2523        .tempdir()
2524        .unwrap_or_else(|error| {
2525            command_error(&format!("failed to create baseline tempdir: {error}"))
2526        });
2527    let variant_dir = tempfile::Builder::new()
2528        .prefix("harn-eval-variant-")
2529        .tempdir()
2530        .unwrap_or_else(|error| {
2531            command_error(&format!("failed to create variant tempdir: {error}"))
2532        });
2533
2534    let baseline = spawn_eval_pipeline_run(path, baseline_dir.path(), None, argv, llm_mock_mode);
2535    if !baseline.status.success() {
2536        relay_subprocess_failure("baseline", &baseline);
2537    }
2538
2539    let variant = spawn_eval_pipeline_run(
2540        path,
2541        variant_dir.path(),
2542        Some(experiment),
2543        argv,
2544        llm_mock_mode,
2545    );
2546    if !variant.status.success() {
2547        relay_subprocess_failure("variant", &variant);
2548    }
2549
2550    let baseline_runs = collect_structural_eval_runs(baseline_dir.path());
2551    let variant_runs = collect_structural_eval_runs(variant_dir.path());
2552    if baseline_runs.is_empty() || variant_runs.is_empty() {
2553        eprintln!(
2554            "structural eval expected workflow run records under {} and {}, but one side was empty",
2555            baseline_dir.path().display(),
2556            variant_dir.path().display()
2557        );
2558        process::exit(1);
2559    }
2560    if baseline_runs.len() != variant_runs.len() {
2561        eprintln!(
2562            "structural eval produced different run counts: baseline={} variant={}",
2563            baseline_runs.len(),
2564            variant_runs.len()
2565        );
2566        process::exit(1);
2567    }
2568
2569    let mut baseline_ok = 0usize;
2570    let mut variant_ok = 0usize;
2571    let mut any_failures = false;
2572
2573    println!("Structural experiment: {experiment}");
2574    println!("Cases: {}", baseline_runs.len());
2575    for (baseline_run, variant_run) in baseline_runs.iter().zip(variant_runs.iter()) {
2576        let baseline_fixture = baseline_run
2577            .replay_fixture
2578            .clone()
2579            .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(baseline_run));
2580        let variant_fixture = variant_run
2581            .replay_fixture
2582            .clone()
2583            .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(variant_run));
2584        let baseline_report =
2585            harn_vm::orchestration::evaluate_run_against_fixture(baseline_run, &baseline_fixture);
2586        let variant_report =
2587            harn_vm::orchestration::evaluate_run_against_fixture(variant_run, &variant_fixture);
2588        let diff = harn_vm::orchestration::diff_run_records(baseline_run, variant_run);
2589        if baseline_report.pass {
2590            baseline_ok += 1;
2591        }
2592        if variant_report.pass {
2593            variant_ok += 1;
2594        }
2595        any_failures |= !baseline_report.pass || !variant_report.pass;
2596        println!(
2597            "- {} [{}]",
2598            variant_run
2599                .workflow_name
2600                .clone()
2601                .unwrap_or_else(|| variant_run.workflow_id.clone()),
2602            variant_run.task
2603        );
2604        println!(
2605            "  baseline: {}",
2606            if baseline_report.pass { "PASS" } else { "FAIL" }
2607        );
2608        for failure in &baseline_report.failures {
2609            println!("    {failure}");
2610        }
2611        println!(
2612            "  variant: {}",
2613            if variant_report.pass { "PASS" } else { "FAIL" }
2614        );
2615        for failure in &variant_report.failures {
2616            println!("    {failure}");
2617        }
2618        println!("  diff identical: {}", diff.identical);
2619        println!("  stage diffs: {}", diff.stage_diffs.len());
2620        println!("  tool diffs: {}", diff.tool_diffs.len());
2621        println!("  observability diffs: {}", diff.observability_diffs.len());
2622    }
2623
2624    println!("Baseline {} / {} passed", baseline_ok, baseline_runs.len());
2625    println!("Variant {} / {} passed", variant_ok, variant_runs.len());
2626
2627    if any_failures {
2628        process::exit(1);
2629    }
2630}
2631
2632fn spawn_eval_pipeline_run(
2633    path: &Path,
2634    run_dir: &Path,
2635    structural_experiment: Option<&str>,
2636    argv: &[String],
2637    llm_mock_mode: &commands::run::CliLlmMockMode,
2638) -> std::process::Output {
2639    let exe = env::current_exe().unwrap_or_else(|error| {
2640        command_error(&format!("failed to resolve current executable: {error}"))
2641    });
2642    let mut command = std::process::Command::new(exe);
2643    command.current_dir(path.parent().unwrap_or_else(|| Path::new(".")));
2644    command.arg("run");
2645    match llm_mock_mode {
2646        commands::run::CliLlmMockMode::Off => {}
2647        commands::run::CliLlmMockMode::Replay { fixture_path } => {
2648            command
2649                .arg("--llm-mock")
2650                .arg(absolute_cli_path(fixture_path));
2651        }
2652        commands::run::CliLlmMockMode::Record { fixture_path } => {
2653            command
2654                .arg("--llm-mock-record")
2655                .arg(absolute_cli_path(fixture_path));
2656        }
2657    }
2658    command.arg(path);
2659    if !argv.is_empty() {
2660        command.arg("--");
2661        command.args(argv);
2662    }
2663    command.env(harn_vm::runtime_paths::HARN_RUN_DIR_ENV, run_dir);
2664    if let Some(experiment) = structural_experiment {
2665        command.env("HARN_STRUCTURAL_EXPERIMENT", experiment);
2666    }
2667    command.output().unwrap_or_else(|error| {
2668        command_error(&format!(
2669            "failed to spawn `harn run {}` for structural eval: {error}",
2670            path.display()
2671        ))
2672    })
2673}
2674
2675fn absolute_cli_path(path: &Path) -> PathBuf {
2676    if path.is_absolute() {
2677        return path.to_path_buf();
2678    }
2679    env::current_dir()
2680        .unwrap_or_else(|_| PathBuf::from("."))
2681        .join(path)
2682}
2683
2684fn relay_subprocess_failure(label: &str, output: &std::process::Output) -> ! {
2685    let stdout = String::from_utf8_lossy(&output.stdout);
2686    let stderr = String::from_utf8_lossy(&output.stderr);
2687    if !stdout.trim().is_empty() {
2688        eprintln!("[{label}] stdout:\n{stdout}");
2689    }
2690    if !stderr.trim().is_empty() {
2691        eprintln!("[{label}] stderr:\n{stderr}");
2692    }
2693    process::exit(output.status.code().unwrap_or(1));
2694}
2695
2696fn collect_structural_eval_runs(dir: &Path) -> Vec<harn_vm::orchestration::RunRecord> {
2697    let mut paths: Vec<PathBuf> = fs::read_dir(dir)
2698        .unwrap_or_else(|error| {
2699            command_error(&format!(
2700                "failed to read structural eval run dir {}: {error}",
2701                dir.display()
2702            ))
2703        })
2704        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
2705        .filter(|entry| entry.extension().and_then(|ext| ext.to_str()) == Some("json"))
2706        .collect();
2707    paths.sort();
2708    let mut runs: Vec<_> = paths
2709        .iter()
2710        .map(|path| load_run_record_or_exit(path))
2711        .collect();
2712    runs.sort_by(|left, right| {
2713        (
2714            left.started_at.as_str(),
2715            left.workflow_id.as_str(),
2716            left.task.as_str(),
2717        )
2718            .cmp(&(
2719                right.started_at.as_str(),
2720                right.workflow_id.as_str(),
2721                right.task.as_str(),
2722            ))
2723    });
2724    runs
2725}
2726
2727/// Exits on error.
2728pub(crate) fn parse_source_file(path: &str) -> (String, Vec<harn_parser::SNode>) {
2729    let source = match fs::read_to_string(path) {
2730        Ok(s) => s,
2731        Err(e) => {
2732            eprintln!("Error reading {path}: {e}");
2733            process::exit(1);
2734        }
2735    };
2736
2737    let mut lexer = Lexer::new(&source);
2738    let tokens = match lexer.tokenize() {
2739        Ok(t) => t,
2740        Err(e) => {
2741            let diagnostic = harn_parser::diagnostic::render_diagnostic_with_code(
2742                &source,
2743                path,
2744                &error_span_from_lex(&e),
2745                "error",
2746                harn_parser::diagnostic::lexer_error_code(&e),
2747                &e.to_string(),
2748                Some("here"),
2749                None,
2750            );
2751            eprint!("{diagnostic}");
2752            process::exit(1);
2753        }
2754    };
2755
2756    let mut parser = Parser::new(tokens);
2757    let program = match parser.parse() {
2758        Ok(p) => p,
2759        Err(err) => {
2760            if parser.all_errors().is_empty() {
2761                let span = error_span_from_parse(&err);
2762                let diagnostic = harn_parser::diagnostic::render_diagnostic_with_code(
2763                    &source,
2764                    path,
2765                    &span,
2766                    "error",
2767                    harn_parser::diagnostic::parser_error_code(&err),
2768                    &harn_parser::diagnostic::parser_error_message(&err),
2769                    Some(harn_parser::diagnostic::parser_error_label(&err)),
2770                    harn_parser::diagnostic::parser_error_help(&err),
2771                );
2772                eprint!("{diagnostic}");
2773            } else {
2774                for e in parser.all_errors() {
2775                    let span = error_span_from_parse(e);
2776                    let diagnostic = harn_parser::diagnostic::render_diagnostic_with_code(
2777                        &source,
2778                        path,
2779                        &span,
2780                        "error",
2781                        harn_parser::diagnostic::parser_error_code(e),
2782                        &harn_parser::diagnostic::parser_error_message(e),
2783                        Some(harn_parser::diagnostic::parser_error_label(e)),
2784                        harn_parser::diagnostic::parser_error_help(e),
2785                    );
2786                    eprint!("{diagnostic}");
2787                }
2788            }
2789            process::exit(1);
2790        }
2791    };
2792
2793    (source, program)
2794}
2795
2796fn error_span_from_lex(e: &harn_lexer::LexerError) -> harn_lexer::Span {
2797    match e {
2798        harn_lexer::LexerError::UnexpectedCharacter(_, span)
2799        | harn_lexer::LexerError::UnterminatedString(span)
2800        | harn_lexer::LexerError::UnterminatedBlockComment(span) => *span,
2801    }
2802}
2803
2804fn error_span_from_parse(e: &harn_parser::ParserError) -> harn_lexer::Span {
2805    match e {
2806        harn_parser::ParserError::Unexpected { span, .. } => *span,
2807        harn_parser::ParserError::UnexpectedEof { span, .. } => *span,
2808    }
2809}
2810
2811/// Used by REPL and conformance tests.
2812pub(crate) async fn execute(source: &str, source_path: Option<&Path>) -> Result<String, String> {
2813    execute_with_skill_dirs(source, source_path, &[]).await
2814}
2815
2816pub(crate) async fn execute_with_skill_dirs(
2817    source: &str,
2818    source_path: Option<&Path>,
2819    cli_skill_dirs: &[PathBuf],
2820) -> Result<String, String> {
2821    execute_with_skill_dirs_and_optional_harness(source, source_path, cli_skill_dirs, None).await
2822}
2823
2824pub(crate) async fn execute_with_skill_dirs_and_harness(
2825    source: &str,
2826    source_path: Option<&Path>,
2827    cli_skill_dirs: &[PathBuf],
2828    harness: harn_vm::Harness,
2829) -> Result<String, String> {
2830    execute_with_skill_dirs_and_optional_harness(source, source_path, cli_skill_dirs, Some(harness))
2831        .await
2832}
2833
2834async fn execute_with_skill_dirs_and_optional_harness(
2835    source: &str,
2836    source_path: Option<&Path>,
2837    cli_skill_dirs: &[PathBuf],
2838    harness: Option<harn_vm::Harness>,
2839) -> Result<String, String> {
2840    let mut lexer = Lexer::new(source);
2841    let tokens = lexer.tokenize().map_err(|e| e.to_string())?;
2842    let mut parser = Parser::new(tokens);
2843    let program = parser.parse().map_err(|e| e.to_string())?;
2844
2845    // Static cross-module resolution: when executed from a file, derive the
2846    // import graph so `execute` catches undefined calls at typecheck time.
2847    // The REPL / `-e` path invokes this without `source_path`, where there
2848    // is no importing file context; we fall back to no-imports checking.
2849    let mut checker = TypeChecker::new();
2850    if let Some(path) = source_path {
2851        let graph = harn_modules::build(&[path.to_path_buf()]);
2852        if let Some(imported) = graph.imported_names_for_file(path) {
2853            checker = checker.with_imported_names(imported);
2854        }
2855        if let Some(imported) = graph.imported_type_declarations_for_file(path) {
2856            checker = checker.with_imported_type_decls(imported);
2857        }
2858        if let Some(imported) = graph.imported_callable_declarations_for_file(path) {
2859            checker = checker.with_imported_callable_decls(imported);
2860        }
2861    }
2862    let type_diagnostics = checker.check(&program);
2863    let mut warning_lines = Vec::new();
2864    for diag in &type_diagnostics {
2865        match diag.severity {
2866            DiagnosticSeverity::Error => return Err(diag.message.clone()),
2867            DiagnosticSeverity::Warning => {
2868                warning_lines.push(format!("warning: {}", diag.message));
2869            }
2870        }
2871    }
2872
2873    let chunk = harn_vm::Compiler::new()
2874        .compile(&program)
2875        .map_err(|e| e.to_string())?;
2876
2877    let local = tokio::task::LocalSet::new();
2878    local
2879        .run_until(async {
2880            let mut vm = harn_vm::Vm::new();
2881            harn_vm::register_vm_stdlib(&mut vm);
2882            install_default_hostlib(&mut vm);
2883            let source_parent = source_path
2884                .and_then(|p| p.parent())
2885                .unwrap_or(std::path::Path::new("."));
2886            let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
2887            let store_base = project_root.as_deref().unwrap_or(source_parent);
2888            let execution_cwd = std::env::current_dir()
2889                .unwrap_or_else(|_| std::path::PathBuf::from("."))
2890                .to_string_lossy()
2891                .into_owned();
2892            let source_dir = source_parent.to_string_lossy().into_owned();
2893            if source_path.is_some_and(is_conformance_path) {
2894                harn_vm::event_log::install_memory_for_current_thread(64);
2895            }
2896            harn_vm::register_store_builtins(&mut vm, store_base);
2897            harn_vm::register_metadata_builtins(&mut vm, store_base);
2898            let pipeline_name = source_path
2899                .and_then(|p| p.file_stem())
2900                .and_then(|s| s.to_str())
2901                .unwrap_or("default");
2902            harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
2903            harn_vm::stdlib::process::set_thread_execution_context(Some(
2904                harn_vm::orchestration::RunExecutionRecord {
2905                    cwd: Some(execution_cwd),
2906                    source_dir: Some(source_dir),
2907                    env: std::collections::BTreeMap::new(),
2908                    adapter: None,
2909                    repo_path: None,
2910                    worktree_path: None,
2911                    branch: None,
2912                    base_ref: None,
2913                    cleanup: None,
2914                },
2915            ));
2916            if let Some(ref root) = project_root {
2917                vm.set_project_root(root);
2918            }
2919            if let Some(path) = source_path {
2920                if let Some(parent) = path.parent() {
2921                    if !parent.as_os_str().is_empty() {
2922                        vm.set_source_dir(parent);
2923                    }
2924                }
2925            }
2926            // Conformance tests land here via `run_conformance_tests`; for
2927            // `skill_fs_*` fixtures to see the bundled `skills/` folder
2928            // we run the same layered discovery as `harn run`.
2929            let loaded = skill_loader::load_skills(&skill_loader::SkillLoaderInputs {
2930                cli_dirs: cli_skill_dirs.to_vec(),
2931                source_path: source_path.map(Path::to_path_buf),
2932            });
2933            skill_loader::emit_loader_warnings(&loaded.loader_warnings);
2934            skill_loader::install_skills_global(&mut vm, &loaded);
2935            vm.set_harness(harness.unwrap_or_else(harn_vm::Harness::real));
2936            if let Some(path) = source_path {
2937                let extensions = package::load_runtime_extensions(path);
2938                package::install_runtime_extensions(&extensions);
2939                package::install_manifest_triggers(&mut vm, &extensions)
2940                    .await
2941                    .map_err(|error| format!("failed to install manifest triggers: {error}"))?;
2942                package::install_manifest_hooks(&mut vm, &extensions)
2943                    .await
2944                    .map_err(|error| format!("failed to install manifest hooks: {error}"))?;
2945            }
2946            let _event_log = harn_vm::event_log::active_event_log()
2947                .unwrap_or_else(|| harn_vm::event_log::install_memory_for_current_thread(64));
2948            let connector_clients_installed =
2949                should_install_default_connector_clients(source, source_path);
2950            if connector_clients_installed {
2951                install_default_connector_clients(store_base)
2952                    .await
2953                    .map_err(|error| format!("failed to initialize connector clients: {error}"))?;
2954            }
2955            let execution_result = vm.execute(&chunk).await.map_err(|e| e.to_string());
2956            harn_vm::egress::reset_egress_policy_for_host();
2957            if connector_clients_installed {
2958                harn_vm::clear_active_connector_clients();
2959            }
2960            harn_vm::stdlib::process::set_thread_execution_context(None);
2961            execution_result?;
2962            let mut output = String::new();
2963            for wl in &warning_lines {
2964                output.push_str(wl);
2965                output.push('\n');
2966            }
2967            output.push_str(vm.output());
2968            Ok(output)
2969        })
2970        .await
2971}
2972
2973fn should_install_default_connector_clients(source: &str, source_path: Option<&Path>) -> bool {
2974    if !source_path.is_some_and(is_conformance_path) {
2975        return true;
2976    }
2977    source.contains("connector_call")
2978        || source.contains("std/connectors")
2979        || source.contains("connectors/")
2980}
2981
2982fn is_conformance_path(path: &Path) -> bool {
2983    path.components()
2984        .any(|component| component.as_os_str() == "conformance")
2985}
2986
2987async fn install_default_connector_clients(base_dir: &Path) -> Result<(), String> {
2988    let event_log = harn_vm::event_log::active_event_log()
2989        .unwrap_or_else(|| harn_vm::event_log::install_memory_for_current_thread(64));
2990    let secret_namespace = connector_secret_namespace(base_dir);
2991    let secrets: Arc<dyn harn_vm::secrets::SecretProvider> = Arc::new(
2992        harn_vm::secrets::configured_default_chain(secret_namespace)
2993            .map_err(|error| format!("failed to configure secret providers: {error}"))?,
2994    );
2995
2996    let registry = harn_vm::ConnectorRegistry::default();
2997    let metrics = Arc::new(harn_vm::MetricsRegistry::default());
2998    let inbox = Arc::new(
2999        harn_vm::InboxIndex::new(event_log.clone(), metrics.clone())
3000            .await
3001            .map_err(|error| error.to_string())?,
3002    );
3003    registry
3004        .init_all(harn_vm::ConnectorCtx {
3005            event_log,
3006            secrets,
3007            inbox,
3008            metrics,
3009            rate_limiter: Arc::new(harn_vm::RateLimiterFactory::default()),
3010        })
3011        .await
3012        .map_err(|error| error.to_string())?;
3013    let clients = registry.client_map().await;
3014    harn_vm::install_active_connector_clients(clients);
3015    Ok(())
3016}
3017
3018fn connector_secret_namespace(base_dir: &Path) -> String {
3019    match std::env::var("HARN_SECRET_NAMESPACE") {
3020        Ok(namespace) if !namespace.trim().is_empty() => namespace,
3021        _ => {
3022            let leaf = base_dir
3023                .file_name()
3024                .and_then(|name| name.to_str())
3025                .filter(|name| !name.is_empty())
3026                .unwrap_or("workspace");
3027            format!("harn/{leaf}")
3028        }
3029    }
3030}
3031
3032#[cfg(test)]
3033mod main_tests {
3034    use super::{
3035        is_broken_pipe_panic_payload, normalize_serve_args,
3036        should_install_default_connector_clients,
3037    };
3038    use std::path::Path;
3039
3040    #[test]
3041    fn normalize_serve_args_inserts_a2a_for_legacy_shape() {
3042        let args = normalize_serve_args(vec![
3043            "harn".to_string(),
3044            "serve".to_string(),
3045            "--port".to_string(),
3046            "3000".to_string(),
3047            "agent.harn".to_string(),
3048        ]);
3049        assert_eq!(
3050            args,
3051            vec![
3052                "harn".to_string(),
3053                "serve".to_string(),
3054                "a2a".to_string(),
3055                "--port".to_string(),
3056                "3000".to_string(),
3057                "agent.harn".to_string(),
3058            ]
3059        );
3060    }
3061
3062    #[test]
3063    fn normalize_serve_args_preserves_explicit_subcommands() {
3064        let args = normalize_serve_args(vec![
3065            "harn".to_string(),
3066            "serve".to_string(),
3067            "acp".to_string(),
3068            "server.harn".to_string(),
3069        ]);
3070        assert_eq!(
3071            args,
3072            vec![
3073                "harn".to_string(),
3074                "serve".to_string(),
3075                "acp".to_string(),
3076                "server.harn".to_string(),
3077            ]
3078        );
3079    }
3080
3081    #[test]
3082    fn conformance_skips_connector_clients_unless_fixture_uses_connectors() {
3083        let path = Path::new("conformance/tests/language/basic.harn");
3084        assert!(!should_install_default_connector_clients(
3085            "__io_println(1)",
3086            Some(path)
3087        ));
3088        assert!(!should_install_default_connector_clients(
3089            "trust_graph_verify_chain()",
3090            Some(path)
3091        ));
3092        assert!(should_install_default_connector_clients(
3093            "import { post_message } from \"std/connectors/slack\"",
3094            Some(path)
3095        ));
3096        assert!(should_install_default_connector_clients(
3097            "__io_println(1)",
3098            Some(Path::new("examples/demo.harn"))
3099        ));
3100    }
3101
3102    #[test]
3103    fn broken_pipe_print_panic_is_classified_as_clean_consumer_close() {
3104        let payload = String::from("failed printing to stdout: Broken pipe (os error 32)");
3105        assert!(is_broken_pipe_panic_payload(&payload));
3106    }
3107
3108    #[test]
3109    fn unrelated_panic_is_not_classified_as_broken_pipe() {
3110        let payload = String::from("assertion failed: expected true");
3111        assert!(!is_broken_pipe_panic_payload(&payload));
3112    }
3113}