harn-cli 0.7.59

CLI for the Harn programming language — run, test, REPL, format, and lint
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! `harn dump-trigger-quickref` — regenerate the LLM trigger quickref.
//!
//! The quickref is intentionally generated from the runtime provider catalog
//! so connector docs do not drift from `std/triggers::list_providers()`.

use std::fs;
use std::path::Path;
use std::process;

use harn_vm::{
    registered_provider_metadata, ProviderMetadata, ProviderRuntimeMetadata,
    SignatureVerificationMetadata,
};

struct FirstPartyConnectorPackage {
    provider: &'static str,
    package_url: &'static str,
    install: &'static str,
    package_gate: &'static str,
}

const FIRST_PARTY_CONNECTOR_PACKAGES: &[FirstPartyConnectorPackage] = &[
    FirstPartyConnectorPackage {
        provider: "GitHub",
        package_url: "https://github.com/burin-labs/harn-github-connector",
        install: "harn add github.com/burin-labs/harn-github-connector@v0.2.0",
        package_gate: "harn connector test . --provider github",
    },
    FirstPartyConnectorPackage {
        provider: "Slack",
        package_url: "https://github.com/burin-labs/harn-slack-connector",
        install: "harn add github.com/burin-labs/harn-slack-connector@v0.1.0",
        package_gate: "harn connector test . --provider slack",
    },
    FirstPartyConnectorPackage {
        provider: "Linear",
        package_url: "https://github.com/burin-labs/harn-linear-connector",
        install: "harn add github.com/burin-labs/harn-linear-connector@v0.1.0",
        package_gate: "harn connector test . --provider linear",
    },
    FirstPartyConnectorPackage {
        provider: "Notion",
        package_url: "https://github.com/burin-labs/harn-notion-connector",
        install: "harn add github.com/burin-labs/harn-notion-connector@v0.1.0",
        package_gate: "harn connector test . --provider notion --run-poll-tick",
    },
    FirstPartyConnectorPackage {
        provider: "GitLab",
        package_url: "https://github.com/burin-labs/harn-gitlab-connector",
        install: "harn add github.com/burin-labs/harn-gitlab-connector@v0.1.0",
        package_gate: "harn connector test . --provider gitlab",
    },
    FirstPartyConnectorPackage {
        provider: "Forgejo",
        package_url: "https://github.com/burin-labs/harn-forgejo-connector",
        install: "harn add github.com/burin-labs/harn-forgejo-connector@v0.1.0",
        package_gate: "harn connector test . --provider forgejo",
    },
    FirstPartyConnectorPackage {
        provider: "Gitea",
        package_url: "https://github.com/burin-labs/harn-gitea-connector",
        install: "harn add github.com/burin-labs/harn-gitea-connector@v0.1.0",
        package_gate: "harn connector test . --provider gitea",
    },
    FirstPartyConnectorPackage {
        provider: "Bitbucket",
        package_url: "https://github.com/burin-labs/harn-bitbucket-connector",
        install: "harn add github.com/burin-labs/harn-bitbucket-connector@v0.1.0",
        package_gate: "harn connector test . --provider bitbucket",
    },
    FirstPartyConnectorPackage {
        provider: "SourceHut",
        package_url: "https://github.com/burin-labs/harn-sourcehut-connector",
        install: "harn add github.com/burin-labs/harn-sourcehut-connector@v0.1.0",
        package_gate: "harn connector test . --provider sourcehut",
    },
    FirstPartyConnectorPackage {
        provider: "Subversion",
        package_url: "https://github.com/burin-labs/harn-svn-connector",
        install: "harn add github.com/burin-labs/harn-svn-connector@v0.1.0",
        package_gate: "harn connector test . --provider svn --run-poll-tick",
    },
];

pub(crate) fn run(output_path: &str, check_only: bool) {
    let generated = generate_file();
    let path = Path::new(output_path);

    if check_only {
        let existing = match fs::read_to_string(path) {
            Ok(s) => s,
            Err(e) => {
                eprintln!("error: cannot read {}: {e}", path.display());
                eprintln!("hint: run `make gen-trigger-quickref` to regenerate.");
                process::exit(1);
            }
        };
        if normalize_line_endings(&existing) != normalize_line_endings(&generated) {
            eprintln!(
                "error: {} is stale relative to the trigger provider catalog.",
                path.display()
            );
            eprintln!("hint: run `make gen-trigger-quickref` to regenerate.");
            process::exit(1);
        }
        return;
    }

    if let Some(parent) = path.parent() {
        if let Err(e) = fs::create_dir_all(parent) {
            eprintln!("error: cannot create {}: {e}", parent.display());
            process::exit(1);
        }
    }
    if let Err(e) = fs::write(path, &generated) {
        eprintln!("error: cannot write {}: {e}", path.display());
        process::exit(1);
    }
    println!("wrote {}", path.display());
}

fn generate_file() -> String {
    let mut providers = registered_provider_metadata();
    providers.sort_by(|a, b| a.provider.cmp(&b.provider));

    let mut out = String::new();
    out.push_str("# Harn Trigger Quick Reference (LLM-friendly)\n\n");
    out.push_str("<!-- GENERATED by `harn dump-trigger-quickref` -- do not edit by hand. -->\n");
    out.push_str("<!-- Sources of truth: crates/harn-vm/src/triggers/event.rs ProviderCatalog metadata and connector contract v1 docs. -->\n\n");
    out.push_str("<!-- markdownlint-disable MD013 -->\n\n");
    out.push_str(
        "**Canonical URL:** <https://harnlang.com/docs/llm/harn-triggers-quickref.html>\n\n",
    );
    out.push_str("Use this with `docs/llm/harn-quickref.md` when writing trigger, connector, or orchestrator code. It covers manifest shape, provider catalog metadata, the pure-Harn connector contract, and example-library commands.\n\n");
    out.push_str("## Trigger Manifest\n\n");
    out.push_str("```toml\n");
    out.push_str("[package]\nname = \"review-bot\"\n\n");
    out.push_str("[exports]\nhandlers = \"lib.harn\"\n\n");
    out.push_str("[[triggers]]\n");
    out.push_str("id = \"github-prs\"\nkind = \"webhook\"\nprovider = \"github\"\nmatch = { path = \"/hooks/github\", events = [\"pull_request.opened\"] }\nhandler = \"handlers::on_pull_request\"\nwhen = \"handlers::should_handle\"\ndedupe_key = \"event.dedupe_key\"\nretry = { max = 7, backoff = \"svix\" }\nbudget = { daily_cost_usd = 5.00, max_concurrent = 4 }\nsecrets = { signing_secret = \"github/webhook-secret\" }\n\n");
    out.push_str("[[triggers]]\nid = \"weekday-digest\"\nkind = \"cron\"\nprovider = \"cron\"\nschedule = \"0 9 * * 1-5\"\ntimezone = \"America/Los_Angeles\"\nhandler = \"handlers::send_digest\"\n```\n\n");
    out.push_str("Key fields: `id`, `kind`, `provider`, `handler`, `match.events`, `match.path`, `when`, `dedupe_key`, `retry`, `budget`, `secrets`, `schedule`, `timezone`, and provider-specific config tables such as `poll`.\n\n");

    out.push_str("## Provider Catalog\n\n");
    out.push_str("This table is generated from `std/triggers::list_providers()` / `ProviderCatalog` metadata.\n\n");
    out.push_str(
        "| Provider | Kinds | Schema | Runtime | Signature | Secrets | Outbound methods |\n",
    );
    out.push_str("|---|---|---|---|---|---|---|\n");
    for provider in &providers {
        out.push_str(&provider_row(provider));
    }

    out.push_str("\n## First-party Connector Packages\n\n");
    out.push_str("Provider business logic ships as pure-Harn packages. The Rust runtime keeps only core connector primitives such as webhook intake, cron, A2A push, and stream ingress.\n\n");
    out.push_str("| Provider | Package | Install | Package gate |\n");
    out.push_str("|---|---|---|---|\n");
    for package in FIRST_PARTY_CONNECTOR_PACKAGES {
        out.push_str(&format!(
            "| {} | <{}> | `{}` | `{}` |\n",
            package.provider, package.package_url, package.install, package.package_gate
        ));
    }
    out.push('\n');
    out.push_str("Community connectors are Harn packages that declare `connector_contract = \"v1\"` and export the connector functions below. Direct GitHub refs are enough for private or pre-registry packages; registry names such as `@burin/notion-connector` are for discoverable package-index entries.\n\n");

    out.push_str("## Connector Contract V1\n\n");
    out.push_str("Required exports for a pure-Harn connector package:\n\n");
    out.push_str("| Export | Required | Purpose |\n");
    out.push_str("|---|---:|---|\n");
    out.push_str(
        "| `provider_id() -> string` | Yes | Provider id, matching `[[providers]].id`. |\n",
    );
    out.push_str("| `kinds() -> list<string>` | Yes | Trigger kinds such as `webhook`, `poll`, `cron`, `a2a-push`, or `stream`. |\n");
    out.push_str("| `payload_schema() -> dict` | Yes | `{ harn_schema_name, json_schema? }`; the contract check rejects `{ name = ... }` drift. |\n");
    out.push_str("| `normalize_inbound(raw) -> dict` | Inbound | Returns `NormalizeResult` v1 for webhook-style input. |\n");
    out.push_str("| `poll_tick(ctx) -> dict` | Poll | Required when `kinds()` includes `poll`; returns events plus optional `cursor`/`state`. |\n");
    out.push_str("| `call(method, args) -> dict` | Outbound | Provider API escape hatch. Unknown probes may throw `method_not_found:<method>`. |\n");
    out.push_str("| `init(ctx)` | No | Receives event log, secrets, metrics, inbox, and rate-limit handles. |\n");
    out.push_str("| `activate(bindings)` | No | Runs on manifest activation/reload. |\n");
    out.push_str("| `shutdown()` | No | Cleanup on reload or process shutdown. |\n\n");
    out.push_str("`normalize_inbound(raw)` must return one of these tagged shapes: `{ type: \"event\", event }`, `{ type: \"batch\", events }`, `{ type: \"immediate_response\", immediate_response, event?, events? }`, or `{ type: \"reject\", status, body? }`. Direct legacy event dicts are transitional only; new packages should use the tagged shape.\n\n");
    out.push_str("Connector-only builtins available during connector export execution: `secret_get`, `event_log_emit`, and `metrics_inc`. The hot-path `normalize_inbound` effect policy rejects network calls, LLM calls, process execution, host calls, MCP calls, and ambient filesystem/project access.\n\n");
    out.push_str("Runtime scripts can observe EventLog topics directly with `event_log.subscribe({topic, from_cursor})`, which returns a `Stream<dict>` of `{id, cursor, topic, kind, payload, headers, occurred_at_ms}` records. Use `event_log.latest(topic)` before subscribing to tail new events only, or persist the `cursor` field to resume after a reconnect.\n\n");

    out.push_str("## Package Fixtures\n\n");
    out.push_str("Connector packages should declare deterministic fixtures in `harn.toml` and run them in CI:\n\n");
    out.push_str("```toml\n[connector_contract]\nversion = 1\n\n[[connector_contract.fixtures]]\nprovider = \"slack\"\nname = \"url verification\"\nkind = \"webhook\"\nheaders = { \"content-type\" = \"application/json\" }\nbody_json = { type = \"url_verification\", challenge = \"challenge-token\" }\nexpect_type = \"immediate_response\"\nexpect_event_count = 0\n```\n\n");
    out.push_str("Run `harn connector test .` locally. Use `--provider <id>` for a multi-provider package, `--run-poll-tick` to execute the first poll tick, and `--json` for CI output.\n\n");

    out.push_str("## Example Library\n\n");
    out.push_str("Ready-to-customize pipelines live under `examples/triggers/`. Each example includes `harn.toml`, `lib.harn`, `README.md`, and `SKILL.md` so it can be copied into a project or installed as a local skill bundle. Validate examples with `make check-trigger-examples`.\n\n");

    out.push_str("## Generic Webhook Intake Substrate\n\n");
    out.push_str(
        "Below the per-provider connectors lives a forge-agnostic intake substrate\n\
that any connector can wire into. It is the lowest-level entry point: a\n\
connector declares a path scope, a signature header + algorithm, a\n\
delivery-id header, and a topic; the substrate handles HMAC verification,\n\
delivery-id deduplication (durable across process restarts), and\n\
republishing onto the chosen topic. Per-forge event normalization lives in\n\
the connector that consumes the topic.\n\n",
    );
    out.push_str("Builtins:\n\n");
    out.push_str(
        "- `webhook_intake_register(config) -> dict` — register an intake. Returns\n  \
`{ id, path, topic, signature_header, signature_prefix,\n  signature_encoding, algorithm, delivery_id_header,\n  dedupe_ttl_seconds }`. Config keys:\n  \
- `id` (optional) — pin the intake id; one is generated if omitted.\n  \
- `path` (optional) — HTTP path scope. When set, `webhook_intake_feed`\n    rejects deliveries on a different path.\n  \
- `secret` (string or bytes, required) — HMAC key.\n  \
- `signature_header` (required) — e.g. `\"x-hub-signature-256\"`.\n  \
- `signature_prefix` — defaults to `\"<algorithm>=\"`. Pass `\"\"` to opt out.\n  \
- `signature_encoding` — `\"hex\"` (default) or `\"base64\"`.\n  \
- `algorithm` — `\"sha256\"` (default) or `\"sha1\"` (legacy).\n  \
- `delivery_id_header` (required) — e.g. `\"x-github-delivery\"`.\n  \
- `topic` (required) — event-log topic accepted deliveries are appended to.\n  \
- `dedupe_ttl_seconds` — defaults to 24h.\n\
- `webhook_intake_feed(intake_id, request) -> dict` — feed a delivery.\n  \
Request keys: `headers` (dict), `body` (string or bytes), optional `path`\n  \
and `received_at` (RFC3339). Returns `{ status, intake_id, topic,\n  \
delivery_id, topic_event_id, reason, received_at }`. `status` is\n  \
`\"accepted\"`, `\"duplicate\"`, or `\"rejected\"`.\n\
- `webhook_intake_recent(intake_id, limit?) -> list` — bounded replay\n  \
buffer. Reads the last `limit` accepted deliveries from the topic.\n\
- `webhook_intake_list() -> list` — all currently-registered intakes.\n\
- `webhook_intake_deregister(intake_id) -> bool` — remove an intake.\n\n",
    );
    out.push_str("A connector wires this in well under 30 lines:\n\n");
    out.push_str(
        "```harn\n\
import \"std/triggers\"\n\n\
let intake = webhook_intake_register({\n  \
id: \"github\",\n  \
path: \"/hooks/github\",\n  \
secret: secret_get(\"github/webhook-secret\"),\n  \
signature_header: \"x-hub-signature-256\",\n  \
delivery_id_header: \"x-github-delivery\",\n  \
topic: \"github.events\",\n\
})\n\n\
// In your inbound HTTP handler:\n\
let outcome = webhook_intake_feed(intake.id, {\n  \
headers: request.headers,\n  \
body: request.body,\n  \
path: request.path,\n\
})\n\
return { status: outcome.status == \"rejected\" ? 401 : 202 }\n\
```\n\n",
    );
    out.push_str("Rejections are appended to `triggers.webhook_intake.rejections` with the\nintake id and reason for audit. The substrate is agnostic to per-forge\nevent shape — connectors normalize the opaque payload after consuming the\ntopic.\n");
    out
}

fn normalize_line_endings(text: &str) -> String {
    text.replace("\r\n", "\n").replace('\r', "\n")
}

fn provider_row(provider: &ProviderMetadata) -> String {
    format!(
        "| `{}` | {} | `{}` | {} | {} | {} | {} |\n",
        provider.provider,
        comma_code(&provider.kinds),
        provider.schema_name,
        runtime_summary(&provider.runtime),
        signature_summary(&provider.signature_verification),
        secret_summary(provider),
        method_summary(provider),
    )
}

fn comma_code(values: &[String]) -> String {
    if values.is_empty() {
        "-".to_string()
    } else {
        values
            .iter()
            .map(|value| format!("`{value}`"))
            .collect::<Vec<_>>()
            .join(", ")
    }
}

fn runtime_summary(runtime: &ProviderRuntimeMetadata) -> String {
    match runtime {
        ProviderRuntimeMetadata::Builtin {
            connector,
            default_signature_variant,
        } => match default_signature_variant {
            Some(variant) => format!("builtin `{connector}` / `{variant}` signatures"),
            None => format!("builtin `{connector}`"),
        },
        ProviderRuntimeMetadata::Placeholder => "placeholder".to_string(),
    }
}

fn signature_summary(signature: &SignatureVerificationMetadata) -> String {
    match signature {
        SignatureVerificationMetadata::None => "none".to_string(),
        SignatureVerificationMetadata::Hmac {
            variant,
            signature_header,
            timestamp_header,
            id_header,
            default_tolerance_secs,
            digest,
            encoding,
            ..
        } => {
            let mut parts = vec![
                format!("HMAC `{variant}`"),
                format!("header `{signature_header}`"),
                format!("{digest}/{encoding}"),
            ];
            if let Some(header) = timestamp_header {
                parts.push(format!("ts `{header}`"));
            }
            if let Some(header) = id_header {
                parts.push(format!("id `{header}`"));
            }
            if let Some(tolerance) = default_tolerance_secs {
                parts.push(format!("{tolerance}s tolerance"));
            }
            parts.join(", ")
        }
    }
}

fn secret_summary(provider: &ProviderMetadata) -> String {
    if provider.secret_requirements.is_empty() {
        return "-".to_string();
    }
    provider
        .secret_requirements
        .iter()
        .map(|secret| {
            let required = if secret.required {
                "required"
            } else {
                "optional"
            };
            format!("`{}/{}` ({required})", secret.namespace, secret.name)
        })
        .collect::<Vec<_>>()
        .join(", ")
}

fn method_summary(provider: &ProviderMetadata) -> String {
    if provider.outbound_methods.is_empty() {
        "-".to_string()
    } else {
        provider
            .outbound_methods
            .iter()
            .map(|method| format!("`{}`", method.name))
            .collect::<Vec<_>>()
            .join(", ")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn generated_quickref_contains_catalog_and_contract() {
        let out = generate_file();
        assert!(out.contains("| `github` | `webhook` | `GitHubEventPayload` | placeholder |"));
        assert!(out.contains("Connector Contract V1"));
        assert!(out.contains("harn connector test ."));
        assert!(out.contains("harn-forgejo-connector"));
        assert!(out.contains("harn-svn-connector"));
    }

    #[test]
    fn committed_trigger_quickref_matches_generator() {
        let manifest_dir = env!("CARGO_MANIFEST_DIR");
        let path = std::path::Path::new(manifest_dir)
            .join("..")
            .join("..")
            .join("docs")
            .join("llm")
            .join("harn-triggers-quickref.md");
        let on_disk = std::fs::read_to_string(&path).unwrap_or_else(|e| {
            panic!(
                "failed to read {}: {e}\n\
                 hint: run `make gen-trigger-quickref` to regenerate.",
                path.display()
            )
        });
        let generated = generate_file();
        assert_eq!(
            normalize_line_endings(&on_disk),
            normalize_line_endings(&generated),
            "docs/llm/harn-triggers-quickref.md is stale relative to the trigger provider catalog.\n\
             Run `make gen-trigger-quickref` to regenerate."
        );
    }

    #[test]
    fn generated_quickref_comparison_ignores_platform_line_endings() {
        let generated = "# Harn Trigger Quick Reference\n\n| Provider |\n";
        let on_disk = generated.replace('\n', "\r\n");

        assert_eq!(
            normalize_line_endings(&on_disk),
            normalize_line_endings(generated)
        );
    }
}