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