envpkt 0.14.1 → 0.16.0

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/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import { randomBytes } from "node:crypto";
15
15
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
16
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
17
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
18
+ import { text } from "node:stream/consumers";
18
19
  import { createInterface } from "node:readline";
19
20
  //#region src/core/namespace.ts
20
21
  const DEFAULT_SEPARATOR = "__";
@@ -123,14 +124,15 @@ const computeAudit = (config, fnoxKeys, today, aliasTable) => {
123
124
  const staleWarningDays = lifecycle.stale_warning_days ?? 90;
124
125
  const requireExpiration = lifecycle.require_expiration ?? false;
125
126
  const requireService = lifecycle.require_service ?? false;
126
- const keys = fnoxKeys ?? /* @__PURE__ */ new Set();
127
+ const keys = fnoxKeys ?? Set$1.empty();
127
128
  const secretEntries = config.secret ?? {};
128
129
  const nonAliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key === void 0);
129
130
  const aliasEntries = Object.entries(secretEntries).filter(([, meta]) => meta.from_key !== void 0);
130
131
  const nonAliasHealth = nonAliasEntries.map(([key, meta]) => classifySecret(key, meta, keys, staleWarningDays, requireExpiration, requireService, now));
131
132
  const healthByKey = Map$1(nonAliasHealth.map((h) => [h.key, h]));
132
133
  const parseTargetKey = (from_key) => {
133
- return Option(from_key.match(/^secret\.(.+)$/)?.[1]);
134
+ const match = from_key.match(/^secret\.(.+)$/);
135
+ return Option(match?.[1]);
134
136
  };
135
137
  const aliasHealth = aliasEntries.map(([key, meta]) => {
136
138
  const tableEntry = aliasTable?.entries.get(`secret.${key}`);
@@ -184,8 +186,10 @@ const computeEnvAudit = (config, env = process.env) => {
184
186
  const envWire = (key) => namer(key, envEntries[key]?.namespace);
185
187
  const resolveEffectiveDefault = (entry) => {
186
188
  return Do(function* () {
187
- const targetKey = yield* $(Option((yield* $(Option(entry.from_key))).match(/^env\.(.+)$/)?.[1]));
188
- return yield* $(Option((yield* $(Option(envEntries[targetKey]))).value));
189
+ const fk = yield* $(Option(entry.from_key));
190
+ const targetKey = yield* $(Option(fk.match(/^env\.(.+)$/)?.[1]));
191
+ const targetEntry = yield* $(Option(envEntries[targetKey]));
192
+ return yield* $(Option(targetEntry.value));
189
193
  }).orElse(entry.value ?? "");
190
194
  };
191
195
  const entries = Object.entries(envEntries).map(([key, entry]) => {
@@ -241,6 +245,21 @@ const NamespaceSchema = Type.Object({
241
245
  description: "Separator between prefix and key. Default '__' (shell-safe). Note: '.' and ':' are not valid in shell identifiers."
242
246
  }))
243
247
  }, { description: "Optional namespace/package prefix for injected environment variable names" });
248
+ const McpHeaderSchema = Type.Object({
249
+ server: Type.String({
250
+ minLength: 1,
251
+ description: "MCP server name, as keyed in .mcp.json"
252
+ }),
253
+ header: Type.Optional(Type.String({
254
+ default: "Authorization",
255
+ minLength: 1,
256
+ description: "HTTP header name to send the secret in"
257
+ })),
258
+ scheme: Type.Optional(Type.String({
259
+ default: "Bearer",
260
+ description: "Prefix placed before the value (e.g. 'Bearer'). Empty string sends the raw value."
261
+ }))
262
+ }, { description: "Send this secret as an HTTP header to an MCP server" });
244
263
  const SecretMetaSchema = Type.Object({
245
264
  service: Type.Optional(Type.String({ description: "Service or system this secret authenticates to" })),
246
265
  expires: Type.Optional(Type.String({
@@ -271,7 +290,8 @@ const SecretMetaSchema = Type.Object({
271
290
  required: Type.Optional(Type.Boolean({ description: "Whether this secret is required for operation" })),
272
291
  external: Type.Optional(Type.Boolean({ description: "Value is managed outside envpkt (e.g. a Cloudflare/Vault secret). The entry registers the name for inventory and dev/prod parity only: seal never touches it, and audit reports it as 'external' rather than 'missing'." })),
273
292
  tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value tags for grouping and filtering" })),
274
- namespace: Type.Optional(Type.String({ description: "Override the file-level namespace for this entry's injected name. Empty string opts out of any prefix." }))
293
+ namespace: Type.Optional(Type.String({ description: "Override the file-level namespace for this entry's injected name. Empty string opts out of any prefix." })),
294
+ mcp: Type.Optional(Type.Array(McpHeaderSchema, { description: "MCP servers this secret authenticates to. Read by `envpkt headers` (Claude Code headersHelper)." }))
275
295
  }, { description: "Metadata about a single secret" });
276
296
  const LifecycleConfigSchema = Type.Object({
277
297
  stale_warning_days: Type.Optional(Type.Number({
@@ -399,10 +419,13 @@ const discoverConfig = (cwd) => {
399
419
  template,
400
420
  expanded: expandPath(template)
401
421
  })).filter(({ expanded }) => expanded !== "" && !expanded.startsWith("/.envpkt")).map(({ expanded }) => expandGlobPath(expanded)).find((matches) => matches.length > 0);
402
- if (customMatch) return Option({
403
- path: customMatch[0],
404
- source: "search"
405
- });
422
+ if (customMatch) {
423
+ const found = {
424
+ path: customMatch[0],
425
+ source: "search"
426
+ };
427
+ return Option(found);
428
+ }
406
429
  const platformMatch = buildSearchPaths().find((p) => Fs.existsSync(p));
407
430
  if (platformMatch) return Option({
408
431
  path: platformMatch,
@@ -444,9 +467,10 @@ const parseToml = (raw) => Try(() => parse(raw)).fold((err) => Left({
444
467
  /** Validate parsed data against the TypeBox schema */
445
468
  const validateConfig = (data) => {
446
469
  if (compiledSchema.Check(data)) return Right(data);
470
+ const errors = List([...compiledSchema.Errors(data)].map((e) => `${e.path}: ${e.message}`));
447
471
  return Left({
448
472
  _tag: "ValidationError",
449
- errors: List([...compiledSchema.Errors(data)].map((e) => `${e.path}: ${e.message}`))
473
+ errors
450
474
  });
451
475
  };
452
476
  /** Load and validate an envpkt.toml from a file path */
@@ -856,7 +880,8 @@ const runAudit = (options) => {
856
880
  console.error(formatError(err));
857
881
  process.exit(2);
858
882
  }, (rawConfig) => {
859
- resolveConfig(rawConfig, dirname(path)).fold((err) => {
883
+ const configDir = dirname(path);
884
+ resolveConfig(rawConfig, configDir).fold((err) => {
860
885
  console.error(formatError(err));
861
886
  process.exit(2);
862
887
  }, (resolveResult) => {
@@ -898,7 +923,7 @@ const runAuditOnConfig = (config, options) => {
898
923
  secrets: audit.secrets.filter((s) => !!secretEntries[s.key].encrypted_value)
899
924
  };
900
925
  })() : audit;
901
- const afterExternal = options.external ? (() => {
926
+ const afterUnsealed = options.unsealed ? (() => {
902
927
  const secretEntries = config.secret ?? {};
903
928
  return {
904
929
  ...afterSealed,
@@ -906,9 +931,9 @@ const runAuditOnConfig = (config, options) => {
906
931
  };
907
932
  })() : afterSealed;
908
933
  const afterStatus = options.status ? {
909
- ...afterExternal,
910
- secrets: afterExternal.secrets.filter((s) => s.status === options.status)
911
- } : afterExternal;
934
+ ...afterUnsealed,
935
+ secrets: afterUnsealed.secrets.filter((s) => s.status === options.status)
936
+ } : afterUnsealed;
912
937
  const filtered = Option(options.expiring).fold(() => afterStatus, (expiring) => ({
913
938
  ...afterStatus,
914
939
  secrets: afterStatus.secrets.filter((s) => s.days_remaining.fold(() => false, (d) => d >= 0 && d <= expiring))
@@ -920,8 +945,10 @@ const runAuditOnConfig = (config, options) => {
920
945
  if (options.format === "json") console.log(formatAuditJson(sorted));
921
946
  else if (options.format === "minimal") console.log(formatAuditMinimal(sorted));
922
947
  else console.log(formatAudit(sorted));
923
- if (options.all) if (options.format === "json") console.log(formatEnvAuditJson(config));
924
- else formatEnvAuditTable(config);
948
+ if (options.all) {
949
+ if (options.format === "json") console.log(formatEnvAuditJson(config));
950
+ else formatEnvAuditTable(config);
951
+ }
925
952
  const code = options.strict ? exitCodeForAudit(audit) : audit.status === "critical" ? 2 : 0;
926
953
  process.exit(code);
927
954
  };
@@ -1497,7 +1524,8 @@ const bootSafe = (options) => {
1497
1524
  _tag: "ReadError",
1498
1525
  message: "unexpected"
1499
1526
  }));
1500
- const audit = computeAudit(config, detectFnoxKeys(configDir), void 0, aliasTable);
1527
+ const fnoxKeys = detectFnoxKeys(configDir);
1528
+ const audit = computeAudit(config, fnoxKeys, void 0, aliasTable);
1501
1529
  const sealIdentity = hasSealedValues ? resolveSealIdentity(config, configDir) : Option(void 0);
1502
1530
  if (hasSealedValues && sealIdentity.isEmpty) return Left({
1503
1531
  _tag: "SealKeyUnavailable",
@@ -1539,37 +1567,39 @@ const bootSafe = (options) => {
1539
1567
  }
1540
1568
  });
1541
1569
  const remainingKeys = nonAliasMetaKeys.filter((k) => !sealedKeys.has(k));
1542
- if (remainingKeys.length > 0) if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
1543
- log.warn("phase.fnox.export_failed", {
1544
- message: err.message,
1545
- skipped: remainingKeys.length
1546
- });
1547
- warnings.push(`fnox export failed: ${err.message}`);
1548
- skipped.push(...remainingKeys);
1549
- }, (exported) => {
1550
- const found = remainingKeys.filter((key) => key in exported);
1551
- const notFound = remainingKeys.filter((key) => !(key in exported));
1552
- found.forEach((key) => {
1553
- secrets[key] = exported[key];
1554
- log.debug("phase.fnox.resolved", {
1555
- key,
1556
- profile: opts.profile
1570
+ if (remainingKeys.length > 0) {
1571
+ if (fnoxAvailable()) fnoxExport(opts.profile, identityKey.orUndefined()).fold((err) => {
1572
+ log.warn("phase.fnox.export_failed", {
1573
+ message: err.message,
1574
+ skipped: remainingKeys.length
1557
1575
  });
1558
- });
1559
- notFound.forEach((key) => {
1560
- log.debug("phase.fnox.not_in_export", {
1561
- key,
1562
- profile: opts.profile
1576
+ warnings.push(`fnox export failed: ${err.message}`);
1577
+ skipped.push(...remainingKeys);
1578
+ }, (exported) => {
1579
+ const found = remainingKeys.filter((key) => key in exported);
1580
+ const notFound = remainingKeys.filter((key) => !(key in exported));
1581
+ found.forEach((key) => {
1582
+ secrets[key] = exported[key];
1583
+ log.debug("phase.fnox.resolved", {
1584
+ key,
1585
+ profile: opts.profile
1586
+ });
1563
1587
  });
1588
+ notFound.forEach((key) => {
1589
+ log.debug("phase.fnox.not_in_export", {
1590
+ key,
1591
+ profile: opts.profile
1592
+ });
1593
+ });
1594
+ injected.push(...found);
1595
+ skipped.push(...notFound);
1564
1596
  });
1565
- injected.push(...found);
1566
- skipped.push(...notFound);
1567
- });
1568
- else {
1569
- log.debug("phase.fnox.unavailable", { skipped: remainingKeys.length });
1570
- if (!hasSealedValues) warnings.push("fnox not available — no secrets injected");
1571
- else warnings.push("fnox not available — unsealed secrets could not be resolved");
1572
- skipped.push(...remainingKeys);
1597
+ else {
1598
+ log.debug("phase.fnox.unavailable", { skipped: remainingKeys.length });
1599
+ if (!hasSealedValues) warnings.push("fnox not available — no secrets injected");
1600
+ else warnings.push("fnox not available — unsealed secrets could not be resolved");
1601
+ skipped.push(...remainingKeys);
1602
+ }
1573
1603
  }
1574
1604
  aliasSecretKeys.forEach((aliasKey) => {
1575
1605
  const entry = aliasTable.entries.get(`secret.${aliasKey}`);
@@ -1635,7 +1665,8 @@ const tomlInlineTable = (rec) => {
1635
1665
  };
1636
1666
  /**
1637
1667
  * The SecretMeta to write into the destination on copy.
1638
- * - `created` is reset to today: the entry is new *here*, regardless of the source's age.
1668
+ * - `created` comes from the caller, never the source: today for an entry new to the
1669
+ * destination, or the destination's own `created` when the copy overwrites it.
1639
1670
  * - `last_rotated_at` is dropped — it's the source's rotation history, not the copy's.
1640
1671
  * - `encryptedValue` re-derives the ciphertext: `Some(cipher)` sets the resealed value,
1641
1672
  * `None` strips it entirely (a metadata-only copy of a secret with no sealed value).
@@ -1644,10 +1675,10 @@ const copyableSecretMeta = (meta, opts) => {
1644
1675
  const { last_rotated_at: _lra, encrypted_value: _ev, ...rest } = meta;
1645
1676
  return opts.encryptedValue.fold(() => ({
1646
1677
  ...rest,
1647
- created: opts.today
1678
+ created: opts.created
1648
1679
  }), (cipher) => ({
1649
1680
  ...rest,
1650
- created: opts.today,
1681
+ created: opts.created,
1651
1682
  encrypted_value: cipher
1652
1683
  }));
1653
1684
  };
@@ -1722,11 +1753,28 @@ const findSectionRange = (lines, sectionHeader) => {
1722
1753
  end: List(lines.slice(start + 1)).zipWithIndex().foldLeft(initial)((state, entry) => scanSectionBoundary(state, entry[0], start + 1 + entry[1])).end
1723
1754
  };
1724
1755
  };
1756
+ /**
1757
+ * End (exclusive) of a section's own lines: through its last field, before the blank and
1758
+ * comment lines that run up to the next header. Those trailing lines separate it from its
1759
+ * neighbour (or are the neighbour's doc comment), so edits to this section leave them alone.
1760
+ */
1761
+ const sectionBodyEnd = (lines, range) => {
1762
+ const lastContent = lines.slice(range.start, range.end).findLastIndex((l) => {
1763
+ const t = l.trim();
1764
+ return t !== "" && !t.startsWith("#");
1765
+ });
1766
+ return range.start + lastContent + 1;
1767
+ };
1768
+ const dropTrailingBlanks = (xs) => xs.slice(0, xs.findLastIndex((l) => l.trim() !== "") + 1);
1769
+ const dropLeadingBlanks = (xs) => {
1770
+ const first = xs.findIndex((l) => l.trim() !== "");
1771
+ return first === -1 ? [] : xs.slice(first);
1772
+ };
1725
1773
  /** Check whether a section header exists in the raw TOML */
1726
1774
  const sectionExists = (lines, sectionHeader) => lines.some((l) => l.trim() === sectionHeader);
1727
1775
  /**
1728
- * Remove a TOML section (e.g. `[secret.X]`) and all its fields through the next section or EOF.
1729
- * Strips trailing blank lines left behind.
1776
+ * Remove a TOML section (e.g. `[secret.X]`) and all its fields. A comment above the next
1777
+ * section stays. The sections on either side end up one blank line apart.
1730
1778
  */
1731
1779
  const removeSection = (raw, sectionHeader) => {
1732
1780
  const lines = raw.split("\n");
@@ -1735,11 +1783,33 @@ const removeSection = (raw, sectionHeader) => {
1735
1783
  _tag: "SectionNotFound",
1736
1784
  section: sectionHeader
1737
1785
  });
1738
- const after = lines.slice(range.end);
1739
- const beforeAll = lines.slice(0, range.start);
1740
- const lastNonBlank = beforeAll.findLastIndex((l) => l.trim() !== "");
1741
- const result = [...lastNonBlank === -1 ? [] : beforeAll.slice(0, lastNonBlank + 1), ...after].join("\n");
1742
- return Either.right(result);
1786
+ const before = dropTrailingBlanks(lines.slice(0, range.start));
1787
+ const after = dropLeadingBlanks(lines.slice(sectionBodyEnd(lines, range)));
1788
+ if (after.length === 0) return Either.right([...before, ...raw.endsWith("\n") ? [""] : []].join("\n"));
1789
+ if (before.length === 0) return Either.right(after.join("\n"));
1790
+ return Either.right([
1791
+ ...before,
1792
+ "",
1793
+ ...after
1794
+ ].join("\n"));
1795
+ };
1796
+ /**
1797
+ * Replace a TOML section with `block`, in place. Lines outside the section, including the
1798
+ * blank and comment lines between it and the next header, are left as they were.
1799
+ */
1800
+ const replaceSection = (raw, sectionHeader, block) => {
1801
+ const lines = raw.split("\n");
1802
+ const range = findSectionRange(lines, sectionHeader);
1803
+ if (!range) return Either.left({
1804
+ _tag: "SectionNotFound",
1805
+ section: sectionHeader
1806
+ });
1807
+ const replaced = [
1808
+ ...lines.slice(0, range.start),
1809
+ ...block.trimEnd().split("\n"),
1810
+ ...lines.slice(sectionBodyEnd(lines, range))
1811
+ ];
1812
+ return Either.right(replaced.join("\n"));
1743
1813
  };
1744
1814
  /**
1745
1815
  * Rename a TOML section header (e.g. `[secret.OLD]` → `[secret.NEW]`).
@@ -1964,16 +2034,114 @@ const sortConfigToml = (raw) => {
1964
2034
  return `${parts.join("\n\n")}\n`;
1965
2035
  };
1966
2036
  //#endregion
2037
+ //#region src/core/mcp-headers.ts
2038
+ const DEFAULT_HEADER = "Authorization";
2039
+ const DEFAULT_SCHEME = "Bearer";
2040
+ /** RFC 9110 `token`: the legal characters of a header name (and of an auth scheme). */
2041
+ const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
2042
+ /** Characters that would split or corrupt an HTTP header value. */
2043
+ const UNSAFE_VALUE_RE = /[\r\n\0]/;
2044
+ /** Flatten every `[secret.*].mcp` entry into one binding per (secret, server), with defaults applied. */
2045
+ const collectBindings = (config) => Object.entries(config.secret ?? {}).flatMap(([key, meta]) => (meta.mcp ?? []).map((m) => ({
2046
+ key,
2047
+ server: m.server,
2048
+ header: m.header ?? DEFAULT_HEADER,
2049
+ scheme: m.scheme ?? DEFAULT_SCHEME
2050
+ })));
2051
+ /**
2052
+ * Check bindings for structural problems: header names and schemes that are not HTTP
2053
+ * tokens, and two bindings claiming the same (server, header) pair. Header names compare
2054
+ * case-insensitively, as HTTP does.
2055
+ */
2056
+ const validateBindings = (bindings) => {
2057
+ const badHeader = bindings.find((b) => !TOKEN_RE.test(b.header));
2058
+ if (badHeader) return Left({
2059
+ _tag: "McpHeaderInvalid",
2060
+ key: badHeader.key,
2061
+ server: badHeader.server,
2062
+ header: badHeader.header
2063
+ });
2064
+ const badScheme = bindings.find((b) => b.scheme !== "" && !TOKEN_RE.test(b.scheme));
2065
+ if (badScheme) return Left({
2066
+ _tag: "McpSchemeInvalid",
2067
+ key: badScheme.key,
2068
+ server: badScheme.server,
2069
+ scheme: badScheme.scheme
2070
+ });
2071
+ const claims = bindings.reduce((acc, b) => {
2072
+ const id = `${b.server}\u0000${b.header.toLowerCase()}`;
2073
+ return {
2074
+ ...acc,
2075
+ [id]: [...acc[id] ?? [], b]
2076
+ };
2077
+ }, {});
2078
+ const clash = Object.values(claims).find((group) => group.length > 1);
2079
+ if (clash) {
2080
+ const first = clash[0];
2081
+ return Left({
2082
+ _tag: "McpHeaderDuplicate",
2083
+ server: first.server,
2084
+ header: first.header,
2085
+ keys: Set$1(clash.map((b) => b.key)).toArray()
2086
+ });
2087
+ }
2088
+ return Right(void 0);
2089
+ };
2090
+ /** Check every `mcp` binding in a config. Used by `envpkt validate` and the write gate. */
2091
+ const validateMcpHeaders = (config) => validateBindings(collectBindings(config));
2092
+ /**
2093
+ * Build the HTTP header object for one MCP server: every secret whose `mcp` list names
2094
+ * `server`, merged into one object. `values` is keyed by logical secret name (as in
2095
+ * `BootResult.secrets`), so namespaces and aliases are already resolved by the caller.
2096
+ * Only this server's bindings are checked, so a problem on another server never blocks it.
2097
+ */
2098
+ const buildMcpHeaders = (config, values, server) => {
2099
+ const matches = collectBindings(config).filter((b) => b.server === server);
2100
+ if (matches.length === 0) return Left({
2101
+ _tag: "McpServerUnknown",
2102
+ server
2103
+ });
2104
+ return validateBindings(matches).flatMap(() => {
2105
+ const unresolved = matches.filter((b) => !values[b.key]).map((b) => b.key);
2106
+ if (unresolved.length > 0) return Left({
2107
+ _tag: "McpValueUnresolved",
2108
+ server,
2109
+ keys: unresolved
2110
+ });
2111
+ const unsafe = matches.filter((b) => UNSAFE_VALUE_RE.test(values[b.key])).map((b) => b.key);
2112
+ if (unsafe.length > 0) return Left({
2113
+ _tag: "McpValueInvalid",
2114
+ server,
2115
+ keys: unsafe
2116
+ });
2117
+ return Right(Object.fromEntries(matches.map((b) => {
2118
+ const value = values[b.key];
2119
+ return [b.header, b.scheme === "" ? value : `${b.scheme} ${value}`];
2120
+ })));
2121
+ });
2122
+ };
2123
+ /** Human-readable one-liner for any McpHeadersError tag. Never includes a secret value. */
2124
+ const formatMcpHeadersError = (error) => {
2125
+ switch (error._tag) {
2126
+ case "McpHeaderInvalid": return `[secret.${error.key}] mcp server "${error.server}": header "${error.header}" is not a valid HTTP header name`;
2127
+ case "McpSchemeInvalid": return `[secret.${error.key}] mcp server "${error.server}": scheme "${error.scheme}" must be a single word or ""`;
2128
+ case "McpHeaderDuplicate": return `MCP server "${error.server}": header "${error.header}" is claimed by more than one binding: ${error.keys.join(", ")}`;
2129
+ case "McpServerUnknown": return `No secret declares mcp server "${error.server}"`;
2130
+ case "McpValueUnresolved": return `MCP server "${error.server}": no value resolved for ${error.keys.join(", ")}`;
2131
+ case "McpValueInvalid": return `MCP server "${error.server}": value of ${error.keys.join(", ")} contains a line break or NUL and cannot be sent as a header`;
2132
+ }
2133
+ };
2134
+ //#endregion
1967
2135
  //#region src/core/validate.ts
1968
2136
  /**
1969
- * Validate a raw TOML string as a complete envpkt config: parse → schema → aliases.
2137
+ * Validate a raw TOML string as a complete envpkt config: parse → schema → aliases → mcp.
1970
2138
  *
1971
2139
  * Used by write-path CLI commands to verify the post-edit file would still be
1972
2140
  * structurally valid before persisting. Catalog resolution is intentionally
1973
2141
  * excluded — catalog issues depend on external files, not on the local edit,
1974
2142
  * and `envpkt validate` covers them as a separate explicit check.
1975
2143
  */
1976
- const validateRawConfig = (raw) => parseToml(raw).flatMap(validateConfig).flatMap((config) => validateAliases(config).map(() => config));
2144
+ const validateRawConfig = (raw) => parseToml(raw).flatMap(validateConfig).flatMap((config) => validateAliases(config).map(() => config)).flatMap((config) => validateMcpHeaders(config).map(() => config));
1977
2145
  /** Human-readable one-liner for any ValidationError tag. */
1978
2146
  const formatValidationError = (err) => {
1979
2147
  switch (err._tag) {
@@ -1987,6 +2155,9 @@ const formatValidationError = (err) => {
1987
2155
  case "AliasChained":
1988
2156
  case "AliasCrossType":
1989
2157
  case "AliasValueConflict": return formatAliasError(err);
2158
+ case "McpHeaderInvalid":
2159
+ case "McpSchemeInvalid":
2160
+ case "McpHeaderDuplicate": return formatMcpHeadersError(err);
1990
2161
  }
1991
2162
  };
1992
2163
  //#endregion
@@ -2043,14 +2214,15 @@ const todayIso$2 = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0
2043
2214
  * Build the `[secret.<destName>]` block for a copy: unseal the source value with the
2044
2215
  * source identity and reseal it for the destination recipient. Secrets with no sealed
2045
2216
  * value are copied as metadata only (with a warning) — there's nothing to reseal.
2217
+ * Overwriting an existing destination entry keeps its `created`; a new entry gets today.
2046
2218
  */
2047
2219
  const sealedSecretBlock = (key, destName, srcConfig, srcPath, destConfig) => {
2048
2220
  const meta = srcConfig.secret[key];
2049
- const today = todayIso$2();
2221
+ const created = destConfig.secret?.[destName]?.created ?? todayIso$2();
2050
2222
  if (meta.encrypted_value === void 0 || meta.encrypted_value === "") {
2051
2223
  console.error(`${YELLOW}Warning:${RESET} secret "${key}" has no sealed value — copying metadata only.`);
2052
2224
  return serializeSecretBlock(destName, copyableSecretMeta(meta, {
2053
- today,
2225
+ created,
2054
2226
  encryptedValue: Option.none()
2055
2227
  }));
2056
2228
  }
@@ -2073,14 +2245,15 @@ const sealedSecretBlock = (key, destName, srcConfig, srcPath, destConfig) => {
2073
2245
  console.error(`${RED}Error:${RESET} "${key}" produced no plaintext on unseal.`);
2074
2246
  return process.exit(2);
2075
2247
  }
2248
+ const cipher = orExit(ageEncrypt(plaintext, recipient), (err) => `${RED}Error:${RESET} reseal failed for "${destName}": ${err.message}`, 2);
2076
2249
  return serializeSecretBlock(destName, copyableSecretMeta(meta, {
2077
- today,
2078
- encryptedValue: Option(orExit(ageEncrypt(plaintext, recipient), (err) => `${RED}Error:${RESET} reseal failed for "${destName}": ${err.message}`, 2))
2250
+ created,
2251
+ encryptedValue: Option(cipher)
2079
2252
  }));
2080
2253
  };
2081
2254
  const writeBlock = (destPath, header, block, overwrite, dryRun, successMsg) => {
2082
2255
  const raw = readFileSync(destPath, "utf-8");
2083
- const updated = appendSection(overwrite ? orExit(removeSection(raw, header), (err) => `${RED}Error:${RESET} ${err._tag}: ${err.section}`, 2) : raw, block);
2256
+ const updated = overwrite ? orExit(replaceSection(raw, header, block), (err) => `${RED}Error:${RESET} ${err._tag}: ${err.section}`, 2) : appendSection(raw, block);
2084
2257
  if (dryRun) {
2085
2258
  previewIfValid(updated, block);
2086
2259
  return;
@@ -2121,7 +2294,8 @@ const runCopy = (key, options) => {
2121
2294
  process.exit(1);
2122
2295
  }
2123
2296
  const block = inSecret ? sealedSecretBlock(key, destName, srcConfig, src.path, destConfig) : serializeEnvBlock(destName, srcConfig.env[key]);
2124
- const successMsg = `${GREEN}✓${RESET} Copied ${kindWord} ${BOLD}${key}${RESET}${destName !== key ? ` → ${BOLD}${destName}${RESET}` : ""} to ${CYAN}${dest.path}${RESET}`;
2297
+ const renamed = destName !== key ? ` → ${BOLD}${destName}${RESET}` : "";
2298
+ const successMsg = `${GREEN}✓${RESET} Copied ${kindWord} ${BOLD}${key}${RESET}${renamed} to ${CYAN}${dest.path}${RESET}`;
2125
2299
  writeBlock(dest.path, `[${kindWord}.${destName}]`, block, existsInDest, options.dryRun === true, successMsg);
2126
2300
  };
2127
2301
  //#endregion
@@ -2906,7 +3080,8 @@ const VALUE_SHAPE_PATTERNS = [
2906
3080
  ];
2907
3081
  /** Detect service from value prefix/shape */
2908
3082
  const matchValueShape = (value) => {
2909
- return Option(VALUE_SHAPE_PATTERNS.find((vp) => value.startsWith(vp.prefix))).map((vp) => ({
3083
+ const match = VALUE_SHAPE_PATTERNS.find((vp) => value.startsWith(vp.prefix));
3084
+ return Option(match).map((vp) => ({
2910
3085
  service: vp.service,
2911
3086
  description: vp.description
2912
3087
  }));
@@ -2946,7 +3121,8 @@ const matchEnvVar = (name, value) => {
2946
3121
  matchedBy: `exact:${exactMatch.pattern}`
2947
3122
  });
2948
3123
  return matchValueShape(value).fold(() => {
2949
- return Option(SUFFIX_PATTERNS.find((sp) => name.endsWith(sp.suffix))).map((sp) => ({
3124
+ const suffixMatch = SUFFIX_PATTERNS.find((sp) => name.endsWith(sp.suffix));
3125
+ return Option(suffixMatch).map((sp) => ({
2950
3126
  envVar: name,
2951
3127
  value,
2952
3128
  service: Option(deriveServiceFromName(name)),
@@ -3148,7 +3324,8 @@ const runEnvCheck = (options) => {
3148
3324
  console.error(formatError(err));
3149
3325
  process.exit(2);
3150
3326
  }, (rawConfig) => {
3151
- resolveConfig(rawConfig, dirname(path)).fold((err) => {
3327
+ const configDir = dirname(path);
3328
+ resolveConfig(rawConfig, configDir).fold((err) => {
3152
3329
  console.error(formatError(err));
3153
3330
  process.exit(2);
3154
3331
  }, (resolveResult) => {
@@ -3319,7 +3496,9 @@ const runEnvAdd = (name, value, options) => {
3319
3496
  console.log(block);
3320
3497
  return;
3321
3498
  }
3322
- writeIfValid(configPath, appendSection(readFileSync(configPath, "utf-8"), block), `${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
3499
+ const raw = readFileSync(configPath, "utf-8");
3500
+ const updated = appendSection(raw, block);
3501
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Added ${BOLD}${name}${RESET} to ${CYAN}${configPath}${RESET}`);
3323
3502
  });
3324
3503
  });
3325
3504
  };
@@ -3440,7 +3619,9 @@ const runEnvAlias = (name, options) => {
3440
3619
  return;
3441
3620
  }
3442
3621
  const raw = readFileSync(configPath, "utf-8");
3443
- writeIfValid(configPath, appendSection(existing ? removeSection(raw, `[env.${name}]`).fold(() => raw, (r) => r) : raw, block), `${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
3622
+ const base = existing ? removeSection(raw, `[env.${name}]`).fold(() => raw, (r) => r) : raw;
3623
+ const updated = appendSection(base, block);
3624
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
3444
3625
  });
3445
3626
  });
3446
3627
  };
@@ -3565,7 +3746,7 @@ function* findEnvpktFiles(dir, maxDepth, currentDepth = 0) {
3565
3746
  }
3566
3747
  const scanFleet = (rootDir, options) => {
3567
3748
  const maxDepth = options?.maxDepth ?? 3;
3568
- const agentList = List(Array.from(findEnvpktFiles(rootDir, maxDepth)).flatMap((configPath) => loadConfig(configPath).fold(() => [], (config) => {
3749
+ const agents = Array.from(findEnvpktFiles(rootDir, maxDepth)).flatMap((configPath) => loadConfig(configPath).fold(() => [], (config) => {
3569
3750
  const audit = computeAudit(config);
3570
3751
  return [{
3571
3752
  path: configPath,
@@ -3573,7 +3754,8 @@ const scanFleet = (rootDir, options) => {
3573
3754
  min_expiry_days: audit.secrets.toArray().reduce((min, s) => s.days_remaining.fold(() => min, (d) => min.fold(() => Option(d), (m) => Option(Math.min(m, d)))), Option(void 0)).orUndefined(),
3574
3755
  audit
3575
3756
  }];
3576
- })));
3757
+ }));
3758
+ const agentList = List(agents);
3577
3759
  const total_agents = agentList.size;
3578
3760
  const total_secrets = agentList.toArray().reduce((acc, a) => acc + a.audit.total, 0);
3579
3761
  const expired = agentList.toArray().reduce((acc, a) => acc + a.audit.expired, 0);
@@ -3599,7 +3781,8 @@ const statusIcon = (status) => {
3599
3781
  }
3600
3782
  };
3601
3783
  const runFleet = (options) => {
3602
- const fleet = scanFleet(resolve(options.dir ?? "."), { maxDepth: options.depth });
3784
+ const rootDir = resolve(options.dir ?? ".");
3785
+ const fleet = scanFleet(rootDir, { maxDepth: options.depth });
3603
3786
  if (options.format === "json") {
3604
3787
  console.log(formatFleetJson(fleet));
3605
3788
  process.exit(fleet.status === "critical" ? 2 : 0);
@@ -3618,6 +3801,36 @@ const runFleet = (options) => {
3618
3801
  process.exit(fleet.status === "critical" ? 2 : 0);
3619
3802
  };
3620
3803
  //#endregion
3804
+ //#region src/cli/commands/headers.ts
3805
+ const SERVER_ENV = "CLAUDE_CODE_MCP_SERVER_NAME";
3806
+ /**
3807
+ * Claude Code `headersHelper` entry point: print the HTTP headers for one MCP server as a
3808
+ * JSON object. stdout carries the JSON and nothing else; every diagnostic goes to stderr,
3809
+ * because Claude parses stdout verbatim.
3810
+ */
3811
+ const runHeaders = (serverArg, options) => {
3812
+ const server = serverArg ?? process.env[SERVER_ENV];
3813
+ if (!server) {
3814
+ console.error(`${RED}Error:${RESET} No MCP server named. Pass [server] or set ${SERVER_ENV}.`);
3815
+ process.exit(2);
3816
+ }
3817
+ resolveForEmit(options).fold((err) => {
3818
+ console.error(`MCP server "${server}":\n${formatError(err)}`);
3819
+ process.exit(2);
3820
+ }, (boot) => {
3821
+ emitWarnings(boot);
3822
+ loadConfig(boot.configPath).flatMap((config) => resolveConfig(config, dirname(boot.configPath)).map((r) => r.config)).fold((err) => {
3823
+ console.error(`MCP server "${server}":\n${formatError(err)}`);
3824
+ return process.exit(2);
3825
+ }, (config) => buildMcpHeaders(config, boot.secrets, server)).fold((err) => {
3826
+ console.error(`${RED}Error:${RESET} ${formatMcpHeadersError(err)}`);
3827
+ process.exit(1);
3828
+ }, (map) => {
3829
+ process.stdout.write(`${JSON.stringify(map)}\n`);
3830
+ });
3831
+ });
3832
+ };
3833
+ //#endregion
3621
3834
  //#region src/cli/commands/init.ts
3622
3835
  const CONFIG_FILENAME = "envpkt.toml";
3623
3836
  /** Resolve the top-level `scope` to scaffold: explicit --scope wins; --global implies "shell". */
@@ -3893,7 +4106,8 @@ const deriveKeyPath = (configPath) => {
3893
4106
  };
3894
4107
  const runKeygen = (options) => {
3895
4108
  const configPath = resolve(options.config ?? join(process.cwd(), "envpkt.toml"));
3896
- generateKeypair({ outputPath: options.output ?? (options.global ? resolveKeyPath() : deriveKeyPath(configPath)) }).fold((err) => {
4109
+ const outputPath = options.output ?? (options.global ? resolveKeyPath() : deriveKeyPath(configPath));
4110
+ generateKeypair({ outputPath }).fold((err) => {
3897
4111
  if (err._tag === "KeyExists") {
3898
4112
  console.error(`${YELLOW}Warning:${RESET} Identity file already exists: ${CYAN}${err.path}${RESET}`);
3899
4113
  console.error(`${DIM}To replace it: remove the file first, then re-run keygen.${RESET}`);
@@ -4263,7 +4477,8 @@ const runResolve = (options) => {
4263
4477
  console.error(formatError(err));
4264
4478
  process.exit(2);
4265
4479
  }, (config) => {
4266
- resolveConfig(config, dirname(configPath)).fold((err) => {
4480
+ const configDir = dirname(configPath);
4481
+ resolveConfig(config, configDir).fold((err) => {
4267
4482
  console.error(formatError(err));
4268
4483
  process.exit(2);
4269
4484
  }, (result) => {
@@ -4419,13 +4634,18 @@ const applySealedToml = (raw, sealedMeta) => {
4419
4634
  };
4420
4635
  return flushPending(List(lines).zipWithIndex().foldLeft(initial)((state, entry) => step(state, entry[0], entry[1]))).output.join("\n");
4421
4636
  };
4637
+ /**
4638
+ * A value with no non-whitespace content. Unsealing trims, so such a value would come back as
4639
+ * `""` — it is treated as empty on both the prompt and stdin paths.
4640
+ */
4641
+ const isBlank = (value) => value.trim() === "";
4422
4642
  /** Affirmative answer to a `[y/N]` confirm prompt. */
4423
4643
  const isAffirmative = (answer) => /^y(es)?$/i.test(answer.trim());
4424
4644
  /**
4425
4645
  * Collect new plaintext values for the `--edit`ed keys via the injected `prompt`. Before
4426
4646
  * overwriting an entry that is **already sealed** (`encrypted_value` present), require an
4427
4647
  * explicit confirmation — replacing it discards the only ciphertext, and the prior value
4428
- * can't be recovered without the original key. Declined or empty entries are skipped (reported
4648
+ * can't be recovered without the original key. Declined or empty (whitespace-only) entries are skipped (reported
4429
4649
  * via `onSkip`). I/O flows only through `prompt`/`onSkip`, so the confirm/skip logic is
4430
4650
  * unit-testable without a TTY.
4431
4651
  */
@@ -4440,7 +4660,7 @@ const collectEditedValues = async (editKeys, entries, prompt, onSkip) => {
4440
4660
  }
4441
4661
  }
4442
4662
  const value = await prompt(`Enter new value for ${key}: `);
4443
- if (value === "") {
4663
+ if (isBlank(value)) {
4444
4664
  onSkip?.(key, "empty");
4445
4665
  continue;
4446
4666
  }
@@ -4448,6 +4668,23 @@ const collectEditedValues = async (editKeys, entries, prompt, onSkip) => {
4448
4668
  }
4449
4669
  return values;
4450
4670
  };
4671
+ /**
4672
+ * Pick the `--edit` input source. Stdin is used when `--stdin` is passed or no terminal is
4673
+ * attached; it carries one value, so it needs exactly one key. Left holds the error message.
4674
+ */
4675
+ const resolveEditInput = (keyCount, opts) => {
4676
+ if (!opts.stdin && opts.isTTY) return Right("prompt");
4677
+ if (keyCount !== 1) {
4678
+ const why = opts.stdin ? "--stdin" : "Without an interactive terminal, --edit";
4679
+ return Left(`${why} reads one value from stdin, so it takes exactly one key (got ${keyCount}). Seal one key at a time.`);
4680
+ }
4681
+ return Right("stdin");
4682
+ };
4683
+ /** Strip one trailing newline (`\n` or `\r\n`) from piped input. None when nothing but whitespace is left. */
4684
+ const parseStdinValue = (raw) => {
4685
+ const value = raw.replace(/\r?\n$/, "");
4686
+ return isBlank(value) ? Option.none() : Option(value);
4687
+ };
4451
4688
  /** Write sealed values back into the TOML file, preserving structure. */
4452
4689
  const writeSealedToml = (configPath, sealedMeta) => {
4453
4690
  const raw = readFileSync(configPath, "utf-8");
@@ -4455,6 +4692,35 @@ const writeSealedToml = (configPath, sealedMeta) => {
4455
4692
  validateOrExit(finalContent);
4456
4693
  writeFileSync(configPath, finalContent);
4457
4694
  };
4695
+ /**
4696
+ * Read one `--edit` value from stdin. The value is never echoed or logged. Piping a value is
4697
+ * an explicit request to replace it, so a sealed entry is replaced without the confirm prompt.
4698
+ */
4699
+ const readEditedValueFromStdin = async (key) => {
4700
+ const endHint = process.stdin.isTTY ? " (end with Ctrl-D)" : "";
4701
+ console.error(`${DIM}Reading the value for ${key} from stdin${endHint}${RESET}`);
4702
+ return parseStdinValue(await text(process.stdin)).fold(() => {
4703
+ console.error(`${RED}Error:${RESET} No value on stdin for ${key}`);
4704
+ console.error(`${DIM}Pipe the value in, e.g. ${CYAN}pbpaste | envpkt seal --edit ${key}${RESET}${DIM}, or run in a terminal to be prompted.${RESET}`);
4705
+ process.exit(2);
4706
+ return {};
4707
+ }, (value) => ({ [key]: value }));
4708
+ };
4709
+ /** Prompt for each `--edit` value on the terminal, confirming before replacing a sealed one. */
4710
+ const promptEditedValues = async (editKeys, secretEntries) => {
4711
+ const rl = await import("node:readline").then((m) => m.createInterface({
4712
+ input: process.stdin,
4713
+ output: process.stderr
4714
+ }));
4715
+ const prompt = (question) => new Promise((resolve) => {
4716
+ rl.question(question, (answer) => resolve(answer));
4717
+ });
4718
+ const values = await collectEditedValues(editKeys, secretEntries, prompt, (key, reason) => {
4719
+ console.error(`${YELLOW}Skipped${RESET} ${key} (${reason === "empty" ? "empty value" : "kept existing sealed value"})`);
4720
+ });
4721
+ rl.close();
4722
+ return values;
4723
+ };
4458
4724
  const runSeal = async (options) => {
4459
4725
  const { path: configPath, source: configSource } = resolveConfigPath(options.config).fold((err) => {
4460
4726
  console.error(formatError(err));
@@ -4490,13 +4756,18 @@ const runSeal = async (options) => {
4490
4756
  process.exit(2);
4491
4757
  }
4492
4758
  const identityKey = Option(config.identity.key_file).flatMap((keyFile) => {
4493
- return unwrapAgentKey(resolve(configDir, expandPath(keyFile))).fold((err) => {
4759
+ const identityPath = resolve(configDir, expandPath(keyFile));
4760
+ return unwrapAgentKey(identityPath).fold((err) => {
4494
4761
  const msg = err._tag === "IdentityNotFound" ? `not found: ${err.path}` : err.message;
4495
4762
  console.error(`${YELLOW}Warning:${RESET} Could not unwrap agent key: ${msg}`);
4496
4763
  return Option.none();
4497
4764
  }, (k) => Option(k));
4498
4765
  });
4499
4766
  const editKeys = options.edit ? options.edit.split(",").map((k) => k.trim()).filter((k) => k.length > 0) : [];
4767
+ if (options.stdin && editKeys.length === 0) {
4768
+ console.error(`${RED}Error:${RESET} --stdin needs --edit <key> to name the secret the value is for`);
4769
+ process.exit(2);
4770
+ }
4500
4771
  if (editKeys.length > 0) {
4501
4772
  const allSecretEntries = config.secret ?? {};
4502
4773
  const unknownKeys = editKeys.filter((k) => !(k in allSecretEntries));
@@ -4517,24 +4788,18 @@ const runSeal = async (options) => {
4517
4788
  console.error(`${DIM}external = true means the value is managed outside envpkt — set it in that store instead.${RESET}`);
4518
4789
  process.exit(2);
4519
4790
  }
4520
- if (!process.stdin.isTTY) {
4521
- console.error(`${RED}Error:${RESET} --edit requires an interactive terminal`);
4791
+ const input = resolveEditInput(editKeys.length, {
4792
+ stdin: options.stdin === true,
4793
+ isTTY: process.stdin.isTTY === true
4794
+ }).fold((msg) => {
4795
+ console.error(`${RED}Error:${RESET} ${msg}`);
4522
4796
  process.exit(2);
4523
- }
4797
+ return "prompt";
4798
+ }, (i) => i);
4524
4799
  const secretEntries = Object.fromEntries(editKeys.map((k) => [k, allSecretEntries[k]]));
4525
4800
  console.log(`${BOLD}Editing ${editKeys.length} secret(s)${RESET} with recipient ${CYAN}${recipient.slice(0, 20)}...${RESET}`);
4526
4801
  console.log("");
4527
- const rl = await import("node:readline").then((m) => m.createInterface({
4528
- input: process.stdin,
4529
- output: process.stderr
4530
- }));
4531
- const prompt = (question) => new Promise((resolve) => {
4532
- rl.question(question, (answer) => resolve(answer));
4533
- });
4534
- const values = await collectEditedValues(editKeys, secretEntries, prompt, (key, reason) => {
4535
- console.error(`${YELLOW}Skipped${RESET} ${key} (${reason === "empty" ? "empty value" : "kept existing sealed value"})`);
4536
- });
4537
- rl.close();
4802
+ const values = input === "stdin" ? await readEditedValueFromStdin(editKeys[0]) : await promptEditedValues(editKeys, secretEntries);
4538
4803
  if (Object.keys(values).length === 0) {
4539
4804
  console.error(`${RED}Error:${RESET} No values provided`);
4540
4805
  process.exit(2);
@@ -4586,7 +4851,8 @@ const runSeal = async (options) => {
4586
4851
  console.error(`${DIM} 3. ~/.envpkt/age-key.txt${RESET}`);
4587
4852
  process.exit(2);
4588
4853
  }
4589
- const decrypted = unsealSecrets(Object.fromEntries(alreadySealed.map((k) => [k, allSecretEntries[k]])), identityPath).fold((err) => {
4854
+ const sealedEntries = Object.fromEntries(alreadySealed.map((k) => [k, allSecretEntries[k]]));
4855
+ const decrypted = unsealSecrets(sealedEntries, identityPath).fold((err) => {
4590
4856
  console.error(`${RED}Error:${RESET} Failed to decrypt existing secrets: ${err.message}`);
4591
4857
  process.exit(2);
4592
4858
  return {};
@@ -4723,7 +4989,8 @@ const runSecretAdd = (name, options) => {
4723
4989
  process.exit(1);
4724
4990
  }
4725
4991
  const block = buildSecretBlock(name, options);
4726
- const updated = appendSection(readFileSync(configPath, "utf-8"), block);
4992
+ const raw = readFileSync(configPath, "utf-8");
4993
+ const updated = appendSection(raw, block);
4727
4994
  if (options.dryRun) {
4728
4995
  previewIfValid(updated, block);
4729
4996
  return;
@@ -4864,7 +5131,9 @@ const runSecretAlias = (name, options) => {
4864
5131
  return;
4865
5132
  }
4866
5133
  const raw = readFileSync(configPath, "utf-8");
4867
- writeIfValid(configPath, appendSection(existing ? removeSection(raw, `[secret.${name}]`).fold(() => raw, (r) => r) : raw, block), `${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
5134
+ const base = existing ? removeSection(raw, `[secret.${name}]`).fold(() => raw, (r) => r) : raw;
5135
+ const updated = appendSection(base, block);
5136
+ writeIfValid(configPath, updated, `${GREEN}✓${RESET} Aliased ${BOLD}${name}${RESET} → ${BOLD}${options.from}${RESET} in ${CYAN}${configPath}${RESET}`);
4868
5137
  });
4869
5138
  });
4870
5139
  };
@@ -4947,7 +5216,8 @@ const runSecretRotate = async (name, options) => {
4947
5216
  process.exit(2);
4948
5217
  return "";
4949
5218
  }, (ct) => ct);
4950
- updateSectionFields(applySealedToml(raw, { [name]: { encrypted_value: ciphertext } }), `[secret.${name}]`, { last_rotated_at: `"${today}"` }).fold((err) => {
5219
+ const withSealed = applySealedToml(raw, { [name]: { encrypted_value: ciphertext } });
5220
+ updateSectionFields(withSealed, `[secret.${name}]`, { last_rotated_at: `"${today}"` }).fold((err) => {
4951
5221
  console.error(`${RED}Error:${RESET} ${err._tag}: ${err.section}`);
4952
5222
  process.exit(2);
4953
5223
  }, (result) => {
@@ -5182,6 +5452,7 @@ const CHECK_NAMES = {
5182
5452
  schema: "Schema",
5183
5453
  catalog: "Catalog",
5184
5454
  aliases: "Aliases",
5455
+ mcp: "MCP headers",
5185
5456
  sealed: "Sealed blocks"
5186
5457
  };
5187
5458
  /** Structural sanity scan over each [secret.*].encrypted_value PEM block. No decryption. */
@@ -5210,6 +5481,24 @@ const checkSealedBlocks = (config) => {
5210
5481
  error: broken.join("; ")
5211
5482
  };
5212
5483
  };
5484
+ /** `mcp` bindings: valid header names and schemes, no (server, header) pair claimed twice. */
5485
+ const checkMcpHeaders = (config) => {
5486
+ const count = Object.values(config.secret ?? {}).reduce((n, meta) => n + (meta.mcp?.length ?? 0), 0);
5487
+ if (count === 0) return {
5488
+ name: CHECK_NAMES.mcp,
5489
+ status: "na",
5490
+ detail: "no mcp bindings"
5491
+ };
5492
+ return validateMcpHeaders(config).fold((err) => ({
5493
+ name: CHECK_NAMES.mcp,
5494
+ status: "failed",
5495
+ error: formatValidationError(err)
5496
+ }), () => ({
5497
+ name: CHECK_NAMES.mcp,
5498
+ status: "ok",
5499
+ detail: `${count} binding(s)`
5500
+ }));
5501
+ };
5213
5502
  const skipped = (name) => ({
5214
5503
  name,
5215
5504
  status: "skipped"
@@ -5227,6 +5516,7 @@ const buildReport = (path, raw) => {
5227
5516
  skipped(CHECK_NAMES.schema),
5228
5517
  skipped(CHECK_NAMES.catalog),
5229
5518
  skipped(CHECK_NAMES.aliases),
5519
+ skipped(CHECK_NAMES.mcp),
5230
5520
  skipped(CHECK_NAMES.sealed)
5231
5521
  ]
5232
5522
  }), (data) => {
@@ -5246,6 +5536,7 @@ const buildReport = (path, raw) => {
5246
5536
  },
5247
5537
  skipped(CHECK_NAMES.catalog),
5248
5538
  skipped(CHECK_NAMES.aliases),
5539
+ skipped(CHECK_NAMES.mcp),
5249
5540
  skipped(CHECK_NAMES.sealed)
5250
5541
  ]
5251
5542
  }), (config) => {
@@ -5275,12 +5566,15 @@ const buildReport = (path, raw) => {
5275
5566
  status: "ok",
5276
5567
  detail: `${table.entries.size} alias(es)`
5277
5568
  }));
5569
+ const merged = config.catalog ? resolveConfig(config, dirname(path)).fold(() => config, (r) => r.config) : config;
5570
+ const mcpCheck = checkMcpHeaders(merged);
5278
5571
  const sealedCheck = checkSealedBlocks(config);
5279
5572
  const checks = [
5280
5573
  tomlOk,
5281
5574
  schemaOk,
5282
5575
  catalogCheck,
5283
5576
  aliasCheck,
5577
+ mcpCheck,
5284
5578
  sealedCheck
5285
5579
  ];
5286
5580
  return {
@@ -5303,7 +5597,9 @@ const renderCheckLines = (check) => {
5303
5597
  }
5304
5598
  };
5305
5599
  const formatTextReport = (report, sourceMsg) => {
5306
- const header = `${report.ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`} ${BOLD}${report.ok ? `${GREEN}VALID${RESET}` : `${RED}INVALID${RESET}`}${RESET} — ${CYAN}${report.configPath}${RESET}`;
5600
+ const headerIcon = report.ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
5601
+ const headerLabel = report.ok ? `${GREEN}VALID${RESET}` : `${RED}INVALID${RESET}`;
5602
+ const header = `${headerIcon} ${BOLD}${headerLabel}${RESET} — ${CYAN}${report.configPath}${RESET}`;
5307
5603
  const checkLines = report.checks.flatMap((c) => [...renderCheckLines(c)]);
5308
5604
  return [
5309
5605
  ...sourceMsg ? [sourceMsg] : [],
@@ -5382,13 +5678,13 @@ program.command("init").description("Initialize a new envpkt.toml in the current
5382
5678
  program.command("keygen").description("Generate an age keypair for sealing secrets — run this before `seal` if you don't have a key yet").option("-c, --config <path>", "Path to envpkt.toml (updates identity.recipient if found)").option("-o, --output <path>", "Output path for identity file (default: ~/.envpkt/<project>-key.txt)").option("--global", "Write key to the shared default path (~/.envpkt/age-key.txt) instead of a project-specific one").action((options) => {
5383
5679
  runKeygen(options);
5384
5680
  });
5385
- program.command("audit").description("Audit credential health from envpkt.toml (use --strict in CI pipelines to gate deploys)").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json | minimal", "table").option("--expiring <days>", "Show secrets expiring within N days", parseInt).option("--status <status>", "Filter by status: healthy | expiring_soon | expired | stale | missing").option("--strict", "Exit non-zero on any non-healthy secret").option("--all", "Show both secrets and env defaults").option("--env-only", "Show only env defaults (drift detection)").option("--sealed", "Show only secrets with encrypted_value").option("--external", "Show only secrets without encrypted_value").option("--sort", "Sort secrets alphabetically within each status bucket (no file mutation)").action((options) => {
5681
+ program.command("audit").description("Audit credential health from envpkt.toml (use --strict in CI pipelines to gate deploys)").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json | minimal", "table").option("--expiring <days>", "Show secrets expiring within N days", parseInt).option("--status <status>", "Filter by status: healthy | expiring_soon | expired | stale | missing | missing_metadata | external").option("--strict", "Exit non-zero on any non-healthy secret").option("--all", "Show both secrets and env defaults").option("--env-only", "Show only env defaults (drift detection)").option("--sealed", "Show only secrets with encrypted_value").option("--unsealed", "Show only secrets without encrypted_value (resolved at runtime or external)").option("--sort", "Sort secrets alphabetically within each status bucket (no file mutation)").action((options) => {
5386
5682
  runAudit(options);
5387
5683
  });
5388
5684
  program.command("fleet").description("Scan directory tree for envpkt.toml files and aggregate health (use in CI for fleet-wide monitoring)").option("-d, --dir <path>", "Root directory to scan", ".").option("--depth <n>", "Max directory depth", parseInt).option("--format <format>", "Output format: table | json", "table").option("--status <status>", "Filter agents by health status").action((options) => {
5389
5685
  runFleet(options);
5390
5686
  });
5391
- program.command("validate").description("Verify envpkt.toml integrity — runs TOML syntax, schema, catalog, alias, and sealed-block structural checks").option("-c, --config <path>", "Path to envpkt.toml").option("--json", "Output structured JSON instead of human-readable text").action((options) => {
5687
+ program.command("validate").description("Verify envpkt.toml integrity — runs TOML syntax, schema, catalog, alias, MCP header, and sealed-block structural checks").option("-c, --config <path>", "Path to envpkt.toml").option("--json", "Output structured JSON instead of human-readable text").action((options) => {
5392
5688
  runValidate(options);
5393
5689
  });
5394
5690
  program.command("inspect").description("Display structured view of envpkt.toml").option("-c, --config <path>", "Path to envpkt.toml").option("--format <format>", "Output format: table | json", "table").option("--resolved", "Show resolved view (catalog merged)").option("--secrets", "Show secret values from environment (masked by default)").option("--plaintext", "Show secret values in plaintext (requires --secrets)").option("--sort", "Display secrets and env entries in alphabetical order (no file mutation)").action((options) => {
@@ -5397,10 +5693,13 @@ program.command("inspect").description("Display structured view of envpkt.toml")
5397
5693
  program.command("exec").description("Run pre-flight audit then execute a command with injected secrets (sealed → fnox → env cascade)").argument("<command...>", "Command to execute").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").option("--skip-audit", "Skip the pre-flight audit (alias: --no-check)").option("--no-check", "Skip the pre-flight audit").option("--warn-only", "Warn on critical audit but do not abort").option("--strict", "Abort on any non-healthy secret").action((args, options) => {
5398
5694
  runExec(args, options);
5399
5695
  });
5696
+ program.command("headers").description("Print HTTP headers for an MCP server as JSON — for Claude Code's headersHelper in .mcp.json").argument("[server]", "MCP server name (default: $CLAUDE_CODE_MCP_SERVER_NAME)").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use").action((server, options) => {
5697
+ runHeaders(server, options);
5698
+ });
5400
5699
  program.command("resolve").description("Resolve catalog references and output a flat, self-contained config").option("-c, --config <path>", "Path to envpkt.toml").option("-o, --output <path>", "Write resolved config to file (default: stdout)").option("--format <format>", "Output format: toml | json", "toml").option("--dry-run", "Show what would be resolved without writing").action((options) => {
5401
5700
  runResolve(options);
5402
5701
  });
5403
- program.command("seal").description("Encrypt secret values into envpkt.toml using age — sealed packets are safe to commit to git").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use for value resolution").option("--reseal", "Re-encrypt all secrets, including already sealed (for key rotation)").option("--edit <keys>", "Re-seal specific keys with new values (comma-separated), always prompts interactively").action(async (options) => {
5702
+ program.command("seal").description("Encrypt secret values into envpkt.toml using age — sealed packets are safe to commit to git").option("-c, --config <path>", "Path to envpkt.toml").option("--profile <profile>", "fnox profile to use for value resolution").option("--reseal", "Re-encrypt all secrets, including already sealed (for key rotation)").option("--edit <keys>", "Re-seal specific keys with new values (comma-separated). Prompts in a terminal; without one, reads a single key's value from stdin").option("--stdin", "With --edit <key>, read the new value from stdin even in a terminal (one key; one trailing newline is stripped)").action(async (options) => {
5404
5703
  await runSeal(options);
5405
5704
  });
5406
5705
  program.command("mcp").description("Start the envpkt MCP server (stdio transport)").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {