Skip to main content

harn_cli/
lib.rs

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