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