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