Skip to main content

harn_cli/
lib.rs

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