Skip to main content

harn_cli/commands/
demo.rs

1//! `harn demo` — bundled, fully-offline scenarios that demonstrate the
2//! Harn moat (persona supervision, replay determinism, provider
3//! routing) without any API keys. See issue #1650.
4//!
5//! Each scenario ships with:
6//!   - a `.harn` script (`assets/demo/<id>/scenario.harn`)
7//!   - a JSONL `--llm-mock` tape (`assets/demo/<id>/tape.jsonl`)
8//!   - optionally, sibling files (`.harn.prompt` templates, imported modules,
9//!     fixtures) anywhere under `assets/demo/<id>/`
10//!
11//! The script and tape are `include_str!`'d here; sibling files are embedded by
12//! `build.rs` (see `DEMO_SIBLING_FILES`). At run time `stage_scenario`
13//! materializes the whole set into a tempdir and `run_scenario` executes there
14//! hermetically, so `harn demo <id>` works from a static-linked install with no
15//! repo checkout — and a multi-file scenario behaves exactly like a real
16//! on-disk Harn project.
17
18use std::collections::HashSet;
19use std::fs;
20use std::io::IsTerminal;
21use std::path::{Path, PathBuf};
22use std::time::{Instant, SystemTime, UNIX_EPOCH};
23
24use unicode_width::UnicodeWidthStr;
25
26use crate::cli::DemoArgs;
27use crate::commands::run::{
28    execute_run_with_sandbox_options, CliLlmMockMode, RunOutcome, RunProfileOptions,
29    RunSandboxOptions,
30};
31
32// Generated by build.rs: `(<id>/<relative path>, contents)` for every demo
33// sibling file (`.harn.prompt` templates, imported modules, fixtures) beyond
34// the primary scenario.harn + tape.jsonl. Materialized into the run tempdir by
35// `stage_scenario` so multi-file scenarios run fully offline.
36include!(concat!(env!("OUT_DIR"), "/demo_assets_table.rs"));
37
38/// Bundled scenarios shipped with the binary. Keep ordered by the
39/// "first-touch impact" we want stranger users to see — the menu and
40/// `--list` print in this order, and the interactive menu offers the first
41/// entry as its default choice.
42const SCENARIOS: &[Scenario] = &[
43    Scenario {
44        id: "merge-captain",
45        title: "Merge Captain triages 3 PRs",
46        description: "A merge_captain persona triages three mocked PRs (trivial, risky, buggy), \
47                      asks an LLM per PR, and emits a structured supervision receipt. \
48                      Demonstrates persona supervision, approval gates, and trust receipts.",
49        script: include_str!("../../assets/demo/merge-captain/scenario.harn"),
50        tape: include_str!("../../assets/demo/merge-captain/tape.jsonl"),
51    },
52    Scenario {
53        id: "review-captain",
54        title: "Review Captain inspects a 5-file diff",
55        description: "A review_captain reviews a 5-file diff, asks one clarifying question \
56                      (HITL surfaced via the receipt), then renders a verdict with \
57                      reasoning. Demonstrates clarifying-question loops and structured \
58                      review receipts.",
59        script: include_str!("../../assets/demo/review-captain/scenario.harn"),
60        tape: include_str!("../../assets/demo/review-captain/tape.jsonl"),
61    },
62    Scenario {
63        id: "provider-race",
64        title: "Provider race with cost attribution",
65        description: "Race three providers on one prompt with `parallel each`, pick the \
66                      lowest-latency winner, and emit a cost-attribution receipt. \
67                      Previews the routing_policy primitive (#1649).",
68        script: include_str!("../../assets/demo/provider-race/scenario.harn"),
69        tape: include_str!("../../assets/demo/provider-race/tape.jsonl"),
70    },
71    Scenario {
72        id: "routing-policy",
73        title: "routing_policy escalates a cheap chain to a frontier link",
74        description: "Drive the v0.8.40 routing_policy primitive through three mocked tasks: a \
75                      clean cheap-link success, a 429 that fails over to the frontier, and a \
76                      TODO-poisoned cheap reply that the lint verifier escalates. Demonstrates \
77                      per-attempt routing receipts, failover, and verifier-signal escalation \
78                      (canary scenario for the demo gate, #2437).",
79        script: include_str!("../../assets/demo/routing-policy/scenario.harn"),
80        tape: include_str!("../../assets/demo/routing-policy/tape.jsonl"),
81    },
82    Scenario {
83        id: "stdlib-toolkit",
84        title: "stdlib toolkit assembles an XML system-prompt context",
85        description: "Walk the new clone / deep_merge / unique / dict_from_pairs / to_xml / \
86                      from_xml / word_wrap / indent / repeat built-ins through a realistic \
87                      pre-flight render: layer per-task overrides onto a shared defaults dict, \
88                      dedupe the operator's `previous_chats` list, emit an XML `<context>` \
89                      block, round-trip it back through the parser, and frame the result in a \
90                      60-column prompt margin. Fully offline.",
91        script: include_str!("../../assets/demo/stdlib-toolkit/scenario.harn"),
92        tape: include_str!("../../assets/demo/stdlib-toolkit/tape.jsonl"),
93    },
94    Scenario {
95        id: "embed-similarity",
96        title: "hostlib_embed_* ranks code-agent context offline",
97        description: "Drive the cross-platform hostlib embedding surface fully offline: inspect \
98                      the active backend, embed one symbol-like query, compare related vs \
99                      unrelated text, and top-k rank a small context corpus. Demonstrates the \
100                      shared similarity contract Burin can reuse for push-context retrieval and \
101                      symbol relevance without macOS-only NaturalLanguage or live model calls.",
102        script: include_str!("../../assets/demo/embed-similarity/scenario.harn"),
103        tape: include_str!("../../assets/demo/embed-similarity/tape.jsonl"),
104    },
105    Scenario {
106        id: "project-metadata",
107        title: "project.metadata_* falls back to Harn metadata offline",
108        description: "Drive standalone `host_call(\"project.metadata_*\", ...)` through Harn's \
109                      built-in metadata store: set a namespace, read it with hierarchical \
110                      inheritance, inspect the origin, refresh structure hashes, and save. \
111                      Demonstrates the CLI/debug fallback path while product host bridges keep \
112                      precedence when attached.",
113        script: include_str!("../../assets/demo/project-metadata/scenario.harn"),
114        tape: include_str!("../../assets/demo/project-metadata/tape.jsonl"),
115    },
116    Scenario {
117        id: "runtime-prompt-content",
118        title: "runtime.prompt_content reads typed multimodal session input",
119        description: "Mock an ACP session prompt at the runtime host boundary, read its typed text \
120                      and image blocks through `runtime_prompt_content()`, and emit a structured \
121                      receipt. Fully offline.",
122        script: include_str!("../../assets/demo/runtime-prompt-content/scenario.harn"),
123        tape: include_str!("../../assets/demo/runtime-prompt-content/tape.jsonl"),
124    },
125    Scenario {
126        id: "verification-snapshot",
127        title: "verification_file_hash_snapshot binds checks to current file bytes",
128        description: "Build a throw-away Rust workspace, seed the code index, then capture a \
129                      seq-bound batch of file hashes through `std/verification`: fresh indexed \
130                      files reuse index metadata, changed or newly-created files read current \
131                      disk bytes, missing paths are explicit, and the resulting path->hash map \
132                      feeds `verification_diagnostic_classify`. Fully offline.",
133        script: include_str!("../../assets/demo/verification-snapshot/scenario.harn"),
134        tape: include_str!("../../assets/demo/verification-snapshot/tape.jsonl"),
135    },
136    Scenario {
137        id: "command-capture",
138        title: "run_command preserves a slow command's full output past a `| tail` filter",
139        description: "Walk the std/agent/command_capture recognizer: rewrite `producer | tail/wc/grep` \
140                      pipelines to `producer | tee '<capture>' 2>/dev/null | filter` so the agent \
141                      still sees the filtered output while the producer's COMPLETE output is \
142                      preserved on disk, show the cases it deliberately leaves untouched (head, \
143                      grep -q, command substitution, subshell grouping), then materialize a capture \
144                      and demonstrate the post-run `output_capture` hint that lets an agent read the \
145                      full output instead of re-running a slow command. Fully offline — no LLM, no \
146                      subprocess.",
147        script: include_str!("../../assets/demo/command-capture/scenario.harn"),
148        tape: include_str!("../../assets/demo/command-capture/tape.jsonl"),
149    },
150    Scenario {
151        id: "agent-edit-tools",
152        title: "agent_edit_tools ships the canonical write/edit/mkdir/delete toolset",
153        description: "Drive the default mutation tools (#PR-D) offline: `write_file`, `edit_file`, \
154                      `create_directory`, and `delete_path` from `std/agent/host_tools`, root-scoped \
155                      wrappers over the hostlib filesystem primitives. Walk the happy paths, the \
156                      guardrails (edit_file refuses an ambiguous match; delete_path refuses a \
157                      non-empty directory unless recursive), and the middleware seam — the same \
158                      named tools wrapped with `with_consent` + `with_audit_log` so an embedder gets \
159                      approval and receipts for free. Honestly annotated as mutating so the \
160                      read-only stance hides them. Fully offline — no LLM, no subprocess.",
161        script: include_str!("../../assets/demo/agent-edit-tools/scenario.harn"),
162        tape: include_str!("../../assets/demo/agent-edit-tools/tape.jsonl"),
163    },
164    Scenario {
165        id: "compaction-policy",
166        title: "compaction.{policy,check,run} drives a session through the lifecycle",
167        description: "Declare a per-session compaction policy with thresholds, call \
168                      `compaction.check` to get a `compact_now` / `defer` decision, then drive \
169                      the canonical #2323 lifecycle via `compaction.run`. Demonstrates the \
170                      lifted-from-TUI policy primitive (#2505) entirely offline using a custom \
171                      summarize closure.",
172        script: include_str!("../../assets/demo/compaction-policy/scenario.harn"),
173        tape: include_str!("../../assets/demo/compaction-policy/tape.jsonl"),
174    },
175    Scenario {
176        id: "mcp-host",
177        title: "harn.mcp.* host primitive lazy-spawn + status round-trip",
178        description: "Drive the supervised MCP-host primitive (#2504): register two lazy MCP \
179                      server specs, snapshot the supervision status (restart_count, circuit, \
180                      cache_entries), stop one of them, and re-snapshot. Stays fully offline \
181                      because lazy spawn doesn't try to connect. The receipt asserts every \
182                      initial entry starts with the circuit closed and cache empty — the \
183                      invariants downstream observability hooks lean on.",
184        script: include_str!("../../assets/demo/mcp-host/scenario.harn"),
185        tape: include_str!("../../assets/demo/mcp-host/tape.jsonl"),
186    },
187    Scenario {
188        id: "http-transport",
189        title: "http_etag / http_choose / http_not_modified / http_upgrade_ws / http_push_hints",
190        description: "Drive the A.12 transport-completeness builtins (#2515) offline: pick a \
191                      content type from a simulated `Accept` header via `http_choose`, derive a \
192                      strong ETag from the JSON payload, build the matching `http_not_modified` \
193                      envelope, assemble the `http_upgrade_ws` envelope with subprotocol \
194                      negotiation, and decorate an `http_ok` envelope with `http_push_hints` so \
195                      the codec emits one `Link: <path>; rel=preload; as=...` header per asset. \
196                      The conformance test in `crates/harn-serve/tests/\
197                      transport_conformance.rs` exercises the live HTTP / WS path; the demo \
198                      keeps the offline smoke covered.",
199        script: include_str!("../../assets/demo/http-transport/scenario.harn"),
200        tape: include_str!("../../assets/demo/http-transport/tape.jsonl"),
201    },
202    Scenario {
203        id: "harn-site",
204        title: "harn serve site — a .harn file answers its own HTTP routes",
205        description: "Drive the `harn serve site` handler contract (#2574) offline: a routed \
206                      `pub fn` receives a request dict and returns an `http_*` envelope. The \
207                      scenario calls a GET handler, a POST handler that echoes its body, and a \
208                      conditional GET that returns 200 then 304 via `http_not_modified`, plus the \
209                      `on_message` WebSocket frame callback — capturing each status in a receipt. \
210                      The live socket path (routing, multipart, WS upgrade) is covered by \
211                      `crates/harn-serve/tests/site_hosting.rs`; the demo keeps the offline \
212                      handler-contract smoke covered.",
213        script: include_str!("../../assets/demo/harn-site/scenario.harn"),
214        tape: include_str!("../../assets/demo/harn-site/tape.jsonl"),
215    },
216    Scenario {
217        id: "obs-primitive",
218        title: "harness.obs.* spans + counter/histogram/gauge + audit roundtrip",
219        description: "Drive the standardized observability primitive (#2513): open a span over a \
220                      simulated `harn.session.put`, record one of each instrument variant \
221                      (counter / histogram / gauge), emit a structured log inside the span, then \
222                      drain the in-process buffer and surface a receipt of events-by-kind plus \
223                      the bound request_id. Validates vocabulary at emit time and routes through \
224                      the `test` backend so the scenario stays fully offline.",
225        script: include_str!("../../assets/demo/obs-primitive/scenario.harn"),
226        tape: include_str!("../../assets/demo/obs-primitive/tape.jsonl"),
227    },
228    Scenario {
229        id: "edit-rename-symbol",
230        title: "edit.rename_symbol rewrites a Rust struct across the workspace",
231        description: "Stage a tiny Rust workspace, build the typed symbol graph (#2434), then \
232                      drive `edit_rename_symbol` (#2508) through dry-run + applied + conflict \
233                      paths: a workspace-scoped plan with per-edit byte/(row,col) spans, the \
234                      same plan committed for real with identifier-context rewrites (skipping \
235                      string literals and comments), and a follow-up rename whose new name \
236                      already shadows another identifier — host short-circuits without \
237                      touching disk. Fully offline.",
238        script: include_str!("../../assets/demo/edit-rename-symbol/scenario.harn"),
239        tape: include_str!("../../assets/demo/edit-rename-symbol/tape.jsonl"),
240    },
241    Scenario {
242        id: "edit-language-coverage",
243        title: "edit.capabilities + apply_node span the B.7 tier-1 languages",
244        description: "Read the per-language AST-precise edit capability matrix (#2519), show \
245                      graceful degradation for a language with no grammar (Dockerfile returns \
246                      `unsupported_language` plus a text-edit fallback), then drive \
247                      `edit_apply_node` (dry-run) against bundled JSON and CSS seeds to prove a \
248                      format-preserving edit round-trips on the data/markup grammars added in \
249                      B.7. Fully offline.",
250        script: include_str!("../../assets/demo/edit-language-coverage/scenario.harn"),
251        tape: include_str!("../../assets/demo/edit-language-coverage/tape.jsonl"),
252    },
253    Scenario {
254        id: "edit-refactor",
255        title: "edit.extract_function / add_parameter / change_return_type preview as diffs",
256        description: "Drive the B.8 structured-refactoring primitives (#2520) offline against a \
257                      bundled seed workspace, all in dry-run mode: extract two statements of a \
258                      Python function into `compute_subtotal` (capturing `base`/`qty` but not the \
259                      module-level `audit`), append a trailing parameter to a Rust function and \
260                      fill the argument at all three call sites, and rewrite that function's \
261                      return type — each previewed as a unified diff against a throw-away \
262                      staged-fs overlay so no bytes hit disk. The conformance test in \
263                      `crates/harn-vm`/`conformance` exercises the apply path; this demo keeps \
264                      the offline preview smoke covered.",
265        script: include_str!("../../assets/demo/edit-refactor/scenario.harn"),
266        tape: include_str!("../../assets/demo/edit-refactor/tape.jsonl"),
267    },
268    Scenario {
269        id: "prompt-guidance",
270        title: "tool guidance rides with the tool, and the prompt is auditable",
271        description: "Drive the unified prompt-fragment assembler: a tool carries a `guidance` \
272                      string that the runtime injects as a capability-gated system-prompt fragment \
273                      (`requires_tools: [<that tool>]`), so a 'always update the TODO tracker' \
274                      instruction appears only when the todo tool is present and never otherwise — \
275                      instruction and tool share one source of truth and cannot drift. Calls \
276                      `prompt_explain(options)` with and without the tool, prints the assembled \
277                      system string plus the per-fragment provenance (included/excluded + reason + \
278                      bytes) an operator inspects, and proves the only difference between the two \
279                      prompts is exactly the gated fragment. Fully offline — no LLM, no network.",
280        script: include_str!("../../assets/demo/prompt-guidance/scenario.harn"),
281        tape: include_str!("../../assets/demo/prompt-guidance/tape.jsonl"),
282    },
283    Scenario {
284        id: "destructure-with-defaults",
285        title: "destructuring-with-defaults collapses the `input?.x ?? default` idiom",
286        description: "Collapse the most-repeated idiom in our Harn corpus — \
287                      `const x = input?.field ?? default` (~5,700 sites across BurinCore alone) — \
288                      into a single destructuring bind: `const { path = \"\", namespace = nil } = \
289                      pipeline_input() ?? {}`. Present keys win, missing keys fall to their \
290                      defaults, and the type checker now infers each binding's type from \
291                      `field + default` exactly as the `?.`/`??` form did, so the migration is \
292                      lossless under the checker. Fully offline — no LLM, no network.",
293        script: include_str!("../../assets/demo/destructure-with-defaults/scenario.harn"),
294        tape: include_str!("../../assets/demo/destructure-with-defaults/tape.jsonl"),
295    },
296    Scenario {
297        id: "lexical-block",
298        title: "`block` bounds cleanup and owned lifetimes",
299        description: "Use an explicit `block { ... }` to create a lexical lifetime without a \
300                      control-flow condition: keep inner bindings scoped, run deferred cleanup at \
301                      the block boundary, then continue with outer state. This is \
302                      the direct lifetime primitive for owned resources that previously required \
303                      a helper function. Fully offline — no LLM, network, or subprocess.",
304        script: include_str!("../../assets/demo/lexical-block/scenario.harn"),
305        tape: include_str!("../../assets/demo/lexical-block/tape.jsonl"),
306    },
307    Scenario {
308        id: "pub-type-exports",
309        title: "`pub type` shares one alias across modules, annotations, and output",
310        description: "Export a type alias from one module and consume it from another: the \
311                      imported alias drives a plain annotation, an exhaustive `match` over its \
312                      literal union, and schema-as-type on `llm_call` \
313                      (`output: GradeReport`) — the compiler lowers the exported alias to \
314                      JSON Schema and the validated result narrows back to it. Before `pub type`, \
315                      every consumer re-declared shared wire shapes because aliases were \
316                      module-private. Offline — the single LLM call replays from the bundled \
317                      tape.",
318        script: include_str!("../../assets/demo/pub-type-exports/scenario.harn"),
319        tape: include_str!("../../assets/demo/pub-type-exports/tape.jsonl"),
320    },
321    Scenario {
322        id: "catalog-patch-models",
323        title: "[patch.models] tweaks one catalog field without copying the row",
324        description: "A sibling harn.toml contributes a demo catalog row and a field-wise \
325                      `[llm.patch.models]` patch for two of its fields (stream_timeout and \
326                      pricing.output_per_mtok). The scenario reads the effective catalog back \
327                      via `llm_model_info` and asserts the patched fields carry the patch \
328                      values while every unpatched sibling field keeps its baseline — the \
329                      alternative used to be copying the whole row verbatim and freezing it \
330                      against catalog updates. Fully offline — no LLM calls.",
331        script: include_str!("../../assets/demo/catalog-patch-models/scenario.harn"),
332        tape: include_str!("../../assets/demo/catalog-patch-models/tape.jsonl"),
333    },
334];
335
336#[derive(Clone, Copy)]
337struct Scenario {
338    id: &'static str,
339    title: &'static str,
340    description: &'static str,
341    script: &'static str,
342    tape: &'static str,
343}
344
345/// Public list of bundled scenario ids — used by tests and the smoke
346/// loop that exercises every demo on every PR.
347pub fn scenario_ids() -> Vec<&'static str> {
348    SCENARIOS.iter().map(|s| s.id).collect()
349}
350
351/// What a bare `harn demo` (no scenario argument) should do.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353enum NoScenarioAction {
354    /// Render the scenario list as JSON.
355    ListJson,
356    /// Prompt for a choice on an interactive terminal.
357    Prompt,
358    /// Print the scenario list as a table.
359    ListTable,
360}
361
362/// Prompting needs a terminal at both ends: stdout to render the menu and stdin
363/// to read the choice. Anywhere else — a pipe, a redirect, CI — print the menu
364/// rather than running a scenario the caller never named.
365fn no_scenario_action(json: bool, stdout_tty: bool, stdin_tty: bool) -> NoScenarioAction {
366    if json {
367        return NoScenarioAction::ListJson;
368    }
369    if stdout_tty && stdin_tty {
370        return NoScenarioAction::Prompt;
371    }
372    NoScenarioAction::ListTable
373}
374
375pub(crate) async fn run(args: DemoArgs) -> i32 {
376    if args.list {
377        print_list_table(args.json);
378        return 0;
379    }
380
381    let Some(scenario_id) = args.scenario.clone() else {
382        return match no_scenario_action(
383            args.json,
384            std::io::stdout().is_terminal(),
385            std::io::stdin().is_terminal(),
386        ) {
387            NoScenarioAction::ListJson => {
388                print_list_table(true);
389                0
390            }
391            NoScenarioAction::Prompt => interactive_pick(&args).await,
392            NoScenarioAction::ListTable => {
393                print_list_table(false);
394                0
395            }
396        };
397    };
398
399    let Some(scenario) = lookup_scenario(&scenario_id) else {
400        eprintln!("error: unknown scenario `{scenario_id}`");
401        eprintln!();
402        print_list_table(false);
403        return 2;
404    };
405    run_scenario(&args, *scenario).await
406}
407
408fn lookup_scenario(id: &str) -> Option<&'static Scenario> {
409    SCENARIOS.iter().find(|s| s.id == id)
410}
411
412fn print_list_table(as_json: bool) {
413    if as_json {
414        let entries: Vec<serde_json::Value> = SCENARIOS
415            .iter()
416            .map(|s| {
417                serde_json::json!({
418                    "id": s.id,
419                    "title": s.title,
420                    "description": s.description,
421                })
422            })
423            .collect();
424        println!(
425            "{}",
426            serde_json::to_string_pretty(&serde_json::json!({"scenarios": entries}))
427                .unwrap_or_default()
428        );
429        return;
430    }
431    println!("Available demos (replayable offline, no API keys required):");
432    println!();
433    for s in SCENARIOS {
434        println!("  {:<16}  {}", s.id, s.title);
435        for line in wrap_text(s.description, 70) {
436            println!("    {line}");
437        }
438        println!();
439    }
440    println!("Run a scenario:    harn demo <id>");
441    println!("Use real provider: harn demo <id> --live");
442}
443
444/// Greedy word-wrap for terminal output. `width` is a column budget, so the
445/// fit test measures display columns — byte length wraps non-ASCII text far
446/// too early, and a `char` count misses double-width CJK and emoji.
447fn wrap_text(text: &str, width: usize) -> Vec<String> {
448    let mut lines = Vec::new();
449    let mut current = String::new();
450    for word in text.split_whitespace() {
451        if current.is_empty() {
452            current.push_str(word);
453            continue;
454        }
455        if UnicodeWidthStr::width(current.as_str()) + 1 + UnicodeWidthStr::width(word) > width {
456            lines.push(std::mem::take(&mut current));
457            current.push_str(word);
458        } else {
459            current.push(' ');
460            current.push_str(word);
461        }
462    }
463    if !current.is_empty() {
464        lines.push(current);
465    }
466    lines
467}
468
469async fn interactive_pick(args: &DemoArgs) -> i32 {
470    use std::io::Write;
471    println!("Pick a Harn demo (offline replay, no API keys required):");
472    println!();
473    for (idx, s) in SCENARIOS.iter().enumerate() {
474        println!("  {}) {:<16}  {}", idx + 1, s.id, s.title);
475    }
476    println!();
477    print!("Choice [1-{}, default 1]: ", SCENARIOS.len());
478    let _ = std::io::stdout().flush();
479    let mut buf = String::new();
480    let n = std::io::stdin().read_line(&mut buf).unwrap_or(0);
481    let trimmed = buf.trim();
482    let pick: usize = if n == 0 || trimmed.is_empty() {
483        1
484    } else {
485        match trimmed.parse::<usize>() {
486            Ok(n) if (1..=SCENARIOS.len()).contains(&n) => n,
487            _ => {
488                eprintln!("error: invalid selection `{trimmed}`");
489                return 2;
490            }
491        }
492    };
493    run_scenario(args, SCENARIOS[pick - 1]).await
494}
495
496async fn run_scenario(args: &DemoArgs, scenario: Scenario) -> i32 {
497    let staged = match stage_scenario(scenario) {
498        Ok(s) => s,
499        Err(error) => {
500            eprintln!("error: {error}");
501            return 1;
502        }
503    };
504
505    if !args.json {
506        println!("=== harn demo · {} ===", scenario.id);
507        println!("{}", scenario.title);
508        println!();
509        if !args.live {
510            println!("(offline replay — no API keys required)");
511            println!();
512        }
513    }
514
515    let llm_mock_mode = if args.live {
516        if !args.json {
517            println!("(--live: routing through the configured provider — set HARN_LLM_PROVIDER if none is wired)");
518            println!();
519        }
520        CliLlmMockMode::Off
521    } else {
522        CliLlmMockMode::Replay {
523            fixture_path: staged.tape_path.clone(),
524        }
525    };
526
527    // Run the demo hermetically: root both the sandbox workspace and the
528    // process working directory at the staged tempdir. This way file-backed
529    // prompts and imports resolve relative to the script, and anything the
530    // scenario writes (e.g. command-capture's output file, addressed via
531    // `execution_root()`) lands in the tempdir instead of polluting the
532    // caller's cwd. The cwd guard is scoped to the run and restored before the
533    // run record is written, so records still land under the user's cwd.
534    let sandbox = RunSandboxOptions {
535        workspace_root: Some(staged.root.clone()),
536        ..RunSandboxOptions::default()
537    };
538
539    let started = Instant::now();
540    let outcome = {
541        let _cwd = ScopedCwd::enter(&staged.root);
542        execute_run_with_sandbox_options(
543            staged.script_path.to_string_lossy().as_ref(),
544            false,
545            HashSet::new(),
546            Vec::new(),
547            Vec::new(),
548            llm_mock_mode,
549            None,
550            RunProfileOptions::default(),
551            sandbox,
552        )
553        .await
554    };
555    let elapsed = started.elapsed();
556
557    if !args.json && !outcome.stdout.is_empty() {
558        print!("{}", outcome.stdout);
559    }
560    if !outcome.stderr.is_empty() {
561        eprint!("{}", outcome.stderr);
562    }
563
564    if outcome.exit_code != 0 {
565        if !args.json {
566            eprintln!(
567                "error: demo `{}` failed (exit {})",
568                scenario.id, outcome.exit_code
569            );
570            if args.live && live_failure_looks_like_provider_misconfig(&outcome) {
571                eprintln!();
572                eprintln!("hint: --live needs a configured LLM provider. Re-run without --live");
573                eprintln!("      to use the bundled offline tape, or run `harn quickstart`");
574                eprintln!("      to wire a provider.");
575            }
576        } else {
577            print_json_summary(scenario, &outcome, elapsed.as_millis(), None);
578        }
579        return outcome.exit_code;
580    }
581
582    let receipt_dir = if args.no_record {
583        None
584    } else {
585        match write_run_record(scenario, &outcome) {
586            Ok(path) => Some(path),
587            Err(error) => {
588                eprintln!("warning: failed to write demo run record: {error}");
589                None
590            }
591        }
592    };
593
594    if args.json {
595        print_json_summary(
596            scenario,
597            &outcome,
598            elapsed.as_millis(),
599            receipt_dir.as_deref(),
600        );
601    } else {
602        println!();
603        println!("--- demo complete in {} ms ---", elapsed.as_millis());
604        if let Some(dir) = &receipt_dir {
605            println!("  run record: {}", dir.join("run.json").display());
606        }
607        println!();
608        println!("Next steps:");
609        println!("  harn demo --list           list every bundled scenario");
610        if !args.live {
611            println!(
612                "  harn demo {} --live      run again against the configured provider",
613                scenario.id
614            );
615        }
616        println!("  harn portal                browse run records in the UI");
617    }
618    0
619}
620
621struct StagedScenario {
622    _temp_root: tempfile::TempDir,
623    script_path: PathBuf,
624    tape_path: PathBuf,
625    /// The staged tempdir holding the script, tape, and any sibling files.
626    /// The run is rooted here (cwd + sandbox workspace) so the scenario
627    /// executes hermetically: file-backed prompts and imports resolve, and any
628    /// artifacts it writes land in the tempdir instead of the caller's cwd.
629    root: PathBuf,
630}
631
632/// RAII guard that sets the process working directory for the duration of a
633/// single demo run and restores the previous one on drop (including on early
634/// return or panic). `harn demo` runs one scenario at a time, so this brief,
635/// scoped change to process-global cwd is safe and lets scenarios execute as if
636/// the staged tempdir were the working directory.
637struct ScopedCwd {
638    previous: Option<PathBuf>,
639}
640
641impl ScopedCwd {
642    fn enter(dir: &Path) -> Self {
643        let previous = std::env::current_dir().ok();
644        if let Err(error) = std::env::set_current_dir(dir) {
645            eprintln!(
646                "warning: failed to enter demo working dir {}: {error}",
647                dir.display()
648            );
649        }
650        Self { previous }
651    }
652}
653
654impl Drop for ScopedCwd {
655    fn drop(&mut self) {
656        if let Some(previous) = self.previous.take() {
657            let _ = std::env::set_current_dir(previous);
658        }
659    }
660}
661
662fn stage_scenario(scenario: Scenario) -> Result<StagedScenario, String> {
663    let dir = tempfile::Builder::new()
664        .prefix(&format!("harn-demo-{}-", scenario.id))
665        .tempdir()
666        .map_err(|e| format!("failed to create demo tempdir: {e}"))?;
667    let script_path = dir.path().join(format!("{}.harn", scenario.id));
668    let tape_path = dir.path().join(format!("{}.tape.jsonl", scenario.id));
669    fs::write(&script_path, scenario.script)
670        .map_err(|e| format!("failed to stage demo script: {e}"))?;
671    fs::write(&tape_path, scenario.tape).map_err(|e| format!("failed to stage demo tape: {e}"))?;
672
673    // Materialize any sibling files (`.harn.prompt` templates, imported
674    // modules, fixtures) next to the script so file-backed prompts and imports
675    // resolve relative to the script, the same way a real Harn project is laid
676    // out on disk.
677    let prefix = format!("{}/", scenario.id);
678    for (rel, bytes) in DEMO_SIBLING_FILES {
679        let Some(sub) = rel.strip_prefix(&prefix) else {
680            continue;
681        };
682        let dest = dir.path().join(sub);
683        if let Some(parent) = dest.parent() {
684            fs::create_dir_all(parent).map_err(|e| {
685                format!("failed to create demo asset dir {}: {e}", parent.display())
686            })?;
687        }
688        fs::write(&dest, bytes).map_err(|e| format!("failed to stage demo asset {sub}: {e}"))?;
689    }
690
691    let root = dir.path().to_path_buf();
692    Ok(StagedScenario {
693        _temp_root: dir,
694        script_path,
695        tape_path,
696        root,
697    })
698}
699
700fn write_run_record(scenario: Scenario, outcome: &RunOutcome) -> Result<PathBuf, String> {
701    let cwd = std::env::current_dir().map_err(|e| format!("cwd: {e}"))?;
702    let runs_root = cwd.join(".harn-runs");
703    let ts = SystemTime::now()
704        .duration_since(UNIX_EPOCH)
705        .map(|d| d.as_secs())
706        .unwrap_or(0);
707    let started_iso = time::OffsetDateTime::from_unix_timestamp(ts as i64)
708        .ok()
709        .and_then(|t| {
710            t.format(&time::format_description::well_known::Rfc3339)
711                .ok()
712        })
713        .unwrap_or_else(|| format!("1970-01-01T00:00:{ts:02}Z"));
714    let dir = runs_root.join(format!("demo-{}-{ts}", scenario.id));
715    fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
716    // Conform to the `run_record` envelope the portal scans for so the
717    // demo shows up in `harn portal` alongside real workflow runs. The
718    // demo-specific payload (script, tape ref, stdout/stderr) lives
719    // under `metadata.demo` so portal listings can group on it.
720    let record = serde_json::json!({
721        "_type": "run_record",
722        "id": format!("demo-{}-{ts}", scenario.id),
723        "workflow_id": format!("harn-demo:{}", scenario.id),
724        "workflow_name": scenario.title,
725        "task": scenario.id,
726        "status": if outcome.exit_code == 0 { "complete" } else { "failed" },
727        "started_at": started_iso,
728        "finished_at": started_iso,
729        "stages": [],
730        "transitions": [],
731        "checkpoints": [],
732        "pending_nodes": [],
733        "completed_nodes": [],
734        "child_runs": [],
735        "artifacts": [],
736        "policy": {},
737        "metadata": {
738            "demo": {
739                "scenario": scenario.id,
740                "title": scenario.title,
741                "description": scenario.description,
742                "exit_code": outcome.exit_code,
743                "stdout": outcome.stdout,
744                "stderr": outcome.stderr,
745                "recorded_at_unix_seconds": ts,
746            }
747        },
748    });
749    let path = dir.join("run.json");
750    fs::write(
751        &path,
752        serde_json::to_string_pretty(&record).unwrap_or_default(),
753    )
754    .map_err(|e| format!("write {}: {e}", path.display()))?;
755    Ok(dir)
756}
757
758fn live_failure_looks_like_provider_misconfig(outcome: &RunOutcome) -> bool {
759    // Heuristic on the rendered diagnostic — every error path Harn
760    // surfaces for "no key / wrong key / no provider" is one of these
761    // category strings or substrings. Avoids over-firing on script
762    // bugs that happen to fail under `--live`.
763    let blob = format!("{}{}", outcome.stderr, outcome.stdout);
764    blob.contains("category: auth")
765        || blob.contains("auth_failure")
766        || blob.contains("HTTP 401")
767        || blob.contains("HTTP 403")
768        || blob.contains("api_key")
769        || blob.contains("HARN_LLM_PROVIDER")
770        || blob.contains("no provider configured")
771}
772
773fn print_json_summary(
774    scenario: Scenario,
775    outcome: &RunOutcome,
776    elapsed_ms: u128,
777    record_dir: Option<&Path>,
778) {
779    let record = serde_json::json!({
780        "scenario": scenario.id,
781        "title": scenario.title,
782        "exit_code": outcome.exit_code,
783        "elapsed_ms": elapsed_ms,
784        "stdout": outcome.stdout,
785        "stderr": outcome.stderr,
786        "run_record_dir": record_dir.map(|p| p.display().to_string()),
787    });
788    println!(
789        "{}",
790        serde_json::to_string_pretty(&record).unwrap_or_default()
791    );
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    #[test]
799    fn wrap_text_budgets_columns_not_bytes_or_chars() {
800        // Each word is 2 chars / 6 bytes / 4 columns, which separates all three
801        // readings under a budget of 9: by columns exactly two words fit
802        // (4 + 1 + 4 = 9); by bytes not even two would (6 + 1 + 6 = 13); by
803        // chars all three would have been packed onto one line (8).
804        let wrapped = wrap_text("日本 語で すね", 9);
805        assert_eq!(wrapped, vec!["日本 語で".to_string(), "すね".to_string()]);
806    }
807
808    #[test]
809    fn wrap_text_treats_emoji_as_double_width() {
810        // "🚀🚀" is 2 chars but 4 columns, so it cannot join "ab" under a
811        // budget of 6 (2 + 1 + 4 = 7). A char count would have packed them.
812        assert_eq!(
813            wrap_text("ab 🚀🚀", 6),
814            vec!["ab".to_string(), "🚀🚀".to_string()]
815        );
816    }
817
818    #[test]
819    fn wrap_text_is_unchanged_for_ascii() {
820        assert_eq!(
821            wrap_text("the quick brown fox", 9),
822            vec!["the quick".to_string(), "brown fox".to_string()]
823        );
824    }
825
826    #[test]
827    fn bare_demo_action_covers_every_stdio_shape() {
828        let cases = [
829            (false, true, true, NoScenarioAction::Prompt),
830            (false, true, false, NoScenarioAction::ListTable),
831            (false, false, true, NoScenarioAction::ListTable),
832            (false, false, false, NoScenarioAction::ListTable),
833            (true, true, true, NoScenarioAction::ListJson),
834            (true, true, false, NoScenarioAction::ListJson),
835            (true, false, true, NoScenarioAction::ListJson),
836            (true, false, false, NoScenarioAction::ListJson),
837        ];
838        for (json, stdout_tty, stdin_tty, expected) in cases {
839            assert_eq!(
840                no_scenario_action(json, stdout_tty, stdin_tty),
841                expected,
842                "json={json} stdout_tty={stdout_tty} stdin_tty={stdin_tty}"
843            );
844        }
845    }
846
847    #[test]
848    fn scenarios_have_unique_nonempty_ids() {
849        let mut seen = HashSet::new();
850        for s in SCENARIOS {
851            assert!(!s.id.is_empty(), "scenario id is empty");
852            assert!(!s.title.is_empty(), "scenario {} has empty title", s.id);
853            assert!(
854                !s.description.is_empty(),
855                "scenario {} has empty description",
856                s.id
857            );
858            assert!(!s.script.is_empty(), "scenario {} script is empty", s.id);
859            assert!(!s.tape.is_empty(), "scenario {} tape is empty", s.id);
860            assert!(seen.insert(s.id), "duplicate scenario id: {}", s.id);
861        }
862    }
863
864    #[test]
865    fn review_captain_prompt_templates_are_embedded() {
866        // Drift guard for the build.rs sibling-asset embedding + the
867        // review-captain migration to file-backed prompts. If the build script
868        // stops embedding sibling files (or the prompt files are renamed away),
869        // the demo's `render_prompt` calls would fail at runtime; catch it here.
870        for name in [
871            "review-captain/review.harn.prompt",
872            "review-captain/clarification.harn.prompt",
873        ] {
874            let found = DEMO_SIBLING_FILES
875                .iter()
876                .find(|(rel, _)| *rel == name)
877                .unwrap_or_else(|| panic!("demo sibling asset `{name}` was not embedded"));
878            assert!(!found.1.is_empty(), "embedded demo asset `{name}` is empty");
879        }
880    }
881
882    #[test]
883    fn embedded_sibling_files_exclude_primary_and_runrecord_files() {
884        // The primary script + tape are embedded separately via include_str!;
885        // the gitignored `.harn/` run-record dir must never be embedded.
886        for (rel, _) in DEMO_SIBLING_FILES {
887            assert!(
888                !rel.ends_with("/scenario.harn") && !rel.ends_with("/tape.jsonl"),
889                "primary file `{rel}` should not be in the sibling table"
890            );
891            assert!(
892                !rel.contains("/.harn/"),
893                "run-record file `{rel}` should not be embedded"
894            );
895        }
896    }
897
898    #[test]
899    fn scenario_tape_lines_parse_as_json() {
900        for s in SCENARIOS {
901            for (i, line) in s.tape.lines().enumerate() {
902                if line.trim().is_empty() {
903                    continue;
904                }
905                serde_json::from_str::<serde_json::Value>(line).unwrap_or_else(|e| {
906                    panic!("scenario {} tape line {} is invalid JSON: {e}", s.id, i + 1)
907                });
908            }
909        }
910    }
911
912    #[test]
913    fn scenario_ids_match_assets_dir_names() {
914        // Sanity: the const SCENARIOS array's ids should mirror the
915        // checked-in asset directories. If a developer adds a new
916        // scenario but forgets to wire it into SCENARIOS, this test
917        // does nothing — but if they rename an asset dir without
918        // updating the const, the include_str! at top will fail to
919        // compile, which is the better failure mode.
920        let manifest_dir = env!("CARGO_MANIFEST_DIR");
921        let assets = std::path::Path::new(manifest_dir).join("assets/demo");
922        for s in SCENARIOS {
923            let dir = assets.join(s.id);
924            assert!(dir.is_dir(), "missing demo asset dir for {}", s.id);
925            assert!(
926                dir.join("scenario.harn").is_file(),
927                "missing scenario.harn for {}",
928                s.id
929            );
930            assert!(
931                dir.join("tape.jsonl").is_file(),
932                "missing tape.jsonl for {}",
933                s.id
934            );
935        }
936    }
937}