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