Skip to main content

harn_cli/commands/
run.rs

1use std::collections::HashSet;
2use std::fs;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::process;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::Arc;
8
9use harn_parser::DiagnosticSeverity;
10use harn_vm::event_log::EventLog;
11
12use crate::commands::mcp::{self, AuthResolution};
13use crate::package;
14use crate::parse_source_file;
15use crate::skill_loader::{
16    canonicalize_cli_dirs, emit_loader_warnings, install_skills_global, load_skills,
17    SkillLoaderInputs,
18};
19
20pub(crate) enum RunFileMcpServeMode {
21    Stdio,
22    Http {
23        options: harn_serve::McpHttpServeOptions,
24        auth_policy: harn_serve::AuthPolicy,
25    },
26}
27
28/// Core builtins that are never denied, even when using `--allow`.
29const CORE_BUILTINS: &[&str] = &[
30    "println",
31    "print",
32    "log",
33    "type_of",
34    "to_string",
35    "to_int",
36    "to_float",
37    "len",
38    "assert",
39    "assert_eq",
40    "assert_ne",
41    "json_parse",
42    "json_stringify",
43    "runtime_context",
44    "task_current",
45    "runtime_context_values",
46    "runtime_context_get",
47    "runtime_context_set",
48    "runtime_context_clear",
49];
50
51/// Build the set of denied builtin names from `--deny` or `--allow` flags.
52///
53/// - `--deny a,b,c` denies exactly those names.
54/// - `--allow a,b,c` denies everything *except* the listed names and the core builtins.
55pub(crate) fn build_denied_builtins(
56    deny_csv: Option<&str>,
57    allow_csv: Option<&str>,
58) -> HashSet<String> {
59    if let Some(csv) = deny_csv {
60        csv.split(',')
61            .map(|s| s.trim().to_string())
62            .filter(|s| !s.is_empty())
63            .collect()
64    } else if let Some(csv) = allow_csv {
65        // With --allow, we mark every registered stdlib builtin as denied
66        // *except* those in the allow list and the core builtins.
67        let allowed: HashSet<String> = csv
68            .split(',')
69            .map(|s| s.trim().to_string())
70            .filter(|s| !s.is_empty())
71            .collect();
72        let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
73
74        // Create a temporary VM with stdlib registered to enumerate all builtin names.
75        let mut tmp = harn_vm::Vm::new();
76        harn_vm::register_vm_stdlib(&mut tmp);
77        harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
78        harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
79
80        tmp.builtin_names()
81            .into_iter()
82            .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
83            .collect()
84    } else {
85        HashSet::new()
86    }
87}
88
89/// Run the static type checker against `program` with cross-module
90/// import-aware call resolution when the file's imports all resolve. Used
91/// by `run_file` and the MCP server entry so `harn run` catches undefined
92/// cross-module calls before the VM starts.
93fn typecheck_with_imports(
94    program: &[harn_parser::SNode],
95    path: &Path,
96    source: &str,
97) -> Vec<harn_parser::TypeDiagnostic> {
98    if let Err(error) = package::ensure_dependencies_materialized(path) {
99        eprintln!("error: {error}");
100        process::exit(1);
101    }
102    let graph = harn_modules::build(&[path.to_path_buf()]);
103    let mut checker = harn_parser::TypeChecker::new();
104    if let Some(imported) = graph.imported_names_for_file(path) {
105        checker = checker.with_imported_names(imported);
106    }
107    if let Some(imported) = graph.imported_type_declarations_for_file(path) {
108        checker = checker.with_imported_type_decls(imported);
109    }
110    checker.check_with_source(program, source)
111}
112
113/// Build the wrapped source and temp file backing a `harn run -e` invocation.
114///
115/// `import` is a top-level declaration in Harn, so the leading prefix of
116/// import lines (with surrounding blanks/comments) is hoisted out of the
117/// `pipeline main(task) { ... }` wrapper. The temp file is created in the
118/// current working directory so relative imports (`import "./lib"`) and
119/// `harn.toml` discovery resolve against the user's project, not the
120/// system temp dir. If the CWD is unwritable we fall back to the system
121/// temp dir with a stderr warning — pure-expression `-e` still works,
122/// but relative imports will fail to resolve.
123pub(crate) fn prepare_eval_temp_file(
124    code: &str,
125) -> Result<(String, tempfile::NamedTempFile), String> {
126    let (header, body) = split_eval_header(code);
127    let wrapped = if header.is_empty() {
128        format!("pipeline main(task) {{\n{body}\n}}")
129    } else {
130        format!("{header}\npipeline main(task) {{\n{body}\n}}")
131    };
132
133    let tmp = create_eval_temp_file()?;
134    Ok((wrapped, tmp))
135}
136
137/// Try to place the `-e` temp file in the current working directory so
138/// relative imports and `harn.toml` discovery resolve against the user's
139/// project. Fall back to the system temp dir on failure (with a warning),
140/// so pure-expression `-e` keeps working in read-only contexts.
141fn create_eval_temp_file() -> Result<tempfile::NamedTempFile, String> {
142    if let Some(dir) = std::env::current_dir().ok().as_deref() {
143        // Hidden prefix on Unix so editors / tree-walkers are less likely
144        // to pick the file up during its short lifetime.
145        match tempfile::Builder::new()
146            .prefix(".harn-eval-")
147            .suffix(".harn")
148            .tempfile_in(dir)
149        {
150            Ok(tmp) => return Ok(tmp),
151            Err(error) => eprintln!(
152                "warning: harn run -e: could not create temp file in {}: {error}; \
153                 relative imports will not resolve",
154                dir.display()
155            ),
156        }
157    }
158    tempfile::Builder::new()
159        .prefix("harn-eval-")
160        .suffix(".harn")
161        .tempfile()
162        .map_err(|e| format!("failed to create temp file for -e: {e}"))
163}
164
165/// Split the `-e` input into a header (top-level imports + leading
166/// blanks/comments) and a body (everything else, to be wrapped in
167/// `pipeline main(task)`). The header may be empty.
168///
169/// Lines whose first non-whitespace token is `import` or `pub import`
170/// are treated as imports. Scanning stops at the first non-blank,
171/// non-comment, non-import line.
172fn split_eval_header(code: &str) -> (String, String) {
173    let mut header_end = 0usize;
174    let mut last_kept = 0usize;
175    for (idx, line) in code.lines().enumerate() {
176        let trimmed = line.trim_start();
177        if trimmed.is_empty() || trimmed.starts_with("//") {
178            header_end = idx + 1;
179            continue;
180        }
181        let is_import = trimmed.starts_with("import ")
182            || trimmed.starts_with("import\t")
183            || trimmed.starts_with("import\"")
184            || trimmed.starts_with("pub import ")
185            || trimmed.starts_with("pub import\t");
186        if is_import {
187            header_end = idx + 1;
188            last_kept = idx + 1;
189        } else {
190            break;
191        }
192    }
193    if last_kept == 0 {
194        return (String::new(), code.to_string());
195    }
196    let mut header_lines: Vec<&str> = Vec::new();
197    let mut body_lines: Vec<&str> = Vec::new();
198    for (idx, line) in code.lines().enumerate() {
199        if idx < header_end {
200            header_lines.push(line);
201        } else {
202            body_lines.push(line);
203        }
204    }
205    (header_lines.join("\n"), body_lines.join("\n"))
206}
207
208#[derive(Clone, Debug, Default, PartialEq, Eq)]
209pub enum CliLlmMockMode {
210    #[default]
211    Off,
212    Replay {
213        fixture_path: PathBuf,
214    },
215    Record {
216        fixture_path: PathBuf,
217    },
218}
219
220#[derive(Clone, Debug, Default, PartialEq, Eq)]
221pub struct RunAttestationOptions {
222    pub receipt_out: Option<PathBuf>,
223    pub agent_id: Option<String>,
224}
225
226/// Opt-in profiling. When `text` is true the run prints a categorical
227/// breakdown to stderr after execution; when `json_path` is set the same
228/// rollup is serialized to that path. Either flag enables span tracing
229/// (i.e. `harn_vm::tracing::set_tracing_enabled(true)`).
230#[derive(Clone, Debug, Default, PartialEq, Eq)]
231pub struct RunProfileOptions {
232    pub text: bool,
233    pub json_path: Option<PathBuf>,
234}
235
236impl RunProfileOptions {
237    pub fn is_enabled(&self) -> bool {
238        self.text || self.json_path.is_some()
239    }
240}
241
242/// Captured outcome of an in-process `execute_run` invocation. Tests use this
243/// instead of spawning the `harn` binary; the binary entry point translates
244/// it into real stdout/stderr writes + `process::exit`.
245#[derive(Clone, Debug, Default)]
246pub struct RunOutcome {
247    pub stdout: String,
248    pub stderr: String,
249    pub exit_code: i32,
250}
251
252fn load_cli_llm_mocks(path: &Path) -> Result<Vec<harn_vm::llm::LlmMock>, String> {
253    let content = fs::read_to_string(path)
254        .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
255    let mut mocks = Vec::new();
256    for (idx, raw_line) in content.lines().enumerate() {
257        let line_no = idx + 1;
258        let line = raw_line.trim();
259        if line.is_empty() {
260            continue;
261        }
262        let value: serde_json::Value = serde_json::from_str(line).map_err(|error| {
263            format!(
264                "invalid JSON in {} line {}: {error}",
265                path.display(),
266                line_no
267            )
268        })?;
269        mocks.push(parse_cli_llm_mock_value(&value).map_err(|error| {
270            format!(
271                "invalid --llm-mock fixture in {} line {}: {error}",
272                path.display(),
273                line_no
274            )
275        })?);
276    }
277    Ok(mocks)
278}
279
280fn parse_cli_llm_mock_value(value: &serde_json::Value) -> Result<harn_vm::llm::LlmMock, String> {
281    let object = value
282        .as_object()
283        .ok_or_else(|| "fixture line must be a JSON object".to_string())?;
284
285    let match_pattern = optional_string_field(object, "match")?;
286    let consume_on_match = object
287        .get("consume_match")
288        .and_then(|value| value.as_bool())
289        .unwrap_or(false);
290    let text = optional_string_field(object, "text")?.unwrap_or_default();
291    let input_tokens = optional_i64_field(object, "input_tokens")?;
292    let output_tokens = optional_i64_field(object, "output_tokens")?;
293    let cache_read_tokens = optional_i64_field(object, "cache_read_tokens")?;
294    let cache_write_tokens = optional_i64_field(object, "cache_write_tokens")?
295        .or(optional_i64_field(object, "cache_creation_input_tokens")?);
296    let thinking = optional_string_field(object, "thinking")?;
297    let thinking_summary = optional_string_field(object, "thinking_summary")?;
298    let stop_reason = optional_string_field(object, "stop_reason")?;
299    let model = optional_string_field(object, "model")?.unwrap_or_else(|| "mock".to_string());
300    let provider = optional_string_field(object, "provider")?;
301    let blocks = optional_vec_field(object, "blocks")?;
302    let logprobs = optional_vec_field(object, "logprobs")?.unwrap_or_default();
303    let tool_calls = parse_cli_llm_tool_calls(object.get("tool_calls"))?;
304    let error = parse_cli_llm_mock_error(object.get("error"))?;
305
306    Ok(harn_vm::llm::LlmMock {
307        text,
308        tool_calls,
309        match_pattern,
310        consume_on_match,
311        input_tokens,
312        output_tokens,
313        cache_read_tokens,
314        cache_write_tokens,
315        thinking,
316        thinking_summary,
317        stop_reason,
318        model,
319        provider,
320        blocks,
321        logprobs,
322        error,
323    })
324}
325
326fn parse_cli_llm_tool_calls(
327    value: Option<&serde_json::Value>,
328) -> Result<Vec<serde_json::Value>, String> {
329    let Some(value) = value else {
330        return Ok(Vec::new());
331    };
332    let items = value
333        .as_array()
334        .ok_or_else(|| "tool_calls must be an array".to_string())?;
335    items
336        .iter()
337        .enumerate()
338        .map(|(idx, item)| {
339            normalize_cli_llm_tool_call(item).map_err(|error| format!("tool_calls[{idx}] {error}"))
340        })
341        .collect()
342}
343
344fn normalize_cli_llm_tool_call(value: &serde_json::Value) -> Result<serde_json::Value, String> {
345    let object = value
346        .as_object()
347        .ok_or_else(|| "must be a JSON object".to_string())?;
348    let name = object
349        .get("name")
350        .and_then(|value| value.as_str())
351        .ok_or_else(|| "is missing string field `name`".to_string())?;
352    let arguments = object
353        .get("arguments")
354        .cloned()
355        .or_else(|| object.get("args").cloned())
356        .unwrap_or_else(|| serde_json::json!({}));
357    Ok(serde_json::json!({
358        "name": name,
359        "arguments": arguments,
360    }))
361}
362
363fn parse_cli_llm_mock_error(
364    value: Option<&serde_json::Value>,
365) -> Result<Option<harn_vm::llm::MockError>, String> {
366    let Some(value) = value else {
367        return Ok(None);
368    };
369    if value.is_null() {
370        return Ok(None);
371    }
372    let object = value.as_object().ok_or_else(|| {
373        "error must be an object {category, message, retry_after_ms?}".to_string()
374    })?;
375    let category_str = object
376        .get("category")
377        .and_then(|value| value.as_str())
378        .ok_or_else(|| "error.category is required".to_string())?;
379    let category = harn_vm::ErrorCategory::parse(category_str);
380    if category.as_str() != category_str {
381        return Err(format!("unknown error category `{category_str}`"));
382    }
383    let message = object
384        .get("message")
385        .and_then(|value| value.as_str())
386        .unwrap_or_default()
387        .to_string();
388    let retry_after_ms = match object.get("retry_after_ms") {
389        None | Some(serde_json::Value::Null) => None,
390        Some(serde_json::Value::Number(n)) => match n.as_u64() {
391            Some(v) => Some(v),
392            None => return Err("error.retry_after_ms must be a non-negative integer".to_string()),
393        },
394        Some(_) => return Err("error.retry_after_ms must be a non-negative integer".to_string()),
395    };
396    Ok(Some(harn_vm::llm::MockError {
397        category,
398        message,
399        retry_after_ms,
400    }))
401}
402
403fn optional_string_field(
404    object: &serde_json::Map<String, serde_json::Value>,
405    key: &str,
406) -> Result<Option<String>, String> {
407    match object.get(key) {
408        None | Some(serde_json::Value::Null) => Ok(None),
409        Some(serde_json::Value::String(value)) => Ok(Some(value.clone())),
410        Some(_) => Err(format!("`{key}` must be a string")),
411    }
412}
413
414fn optional_i64_field(
415    object: &serde_json::Map<String, serde_json::Value>,
416    key: &str,
417) -> Result<Option<i64>, String> {
418    match object.get(key) {
419        None | Some(serde_json::Value::Null) => Ok(None),
420        Some(value) => value
421            .as_i64()
422            .map(Some)
423            .ok_or_else(|| format!("`{key}` must be an integer")),
424    }
425}
426
427fn optional_vec_field(
428    object: &serde_json::Map<String, serde_json::Value>,
429    key: &str,
430) -> Result<Option<Vec<serde_json::Value>>, String> {
431    match object.get(key) {
432        None | Some(serde_json::Value::Null) => Ok(None),
433        Some(serde_json::Value::Array(items)) => Ok(Some(items.clone())),
434        Some(_) => Err(format!("`{key}` must be an array")),
435    }
436}
437
438pub fn install_cli_llm_mock_mode(mode: &CliLlmMockMode) -> Result<(), String> {
439    harn_vm::llm::clear_cli_llm_mock_mode();
440    match mode {
441        CliLlmMockMode::Off => Ok(()),
442        CliLlmMockMode::Replay { fixture_path } => {
443            let mocks = load_cli_llm_mocks(fixture_path)?;
444            harn_vm::llm::install_cli_llm_mocks(mocks);
445            Ok(())
446        }
447        CliLlmMockMode::Record { .. } => {
448            harn_vm::llm::enable_cli_llm_mock_recording();
449            Ok(())
450        }
451    }
452}
453
454pub fn persist_cli_llm_mock_recording(mode: &CliLlmMockMode) -> Result<(), String> {
455    let CliLlmMockMode::Record { fixture_path } = mode else {
456        return Ok(());
457    };
458    if let Some(parent) = fixture_path.parent() {
459        if !parent.as_os_str().is_empty() {
460            fs::create_dir_all(parent).map_err(|error| {
461                format!(
462                    "failed to create fixture directory {}: {error}",
463                    parent.display()
464                )
465            })?;
466        }
467    }
468
469    let lines = harn_vm::llm::take_cli_llm_recordings()
470        .into_iter()
471        .map(serialize_cli_llm_mock)
472        .collect::<Result<Vec<_>, _>>()?;
473    let body = if lines.is_empty() {
474        String::new()
475    } else {
476        format!("{}\n", lines.join("\n"))
477    };
478    fs::write(fixture_path, body)
479        .map_err(|error| format!("failed to write {}: {error}", fixture_path.display()))
480}
481
482fn serialize_cli_llm_mock(mock: harn_vm::llm::LlmMock) -> Result<String, String> {
483    let mut object = serde_json::Map::new();
484    if let Some(match_pattern) = mock.match_pattern {
485        object.insert(
486            "match".to_string(),
487            serde_json::Value::String(match_pattern),
488        );
489    }
490    if !mock.text.is_empty() {
491        object.insert("text".to_string(), serde_json::Value::String(mock.text));
492    }
493    if !mock.tool_calls.is_empty() {
494        let tool_calls = mock
495            .tool_calls
496            .into_iter()
497            .map(|tool_call| {
498                let object = tool_call
499                    .as_object()
500                    .ok_or_else(|| "recorded tool call must be an object".to_string())?;
501                let name = object
502                    .get("name")
503                    .and_then(|value| value.as_str())
504                    .ok_or_else(|| "recorded tool call is missing `name`".to_string())?;
505                Ok(serde_json::json!({
506                    "name": name,
507                    "args": object
508                        .get("arguments")
509                        .cloned()
510                        .unwrap_or_else(|| serde_json::json!({})),
511                }))
512            })
513            .collect::<Result<Vec<_>, String>>()?;
514        object.insert(
515            "tool_calls".to_string(),
516            serde_json::Value::Array(tool_calls),
517        );
518    }
519    if let Some(input_tokens) = mock.input_tokens {
520        object.insert(
521            "input_tokens".to_string(),
522            serde_json::Value::Number(input_tokens.into()),
523        );
524    }
525    if let Some(output_tokens) = mock.output_tokens {
526        object.insert(
527            "output_tokens".to_string(),
528            serde_json::Value::Number(output_tokens.into()),
529        );
530    }
531    if let Some(cache_read_tokens) = mock.cache_read_tokens {
532        object.insert(
533            "cache_read_tokens".to_string(),
534            serde_json::Value::Number(cache_read_tokens.into()),
535        );
536    }
537    if let Some(cache_write_tokens) = mock.cache_write_tokens {
538        object.insert(
539            "cache_write_tokens".to_string(),
540            serde_json::Value::Number(cache_write_tokens.into()),
541        );
542        object.insert(
543            "cache_creation_input_tokens".to_string(),
544            serde_json::Value::Number(cache_write_tokens.into()),
545        );
546    }
547    if let Some(thinking) = mock.thinking {
548        object.insert("thinking".to_string(), serde_json::Value::String(thinking));
549    }
550    if let Some(stop_reason) = mock.stop_reason {
551        object.insert(
552            "stop_reason".to_string(),
553            serde_json::Value::String(stop_reason),
554        );
555    }
556    object.insert("model".to_string(), serde_json::Value::String(mock.model));
557    if let Some(provider) = mock.provider {
558        object.insert("provider".to_string(), serde_json::Value::String(provider));
559    }
560    if let Some(blocks) = mock.blocks {
561        object.insert("blocks".to_string(), serde_json::Value::Array(blocks));
562    }
563    if !mock.logprobs.is_empty() {
564        object.insert(
565            "logprobs".to_string(),
566            serde_json::Value::Array(mock.logprobs),
567        );
568    }
569    if let Some(error) = mock.error {
570        object.insert(
571            "error".to_string(),
572            serde_json::json!({
573                "category": error.category.as_str(),
574                "message": error.message,
575            }),
576        );
577    }
578    serde_json::to_string(&serde_json::Value::Object(object))
579        .map_err(|error| format!("failed to serialize recorded fixture: {error}"))
580}
581
582pub(crate) async fn run_file(
583    path: &str,
584    trace: bool,
585    denied_builtins: HashSet<String>,
586    script_argv: Vec<String>,
587    llm_mock_mode: CliLlmMockMode,
588    attestation: Option<RunAttestationOptions>,
589    profile: RunProfileOptions,
590) {
591    run_file_with_skill_dirs(
592        path,
593        trace,
594        denied_builtins,
595        script_argv,
596        Vec::new(),
597        llm_mock_mode,
598        attestation,
599        profile,
600    )
601    .await;
602}
603
604pub(crate) async fn run_file_with_skill_dirs(
605    path: &str,
606    trace: bool,
607    denied_builtins: HashSet<String>,
608    script_argv: Vec<String>,
609    skill_dirs_raw: Vec<String>,
610    llm_mock_mode: CliLlmMockMode,
611    attestation: Option<RunAttestationOptions>,
612    profile: RunProfileOptions,
613) {
614    // Graceful shutdown: flush run records before exit on SIGINT/SIGTERM.
615    let cancelled = install_signal_shutdown_handler();
616
617    let _stdout_passthrough = StdoutPassthroughGuard::enable();
618    let outcome = execute_run(
619        path,
620        trace,
621        denied_builtins,
622        script_argv,
623        skill_dirs_raw,
624        llm_mock_mode,
625        attestation,
626        profile,
627    )
628    .await;
629
630    // `harn run` streams normal program stdout during execution. Any stdout
631    // left here came from older capture paths, so flush it after diagnostics.
632    if !outcome.stderr.is_empty() {
633        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
634    }
635    if !outcome.stdout.is_empty() {
636        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
637    }
638
639    let mut exit_code = outcome.exit_code;
640    if exit_code != 0 && cancelled.load(Ordering::SeqCst) {
641        exit_code = 124;
642    }
643    if exit_code != 0 {
644        process::exit(exit_code);
645    }
646}
647
648struct StdoutPassthroughGuard {
649    previous: bool,
650}
651
652impl StdoutPassthroughGuard {
653    fn enable() -> Self {
654        Self {
655            previous: harn_vm::set_stdout_passthrough(true),
656        }
657    }
658}
659
660impl Drop for StdoutPassthroughGuard {
661    fn drop(&mut self) {
662        harn_vm::set_stdout_passthrough(self.previous);
663    }
664}
665
666fn install_signal_shutdown_handler() -> Arc<AtomicBool> {
667    let cancelled = Arc::new(AtomicBool::new(false));
668    let cancelled_clone = cancelled.clone();
669    tokio::spawn(async move {
670        #[cfg(unix)]
671        {
672            use tokio::signal::unix::{signal, SignalKind};
673            let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler");
674            let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT handler");
675            tokio::select! {
676                _ = sigterm.recv() => {},
677                _ = sigint.recv() => {},
678            }
679            cancelled_clone.store(true, Ordering::SeqCst);
680            eprintln!("[harn] signal received, flushing state...");
681            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
682            process::exit(124);
683        }
684        #[cfg(not(unix))]
685        {
686            let _ = tokio::signal::ctrl_c().await;
687            cancelled_clone.store(true, Ordering::SeqCst);
688            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
689            process::exit(124);
690        }
691    });
692    cancelled
693}
694
695/// In-process equivalent of `run_file_with_skill_dirs`. Returns the captured
696/// stdout, stderr, and what exit code the binary entry would have used,
697/// instead of writing to real stdout/stderr or calling `process::exit`.
698///
699/// Tests should call this directly. The `harn run` binary path wraps it.
700pub async fn execute_run(
701    path: &str,
702    trace: bool,
703    denied_builtins: HashSet<String>,
704    script_argv: Vec<String>,
705    skill_dirs_raw: Vec<String>,
706    llm_mock_mode: CliLlmMockMode,
707    attestation: Option<RunAttestationOptions>,
708    profile: RunProfileOptions,
709) -> RunOutcome {
710    let mut stderr = String::new();
711    let mut stdout = String::new();
712
713    let (source, program) = parse_source_file(path);
714
715    let mut had_type_error = false;
716    let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
717    for diag in &type_diagnostics {
718        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
719        if matches!(diag.severity, DiagnosticSeverity::Error) {
720            had_type_error = true;
721        }
722        stderr.push_str(&rendered);
723    }
724    if had_type_error {
725        return RunOutcome {
726            stdout,
727            stderr,
728            exit_code: 1,
729        };
730    }
731
732    let chunk = match harn_vm::Compiler::new().compile(&program) {
733        Ok(c) => c,
734        Err(e) => {
735            stderr.push_str(&format!("error: compile error: {e}\n"));
736            return RunOutcome {
737                stdout,
738                stderr,
739                exit_code: 1,
740            };
741        }
742    };
743
744    if trace {
745        harn_vm::llm::enable_tracing();
746    }
747    if profile.is_enabled() {
748        harn_vm::tracing::set_tracing_enabled(true);
749    }
750    if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
751        stderr.push_str(&format!("error: {error}\n"));
752        return RunOutcome {
753            stdout,
754            stderr,
755            exit_code: 1,
756        };
757    }
758
759    let mut vm = harn_vm::Vm::new();
760    harn_vm::register_vm_stdlib(&mut vm);
761    crate::install_default_hostlib(&mut vm);
762    let source_parent = std::path::Path::new(path)
763        .parent()
764        .unwrap_or(std::path::Path::new("."));
765    // Metadata/store rooted at harn.toml when present; source dir otherwise.
766    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
767    let store_base = project_root.as_deref().unwrap_or(source_parent);
768    let attestation_started_at_ms = now_ms();
769    let attestation_log = if attestation.is_some() {
770        Some(harn_vm::event_log::install_memory_for_current_thread(256))
771    } else {
772        None
773    };
774    if let Some(log) = attestation_log.as_ref() {
775        append_run_provenance_event(
776            log,
777            "started",
778            serde_json::json!({
779                "pipeline": path,
780                "argv": &script_argv,
781                "project_root": store_base.display().to_string(),
782            }),
783        )
784        .await;
785    }
786    harn_vm::register_store_builtins(&mut vm, store_base);
787    harn_vm::register_metadata_builtins(&mut vm, store_base);
788    let pipeline_name = std::path::Path::new(path)
789        .file_stem()
790        .and_then(|s| s.to_str())
791        .unwrap_or("default");
792    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
793    vm.set_source_info(path, &source);
794    if !denied_builtins.is_empty() {
795        vm.set_denied_builtins(denied_builtins);
796    }
797    if let Some(ref root) = project_root {
798        vm.set_project_root(root);
799    }
800
801    if let Some(p) = std::path::Path::new(path).parent() {
802        if !p.as_os_str().is_empty() {
803            vm.set_source_dir(p);
804        }
805    }
806
807    // Load filesystem + manifest skills before the pipeline runs so
808    // `skills` is populated with a pre-discovered registry (see #73).
809    let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
810    let loaded = load_skills(&SkillLoaderInputs {
811        cli_dirs,
812        source_path: Some(std::path::PathBuf::from(path)),
813    });
814    emit_loader_warnings(&loaded.loader_warnings);
815    install_skills_global(&mut vm, &loaded);
816
817    // `harn run script.harn -- a b c` yields `argv == ["a", "b", "c"]`.
818    // Always set so scripts can rely on `len(argv)`.
819    let argv_values: Vec<harn_vm::VmValue> = script_argv
820        .iter()
821        .map(|s| harn_vm::VmValue::String(std::rc::Rc::from(s.as_str())))
822        .collect();
823    vm.set_global(
824        "argv",
825        harn_vm::VmValue::List(std::rc::Rc::new(argv_values)),
826    );
827
828    let extensions = package::load_runtime_extensions(Path::new(path));
829    package::install_runtime_extensions(&extensions);
830    if let Some(manifest) = extensions.root_manifest.as_ref() {
831        if !manifest.mcp.is_empty() {
832            connect_mcp_servers(&manifest.mcp, &mut vm).await;
833        }
834    }
835    if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
836        stderr.push_str(&format!(
837            "error: failed to install manifest triggers: {error}\n"
838        ));
839        return RunOutcome {
840            stdout,
841            stderr,
842            exit_code: 1,
843        };
844    }
845    if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
846        stderr.push_str(&format!(
847            "error: failed to install manifest hooks: {error}\n"
848        ));
849        return RunOutcome {
850            stdout,
851            stderr,
852            exit_code: 1,
853        };
854    }
855
856    // Run inside a LocalSet so spawn_local works for concurrency builtins.
857    let local = tokio::task::LocalSet::new();
858    let execution = local
859        .run_until(async {
860            match vm.execute(&chunk).await {
861                Ok(value) => Ok((vm.output(), value)),
862                Err(e) => Err(vm.format_runtime_error(&e)),
863            }
864        })
865        .await;
866    if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
867        stderr.push_str(&format!("error: {error}\n"));
868        return RunOutcome {
869            stdout,
870            stderr,
871            exit_code: 1,
872        };
873    }
874
875    // Always drain any captured stderr accumulated during execution.
876    let buffered_stderr = harn_vm::take_stderr_buffer();
877    stderr.push_str(&buffered_stderr);
878
879    let exit_code = match &execution {
880        Ok((_, return_value)) => exit_code_from_return_value(return_value),
881        Err(_) => 1,
882    };
883
884    if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
885        if let Err(error) = emit_run_attestation(
886            log,
887            path,
888            store_base,
889            attestation_started_at_ms,
890            exit_code,
891            options,
892            &mut stderr,
893        )
894        .await
895        {
896            stderr.push_str(&format!(
897                "error: failed to emit provenance receipt: {error}\n"
898            ));
899            return RunOutcome {
900                stdout,
901                stderr,
902                exit_code: 1,
903            };
904        }
905        harn_vm::event_log::reset_active_event_log();
906    }
907
908    match execution {
909        Ok((output, return_value)) => {
910            stdout.push_str(output);
911            if trace {
912                stderr.push_str(&render_trace_summary());
913            }
914            if profile.is_enabled() {
915                if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
916                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
917                }
918            }
919            if exit_code != 0 {
920                stderr.push_str(&render_return_value_error(&return_value));
921            }
922            RunOutcome {
923                stdout,
924                stderr,
925                exit_code,
926            }
927        }
928        Err(rendered_error) => {
929            stderr.push_str(&rendered_error);
930            if profile.is_enabled() {
931                if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
932                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
933                }
934            }
935            RunOutcome {
936                stdout,
937                stderr,
938                exit_code: 1,
939            }
940        }
941    }
942}
943
944fn render_and_persist_profile(
945    options: &RunProfileOptions,
946    stderr: &mut String,
947) -> Result<(), String> {
948    let spans = harn_vm::tracing::peek_spans();
949    let profile = harn_vm::profile::build(&spans);
950    if options.text {
951        stderr.push_str(&harn_vm::profile::render(&profile));
952    }
953    if let Some(path) = options.json_path.as_ref() {
954        if let Some(parent) = path.parent() {
955            if !parent.as_os_str().is_empty() {
956                fs::create_dir_all(parent)
957                    .map_err(|error| format!("create {}: {error}", parent.display()))?;
958            }
959        }
960        let json = serde_json::to_string_pretty(&profile)
961            .map_err(|error| format!("serialize profile: {error}"))?;
962        fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
963    }
964    Ok(())
965}
966
967async fn append_run_provenance_event(
968    log: &Arc<harn_vm::event_log::AnyEventLog>,
969    kind: &str,
970    payload: serde_json::Value,
971) {
972    let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
973        return;
974    };
975    let _ = log
976        .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
977        .await;
978}
979
980async fn emit_run_attestation(
981    log: &Arc<harn_vm::event_log::AnyEventLog>,
982    path: &str,
983    store_base: &Path,
984    started_at_ms: i64,
985    exit_code: i32,
986    options: &RunAttestationOptions,
987    stderr: &mut String,
988) -> Result<(), String> {
989    let finished_at_ms = now_ms();
990    let status = if exit_code == 0 { "success" } else { "failure" };
991    append_run_provenance_event(
992        log,
993        "finished",
994        serde_json::json!({
995            "pipeline": path,
996            "status": status,
997            "exit_code": exit_code,
998        }),
999    )
1000    .await;
1001    log.flush()
1002        .await
1003        .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
1004    let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
1005        .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
1006    let (signing_key, key_id) =
1007        harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
1008            .await
1009            .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
1010    let receipt = harn_vm::build_signed_receipt(
1011        log,
1012        harn_vm::ReceiptBuildOptions {
1013            pipeline: path.to_string(),
1014            status: status.to_string(),
1015            started_at_ms,
1016            finished_at_ms,
1017            exit_code,
1018            producer_name: "harn-cli".to_string(),
1019            producer_version: env!("CARGO_PKG_VERSION").to_string(),
1020        },
1021        &signing_key,
1022        key_id,
1023    )
1024    .await
1025    .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
1026    let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
1027    if let Some(parent) = receipt_path.parent() {
1028        fs::create_dir_all(parent)
1029            .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1030    }
1031    let encoded = serde_json::to_vec_pretty(&receipt)
1032        .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
1033    fs::write(&receipt_path, encoded)
1034        .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
1035    stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
1036    Ok(())
1037}
1038
1039fn receipt_output_path(
1040    store_base: &Path,
1041    options: &RunAttestationOptions,
1042    receipt_id: &str,
1043) -> PathBuf {
1044    if let Some(path) = options.receipt_out.as_ref() {
1045        return path.clone();
1046    }
1047    harn_vm::runtime_paths::state_root(store_base)
1048        .join("receipts")
1049        .join(format!("{receipt_id}.json"))
1050}
1051
1052fn now_ms() -> i64 {
1053    std::time::SystemTime::now()
1054        .duration_since(std::time::UNIX_EPOCH)
1055        .map(|duration| duration.as_millis() as i64)
1056        .unwrap_or(0)
1057}
1058
1059/// Map a script's top-level return value to a process exit code.
1060///
1061/// - `int n`             → exit n (clamped to 0..=255)
1062/// - `Result::Ok(_)`     → exit 0
1063/// - `Result::Err(_)`    → exit 1
1064/// - anything else       → exit 0
1065fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
1066    use harn_vm::VmValue;
1067    match value {
1068        VmValue::Int(n) => (*n).clamp(0, 255) as i32,
1069        VmValue::EnumVariant {
1070            enum_name,
1071            variant,
1072            fields,
1073        } if enum_name.as_ref() == "Result" && variant.as_ref() == "Err" => 1,
1074        _ => 0,
1075    }
1076}
1077
1078fn render_return_value_error(value: &harn_vm::VmValue) -> String {
1079    let harn_vm::VmValue::EnumVariant {
1080        enum_name,
1081        variant,
1082        fields,
1083    } = value
1084    else {
1085        return String::new();
1086    };
1087    if enum_name.as_ref() != "Result" || variant.as_ref() != "Err" {
1088        return String::new();
1089    }
1090    let rendered = fields.first().map(|p| p.display()).unwrap_or_default();
1091    if rendered.is_empty() {
1092        "error\n".to_string()
1093    } else if rendered.ends_with('\n') {
1094        rendered
1095    } else {
1096        format!("{rendered}\n")
1097    }
1098}
1099
1100/// Connect to MCP servers declared in `harn.toml` and register them as
1101/// `mcp.<name>` globals on the VM. Connection failures are warned but do
1102/// not abort execution.
1103///
1104/// Servers with `lazy = true` are registered with the VM-side MCP
1105/// registry but NOT booted — their processes start the first time a
1106/// skill's `requires_mcp` list names them or user code calls
1107/// `mcp_ensure_active("name")` / `mcp_call(mcp.<name>, ...)`.
1108pub(crate) async fn connect_mcp_servers(
1109    servers: &[package::McpServerConfig],
1110    vm: &mut harn_vm::Vm,
1111) {
1112    use std::collections::BTreeMap;
1113    use std::rc::Rc;
1114    use std::time::Duration;
1115
1116    let mut mcp_dict: BTreeMap<String, harn_vm::VmValue> = BTreeMap::new();
1117    let mut registrations: Vec<harn_vm::RegisteredMcpServer> = Vec::new();
1118
1119    for server in servers {
1120        let resolved_auth = match mcp::resolve_auth_for_server(server).await {
1121            Ok(resolution) => resolution,
1122            Err(error) => {
1123                eprintln!(
1124                    "warning: mcp: failed to load auth for '{}': {}",
1125                    server.name, error
1126                );
1127                AuthResolution::None
1128            }
1129        };
1130        let spec = serde_json::json!({
1131            "name": server.name,
1132            "transport": server.transport.clone().unwrap_or_else(|| "stdio".to_string()),
1133            "command": server.command,
1134            "args": server.args,
1135            "env": server.env,
1136            "url": server.url,
1137            "auth_token": match resolved_auth {
1138                AuthResolution::Bearer(token) => Some(token),
1139                AuthResolution::None => server.auth_token.clone(),
1140            },
1141            "protocol_version": server.protocol_version,
1142            "proxy_server_name": server.proxy_server_name,
1143        });
1144
1145        // Register with the VM-side registry regardless of lazy flag —
1146        // skill activation and `mcp_ensure_active` look up specs there.
1147        registrations.push(harn_vm::RegisteredMcpServer {
1148            name: server.name.clone(),
1149            spec: spec.clone(),
1150            lazy: server.lazy,
1151            card: server.card.clone(),
1152            keep_alive: server.keep_alive_ms.map(Duration::from_millis),
1153        });
1154
1155        if server.lazy {
1156            eprintln!(
1157                "[harn] mcp: deferred '{}' (lazy, boots on first use)",
1158                server.name
1159            );
1160            continue;
1161        }
1162
1163        match harn_vm::connect_mcp_server_from_json(&spec).await {
1164            Ok(handle) => {
1165                eprintln!("[harn] mcp: connected to '{}'", server.name);
1166                harn_vm::mcp_install_active(&server.name, handle.clone());
1167                mcp_dict.insert(server.name.clone(), harn_vm::VmValue::McpClient(handle));
1168            }
1169            Err(e) => {
1170                eprintln!(
1171                    "warning: mcp: failed to connect to '{}': {}",
1172                    server.name, e
1173                );
1174            }
1175        }
1176    }
1177
1178    // Install registrations AFTER eager connects so `install_active`
1179    // above doesn't get overwritten.
1180    harn_vm::mcp_register_servers(registrations);
1181
1182    if !mcp_dict.is_empty() {
1183        vm.set_global("mcp", harn_vm::VmValue::Dict(Rc::new(mcp_dict)));
1184    }
1185}
1186
1187fn render_trace_summary() -> String {
1188    use std::fmt::Write;
1189    let entries = harn_vm::llm::take_trace();
1190    if entries.is_empty() {
1191        return String::new();
1192    }
1193    let mut out = String::new();
1194    let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
1195    let mut total_input = 0i64;
1196    let mut total_output = 0i64;
1197    let mut total_ms = 0u64;
1198    for (i, entry) in entries.iter().enumerate() {
1199        let _ = writeln!(
1200            out,
1201            "  #{}: {} | {} in + {} out tokens | {} ms",
1202            i + 1,
1203            entry.model,
1204            entry.input_tokens,
1205            entry.output_tokens,
1206            entry.duration_ms,
1207        );
1208        total_input += entry.input_tokens;
1209        total_output += entry.output_tokens;
1210        total_ms += entry.duration_ms;
1211    }
1212    let total_tokens = total_input + total_output;
1213    // Rough cost estimate using Sonnet 4 pricing ($3/MTok in, $15/MTok out).
1214    let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
1215    let _ = writeln!(
1216        out,
1217        "  \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
1218        entries.len(),
1219        if entries.len() == 1 { "" } else { "s" },
1220        total_tokens,
1221        total_input,
1222        total_output,
1223        total_ms,
1224        cost,
1225    );
1226    out
1227}
1228
1229/// Run a .harn file as an MCP server using the script-driven surface.
1230/// The pipeline must call `mcp_tools(registry)` (or the alias
1231/// `mcp_serve(registry)`) so the CLI can expose its tools, and may
1232/// register additional resources/prompts via `mcp_resource(...)` /
1233/// `mcp_resource_template(...)` / `mcp_prompt(...)`.
1234///
1235/// Dispatched into by `harn serve mcp <file>` when the script does not
1236/// define any `pub fn` exports — see `commands::serve::run_mcp_server`.
1237///
1238/// `card_source` — optional `--card` argument. Accepts either a path to
1239/// a JSON file or an inline JSON string. When present, the card is
1240/// embedded in the `initialize` response and exposed as the
1241/// `well-known://mcp-card` resource.
1242pub(crate) async fn run_file_mcp_serve(
1243    path: &str,
1244    card_source: Option<&str>,
1245    mode: RunFileMcpServeMode,
1246) {
1247    let (source, program) = crate::parse_source_file(path);
1248
1249    let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
1250    for diag in &type_diagnostics {
1251        match diag.severity {
1252            DiagnosticSeverity::Error => {
1253                let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
1254                eprint!("{rendered}");
1255                process::exit(1);
1256            }
1257            DiagnosticSeverity::Warning => {
1258                let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
1259                eprint!("{rendered}");
1260            }
1261        }
1262    }
1263
1264    let chunk = match harn_vm::Compiler::new().compile(&program) {
1265        Ok(c) => c,
1266        Err(e) => {
1267            eprintln!("error: compile error: {e}");
1268            process::exit(1);
1269        }
1270    };
1271
1272    let mut vm = harn_vm::Vm::new();
1273    harn_vm::register_vm_stdlib(&mut vm);
1274    crate::install_default_hostlib(&mut vm);
1275    let source_parent = std::path::Path::new(path)
1276        .parent()
1277        .unwrap_or(std::path::Path::new("."));
1278    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1279    let store_base = project_root.as_deref().unwrap_or(source_parent);
1280    harn_vm::register_store_builtins(&mut vm, store_base);
1281    harn_vm::register_metadata_builtins(&mut vm, store_base);
1282    let pipeline_name = std::path::Path::new(path)
1283        .file_stem()
1284        .and_then(|s| s.to_str())
1285        .unwrap_or("default");
1286    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1287    vm.set_source_info(path, &source);
1288    if let Some(ref root) = project_root {
1289        vm.set_project_root(root);
1290    }
1291    if let Some(p) = std::path::Path::new(path).parent() {
1292        if !p.as_os_str().is_empty() {
1293            vm.set_source_dir(p);
1294        }
1295    }
1296
1297    // Same skill discovery as `harn run` — see comment there.
1298    let loaded = load_skills(&SkillLoaderInputs {
1299        cli_dirs: Vec::new(),
1300        source_path: Some(std::path::PathBuf::from(path)),
1301    });
1302    emit_loader_warnings(&loaded.loader_warnings);
1303    install_skills_global(&mut vm, &loaded);
1304
1305    let extensions = package::load_runtime_extensions(Path::new(path));
1306    package::install_runtime_extensions(&extensions);
1307    if let Some(manifest) = extensions.root_manifest.as_ref() {
1308        if !manifest.mcp.is_empty() {
1309            connect_mcp_servers(&manifest.mcp, &mut vm).await;
1310        }
1311    }
1312    if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1313        eprintln!("error: failed to install manifest triggers: {error}");
1314        process::exit(1);
1315    }
1316    if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1317        eprintln!("error: failed to install manifest hooks: {error}");
1318        process::exit(1);
1319    }
1320
1321    let local = tokio::task::LocalSet::new();
1322    local
1323        .run_until(async {
1324            match vm.execute(&chunk).await {
1325                Ok(_) => {}
1326                Err(e) => {
1327                    eprint!("{}", vm.format_runtime_error(&e));
1328                    process::exit(1);
1329                }
1330            }
1331
1332            // Pipeline output goes to stderr — stdout is the MCP transport.
1333            let output = vm.output();
1334            if !output.is_empty() {
1335                eprint!("{output}");
1336            }
1337
1338            let registry = match harn_vm::take_mcp_serve_registry() {
1339                Some(r) => r,
1340                None => {
1341                    eprintln!("error: pipeline did not call mcp_serve(registry)");
1342                    eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
1343                    process::exit(1);
1344                }
1345            };
1346
1347            let tools = match harn_vm::tool_registry_to_mcp_tools(&registry) {
1348                Ok(t) => t,
1349                Err(e) => {
1350                    eprintln!("error: {e}");
1351                    process::exit(1);
1352                }
1353            };
1354
1355            let resources = harn_vm::take_mcp_serve_resources();
1356            let resource_templates = harn_vm::take_mcp_serve_resource_templates();
1357            let prompts = harn_vm::take_mcp_serve_prompts();
1358
1359            let server_name = std::path::Path::new(path)
1360                .file_stem()
1361                .and_then(|s| s.to_str())
1362                .unwrap_or("harn")
1363                .to_string();
1364
1365            let mut caps = Vec::new();
1366            if !tools.is_empty() {
1367                caps.push(format!(
1368                    "{} tool{}",
1369                    tools.len(),
1370                    if tools.len() == 1 { "" } else { "s" }
1371                ));
1372            }
1373            let total_resources = resources.len() + resource_templates.len();
1374            if total_resources > 0 {
1375                caps.push(format!(
1376                    "{total_resources} resource{}",
1377                    if total_resources == 1 { "" } else { "s" }
1378                ));
1379            }
1380            if !prompts.is_empty() {
1381                caps.push(format!(
1382                    "{} prompt{}",
1383                    prompts.len(),
1384                    if prompts.len() == 1 { "" } else { "s" }
1385                ));
1386            }
1387            eprintln!(
1388                "[harn] serve mcp: serving {} as '{server_name}'",
1389                caps.join(", ")
1390            );
1391
1392            let mut server =
1393                harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
1394            if let Some(source) = card_source {
1395                match resolve_card_source(source) {
1396                    Ok(card) => server = server.with_server_card(card),
1397                    Err(e) => {
1398                        eprintln!("error: --card: {e}");
1399                        process::exit(1);
1400                    }
1401                }
1402            }
1403            match mode {
1404                RunFileMcpServeMode::Stdio => {
1405                    if let Err(e) = server.run(&mut vm).await {
1406                        eprintln!("error: MCP server error: {e}");
1407                        process::exit(1);
1408                    }
1409                }
1410                RunFileMcpServeMode::Http {
1411                    options,
1412                    auth_policy,
1413                } => {
1414                    if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
1415                        server,
1416                        vm,
1417                        options,
1418                        auth_policy,
1419                    )
1420                    .await
1421                    {
1422                        eprintln!("error: MCP server error: {e}");
1423                        process::exit(1);
1424                    }
1425                }
1426            }
1427        })
1428        .await;
1429}
1430
1431/// Accept either a path to a JSON file or an inline JSON blob and
1432/// return the parsed `serde_json::Value`. Used by `--card`. Disambiguates
1433/// by peeking at the first non-whitespace character: `{` → inline JSON,
1434/// anything else → path.
1435pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
1436    let trimmed = source.trim_start();
1437    if trimmed.starts_with('{') || trimmed.starts_with('[') {
1438        return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
1439    }
1440    let path = std::path::Path::new(source);
1441    harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
1442}
1443
1444pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1445    use notify::{Event, EventKind, RecursiveMode, Watcher};
1446
1447    let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1448        eprintln!("Error: {e}");
1449        process::exit(1);
1450    });
1451    let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1452
1453    eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1454    run_file(
1455        path,
1456        false,
1457        denied_builtins.clone(),
1458        Vec::new(),
1459        CliLlmMockMode::Off,
1460        None,
1461        RunProfileOptions::default(),
1462    )
1463    .await;
1464
1465    let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1466    let _watcher = {
1467        let tx = tx.clone();
1468        let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1469            if let Ok(event) = res {
1470                if matches!(
1471                    event.kind,
1472                    EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1473                ) {
1474                    let has_harn = event
1475                        .paths
1476                        .iter()
1477                        .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1478                    if has_harn {
1479                        let _ = tx.blocking_send(());
1480                    }
1481                }
1482            }
1483        })
1484        .unwrap_or_else(|e| {
1485            eprintln!("Error setting up file watcher: {e}");
1486            process::exit(1);
1487        });
1488        watcher
1489            .watch(watch_dir, RecursiveMode::Recursive)
1490            .unwrap_or_else(|e| {
1491                eprintln!("Error watching directory: {e}");
1492                process::exit(1);
1493            });
1494        watcher // keep alive
1495    };
1496
1497    eprintln!(
1498        "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
1499        watch_dir.display()
1500    );
1501
1502    loop {
1503        rx.recv().await;
1504        // Debounce: let bursts of events settle for 200ms before re-running.
1505        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1506        while rx.try_recv().is_ok() {}
1507
1508        eprintln!();
1509        eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
1510        run_file(
1511            path,
1512            false,
1513            denied_builtins.clone(),
1514            Vec::new(),
1515            CliLlmMockMode::Off,
1516            None,
1517            RunProfileOptions::default(),
1518        )
1519        .await;
1520    }
1521}
1522
1523#[cfg(test)]
1524mod tests {
1525    use super::{
1526        execute_run, parse_cli_llm_mock_value, serialize_cli_llm_mock, split_eval_header,
1527        CliLlmMockMode, RunProfileOptions, StdoutPassthroughGuard,
1528    };
1529    use std::collections::HashSet;
1530
1531    #[test]
1532    fn split_eval_header_no_imports_returns_full_body() {
1533        let (header, body) = split_eval_header("println(1 + 2)");
1534        assert_eq!(header, "");
1535        assert_eq!(body, "println(1 + 2)");
1536    }
1537
1538    #[test]
1539    fn split_eval_header_lifts_leading_imports() {
1540        let code = "import \"./lib\"\nimport { x } from \"std/math\"\nprintln(x)";
1541        let (header, body) = split_eval_header(code);
1542        assert_eq!(header, "import \"./lib\"\nimport { x } from \"std/math\"");
1543        assert_eq!(body, "println(x)");
1544    }
1545
1546    #[test]
1547    fn split_eval_header_keeps_pub_import_and_comments_in_header() {
1548        let code = "// header comment\npub import { y } from \"./lib\"\n\nfoo()";
1549        let (header, body) = split_eval_header(code);
1550        assert_eq!(
1551            header,
1552            "// header comment\npub import { y } from \"./lib\"\n"
1553        );
1554        assert_eq!(body, "foo()");
1555    }
1556
1557    #[test]
1558    fn split_eval_header_does_not_lift_imports_after_other_statements() {
1559        let code = "let a = 1\nimport \"./lib\"";
1560        let (header, body) = split_eval_header(code);
1561        assert_eq!(header, "");
1562        assert_eq!(body, "let a = 1\nimport \"./lib\"");
1563    }
1564
1565    #[test]
1566    fn cli_llm_mock_roundtrips_logprobs() {
1567        let mock = parse_cli_llm_mock_value(&serde_json::json!({
1568            "text": "visible",
1569            "logprobs": [{"token": "visible", "logprob": 0.0}]
1570        }))
1571        .expect("parse mock");
1572        assert_eq!(mock.logprobs.len(), 1);
1573
1574        let line = serialize_cli_llm_mock(mock).expect("serialize mock");
1575        let value: serde_json::Value = serde_json::from_str(&line).expect("json line");
1576        assert_eq!(value["logprobs"][0]["token"].as_str(), Some("visible"));
1577
1578        let reparsed = parse_cli_llm_mock_value(&value).expect("reparse mock");
1579        assert_eq!(reparsed.logprobs.len(), 1);
1580        assert_eq!(reparsed.logprobs[0]["logprob"].as_f64(), Some(0.0));
1581    }
1582
1583    #[test]
1584    fn stdout_passthrough_guard_restores_previous_state() {
1585        let original = harn_vm::set_stdout_passthrough(false);
1586        {
1587            let _guard = StdoutPassthroughGuard::enable();
1588            assert!(harn_vm::set_stdout_passthrough(true));
1589        }
1590        assert!(!harn_vm::set_stdout_passthrough(original));
1591    }
1592
1593    #[cfg(feature = "hostlib")]
1594    #[tokio::test]
1595    async fn execute_run_installs_hostlib_gate() {
1596        let temp = tempfile::NamedTempFile::new().expect("temp file");
1597        std::fs::write(
1598            temp.path(),
1599            r#"
1600pipeline main() {
1601  let _ = hostlib_enable("tools:deterministic")
1602  println("enabled")
1603}
1604"#,
1605        )
1606        .expect("write script");
1607
1608        let outcome = execute_run(
1609            &temp.path().to_string_lossy(),
1610            false,
1611            HashSet::new(),
1612            Vec::new(),
1613            Vec::new(),
1614            CliLlmMockMode::Off,
1615            None,
1616            RunProfileOptions::default(),
1617        )
1618        .await;
1619
1620        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1621        assert_eq!(outcome.stdout.trim(), "enabled");
1622    }
1623
1624    #[cfg(all(feature = "hostlib", unix))]
1625    #[tokio::test]
1626    async fn execute_run_can_read_hostlib_command_artifacts() {
1627        let temp = tempfile::NamedTempFile::new().expect("temp file");
1628        std::fs::write(
1629            temp.path(),
1630            r#"
1631pipeline main() {
1632  let _ = hostlib_enable("tools:deterministic")
1633  let result = hostlib_tools_run_command({
1634    argv: ["sh", "-c", "i=0; while [ $i -lt 2000 ]; do printf x; i=$((i+1)); done"],
1635    capture: {max_inline_bytes: 8},
1636    timeout_ms: 5000,
1637  })
1638  println(starts_with(result.command_id, "cmd_"))
1639  println(len(result.stdout))
1640  println(result.byte_count)
1641  let window = hostlib_tools_read_command_output({
1642    command_id: result.command_id,
1643    offset: 1990,
1644    length: 20,
1645  })
1646  println(len(window.content))
1647  println(window.eof)
1648}
1649"#,
1650        )
1651        .expect("write script");
1652
1653        let outcome = execute_run(
1654            &temp.path().to_string_lossy(),
1655            false,
1656            HashSet::new(),
1657            Vec::new(),
1658            Vec::new(),
1659            CliLlmMockMode::Off,
1660            None,
1661            RunProfileOptions::default(),
1662        )
1663        .await;
1664
1665        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1666        assert_eq!(outcome.stdout.trim(), "true\n8\n2000\n10\ntrue");
1667    }
1668}