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