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