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