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