Skip to main content

harn_cli/
lib.rs

1#![recursion_limit = "256"]
2
3pub mod acp;
4pub mod cli;
5pub mod commands;
6pub mod config;
7pub mod env_guard;
8pub mod format;
9pub mod package;
10mod provider_bootstrap;
11pub mod skill_loader;
12pub mod skill_provenance;
13pub mod test_runner;
14#[doc(hidden)]
15pub mod tests;
16
17use clap::{error::ErrorKind, CommandFactory, Parser as ClapParser};
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::{env, fs, process, thread};
21
22use cli::{
23    Cli, Command, CompletionShell, MergeCaptainCommand, MergeCaptainMockCommand, ModelInfoArgs,
24    PackageArtifactsCommand, PackageCacheCommand, PackageCommand, PersonaCommand, RunsCommand,
25    ServeCommand, SkillCommand, SkillKeyCommand, SkillTrustCommand, SkillsCommand,
26};
27use harn_lexer::Lexer;
28use harn_parser::{DiagnosticSeverity, Parser, TypeChecker};
29
30pub const CLI_RUNTIME_STACK_SIZE: usize = 16 * 1024 * 1024;
31
32#[cfg(feature = "hostlib")]
33pub(crate) fn install_default_hostlib(vm: &mut harn_vm::Vm) {
34    let _ = harn_hostlib::install_default(vm);
35}
36
37#[cfg(not(feature = "hostlib"))]
38pub(crate) fn install_default_hostlib(_vm: &mut harn_vm::Vm) {}
39
40/// Entry point used by `src/main.rs`. Hosts the CLI runtime thread and
41/// drives the async dispatcher in `async_main`.
42pub fn run() {
43    let handle = thread::Builder::new()
44        .name("harn-cli".to_string())
45        .stack_size(CLI_RUNTIME_STACK_SIZE)
46        .spawn(|| {
47            let runtime = tokio::runtime::Builder::new_multi_thread()
48                .enable_all()
49                .build()
50                .unwrap_or_else(|error| {
51                    eprintln!("failed to start async runtime: {error}");
52                    process::exit(1);
53                });
54            runtime.block_on(async_main());
55        })
56        .unwrap_or_else(|error| {
57            eprintln!("failed to start CLI runtime thread: {error}");
58            process::exit(1);
59        });
60
61    if let Err(payload) = handle.join() {
62        std::panic::resume_unwind(payload);
63    }
64}
65
66async fn async_main() {
67    let raw_args = normalize_serve_args(env::args().collect());
68    if raw_args.len() == 2 && raw_args[1].ends_with(".harn") {
69        provider_bootstrap::maybe_seed_ollama_for_run_file(Path::new(&raw_args[1]), false, false)
70            .await;
71        commands::run::run_file(
72            &raw_args[1],
73            false,
74            std::collections::HashSet::new(),
75            Vec::new(),
76            commands::run::CliLlmMockMode::Off,
77            None,
78            commands::run::RunProfileOptions::default(),
79        )
80        .await;
81        return;
82    }
83
84    let cli = match Cli::try_parse_from(&raw_args) {
85        Ok(cli) => cli,
86        Err(error) => {
87            if matches!(
88                error.kind(),
89                ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
90            ) {
91                error.exit();
92            }
93            error.exit();
94        }
95    };
96
97    match cli.command.expect("clap requires a command") {
98        Command::Version => print_version(),
99        Command::Skill(args) => match args.command {
100            SkillCommand::Key(key_args) => match key_args.command {
101                SkillKeyCommand::Generate(generate) => commands::skill::run_key_generate(&generate),
102            },
103            SkillCommand::Sign(sign) => commands::skill::run_sign(&sign),
104            SkillCommand::Endorse(endorse) => commands::skill::run_endorse(&endorse),
105            SkillCommand::Verify(verify) => commands::skill::run_verify(&verify),
106            SkillCommand::WhoSigned(who_signed) => {
107                commands::skill::run_who_signed(&who_signed).await
108            }
109            SkillCommand::Trust(trust_args) => match trust_args.command {
110                SkillTrustCommand::Add(add) => commands::skill::run_trust_add(&add),
111                SkillTrustCommand::List(list) => commands::skill::run_trust_list(&list),
112            },
113        },
114        Command::Run(args) => {
115            if !args.explain_cost {
116                match (args.eval.as_deref(), args.file.as_deref()) {
117                    (Some(code), None) => {
118                        provider_bootstrap::maybe_seed_ollama_for_inline(
119                            code,
120                            args.yes,
121                            args.llm_mock.is_some(),
122                        )
123                        .await;
124                    }
125                    (None, Some(file)) => {
126                        provider_bootstrap::maybe_seed_ollama_for_run_file(
127                            Path::new(file),
128                            args.yes,
129                            args.llm_mock.is_some(),
130                        )
131                        .await;
132                    }
133                    _ => {}
134                }
135            }
136            let denied =
137                commands::run::build_denied_builtins(args.deny.as_deref(), args.allow.as_deref());
138            let llm_mock_mode = if let Some(path) = args.llm_mock.as_ref() {
139                commands::run::CliLlmMockMode::Replay {
140                    fixture_path: PathBuf::from(path),
141                }
142            } else if let Some(path) = args.llm_mock_record.as_ref() {
143                commands::run::CliLlmMockMode::Record {
144                    fixture_path: PathBuf::from(path),
145                }
146            } else {
147                commands::run::CliLlmMockMode::Off
148            };
149            let attestation = args.attest.then(|| commands::run::RunAttestationOptions {
150                receipt_out: args.receipt_out.as_ref().map(PathBuf::from),
151                agent_id: args.attest_agent.clone(),
152            });
153            let profile_options = commands::run::RunProfileOptions {
154                text: args.profile,
155                json_path: args.profile_json.as_ref().map(PathBuf::from),
156            };
157
158            match (args.eval.as_deref(), args.file.as_deref()) {
159                (Some(code), None) => {
160                    let (wrapped, tmp) = commands::run::prepare_eval_temp_file(code)
161                        .unwrap_or_else(|e| command_error(&e));
162                    let tmp_path: PathBuf = tmp.path().to_path_buf();
163                    fs::write(&tmp_path, &wrapped).unwrap_or_else(|e| {
164                        command_error(&format!("failed to write temp file for -e: {e}"))
165                    });
166                    let tmp_str = tmp_path.to_string_lossy().into_owned();
167                    if args.explain_cost {
168                        commands::run::run_explain_cost_file_with_skill_dirs(&tmp_str);
169                    } else {
170                        commands::run::run_file_with_skill_dirs(
171                            &tmp_str,
172                            args.trace,
173                            denied,
174                            args.argv.clone(),
175                            args.skill_dir.clone(),
176                            llm_mock_mode.clone(),
177                            attestation.clone(),
178                            profile_options.clone(),
179                        )
180                        .await;
181                    }
182                    drop(tmp);
183                }
184                (None, Some(file)) => {
185                    if args.explain_cost {
186                        commands::run::run_explain_cost_file_with_skill_dirs(file);
187                    } else {
188                        commands::run::run_file_with_skill_dirs(
189                            file,
190                            args.trace,
191                            denied,
192                            args.argv.clone(),
193                            args.skill_dir.clone(),
194                            llm_mock_mode,
195                            attestation,
196                            profile_options,
197                        )
198                        .await
199                    }
200                }
201                (Some(_), Some(_)) => command_error(
202                    "`harn run` accepts either `-e <code>` or `<file.harn>`, not both",
203                ),
204                (None, None) => {
205                    command_error("`harn run` requires either `-e <code>` or `<file.harn>`")
206                }
207            }
208        }
209        Command::Check(args) => {
210            if args.provider_matrix {
211                let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
212                let extensions = package::load_runtime_extensions(&cwd);
213                package::install_runtime_extensions(&extensions);
214                commands::check::provider_matrix::run(args.format, args.filter.as_deref());
215                return;
216            }
217            if args.connector_matrix {
218                commands::check::connector_matrix::run(
219                    args.format,
220                    args.filter.as_deref(),
221                    &args.targets,
222                );
223                return;
224            }
225            let mut target_strings: Vec<String> = args.targets.clone();
226            if args.workspace {
227                let anchor = target_strings.first().map(Path::new);
228                match package::load_workspace_config(anchor) {
229                    Some((workspace, manifest_dir)) if !workspace.pipelines.is_empty() => {
230                        for pipeline in &workspace.pipelines {
231                            let candidate = Path::new(pipeline);
232                            let resolved = if candidate.is_absolute() {
233                                candidate.to_path_buf()
234                            } else {
235                                manifest_dir.join(candidate)
236                            };
237                            target_strings.push(resolved.to_string_lossy().into_owned());
238                        }
239                    }
240                    Some(_) => command_error(
241                        "--workspace requires `[workspace].pipelines` in the nearest harn.toml",
242                    ),
243                    None => command_error(
244                        "--workspace could not find a harn.toml walking up from the target(s)",
245                    ),
246                }
247            }
248            if target_strings.is_empty() {
249                command_error(
250                    "`harn check` requires at least one target path, or `--workspace` with `[workspace].pipelines`",
251                );
252            }
253            for target in &target_strings {
254                if let Err(error) = package::validate_runtime_manifest_extensions(Path::new(target))
255                {
256                    command_error(&format!("manifest extension validation failed: {error}"));
257                }
258            }
259            let targets: Vec<&str> = target_strings.iter().map(String::as_str).collect();
260            let files = commands::check::collect_harn_targets(&targets);
261            if files.is_empty() {
262                command_error("no .harn files found under the given target(s)");
263            }
264            let module_graph = commands::check::build_module_graph(&files);
265            let cross_file_imports = commands::check::collect_cross_file_imports(&module_graph);
266            let mut should_fail = false;
267            for file in &files {
268                let mut config = package::load_check_config(Some(file));
269                if let Some(path) = args.host_capabilities.as_ref() {
270                    config.host_capabilities_path = Some(path.clone());
271                }
272                if let Some(path) = args.bundle_root.as_ref() {
273                    config.bundle_root = Some(path.clone());
274                }
275                if args.strict_types {
276                    config.strict_types = true;
277                }
278                if let Some(sev) = args.preflight.as_deref() {
279                    config.preflight_severity = Some(sev.to_string());
280                }
281                let outcome = commands::check::check_file_inner(
282                    file,
283                    &config,
284                    &cross_file_imports,
285                    &module_graph,
286                    args.invariants,
287                );
288                should_fail |= outcome.should_fail(config.strict);
289            }
290            if should_fail {
291                process::exit(1);
292            }
293        }
294        Command::Explain(args) => {
295            let code = commands::explain::run_explain(&args);
296            if code != 0 {
297                process::exit(code);
298            }
299        }
300        Command::Contracts(args) => {
301            commands::contracts::handle_contracts_command(args).await;
302        }
303        Command::Connect(args) => {
304            commands::connect::run_connect(*args).await;
305        }
306        Command::Lint(args) => {
307            let targets: Vec<&str> = args.targets.iter().map(String::as_str).collect();
308            let files = commands::check::collect_harn_targets(&targets);
309            if files.is_empty() {
310                command_error("no .harn files found under the given target(s)");
311            }
312            let module_graph = commands::check::build_module_graph(&files);
313            let cross_file_imports = commands::check::collect_cross_file_imports(&module_graph);
314            if args.fix {
315                for file in &files {
316                    let mut config = package::load_check_config(Some(file));
317                    commands::check::apply_harn_lint_config(file, &mut config);
318                    let require_header = args.require_file_header
319                        || commands::check::harn_lint_require_file_header(file);
320                    let complexity_threshold =
321                        commands::check::harn_lint_complexity_threshold(file);
322                    let persona_step_allowlist =
323                        commands::check::harn_lint_persona_step_allowlist(file);
324                    commands::check::lint_fix_file(
325                        file,
326                        &config,
327                        &cross_file_imports,
328                        &module_graph,
329                        require_header,
330                        complexity_threshold,
331                        &persona_step_allowlist,
332                    );
333                }
334            } else {
335                let mut should_fail = false;
336                for file in &files {
337                    let mut config = package::load_check_config(Some(file));
338                    commands::check::apply_harn_lint_config(file, &mut config);
339                    let require_header = args.require_file_header
340                        || commands::check::harn_lint_require_file_header(file);
341                    let complexity_threshold =
342                        commands::check::harn_lint_complexity_threshold(file);
343                    let persona_step_allowlist =
344                        commands::check::harn_lint_persona_step_allowlist(file);
345                    let outcome = commands::check::lint_file_inner(
346                        file,
347                        &config,
348                        &cross_file_imports,
349                        &module_graph,
350                        require_header,
351                        complexity_threshold,
352                        &persona_step_allowlist,
353                    );
354                    should_fail |= outcome.should_fail(config.strict);
355                }
356                if should_fail {
357                    process::exit(1);
358                }
359            }
360        }
361        Command::Fmt(args) => {
362            let targets: Vec<&str> = args.targets.iter().map(String::as_str).collect();
363            // Anchor config resolution on the first target; CLI flags
364            // always win over harn.toml values.
365            let anchor = targets.first().map(Path::new).unwrap_or(Path::new("."));
366            let loaded = match config::load_for_path(anchor) {
367                Ok(c) => c,
368                Err(e) => {
369                    eprintln!("warning: {e}");
370                    config::HarnConfig::default()
371                }
372            };
373            let mut opts = harn_fmt::FmtOptions::default();
374            if let Some(w) = loaded.fmt.line_width {
375                opts.line_width = w;
376            }
377            if let Some(w) = loaded.fmt.separator_width {
378                opts.separator_width = w;
379            }
380            if let Some(w) = args.line_width {
381                opts.line_width = w;
382            }
383            if let Some(w) = args.separator_width {
384                opts.separator_width = w;
385            }
386            commands::check::fmt_targets(
387                &targets,
388                commands::check::FmtMode::from_check_flag(args.check),
389                &opts,
390            );
391        }
392        Command::Test(args) => {
393            if args.target.as_deref() == Some("agents-conformance") {
394                if args.selection.is_some() {
395                    command_error(
396                        "`harn test agents-conformance` does not accept a second positional target; use --category instead",
397                    );
398                }
399                if args.evals || args.determinism || args.record || args.replay || args.watch {
400                    command_error(
401                        "`harn test agents-conformance` cannot be combined with --evals, --determinism, --record, --replay, or --watch",
402                    );
403                }
404                let Some(target_url) = args.agents_target.clone() else {
405                    command_error("`harn test agents-conformance` requires --target <url>");
406                };
407                commands::agents_conformance::run_agents_conformance(
408                    commands::agents_conformance::AgentsConformanceConfig {
409                        target_url,
410                        api_key: args.agents_api_key.clone(),
411                        categories: args.agents_category.clone(),
412                        timeout_ms: args.timeout,
413                        verbose: args.verbose,
414                        json: args.json,
415                        json_out: args.json_out.clone(),
416                        workspace_id: args.agents_workspace_id.clone(),
417                        session_id: args.agents_session_id.clone(),
418                    },
419                )
420                .await;
421                return;
422            }
423            if args.target.as_deref() == Some("protocols") {
424                if args.evals || args.determinism || args.record || args.replay || args.watch {
425                    command_error(
426                        "`harn test protocols` cannot be combined with --evals, --determinism, --record, --replay, or --watch",
427                    );
428                }
429                if args.junit.is_some()
430                    || args.agents_target.is_some()
431                    || args.agents_api_key.is_some()
432                    || !args.agents_category.is_empty()
433                    || args.json
434                    || args.json_out.is_some()
435                    || args.agents_workspace_id.is_some()
436                    || args.agents_session_id.is_some()
437                    || args.parallel
438                    || !args.skill_dir.is_empty()
439                {
440                    command_error(
441                        "`harn test protocols` accepts only --filter, --verbose, --timing, and an optional fixture selection",
442                    );
443                }
444                commands::protocol_conformance::run_protocol_conformance(
445                    args.selection.as_deref(),
446                    args.filter.as_deref(),
447                    args.verbose || args.timing,
448                );
449                return;
450            }
451            if args.evals {
452                if args.determinism || args.record || args.replay || args.watch {
453                    command_error("--evals cannot be combined with --determinism, --record, --replay, or --watch");
454                }
455                if args.target.as_deref() != Some("package") || args.selection.is_some() {
456                    command_error("package evals are run with `harn test package --evals`");
457                }
458                run_package_evals();
459            } else if args.determinism {
460                if args.watch {
461                    command_error("--determinism cannot be combined with --watch");
462                }
463                if args.record || args.replay {
464                    command_error("--determinism manages its own record/replay cycle");
465                }
466                if let Some(t) = args.target.as_deref() {
467                    if t == "conformance" {
468                        commands::test::run_conformance_determinism_tests(
469                            t,
470                            args.selection.as_deref(),
471                            args.filter.as_deref(),
472                            args.timeout,
473                        )
474                        .await;
475                    } else if args.selection.is_some() {
476                        command_error(
477                            "only `harn test conformance` accepts a second positional target",
478                        );
479                    } else {
480                        commands::test::run_determinism_tests(
481                            t,
482                            args.filter.as_deref(),
483                            args.timeout,
484                        )
485                        .await;
486                    }
487                } else {
488                    let test_dir = if PathBuf::from("tests").is_dir() {
489                        "tests".to_string()
490                    } else {
491                        command_error("no path specified and no tests/ directory found");
492                    };
493                    if args.selection.is_some() {
494                        command_error(
495                            "only `harn test conformance` accepts a second positional target",
496                        );
497                    }
498                    commands::test::run_determinism_tests(
499                        &test_dir,
500                        args.filter.as_deref(),
501                        args.timeout,
502                    )
503                    .await;
504                }
505            } else {
506                if args.record {
507                    harn_vm::llm::set_replay_mode(
508                        harn_vm::llm::LlmReplayMode::Record,
509                        ".harn-fixtures",
510                    );
511                } else if args.replay {
512                    harn_vm::llm::set_replay_mode(
513                        harn_vm::llm::LlmReplayMode::Replay,
514                        ".harn-fixtures",
515                    );
516                }
517
518                if let Some(t) = args.target.as_deref() {
519                    if t == "conformance" {
520                        commands::test::run_conformance_tests(
521                            t,
522                            args.selection.as_deref(),
523                            args.filter.as_deref(),
524                            args.junit.as_deref(),
525                            args.timeout,
526                            args.verbose,
527                            args.timing,
528                            args.differential_optimizations,
529                        )
530                        .await;
531                    } else if args.selection.is_some() {
532                        command_error(
533                            "only `harn test conformance` accepts a second positional target",
534                        );
535                    } else if args.watch {
536                        commands::test::run_watch_tests(
537                            t,
538                            args.filter.as_deref(),
539                            args.timeout,
540                            args.parallel,
541                        )
542                        .await;
543                    } else {
544                        commands::test::run_user_tests(
545                            t,
546                            args.filter.as_deref(),
547                            args.timeout,
548                            args.parallel,
549                        )
550                        .await;
551                    }
552                } else {
553                    let test_dir = if PathBuf::from("tests").is_dir() {
554                        "tests".to_string()
555                    } else {
556                        command_error("no path specified and no tests/ directory found");
557                    };
558                    if args.selection.is_some() {
559                        command_error(
560                            "only `harn test conformance` accepts a second positional target",
561                        );
562                    }
563                    if args.watch {
564                        commands::test::run_watch_tests(
565                            &test_dir,
566                            args.filter.as_deref(),
567                            args.timeout,
568                            args.parallel,
569                        )
570                        .await;
571                    } else {
572                        commands::test::run_user_tests(
573                            &test_dir,
574                            args.filter.as_deref(),
575                            args.timeout,
576                            args.parallel,
577                        )
578                        .await;
579                    }
580                }
581            }
582        }
583        Command::Init(args) => commands::init::init_project(args.name.as_deref(), args.template),
584        Command::New(args) => match commands::init::resolve_new_args(&args) {
585            Ok((name, template)) => commands::init::init_project(name.as_deref(), template),
586            Err(error) => {
587                eprintln!("error: {error}");
588                process::exit(1);
589            }
590        },
591        Command::Doctor(args) => {
592            commands::doctor::run_doctor_with_options(commands::doctor::DoctorOptions {
593                network: !args.no_network,
594                json: args.json,
595            })
596            .await
597        }
598        Command::Models(args) => commands::models::run(args).await,
599        Command::Try(args) => commands::try_cmd::run(args).await,
600        Command::Quickstart(args) => {
601            if let Err(error) = commands::quickstart::run_quickstart(&args).await {
602                command_error(&error);
603            }
604        }
605        Command::Serve(args) => match args.command {
606            ServeCommand::Acp(args) => {
607                if let Err(error) = commands::serve::run_acp_server(&args).await {
608                    command_error(&error);
609                }
610            }
611            ServeCommand::A2a(args) => {
612                if let Err(error) = commands::serve::run_a2a_server(&args).await {
613                    command_error(&error);
614                }
615            }
616            ServeCommand::Mcp(args) => {
617                if let Err(error) = commands::serve::run_mcp_server(&args).await {
618                    command_error(&error);
619                }
620            }
621        },
622        Command::Connector(args) => {
623            if let Err(error) = commands::connector::handle_connector_command(args).await {
624                eprintln!("error: {error}");
625                process::exit(1);
626            }
627        }
628        Command::Mcp(args) => commands::mcp::handle_mcp_command(&args.command).await,
629        Command::Watch(args) => {
630            let denied =
631                commands::run::build_denied_builtins(args.deny.as_deref(), args.allow.as_deref());
632            commands::run::run_watch(&args.file, denied).await;
633        }
634        Command::Portal(args) => {
635            commands::portal::run_portal(
636                &args.dir,
637                args.manifest,
638                args.persona_state_dir,
639                &args.host,
640                args.port,
641                args.open,
642            )
643            .await
644        }
645        Command::Trigger(args) => {
646            if let Err(error) = commands::trigger::handle(args).await {
647                eprintln!("error: {error}");
648                process::exit(1);
649            }
650        }
651        Command::Flow(args) => match commands::flow::run_flow(&args) {
652            Ok(code) => {
653                if code != 0 {
654                    process::exit(code);
655                }
656            }
657            Err(error) => command_error(&error),
658        },
659        Command::Workflow(args) => match commands::workflow::handle(args) {
660            Ok(code) => {
661                if code != 0 {
662                    process::exit(code);
663                }
664            }
665            Err(error) => command_error(&error),
666        },
667        Command::Supervisor(args) => {
668            if let Err(error) = commands::supervisor::handle(args).await {
669                eprintln!("error: {error}");
670                process::exit(1);
671            }
672        }
673        Command::Trace(args) => {
674            if let Err(error) = commands::trace::handle(args).await {
675                eprintln!("error: {error}");
676                process::exit(1);
677            }
678        }
679        Command::Crystallize(args) => {
680            if let Err(error) = commands::crystallize::run(args) {
681                eprintln!("error: {error}");
682                process::exit(1);
683            }
684        }
685        Command::Trust(args) | Command::TrustGraph(args) => {
686            if let Err(error) = commands::trust::handle(args).await {
687                eprintln!("error: {error}");
688                process::exit(1);
689            }
690        }
691        Command::Verify(args) => {
692            if let Err(error) = verify_provenance_receipt(&args.receipt, args.json) {
693                eprintln!("error: {error}");
694                process::exit(1);
695            }
696        }
697        Command::Completions(args) => print_completions(args.shell),
698        Command::Orchestrator(args) => {
699            if let Err(error) = commands::orchestrator::handle(args).await {
700                eprintln!("error: {error}");
701                process::exit(1);
702            }
703        }
704        Command::Playground(args) => {
705            provider_bootstrap::maybe_seed_ollama_for_playground(
706                Path::new(&args.host),
707                Path::new(&args.script),
708                args.yes,
709                args.llm.is_some(),
710                args.llm_mock.is_some(),
711            )
712            .await;
713            let llm_mock_mode = if let Some(path) = args.llm_mock.as_ref() {
714                commands::run::CliLlmMockMode::Replay {
715                    fixture_path: PathBuf::from(path),
716                }
717            } else if let Some(path) = args.llm_mock_record.as_ref() {
718                commands::run::CliLlmMockMode::Record {
719                    fixture_path: PathBuf::from(path),
720                }
721            } else {
722                commands::run::CliLlmMockMode::Off
723            };
724            if let Err(error) = commands::playground::run_command(args, llm_mock_mode).await {
725                eprint!("{error}");
726                process::exit(1);
727            }
728        }
729        Command::Runs(args) => match args.command {
730            RunsCommand::Inspect(inspect) => {
731                inspect_run_record(&inspect.path, inspect.compare.as_deref())
732            }
733        },
734        Command::Replay(args) => replay_run_record(&args.path),
735        Command::Eval(args) => {
736            let llm_mock_mode = if let Some(path) = args.llm_mock.as_ref() {
737                commands::run::CliLlmMockMode::Replay {
738                    fixture_path: PathBuf::from(path),
739                }
740            } else if let Some(path) = args.llm_mock_record.as_ref() {
741                commands::run::CliLlmMockMode::Record {
742                    fixture_path: PathBuf::from(path),
743                }
744            } else {
745                commands::run::CliLlmMockMode::Off
746            };
747            eval_run_record(
748                &args.path,
749                args.compare.as_deref(),
750                args.structural_experiment.as_deref(),
751                &args.argv,
752                &llm_mock_mode,
753            )
754        }
755        Command::Repl => commands::repl::run_repl().await,
756        Command::Bench(args) => commands::bench::run_bench(&args.file, args.iterations).await,
757        Command::TestBench(args) => commands::test_bench::run(args.command).await,
758        Command::Viz(args) => commands::viz::run_viz(&args.file, args.output.as_deref()),
759        Command::Install(args) => package::install_packages(
760            args.frozen || args.locked || args.offline,
761            args.refetch.as_deref(),
762            args.offline,
763            args.json,
764        ),
765        Command::Add(args) => package::add_package_with_registry(
766            &args.name_or_spec,
767            args.alias.as_deref(),
768            args.git.as_deref(),
769            args.tag.as_deref(),
770            args.rev.as_deref(),
771            args.branch.as_deref(),
772            args.path.as_deref(),
773            args.registry.as_deref(),
774        ),
775        Command::Update(args) => {
776            package::update_packages(args.alias.as_deref(), args.all, args.json)
777        }
778        Command::Remove(args) => package::remove_package(&args.alias),
779        Command::Lock => package::lock_packages(),
780        Command::Package(args) => match args.command {
781            PackageCommand::Search(search) => package::search_package_registry(
782                search.query.as_deref(),
783                search.registry.as_deref(),
784                search.json,
785            ),
786            PackageCommand::Info(info) => {
787                package::show_package_registry_info(&info.name, info.registry.as_deref(), info.json)
788            }
789            PackageCommand::Check(check) => {
790                package::check_package(check.package.as_deref(), check.json)
791            }
792            PackageCommand::Pack(pack) => package::pack_package(
793                pack.package.as_deref(),
794                pack.output.as_deref(),
795                pack.dry_run,
796                pack.json,
797            ),
798            PackageCommand::Docs(docs) => package::generate_package_docs(
799                docs.package.as_deref(),
800                docs.output.as_deref(),
801                docs.check,
802            ),
803            PackageCommand::Cache(cache) => match cache.command {
804                PackageCacheCommand::List => package::list_package_cache(),
805                PackageCacheCommand::Clean(clean) => package::clean_package_cache(clean.all),
806                PackageCacheCommand::Verify(verify) => {
807                    package::verify_package_cache(verify.materialized)
808                }
809            },
810            PackageCommand::Outdated(args) => package::outdated_packages(
811                args.refresh,
812                args.remote,
813                args.registry.as_deref(),
814                args.json,
815            ),
816            PackageCommand::Audit(args) => {
817                package::audit_packages(args.registry.as_deref(), args.skip_materialized, args.json)
818            }
819            PackageCommand::Artifacts(args) => match args.command {
820                PackageArtifactsCommand::Manifest(manifest) => {
821                    package::artifacts_manifest(manifest.output.as_deref())
822                }
823                PackageArtifactsCommand::Check(check) => {
824                    package::artifacts_check(&check.manifest, check.json)
825                }
826            },
827        },
828        Command::Publish(args) => package::publish_package(
829            args.package.as_deref(),
830            args.dry_run,
831            args.registry.as_deref(),
832            args.json,
833        ),
834        Command::MergeCaptain(args) => match args.command {
835            MergeCaptainCommand::Run(run) => {
836                let code = commands::merge_captain::run_driver(&run);
837                if code != 0 {
838                    process::exit(code);
839                }
840            }
841            MergeCaptainCommand::Ladder(ladder) => {
842                let code = commands::merge_captain::run_ladder(&ladder);
843                if code != 0 {
844                    process::exit(code);
845                }
846            }
847            MergeCaptainCommand::Iterate(iterate) => {
848                let code = commands::merge_captain::run_iterate(&iterate);
849                if code != 0 {
850                    process::exit(code);
851                }
852            }
853            MergeCaptainCommand::Audit(audit) => {
854                let code = commands::merge_captain::run_audit(&audit);
855                if code != 0 {
856                    process::exit(code);
857                }
858            }
859            MergeCaptainCommand::Mock(mock) => {
860                let code = match mock {
861                    MergeCaptainMockCommand::Init(args) => {
862                        commands::merge_captain_mock::run_init(&args)
863                    }
864                    MergeCaptainMockCommand::Step(args) => {
865                        commands::merge_captain_mock::run_step(&args)
866                    }
867                    MergeCaptainMockCommand::Status(args) => {
868                        commands::merge_captain_mock::run_status(&args)
869                    }
870                    MergeCaptainMockCommand::Serve(args) => {
871                        commands::merge_captain_mock::run_serve(&args).await
872                    }
873                    MergeCaptainMockCommand::Cleanup(args) => {
874                        commands::merge_captain_mock::run_cleanup(&args)
875                    }
876                    MergeCaptainMockCommand::Scenarios => {
877                        commands::merge_captain_mock::run_scenarios()
878                    }
879                };
880                if code != 0 {
881                    process::exit(code);
882                }
883            }
884        },
885        Command::Persona(args) => match args.command {
886            PersonaCommand::New(new) => {
887                if let Err(error) = commands::persona_scaffold::run_new(&new) {
888                    eprintln!("error: {error}");
889                    process::exit(1);
890                }
891            }
892            PersonaCommand::Doctor(doctor) => {
893                if let Err(error) =
894                    commands::persona_doctor::run_doctor(args.manifest.as_deref(), &doctor).await
895                {
896                    eprintln!("error: {error}");
897                    process::exit(1);
898                }
899            }
900            PersonaCommand::Check(check) => {
901                commands::persona::run_check(args.manifest.as_deref(), &check)
902            }
903            PersonaCommand::List(list) => {
904                commands::persona::run_list(args.manifest.as_deref(), &list)
905            }
906            PersonaCommand::Inspect(inspect) => {
907                commands::persona::run_inspect(args.manifest.as_deref(), &inspect)
908            }
909            PersonaCommand::Status(status) => {
910                if let Err(error) = commands::persona::run_status(
911                    args.manifest.as_deref(),
912                    &args.state_dir,
913                    &status,
914                )
915                .await
916                {
917                    eprintln!("error: {error}");
918                    process::exit(1);
919                }
920            }
921            PersonaCommand::Pause(control) => {
922                if let Err(error) = commands::persona::run_pause(
923                    args.manifest.as_deref(),
924                    &args.state_dir,
925                    &control,
926                )
927                .await
928                {
929                    eprintln!("error: {error}");
930                    process::exit(1);
931                }
932            }
933            PersonaCommand::Resume(control) => {
934                if let Err(error) = commands::persona::run_resume(
935                    args.manifest.as_deref(),
936                    &args.state_dir,
937                    &control,
938                )
939                .await
940                {
941                    eprintln!("error: {error}");
942                    process::exit(1);
943                }
944            }
945            PersonaCommand::Disable(control) => {
946                if let Err(error) = commands::persona::run_disable(
947                    args.manifest.as_deref(),
948                    &args.state_dir,
949                    &control,
950                )
951                .await
952                {
953                    eprintln!("error: {error}");
954                    process::exit(1);
955                }
956            }
957            PersonaCommand::Tick(tick) => {
958                if let Err(error) =
959                    commands::persona::run_tick(args.manifest.as_deref(), &args.state_dir, &tick)
960                        .await
961                {
962                    eprintln!("error: {error}");
963                    process::exit(1);
964                }
965            }
966            PersonaCommand::Trigger(trigger) => {
967                if let Err(error) = commands::persona::run_trigger(
968                    args.manifest.as_deref(),
969                    &args.state_dir,
970                    &trigger,
971                )
972                .await
973                {
974                    eprintln!("error: {error}");
975                    process::exit(1);
976                }
977            }
978            PersonaCommand::Spend(spend) => {
979                if let Err(error) =
980                    commands::persona::run_spend(args.manifest.as_deref(), &args.state_dir, &spend)
981                        .await
982                {
983                    eprintln!("error: {error}");
984                    process::exit(1);
985                }
986            }
987        },
988        Command::ModelInfo(args) => {
989            if !print_model_info(&args).await {
990                process::exit(1);
991            }
992        }
993        Command::ProviderCatalog(args) => print_provider_catalog(args.available_only),
994        Command::ProviderReady(args) => {
995            run_provider_ready(
996                &args.provider,
997                args.model.as_deref(),
998                args.base_url.as_deref(),
999                args.json,
1000            )
1001            .await
1002        }
1003        Command::Skills(args) => match args.command {
1004            SkillsCommand::List(list) => commands::skills::run_list(&list),
1005            SkillsCommand::Inspect(inspect) => commands::skills::run_inspect(&inspect),
1006            SkillsCommand::Match(matcher) => commands::skills::run_match(&matcher),
1007            SkillsCommand::Install(install) => commands::skills::run_install(&install),
1008            SkillsCommand::New(new_args) => commands::skills::run_new(&new_args),
1009        },
1010        Command::DumpHighlightKeywords(args) => {
1011            commands::dump_highlight_keywords::run(&args.output, args.check);
1012        }
1013        Command::DumpTriggerQuickref(args) => {
1014            commands::dump_trigger_quickref::run(&args.output, args.check);
1015        }
1016        Command::DumpConnectorMatrix(args) => {
1017            commands::check::connector_matrix::run_docs(&args.output, &args.sources, args.check);
1018        }
1019        Command::DumpProtocolArtifacts(args) => {
1020            commands::dump_protocol_artifacts::run(&args.output_dir, args.check);
1021        }
1022    }
1023}
1024
1025fn print_completions(shell: CompletionShell) {
1026    let mut command = Cli::command();
1027    let shell = clap_complete::Shell::from(shell);
1028    clap_complete::generate(shell, &mut command, "harn", &mut std::io::stdout());
1029}
1030
1031fn normalize_serve_args(mut raw_args: Vec<String>) -> Vec<String> {
1032    if raw_args.len() > 2
1033        && raw_args.get(1).is_some_and(|arg| arg == "serve")
1034        && !matches!(
1035            raw_args.get(2).map(String::as_str),
1036            Some("acp" | "a2a" | "mcp" | "-h" | "--help")
1037        )
1038    {
1039        raw_args.insert(2, "a2a".to_string());
1040    }
1041    raw_args
1042}
1043
1044fn print_version() {
1045    println!(
1046        r#"
1047 ╱▔▔╲
1048 ╱    ╲    harn v{}
1049 │ ◆  │    the agent harness language
1050 │    │
1051 ╰──╯╱
1052   ╱╱
1053"#,
1054        env!("CARGO_PKG_VERSION")
1055    );
1056}
1057
1058async fn print_model_info(args: &ModelInfoArgs) -> bool {
1059    let resolved = harn_vm::llm_config::resolve_model_info(&args.model);
1060    let api_key_result = harn_vm::llm::resolve_api_key(&resolved.provider);
1061    let api_key_set = api_key_result.is_ok();
1062    let api_key = api_key_result.unwrap_or_default();
1063    let context_window =
1064        harn_vm::llm::fetch_provider_max_context(&resolved.provider, &resolved.id, &api_key).await;
1065    let readiness = local_openai_readiness(&resolved.provider, &resolved.id, &api_key).await;
1066    let catalog = harn_vm::llm_config::model_catalog_entry(&resolved.id);
1067    let runtime_context_window = catalog
1068        .as_ref()
1069        .and_then(|entry| entry.runtime_context_window);
1070    let capabilities = harn_vm::llm::capabilities::lookup(&resolved.provider, &resolved.id);
1071    let mut payload = serde_json::json!({
1072        "alias": args.model,
1073        "id": resolved.id,
1074        "provider": resolved.provider,
1075        "resolved_alias": resolved.alias,
1076        "tool_format": resolved.tool_format,
1077        "tier": resolved.tier,
1078        "api_key_set": api_key_set,
1079        "context_window": context_window,
1080        "runtime_context_window": runtime_context_window,
1081        "readiness": readiness,
1082        "catalog": catalog,
1083        "capabilities": {
1084            "native_tools": capabilities.native_tools,
1085            "defer_loading": capabilities.defer_loading,
1086            "tool_search": capabilities.tool_search,
1087            "max_tools": capabilities.max_tools,
1088            "prompt_caching": capabilities.prompt_caching,
1089            "vision": capabilities.vision,
1090            "vision_supported": capabilities.vision_supported,
1091            "audio": capabilities.audio,
1092            "pdf": capabilities.pdf,
1093            "files_api_supported": capabilities.files_api_supported,
1094            "json_schema": capabilities.json_schema,
1095            "thinking": !capabilities.thinking_modes.is_empty(),
1096            "thinking_modes": capabilities.thinking_modes,
1097            "interleaved_thinking_supported": capabilities.interleaved_thinking_supported,
1098            "anthropic_beta_features": capabilities.anthropic_beta_features,
1099            "preserve_thinking": capabilities.preserve_thinking,
1100            "server_parser": capabilities.server_parser,
1101            "honors_chat_template_kwargs": capabilities.honors_chat_template_kwargs,
1102            "recommended_endpoint": capabilities.recommended_endpoint,
1103            "text_tool_wire_format_supported": capabilities.text_tool_wire_format_supported,
1104        },
1105        "qc_default_model": harn_vm::llm_config::qc_default_model(&resolved.provider),
1106    });
1107
1108    let should_verify = args.verify || args.warm;
1109    let mut ok = true;
1110    if should_verify {
1111        if resolved.provider == "ollama" {
1112            let mut readiness = harn_vm::llm::OllamaReadinessOptions::new(resolved.id.clone());
1113            readiness.warm = args.warm;
1114            readiness.keep_alive = args
1115                .keep_alive
1116                .as_deref()
1117                .and_then(harn_vm::llm::normalize_ollama_keep_alive);
1118            let result = harn_vm::llm::ollama_readiness(readiness).await;
1119            ok = result.valid;
1120            payload["readiness"] = serde_json::to_value(&result).unwrap_or_else(|error| {
1121                serde_json::json!({
1122                    "valid": false,
1123                    "status": "serialization_error",
1124                    "message": format!("failed to serialize readiness result: {error}"),
1125                })
1126            });
1127        } else {
1128            ok = false;
1129            payload["readiness"] = serde_json::json!({
1130                "valid": false,
1131                "status": "unsupported_provider",
1132                "message": format!(
1133                    "model-info --verify is only supported for Ollama models; resolved provider is '{}'",
1134                    resolved.provider
1135                ),
1136                "provider": resolved.provider,
1137            });
1138        }
1139    }
1140
1141    println!(
1142        "{}",
1143        serde_json::to_string(&payload).unwrap_or_else(|error| {
1144            command_error(&format!("failed to serialize model info: {error}"))
1145        })
1146    );
1147    ok
1148}
1149
1150async fn local_openai_readiness(
1151    provider: &str,
1152    model: &str,
1153    api_key: &str,
1154) -> Option<serde_json::Value> {
1155    let def = harn_vm::llm_config::provider_config(provider)?;
1156    if def.auth_style != "none" || !harn_vm::llm::supports_model_readiness_probe(&def) {
1157        return None;
1158    }
1159    let readiness = harn_vm::llm::probe_openai_compatible_model(provider, model, api_key).await;
1160    Some(serde_json::json!({
1161        "valid": readiness.valid,
1162        "category": readiness.category,
1163        "message": readiness.message,
1164        "provider": readiness.provider,
1165        "model": readiness.model,
1166        "url": readiness.url,
1167        "status": readiness.status,
1168        "available_models": readiness.available_models,
1169    }))
1170}
1171
1172fn print_provider_catalog(available_only: bool) {
1173    let provider_names = if available_only {
1174        harn_vm::llm_config::available_provider_names()
1175    } else {
1176        harn_vm::llm_config::provider_names()
1177    };
1178    let providers: Vec<_> = provider_names
1179        .into_iter()
1180        .filter_map(|name| {
1181            harn_vm::llm_config::provider_config(&name).map(|def| {
1182                serde_json::json!({
1183                    "name": name,
1184                    "display_name": def.display_name,
1185                    "icon": def.icon,
1186                    "base_url": harn_vm::llm_config::resolve_base_url(&def),
1187                    "base_url_env": def.base_url_env,
1188                    "auth_style": def.auth_style,
1189                    "auth_envs": harn_vm::llm_config::auth_env_names(&def.auth_env),
1190                    "auth_available": harn_vm::llm_config::provider_key_available(&name),
1191                    "features": def.features,
1192                    "cost_per_1k_in": def.cost_per_1k_in,
1193                    "cost_per_1k_out": def.cost_per_1k_out,
1194                    "latency_p50_ms": def.latency_p50_ms,
1195                })
1196            })
1197        })
1198        .collect();
1199    let models: Vec<_> = harn_vm::llm_config::model_catalog_entries()
1200        .into_iter()
1201        .map(|(id, model)| {
1202            serde_json::json!({
1203                "id": id,
1204                "name": model.name,
1205                "provider": model.provider,
1206                "context_window": model.context_window,
1207                "runtime_context_window": model.runtime_context_window,
1208                "stream_timeout": model.stream_timeout,
1209                "capabilities": model.capabilities,
1210                "pricing": model.pricing,
1211            })
1212        })
1213        .collect();
1214    let aliases: Vec<_> = harn_vm::llm_config::alias_entries()
1215        .into_iter()
1216        .map(|(name, alias)| {
1217            serde_json::json!({
1218                "name": name,
1219                "id": alias.id,
1220                "provider": alias.provider,
1221                "tool_format": alias.tool_format,
1222            })
1223        })
1224        .collect();
1225    let payload = serde_json::json!({
1226        "providers": providers,
1227        "known_model_names": harn_vm::llm_config::known_model_names(),
1228        "available_providers": harn_vm::llm_config::available_provider_names(),
1229        "aliases": aliases,
1230        "models": models,
1231        "qc_defaults": harn_vm::llm_config::qc_defaults(),
1232    });
1233    println!(
1234        "{}",
1235        serde_json::to_string(&payload).unwrap_or_else(|error| {
1236            command_error(&format!("failed to serialize provider catalog: {error}"))
1237        })
1238    );
1239}
1240
1241async fn run_provider_ready(
1242    provider: &str,
1243    model: Option<&str>,
1244    base_url: Option<&str>,
1245    json: bool,
1246) {
1247    let readiness =
1248        harn_vm::llm::readiness::probe_provider_readiness(provider, model, base_url).await;
1249    if json {
1250        match serde_json::to_string_pretty(&readiness) {
1251            Ok(payload) => println!("{payload}"),
1252            Err(error) => command_error(&format!("failed to serialize readiness result: {error}")),
1253        }
1254    } else if readiness.ok {
1255        println!("{}", readiness.message);
1256    } else {
1257        eprintln!("{}", readiness.message);
1258    }
1259    if !readiness.ok {
1260        process::exit(1);
1261    }
1262}
1263
1264fn command_error(message: &str) -> ! {
1265    Cli::command()
1266        .error(ErrorKind::ValueValidation, message)
1267        .exit()
1268}
1269
1270fn verify_provenance_receipt(path: &str, json: bool) -> Result<(), String> {
1271    let raw =
1272        fs::read_to_string(path).map_err(|error| format!("failed to read {path}: {error}"))?;
1273    let receipt: harn_vm::ProvenanceReceipt = serde_json::from_str(&raw)
1274        .map_err(|error| format!("failed to parse provenance receipt {path}: {error}"))?;
1275    let report = harn_vm::verify_receipt(&receipt);
1276    if json {
1277        println!(
1278            "{}",
1279            serde_json::to_string_pretty(&report).map_err(|error| error.to_string())?
1280        );
1281    } else if report.verified {
1282        println!(
1283            "verified receipt={} events={} receipt_hash={} event_root_hash={}",
1284            report.receipt_id.unwrap_or_else(|| "-".to_string()),
1285            report.event_count,
1286            report.receipt_hash.unwrap_or_else(|| "-".to_string()),
1287            report.event_root_hash.unwrap_or_else(|| "-".to_string())
1288        );
1289    } else {
1290        println!(
1291            "failed receipt={} events={}",
1292            report.receipt_id.unwrap_or_else(|| "-".to_string()),
1293            report.event_count
1294        );
1295        for error in &report.errors {
1296            println!("  {error}");
1297        }
1298        return Err("provenance receipt verification failed".to_string());
1299    }
1300    Ok(())
1301}
1302
1303fn load_run_record_or_exit(path: &Path) -> harn_vm::orchestration::RunRecord {
1304    match harn_vm::orchestration::load_run_record(path) {
1305        Ok(run) => run,
1306        Err(error) => {
1307            eprintln!("Failed to load run record: {error}");
1308            process::exit(1);
1309        }
1310    }
1311}
1312
1313fn load_eval_suite_manifest_or_exit(path: &Path) -> harn_vm::orchestration::EvalSuiteManifest {
1314    harn_vm::orchestration::load_eval_suite_manifest(path).unwrap_or_else(|error| {
1315        eprintln!("Failed to load eval manifest {}: {error}", path.display());
1316        process::exit(1);
1317    })
1318}
1319
1320fn load_eval_pack_manifest_or_exit(path: &Path) -> harn_vm::orchestration::EvalPackManifest {
1321    harn_vm::orchestration::load_eval_pack_manifest(path).unwrap_or_else(|error| {
1322        eprintln!("Failed to load eval pack {}: {error}", path.display());
1323        process::exit(1);
1324    })
1325}
1326
1327fn load_persona_eval_ladder_manifest_or_exit(
1328    path: &Path,
1329) -> harn_vm::orchestration::PersonaEvalLadderManifest {
1330    harn_vm::orchestration::load_persona_eval_ladder_manifest(path).unwrap_or_else(|error| {
1331        eprintln!(
1332            "Failed to load persona eval ladder {}: {error}",
1333            path.display()
1334        );
1335        process::exit(1);
1336    })
1337}
1338
1339fn file_looks_like_eval_manifest(path: &Path) -> bool {
1340    if path.file_name().and_then(|name| name.to_str()) == Some("harn.eval.toml") {
1341        return true;
1342    }
1343    if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
1344        let Ok(content) = fs::read_to_string(path) else {
1345            return false;
1346        };
1347        return toml::from_str::<harn_vm::orchestration::EvalPackManifest>(&content)
1348            .is_ok_and(|manifest| !manifest.cases.is_empty() || !manifest.ladders.is_empty());
1349    }
1350    let Ok(content) = fs::read_to_string(path) else {
1351        return false;
1352    };
1353    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1354        return false;
1355    };
1356    json.get("_type").and_then(|value| value.as_str()) == Some("eval_suite_manifest")
1357        || json.get("cases").is_some()
1358}
1359
1360fn file_looks_like_eval_pack_manifest(path: &Path) -> bool {
1361    if path.file_name().and_then(|name| name.to_str()) == Some("harn.eval.toml") {
1362        return true;
1363    }
1364    if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
1365        return file_looks_like_eval_manifest(path);
1366    }
1367    let Ok(content) = fs::read_to_string(path) else {
1368        return false;
1369    };
1370    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1371        return false;
1372    };
1373    json.get("version").is_some()
1374        && (json.get("cases").is_some() || json.get("ladders").is_some())
1375        && json.get("_type").and_then(|value| value.as_str()) != Some("eval_suite_manifest")
1376}
1377
1378fn file_looks_like_persona_eval_ladder_manifest(path: &Path) -> bool {
1379    let Ok(content) = fs::read_to_string(path) else {
1380        return false;
1381    };
1382    if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
1383        let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1384            return false;
1385        };
1386        return json.get("_type").and_then(|value| value.as_str())
1387            == Some("persona_eval_ladder_manifest")
1388            || json.get("timeout_tiers").is_some()
1389            || json.get("timeout-tiers").is_some();
1390    }
1391    toml::from_str::<harn_vm::orchestration::PersonaEvalLadderManifest>(&content).is_ok_and(
1392        |manifest| {
1393            manifest
1394                .type_name
1395                .eq_ignore_ascii_case("persona_eval_ladder_manifest")
1396                || (!manifest.timeout_tiers.is_empty() && manifest.backend.path.is_some())
1397        },
1398    )
1399}
1400
1401fn collect_run_record_paths(path: &str) -> Vec<PathBuf> {
1402    let path = Path::new(path);
1403    if path.is_file() {
1404        return vec![path.to_path_buf()];
1405    }
1406    if path.is_dir() {
1407        let mut entries: Vec<PathBuf> = fs::read_dir(path)
1408            .unwrap_or_else(|error| {
1409                eprintln!("Failed to read run directory {}: {error}", path.display());
1410                process::exit(1);
1411            })
1412            .filter_map(|entry| entry.ok().map(|entry| entry.path()))
1413            .filter(|entry| entry.extension().and_then(|ext| ext.to_str()) == Some("json"))
1414            .collect();
1415        entries.sort();
1416        return entries;
1417    }
1418    eprintln!("Run path does not exist: {}", path.display());
1419    process::exit(1);
1420}
1421
1422fn print_run_diff(diff: &harn_vm::orchestration::RunDiffReport) {
1423    println!(
1424        "Diff: {} -> {} [{} -> {}]",
1425        diff.left_run_id, diff.right_run_id, diff.left_status, diff.right_status
1426    );
1427    println!("Identical: {}", diff.identical);
1428    println!("Stage diffs: {}", diff.stage_diffs.len());
1429    println!("Tool diffs: {}", diff.tool_diffs.len());
1430    println!("Observability diffs: {}", diff.observability_diffs.len());
1431    println!("Transition delta: {}", diff.transition_count_delta);
1432    println!("Artifact delta: {}", diff.artifact_count_delta);
1433    println!("Checkpoint delta: {}", diff.checkpoint_count_delta);
1434    for stage in &diff.stage_diffs {
1435        println!("- {} [{}]", stage.node_id, stage.change);
1436        for detail in &stage.details {
1437            println!("  {}", detail);
1438        }
1439    }
1440    for tool in &diff.tool_diffs {
1441        println!("- tool {} [{}]", tool.tool_name, tool.args_hash);
1442        println!("  left: {:?}", tool.left_result);
1443        println!("  right: {:?}", tool.right_result);
1444    }
1445    for item in &diff.observability_diffs {
1446        println!("- {} [{}]", item.label, item.section);
1447        for detail in &item.details {
1448            println!("  {}", detail);
1449        }
1450    }
1451}
1452
1453fn inspect_run_record(path: &str, compare: Option<&str>) {
1454    let run = load_run_record_or_exit(Path::new(path));
1455    println!("Run: {}", run.id);
1456    println!(
1457        "Workflow: {}",
1458        run.workflow_name
1459            .clone()
1460            .unwrap_or_else(|| run.workflow_id.clone())
1461    );
1462    println!("Status: {}", run.status);
1463    println!("Task: {}", run.task);
1464    println!("Stages: {}", run.stages.len());
1465    println!("Artifacts: {}", run.artifacts.len());
1466    println!("Transitions: {}", run.transitions.len());
1467    println!("Checkpoints: {}", run.checkpoints.len());
1468    println!("HITL questions: {}", run.hitl_questions.len());
1469    if let Some(observability) = &run.observability {
1470        println!("Planner rounds: {}", observability.planner_rounds.len());
1471        println!("Research facts: {}", observability.research_fact_count);
1472        println!("Workers: {}", observability.worker_lineage.len());
1473        println!(
1474            "Action graph: {} nodes / {} edges",
1475            observability.action_graph_nodes.len(),
1476            observability.action_graph_edges.len()
1477        );
1478        println!(
1479            "Transcript pointers: {}",
1480            observability.transcript_pointers.len()
1481        );
1482        println!("Daemon events: {}", observability.daemon_events.len());
1483    }
1484    if let Some(parent_worker_id) = run
1485        .metadata
1486        .get("parent_worker_id")
1487        .and_then(|value| value.as_str())
1488    {
1489        println!("Parent worker: {}", parent_worker_id);
1490    }
1491    if let Some(parent_stage_id) = run
1492        .metadata
1493        .get("parent_stage_id")
1494        .and_then(|value| value.as_str())
1495    {
1496        println!("Parent stage: {}", parent_stage_id);
1497    }
1498    if run
1499        .metadata
1500        .get("delegated")
1501        .and_then(|value| value.as_bool())
1502        .unwrap_or(false)
1503    {
1504        println!("Delegated: true");
1505    }
1506    println!(
1507        "Pending nodes: {}",
1508        if run.pending_nodes.is_empty() {
1509            "-".to_string()
1510        } else {
1511            run.pending_nodes.join(", ")
1512        }
1513    );
1514    println!(
1515        "Replay fixture: {}",
1516        if run.replay_fixture.is_some() {
1517            "embedded"
1518        } else {
1519            "derived"
1520        }
1521    );
1522    for stage in &run.stages {
1523        let worker = stage.metadata.get("worker");
1524        let worker_suffix = worker
1525            .and_then(|value| value.get("name"))
1526            .and_then(|value| value.as_str())
1527            .map(|name| format!(" worker={name}"))
1528            .unwrap_or_default();
1529        println!(
1530            "- {} [{}] status={} outcome={} branch={}{}",
1531            stage.node_id,
1532            stage.kind,
1533            stage.status,
1534            stage.outcome,
1535            stage.branch.clone().unwrap_or_else(|| "-".to_string()),
1536            worker_suffix,
1537        );
1538        if let Some(worker) = worker {
1539            if let Some(worker_id) = worker.get("id").and_then(|value| value.as_str()) {
1540                println!("  worker_id: {}", worker_id);
1541            }
1542            if let Some(child_run_id) = worker.get("child_run_id").and_then(|value| value.as_str())
1543            {
1544                println!("  child_run_id: {}", child_run_id);
1545            }
1546            if let Some(child_run_path) = worker
1547                .get("child_run_path")
1548                .and_then(|value| value.as_str())
1549            {
1550                println!("  child_run_path: {}", child_run_path);
1551            }
1552        }
1553    }
1554    if let Some(observability) = &run.observability {
1555        for round in &observability.planner_rounds {
1556            println!(
1557                "- planner {} iterations={} llm_calls={} tools={} research_facts={}",
1558                round.node_id,
1559                round.iteration_count,
1560                round.llm_call_count,
1561                round.tool_execution_count,
1562                round.research_facts.len()
1563            );
1564        }
1565        for pointer in &observability.transcript_pointers {
1566            println!(
1567                "- transcript {} [{}] available={} {}",
1568                pointer.label,
1569                pointer.kind,
1570                pointer.available,
1571                pointer
1572                    .path
1573                    .clone()
1574                    .unwrap_or_else(|| pointer.location.clone())
1575            );
1576        }
1577        for event in &observability.daemon_events {
1578            println!(
1579                "- daemon {} [{:?}] at {}",
1580                event.name, event.kind, event.timestamp
1581            );
1582            println!("  id: {}", event.daemon_id);
1583            println!("  persist_path: {}", event.persist_path);
1584            if let Some(summary) = &event.payload_summary {
1585                println!("  payload: {}", summary);
1586            }
1587        }
1588    }
1589    if let Some(compare_path) = compare {
1590        let baseline = load_run_record_or_exit(Path::new(compare_path));
1591        print_run_diff(&harn_vm::orchestration::diff_run_records(&baseline, &run));
1592    }
1593}
1594
1595fn replay_run_record(path: &str) {
1596    let run = load_run_record_or_exit(Path::new(path));
1597    println!("Replay: {}", run.id);
1598    for stage in &run.stages {
1599        println!(
1600            "[{}] status={} outcome={} branch={}",
1601            stage.node_id,
1602            stage.status,
1603            stage.outcome,
1604            stage.branch.clone().unwrap_or_else(|| "-".to_string())
1605        );
1606        if let Some(text) = &stage.visible_text {
1607            println!("  visible: {}", text);
1608        }
1609        if let Some(verification) = &stage.verification {
1610            println!("  verification: {}", verification);
1611        }
1612    }
1613    if let Some(transcript) = &run.transcript {
1614        println!(
1615            "Transcript events persisted: {}",
1616            transcript["events"]
1617                .as_array()
1618                .map(|v| v.len())
1619                .unwrap_or(0)
1620        );
1621    }
1622    let fixture = run
1623        .replay_fixture
1624        .clone()
1625        .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(&run));
1626    let report = harn_vm::orchestration::evaluate_run_against_fixture(&run, &fixture);
1627    println!(
1628        "Embedded replay fixture: {}",
1629        if report.pass { "PASS" } else { "FAIL" }
1630    );
1631    for transition in &run.transitions {
1632        println!(
1633            "transition {} -> {} ({})",
1634            transition
1635                .from_node_id
1636                .clone()
1637                .unwrap_or_else(|| "start".to_string()),
1638            transition.to_node_id,
1639            transition
1640                .branch
1641                .clone()
1642                .unwrap_or_else(|| "default".to_string())
1643        );
1644    }
1645}
1646
1647fn eval_run_record(
1648    path: &str,
1649    compare: Option<&str>,
1650    structural_experiment: Option<&str>,
1651    argv: &[String],
1652    llm_mock_mode: &commands::run::CliLlmMockMode,
1653) {
1654    if let Some(experiment) = structural_experiment {
1655        let path_buf = PathBuf::from(path);
1656        if !path_buf.is_file() || path_buf.extension().and_then(|ext| ext.to_str()) != Some("harn")
1657        {
1658            eprintln!(
1659                "--structural-experiment currently requires a .harn pipeline path, got {}",
1660                path
1661            );
1662            process::exit(1);
1663        }
1664        if compare.is_some() {
1665            eprintln!("--compare cannot be combined with --structural-experiment");
1666            process::exit(1);
1667        }
1668        if matches!(llm_mock_mode, commands::run::CliLlmMockMode::Record { .. }) {
1669            eprintln!("--llm-mock-record cannot be combined with --structural-experiment");
1670            process::exit(1);
1671        }
1672        let path_buf = fs::canonicalize(&path_buf).unwrap_or_else(|error| {
1673            command_error(&format!(
1674                "failed to canonicalize structural eval pipeline {}: {error}",
1675                path_buf.display()
1676            ))
1677        });
1678        run_structural_experiment_eval(&path_buf, experiment, argv, llm_mock_mode);
1679        return;
1680    }
1681
1682    let path_buf = PathBuf::from(path);
1683    if path_buf.is_file() && file_looks_like_persona_eval_ladder_manifest(&path_buf) {
1684        if compare.is_some() {
1685            eprintln!("--compare is not supported with persona eval ladder manifests");
1686            process::exit(1);
1687        }
1688        let manifest = load_persona_eval_ladder_manifest_or_exit(&path_buf);
1689        let report =
1690            harn_vm::orchestration::run_persona_eval_ladder(&manifest).unwrap_or_else(|error| {
1691                eprintln!(
1692                    "Failed to evaluate persona eval ladder {}: {error}",
1693                    path_buf.display()
1694                );
1695                process::exit(1);
1696            });
1697        print_persona_ladder_report(&report);
1698        if !report.pass {
1699            process::exit(1);
1700        }
1701        return;
1702    }
1703
1704    if path_buf.is_file() && file_looks_like_eval_pack_manifest(&path_buf) {
1705        if compare.is_some() {
1706            eprintln!("--compare is not supported with eval pack manifests");
1707            process::exit(1);
1708        }
1709        let manifest = load_eval_pack_manifest_or_exit(&path_buf);
1710        let report = harn_vm::orchestration::evaluate_eval_pack_manifest(&manifest).unwrap_or_else(
1711            |error| {
1712                eprintln!(
1713                    "Failed to evaluate eval pack {}: {error}",
1714                    path_buf.display()
1715                );
1716                process::exit(1);
1717            },
1718        );
1719        print_eval_pack_report(&report);
1720        if !report.pass {
1721            process::exit(1);
1722        }
1723        return;
1724    }
1725
1726    if path_buf.is_file() && file_looks_like_eval_manifest(&path_buf) {
1727        if compare.is_some() {
1728            eprintln!("--compare is not supported with eval suite manifests");
1729            process::exit(1);
1730        }
1731        let manifest = load_eval_suite_manifest_or_exit(&path_buf);
1732        let suite = harn_vm::orchestration::evaluate_run_suite_manifest(&manifest).unwrap_or_else(
1733            |error| {
1734                eprintln!(
1735                    "Failed to evaluate manifest {}: {error}",
1736                    path_buf.display()
1737                );
1738                process::exit(1);
1739            },
1740        );
1741        println!(
1742            "{} {} passed, {} failed, {} total",
1743            if suite.pass { "PASS" } else { "FAIL" },
1744            suite.passed,
1745            suite.failed,
1746            suite.total
1747        );
1748        for case in &suite.cases {
1749            println!(
1750                "- {} [{}] {}",
1751                case.label.clone().unwrap_or_else(|| case.run_id.clone()),
1752                case.workflow_id,
1753                if case.pass { "PASS" } else { "FAIL" }
1754            );
1755            if let Some(path) = &case.source_path {
1756                println!("  path: {}", path);
1757            }
1758            if let Some(comparison) = &case.comparison {
1759                println!("  baseline identical: {}", comparison.identical);
1760                if !comparison.identical {
1761                    println!(
1762                        "  baseline status: {} -> {}",
1763                        comparison.left_status, comparison.right_status
1764                    );
1765                }
1766            }
1767            for failure in &case.failures {
1768                println!("  {}", failure);
1769            }
1770        }
1771        if !suite.pass {
1772            process::exit(1);
1773        }
1774        return;
1775    }
1776
1777    let paths = collect_run_record_paths(path);
1778    if paths.len() > 1 {
1779        let mut cases = Vec::new();
1780        for path in &paths {
1781            let run = load_run_record_or_exit(path);
1782            let fixture = run
1783                .replay_fixture
1784                .clone()
1785                .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(&run));
1786            cases.push((run, fixture, Some(path.display().to_string())));
1787        }
1788        let suite = harn_vm::orchestration::evaluate_run_suite(cases);
1789        println!(
1790            "{} {} passed, {} failed, {} total",
1791            if suite.pass { "PASS" } else { "FAIL" },
1792            suite.passed,
1793            suite.failed,
1794            suite.total
1795        );
1796        for case in &suite.cases {
1797            println!(
1798                "- {} [{}] {}",
1799                case.run_id,
1800                case.workflow_id,
1801                if case.pass { "PASS" } else { "FAIL" }
1802            );
1803            if let Some(path) = &case.source_path {
1804                println!("  path: {}", path);
1805            }
1806            if let Some(comparison) = &case.comparison {
1807                println!("  baseline identical: {}", comparison.identical);
1808            }
1809            for failure in &case.failures {
1810                println!("  {}", failure);
1811            }
1812        }
1813        if !suite.pass {
1814            process::exit(1);
1815        }
1816        return;
1817    }
1818
1819    let run = load_run_record_or_exit(&paths[0]);
1820    let fixture = run
1821        .replay_fixture
1822        .clone()
1823        .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(&run));
1824    let report = harn_vm::orchestration::evaluate_run_against_fixture(&run, &fixture);
1825    println!("{}", if report.pass { "PASS" } else { "FAIL" });
1826    println!("Stages: {}", report.stage_count);
1827    if let Some(compare_path) = compare {
1828        let baseline = load_run_record_or_exit(Path::new(compare_path));
1829        print_run_diff(&harn_vm::orchestration::diff_run_records(&baseline, &run));
1830    }
1831    if !report.failures.is_empty() {
1832        for failure in &report.failures {
1833            println!("- {}", failure);
1834        }
1835    }
1836    if !report.pass {
1837        process::exit(1);
1838    }
1839}
1840
1841fn print_eval_pack_report(report: &harn_vm::orchestration::EvalPackReport) {
1842    println!(
1843        "{} {} passed, {} blocking failed, {} warning, {} informational, {} total",
1844        if report.pass { "PASS" } else { "FAIL" },
1845        report.passed,
1846        report.blocking_failed,
1847        report.warning_failed,
1848        report.informational_failed,
1849        report.total
1850    );
1851    for case in &report.cases {
1852        println!(
1853            "- {} [{}] {} ({})",
1854            case.label,
1855            case.workflow_id,
1856            if case.pass { "PASS" } else { "FAIL" },
1857            case.severity
1858        );
1859        if let Some(path) = &case.source_path {
1860            println!("  path: {}", path);
1861        }
1862        if let Some(comparison) = &case.comparison {
1863            println!("  baseline identical: {}", comparison.identical);
1864            if !comparison.identical {
1865                println!(
1866                    "  baseline status: {} -> {}",
1867                    comparison.left_status, comparison.right_status
1868                );
1869            }
1870        }
1871        for failure in &case.failures {
1872            println!("  {}", failure);
1873        }
1874        for warning in &case.warnings {
1875            println!("  warning: {}", warning);
1876        }
1877        for item in &case.informational {
1878            println!("  info: {}", item);
1879        }
1880    }
1881    for ladder in &report.ladders {
1882        println!(
1883            "- ladder {} [{}] {} ({}) first_correct={}/{}",
1884            ladder.id,
1885            ladder.persona,
1886            if ladder.pass { "PASS" } else { "FAIL" },
1887            ladder.severity,
1888            ladder.first_correct_route.as_deref().unwrap_or("<none>"),
1889            ladder.first_correct_tier.as_deref().unwrap_or("<none>")
1890        );
1891        println!("  artifacts: {}", ladder.artifact_root);
1892        for tier in &ladder.tiers {
1893            println!(
1894                "  - {} [{}] {} tools={} models={} latency={}ms cost=${:.6}",
1895                tier.timeout_tier,
1896                tier.route_id,
1897                tier.outcome,
1898                tier.tool_calls,
1899                tier.model_calls,
1900                tier.latency_ms,
1901                tier.cost_usd
1902            );
1903            for reason in &tier.degradation_reasons {
1904                println!("    {}", reason);
1905            }
1906        }
1907    }
1908}
1909
1910fn print_persona_ladder_report(report: &harn_vm::orchestration::PersonaEvalLadderReport) {
1911    println!(
1912        "{} ladder {} passed, {} degraded/looped, {} total",
1913        if report.pass { "PASS" } else { "FAIL" },
1914        report.passed,
1915        report.failed,
1916        report.total
1917    );
1918    println!(
1919        "first_correct: {}/{}",
1920        report.first_correct_route.as_deref().unwrap_or("<none>"),
1921        report.first_correct_tier.as_deref().unwrap_or("<none>")
1922    );
1923    println!("artifacts: {}", report.artifact_root);
1924    for tier in &report.tiers {
1925        println!(
1926            "- {} [{}] {} tools={} models={} latency={}ms cost=${:.6}",
1927            tier.timeout_tier,
1928            tier.route_id,
1929            tier.outcome,
1930            tier.tool_calls,
1931            tier.model_calls,
1932            tier.latency_ms,
1933            tier.cost_usd
1934        );
1935        for reason in &tier.degradation_reasons {
1936            println!("  {}", reason);
1937        }
1938    }
1939}
1940
1941fn run_package_evals() {
1942    let paths = package::load_package_eval_pack_paths(None).unwrap_or_else(|error| {
1943        eprintln!("{error}");
1944        process::exit(1);
1945    });
1946    let mut all_pass = true;
1947    for path in &paths {
1948        println!("Eval pack: {}", path.display());
1949        let manifest = load_eval_pack_manifest_or_exit(path);
1950        let report = harn_vm::orchestration::evaluate_eval_pack_manifest(&manifest).unwrap_or_else(
1951            |error| {
1952                eprintln!("Failed to evaluate eval pack {}: {error}", path.display());
1953                process::exit(1);
1954            },
1955        );
1956        print_eval_pack_report(&report);
1957        all_pass &= report.pass;
1958    }
1959    if !all_pass {
1960        process::exit(1);
1961    }
1962}
1963
1964fn run_structural_experiment_eval(
1965    path: &Path,
1966    experiment: &str,
1967    argv: &[String],
1968    llm_mock_mode: &commands::run::CliLlmMockMode,
1969) {
1970    let baseline_dir = tempfile::Builder::new()
1971        .prefix("harn-eval-baseline-")
1972        .tempdir()
1973        .unwrap_or_else(|error| {
1974            command_error(&format!("failed to create baseline tempdir: {error}"))
1975        });
1976    let variant_dir = tempfile::Builder::new()
1977        .prefix("harn-eval-variant-")
1978        .tempdir()
1979        .unwrap_or_else(|error| {
1980            command_error(&format!("failed to create variant tempdir: {error}"))
1981        });
1982
1983    let baseline = spawn_eval_pipeline_run(path, baseline_dir.path(), None, argv, llm_mock_mode);
1984    if !baseline.status.success() {
1985        relay_subprocess_failure("baseline", &baseline);
1986    }
1987
1988    let variant = spawn_eval_pipeline_run(
1989        path,
1990        variant_dir.path(),
1991        Some(experiment),
1992        argv,
1993        llm_mock_mode,
1994    );
1995    if !variant.status.success() {
1996        relay_subprocess_failure("variant", &variant);
1997    }
1998
1999    let baseline_runs = collect_structural_eval_runs(baseline_dir.path());
2000    let variant_runs = collect_structural_eval_runs(variant_dir.path());
2001    if baseline_runs.is_empty() || variant_runs.is_empty() {
2002        eprintln!(
2003            "structural eval expected workflow run records under {} and {}, but one side was empty",
2004            baseline_dir.path().display(),
2005            variant_dir.path().display()
2006        );
2007        process::exit(1);
2008    }
2009    if baseline_runs.len() != variant_runs.len() {
2010        eprintln!(
2011            "structural eval produced different run counts: baseline={} variant={}",
2012            baseline_runs.len(),
2013            variant_runs.len()
2014        );
2015        process::exit(1);
2016    }
2017
2018    let mut baseline_ok = 0usize;
2019    let mut variant_ok = 0usize;
2020    let mut any_failures = false;
2021
2022    println!("Structural experiment: {}", experiment);
2023    println!("Cases: {}", baseline_runs.len());
2024    for (baseline_run, variant_run) in baseline_runs.iter().zip(variant_runs.iter()) {
2025        let baseline_fixture = baseline_run
2026            .replay_fixture
2027            .clone()
2028            .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(baseline_run));
2029        let variant_fixture = variant_run
2030            .replay_fixture
2031            .clone()
2032            .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(variant_run));
2033        let baseline_report =
2034            harn_vm::orchestration::evaluate_run_against_fixture(baseline_run, &baseline_fixture);
2035        let variant_report =
2036            harn_vm::orchestration::evaluate_run_against_fixture(variant_run, &variant_fixture);
2037        let diff = harn_vm::orchestration::diff_run_records(baseline_run, variant_run);
2038        if baseline_report.pass {
2039            baseline_ok += 1;
2040        }
2041        if variant_report.pass {
2042            variant_ok += 1;
2043        }
2044        any_failures |= !baseline_report.pass || !variant_report.pass;
2045        println!(
2046            "- {} [{}]",
2047            variant_run
2048                .workflow_name
2049                .clone()
2050                .unwrap_or_else(|| variant_run.workflow_id.clone()),
2051            variant_run.task
2052        );
2053        println!(
2054            "  baseline: {}",
2055            if baseline_report.pass { "PASS" } else { "FAIL" }
2056        );
2057        for failure in &baseline_report.failures {
2058            println!("    {}", failure);
2059        }
2060        println!(
2061            "  variant: {}",
2062            if variant_report.pass { "PASS" } else { "FAIL" }
2063        );
2064        for failure in &variant_report.failures {
2065            println!("    {}", failure);
2066        }
2067        println!("  diff identical: {}", diff.identical);
2068        println!("  stage diffs: {}", diff.stage_diffs.len());
2069        println!("  tool diffs: {}", diff.tool_diffs.len());
2070        println!("  observability diffs: {}", diff.observability_diffs.len());
2071    }
2072
2073    println!("Baseline {} / {} passed", baseline_ok, baseline_runs.len());
2074    println!("Variant {} / {} passed", variant_ok, variant_runs.len());
2075
2076    if any_failures {
2077        process::exit(1);
2078    }
2079}
2080
2081fn spawn_eval_pipeline_run(
2082    path: &Path,
2083    run_dir: &Path,
2084    structural_experiment: Option<&str>,
2085    argv: &[String],
2086    llm_mock_mode: &commands::run::CliLlmMockMode,
2087) -> std::process::Output {
2088    let exe = env::current_exe().unwrap_or_else(|error| {
2089        command_error(&format!("failed to resolve current executable: {error}"))
2090    });
2091    let mut command = std::process::Command::new(exe);
2092    command.current_dir(path.parent().unwrap_or_else(|| Path::new(".")));
2093    command.arg("run");
2094    match llm_mock_mode {
2095        commands::run::CliLlmMockMode::Off => {}
2096        commands::run::CliLlmMockMode::Replay { fixture_path } => {
2097            command
2098                .arg("--llm-mock")
2099                .arg(absolute_cli_path(fixture_path));
2100        }
2101        commands::run::CliLlmMockMode::Record { fixture_path } => {
2102            command
2103                .arg("--llm-mock-record")
2104                .arg(absolute_cli_path(fixture_path));
2105        }
2106    }
2107    command.arg(path);
2108    if !argv.is_empty() {
2109        command.arg("--");
2110        command.args(argv);
2111    }
2112    command.env(harn_vm::runtime_paths::HARN_RUN_DIR_ENV, run_dir);
2113    if let Some(experiment) = structural_experiment {
2114        command.env("HARN_STRUCTURAL_EXPERIMENT", experiment);
2115    }
2116    command.output().unwrap_or_else(|error| {
2117        command_error(&format!(
2118            "failed to spawn `harn run {}` for structural eval: {error}",
2119            path.display()
2120        ))
2121    })
2122}
2123
2124fn absolute_cli_path(path: &Path) -> PathBuf {
2125    if path.is_absolute() {
2126        return path.to_path_buf();
2127    }
2128    env::current_dir()
2129        .unwrap_or_else(|_| PathBuf::from("."))
2130        .join(path)
2131}
2132
2133fn relay_subprocess_failure(label: &str, output: &std::process::Output) -> ! {
2134    let stdout = String::from_utf8_lossy(&output.stdout);
2135    let stderr = String::from_utf8_lossy(&output.stderr);
2136    if !stdout.trim().is_empty() {
2137        eprintln!("[{label}] stdout:\n{stdout}");
2138    }
2139    if !stderr.trim().is_empty() {
2140        eprintln!("[{label}] stderr:\n{stderr}");
2141    }
2142    process::exit(output.status.code().unwrap_or(1));
2143}
2144
2145fn collect_structural_eval_runs(dir: &Path) -> Vec<harn_vm::orchestration::RunRecord> {
2146    let mut paths: Vec<PathBuf> = fs::read_dir(dir)
2147        .unwrap_or_else(|error| {
2148            command_error(&format!(
2149                "failed to read structural eval run dir {}: {error}",
2150                dir.display()
2151            ))
2152        })
2153        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
2154        .filter(|entry| entry.extension().and_then(|ext| ext.to_str()) == Some("json"))
2155        .collect();
2156    paths.sort();
2157    let mut runs: Vec<_> = paths
2158        .iter()
2159        .map(|path| load_run_record_or_exit(path))
2160        .collect();
2161    runs.sort_by(|left, right| {
2162        (
2163            left.started_at.as_str(),
2164            left.workflow_id.as_str(),
2165            left.task.as_str(),
2166        )
2167            .cmp(&(
2168                right.started_at.as_str(),
2169                right.workflow_id.as_str(),
2170                right.task.as_str(),
2171            ))
2172    });
2173    runs
2174}
2175
2176/// Parse a .harn file, returning (source, AST). Exits on error.
2177pub(crate) fn parse_source_file(path: &str) -> (String, Vec<harn_parser::SNode>) {
2178    let source = match fs::read_to_string(path) {
2179        Ok(s) => s,
2180        Err(e) => {
2181            eprintln!("Error reading {path}: {e}");
2182            process::exit(1);
2183        }
2184    };
2185
2186    let mut lexer = Lexer::new(&source);
2187    let tokens = match lexer.tokenize() {
2188        Ok(t) => t,
2189        Err(e) => {
2190            let diagnostic = harn_parser::diagnostic::render_diagnostic(
2191                &source,
2192                path,
2193                &error_span_from_lex(&e),
2194                "error",
2195                &e.to_string(),
2196                Some("here"),
2197                None,
2198            );
2199            eprint!("{diagnostic}");
2200            process::exit(1);
2201        }
2202    };
2203
2204    let mut parser = Parser::new(tokens);
2205    let program = match parser.parse() {
2206        Ok(p) => p,
2207        Err(err) => {
2208            if parser.all_errors().is_empty() {
2209                let span = error_span_from_parse(&err);
2210                let diagnostic = harn_parser::diagnostic::render_diagnostic(
2211                    &source,
2212                    path,
2213                    &span,
2214                    "error",
2215                    &harn_parser::diagnostic::parser_error_message(&err),
2216                    Some(harn_parser::diagnostic::parser_error_label(&err)),
2217                    harn_parser::diagnostic::parser_error_help(&err),
2218                );
2219                eprint!("{diagnostic}");
2220            } else {
2221                for e in parser.all_errors() {
2222                    let span = error_span_from_parse(e);
2223                    let diagnostic = harn_parser::diagnostic::render_diagnostic(
2224                        &source,
2225                        path,
2226                        &span,
2227                        "error",
2228                        &harn_parser::diagnostic::parser_error_message(e),
2229                        Some(harn_parser::diagnostic::parser_error_label(e)),
2230                        harn_parser::diagnostic::parser_error_help(e),
2231                    );
2232                    eprint!("{diagnostic}");
2233                }
2234            }
2235            process::exit(1);
2236        }
2237    };
2238
2239    (source, program)
2240}
2241
2242fn error_span_from_lex(e: &harn_lexer::LexerError) -> harn_lexer::Span {
2243    match e {
2244        harn_lexer::LexerError::UnexpectedCharacter(_, span)
2245        | harn_lexer::LexerError::UnterminatedString(span)
2246        | harn_lexer::LexerError::UnterminatedBlockComment(span) => *span,
2247    }
2248}
2249
2250fn error_span_from_parse(e: &harn_parser::ParserError) -> harn_lexer::Span {
2251    match e {
2252        harn_parser::ParserError::Unexpected { span, .. } => *span,
2253        harn_parser::ParserError::UnexpectedEof { span, .. } => *span,
2254    }
2255}
2256
2257/// Execute source code and return the output. Used by REPL and conformance tests.
2258pub(crate) async fn execute(source: &str, source_path: Option<&Path>) -> Result<String, String> {
2259    let mut lexer = Lexer::new(source);
2260    let tokens = lexer.tokenize().map_err(|e| e.to_string())?;
2261    let mut parser = Parser::new(tokens);
2262    let program = parser.parse().map_err(|e| e.to_string())?;
2263
2264    // Static cross-module resolution: when executed from a file, derive the
2265    // import graph so `execute` catches undefined calls at typecheck time.
2266    // The REPL / `-e` path invokes this without `source_path`, where there
2267    // is no importing file context; we fall back to no-imports checking.
2268    let mut checker = TypeChecker::new();
2269    if let Some(path) = source_path {
2270        let graph = harn_modules::build(&[path.to_path_buf()]);
2271        if let Some(imported) = graph.imported_names_for_file(path) {
2272            checker = checker.with_imported_names(imported);
2273        }
2274        if let Some(imported) = graph.imported_type_declarations_for_file(path) {
2275            checker = checker.with_imported_type_decls(imported);
2276        }
2277    }
2278    let type_diagnostics = checker.check(&program);
2279    let mut warning_lines = Vec::new();
2280    for diag in &type_diagnostics {
2281        match diag.severity {
2282            DiagnosticSeverity::Error => return Err(diag.message.clone()),
2283            DiagnosticSeverity::Warning => {
2284                warning_lines.push(format!("warning: {}", diag.message));
2285            }
2286        }
2287    }
2288
2289    let chunk = harn_vm::Compiler::new()
2290        .compile(&program)
2291        .map_err(|e| e.to_string())?;
2292
2293    let local = tokio::task::LocalSet::new();
2294    local
2295        .run_until(async {
2296            let mut vm = harn_vm::Vm::new();
2297            harn_vm::register_vm_stdlib(&mut vm);
2298            install_default_hostlib(&mut vm);
2299            let source_parent = source_path
2300                .and_then(|p| p.parent())
2301                .unwrap_or(std::path::Path::new("."));
2302            let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
2303            let store_base = project_root.as_deref().unwrap_or(source_parent);
2304            let execution_cwd = std::env::current_dir()
2305                .unwrap_or_else(|_| std::path::PathBuf::from("."))
2306                .to_string_lossy()
2307                .into_owned();
2308            let source_dir = source_parent.to_string_lossy().into_owned();
2309            if source_path.is_some_and(is_conformance_path) {
2310                harn_vm::event_log::install_memory_for_current_thread(64);
2311            }
2312            harn_vm::register_store_builtins(&mut vm, store_base);
2313            harn_vm::register_metadata_builtins(&mut vm, store_base);
2314            let pipeline_name = source_path
2315                .and_then(|p| p.file_stem())
2316                .and_then(|s| s.to_str())
2317                .unwrap_or("default");
2318            harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
2319            harn_vm::stdlib::process::set_thread_execution_context(Some(
2320                harn_vm::orchestration::RunExecutionRecord {
2321                    cwd: Some(execution_cwd),
2322                    source_dir: Some(source_dir),
2323                    env: std::collections::BTreeMap::new(),
2324                    adapter: None,
2325                    repo_path: None,
2326                    worktree_path: None,
2327                    branch: None,
2328                    base_ref: None,
2329                    cleanup: None,
2330                },
2331            ));
2332            if let Some(ref root) = project_root {
2333                vm.set_project_root(root);
2334            }
2335            if let Some(path) = source_path {
2336                if let Some(parent) = path.parent() {
2337                    if !parent.as_os_str().is_empty() {
2338                        vm.set_source_dir(parent);
2339                    }
2340                }
2341            }
2342            // Conformance tests land here via `run_conformance_tests`; for
2343            // `skill_fs_*` fixtures to see the bundled `skills/` folder
2344            // we run the same layered discovery as `harn run`.
2345            let loaded = skill_loader::load_skills(&skill_loader::SkillLoaderInputs {
2346                cli_dirs: Vec::new(),
2347                source_path: source_path.map(Path::to_path_buf),
2348            });
2349            skill_loader::emit_loader_warnings(&loaded.loader_warnings);
2350            skill_loader::install_skills_global(&mut vm, &loaded);
2351            if let Some(path) = source_path {
2352                let extensions = package::load_runtime_extensions(path);
2353                package::install_runtime_extensions(&extensions);
2354                package::install_manifest_triggers(&mut vm, &extensions)
2355                    .await
2356                    .map_err(|error| format!("failed to install manifest triggers: {error}"))?;
2357                package::install_manifest_hooks(&mut vm, &extensions)
2358                    .await
2359                    .map_err(|error| format!("failed to install manifest hooks: {error}"))?;
2360            }
2361            let _event_log = harn_vm::event_log::active_event_log()
2362                .unwrap_or_else(|| harn_vm::event_log::install_memory_for_current_thread(64));
2363            let connector_clients_installed =
2364                should_install_default_connector_clients(source, source_path);
2365            if connector_clients_installed {
2366                install_default_connector_clients(store_base)
2367                    .await
2368                    .map_err(|error| format!("failed to initialize connector clients: {error}"))?;
2369            }
2370            let execution_result = vm.execute(&chunk).await.map_err(|e| e.to_string());
2371            harn_vm::egress::reset_egress_policy_for_host();
2372            if connector_clients_installed {
2373                harn_vm::clear_active_connector_clients();
2374            }
2375            harn_vm::stdlib::process::set_thread_execution_context(None);
2376            execution_result?;
2377            let mut output = String::new();
2378            for wl in &warning_lines {
2379                output.push_str(wl);
2380                output.push('\n');
2381            }
2382            output.push_str(vm.output());
2383            Ok(output)
2384        })
2385        .await
2386}
2387
2388fn should_install_default_connector_clients(source: &str, source_path: Option<&Path>) -> bool {
2389    if !source_path.is_some_and(is_conformance_path) {
2390        return true;
2391    }
2392    source.contains("connector_call")
2393        || source.contains("std/connectors")
2394        || source.contains("connectors/")
2395}
2396
2397fn is_conformance_path(path: &Path) -> bool {
2398    path.components()
2399        .any(|component| component.as_os_str() == "conformance")
2400}
2401
2402async fn install_default_connector_clients(base_dir: &Path) -> Result<(), String> {
2403    let event_log = harn_vm::event_log::active_event_log()
2404        .unwrap_or_else(|| harn_vm::event_log::install_memory_for_current_thread(64));
2405    let secret_namespace = connector_secret_namespace(base_dir);
2406    let secrets: Arc<dyn harn_vm::secrets::SecretProvider> = Arc::new(
2407        harn_vm::secrets::configured_default_chain(secret_namespace)
2408            .map_err(|error| format!("failed to configure secret providers: {error}"))?,
2409    );
2410
2411    let registry = harn_vm::ConnectorRegistry::default();
2412    let metrics = Arc::new(harn_vm::MetricsRegistry::default());
2413    let inbox = Arc::new(
2414        harn_vm::InboxIndex::new(event_log.clone(), metrics.clone())
2415            .await
2416            .map_err(|error| error.to_string())?,
2417    );
2418    registry
2419        .init_all(harn_vm::ConnectorCtx {
2420            event_log,
2421            secrets,
2422            inbox,
2423            metrics,
2424            rate_limiter: Arc::new(harn_vm::RateLimiterFactory::default()),
2425        })
2426        .await
2427        .map_err(|error| error.to_string())?;
2428    let clients = registry.client_map().await;
2429    harn_vm::install_active_connector_clients(clients);
2430    Ok(())
2431}
2432
2433fn connector_secret_namespace(base_dir: &Path) -> String {
2434    match std::env::var("HARN_SECRET_NAMESPACE") {
2435        Ok(namespace) if !namespace.trim().is_empty() => namespace,
2436        _ => {
2437            let leaf = base_dir
2438                .file_name()
2439                .and_then(|name| name.to_str())
2440                .filter(|name| !name.is_empty())
2441                .unwrap_or("workspace");
2442            format!("harn/{leaf}")
2443        }
2444    }
2445}
2446
2447#[cfg(test)]
2448mod main_tests {
2449    use super::{normalize_serve_args, should_install_default_connector_clients};
2450    use std::path::Path;
2451
2452    #[test]
2453    fn normalize_serve_args_inserts_a2a_for_legacy_shape() {
2454        let args = normalize_serve_args(vec![
2455            "harn".to_string(),
2456            "serve".to_string(),
2457            "--port".to_string(),
2458            "3000".to_string(),
2459            "agent.harn".to_string(),
2460        ]);
2461        assert_eq!(
2462            args,
2463            vec![
2464                "harn".to_string(),
2465                "serve".to_string(),
2466                "a2a".to_string(),
2467                "--port".to_string(),
2468                "3000".to_string(),
2469                "agent.harn".to_string(),
2470            ]
2471        );
2472    }
2473
2474    #[test]
2475    fn normalize_serve_args_preserves_explicit_subcommands() {
2476        let args = normalize_serve_args(vec![
2477            "harn".to_string(),
2478            "serve".to_string(),
2479            "acp".to_string(),
2480            "server.harn".to_string(),
2481        ]);
2482        assert_eq!(
2483            args,
2484            vec![
2485                "harn".to_string(),
2486                "serve".to_string(),
2487                "acp".to_string(),
2488                "server.harn".to_string(),
2489            ]
2490        );
2491    }
2492
2493    #[test]
2494    fn conformance_skips_connector_clients_unless_fixture_uses_connectors() {
2495        let path = Path::new("conformance/tests/language/basic.harn");
2496        assert!(!should_install_default_connector_clients(
2497            "println(1)",
2498            Some(path)
2499        ));
2500        assert!(!should_install_default_connector_clients(
2501            "trust_graph_verify_chain()",
2502            Some(path)
2503        ));
2504        assert!(should_install_default_connector_clients(
2505            "import { post_message } from \"std/connectors/slack\"",
2506            Some(path)
2507        ));
2508        assert!(should_install_default_connector_clients(
2509            "println(1)",
2510            Some(Path::new("examples/demo.harn"))
2511        ));
2512    }
2513}