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