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