@fiscalmindset/blindfold 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/SKILL.md CHANGED
@@ -14,11 +14,12 @@ You're working in (or with) Blindfold — a Terminal 3 TDX-enclave wrapper that
14
14
  - User asks about `.env`, sealing, vaulting, or "where do I put my key".
15
15
  - You're about to write code that would read `process.env.SOME_PROVIDER_KEY` for an outbound call.
16
16
  - User says "use Blindfold" or "seal it" or "make this safe".
17
+ - User has no Terminal 3 tenant yet ("how do I start", "I don't have an account/DID", `doctor` shows creds MISSING) → point them at self-serve `blindfold signup` (see below).
17
18
 
18
19
  ## The four rules (mirror of `usage_by_claude.md` §3)
19
20
 
20
- 1. **R1 — no-paste-into-chat.** If a new secret needs sealing, *propose* the command (`npm run blindfold -- register --name <KV_KEY>`) for the user to run in their own terminal. Do NOT ask them to paste the value into chat. Only use `printf 'VALUE' | ...` from chat as a fallback, and only after the user explicitly says "go ahead from here".
21
- 2. **R2 — verify by fingerprint, never by value.** To check what's sealed: `npm run blindfold -- sealed`. To check what's in `.env`: `npm run env:fingerprint`. To check a specific sealed key matches an expected value: `npx tsx scripts/test-v5-release.ts <secret_name>` (prints `first3…last2 (N bytes)`, never plaintext). Ask the user to paste the *output* of those commands — that's safe.
21
+ 1. **R1 — no-paste-into-chat.** If a new secret needs sealing, *propose* the command (`blindfold register --name <KV_KEY>`) for the user to run in their own terminal. Do NOT ask them to paste the value into chat. Only use `printf 'VALUE' | ...` from chat as a fallback, and only after the user explicitly says "go ahead from here".
22
+ 2. **R2 — verify by fingerprint, never by value.** To check what's sealed: `blindfold sealed`. To check what's in `.env`: `npm run env:fingerprint`. To check a specific sealed key matches an expected value: `npx tsx scripts/test-v5-release.ts <secret_name>` (prints `first3…last2 (N bytes)`, never plaintext). Ask the user to paste the *output* of those commands — that's safe.
22
23
  3. **R3 — code uses release-broker pattern.** Any code you write that needs a provider key must fetch it from T3 just-in-time via `tenant.contracts.execute("blindfold-proxy", { version: CONTRACT_VERSION, functionName: "release-to-tenant", input: { secret_key: "<name>" } })`, use it inside a `try { … } finally { /* dropped */ }`, and never reference `process.env.<provider>_API_KEY`. Reference templates: `examples/grok-via-blindfold.ts` (HTTPS) and `scripts/smtp-with-blindfold.ts` (non-HTTP).
23
24
  4. **R4 — propose `.env` cleanup after every successful seal.** The sealed copy is canonical; the `.env` copy is leak surface. *Exception:* `T3N_API_KEY` itself stays in `.env` — it's the root credential, can't be sealed (chicken-and-egg).
24
25
 
@@ -26,19 +27,22 @@ You're working in (or with) Blindfold — a Terminal 3 TDX-enclave wrapper that
26
27
 
27
28
  | Command | Purpose | Safe to paste output? |
28
29
  |---|---|---|
29
- | `npm run blindfold -- doctor` | mode + cred presence (yes/no) | |
30
- | `npm run blindfold -- verify` | T3 round-trip status | ✅ |
31
- | `npm run blindfold -- login` | store tenant creds in `~/.blindfold` (OS keychain for the key) — works from any dir | tell user to run it (prompts for key) |
32
- | `npm run blindfold -- whoami` | show config path, tenant, env, key source (never the value) | ✅ |
33
- | `npm run blindfold -- logout` | remove stored creds (keychain + `~/.blindfold/config.json`) | ✅ |
34
- | `npm run blindfold -- sealed` | sealed-keys ledger (metadata only, LOCAL) | |
35
- | `npm run blindfold -- audit` | reconcile ledger against the ENCLAVE what's actually usable now | ✅ |
36
- | `npm run blindfold -- status` | one-glance: mode, tenant health, sealed list | ✅ |
30
+ | `blindfold signup --email <addr>` | self-serve: mint a new testnet tenant (key local, email-verified) | tell user to run it (prompts for the emailed code) |
31
+ | `blindfold doctor` | mode + cred presence (yes/no) | ✅ |
32
+ | `blindfold verify` | T3 round-trip status | |
33
+ | `blindfold credit` | tenant token/credit balance | ✅ |
34
+ | `blindfold attest [--pin]` | verify enclave TDX attestation; `--pin` gates seal/proxy on the code measurement | ✅ |
35
+ | `blindfold login` | store tenant creds in `~/.blindfold` (OS keychain for the key) — works from any dir | tell user to run it (prompts for key) |
36
+ | `blindfold whoami` | show config path, tenant, env, key source (never the value) | ✅ |
37
+ | `blindfold logout` | remove stored creds (keychain + `~/.blindfold/config.json`) | ✅ |
38
+ | `blindfold sealed` | sealed-keys ledger (metadata only, LOCAL) | ✅ |
39
+ | `blindfold audit` | reconcile ledger against the ENCLAVE — what's actually usable now | ✅ |
40
+ | `blindfold status` | one-glance: mode, tenant health, sealed list | ✅ |
37
41
  | `npm run env:fingerprint` | `.env` lines as `KEY = first3…last2 (N bytes)` | ✅ |
38
42
  | `npx tsx scripts/test-v5-release.ts <name>` | fingerprint of the released value | ✅ |
39
43
  | `npm run dashboard` | live HTML dashboard at `http://127.0.0.1:8799` | n/a (UI) |
40
- | `npm run blindfold -- register --name <K>` | interactive seal (no echo) | ⚠ tell user to run in their terminal |
41
- | `printf 'V' \| npm run blindfold -- register --name <K>` | piped seal (value briefly in this process) | ⚠ only if user already pasted value |
44
+ | `blindfold register --name <K>` | interactive seal (no echo) | ⚠ tell user to run in their terminal |
45
+ | `printf 'V' \| blindfold register --name <K>` | piped seal (value briefly in this process) | ⚠ only if user already pasted value |
42
46
 
43
47
  ## Seal once, use forever (no re-seal per session)
44
48
 
@@ -51,16 +55,21 @@ To **use** already-sealed keys, a session needs three persistent things (all
51
55
  survive across sessions — none require re-sealing):
52
56
 
53
57
  1. **Tenant creds** — `T3N_API_KEY` + `DID`. These authenticate to the enclave
54
- and are what release/proxy require. Two ways to provide them:
58
+ and are what release/proxy require. Ways to provide them:
59
+ - **`blindfold signup --email you@x.com`** (self-serve, the fastest start):
60
+ mints a brand-new Terminal 3 **testnet** tenant with no manual token claim —
61
+ generates the tenant key locally (→ OS keychain, never printed), verifies the
62
+ email by an emailed code, self-admits, and mints welcome credits. One email
63
+ binds to one tenant. Use this when the user has no tenant yet.
55
64
  - **Repo `.env`** (dev). Exception to R4: they stay in `.env`; see R4.
56
- - **`blindfold login`** (v0.2+, product path): stores DID + settings in
65
+ - **`blindfold login`** (v0.2+, product path, for an EXISTING tenant): stores DID + settings in
57
66
  `~/.blindfold/config.json` and the tenant key in the **OS keychain** (v0.3;
58
67
  macOS Keychain / Linux secret-tool) — so the CLI works from any directory
59
68
  and the key isn't a readable file. Precedence: `process.env` > repo `.env` >
60
69
  `~/.blindfold`. State (ledger/usage/egress) also lives in `~/.blindfold`.
61
70
  2. **Egress already granted** for the host you'll call (`blindfold grant --host
62
71
  <host>`). The grant is per-tenant and persistent.
63
- 3. **The proxy running** if using the HTTP path: `npm run blindfold -- proxy`
72
+ 3. **The proxy running** if using the HTTP path: `blindfold proxy`
64
73
  (listens on `127.0.0.1:8787`). Point the tool at it with base URL
65
74
  `http://127.0.0.1:8787/v1` and key `__BLINDFOLD__`. (Code paths use the
66
75
  release-broker instead — see R3.) On a shared machine, run `proxy --auth`:
@@ -76,6 +85,24 @@ re-sealing.
76
85
 
77
86
  ## What to do, by scenario
78
87
 
88
+ ### "I'm new / I don't have a Terminal 3 tenant yet" (onboarding)
89
+
90
+ Blindfold is published on npm and onboarding is self-serve — no manual token claim.
91
+ Propose (the user runs it in their own terminal):
92
+
93
+ ```bash
94
+ npm i -g @fiscalmindset/blindfold
95
+ blindfold signup --email you@example.com
96
+ ```
97
+
98
+ `signup` generates the tenant key locally (→ OS keychain, never printed),
99
+ verifies the email by an emailed code, self-admits a funded **testnet** tenant,
100
+ and mints welcome credits. Then confirm with `blindfold doctor` (should show the
101
+ tenant `active`) and `blindfold credit` (token balance). After that they can
102
+ `register` and `use` keys. Notes: testnet-only; one email = one tenant (Gmail
103
+ `+aliases` give fresh identities); if the email is already registered, use
104
+ `blindfold login` with that tenant's key instead.
105
+
79
106
  ### "I want to seal my Stripe key" (or any new credential)
80
107
 
81
108
  **Don't ask for the value.** Respond with:
@@ -83,10 +110,10 @@ re-sealing.
83
110
  > In your own terminal, run:
84
111
  >
85
112
  > ```bash
86
- > npm run blindfold -- register --name stripe_api_key
113
+ > blindfold register --name stripe_api_key
87
114
  > ```
88
115
  >
89
- > It'll prompt for the value with input hidden (no echo, no shell history). Paste your `sk_live_…` there, press Enter. Once done, paste me the output of `npm run blindfold -- sealed` so I can verify it landed in the right place.
116
+ > It'll prompt for the value with input hidden (no echo, no shell history). Paste your `sk_live_…` there, press Enter. Once done, paste me the output of `blindfold sealed` so I can verify it landed in the right place.
90
117
 
91
118
  ### "Seal / use a webhook URL (Discord, Slack, …)"
92
119
 
@@ -126,7 +153,7 @@ end**; only the final `register` (plaintext) is handed back to the user.
126
153
  ```
127
154
  2. Rebuild (`npm run build` in `packages/blindfold`) so `dist/` picks up the entry.
128
155
  3. Hand back the two one-time human steps: seal the key —
129
- `npm run blindfold -- register --name notion_api_key` (in their terminal, R1) —
156
+ `blindfold register --name notion_api_key` (in their terminal, R1) —
130
157
  and grant egress: `blindfold grant --host api.notion.com`.
131
158
  4. The agent then calls `http://127.0.0.1:8787/notion/...` with
132
159
  `Authorization: Bearer __BLINDFOLD__`; the enclave swaps in the sealed key.
@@ -139,7 +166,7 @@ auth scheme none of the four cover — otherwise this stays a TypeScript-only ch
139
166
  It's in our chat context now (can't undo). Reduce future surface:
140
167
 
141
168
  ```bash
142
- printf 'sk-...' | npx tsx packages/blindfold/bin/blindfold.ts register --name openai_api_key
169
+ printf 'sk-...' | blindfold register --name openai_api_key
143
170
  ```
144
171
 
145
172
  Then propose deleting any `.env` copy. Note in your response that the chat-context exposure already happened and you can't retroactively fix it — only forward-protect.
@@ -174,9 +201,9 @@ Ask the user to paste that output. You get key *names* + lengths + first/last fe
174
201
  Run these in order, asking the user to paste each output:
175
202
 
176
203
  ```bash
177
- npm run blindfold -- doctor # config sanity
178
- npm run blindfold -- verify # T3 reachability
179
- npm run blindfold -- sealed # is the key there?
204
+ blindfold doctor # config sanity
205
+ blindfold verify # T3 reachability
206
+ blindfold sealed # is the key there?
180
207
  ```
181
208
 
182
209
  Cross-reference any errors with `vicky.md` Q6 (keyword-indexed error table).
package/bin/blindfold.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * helpers in ./cli-shared.ts. Every action prints what it did, never a secret.
5
5
  */
6
6
  import { type Argv, parseArgv } from "./cli-shared.ts";
7
- import { c, head } from "../src/color.ts";
7
+ import { c, bad } from "../src/color.ts";
8
+ import { bannerBox, commandBox, nearest } from "../src/tui.ts";
8
9
  import { handleAuth } from "./cmd-auth.ts";
9
10
  import { handleSecrets } from "./cmd-secrets.ts";
10
11
  import { handleLifecycle } from "./cmd-lifecycle.ts";
@@ -36,52 +37,89 @@ async function main(): Promise<void> {
36
37
  const cmd = argv._[0] ?? "help";
37
38
  const handler = ROUTES[cmd];
38
39
  if (handler) await handler(cmd, argv, cmdArgs);
39
- else printHelp();
40
+ else if (cmd === "help" || cmd === "--help" || cmd === "-h") printHelp();
41
+ else printUnknown(cmd);
40
42
  }
41
43
 
42
- function printHelp(): void {
43
- console.log(`${head("🛡️ Blindfold")} ${c.gray("— protect your AI agent's API keys with Terminal 3 enclaves.")}
44
-
45
- ${c.bold("Commands:")}
46
- signup [--email <you@x.com>] Self-serve: create a Terminal 3 testnet tenant from scratch. Generates a key locally, verifies your email by code, and mints welcome credits — no manual provisioning. Then run doctor + register.
47
- init [--seed KV:ENV]... [--start] One-command zero-knowledge setup. Walks through .env, build, auth, publish, seed; can auto-launch the proxy.
48
- verify Handshake + auth against T3 (smoke test).
49
- compat [--json] Scan this machine for AI agent tools/SDKs and print the exact env-var swap for each.
50
- register --name <KV_KEY> [--from-env <ENV_VAR>] Seal a secret into the enclave (one-time). With --from-env: reads process.env. Without: prompts the terminal with no echo (preferred — never touches disk/history). Also accepts piped stdin.
51
- use --name <secret> [--as <ENV>] -- <cmd> USE a sealed secret: release it and run <cmd> with it injected as $ENV for that command only — never back in your env. --as is auto-detected for known tools (gh→GH_TOKEN, psql→PGPASSWORD, …). Or --url <https> for a quick auth check.
52
- rotate --name <secret> [--from-env <ENV_VAR>] Replace a sealed secret's value (snapshots the old value for rollback; shows before/after fingerprints, never the value).
53
- export --name <secret> [--as <ENV_VAR>] CI-only: release a sealed secret into $GITHUB_ENV for later steps (masked in logs). Used by the Blindfold GitHub Action.
54
- rollback --name <secret> [--to <fp|iso-ts>] Restore a previous value snapshotted by rotate (most recent by default).
55
- versions [--name <secret>] List the snapshots available to roll back to (metadata only).
56
- migrate [--dry-run] [--keep] Seal EVERY secret in your .env in one shot, then remove the plaintext lines (backup kept). --dry-run previews; --keep comments lines instead of deleting. Skips T3 creds + config.
57
- status One-glance overview: mode, tenant health, and the list of sealed secrets.
58
- sealed List sealed keys — metadata only (name, byte-length, when, where). Never the value.
59
- audit Verify the ledger's tamper-evident hash-chain AND reconcile it against the enclave (the source of truth) — flags drift/missing/tampering.
60
- proxy [--port 8787] [--auth] [--socket [path]] Run the local OpenAI-shaped proxy. --auth mints a per-session token; --socket binds a 0600 unix socket (only your OS user can connect).
61
- attest [--expect-rtmr3 <b64>] [--pin] [--json] Verify the enclave's TDX attestation (chains to Intel's root CA). --pin records the RTMR3 so seal/proxy auto-verify it first.
62
- publish [--wasm path/to/blindfold_proxy.wasm] Publish the Rust→WASM contract (one-time).
63
- grant --host <host>[,<host2>...] Authorize the contract to call these hosts (required before the proxy / in-enclave path can reach them). E.g. --host api.openai.com
64
- share --to <agent-did> --host <host>[,...] Let a teammate's agent USE your sealed keys for those hosts via the enclave — they never receive the plaintext (forward only, least privilege).
65
- revoke --to <agent-did> Remove a teammate's access. Immediate and complete — nobody holds a raw key copy.
66
-
67
- skill install [--global|--cursor|--opencode|--cline|--all] Install the Blindfold agent skill so your coding agent handles secrets safely. Default: this project.
68
- skill uninstall Remove all installed skill files.
69
-
70
- dashboard [--port 8799] Live HTML dashboard of proxy usage.
71
- stats CLI summary of proxy usage.
72
- stats:clear Wipe the usage log.
73
- doctor Show current mode + config.
74
- credit [--json] Show the tenant's Terminal 3 token/credit balance (no credit cost).
75
- update [--from <path>] Update the global blindfold (from npm, or a local repo with --from).
44
+ /** Unknown command: a concise error with a "did you mean" suggestion — not the
45
+ * full help dump (which used to appear on any typo). */
46
+ function printUnknown(cmd: string): void {
47
+ const all = [...Object.keys(ROUTES), "help"];
48
+ const guess = nearest(cmd, all);
49
+ console.error(
50
+ bad(`✖ Unknown command: ${cmd}`) +
51
+ (guess ? ` ${c.gray("— did you mean")} ${c.cyan(guess)}${c.gray("?")}` : ""),
52
+ );
53
+ console.error(c.gray(" Run ") + c.cyan("blindfold help") + c.gray(" to see all commands."));
54
+ process.exit(1);
55
+ }
76
56
 
77
- The friendliest path is just: blindfold init
57
+ function printHelp(): void {
58
+ const groups: Array<[string, Array<[string, string]>]> = [
59
+ ["🚀 Get started", [
60
+ ["signup", "Self-serve: mint a funded Terminal 3 testnet tenant (key generated locally, email-verified)."],
61
+ ["init", "Guided zero-knowledge setup: .env, build, auth, publish, seed; can auto-start the proxy."],
62
+ ["doctor", "Show mode + config and run a live tenant health check."],
63
+ ["credit", "Show the tenant's Terminal 3 token balance (costs nothing)."],
64
+ ["verify", "Handshake + authenticate against T3 (smoke test)."],
65
+ ]],
66
+ ["🔑 Secrets", [
67
+ ["register", "Seal a secret into the enclave (hidden prompt; never touches disk)."],
68
+ ["use", "Release a sealed secret into one command as $ENV — never back in your env."],
69
+ ["export", "CI: release a sealed secret into $GITHUB_ENV (masked in logs)."],
70
+ ["rotate", "Replace a sealed secret's value (snapshots the old one for rollback)."],
71
+ ["rollback", "Restore a previous value snapshotted by rotate."],
72
+ ["versions", "List the snapshots available to roll back to (metadata only)."],
73
+ ["migrate", "Seal every secret in .env at once, then remove the plaintext lines."],
74
+ ]],
75
+ ["🌐 Proxy & serve", [
76
+ ["proxy", "Run the local sentinel proxy. --auth mints a session token; --socket binds a 0600 unix socket."],
77
+ ["attest", "Verify the enclave's TDX attestation (Intel root CA). --pin gates seal/proxy on the code measurement."],
78
+ ["dashboard", "Live HTML dashboard of proxy usage (default :8799)."],
79
+ ["stats", "CLI summary of proxy usage (stats:clear wipes it)."],
80
+ ]],
81
+ ["👥 Team & sharing", [
82
+ ["grant", "Authorize the contract to call these hosts (e.g. --host api.openai.com)."],
83
+ ["share", "Let a teammate's agent USE your sealed keys for a host — forward only, no plaintext."],
84
+ ["revoke", "Remove a teammate's access — immediate and complete."],
85
+ ]],
86
+ ["📦 Enclave & admin", [
87
+ ["publish", "Publish the Rust→WASM contract to your tenant (one-time)."],
88
+ ["status", "One-glance: mode, tenant health, and sealed secrets."],
89
+ ["sealed", "List sealed keys — metadata only, never the value."],
90
+ ["audit", "Verify the ledger hash-chain and reconcile it against the enclave."],
91
+ ["compat", "Scan this machine for AI agent tools + print the env-var swap for each."],
92
+ ["update", "Update the global install (from npm, or --from <repo>)."],
93
+ ]],
94
+ ["👤 Account", [
95
+ ["login", "Store existing Terminal 3 credentials (key → OS keychain)."],
96
+ ["logout", "Remove stored credentials."],
97
+ ["whoami", "Show tenant, env, and key source (never the value)."],
98
+ ]],
99
+ ["🤖 Agent skill", [
100
+ ["skill", "install [--global|--cursor|--opencode|--cline|--all] / uninstall — the agent skill for your coding agent."],
101
+ ]],
102
+ ];
78
103
 
79
- Quick start:
80
- 1) ./scripts/build-contract.sh # build the Rust contract (REAL mode only)
81
- 2) blindfold publish # register the contract on T3
82
- 3) blindfold register --name openai_api_key --from-env OPENAI_API_KEY
83
- 4) blindfold proxy # then point your agent at it
84
- `);
104
+ const out: string[] = [
105
+ "",
106
+ bannerBox("🛡️ Blindfold", "Protect your AI agent's API keys with Terminal 3 enclaves. The agent only ever holds a placeholder — the real key is substituted inside the TDX enclave."),
107
+ "",
108
+ ];
109
+ for (const [title, rows] of groups) {
110
+ out.push(commandBox(title, rows));
111
+ out.push("");
112
+ }
113
+ out.push(
114
+ c.bold("Quick start"),
115
+ ` ${c.cyan("blindfold signup --email you@x.com")} ${c.gray("# create a funded testnet tenant")}`,
116
+ ` ${c.cyan("blindfold register --name openai_api_key")}`,
117
+ ` ${c.cyan("blindfold proxy")} ${c.gray("# point your agent at http://127.0.0.1:8787")}`,
118
+ "",
119
+ `${c.gray("Docs:")} ${c.cyan("https://www.npmjs.com/package/@fiscalmindset/blindfold")} ${c.gray("·")} ${c.gray("Run")} ${c.cyan("blindfold <command> --help")} ${c.gray("for details")}`,
120
+ "",
121
+ );
122
+ console.log(out.join("\n"));
85
123
  }
86
124
 
87
125
  main().catch((e) => {
package/dist/cli.mjs CHANGED
@@ -1374,6 +1374,110 @@ function die(msg) {
1374
1374
  process.exit(2);
1375
1375
  }
1376
1376
 
1377
+ // src/tui.ts
1378
+ function termWidth() {
1379
+ const cols = process.stdout.columns;
1380
+ if (typeof cols === "number" && cols >= 40) return Math.min(cols, 100);
1381
+ const env = Number(process.env.COLUMNS);
1382
+ if (Number.isFinite(env) && env >= 40) return Math.min(env, 100);
1383
+ return 80;
1384
+ }
1385
+ var ANSI = /\x1b\[[0-9;]*m/g;
1386
+ function isWide(cp) {
1387
+ return cp >= 4352 && cp <= 4447 || cp >= 9728 && cp <= 10175 || cp >= 11008 && cp <= 11263 || cp >= 11904 && cp <= 42191 || cp >= 44032 && cp <= 55203 || cp >= 63744 && cp <= 64255 || cp >= 65072 && cp <= 65103 || cp >= 65280 && cp <= 65376 || cp >= 65504 && cp <= 65510 || cp >= 126976 && cp <= 129791;
1388
+ }
1389
+ function vlen(s) {
1390
+ let w = 0;
1391
+ for (const ch of s.replace(ANSI, "")) {
1392
+ const cp = ch.codePointAt(0) ?? 0;
1393
+ if (cp === 8205 || cp >= 65024 && cp <= 65039 || cp >= 768 && cp <= 879) continue;
1394
+ w += isWide(cp) ? 2 : 1;
1395
+ }
1396
+ return w;
1397
+ }
1398
+ function padEnd(s, w) {
1399
+ return s + " ".repeat(Math.max(0, w - vlen(s)));
1400
+ }
1401
+ function wrapText(text, width) {
1402
+ if (width <= 4) return [text];
1403
+ const out = [];
1404
+ for (const para of text.split("\n")) {
1405
+ const words = para.split(/\s+/).filter(Boolean);
1406
+ let cur = "";
1407
+ for (const w of words) {
1408
+ if (cur === "") cur = w;
1409
+ else if (vlen(cur) + 1 + vlen(w) <= width) cur += " " + w;
1410
+ else {
1411
+ out.push(cur);
1412
+ cur = w;
1413
+ }
1414
+ }
1415
+ out.push(cur);
1416
+ }
1417
+ return out.length ? out : [""];
1418
+ }
1419
+ var B = { tl: "\u256D", tr: "\u256E", bl: "\u2570", br: "\u256F", h: "\u2500", v: "\u2502" };
1420
+ var dim = (s) => c.gray(s);
1421
+ function bannerBox(title, subtitle) {
1422
+ const w = termWidth();
1423
+ const inner = w - 4;
1424
+ const top = dim(B.tl + B.h.repeat(w - 2) + B.tr);
1425
+ const bot = dim(B.bl + B.h.repeat(w - 2) + B.br);
1426
+ const row = (s) => dim(B.v) + " " + padEnd(s, inner) + " " + dim(B.v);
1427
+ const lines = [top, row(c.bold(title))];
1428
+ for (const sl of wrapText(subtitle, inner)) lines.push(row(dim(sl)));
1429
+ lines.push(bot);
1430
+ return lines.join("\n");
1431
+ }
1432
+ function commandBox(title, rows) {
1433
+ const w = termWidth();
1434
+ const inner = w - 4;
1435
+ const gap = 2;
1436
+ const longest = rows.reduce((m, [cmd]) => Math.max(m, vlen(cmd)), 0);
1437
+ const cmdW = Math.min(longest, Math.max(10, Math.floor(inner * 0.34)));
1438
+ const descW = Math.max(12, inner - cmdW - gap);
1439
+ const prefix = B.tl + B.h + " ";
1440
+ const fill = Math.max(
1441
+ 0,
1442
+ w - vlen(prefix) - vlen(title) - 1 - 1
1443
+ /* tr */
1444
+ );
1445
+ const out = [dim(prefix) + c.bold(title) + dim(" " + B.h.repeat(fill) + B.tr)];
1446
+ for (const [cmd, desc] of rows) {
1447
+ const dLines = wrapText(desc, descW);
1448
+ const cmdCell = padEnd(c.cyan(cmd), cmdW);
1449
+ out.push(dim(B.v) + " " + cmdCell + " ".repeat(gap) + padEnd(dLines[0] ?? "", descW) + " " + dim(B.v));
1450
+ for (let i = 1; i < dLines.length; i++) {
1451
+ out.push(dim(B.v) + " " + " ".repeat(cmdW + gap) + padEnd(dim(dLines[i] ?? ""), descW) + " " + dim(B.v));
1452
+ }
1453
+ }
1454
+ out.push(dim(B.bl + B.h.repeat(w - 2) + B.br));
1455
+ return out.join("\n");
1456
+ }
1457
+ function nearest(input, candidates) {
1458
+ let best = null;
1459
+ let bestD = Infinity;
1460
+ for (const cand of candidates) {
1461
+ const d = editDistance(input, cand);
1462
+ if (d < bestD) {
1463
+ bestD = d;
1464
+ best = cand;
1465
+ }
1466
+ }
1467
+ return bestD <= Math.max(2, Math.floor(input.length / 3)) ? best : null;
1468
+ }
1469
+ function editDistance(a, b) {
1470
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
1471
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j;
1472
+ for (let i = 1; i <= a.length; i++) {
1473
+ for (let j = 1; j <= b.length; j++) {
1474
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1475
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
1476
+ }
1477
+ }
1478
+ return dp[a.length][b.length];
1479
+ }
1480
+
1377
1481
  // bin/cmd-auth.ts
1378
1482
  init_env();
1379
1483
  init_keychain();
@@ -3368,11 +3472,11 @@ var bold = (s) => colour("1", s);
3368
3472
  var green = (s) => colour("32", s);
3369
3473
  var yellow = (s) => colour("33", s);
3370
3474
  var red = (s) => colour("31", s);
3371
- var dim = (s) => colour("2", s);
3475
+ var dim2 = (s) => colour("2", s);
3372
3476
  var cyan = (s) => colour("36", s);
3373
3477
  function header(n, total, title) {
3374
3478
  process.stdout.write(`
3375
- ${dim(`[${n}/${total}]`)} ${bold(title)}
3479
+ ${dim2(`[${n}/${total}]`)} ${bold(title)}
3376
3480
  `);
3377
3481
  }
3378
3482
  function ok2(line) {
@@ -3380,7 +3484,7 @@ function ok2(line) {
3380
3484
  `);
3381
3485
  }
3382
3486
  function info(line) {
3383
- process.stdout.write(` ${dim("\xB7")} ${line}
3487
+ process.stdout.write(` ${dim2("\xB7")} ${line}
3384
3488
  `);
3385
3489
  }
3386
3490
  function warn2(line) {
@@ -3391,7 +3495,7 @@ function fail(line, fixHint) {
3391
3495
  process.stdout.write(` ${red("\u2716")} ${line}
3392
3496
  `);
3393
3497
  if (fixHint) {
3394
- for (const ln of fixHint.split("\n")) process.stdout.write(` ${dim("\u2192")} ${cyan(ln)}
3498
+ for (const ln of fixHint.split("\n")) process.stdout.write(` ${dim2("\u2192")} ${cyan(ln)}
3395
3499
  `);
3396
3500
  }
3397
3501
  }
@@ -3422,7 +3526,7 @@ async function runInit(opts = {}) {
3422
3526
  const total = 5;
3423
3527
  process.stdout.write(`
3424
3528
  ${bold("\u{1F6E1}\uFE0F Blindfold \u2014 first-time setup")}
3425
- ${dim("This wizard sets up REAL T3 mode end-to-end. Secrets are read from your .env, never typed onto the command line.")}
3529
+ ${dim2("This wizard sets up REAL T3 mode end-to-end. Secrets are read from your .env, never typed onto the command line.")}
3426
3530
  `);
3427
3531
  header(1, total, "Preflight");
3428
3532
  await ensureEnvOrPrompt(opts);
@@ -3432,7 +3536,7 @@ ${dim("This wizard sets up REAL T3 mode end-to-end. Secrets are read from your .
3432
3536
  fail("Still missing T3N_API_KEY and/or DID after .env walkthrough.", "Open .env, paste the two values, and re-run `npm run setup`.");
3433
3537
  throw new Error("preflight failed");
3434
3538
  }
3435
- ok2(`T3 ${env.t3Env} \xB7 tenant ${dim(env.did)}`);
3539
+ ok2(`T3 ${env.t3Env} \xB7 tenant ${dim2(env.did)}`);
3436
3540
  const haveSdk = await isSdkInstalled2();
3437
3541
  if (!haveSdk) {
3438
3542
  fail("@terminal3/t3n-sdk is not installed.", "Run: npm install @terminal3/t3n-sdk");
@@ -3544,7 +3648,7 @@ Make sure ${fromEnv} is set in .env (you can delete it after sealing).`);
3544
3648
  ${green(bold("\u2713 All done."))}
3545
3649
  `);
3546
3650
  if (opts.start) {
3547
- process.stdout.write(`${dim("Starting the proxy now (Ctrl+C to stop) \u2026")}
3651
+ process.stdout.write(`${dim2("Starting the proxy now (Ctrl+C to stop) \u2026")}
3548
3652
  `);
3549
3653
  const proxy = spawn2(process.execPath, [process.argv[1] ?? "", "proxy"], {
3550
3654
  cwd: REPO_ROOT2,
@@ -3553,15 +3657,15 @@ ${green(bold("\u2713 All done."))}
3553
3657
  proxy.on("exit", (code) => process.exit(code ?? 0));
3554
3658
  return;
3555
3659
  }
3556
- process.stdout.write(`${dim("Next:")}
3660
+ process.stdout.write(`${dim2("Next:")}
3557
3661
  `);
3558
- process.stdout.write(` ${cyan("npm run blindfold -- proxy")} ${dim("# leave this running")}
3662
+ process.stdout.write(` ${cyan("npm run blindfold -- proxy")} ${dim2("# leave this running")}
3559
3663
  `);
3560
- process.stdout.write(` ${cyan("npm run dashboard")} ${dim("# open http://127.0.0.1:8799")}
3664
+ process.stdout.write(` ${cyan("npm run dashboard")} ${dim2("# open http://127.0.0.1:8799")}
3561
3665
  `);
3562
- process.stdout.write(` ${dim("Then point your agent at:")} ${cyan("OPENAI_BASE_URL=http://127.0.0.1:8787/v1 OPENAI_API_KEY=__BLINDFOLD__")}
3666
+ process.stdout.write(` ${dim2("Then point your agent at:")} ${cyan("OPENAI_BASE_URL=http://127.0.0.1:8787/v1 OPENAI_API_KEY=__BLINDFOLD__")}
3563
3667
  `);
3564
- process.stdout.write(`${dim("Or re-run with")} ${cyan("--start")} ${dim("to launch the proxy automatically.")}
3668
+ process.stdout.write(`${dim2("Or re-run with")} ${cyan("--start")} ${dim2("to launch the proxy automatically.")}
3565
3669
  `);
3566
3670
  }
3567
3671
  async function ensureEnvOrPrompt(opts) {
@@ -3673,7 +3777,7 @@ ${bold("\u{1F6E1}\uFE0F Blindfold \u2014 verify")}
3673
3777
  warn2("MOCK mode \u2014 there's nothing to verify on the T3 side. Set T3N_API_KEY + DID.");
3674
3778
  return;
3675
3779
  }
3676
- process.stdout.write(` ${dim("\xB7")} attempting handshake + authenticate against T3 \u2026
3780
+ process.stdout.write(` ${dim2("\xB7")} attempting handshake + authenticate against T3 \u2026
3677
3781
  `);
3678
3782
  try {
3679
3783
  const t3 = await openT3Client(env);
@@ -3701,7 +3805,7 @@ var HERE4 = path11.dirname(fileURLToPath5(import.meta.url));
3701
3805
  var REPO_ROOT3 = path11.resolve(HERE4, "..", "..", "..");
3702
3806
  var colour2 = process.stdout.isTTY ? (c2, s) => `\x1B[${c2}m${s}\x1B[0m` : (_, s) => s;
3703
3807
  var bold2 = (s) => colour2("1", s);
3704
- var dim2 = (s) => colour2("2", s);
3808
+ var dim3 = (s) => colour2("2", s);
3705
3809
  var green2 = (s) => colour2("32", s);
3706
3810
  var yellow2 = (s) => colour2("33", s);
3707
3811
  var red2 = (s) => colour2("31", s);
@@ -3830,14 +3934,14 @@ async function runCompat(opts = {}) {
3830
3934
  }
3831
3935
  process.stdout.write(`
3832
3936
  ${bold2("\u{1F6E1}\uFE0F Blindfold \u2014 compatibility scan")}
3833
- ${dim2("Probing local box for agent tools and SDKs Blindfold can protect.")}
3937
+ ${dim3("Probing local box for agent tools and SDKs Blindfold can protect.")}
3834
3938
 
3835
3939
  `);
3836
3940
  const detected = results.filter((r) => r.detection.detected);
3837
3941
  const notFound = results.filter((r) => !r.detection.detected);
3838
3942
  process.stdout.write(`${bold2(`Detected (${detected.length}):`)}
3839
3943
  `);
3840
- if (detected.length === 0) process.stdout.write(` ${dim2("(none \u2014 install one of the tools below, or just use the OpenAI/Anthropic SDK in your own code)")}
3944
+ if (detected.length === 0) process.stdout.write(` ${dim3("(none \u2014 install one of the tools below, or just use the OpenAI/Anthropic SDK in your own code)")}
3841
3945
  `);
3842
3946
  for (const r of detected) {
3843
3947
  renderTool(r.tool, r.detection, true);
@@ -3849,24 +3953,24 @@ ${bold2(`Not found on this machine (${notFound.length}):`)}
3849
3953
  renderTool(r.tool, r.detection, false);
3850
3954
  }
3851
3955
  process.stdout.write(`
3852
- ${dim2("For a longer-form compatibility writeup see docs/05-compatibility.md.")}
3956
+ ${dim3("For a longer-form compatibility writeup see docs/05-compatibility.md.")}
3853
3957
  `);
3854
3958
  }
3855
3959
  function renderTool(tool, detection, detected) {
3856
- const mark = !detected ? dim2("\xB7") : tool.applies === "applies" ? green2("\u2713") : tool.applies === "depends" ? yellow2("?") : tool.applies === "needs-base-url" ? yellow2("!") : red2("\u2716");
3857
- const status = !detected ? dim2("(not installed)") : tool.applies === "applies" ? green2("Blindfold protects this") : tool.applies === "depends" ? yellow2("Depends on how you authenticate") : tool.applies === "needs-base-url" ? yellow2("No base-URL hook \u2014 needs upstream support") : red2("Doesn't apply (no user-supplied key)");
3858
- process.stdout.write(` ${mark} ${bold2(tool.name)} ${dim2("\xB7")} ${status}
3960
+ const mark = !detected ? dim3("\xB7") : tool.applies === "applies" ? green2("\u2713") : tool.applies === "depends" ? yellow2("?") : tool.applies === "needs-base-url" ? yellow2("!") : red2("\u2716");
3961
+ const status = !detected ? dim3("(not installed)") : tool.applies === "applies" ? green2("Blindfold protects this") : tool.applies === "depends" ? yellow2("Depends on how you authenticate") : tool.applies === "needs-base-url" ? yellow2("No base-URL hook \u2014 needs upstream support") : red2("Doesn't apply (no user-supplied key)");
3962
+ process.stdout.write(` ${mark} ${bold2(tool.name)} ${dim3("\xB7")} ${status}
3859
3963
  `);
3860
- if (detected && detection.detail) process.stdout.write(` ${dim2("at " + detection.detail)}
3964
+ if (detected && detection.detail) process.stdout.write(` ${dim3("at " + detection.detail)}
3861
3965
  `);
3862
3966
  if (detected && tool.recipe.env) {
3863
3967
  const envLine = Object.entries(tool.recipe.env).map(([k, v]) => `${k}=${v}`).join(" ");
3864
3968
  process.stdout.write(` ${cyan2(envLine)}
3865
3969
  `);
3866
3970
  }
3867
- if (detected && tool.recipe.note) process.stdout.write(` ${dim2(tool.recipe.note)}
3971
+ if (detected && tool.recipe.note) process.stdout.write(` ${dim3(tool.recipe.note)}
3868
3972
  `);
3869
- if (detected && tool.explanation) process.stdout.write(` ${dim2(tool.explanation)}
3973
+ if (detected && tool.explanation) process.stdout.write(` ${dim3(tool.explanation)}
3870
3974
  `);
3871
3975
  }
3872
3976
 
@@ -4265,51 +4369,83 @@ async function main() {
4265
4369
  const cmd = argv._[0] ?? "help";
4266
4370
  const handler = ROUTES[cmd];
4267
4371
  if (handler) await handler(cmd, argv, cmdArgs);
4268
- else printHelp();
4372
+ else if (cmd === "help" || cmd === "--help" || cmd === "-h") printHelp();
4373
+ else printUnknown(cmd);
4374
+ }
4375
+ function printUnknown(cmd) {
4376
+ const all = [...Object.keys(ROUTES), "help"];
4377
+ const guess = nearest(cmd, all);
4378
+ console.error(
4379
+ bad(`\u2716 Unknown command: ${cmd}`) + (guess ? ` ${c.gray("\u2014 did you mean")} ${c.cyan(guess)}${c.gray("?")}` : "")
4380
+ );
4381
+ console.error(c.gray(" Run ") + c.cyan("blindfold help") + c.gray(" to see all commands."));
4382
+ process.exit(1);
4269
4383
  }
4270
4384
  function printHelp() {
4271
- console.log(`${head("\u{1F6E1}\uFE0F Blindfold")} ${c.gray("\u2014 protect your AI agent's API keys with Terminal 3 enclaves.")}
4272
-
4273
- ${c.bold("Commands:")}
4274
- signup [--email <you@x.com>] Self-serve: create a Terminal 3 testnet tenant from scratch. Generates a key locally, verifies your email by code, and mints welcome credits \u2014 no manual provisioning. Then run doctor + register.
4275
- init [--seed KV:ENV]... [--start] One-command zero-knowledge setup. Walks through .env, build, auth, publish, seed; can auto-launch the proxy.
4276
- verify Handshake + auth against T3 (smoke test).
4277
- compat [--json] Scan this machine for AI agent tools/SDKs and print the exact env-var swap for each.
4278
- register --name <KV_KEY> [--from-env <ENV_VAR>] Seal a secret into the enclave (one-time). With --from-env: reads process.env. Without: prompts the terminal with no echo (preferred \u2014 never touches disk/history). Also accepts piped stdin.
4279
- use --name <secret> [--as <ENV>] -- <cmd> USE a sealed secret: release it and run <cmd> with it injected as $ENV for that command only \u2014 never back in your env. --as is auto-detected for known tools (gh\u2192GH_TOKEN, psql\u2192PGPASSWORD, \u2026). Or --url <https> for a quick auth check.
4280
- rotate --name <secret> [--from-env <ENV_VAR>] Replace a sealed secret's value (snapshots the old value for rollback; shows before/after fingerprints, never the value).
4281
- export --name <secret> [--as <ENV_VAR>] CI-only: release a sealed secret into $GITHUB_ENV for later steps (masked in logs). Used by the Blindfold GitHub Action.
4282
- rollback --name <secret> [--to <fp|iso-ts>] Restore a previous value snapshotted by rotate (most recent by default).
4283
- versions [--name <secret>] List the snapshots available to roll back to (metadata only).
4284
- migrate [--dry-run] [--keep] Seal EVERY secret in your .env in one shot, then remove the plaintext lines (backup kept). --dry-run previews; --keep comments lines instead of deleting. Skips T3 creds + config.
4285
- status One-glance overview: mode, tenant health, and the list of sealed secrets.
4286
- sealed List sealed keys \u2014 metadata only (name, byte-length, when, where). Never the value.
4287
- audit Verify the ledger's tamper-evident hash-chain AND reconcile it against the enclave (the source of truth) \u2014 flags drift/missing/tampering.
4288
- proxy [--port 8787] [--auth] [--socket [path]] Run the local OpenAI-shaped proxy. --auth mints a per-session token; --socket binds a 0600 unix socket (only your OS user can connect).
4289
- attest [--expect-rtmr3 <b64>] [--pin] [--json] Verify the enclave's TDX attestation (chains to Intel's root CA). --pin records the RTMR3 so seal/proxy auto-verify it first.
4290
- publish [--wasm path/to/blindfold_proxy.wasm] Publish the Rust\u2192WASM contract (one-time).
4291
- grant --host <host>[,<host2>...] Authorize the contract to call these hosts (required before the proxy / in-enclave path can reach them). E.g. --host api.openai.com
4292
- share --to <agent-did> --host <host>[,...] Let a teammate's agent USE your sealed keys for those hosts via the enclave \u2014 they never receive the plaintext (forward only, least privilege).
4293
- revoke --to <agent-did> Remove a teammate's access. Immediate and complete \u2014 nobody holds a raw key copy.
4294
-
4295
- skill install [--global|--cursor|--opencode|--cline|--all] Install the Blindfold agent skill so your coding agent handles secrets safely. Default: this project.
4296
- skill uninstall Remove all installed skill files.
4297
-
4298
- dashboard [--port 8799] Live HTML dashboard of proxy usage.
4299
- stats CLI summary of proxy usage.
4300
- stats:clear Wipe the usage log.
4301
- doctor Show current mode + config.
4302
- credit [--json] Show the tenant's Terminal 3 token/credit balance (no credit cost).
4303
- update [--from <path>] Update the global blindfold (from npm, or a local repo with --from).
4304
-
4305
- The friendliest path is just: blindfold init
4306
-
4307
- Quick start:
4308
- 1) ./scripts/build-contract.sh # build the Rust contract (REAL mode only)
4309
- 2) blindfold publish # register the contract on T3
4310
- 3) blindfold register --name openai_api_key --from-env OPENAI_API_KEY
4311
- 4) blindfold proxy # then point your agent at it
4312
- `);
4385
+ const groups = [
4386
+ ["\u{1F680} Get started", [
4387
+ ["signup", "Self-serve: mint a funded Terminal 3 testnet tenant (key generated locally, email-verified)."],
4388
+ ["init", "Guided zero-knowledge setup: .env, build, auth, publish, seed; can auto-start the proxy."],
4389
+ ["doctor", "Show mode + config and run a live tenant health check."],
4390
+ ["credit", "Show the tenant's Terminal 3 token balance (costs nothing)."],
4391
+ ["verify", "Handshake + authenticate against T3 (smoke test)."]
4392
+ ]],
4393
+ ["\u{1F511} Secrets", [
4394
+ ["register", "Seal a secret into the enclave (hidden prompt; never touches disk)."],
4395
+ ["use", "Release a sealed secret into one command as $ENV \u2014 never back in your env."],
4396
+ ["export", "CI: release a sealed secret into $GITHUB_ENV (masked in logs)."],
4397
+ ["rotate", "Replace a sealed secret's value (snapshots the old one for rollback)."],
4398
+ ["rollback", "Restore a previous value snapshotted by rotate."],
4399
+ ["versions", "List the snapshots available to roll back to (metadata only)."],
4400
+ ["migrate", "Seal every secret in .env at once, then remove the plaintext lines."]
4401
+ ]],
4402
+ ["\u{1F310} Proxy & serve", [
4403
+ ["proxy", "Run the local sentinel proxy. --auth mints a session token; --socket binds a 0600 unix socket."],
4404
+ ["attest", "Verify the enclave's TDX attestation (Intel root CA). --pin gates seal/proxy on the code measurement."],
4405
+ ["dashboard", "Live HTML dashboard of proxy usage (default :8799)."],
4406
+ ["stats", "CLI summary of proxy usage (stats:clear wipes it)."]
4407
+ ]],
4408
+ ["\u{1F465} Team & sharing", [
4409
+ ["grant", "Authorize the contract to call these hosts (e.g. --host api.openai.com)."],
4410
+ ["share", "Let a teammate's agent USE your sealed keys for a host \u2014 forward only, no plaintext."],
4411
+ ["revoke", "Remove a teammate's access \u2014 immediate and complete."]
4412
+ ]],
4413
+ ["\u{1F4E6} Enclave & admin", [
4414
+ ["publish", "Publish the Rust\u2192WASM contract to your tenant (one-time)."],
4415
+ ["status", "One-glance: mode, tenant health, and sealed secrets."],
4416
+ ["sealed", "List sealed keys \u2014 metadata only, never the value."],
4417
+ ["audit", "Verify the ledger hash-chain and reconcile it against the enclave."],
4418
+ ["compat", "Scan this machine for AI agent tools + print the env-var swap for each."],
4419
+ ["update", "Update the global install (from npm, or --from <repo>)."]
4420
+ ]],
4421
+ ["\u{1F464} Account", [
4422
+ ["login", "Store existing Terminal 3 credentials (key \u2192 OS keychain)."],
4423
+ ["logout", "Remove stored credentials."],
4424
+ ["whoami", "Show tenant, env, and key source (never the value)."]
4425
+ ]],
4426
+ ["\u{1F916} Agent skill", [
4427
+ ["skill", "install [--global|--cursor|--opencode|--cline|--all] / uninstall \u2014 the agent skill for your coding agent."]
4428
+ ]]
4429
+ ];
4430
+ const out = [
4431
+ "",
4432
+ bannerBox("\u{1F6E1}\uFE0F Blindfold", "Protect your AI agent's API keys with Terminal 3 enclaves. The agent only ever holds a placeholder \u2014 the real key is substituted inside the TDX enclave."),
4433
+ ""
4434
+ ];
4435
+ for (const [title, rows] of groups) {
4436
+ out.push(commandBox(title, rows));
4437
+ out.push("");
4438
+ }
4439
+ out.push(
4440
+ c.bold("Quick start"),
4441
+ ` ${c.cyan("blindfold signup --email you@x.com")} ${c.gray("# create a funded testnet tenant")}`,
4442
+ ` ${c.cyan("blindfold register --name openai_api_key")}`,
4443
+ ` ${c.cyan("blindfold proxy")} ${c.gray("# point your agent at http://127.0.0.1:8787")}`,
4444
+ "",
4445
+ `${c.gray("Docs:")} ${c.cyan("https://www.npmjs.com/package/@fiscalmindset/blindfold")} ${c.gray("\xB7")} ${c.gray("Run")} ${c.cyan("blindfold <command> --help")} ${c.gray("for details")}`,
4446
+ ""
4447
+ );
4448
+ console.log(out.join("\n"));
4313
4449
  }
4314
4450
  main().catch((e) => {
4315
4451
  console.error("\u2716", e.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiscalmindset/blindfold",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Make any AI agent's API keys un-leakable, by sealing them inside a Terminal 3 TDX enclave.",
6
6
  "license": "MIT",
package/src/tui.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Tiny, dependency-free terminal UI primitives: responsive width, ANSI-aware
3
+ * wrapping, rounded boxes, and a two-column command table. Used to render a
4
+ * structured `blindfold help` that reflows to the terminal.
5
+ */
6
+ import { c } from "./color.ts";
7
+
8
+ /** Terminal width, clamped to a readable range. */
9
+ export function termWidth(): number {
10
+ const cols = process.stdout.columns;
11
+ if (typeof cols === "number" && cols >= 40) return Math.min(cols, 100);
12
+ const env = Number(process.env.COLUMNS);
13
+ if (Number.isFinite(env) && env >= 40) return Math.min(env, 100);
14
+ return 80;
15
+ }
16
+
17
+ const ANSI = /\x1b\[[0-9;]*m/g;
18
+
19
+ /** True for code points most terminals render two columns wide (CJK, emoji). */
20
+ function isWide(cp: number): boolean {
21
+ return (
22
+ (cp >= 0x1100 && cp <= 0x115f) ||
23
+ (cp >= 0x2600 && cp <= 0x27bf) ||
24
+ (cp >= 0x2b00 && cp <= 0x2bff) ||
25
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
26
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
27
+ (cp >= 0xf900 && cp <= 0xfaff) ||
28
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
29
+ (cp >= 0xff00 && cp <= 0xff60) ||
30
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
31
+ (cp >= 0x1f000 && cp <= 0x1faff)
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Display width of a string (ANSI-stripped), accounting for double-width CJK/
37
+ * emoji and zero-width joiners/variation-selectors — so box borders line up.
38
+ */
39
+ export function vlen(s: string): number {
40
+ let w = 0;
41
+ for (const ch of s.replace(ANSI, "")) {
42
+ const cp = ch.codePointAt(0) ?? 0;
43
+ if (cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0x300 && cp <= 0x36f)) continue; // zero-width
44
+ w += isWide(cp) ? 2 : 1;
45
+ }
46
+ return w;
47
+ }
48
+ function padEnd(s: string, w: number): string {
49
+ return s + " ".repeat(Math.max(0, w - vlen(s)));
50
+ }
51
+
52
+ /** Word-wrap `text` to `width` columns (ANSI-aware). */
53
+ export function wrapText(text: string, width: number): string[] {
54
+ if (width <= 4) return [text];
55
+ const out: string[] = [];
56
+ for (const para of text.split("\n")) {
57
+ const words = para.split(/\s+/).filter(Boolean);
58
+ let cur = "";
59
+ for (const w of words) {
60
+ if (cur === "") cur = w;
61
+ else if (vlen(cur) + 1 + vlen(w) <= width) cur += " " + w;
62
+ else { out.push(cur); cur = w; }
63
+ }
64
+ out.push(cur);
65
+ }
66
+ return out.length ? out : [""];
67
+ }
68
+
69
+ // Rounded box-drawing set.
70
+ const B = { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" };
71
+ const dim = (s: string): string => c.gray(s);
72
+
73
+ /** A banner box with a bold title and a dim subtitle, sized to the terminal. */
74
+ export function bannerBox(title: string, subtitle: string): string {
75
+ const w = termWidth();
76
+ const inner = w - 4;
77
+ const top = dim(B.tl + B.h.repeat(w - 2) + B.tr);
78
+ const bot = dim(B.bl + B.h.repeat(w - 2) + B.br);
79
+ const row = (s: string): string => dim(B.v) + " " + padEnd(s, inner) + " " + dim(B.v);
80
+ const lines = [top, row(c.bold(title))];
81
+ for (const sl of wrapText(subtitle, inner)) lines.push(row(dim(sl)));
82
+ lines.push(bot);
83
+ return lines.join("\n");
84
+ }
85
+
86
+ /**
87
+ * A titled box holding a two-column command table: the command in a fixed left
88
+ * column (cyan), the description wrapped in the remaining width. Right border
89
+ * stays aligned regardless of wrapping.
90
+ */
91
+ export function commandBox(title: string, rows: Array<[string, string]>): string {
92
+ const w = termWidth();
93
+ const inner = w - 4; // inside "│ " … " │"
94
+ const gap = 2;
95
+ const longest = rows.reduce((m, [cmd]) => Math.max(m, vlen(cmd)), 0);
96
+ const cmdW = Math.min(longest, Math.max(10, Math.floor(inner * 0.34)));
97
+ const descW = Math.max(12, inner - cmdW - gap);
98
+
99
+ // Title embedded in the top border: ╭─ Title ─────────╮ (border dim, title bold)
100
+ const prefix = B.tl + B.h + " ";
101
+ const fill = Math.max(0, w - vlen(prefix) - vlen(title) - 1 /* space */ - 1 /* tr */);
102
+ const out: string[] = [dim(prefix) + c.bold(title) + dim(" " + B.h.repeat(fill) + B.tr)];
103
+
104
+ for (const [cmd, desc] of rows) {
105
+ const dLines = wrapText(desc, descW);
106
+ const cmdCell = padEnd(c.cyan(cmd), cmdW);
107
+ out.push(dim(B.v) + " " + cmdCell + " ".repeat(gap) + padEnd(dLines[0] ?? "", descW) + " " + dim(B.v));
108
+ for (let i = 1; i < dLines.length; i++) {
109
+ out.push(dim(B.v) + " " + " ".repeat(cmdW + gap) + padEnd(dim(dLines[i] ?? ""), descW) + " " + dim(B.v));
110
+ }
111
+ }
112
+ out.push(dim(B.bl + B.h.repeat(w - 2) + B.br));
113
+ return out.join("\n");
114
+ }
115
+
116
+ /** Nearest command by edit distance, for "did you mean" suggestions. */
117
+ export function nearest(input: string, candidates: string[]): string | null {
118
+ let best: string | null = null;
119
+ let bestD = Infinity;
120
+ for (const cand of candidates) {
121
+ const d = editDistance(input, cand);
122
+ if (d < bestD) { bestD = d; best = cand; }
123
+ }
124
+ return bestD <= Math.max(2, Math.floor(input.length / 3)) ? best : null;
125
+ }
126
+
127
+ function editDistance(a: string, b: string): number {
128
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
129
+ for (let j = 0; j <= b.length; j++) dp[0]![j] = j;
130
+ for (let i = 1; i <= a.length; i++) {
131
+ for (let j = 1; j <= b.length; j++) {
132
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
133
+ dp[i]![j] = Math.min(dp[i - 1]![j]! + 1, dp[i]![j - 1]! + 1, dp[i - 1]![j - 1]! + cost);
134
+ }
135
+ }
136
+ return dp[a.length]![b.length]!;
137
+ }