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