@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 +49 -22
- package/bin/blindfold.ts +81 -43
- package/dist/cli.mjs +202 -66
- package/package.json +1 -1
- package/src/tui.ts +137 -0
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 (`
|
|
21
|
-
2. **R2 — verify by fingerprint, never by value.** To check what's sealed: `
|
|
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
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
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
|
-
| `
|
|
41
|
-
| `printf 'V' \|
|
|
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.
|
|
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: `
|
|
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
|
-
>
|
|
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 `
|
|
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
|
-
`
|
|
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-...' |
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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,
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
${
|
|
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(` ${
|
|
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(` ${
|
|
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
|
-
${
|
|
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 ${
|
|
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(`${
|
|
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(`${
|
|
3660
|
+
process.stdout.write(`${dim2("Next:")}
|
|
3557
3661
|
`);
|
|
3558
|
-
process.stdout.write(` ${cyan("npm run blindfold -- proxy")} ${
|
|
3662
|
+
process.stdout.write(` ${cyan("npm run blindfold -- proxy")} ${dim2("# leave this running")}
|
|
3559
3663
|
`);
|
|
3560
|
-
process.stdout.write(` ${cyan("npm run dashboard")} ${
|
|
3664
|
+
process.stdout.write(` ${cyan("npm run dashboard")} ${dim2("# open http://127.0.0.1:8799")}
|
|
3561
3665
|
`);
|
|
3562
|
-
process.stdout.write(` ${
|
|
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(`${
|
|
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(` ${
|
|
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
|
|
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
|
-
${
|
|
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(` ${
|
|
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
|
-
${
|
|
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 ?
|
|
3857
|
-
const status = !detected ?
|
|
3858
|
-
process.stdout.write(` ${mark} ${bold2(tool.name)} ${
|
|
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(` ${
|
|
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(` ${
|
|
3971
|
+
if (detected && tool.recipe.note) process.stdout.write(` ${dim3(tool.recipe.note)}
|
|
3868
3972
|
`);
|
|
3869
|
-
if (detected && tool.explanation) process.stdout.write(` ${
|
|
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
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
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
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
|
+
}
|