envpkt 0.13.0 → 0.13.2

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.
Files changed (3) hide show
  1. package/README.md +76 -65
  2. package/dist/cli.js +225 -125
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,43 +5,38 @@
5
5
 
6
6
  **Credentials your agents actually understand.**
7
7
 
8
- Structured metadata for every secret capabilities, constraints, expiration, and fleet healthso agents operate within their boundaries instead of flying blind.
8
+ envpkt gives every credential an `envpkt.toml` entry describing _what service it authenticates to_, _what it's allowed to do_, _when it expires_, and _how to rotate it_ while the secret values stay in your secrets manager, encrypted at rest, or injected at runtime, never committed in plaintext.
9
9
 
10
- Every credential in your system gets an `envpkt.toml` entry describing _what service it authenticates to_, _what it's allowed to do_, _when it expires_, and _how to rotate it_. Your agents query this metadata via MCP to understand their operating constraints. Your operators audit credential health across entire agent fleets. The secrets themselves stay where they belong — in your secrets manager, encrypted at rest, or injected at runtime never in the agent's conversation context.
10
+ **Day one, with zero agents,** it's an encrypted-at-rest `.env` replacement with scoped loading: scan the credentials already in your shell, seal them into a file that's safe to commit, and load them automatically on `cd`, into a single command, or as a plain `.env` for any tool that wants one.
11
11
 
12
- ## MCP Integration
12
+ **As you add agents,** the same metadata gives them structured awareness of their own credentials over [MCP](#for-agents-and-fleets) — capabilities, expiry, drift, fleet health — without any secret value ever entering the model's context window.
13
13
 
14
- envpkt ships an [MCP](https://modelcontextprotocol.io/) server that gives AI agents structured awareness of their credentials. Add it to Claude, Cursor, VS Code, or any MCP-compatible client:
14
+ ## Quick Start
15
15
 
16
- ```json
17
- {
18
- "mcpServers": {
19
- "envpkt": {
20
- "command": "envpkt",
21
- "args": ["mcp"]
22
- }
23
- }
24
- }
25
- ```
16
+ The whole loop for a single project — discover, seal, load — with zero agents involved:
26
17
 
27
- ### Tools
18
+ ```bash
19
+ npm install -g envpkt
28
20
 
29
- | Tool | Description |
30
- | ------------------ | ------------------------------------------------------- |
31
- | `getPacketHealth` | Get overall health status with per-secret audit results |
32
- | `listCapabilities` | List agent and per-secret capabilities |
33
- | `getSecretMeta` | Get metadata for a specific secret by key |
34
- | `checkExpiration` | Check expiration status and days remaining |
35
- | `getEnvMeta` | Get metadata for environment defaults and drift status |
21
+ # 1. Discover the credentials already in your shell, and scaffold envpkt.toml from them
22
+ envpkt env scan
23
+ envpkt env scan --write
36
24
 
37
- ### Resources
25
+ # 2. Generate an age key and seal the secret values into envpkt.toml (the file is safe to commit)
26
+ envpkt keygen
27
+ envpkt seal
38
28
 
39
- | URI | Description |
40
- | ----------------------- | --------------------------------- |
41
- | `envpkt://health` | Current credential health summary |
42
- | `envpkt://capabilities` | Agent and secret capabilities |
29
+ # 3. Load them — pick whatever fits the moment:
30
+ envpkt exec -- your-tool # run one command with the secrets injected, scoped to it
31
+ eval "$(envpkt shell-hook zsh)" # add to ~/.zshrc: auto-load on cd into a project, restore on leave
32
+ envpkt env dotenv -o .env # materialize a .env for Docker / Wrangler / Vite / …
33
+
34
+ # Anytime: check health and drift
35
+ envpkt audit
36
+ envpkt env check
37
+ ```
43
38
 
44
- The MCP server exposes metadata onlyit does not have access to secret values. See [Security Model](#security-model) for details.
39
+ Encrypted secrets committed to git, loaded where you need them, with health you can audit and not an agent in sight. Scaling the same metadata to agents and fleets is [act two](#for-agents-and-fleets).
45
40
 
46
41
  ## Security Model
47
42
 
@@ -53,30 +48,6 @@ envpkt operates a three-tier trust model. Each tier has different guarantees, an
53
48
 
54
49
  **Tier 3: Shell-level agents** — Agents with shell access (Claude Code, Devin, etc.) can read environment variables directly. Prevention isn't possible at this tier. envpkt provides encrypted storage, scoped access, and audit trails — because when prevention isn't possible, visibility is what matters.
55
50
 
56
- ## Quick Start
57
-
58
- Start where your credentials already are — environment variables — and graduate to encrypted, per-agent-scoped metadata.
59
-
60
- ```bash
61
- # Install
62
- npm install -g envpkt
63
-
64
- # Auto-discover credentials from your shell environment
65
- envpkt env scan
66
-
67
- # Scaffold envpkt.toml from discovered credentials
68
- envpkt env scan --write
69
-
70
- # Audit credential health
71
- envpkt audit
72
-
73
- # Check for drift between envpkt.toml and live environment
74
- envpkt env check
75
-
76
- # Scan a directory tree of agents
77
- envpkt fleet
78
- ```
79
-
80
51
  ## The envpkt.toml File
81
52
 
82
53
  Every project gets one `envpkt.toml` that describes its credentials. Here's a minimal example:
@@ -236,6 +207,44 @@ A composite action resolves the credentials in `envpkt.toml` into the CI job —
236
207
 
237
208
  > Decrypting sealed packets requires the [`age`](https://github.com/FiloSottile/age) CLI on the runner (install it first, as above) — not needed if you only inject plaintext `[env.*]` defaults or resolve via fnox. Pin to a released tag (e.g. `@v0.12.0`); no moving major tag (`@v1`) is published yet. Node is assumed present; add `actions/setup-node` first to pin a version.
238
209
 
210
+ ## For agents and fleets
211
+
212
+ Everything above stands on its own with no agents involved. Once you _do_ have them, the same `envpkt.toml` metadata powers three more capabilities: agents reading their own constraints over MCP, fleet-wide health monitoring, and shared catalogs across many agents.
213
+
214
+ ### MCP server
215
+
216
+ envpkt ships an [MCP](https://modelcontextprotocol.io/) server that gives AI agents structured awareness of their credentials. Add it to Claude, Cursor, VS Code, or any MCP-compatible client:
217
+
218
+ ```json
219
+ {
220
+ "mcpServers": {
221
+ "envpkt": {
222
+ "command": "envpkt",
223
+ "args": ["mcp"]
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ **Tools**
230
+
231
+ | Tool | Description |
232
+ | ------------------ | ------------------------------------------------------- |
233
+ | `getPacketHealth` | Get overall health status with per-secret audit results |
234
+ | `listCapabilities` | List agent and per-secret capabilities |
235
+ | `getSecretMeta` | Get metadata for a specific secret by key |
236
+ | `checkExpiration` | Check expiration status and days remaining |
237
+ | `getEnvMeta` | Get metadata for environment defaults and drift status |
238
+
239
+ **Resources**
240
+
241
+ | URI | Description |
242
+ | ----------------------- | --------------------------------- |
243
+ | `envpkt://health` | Current credential health summary |
244
+ | `envpkt://capabilities` | Agent and secret capabilities |
245
+
246
+ The MCP server exposes metadata only — it reads `envpkt.toml` and strips any `encrypted_value` ciphertext from responses, so prompt injection cannot leak what isn't there. See [Security Model](#security-model) for the full trust model.
247
+
239
248
  ## Fleet Management
240
249
 
241
250
  When you're running multiple agents, `envpkt fleet` scans a directory tree for `envpkt.toml` files and aggregates credential health across your entire fleet.
@@ -355,6 +364,19 @@ envpkt audit -c path/to/envpkt.toml # Specify config path
355
364
 
356
365
  Exit codes: `0` = healthy, `1` = degraded, `2` = critical.
357
366
 
367
+ ### `envpkt doctor`
368
+
369
+ One-shot environment check: is the `age` CLI installed, is a config resolvable here, and do its sealed secrets decrypt with an available key?
370
+
371
+ ```bash
372
+ envpkt doctor
373
+ # ✓ age v1.2.0
374
+ # ✓ config /path/to/envpkt.toml
375
+ # ✓ secrets 5 resolved, 0 skipped
376
+ ```
377
+
378
+ If `age` is missing it prints the platform-specific install command; if a sealed packet has no key, it lists the paths it searched. Exits non-zero when a check fails.
379
+
358
380
  ### `envpkt resolve`
359
381
 
360
382
  Resolve catalog references and output a flat, self-contained config.
@@ -470,7 +492,8 @@ Secret values are emitted **only when the package sets top-level `scope = "shell
470
492
  Generate a `cd` hook (zsh/bash) that loads a project's credentials when you enter its directory tree and restores your environment when you leave:
471
493
 
472
494
  ```bash
473
- eval "$(envpkt shell-hook zsh)" # add to ~/.zshrc (or: shell-hook bash)
495
+ eval "$(envpkt shell-hook zsh)" # add to ~/.zshrc (or: shell-hook bash)
496
+ eval "$(envpkt shell-hook zsh --no-audit)" # …without the per-cd health-check line
474
497
  ```
475
498
 
476
499
  On each directory change it resolves the **nearest `envpkt.toml`, walking up from the current directory** (like `git`/`direnv` — so it works from any subdirectory, not just the project root), injects that package via `env export --track`, and restores the previous package on leave (prior values, not a blind unset). Env defaults always load; secret values load only for `scope = "shell"` packages. Backed by `envpkt config-path` — a resolve-only command that prints the active config path (no decryption).
@@ -486,18 +509,6 @@ Inject resolved secrets into a GitHub Actions job. Emits `::add-mask::` for each
486
509
  npx envpkt env github --strict
487
510
  ```
488
511
 
489
- ### `envpkt shell-hook`
490
-
491
- Output a shell function that runs `envpkt audit --format minimal` whenever you `cd` into a directory. envpkt's config discovery chain automatically finds config files beyond CWD (see [Config Resolution](#config-resolution)), so the hook works even in directories without a local `envpkt.toml`.
492
-
493
- ```bash
494
- # Add to your .zshrc
495
- eval "$(envpkt shell-hook zsh)"
496
-
497
- # Add to your .bashrc
498
- eval "$(envpkt shell-hook bash)"
499
- ```
500
-
501
512
  ### `envpkt mcp`
502
513
 
503
514
  Start the envpkt MCP server (stdio transport) for AI agent integration.
package/dist/cli.js CHANGED
@@ -8,10 +8,10 @@ import { TypeCompiler } from "@sinclair/typebox/compiler";
8
8
  import { Env, Fs, Path, Platform } from "functype-os";
9
9
  import { TomlDate, parse, stringify } from "smol-toml";
10
10
  import { FormatRegistry, Type } from "@sinclair/typebox";
11
- import { randomBytes } from "node:crypto";
11
+ import { execFileSync } from "node:child_process";
12
12
  import { homedir, tmpdir } from "node:os";
13
13
  import { directSilentLogger } from "functype-log/direct";
14
- import { execFileSync } from "node:child_process";
14
+ 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";
@@ -557,6 +557,63 @@ const resolveConfig = (agentConfig, agentConfigDir) => {
557
557
  }));
558
558
  };
559
559
  //#endregion
560
+ //#region src/fnox/identity.ts
561
+ /** Check if the age CLI is available on PATH */
562
+ const ageAvailable = () => Try(() => {
563
+ execFileSync("age", ["--version"], { stdio: "pipe" });
564
+ return true;
565
+ }).fold(() => false, (v) => v);
566
+ /** The age CLI version string (e.g. "v1.2.0"), or None if age isn't on PATH. */
567
+ const ageVersion = () => Try(() => execFileSync("age", ["--version"], {
568
+ stdio: [
569
+ "pipe",
570
+ "pipe",
571
+ "pipe"
572
+ ],
573
+ encoding: "utf-8"
574
+ }).trim()).fold(() => Option.none(), (v) => Option(v));
575
+ /** Platform-aware instructions for installing the age CLI. */
576
+ const ageInstallHint = () => {
577
+ return `Install age:\n ${{
578
+ darwin: "brew install age",
579
+ linux: "apt install age (or: apk add age · dnf install age · nix-env -iA nixpkgs.age)",
580
+ win32: "scoop install age (or: winget install FiloSottile.age)"
581
+ }[process.platform] ?? "see the install guide"}\n https://github.com/FiloSottile/age#installation`;
582
+ };
583
+ /**
584
+ * Extract the secret key from an age identity file (plain or encrypted).
585
+ * - Plain identity files (from `age-keygen`) contain `AGE-SECRET-KEY-*` lines directly
586
+ * - Encrypted identity files need `age --decrypt` to unwrap
587
+ */
588
+ const unwrapAgentKey = (identityPath) => {
589
+ if (!existsSync(identityPath)) return Left({
590
+ _tag: "IdentityNotFound",
591
+ path: identityPath
592
+ });
593
+ return Try(() => readFileSync(identityPath, "utf-8")).fold((err) => Left({
594
+ _tag: "DecryptFailed",
595
+ message: `Failed to read identity file: ${err}`
596
+ }), (content) => {
597
+ const secretKeyLine = content.split("\n").find((l) => l.startsWith("AGE-SECRET-KEY-"));
598
+ if (secretKeyLine) return Right(secretKeyLine.trim());
599
+ if (!ageAvailable()) return Left({
600
+ _tag: "AgeNotFound",
601
+ message: "age CLI not found on PATH"
602
+ });
603
+ return Try(() => execFileSync("age", ["--decrypt", identityPath], {
604
+ stdio: [
605
+ "pipe",
606
+ "pipe",
607
+ "pipe"
608
+ ],
609
+ encoding: "utf-8"
610
+ })).fold((err) => Left({
611
+ _tag: "DecryptFailed",
612
+ message: `age decrypt failed: ${err}`
613
+ }), (output) => Right(output.trim()));
614
+ });
615
+ };
616
+ //#endregion
560
617
  //#region src/cli/output.ts
561
618
  const RESET = "\x1B[0m";
562
619
  const BOLD = "\x1B[1m";
@@ -659,9 +716,20 @@ const formatError = (error) => {
659
716
  case "ParseError": return `${RED}Error:${RESET} Failed to parse TOML: ${error.message}`;
660
717
  case "ValidationError": return `${RED}Error:${RESET} Config validation failed:\n${String(error.errors)}`;
661
718
  case "ReadError": return `${RED}Error:${RESET} Could not read file: ${error.message}`;
662
- case "AgeNotFound": return `${RED}Error:${RESET} age CLI not found: ${error.message}`;
719
+ case "AgeNotFound": return `${RED}Error:${RESET} age is required for this operation but was not found on PATH.\n${DIM}${ageInstallHint()}${RESET}`;
663
720
  case "DecryptFailed": return `${RED}Error:${RESET} Decrypt failed: ${error.message}`;
664
721
  case "IdentityNotFound": return `${RED}Error:${RESET} Identity file not found: ${error.path}`;
722
+ case "SealKeyUnavailable": {
723
+ const e = error;
724
+ return [
725
+ `${RED}Error:${RESET} ${e.sealedKeys.length} sealed secret(s) can't be decrypted — no age key found.`,
726
+ `${DIM}Searched (in order):${RESET}`,
727
+ e.searched.map((l) => ` • ${l}`).join("\n"),
728
+ `${DIM}Fix one:${RESET}`,
729
+ ` • Restore your key to ~/.envpkt/age-key.txt (or set ENVPKT_AGE_KEY_FILE / ENVPKT_AGE_KEY)`,
730
+ ` • Re-provision from source: envpkt seal --edit <KEY>`
731
+ ].join("\n");
732
+ }
665
733
  case "AuditFailed": return `${RED}Error:${RESET} Audit failed: ${error.message}`;
666
734
  case "CatalogNotFound": return `${RED}Error:${RESET} Catalog not found: ${error.path}`;
667
735
  case "CatalogLoadError": return `${RED}Error:${RESET} Catalog load error: ${error.message}`;
@@ -907,46 +975,6 @@ const fnoxAvailable = () => Try(() => {
907
975
  return true;
908
976
  }).fold(() => false, (v) => v);
909
977
  //#endregion
910
- //#region src/fnox/identity.ts
911
- /** Check if the age CLI is available on PATH */
912
- const ageAvailable = () => Try(() => {
913
- execFileSync("age", ["--version"], { stdio: "pipe" });
914
- return true;
915
- }).fold(() => false, (v) => v);
916
- /**
917
- * Extract the secret key from an age identity file (plain or encrypted).
918
- * - Plain identity files (from `age-keygen`) contain `AGE-SECRET-KEY-*` lines directly
919
- * - Encrypted identity files need `age --decrypt` to unwrap
920
- */
921
- const unwrapAgentKey = (identityPath) => {
922
- if (!existsSync(identityPath)) return Left({
923
- _tag: "IdentityNotFound",
924
- path: identityPath
925
- });
926
- return Try(() => readFileSync(identityPath, "utf-8")).fold((err) => Left({
927
- _tag: "DecryptFailed",
928
- message: `Failed to read identity file: ${err}`
929
- }), (content) => {
930
- const secretKeyLine = content.split("\n").find((l) => l.startsWith("AGE-SECRET-KEY-"));
931
- if (secretKeyLine) return Right(secretKeyLine.trim());
932
- if (!ageAvailable()) return Left({
933
- _tag: "AgeNotFound",
934
- message: "age CLI not found on PATH"
935
- });
936
- return Try(() => execFileSync("age", ["--decrypt", identityPath], {
937
- stdio: [
938
- "pipe",
939
- "pipe",
940
- "pipe"
941
- ],
942
- encoding: "utf-8"
943
- })).fold((err) => Left({
944
- _tag: "DecryptFailed",
945
- message: `age decrypt failed: ${err}`
946
- }), (output) => Right(output.trim()));
947
- });
948
- };
949
- //#endregion
950
978
  //#region src/fnox/parse.ts
951
979
  /** Read and parse fnox.toml, extracting secret keys and profiles */
952
980
  const readFnoxConfig = (path) => Try(() => readFileSync(path, "utf-8")).fold((err) => Left({
@@ -1583,6 +1611,57 @@ const bootSafe = (options) => {
1583
1611
  }));
1584
1612
  };
1585
1613
  //#endregion
1614
+ //#region src/cli/commands/doctor.ts
1615
+ const ok = (label, detail) => console.log(` ${GREEN}✓${RESET} ${label} ${DIM}${detail}${RESET}`);
1616
+ const warn = (label, detail) => console.log(` ${YELLOW}—${RESET} ${label} ${detail}`);
1617
+ const bad = (label, detail) => console.log(` ${RED}✗${RESET} ${label} ${detail}`);
1618
+ /** Print the resolution/key check, returning whether it passed. */
1619
+ const reportResolution = (configPath) => bootSafe({
1620
+ configPath,
1621
+ inject: false,
1622
+ warnOnly: true
1623
+ }).fold((err) => {
1624
+ if (err._tag === "SealKeyUnavailable") {
1625
+ bad("key ", `${err.sealedKeys.length} sealed secret(s) but no decryption key`);
1626
+ err.searched.forEach((line) => console.log(`${DIM} ${line}${RESET}`));
1627
+ } else bad("config", `${err._tag}`);
1628
+ return false;
1629
+ }, (boot) => {
1630
+ const resolved = Object.keys(boot.secrets).length;
1631
+ ok("secrets", `${resolved} resolved, ${boot.skipped.length} skipped`);
1632
+ const auditColor = boot.audit.status === "healthy" ? GREEN : YELLOW;
1633
+ console.log(` ${auditColor}•${RESET} audit ${DIM}${boot.audit.status}${RESET}`);
1634
+ return true;
1635
+ });
1636
+ /**
1637
+ * One-shot environment check: is age installed, is a config resolvable, and do its sealed
1638
+ * secrets decrypt with an available key? Read-only; exits non-zero if any check fails.
1639
+ */
1640
+ const runDoctor = (options) => {
1641
+ console.log(`${BOLD}envpkt doctor${RESET}\n`);
1642
+ const ageOk = ageVersion().fold(() => {
1643
+ bad("age ", "not found on PATH");
1644
+ console.log(`${DIM} ${ageInstallHint().split("\n").join("\n ")}${RESET}`);
1645
+ return false;
1646
+ }, (version) => {
1647
+ ok("age ", version);
1648
+ return true;
1649
+ });
1650
+ const resolveOk = resolveConfigPath(options.config).fold(() => {
1651
+ warn("config", "no envpkt.toml found for this directory");
1652
+ return true;
1653
+ }, ({ path }) => {
1654
+ ok("config", path);
1655
+ return reportResolution(path);
1656
+ });
1657
+ console.log("");
1658
+ if (ageOk && resolveOk) console.log(`${GREEN}✓ no issues${RESET}`);
1659
+ else {
1660
+ console.log(`${RED}✗ ${[!ageOk, !resolveOk].filter(Boolean).length} issue(s) found${RESET} ${CYAN}(see above)${RESET}`);
1661
+ process.exit(1);
1662
+ }
1663
+ };
1664
+ //#endregion
1586
1665
  //#region src/core/dotenv.ts
1587
1666
  const BARE_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
1588
1667
  /**
@@ -2886,12 +2965,11 @@ const runEnvExport = (options) => {
2886
2965
  console.error(formatError(err));
2887
2966
  process.exit(2);
2888
2967
  }, (boot) => {
2889
- emitWarnings(boot);
2968
+ if (!options.track) emitWarnings(boot);
2890
2969
  const scope = loadConfig(boot.configPath).fold(() => "exec", (config) => config.scope ?? "exec");
2970
+ const gateSecrets = options.track === true && scope !== "shell";
2891
2971
  const entries = collectEmitEntries(boot);
2892
- const emit = scope === "shell" ? entries : entries.filter((e) => !e.secret);
2893
- const withheld = entries.length - emit.length;
2894
- if (withheld > 0) console.error(`${DIM}${withheld} secret(s) withheld (scope="${scope}") — use \`envpkt exec\` or set top-level scope="shell".${RESET}`);
2972
+ const emit = gateSecrets ? entries.filter((e) => !e.secret) : entries;
2895
2973
  emit.forEach(({ name, value }) => {
2896
2974
  console.log(options.track ? `_ENVPKT_HAD_${name}=\${${name}+1}; _ENVPKT_PREV_${name}="\${${name}-}"; export ${name}='${shellEscape(value)}'` : `export ${name}='${shellEscape(value)}'`);
2897
2975
  });
@@ -3291,6 +3369,8 @@ const runFleet = (options) => {
3291
3369
  //#endregion
3292
3370
  //#region src/cli/commands/init.ts
3293
3371
  const CONFIG_FILENAME = "envpkt.toml";
3372
+ /** Resolve the top-level `scope` to scaffold: explicit --scope wins; --global implies "shell". */
3373
+ const resolveInitScope = (options) => options.scope ?? (options.global ? "shell" : void 0);
3294
3374
  const todayIso = () => (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
3295
3375
  const generateSecretBlock = (key, service) => {
3296
3376
  return `[secret.${key}]
@@ -3315,6 +3395,8 @@ const generateTemplate = (options, fnoxKeys) => {
3315
3395
  lines.push(`#:schema https://raw.githubusercontent.com/jordanburke/envpkt/main/schemas/envpkt.schema.json`);
3316
3396
  lines.push(``);
3317
3397
  lines.push(`version = 1`);
3398
+ const scope = resolveInitScope(options);
3399
+ if (scope) lines.push(`scope = "${scope}" # shell = secrets load ambiently on cd/eval; exec = only via envpkt exec`);
3318
3400
  lines.push(``);
3319
3401
  if (options.catalog) {
3320
3402
  lines.push(`catalog = "${options.catalog}"`);
@@ -3373,6 +3455,10 @@ const formatConfigError = (err) => {
3373
3455
  };
3374
3456
  const runInit = (dir, options) => {
3375
3457
  const outPath = join(dir, CONFIG_FILENAME);
3458
+ if (options.scope !== void 0 && options.scope !== "shell" && options.scope !== "exec") {
3459
+ console.error(`${RED}Error:${RESET} --scope must be "shell" or "exec" (got "${options.scope}")`);
3460
+ process.exit(1);
3461
+ }
3376
3462
  if (existsSync(outPath) && !options.force) {
3377
3463
  console.error(`${RED}Error:${RESET} ${CONFIG_FILENAME} already exists. Use --force to overwrite.`);
3378
3464
  process.exit(1);
@@ -4636,85 +4722,96 @@ const registerSecretCommands = (program) => {
4636
4722
  };
4637
4723
  //#endregion
4638
4724
  //#region src/cli/commands/shell-hook.ts
4639
- const ZSH_HOOK = `# envpkt shell hook — add to ~/.zshrc: eval "$(envpkt shell-hook zsh)"
4640
- # Loads a project envpkt.toml on cd (secrets only for scope="shell" packages), restores the
4641
- # prior environment on leave, and warns on credential health. Use \`envpkt exec\` for scope="exec".
4642
- _envpkt_restore() {
4643
- [[ -n "$_ENVPKT_INJECTED" ]] || return
4644
- local k had prev
4645
- for k in \${(s: :)_ENVPKT_INJECTED}; do
4646
- had="_ENVPKT_HAD_$k"
4647
- prev="_ENVPKT_PREV_$k"
4648
- if [[ -n "\${(P)had}" ]]; then
4649
- export "$k=\${(P)prev}"
4650
- else
4651
- unset "$k"
4652
- fi
4653
- unset "$had" "$prev"
4654
- done
4655
- unset _ENVPKT_INJECTED
4656
- }
4657
-
4658
- _envpkt_chpwd() {
4659
- local cfg
4660
- cfg="$(envpkt config-path 2>/dev/null)"
4661
- [[ "$cfg" == "$_ENVPKT_DIR" ]] && return
4662
- _envpkt_restore
4663
- _ENVPKT_DIR="$cfg"
4664
- [[ -z "$cfg" ]] && return
4665
- eval "$(envpkt env export --track 2>/dev/null)"
4666
- envpkt audit --format minimal 2>/dev/null
4667
- }
4668
-
4669
- autoload -Uz add-zsh-hook
4670
- add-zsh-hook chpwd _envpkt_chpwd
4671
- _envpkt_chpwd
4672
- `;
4673
- const BASH_HOOK = `# envpkt shell hook — add to ~/.bashrc: eval "$(envpkt shell-hook bash)"
4674
- # Loads a project envpkt.toml on cd (secrets only for scope="shell" packages), restores the
4675
- # prior environment on leave, and warns on credential health. Use \`envpkt exec\` for scope="exec".
4676
- _envpkt_restore() {
4677
- [ -n "$_ENVPKT_INJECTED" ] || return
4678
- local k had prev
4679
- for k in $_ENVPKT_INJECTED; do
4680
- had="_ENVPKT_HAD_$k"
4681
- prev="_ENVPKT_PREV_$k"
4682
- if [ -n "\${!had}" ]; then
4683
- export "$k=\${!prev}"
4684
- else
4685
- unset "$k"
4686
- fi
4687
- unset "$had" "$prev"
4688
- done
4689
- unset _ENVPKT_INJECTED
4690
- }
4691
-
4692
- _envpkt_prompt() {
4693
- [ "$PWD" = "$_ENVPKT_PWD" ] && return
4694
- _ENVPKT_PWD="$PWD"
4695
- local cfg
4696
- cfg="$(envpkt config-path 2>/dev/null)"
4697
- [ "$cfg" = "$_ENVPKT_DIR" ] && return
4698
- _envpkt_restore
4699
- _ENVPKT_DIR="$cfg"
4700
- [ -z "$cfg" ] && return
4701
- eval "$(envpkt env export --track 2>/dev/null)"
4702
- envpkt audit --format minimal 2>/dev/null
4703
- }
4704
-
4705
- case "$PROMPT_COMMAND" in
4706
- *_envpkt_prompt*) ;;
4707
- *) PROMPT_COMMAND="_envpkt_prompt\${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;;
4708
- esac
4709
- _envpkt_prompt
4710
- `;
4711
- const runShellHook = (shell) => {
4725
+ const zshHook = (audit) => [
4726
+ "# envpkt shell hook add to ~/.zshrc: eval \"$(envpkt shell-hook zsh)\"",
4727
+ "# Loads the current directory package on cd, restores the prior env on leave, warns on health.",
4728
+ "_envpkt_restore() {",
4729
+ " [[ -n \"$_ENVPKT_INJECTED\" ]] || return",
4730
+ " local k had prev",
4731
+ " for k in ${(s: :)_ENVPKT_INJECTED}; do",
4732
+ " had=\"_ENVPKT_HAD_$k\"",
4733
+ " prev=\"_ENVPKT_PREV_$k\"",
4734
+ " if [[ -n \"${(P)had}\" ]]; then",
4735
+ " export \"$k=${(P)prev}\"",
4736
+ " else",
4737
+ " unset \"$k\"",
4738
+ " fi",
4739
+ " unset \"$had\" \"$prev\"",
4740
+ " done",
4741
+ " unset _ENVPKT_INJECTED",
4742
+ "}",
4743
+ "",
4744
+ "_envpkt_chpwd() {",
4745
+ " local cfg",
4746
+ " cfg=\"$(envpkt config-path 2>/dev/null)\"",
4747
+ " [[ \"$cfg\" == \"$_ENVPKT_DIR\" ]] && return",
4748
+ " _envpkt_restore",
4749
+ " _ENVPKT_DIR=\"$cfg\"",
4750
+ " [[ -z \"$cfg\" ]] && return",
4751
+ " eval \"$(envpkt env export --track)\"",
4752
+ ...audit ? [" envpkt audit --format minimal 2>/dev/null"] : [],
4753
+ "}",
4754
+ "",
4755
+ "autoload -Uz add-zsh-hook",
4756
+ "add-zsh-hook chpwd _envpkt_chpwd",
4757
+ "_envpkt_chpwd",
4758
+ ""
4759
+ ].join("\n");
4760
+ const bashHook = (audit) => [
4761
+ "# envpkt shell hook add to ~/.bashrc: eval \"$(envpkt shell-hook bash)\"",
4762
+ "# Loads the current directory package on cd, restores the prior env on leave, warns on health.",
4763
+ "_envpkt_restore() {",
4764
+ " [ -n \"$_ENVPKT_INJECTED\" ] || return",
4765
+ " local k had prev",
4766
+ " for k in $_ENVPKT_INJECTED; do",
4767
+ " had=\"_ENVPKT_HAD_$k\"",
4768
+ " prev=\"_ENVPKT_PREV_$k\"",
4769
+ " if [ -n \"${!had}\" ]; then",
4770
+ " export \"$k=${!prev}\"",
4771
+ " else",
4772
+ " unset \"$k\"",
4773
+ " fi",
4774
+ " unset \"$had\" \"$prev\"",
4775
+ " done",
4776
+ " unset _ENVPKT_INJECTED",
4777
+ "}",
4778
+ "",
4779
+ "_envpkt_prompt() {",
4780
+ " [ \"$PWD\" = \"$_ENVPKT_PWD\" ] && return",
4781
+ " _ENVPKT_PWD=\"$PWD\"",
4782
+ " local cfg",
4783
+ " cfg=\"$(envpkt config-path 2>/dev/null)\"",
4784
+ " [ \"$cfg\" = \"$_ENVPKT_DIR\" ] && return",
4785
+ " _envpkt_restore",
4786
+ " _ENVPKT_DIR=\"$cfg\"",
4787
+ " [ -z \"$cfg\" ] && return",
4788
+ " eval \"$(envpkt env export --track)\"",
4789
+ ...audit ? [" envpkt audit --format minimal 2>/dev/null"] : [],
4790
+ "}",
4791
+ "",
4792
+ "# Register on PROMPT_COMMAND, handling both the string and (bash 5.1+) array forms.",
4793
+ "if [[ \"$(declare -p PROMPT_COMMAND 2>/dev/null)\" == \"declare -a\"* ]]; then",
4794
+ " case \" ${PROMPT_COMMAND[*]} \" in",
4795
+ " *\" _envpkt_prompt \"*) ;;",
4796
+ " *) PROMPT_COMMAND+=(_envpkt_prompt) ;;",
4797
+ " esac",
4798
+ "else",
4799
+ " case \"$PROMPT_COMMAND\" in",
4800
+ " *_envpkt_prompt*) ;;",
4801
+ " *) PROMPT_COMMAND=\"_envpkt_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND}\" ;;",
4802
+ " esac",
4803
+ "fi",
4804
+ "_envpkt_prompt",
4805
+ ""
4806
+ ].join("\n");
4807
+ const runShellHook = (shell, options) => {
4808
+ const audit = options?.audit !== false;
4712
4809
  switch (shell) {
4713
4810
  case "zsh":
4714
- console.log(ZSH_HOOK);
4811
+ console.log(zshHook(audit));
4715
4812
  break;
4716
4813
  case "bash":
4717
- console.log(BASH_HOOK);
4814
+ console.log(bashHook(audit));
4718
4815
  break;
4719
4816
  default:
4720
4817
  console.error(`${RED}Error:${RESET} Unsupported shell: ${shell}. Use "zsh" or "bash".`);
@@ -5011,7 +5108,7 @@ program.name("envpkt").description("Credential lifecycle and fleet management fo
5011
5108
  const pkgPath = findPkgJson(dirname(fileURLToPath(import.meta.url)));
5012
5109
  return pkgPath ? JSON.parse(readFileSync(pkgPath, "utf-8")).version : "0.0.0";
5013
5110
  })());
5014
- program.command("init").description("Initialize a new envpkt.toml in the current directory").option("--from-fnox [path]", "Scaffold from fnox.toml (optionally specify path)").option("--catalog <path>", "Path to shared secret catalog").option("--identity", "Include [identity] section").option("--name <name>", "Identity name (requires --identity)").option("--capabilities <caps>", "Comma-separated capabilities (requires --identity)").option("--expires <date>", "Credential expiration YYYY-MM-DD (requires --identity)").option("--force", "Overwrite existing envpkt.toml").action((options) => {
5111
+ program.command("init").description("Initialize a new envpkt.toml in the current directory").option("--from-fnox [path]", "Scaffold from fnox.toml (optionally specify path)").option("--catalog <path>", "Path to shared secret catalog").option("--identity", "Include [identity] section").option("--name <name>", "Identity name (requires --identity)").option("--capabilities <caps>", "Comma-separated capabilities (requires --identity)").option("--expires <date>", "Credential expiration YYYY-MM-DD (requires --identity)").option("--scope <scope>", "Top-level scope: \"shell\" (secrets load ambiently) or \"exec\" (only via envpkt exec)").option("--global", "Scaffold a global/ambient package (implies scope = \"shell\")").option("--force", "Overwrite existing envpkt.toml").action((options) => {
5015
5112
  runInit(process.cwd(), options);
5016
5113
  });
5017
5114
  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) => {
@@ -5049,11 +5146,14 @@ program.command("sort").description("Group [env.*] and [secret.*] sections and a
5049
5146
  program.command("upgrade").description("Upgrade envpkt to the latest version (npm install -g envpkt@latest)").action(() => {
5050
5147
  runUpgrade();
5051
5148
  });
5149
+ program.command("doctor").description("Check that age is installed and that the resolved config's sealed secrets can be decrypted").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
5150
+ runDoctor(options);
5151
+ });
5052
5152
  program.command("config-path").description("Print the envpkt.toml path resolved for the current directory (empty if none). Resolve-only — no decryption.").option("-c, --config <path>", "Path to envpkt.toml").action((options) => {
5053
5153
  runConfigPath(options);
5054
5154
  });
5055
- program.command("shell-hook").description("Output shell function for ambient credential warnings on cd combine with env export for full setup").argument("<shell>", "Shell type: zsh | bash").action((shell) => {
5056
- runShellHook(shell);
5155
+ program.command("shell-hook").description("Output a shell hook that loads a project's credentials on cd and restores them on leave").argument("<shell>", "Shell type: zsh | bash").option("--no-audit", "Omit the credential-health audit line from the emitted hook").action((shell, options) => {
5156
+ runShellHook(shell, options);
5057
5157
  });
5058
5158
  program.parse();
5059
5159
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envpkt",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Credential lifecycle and fleet management for AI agents",
5
5
  "keywords": [
6
6
  "credentials",