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