@seekrit/cli 0.30.0 → 0.32.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/index.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { z } from "zod";
5
5
  import { Command } from "commander";
6
6
  import { homedir, hostname, tmpdir, userInfo } from "node:os";
7
7
  import { dirname, join, parse } from "node:path";
8
8
  import { createInterface } from "node:readline";
9
9
  import { Writable } from "node:stream";
10
+ import { createHash } from "node:crypto";
10
11
  /** All catalog keys as a runtime array (for iteration / zod enums). */
11
12
  const ENTITLEMENT_KEYS = Object.keys({
12
13
  "feature.kms": {
@@ -2719,7 +2720,7 @@ function isCliSessionToken(value) {
2719
2720
  }
2720
2721
  //#endregion
2721
2722
  //#region package.json
2722
- var version = "0.30.0";
2723
+ var version = "0.32.0";
2723
2724
  //#endregion
2724
2725
  //#region ../../packages/api-client/src/index.ts
2725
2726
  var SeekritApiError = class extends Error {
@@ -3406,7 +3407,8 @@ function tryBuildContext(dotenvVars = {}) {
3406
3407
  auth,
3407
3408
  client: CLI_CLIENT
3408
3409
  }),
3409
- auth
3410
+ auth,
3411
+ apiUrl
3410
3412
  };
3411
3413
  }
3412
3414
  function buildContext() {
@@ -4812,6 +4814,163 @@ function registerBranchCommands(program) {
4812
4814
  console.error(`deleted branch ${app.slug}#${target.slug}`);
4813
4815
  });
4814
4816
  }
4817
+ /** Domain separators — must match `crates/seekrit-cache`. */
4818
+ const KEY_DOMAIN = "seekrit-lkg-cache-v1";
4819
+ const TOKEN_DOMAIN = "seekrit-token-fp-v1";
4820
+ /** 24h: long enough to ride out an outage, short enough to bound the tail. */
4821
+ const DEFAULT_MAX_AGE_MS = 1440 * 60 * 1e3;
4822
+ /**
4823
+ * Derive the key for one resolve request. Overrides are sorted, so flag order
4824
+ * never splits the cache. Byte-for-byte identical to `CacheKey::new` in
4825
+ * `crates/seekrit-cache`.
4826
+ */
4827
+ function cacheKey(apiUrl, token, branch, overrides = {}) {
4828
+ const pairs = Object.entries(overrides).map(([group, env]) => `${group}:${env}`).sort();
4829
+ return {
4830
+ key: createHash("sha256").update([
4831
+ KEY_DOMAIN,
4832
+ apiUrl.replace(/\/+$/, ""),
4833
+ token,
4834
+ branch ?? "",
4835
+ pairs.join(",")
4836
+ ].join("\n")).digest("hex"),
4837
+ tokenFingerprint: tokenFingerprint(token)
4838
+ };
4839
+ }
4840
+ /** The hex SHA-256 identifying a token without storing it. */
4841
+ function tokenFingerprint(token) {
4842
+ return createHash("sha256").update(`${TOKEN_DOMAIN}\n${token}`).digest("hex");
4843
+ }
4844
+ /**
4845
+ * `$XDG_CACHE_HOME/seekrit`, else `~/.cache/seekrit` — beside the config
4846
+ * directory holding the credential, never a shared temp directory.
4847
+ */
4848
+ function defaultCacheDir() {
4849
+ return join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "seekrit");
4850
+ }
4851
+ /** Render an age for a log line, rounded to its largest whole unit. */
4852
+ function humanize(ms) {
4853
+ const secs = Math.floor(ms / 1e3);
4854
+ if (secs < 60) return `${secs}s`;
4855
+ if (secs < 3600) return `${Math.floor(secs / 60)}m`;
4856
+ if (secs < 86400) return `${Math.floor(secs / 3600)}h`;
4857
+ return `${Math.floor(secs / 86400)}d`;
4858
+ }
4859
+ /**
4860
+ * Whether a failed resolve means the API was *unreachable* (the cache may stand
4861
+ * in) rather than *refusing us* (it must not). A refusal is an answer, and
4862
+ * revocation is supposed to take effect the moment it arrives.
4863
+ */
4864
+ function mayFallBack(err) {
4865
+ if (err instanceof SeekritApiError) return err.status >= 500 || err.status === 429;
4866
+ return true;
4867
+ }
4868
+ /** An opened cache, bound to one resolve request. */
4869
+ var LkgCache = class {
4870
+ dir;
4871
+ maxAgeMs;
4872
+ id;
4873
+ constructor(id, options = {}) {
4874
+ this.id = id;
4875
+ this.dir = options.dir ?? defaultCacheDir();
4876
+ this.maxAgeMs = options.maxAgeMs ?? 864e5;
4877
+ }
4878
+ path() {
4879
+ return join(this.dir, `${this.id.key}.json`);
4880
+ }
4881
+ read() {
4882
+ const path = this.path();
4883
+ if (!existsSync(path)) return { kind: "missing" };
4884
+ let envelope;
4885
+ try {
4886
+ envelope = JSON.parse(readFileSync(path, "utf8"));
4887
+ } catch (err) {
4888
+ return {
4889
+ kind: "unusable",
4890
+ reason: `corrupt cache entry: ${message(err)}`
4891
+ };
4892
+ }
4893
+ if (envelope.version !== 1) return {
4894
+ kind: "unusable",
4895
+ reason: `cache entry is format v${envelope.version}, this build reads v1`
4896
+ };
4897
+ if (envelope.tokenFingerprint !== this.id.tokenFingerprint) return {
4898
+ kind: "unusable",
4899
+ reason: "cache entry belongs to a different token"
4900
+ };
4901
+ if (typeof envelope.body !== "string") return {
4902
+ kind: "unusable",
4903
+ reason: "cache entry has no body"
4904
+ };
4905
+ const ageMs = Math.max(0, Date.now() - envelope.fetchedAt * 1e3);
4906
+ if (ageMs >= this.maxAgeMs) return {
4907
+ kind: "expired",
4908
+ ageMs
4909
+ };
4910
+ return {
4911
+ kind: "hit",
4912
+ body: envelope.body,
4913
+ ageMs
4914
+ };
4915
+ }
4916
+ /**
4917
+ * Record a freshly-fetched response. Written to a temporary file and renamed,
4918
+ * so a concurrent reader sees either the old entry or the new one — never a
4919
+ * half-written file. Throws only on genuinely unexpected I/O; callers treat a
4920
+ * failed write as a warning, never as a failed resolve.
4921
+ */
4922
+ write(body) {
4923
+ mkdirSync(this.dir, {
4924
+ recursive: true,
4925
+ mode: 448
4926
+ });
4927
+ const envelope = {
4928
+ version: 1,
4929
+ fetchedAt: Math.floor(Date.now() / 1e3),
4930
+ tokenFingerprint: this.id.tokenFingerprint,
4931
+ body
4932
+ };
4933
+ const tmp = join(this.dir, `${this.id.key}.${process.pid}.tmp`);
4934
+ try {
4935
+ writeFileSync(tmp, JSON.stringify(envelope), { mode: 384 });
4936
+ renameSync(tmp, this.path());
4937
+ } catch (err) {
4938
+ rmSync(tmp, { force: true });
4939
+ throw err;
4940
+ }
4941
+ this.pruneExpired();
4942
+ }
4943
+ /** Drop this entry — used when the API refuses the token. */
4944
+ invalidate() {
4945
+ rmSync(this.path(), { force: true });
4946
+ }
4947
+ /**
4948
+ * Delete entries past their usefulness (a rotated token or a changed
4949
+ * `--with` leaves one nothing will read again). Best-effort and silent, and
4950
+ * it only ever touches files this cache named: `<64 hex>.json`.
4951
+ */
4952
+ pruneExpired() {
4953
+ let names;
4954
+ try {
4955
+ names = readdirSync(this.dir);
4956
+ } catch {
4957
+ return;
4958
+ }
4959
+ for (const name of names) {
4960
+ if (!/^[0-9a-f]{64}\.json$/.test(name)) continue;
4961
+ const path = join(this.dir, name);
4962
+ let expired = true;
4963
+ try {
4964
+ const envelope = JSON.parse(readFileSync(path, "utf8"));
4965
+ expired = Math.max(0, Date.now() - envelope.fetchedAt * 1e3) >= this.maxAgeMs;
4966
+ } catch {}
4967
+ if (expired) rmSync(path, { force: true });
4968
+ }
4969
+ }
4970
+ };
4971
+ function message(err) {
4972
+ return err instanceof Error ? err.message : String(err);
4973
+ }
4815
4974
  //#endregion
4816
4975
  //#region src/format.ts
4817
4976
  function shellQuote(value) {
@@ -6070,7 +6229,7 @@ async function materializeEnv(ctx, opts) {
6070
6229
  if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
6071
6230
  query.env = opts.envId;
6072
6231
  }
6073
- const { scope, layers } = await ctx.client.resolve(query);
6232
+ const { scope, layers } = await resolveWithCache(ctx, query, opts.cache);
6074
6233
  const privateKey = await getPrivateKey(ctx);
6075
6234
  const values = {};
6076
6235
  const provenance = {};
@@ -6094,6 +6253,46 @@ async function materializeEnv(ctx, opts) {
6094
6253
  };
6095
6254
  }
6096
6255
  /**
6256
+ * Resolve, going through the last-known-good cache when one is configured.
6257
+ *
6258
+ * Always live first: the cache exists for when the call cannot land, not to
6259
+ * save a round trip, so a recovered network is picked up on the very next
6260
+ * invocation. A *refused* resolve (401/403/…) drops the entry rather than
6261
+ * falling back to it — otherwise revoking a token would keep working offline
6262
+ * until the entry aged out.
6263
+ */
6264
+ async function resolveWithCache(ctx, query, cache) {
6265
+ if (!cache) return ctx.client.resolve(query);
6266
+ try {
6267
+ const response = await ctx.client.resolve(query);
6268
+ try {
6269
+ cache.write(JSON.stringify(response));
6270
+ } catch (err) {
6271
+ warn(`could not update the cache: ${errorMessage(err)}`);
6272
+ }
6273
+ return response;
6274
+ } catch (err) {
6275
+ if (!mayFallBack(err)) {
6276
+ cache.invalidate();
6277
+ throw err;
6278
+ }
6279
+ const found = cache.read();
6280
+ if (found.kind === "hit") {
6281
+ warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
6282
+ return JSON.parse(found.body);
6283
+ }
6284
+ if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
6285
+ else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
6286
+ throw err;
6287
+ }
6288
+ }
6289
+ function warn(text) {
6290
+ process.stderr.write(`seekrit: ${text}\n`);
6291
+ }
6292
+ function errorMessage(err) {
6293
+ return err instanceof Error ? err.message : String(err);
6294
+ }
6295
+ /**
6097
6296
  * Overlay `.env` files onto an existing value/provenance set (later files win).
6098
6297
  * Missing files are skipped. Returns the files that were actually loaded. Used
6099
6298
  * both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
@@ -6840,8 +7039,55 @@ withTarget(secrets.command("rm <name>").description("delete a secret")).action(a
6840
7039
  await ctx.client.deleteSecret(orgId, envId, name);
6841
7040
  console.error(`${name} deleted`);
6842
7041
  });
7042
+ /**
7043
+ * Open the last-known-good cache for this request, or `undefined` when it is
7044
+ * off. `dotenvVars` carries `SEEKRIT_*` values read from a `.env` file, so the
7045
+ * flags follow the same `flag > env > .env` precedence as the credentials.
7046
+ *
7047
+ * **Service tokens only.** A user session's private key is fetched from the API
7048
+ * and unlocked with a passphrase, so caching the resolve response alone would
7049
+ * not make an offline run work — and the token is what the cache key is built
7050
+ * from. `--cache` under user auth is a no-op we say out loud rather than a
7051
+ * silent one.
7052
+ */
7053
+ function openCache(ctx, options, dotenvVars = {}) {
7054
+ const lookup = (key) => process.env[key] ?? dotenvVars[key];
7055
+ const truthy = (value) => [
7056
+ "1",
7057
+ "true",
7058
+ "yes",
7059
+ "on"
7060
+ ].includes((value ?? "").trim().toLowerCase());
7061
+ if (!(options.cache ?? truthy(lookup("SEEKRIT_CACHE")))) return void 0;
7062
+ if (!isTokenAuth(ctx)) {
7063
+ process.stderr.write("seekrit: --cache needs a service token (SEEKRIT_TOKEN); continuing without it\n");
7064
+ return;
7065
+ }
7066
+ const token = ctx.auth.type === "bearer" ? ctx.auth.token : "";
7067
+ const maxAgeRaw = options.cacheMaxAge ?? lookup("SEEKRIT_CACHE_MAX_AGE");
7068
+ const maxAgeMs = maxAgeRaw ? parseDurationMs(maxAgeRaw) : DEFAULT_MAX_AGE_MS;
7069
+ const branch = options.branch ?? process.env.SEEKRIT_BRANCH ?? dotenvVars.SEEKRIT_BRANCH;
7070
+ return new LkgCache(cacheKey(ctx.apiUrl, token, branch, options.with), {
7071
+ dir: options.cacheDir ?? lookup("SEEKRIT_CACHE_DIR"),
7072
+ maxAgeMs
7073
+ });
7074
+ }
7075
+ /** `30s` / `15m` / `24h` / `7d` / bare seconds → ms. Matches the Rust parser. */
7076
+ function parseDurationMs(raw) {
7077
+ const match = /^(\d+)\s*([smhd]?)$/.exec(raw.trim());
7078
+ if (!match) fail(`--cache-max-age: not a duration: "${raw}" (try 15m, 24h, 7d)`);
7079
+ const value = Number(match[1]);
7080
+ if (value === 0) fail("--cache-max-age: must be greater than zero");
7081
+ return value * ({
7082
+ "": 1,
7083
+ s: 1,
7084
+ m: 60,
7085
+ h: 3600,
7086
+ d: 86400
7087
+ }[match[2] ?? ""] ?? 1) * 1e3;
7088
+ }
6843
7089
  /** Resolve the layered environment for the current principal. */
6844
- async function materialize(ctx, options) {
7090
+ async function materialize(ctx, options, dotenvVars = {}) {
6845
7091
  let envId;
6846
7092
  if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, options)).envId;
6847
7093
  return materializeEnv(ctx, {
@@ -6849,7 +7095,8 @@ async function materialize(ctx, options) {
6849
7095
  branch: options.branch ?? process.env.SEEKRIT_BRANCH,
6850
7096
  with: options.with,
6851
7097
  envFiles: options.envFile ?? [".env"],
6852
- interpolate: options.interpolate
7098
+ interpolate: options.interpolate,
7099
+ cache: openCache(ctx, options, dotenvVars)
6853
7100
  });
6854
7101
  }
6855
7102
  /**
@@ -6871,7 +7118,7 @@ async function materializeForRun(options) {
6871
7118
  return await materialize(ctx, {
6872
7119
  ...options,
6873
7120
  branch
6874
- });
7121
+ }, dotenvVars);
6875
7122
  } catch (err) {
6876
7123
  const message = err instanceof Error ? err.message : String(err);
6877
7124
  console.error(`seekrit: continuing without seekrit-managed secrets: ${message}`);
@@ -6994,7 +7241,7 @@ async function reapStragglers(pids, signal) {
6994
7241
  process.kill(pid, "SIGKILL");
6995
7242
  } catch {}
6996
7243
  }
6997
- program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
7244
+ program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--cache", "keep a last-known-good copy of the encrypted response and fall back to it when the API is unreachable (off by default; SEEKRIT_CACHE=1)").option("--cache-dir <path>", "where to keep it (default: $XDG_CACHE_HOME/seekrit)").option("--cache-max-age <duration>", "how stale a cached copy may be and still be used, e.g. 15m, 24h, 7d (default: 24h)").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
6998
7245
  const [cmd, ...args] = commandParts;
6999
7246
  if (!cmd) fail("no command given");
7000
7247
  const { values, provenance, interpolated, unresolvedRefs } = await materializeForRun(options);
@@ -7046,7 +7293,7 @@ program.command("run").description("run a command with decrypted secrets injecte
7046
7293
  });
7047
7294
  child.on("error", (err) => fail(`failed to start ${cmd}: ${err.message}`));
7048
7295
  });
7049
- program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
7296
+ program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--cache", "keep a last-known-good copy of the encrypted response and fall back to it when the API is unreachable (off by default; SEEKRIT_CACHE=1)").option("--cache-dir <path>", "where to keep it (default: $XDG_CACHE_HOME/seekrit)").option("--cache-max-age <duration>", "how stale a cached copy may be and still be used, e.g. 15m, 24h, 7d (default: 24h)").option("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
7050
7297
  if (![
7051
7298
  "dotenv",
7052
7299
  "json",
@@ -7148,7 +7395,7 @@ registerMongoCommands(program);
7148
7395
  registerKmsCommands(program);
7149
7396
  registerRecoveryCommands(program);
7150
7397
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
7151
- const { runMcpServer } = await import("./mcp-DLplPOvz.js");
7398
+ const { runMcpServer } = await import("./mcp-DR-zla_u.js");
7152
7399
  await runMcpServer();
7153
7400
  });
7154
7401
  registerAuditCommands(program);
@@ -26,6 +26,16 @@ function errText(err) {
26
26
  isError: true
27
27
  };
28
28
  }
29
+ /** Read-only and safe to repeat — every list/inspect tool. */
30
+ const ro = {
31
+ readOnly: true,
32
+ idempotent: true
33
+ };
34
+ /** Removes or overwrites something; repeating it lands in the same state. */
35
+ const destructive = {
36
+ destructive: true,
37
+ idempotent: true
38
+ };
29
39
  /**
30
40
  * Short primer surfaced as the MCP server `instructions`. Most clients show
31
41
  * this to the model on connect, so it has to orient an agent that lands here
@@ -194,10 +204,17 @@ async function runMcpServer(options = {}) {
194
204
  version: options.version ?? version
195
205
  }, { instructions: serverInstructions() });
196
206
  /** Register a tool whose handler returns data (serialized) or throws (→ isError). */
197
- const tool = (name, description, shape, handler) => {
207
+ const tool = (name, description, hints, shape, handler) => {
198
208
  server.registerTool(name, {
199
209
  description,
200
- inputSchema: shape
210
+ inputSchema: shape,
211
+ annotations: {
212
+ title: name,
213
+ readOnlyHint: hints.readOnly ?? false,
214
+ destructiveHint: hints.destructive ?? false,
215
+ idempotentHint: hints.idempotent ?? false,
216
+ openWorldHint: true
217
+ }
201
218
  }, (async (args) => {
202
219
  try {
203
220
  return jsonText(await handler(args));
@@ -215,7 +232,7 @@ async function runMcpServer(options = {}) {
215
232
  openWorldHint: false
216
233
  }
217
234
  }, async () => jsonText(getStartedText()));
218
- tool("whoami", "Show the authenticated identity, its role, and accessible orgs.", {}, async () => {
235
+ tool("whoami", "Show the authenticated identity, its role, and accessible orgs.", ro, {}, async () => {
219
236
  const ctx = getCtx();
220
237
  if (isTokenAuth(ctx) && ctx.auth.type === "bearer") {
221
238
  const { tokenId } = await parseServiceToken(ctx.auth.token);
@@ -236,13 +253,13 @@ async function runMcpServer(options = {}) {
236
253
  ...await ctx.client.me()
237
254
  };
238
255
  });
239
- tool("list_orgs", "List organizations the caller can access.", {}, async () => (await getCtx().client.listOrgs()).orgs);
240
- tool("list_apps", "List applications in an organization.", { org: z.string().optional() }, async ({ org }) => {
256
+ tool("list_orgs", "List organizations the caller can access.", ro, {}, async () => (await getCtx().client.listOrgs()).orgs);
257
+ tool("list_apps", "List applications in an organization.", ro, { org: z.string().optional() }, async ({ org }) => {
241
258
  const ctx = getCtx();
242
259
  const orgRef = await resolveOrg(ctx, org);
243
260
  return (await ctx.client.listApps(orgRef.id)).apps;
244
261
  });
245
- tool("list_envs", "List environments of an application.", {
262
+ tool("list_envs", "List environments of an application.", ro, {
246
263
  org: z.string().optional(),
247
264
  app: z.string()
248
265
  }, async ({ org, app }) => {
@@ -253,7 +270,7 @@ async function runMcpServer(options = {}) {
253
270
  if (!appRow) throw new Error(`no app "${app}" in ${orgRef.slug}`);
254
271
  return (await ctx.client.listEnvs(orgRef.id, appRow.id)).environments;
255
272
  });
256
- tool("list_branches", "List ephemeral branch configs in an application (optionally just one environment's).", {
273
+ tool("list_branches", "List ephemeral branch configs in an application (optionally just one environment's).", ro, {
257
274
  org: z.string().optional(),
258
275
  app: z.string(),
259
276
  env: z.string().optional()
@@ -271,12 +288,12 @@ async function runMcpServer(options = {}) {
271
288
  });
272
289
  return (await ctx.client.listBranches(parent.orgId, parent.envId)).branches;
273
290
  });
274
- tool("list_groups", "List shared groups (reusable secret bags) in an organization.", { org: z.string().optional() }, async ({ org }) => {
291
+ tool("list_groups", "List shared groups (reusable secret bags) in an organization.", ro, { org: z.string().optional() }, async ({ org }) => {
275
292
  const ctx = getCtx();
276
293
  const orgRef = await resolveOrg(ctx, org);
277
294
  return (await ctx.client.listGroups(orgRef.id)).groups;
278
295
  });
279
- tool("list_group_envs", "List a group's environments (per-slug value sets).", {
296
+ tool("list_group_envs", "List a group's environments (per-slug value sets).", ro, {
280
297
  org: z.string().optional(),
281
298
  group: z.string()
282
299
  }, async ({ org, group }) => {
@@ -287,7 +304,7 @@ async function runMcpServer(options = {}) {
287
304
  });
288
305
  return (await ctx.client.listGroupEnvs(g.orgId, g.id)).environments;
289
306
  });
290
- tool("list_env_groups", "List the groups composed into an application environment (precedence order).", {
307
+ tool("list_env_groups", "List the groups composed into an application environment (precedence order).", ro, {
291
308
  org: z.string().optional(),
292
309
  app: z.string(),
293
310
  env: z.string()
@@ -300,17 +317,17 @@ async function runMcpServer(options = {}) {
300
317
  });
301
318
  return (await ctx.client.listEnvGroups(target.orgId, target.envId)).groups;
302
319
  });
303
- tool("list_members", "List organization members and their public keys (for granting access).", { org: z.string().optional() }, async ({ org }) => {
320
+ tool("list_members", "List organization members and their public keys (for granting access).", ro, { org: z.string().optional() }, async ({ org }) => {
304
321
  const ctx = getCtx();
305
322
  const orgRef = await resolveOrg(ctx, org);
306
323
  return (await ctx.client.listMembers(orgRef.id)).members;
307
324
  });
308
- tool("kms_list_keys", "List managed KMS keys the caller can see (metadata only).", { org: z.string().optional() }, async ({ org }) => {
325
+ tool("kms_list_keys", "List managed KMS keys the caller can see (metadata only).", ro, { org: z.string().optional() }, async ({ org }) => {
309
326
  const ctx = getCtx();
310
327
  const orgRef = await resolveOrg(ctx, org);
311
328
  return (await ctx.client.listKmsKeys(orgRef.id)).keys;
312
329
  });
313
- tool("kms_create_key", "Create an org-scoped managed key. Material is generated locally and wrapped to each grantee (self plus any listed users/tokens); the server never sees it. Use the CLI for app/group-scoped keys.", {
330
+ tool("kms_create_key", "Create an org-scoped managed key. Material is generated locally and wrapped to each grantee (self plus any listed users/tokens); the server never sees it. Use the CLI for app/group-scoped keys.", { idempotent: false }, {
314
331
  org: z.string().optional(),
315
332
  name: z.string(),
316
333
  purpose: z.enum(["encrypt", "sign"]),
@@ -351,7 +368,7 @@ async function runMcpServer(options = {}) {
351
368
  });
352
369
  return key;
353
370
  });
354
- tool("kms_grant", "Grant a principal (user email or token id) use of a key's current version.", {
371
+ tool("kms_grant", "Grant a principal (user email or token id) use of a key's current version.", { idempotent: true }, {
355
372
  org: z.string().optional(),
356
373
  key: z.string(),
357
374
  user: z.string().optional(),
@@ -376,7 +393,7 @@ async function runMcpServer(options = {}) {
376
393
  key: k.name
377
394
  };
378
395
  });
379
- tool("kms_encrypt", "Encrypt a value under a managed encrypt key; returns a ce1 ciphertext blob. `context` (if given) is bound as AAD and must be supplied identically to decrypt.", {
396
+ tool("kms_encrypt", "Encrypt a value under a managed encrypt key; returns a ce1 ciphertext blob. `context` (if given) is bound as AAD and must be supplied identically to decrypt.", { readOnly: true }, {
380
397
  org: z.string().optional(),
381
398
  key: z.string(),
382
399
  plaintext: z.string(),
@@ -393,7 +410,7 @@ async function runMcpServer(options = {}) {
393
410
  version: currentVersion
394
411
  }, plaintext, context ?? "") };
395
412
  });
396
- tool("kms_decrypt", "Decrypt a ce1 blob. Supply the same `context` used to encrypt.", {
413
+ tool("kms_decrypt", "Decrypt a ce1 blob. Supply the same `context` used to encrypt.", ro, {
397
414
  org: z.string().optional(),
398
415
  key: z.string(),
399
416
  ciphertext: z.string(),
@@ -407,7 +424,7 @@ async function runMcpServer(options = {}) {
407
424
  const { material } = await kmsRecoverMaterial(ctx, orgRef.id, k.id, ref.version);
408
425
  return { plaintext: await kmsDecrypt(material, ciphertext, context ?? "") };
409
426
  });
410
- tool("kms_generate_data_key", "Generate a data key under a managed encrypt key (envelope encryption). Returns the plaintext key (base64) and its wrapped form to store.", {
427
+ tool("kms_generate_data_key", "Generate a data key under a managed encrypt key (envelope encryption). Returns the plaintext key (base64) and its wrapped form to store.", { readOnly: true }, {
411
428
  org: z.string().optional(),
412
429
  key: z.string()
413
430
  }, async ({ org, key }) => {
@@ -426,7 +443,7 @@ async function runMcpServer(options = {}) {
426
443
  wrapped: dk.wrapped
427
444
  };
428
445
  });
429
- tool("kms_sign", "Sign a message with a managed signing key; returns an sg1 signature blob.", {
446
+ tool("kms_sign", "Sign a message with a managed signing key; returns an sg1 signature blob.", { readOnly: true }, {
430
447
  org: z.string().optional(),
431
448
  key: z.string(),
432
449
  message: z.string()
@@ -442,7 +459,7 @@ async function runMcpServer(options = {}) {
442
459
  version: currentVersion
443
460
  }, message) };
444
461
  });
445
- tool("kms_verify", "Verify an sg1 signature over a message using a signing key's published public key (no grant needed).", {
462
+ tool("kms_verify", "Verify an sg1 signature over a message using a signing key's published public key (no grant needed).", ro, {
446
463
  org: z.string().optional(),
447
464
  key: z.string(),
448
465
  signature: z.string(),
@@ -457,7 +474,7 @@ async function runMcpServer(options = {}) {
457
474
  if (!pub) throw new Error(`no published public key for version ${ref.version}`);
458
475
  return { valid: await verifyMessage(await importVerifyingKey(pub), signature, message) };
459
476
  });
460
- tool("list_secrets", "List secret names + versions in an environment (never values).", targetShape, async (o) => {
477
+ tool("list_secrets", "List secret names + versions in an environment (never values).", ro, targetShape, async (o) => {
461
478
  const ctx = getCtx();
462
479
  const { orgId, envId } = await resolveTargetEnv(ctx, o);
463
480
  const { secrets } = await ctx.client.listSecrets(orgId, envId);
@@ -467,12 +484,12 @@ async function runMcpServer(options = {}) {
467
484
  updatedAt: s.updatedAt
468
485
  }));
469
486
  });
470
- tool("list_tokens", "List an organization's service tokens (never the secret token strings).", { org: z.string().optional() }, async ({ org }) => {
487
+ tool("list_tokens", "List an organization's service tokens (never the secret token strings).", ro, { org: z.string().optional() }, async ({ org }) => {
471
488
  const ctx = getCtx();
472
489
  const orgRef = await resolveOrg(ctx, org);
473
490
  return (await ctx.client.listTokens(orgRef.id)).tokens;
474
491
  });
475
- tool("audit", "Read the organization's audit trail (most recent first).", {
492
+ tool("audit", "Read the organization's audit trail (most recent first).", ro, {
476
493
  org: z.string().optional(),
477
494
  limit: z.number().int().min(1).max(200).optional(),
478
495
  action: z.string().optional().describe("filter by action, e.g. secret.updated")
@@ -484,14 +501,14 @@ async function runMcpServer(options = {}) {
484
501
  action
485
502
  })).entries;
486
503
  });
487
- tool("create_org", "Create an organization. Requires a user session — service tokens cannot own a Stytch org.", {
504
+ tool("create_org", "Create an organization. Requires a user session — service tokens cannot own a Stytch org.", { idempotent: false }, {
488
505
  name: z.string(),
489
506
  slug: z.string()
490
507
  }, async ({ name, slug }) => (await getCtx().client.createOrg({
491
508
  name,
492
509
  slug
493
510
  })).org);
494
- tool("create_app", "Create an application in an organization.", {
511
+ tool("create_app", "Create an application in an organization.", { idempotent: false }, {
495
512
  org: z.string().optional(),
496
513
  name: z.string(),
497
514
  slug: z.string()
@@ -503,7 +520,7 @@ async function runMcpServer(options = {}) {
503
520
  slug
504
521
  })).app;
505
522
  });
506
- tool("create_group", "Create a shared group (reusable secret bag) in an organization.", {
523
+ tool("create_group", "Create a shared group (reusable secret bag) in an organization.", { idempotent: false }, {
507
524
  org: z.string().optional(),
508
525
  name: z.string(),
509
526
  slug: z.string()
@@ -515,7 +532,7 @@ async function runMcpServer(options = {}) {
515
532
  slug
516
533
  })).group;
517
534
  });
518
- tool("create_env", "Create an application environment. Generates the data key locally and grants it to the caller.", {
535
+ tool("create_env", "Create an application environment. Generates the data key locally and grants it to the caller.", { idempotent: false }, {
519
536
  org: z.string().optional(),
520
537
  app: z.string(),
521
538
  name: z.string(),
@@ -533,7 +550,7 @@ async function runMcpServer(options = {}) {
533
550
  wrappedDek
534
551
  })).environment;
535
552
  });
536
- tool("create_branch", "Fork an environment into an ephemeral branch (a per-PR / preview config). The branch inherits the parent's secrets by layering at read time — nothing is copied or re-encrypted — and holds only the values you override on it. Generates the branch's data key locally, grants it to the caller, and shares it with the parent's other readers.", {
553
+ tool("create_branch", "Fork an environment into an ephemeral branch (a per-PR / preview config). The branch inherits the parent's secrets by layering at read time — nothing is copied or re-encrypted — and holds only the values you override on it. Generates the branch's data key locally, grants it to the caller, and shares it with the parent's other readers.", { idempotent: false }, {
537
554
  org: z.string().optional(),
538
555
  app: z.string(),
539
556
  from: z.string().describe("the environment to branch"),
@@ -568,7 +585,7 @@ async function runMcpServer(options = {}) {
568
585
  grants
569
586
  })).branch;
570
587
  });
571
- tool("delete_branch", "Tear down a branch config and every value it overrode. The parent environment is untouched.", {
588
+ tool("delete_branch", "Tear down a branch config and every value it overrode. The parent environment is untouched.", destructive, {
572
589
  org: z.string().optional(),
573
590
  app: z.string(),
574
591
  branch: z.string()
@@ -582,7 +599,7 @@ async function runMcpServer(options = {}) {
582
599
  await ctx.client.deleteBranch(appRef.orgId, target.id);
583
600
  return { deleted: target.slug };
584
601
  });
585
- tool("create_group_env", "Create a group environment. Generates the data key locally and grants it to the caller.", {
602
+ tool("create_group_env", "Create a group environment. Generates the data key locally and grants it to the caller.", { idempotent: false }, {
586
603
  org: z.string().optional(),
587
604
  group: z.string(),
588
605
  name: z.string(),
@@ -600,7 +617,7 @@ async function runMcpServer(options = {}) {
600
617
  wrappedDek
601
618
  })).environment;
602
619
  });
603
- tool("compose_group", "Compose a group into an application environment (higher position wins on name clashes).", {
620
+ tool("compose_group", "Compose a group into an application environment (higher position wins on name clashes).", { idempotent: true }, {
604
621
  org: z.string().optional(),
605
622
  app: z.string(),
606
623
  env: z.string(),
@@ -622,7 +639,7 @@ async function runMcpServer(options = {}) {
622
639
  position
623
640
  })).group;
624
641
  });
625
- tool("uncompose_group", "Remove a composed group from an application environment.", {
642
+ tool("uncompose_group", "Remove a composed group from an application environment.", { idempotent: true }, {
626
643
  org: z.string().optional(),
627
644
  app: z.string(),
628
645
  env: z.string(),
@@ -641,7 +658,7 @@ async function runMcpServer(options = {}) {
641
658
  await ctx.client.unlinkEnvGroup(target.orgId, target.envId, g.id);
642
659
  return { ok: true };
643
660
  });
644
- tool("set_secret", "Encrypt a value locally and store it in an environment. A value may reference another secret as ${OTHER_SECRET}: the reference is stored literally and expanded whenever the secret is read, so it tracks the referenced value. Write $${OTHER_SECRET} for a literal.", {
661
+ tool("set_secret", "Encrypt a value locally and store it in an environment. A value may reference another secret as ${OTHER_SECRET}: the reference is stored literally and expanded whenever the secret is read, so it tracks the referenced value. Write $${OTHER_SECRET} for a literal.", { idempotent: false }, {
645
662
  ...targetShape,
646
663
  name: z.string(),
647
664
  value: z.string()
@@ -655,7 +672,7 @@ async function runMcpServer(options = {}) {
655
672
  name: o.name
656
673
  };
657
674
  });
658
- tool("get_secret", "Return one secret. By default only reports presence + version; pass reveal:true to decrypt the plaintext into this response (avoid unless the value is actually needed — prefer run_command). A revealed current value has its ${OTHER_SECRET} references expanded against this environment's own secrets; pass raw:true for the stored text instead. Pass `version` to read an earlier version instead of the current one (always as stored, never expanded).", {
675
+ tool("get_secret", "Return one secret. By default only reports presence + version; pass reveal:true to decrypt the plaintext into this response (avoid unless the value is actually needed — prefer run_command). A revealed current value has its ${OTHER_SECRET} references expanded against this environment's own secrets; pass raw:true for the stored text instead. Pass `version` to read an earlier version instead of the current one (always as stored, never expanded).", ro, {
659
676
  ...targetShape,
660
677
  name: z.string(),
661
678
  reveal: z.boolean().optional(),
@@ -692,7 +709,7 @@ async function runMcpServer(options = {}) {
692
709
  revealed: true
693
710
  };
694
711
  });
695
- tool("list_secret_versions", "List a secret's version history: who wrote each version, when, and which ones were restores. Never returns values — pair it with restore_secret to roll back, or get_secret(version, reveal:true) to inspect one.", {
712
+ tool("list_secret_versions", "List a secret's version history: who wrote each version, when, and which ones were restores. Never returns values — pair it with restore_secret to roll back, or get_secret(version, reveal:true) to inspect one.", ro, {
696
713
  ...targetShape,
697
714
  name: z.string(),
698
715
  limit: z.number().int().min(1).max(200).optional().describe("default 20")
@@ -710,7 +727,7 @@ async function runMcpServer(options = {}) {
710
727
  }))
711
728
  };
712
729
  });
713
- tool("restore_secret", "Roll a secret back to an earlier version. The stored ciphertext is replayed as a NEW version (history is append-only, nothing is overwritten). Keyless — no decryption happens, so this works even without a key.", {
730
+ tool("restore_secret", "Roll a secret back to an earlier version. The stored ciphertext is replayed as a NEW version (history is append-only, nothing is overwritten). Keyless — no decryption happens, so this works even without a key.", { idempotent: false }, {
714
731
  ...targetShape,
715
732
  name: z.string(),
716
733
  version: z.number().int().positive()
@@ -725,7 +742,7 @@ async function runMcpServer(options = {}) {
725
742
  version: secret.version
726
743
  };
727
744
  });
728
- tool("delete_secret", "Delete a secret from an environment.", {
745
+ tool("delete_secret", "Delete a secret from an environment.", destructive, {
729
746
  ...targetShape,
730
747
  name: z.string()
731
748
  }, async (o) => {
@@ -738,6 +755,9 @@ async function runMcpServer(options = {}) {
738
755
  };
739
756
  });
740
757
  tool("run_command", "Run a command with the resolved secrets injected as environment variables, and return its exit code + captured output. Secret VALUES are never returned — this is the preferred way to use secrets. process env > .env > app env > groups.", {
758
+ destructive: true,
759
+ idempotent: false
760
+ }, {
741
761
  command: z.string().describe("executable to run"),
742
762
  args: z.array(z.string()).optional(),
743
763
  org: z.string().optional(),
@@ -760,7 +780,7 @@ async function runMcpServer(options = {}) {
760
780
  injectedVarCount: Object.keys(values).length
761
781
  };
762
782
  });
763
- tool("export_env", "Write the resolved secrets to a dotenv file on disk and return the variable names written (never the values). Use to materialize a .env for local tooling.", {
783
+ tool("export_env", "Write the resolved secrets to a dotenv file on disk and return the variable names written (never the values). Use to materialize a .env for local tooling.", destructive, {
764
784
  file: z.string().describe("path to write, e.g. .env"),
765
785
  org: z.string().optional(),
766
786
  app: z.string().optional(),
@@ -779,7 +799,7 @@ async function runMcpServer(options = {}) {
779
799
  names: Object.keys(values).sort()
780
800
  };
781
801
  });
782
- tool("create_token", "Mint a service token, printed once. Runtime tokens bind to one app environment (auto-granted its keys, so a command/agent can decrypt it). Pass admin:true for an org-scoped provisioning token (create apps/groups/envs, grant, mint tokens) — admin tokens need no env binding.", {
802
+ tool("create_token", "Mint a service token, printed once. Runtime tokens bind to one app environment (auto-granted its keys, so a command/agent can decrypt it). Pass admin:true for an org-scoped provisioning token (create apps/groups/envs, grant, mint tokens) — admin tokens need no env binding.", { idempotent: false }, {
783
803
  name: z.string().describe("display name, e.g. ci-deploy or agent-session"),
784
804
  org: z.string().optional(),
785
805
  app: z.string().optional().describe("bind to this app (runtime tokens)"),
@@ -830,7 +850,7 @@ async function runMcpServer(options = {}) {
830
850
  note: "save this now — the secret token string is not stored and cannot be retrieved"
831
851
  };
832
852
  });
833
- tool("revoke_token", "Revoke a service token by id.", {
853
+ tool("revoke_token", "Revoke a service token by id.", destructive, {
834
854
  org: z.string().optional(),
835
855
  tokenId: z.string()
836
856
  }, async ({ org, tokenId }) => {
@@ -842,7 +862,7 @@ async function runMcpServer(options = {}) {
842
862
  tokenId
843
863
  };
844
864
  });
845
- tool("grant_env", "Grant a member (by email) or service token (by id) access to an environment's data key. Re-wraps the DEK to the grantee — the caller must already hold the key.", {
865
+ tool("grant_env", "Grant a member (by email) or service token (by id) access to an environment's data key. Re-wraps the DEK to the grantee — the caller must already hold the key.", { idempotent: true }, {
846
866
  ...targetShape,
847
867
  user: z.string().optional().describe("org member email"),
848
868
  token: z.string().optional().describe("service token id (skt_…)")
@@ -887,12 +907,12 @@ async function runMcpServer(options = {}) {
887
907
  principalId
888
908
  };
889
909
  });
890
- tool("list_pg_targets", "List registered Postgres provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
910
+ tool("list_pg_targets", "List registered Postgres provisioning targets for temporary credentials.", ro, { org: z.string().optional() }, async ({ org }) => {
891
911
  const ctx = getCtx();
892
912
  const orgRef = await resolveOrg(ctx, org);
893
913
  return (await ctx.client.listLeaseTargets(orgRef.id)).targets;
894
914
  });
895
- tool("create_pg_lease", "Mint a short-lived Postgres credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its SCRAM verifier is sent to the API — the plaintext never reaches seekrit or Postgres at rest. The role auto-expires; revoke early with revoke_pg_lease.", {
915
+ tool("create_pg_lease", "Mint a short-lived Postgres credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its SCRAM verifier is sent to the API — the plaintext never reaches seekrit or Postgres at rest. The role auto-expires; revoke early with revoke_pg_lease.", { idempotent: false }, {
896
916
  org: z.string().optional(),
897
917
  target: z.string().describe("target id or name"),
898
918
  role: z.string().optional().describe("role name to create (default: random tmp_ name)"),
@@ -921,12 +941,12 @@ async function runMcpServer(options = {}) {
921
941
  note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
922
942
  };
923
943
  });
924
- tool("list_pg_leases", "List Postgres leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
944
+ tool("list_pg_leases", "List Postgres leases (the ledger — never secret material).", ro, { org: z.string().optional() }, async ({ org }) => {
925
945
  const ctx = getCtx();
926
946
  const orgRef = await resolveOrg(ctx, org);
927
947
  return (await ctx.client.listLeases(orgRef.id)).leases;
928
948
  });
929
- tool("revoke_pg_lease", "Revoke a Postgres lease now (drops the role immediately).", {
949
+ tool("revoke_pg_lease", "Revoke a Postgres lease now (drops the role immediately).", destructive, {
930
950
  org: z.string().optional(),
931
951
  leaseId: z.string()
932
952
  }, async ({ org, leaseId }) => {
@@ -938,12 +958,12 @@ async function runMcpServer(options = {}) {
938
958
  leaseId
939
959
  };
940
960
  });
941
- tool("list_mysql_targets", "List registered MySQL/MariaDB provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
961
+ tool("list_mysql_targets", "List registered MySQL/MariaDB provisioning targets for temporary credentials.", ro, { org: z.string().optional() }, async ({ org }) => {
942
962
  const ctx = getCtx();
943
963
  const orgRef = await resolveOrg(ctx, org);
944
964
  return (await ctx.client.listLeaseTargets(orgRef.id)).targets.filter((t) => t.provider === "mysql");
945
965
  });
946
- tool("create_mysql_lease", "Mint a short-lived MySQL/MariaDB credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its mysql_native_password hash is sent to the API — the plaintext never reaches seekrit or MySQL at rest. The user auto-expires; revoke early with revoke_mysql_lease.", {
966
+ tool("create_mysql_lease", "Mint a short-lived MySQL/MariaDB credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its mysql_native_password hash is sent to the API — the plaintext never reaches seekrit or MySQL at rest. The user auto-expires; revoke early with revoke_mysql_lease.", { idempotent: false }, {
947
967
  org: z.string().optional(),
948
968
  target: z.string().describe("target id or name"),
949
969
  user: z.string().optional().describe("user name to create (default: random tmp_ name)"),
@@ -973,12 +993,12 @@ async function runMcpServer(options = {}) {
973
993
  note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
974
994
  };
975
995
  });
976
- tool("list_mysql_leases", "List MySQL/MariaDB leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
996
+ tool("list_mysql_leases", "List MySQL/MariaDB leases (the ledger — never secret material).", ro, { org: z.string().optional() }, async ({ org }) => {
977
997
  const ctx = getCtx();
978
998
  const orgRef = await resolveOrg(ctx, org);
979
999
  return (await ctx.client.listLeases(orgRef.id)).leases.filter((l) => l.provider === "mysql");
980
1000
  });
981
- tool("revoke_mysql_lease", "Revoke a MySQL/MariaDB lease now (drops the user immediately).", {
1001
+ tool("revoke_mysql_lease", "Revoke a MySQL/MariaDB lease now (drops the user immediately).", destructive, {
982
1002
  org: z.string().optional(),
983
1003
  leaseId: z.string()
984
1004
  }, async ({ org, leaseId }) => {
@@ -990,7 +1010,7 @@ async function runMcpServer(options = {}) {
990
1010
  leaseId
991
1011
  };
992
1012
  });
993
- tool("configure_project", "Link a directory to an org/app by writing seekrit.json (like `seekrit init`). The environment is chosen by the service token at runtime.", {
1013
+ tool("configure_project", "Link a directory to an org/app by writing seekrit.json (like `seekrit init`). The environment is chosen by the service token at runtime.", destructive, {
994
1014
  org: z.string(),
995
1015
  app: z.string(),
996
1016
  dir: z.string().optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -26,6 +26,7 @@
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.0",
28
28
  "tsdown": "^0.22.3",
29
+ "vitest": "^4.1.9",
29
30
  "@seekrit/api-client": "0.0.1",
30
31
  "@seekrit/core": "0.0.1",
31
32
  "@seekrit/crypto": "0.0.1"
@@ -33,6 +34,7 @@
33
34
  "scripts": {
34
35
  "build": "tsdown",
35
36
  "dev": "tsdown --watch",
37
+ "test": "vitest run",
36
38
  "typecheck": "tsc --noEmit"
37
39
  }
38
40
  }