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