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