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