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