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