Skip to main content

harn_cli/
lib.rs

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