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