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