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