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