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