feat(setup): M3 PromptOpts — flags/min/normalize/error + helper-owned reuse

Widen Prompter.ask with a trailing-optional PromptOpts (flags/min/error/
normalize); existing async ask(name) fakes are untouched. clackPrompter builds
the regex with opts.flags, enforces min-length, surfaces opts.error, and sets
clearOnError on the secret prompt — via an exported, TTY-free promptValidator.
normalize (trim | rstrip-slash | lower) binds DETERMINISTICALLY in the engine,
so an inputs value and a typed answer land identically. The grammar + validator
learn flags/min/normalize/error/reuse on nc:prompt. reuseFromEnv gains a second
pass over reuse:<ENV_KEY> so a credential a helper script owns (imessage Photon
IMESSAGE_SERVER_URL/API_KEY, written by effect:external) regains the masked
reuse offer. Applied: add-teams app_password min:20, public_url
normalize:rstrip-slash; add-imessage server_url flags:i + reuse on the Photon
prompts. SKILLs stay lint-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-06-27 23:32:50 +03:00
parent bd0962fd97
commit d7d104aa0b
8 changed files with 303 additions and 27 deletions
+2 -2
View File
@@ -131,10 +131,10 @@ Set up remote iMessage via Photon:
Then collect the two values:
```nc:prompt server_url when:mode=remote validate:^https?://
```nc:prompt server_url when:mode=remote validate:^https?:// flags:i reuse:IMESSAGE_SERVER_URL
Your Photon server URL — starts with http:// or https:// (e.g. https://photon.example.com).
```
```nc:prompt api_key secret when:mode=remote
```nc:prompt api_key secret when:mode=remote reuse:IMESSAGE_API_KEY
Your Photon API key — from the Photon dashboard.
```
+2 -2
View File
@@ -90,7 +90,7 @@ reach this machine's webhook server (port 3000) at `/api/webhooks/teams`. If you
don't have a tunnel running yet, start one in another terminal first — e.g.
`ngrok http 3000` gives you `https://abcd1234.ngrok.io`.
```nc:prompt public_url validate:^https://
```nc:prompt public_url validate:^https:// normalize:rstrip-slash
Paste your public base URL (https://…, no trailing path) — e.g. https://abcd1234.ngrok.io.
```
@@ -129,7 +129,7 @@ Create the client secret:
4. COPY THE VALUE NOW — Azure only shows it once (the Value column, not the Secret ID).
```
```nc:prompt app_password secret
```nc:prompt app_password secret min:20
Paste the client secret Value — Certificates & secrets (shown only once).
```
+72 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { applySkill, removeSkill, planSkill, fullyApplied, firstFailureHint, stepLabel, type Prompter, type StepReporter } from './skill-apply.js';
import { applySkill, removeSkill, planSkill, fullyApplied, firstFailureHint, stepLabel, type Prompter, type PromptOpts, type StepReporter } from './skill-apply.js';
import { parseDirectives, validate } from './skill-directives.js';
// A synthetic skill exercising the fs handlers for real (no network), plus one
@@ -1075,3 +1075,74 @@ describe('firstFailureHint', () => {
expect(firstFailureHint(res)).toBeUndefined();
});
});
// PromptOpts: `nc:prompt` attrs (flags/min/error/normalize). `normalize:<how>` is
// applied DETERMINISTICALLY at engine bind — to BOTH an `inputs` value and an
// interactive answer — so they land identically (a deliberate behavior change).
// flags/min/error are carried through to the prompter (their interactive
// enforcement is asserted in skill-driver.test.ts against promptValidator).
const NORMALIZE_SKILL = `# normalize demo
## Collect a base URL
\`\`\`nc:prompt public_url normalize:rstrip-slash
Paste your base URL.
\`\`\`
\`\`\`nc:env-set
PUBLIC_URL={{public_url}}
\`\`\`
`;
describe('nc:prompt PromptOpts (normalize at bind, opts threading)', () => {
let nroot: string;
let nskill: string;
beforeEach(() => {
nskill = mkdtempSync(join(tmpdir(), 'nc-opts-skill-'));
nroot = mkdtempSync(join(tmpdir(), 'nc-opts-proj-'));
writeFileSync(join(nskill, 'SKILL.md'), NORMALIZE_SKILL);
writeFileSync(join(nroot, '.env'), '');
writeFileSync(join(nroot, 'package.json'), '{"name":"scratch"}');
});
it('normalize:rstrip-slash strips a trailing slash on an inputs value (bound + consumed downstream)', async () => {
const res = await applySkill(nskill, nroot, { inputs: { public_url: 'https://x.ngrok.io/' }, exec: () => {} });
expect(res.vars.public_url).toBe('https://x.ngrok.io'); // slash stripped at bind
expect(readFileSync(join(nroot, '.env'), 'utf8')).toContain('PUBLIC_URL=https://x.ngrok.io');
});
it('normalize:rstrip-slash strips a trailing slash on an interactive answer too (same bind path)', async () => {
const res = await applySkill(nskill, nroot, { prompter: headless({ public_url: 'https://x.ngrok.io/' }), exec: () => {} });
expect(res.vars.public_url).toBe('https://x.ngrok.io'); // identical to the inputs path
expect(readFileSync(join(nroot, '.env'), 'utf8')).toContain('PUBLIC_URL=https://x.ngrok.io');
});
it('threads validate + PromptOpts (flags/min/normalize) through to the prompter, then normalizes the answer at bind', async () => {
writeFileSync(
join(nskill, 'SKILL.md'),
'# o\n\n```nc:prompt url validate:^https?:// flags:i min:5 normalize:rstrip-slash\nURL?\n```\n',
);
let seenValidate: string | undefined;
let seenOpts: PromptOpts | undefined;
const prompter: Prompter = {
async ask(_n, _q, _s, validate, opts) {
seenValidate = validate;
seenOpts = opts;
return 'HTTPS://x.io/'; // the prompter returns the raw answer; the engine normalizes
},
};
const res = await applySkill(nskill, nroot, { prompter, exec: () => {} });
expect(seenValidate).toBe('^https?://');
expect(seenOpts).toMatchObject({ flags: 'i', min: 5, normalize: 'rstrip-slash' });
// normalize applied at bind (trailing slash gone); case preserved (lower not set)
expect(res.vars.url).toBe('HTTPS://x.io');
});
it('normalize:lower and trim also bind deterministically', async () => {
writeFileSync(
join(nskill, 'SKILL.md'),
'# n\n\n```nc:prompt a normalize:lower\nA?\n```\n```nc:prompt b normalize:trim\nB?\n```\n',
);
const res = await applySkill(nskill, nroot, { inputs: { a: 'MixedCASE', b: ' spaced ' }, exec: () => {} });
expect(res.vars.a).toBe('mixedcase');
expect(res.vars.b).toBe('spaced');
});
});
+57 -5
View File
@@ -27,11 +27,27 @@ import { readFileSync, existsSync, writeFileSync, appendFileSync, copyFileSync,
import { join, dirname } from 'node:path';
import { parseDirectives, promptVar, type Directive } from './skill-directives.js';
// Optional per-prompt UX hints an `nc:prompt`'s attrs carry. Every field is
// trailing-optional, so an existing `async ask(name)` test fake is untouched.
// • flags regex flags for `validate` (e.g. `i` → case-insensitive match)
// • min a minimum length the interactive prompter enforces (re-asks if short)
// • error the validation message the prompter surfaces on a mismatch
// • normalize a deterministic transform applied AT BIND (see `normalizeValue`) to
// BOTH `inputs` and interactive answers: trim | rstrip-slash | lower
export interface PromptOpts {
flags?: string;
min?: number;
error?: string;
normalize?: string;
}
export interface Prompter {
// Return the value, or undefined to DEFER (headless rebuild collects these).
// `validate` is an optional regex (from `nc:prompt … validate:<re>`) the
// interactive prompter enforces, re-asking until the answer matches.
ask(varName: string, question: string, secret: boolean, validate?: string): Promise<string | undefined>;
// interactive prompter enforces, re-asking until the answer matches. `opts`
// carries the trailing-optional PromptOpts (flags/min/error — normalize is the
// engine's, applied at bind, not the prompter's). A fake may ignore both.
ask(varName: string, question: string, secret: boolean, validate?: string, opts?: PromptOpts): Promise<string | undefined>;
// Show an `nc:operator` block to the human operator (a clack note in setup, a
// channel message when a coding agent relays). Absent ⇒ no operator present
// (headless rebuild), so the instructions are simply skipped.
@@ -385,6 +401,38 @@ export function stepLabel(d: Directive, md: string): string | null {
return (effect && byEffect[effect]) || 'Running';
}
// Deterministic input normalization applied AT BIND to every prompt value —
// `inputs` AND interactive answers alike — driven by `nc:prompt normalize:<how>`:
// trim strip leading/trailing whitespace
// rstrip-slash drop trailing slash(es) — a base URL with no trailing path
// lower lowercase
// Absent/unknown ⇒ a no-op (lint gates the known set). Doing it here, not in the
// prompter, means a programmatic `inputs` value and a typed answer land identically.
function normalizeValue(value: string, normalize: string | undefined): string {
switch (normalize) {
case 'trim':
return value.trim();
case 'rstrip-slash':
return value.replace(/\/+$/, '');
case 'lower':
return value.toLowerCase();
default:
return value;
}
}
// The PromptOpts an `nc:prompt`'s attrs carry. Stripped with the fence when a
// skill degrades to prose — invisible to the agent — so a plain re-read still
// reads as a normal question. `min` parses to a number; the rest pass through.
function promptOptsOf(d: Directive): PromptOpts {
const opts: PromptOpts = {};
if (typeof d.attrs.flags === 'string') opts.flags = d.attrs.flags;
if (typeof d.attrs.error === 'string') opts.error = d.attrs.error;
if (typeof d.attrs.normalize === 'string') opts.normalize = d.attrs.normalize;
if (typeof d.attrs.min === 'string' && /^\d+$/.test(d.attrs.min)) opts.min = Number(d.attrs.min);
return opts;
}
function substitute(value: string, vars: Map<string, { value: string; secret: boolean }>): string {
return value.replace(VAR_REF, (_, name) => {
const v = vars.get(name);
@@ -646,13 +694,17 @@ export async function applySkill(skillDir: string, root: string, opts: ApplyOpti
if (d.kind === 'prompt') {
const v = promptVar(d)!;
const secret = d.args.includes('secret');
const validate = typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined;
const promptOpts = promptOptsOf(d);
// Pre-supplied inputs win (fully-programmatic apply); fall back to the
// interactive prompter; still undefined ⇒ defer (headless, no answer).
let val = opts.inputs?.[v];
const validate = typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined;
if (val === undefined) val = await opts.prompter?.ask(v, d.body.join(' '), secret, validate);
if (val === undefined) val = await opts.prompter?.ask(v, d.body.join(' '), secret, validate, promptOpts);
if (val === undefined) res.deferred.push(v);
else vars.set(v, { value: val, secret });
// normalize:<how> binds DETERMINISTICALLY for both inputs and answers, so
// an `inputs` value and a typed one land identically (a trailing slash
// stripped, whitespace trimmed) — see normalizeValue.
else vars.set(v, { value: normalizeValue(val, promptOpts.normalize), secret });
continue;
}
if (d.kind === 'operator') {
+42
View File
@@ -243,3 +243,45 @@ describe('when: guard + multi-field capture', () => {
expect(validate(parseDirectives(md))).toEqual([]);
});
});
describe('prompt PromptOpts attrs (flags/min/normalize/error/reuse)', () => {
it('parses flags/min/normalize/reuse into attrs alongside the var + secret flag', () => {
const md = [
'```nc:prompt server_url secret validate:^https?:// flags:i min:8 normalize:rstrip-slash reuse:IMESSAGE_SERVER_URL',
'URL?',
'```',
].join('\n');
const [d] = parseDirectives(md);
expect(promptVar(d)).toBe('server_url'); // the var, not `secret`
expect(d.attrs.flags).toBe('i');
expect(d.attrs.min).toBe('8');
expect(d.attrs.normalize).toBe('rstrip-slash');
expect(d.attrs.reuse).toBe('IMESSAGE_SERVER_URL');
expect(validate([d])).toEqual([]); // a well-formed prompt with all attrs lints clean
});
it('accepts validate:<re> combined with flags:i (a case-insensitive regex is still valid)', () => {
const md = ['```nc:prompt u validate:^https?:// flags:i', 'URL?', '```'].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
it('flags a non-numeric min:', () => {
const md = ['```nc:prompt u min:lots', 'q', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /min:lots must be a non-negative integer/.test(p.message))).toBe(true);
});
it('flags an unknown normalize: value', () => {
const md = ['```nc:prompt u normalize:uppercase', 'q', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /normalize:uppercase must be one of/.test(p.message))).toBe(true);
});
it('flags a reuse: that is not a valid ENV_KEY', () => {
const md = ['```nc:prompt u reuse:not-an-env-key', 'q', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /reuse:not-an-env-key must be a valid ENV_KEY/.test(p.message))).toBe(true);
});
it('flags illegal regex flags:', () => {
const md = ['```nc:prompt u validate:^x flags:zzz', 'q', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /is not a valid regex/.test(p.message))).toBe(true);
});
});
+33 -4
View File
@@ -44,9 +44,20 @@
// a non-zero exit bounces to an agent (degrade, not crash) and, via the
// run-health gate, blocks the dangerous side effects that follow it (a
// restart, a pairing/QR step, a wire). An unresolved {{var}} defers.
// prompt <var> [secret] [validate:<re>] body: the question → binds {{var}} skip if satisfied
// prompt <var> [secret] [validate:<re>] [flags:<re-flags>] [min:<n>]
// [normalize:trim|rstrip-slash|lower] [error:<msg>] [reuse:<ENV_KEY>]
// body: the question → binds {{var}} skip if satisfied
// validate:<re> is a regex the interactive prompter enforces (e.g.
// validate:^xoxb- to require a Slack bot token); `inputs` bypass it.
// flags:<re-flags> are regex flags applied to validate (e.g. flags:i → a
// case-insensitive scheme match). min:<n> is a minimum length the prompter
// enforces (re-asks if shorter; `inputs` bypass it). error:<msg> overrides
// the message shown on a validation miss (single token — no spaces).
// normalize:<how> deterministically transforms the value AT BIND — for BOTH
// `inputs` and interactive answers — one of trim | rstrip-slash | lower.
// reuse:<ENV_KEY> lets a re-run offer an existing .env value for a credential
// a HELPER SCRIPT owns (written by effect:external, not nc:env-set) — the
// masked reuse offer the env-set→ENV_KEY inference can't otherwise see.
// operator body: instructions for the human operator output-only
// The SKILL.md is addressed to the coding agent; `operator` delineates the
// parts meant for the HUMAN (e.g. clicking through the Slack UI). Lead it
@@ -224,17 +235,35 @@ export function validate(directives: Directive[], ctx?: { chatVersion?: string }
}
break;
}
case 'prompt':
case 'prompt': {
if (!promptVar(d)) flag(d, 'prompt requires a variable name, e.g. `nc:prompt token`');
if (d.body.length === 0) flag(d, 'prompt requires a question in its body');
const flags = typeof d.attrs.flags === 'string' ? d.attrs.flags : undefined;
if (typeof d.attrs.validate === 'string') {
try {
new RegExp(d.attrs.validate);
new RegExp(d.attrs.validate, flags);
} catch {
flag(d, `prompt validate:${d.attrs.validate} is not a valid regex`);
flag(d, `prompt validate:${d.attrs.validate}${flags ? ` flags:${flags}` : ''} is not a valid regex`);
}
} else if (flags !== undefined) {
// flags without validate: still verify they're legal regex flags.
try {
new RegExp('', flags);
} catch {
flag(d, `prompt flags:${flags} are not valid regex flags`);
}
}
if (typeof d.attrs.min === 'string' && !/^\d+$/.test(d.attrs.min)) {
flag(d, `prompt min:${d.attrs.min} must be a non-negative integer`);
}
if (typeof d.attrs.normalize === 'string' && !['trim', 'rstrip-slash', 'lower'].includes(d.attrs.normalize)) {
flag(d, `prompt normalize:${d.attrs.normalize} must be one of trim|rstrip-slash|lower`);
}
if (typeof d.attrs.reuse === 'string' && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(d.attrs.reuse)) {
flag(d, `prompt reuse:${d.attrs.reuse} must be a valid ENV_KEY`);
}
break;
}
case 'operator':
if (d.body.length === 0) flag(d, 'operator requires instructions for the human in its body');
break;
+50 -1
View File
@@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runSkill, hostExec, hostExecStream, type RunSkillOptions } from './skill-driver.js';
import { runSkill, hostExec, hostExecStream, promptValidator, type RunSkillOptions } from './skill-driver.js';
import { fullyApplied, type Prompter, type StepReporter } from '../../scripts/skill-apply.js';
// A small SKILL.md exercising the three things the driver wires: an operator
@@ -148,4 +148,53 @@ describe('thin skill driver', () => {
expect(asked).toContain('bot_token'); // declined → prompted
expect(cmds).toContain('use NEWLY-PASTED');
});
// A cred a HELPER SCRIPT owns (written by effect:external, not nc:env-set) has no
// env-set→ENV_KEY linkage to infer. An explicit `nc:prompt … reuse:<ENV_KEY>`
// restores the masked reuse offer — the imessage Photon case.
function helperReuseScratch(): { root: string; skill: string } {
const root = mkdtempSync(join(tmpdir(), 'reuse-helper-'));
const skill = mkdtempSync(join(tmpdir(), 'reuse-helper-skill-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
// present in .env, but NOT written by any nc:env-set in the skill below
writeFileSync(join(root, '.env'), 'IMESSAGE_SERVER_URL=https://photon.example.com\n');
writeFileSync(
join(skill, 'SKILL.md'),
'# helper reuse demo\n\n```nc:prompt server_url validate:^https?:// reuse:IMESSAGE_SERVER_URL\nYour Photon server URL.\n```\n```nc:run effect:external\nbash configure.sh "{{server_url}}"\n```\n',
);
return { root, skill };
}
it('reuse: offers an existing .env value for a HELPER-owned cred (no env-set linkage)', async () => {
const { root, skill } = helperReuseScratch();
const asked: string[] = [];
const cmds: string[] = [];
const confirmed: string[] = [];
const prompter: Prompter = {
async ask(n) {
asked.push(n);
return 'https://typed.example';
},
async confirm(msg) {
confirmed.push(msg);
return true; // yes, reuse the existing helper-owned value
},
};
await runSkill(skill, { projectRoot: root, prompter, reuse: true, exec: (c) => void cmds.push(c) });
expect(confirmed.some((m) => /IMESSAGE_SERVER_URL/.test(m))).toBe(true); // the reuse: link surfaced the offer
expect(asked).not.toContain('server_url'); // accepted → never re-prompted
expect(cmds).toContain('bash configure.sh "https://photon.example.com"'); // reused value flowed downstream
});
it('promptValidator honors flags:i (case-insensitive) and min (rejects short); error overrides the message', () => {
const ci = promptValidator('^https://', { flags: 'i' });
expect(ci).toBeDefined();
expect(ci!('HTTPS://example.com')).toBeUndefined(); // case-insensitive match passes
expect(ci!('ftp://example.com')).toBeTruthy(); // non-match rejected
const min = promptValidator(undefined, { min: 20 });
expect(min!('short')).toBeTruthy(); // below the minimum length → rejected
expect(min!('x'.repeat(20))).toBeUndefined(); // at the minimum → passes
expect(promptValidator('^x', { error: 'Bad token format' })!('y')).toBe('Bad token format'); // custom message
expect(promptValidator(undefined, undefined)).toBeUndefined(); // no regex + no min ⇒ no validator
});
});
+45 -12
View File
@@ -13,8 +13,8 @@ import { join } from 'node:path';
import * as p from '@clack/prompts';
import { applySkill, fullyApplied, type ApplyResult, type Prompter, type StepOutcome, type StepReporter } from '../../scripts/skill-apply.js';
import { parseDirectives } from '../../scripts/skill-directives.js';
import { applySkill, fullyApplied, type ApplyResult, type Prompter, type PromptOpts, type StepOutcome, type StepReporter } from '../../scripts/skill-apply.js';
import { parseDirectives, promptVar } from '../../scripts/skill-directives.js';
import { startSpinner } from './runner.js';
/**
@@ -23,15 +23,37 @@ import { startSpinner } from './runner.js';
* note. The prompt that follows an operator block is the natural barrier the
* user can't paste a token before they've done the steps.
*/
/**
* Build the clack `validate` callback an `nc:prompt` carries the interactive
* enforcement of `validate:<re>` (with `flags:`), `min:`, and the `error:` message.
* Returns undefined when the prompt has neither a regex nor a min (no validation to
* do). Exported so the policy is unit-testable without a TTY. Normalization is NOT
* here: it's deterministic, applied at bind by the engine (skill-apply
* `normalizeValue`), so it lands the same for `inputs` and typed answers.
*/
export function promptValidator(
validate: string | undefined,
opts: PromptOpts | undefined,
): ((v: string | undefined) => string | undefined) | undefined {
const re = validate ? new RegExp(validate, opts?.flags) : undefined;
const min = opts?.min;
if (!re && min === undefined) return undefined;
return (v) => {
const s = (v ?? '').trim();
if (min !== undefined && s.length < min) return opts?.error ?? `Must be at least ${min} characters.`;
if (re && !re.test(s)) return opts?.error ?? `That doesn't match the expected format.`;
return undefined;
};
}
export function clackPrompter(): Prompter {
return {
async ask(_varName, question, secret, validate) {
const re = validate ? new RegExp(validate) : undefined;
const check = re
? (v: string | undefined) => (re.test((v ?? '').trim()) ? undefined : `That doesn't match the expected format.`)
: undefined;
async ask(_varName, question, secret, validate, opts) {
const check = promptValidator(validate, opts);
// clearOnError wipes a rejected secret so the operator re-pastes cleanly
// (a half-pasted token isn't left masked in the field).
const ans = secret
? await p.password({ message: question, validate: check })
? await p.password({ message: question, validate: check, clearOnError: true })
: await p.text({ message: question, validate: check });
if (p.isCancel(ans)) return undefined; // cancelled ⇒ defer
const v = String(ans).trim();
@@ -82,10 +104,21 @@ async function reuseFromEnv(
}
const varToKey = new Map<string, string>();
for (const d of parseDirectives(md)) {
if (d.kind !== 'env-set') continue;
for (const line of d.body) {
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/);
if (m) varToKey.set(m[2], m[1]); // var → ENV_KEY
// 1st pass: infer var → ENV_KEY from env-set directives (KEY={{var}}).
if (d.kind === 'env-set') {
for (const line of d.body) {
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/);
if (m) varToKey.set(m[2], m[1]); // var → ENV_KEY
}
}
// 2nd pass: an explicit `nc:prompt … reuse:<ENV_KEY>` links a prompt to a
// credential a HELPER SCRIPT owns — written by effect:external, not nc:env-set
// (e.g. imessage's Photon IMESSAGE_SERVER_URL / IMESSAGE_API_KEY). The env-set
// inference above can't see those, so the prompt states the linkage to regain
// the masked reuse offer on a re-run.
if (d.kind === 'prompt' && typeof d.attrs.reuse === 'string') {
const v = promptVar(d);
if (v) varToKey.set(v, d.attrs.reuse); // var → ENV_KEY (explicit)
}
}
let env: Record<string, string> = {};