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