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