@vincemakes/kiso-tools-node 0.34.0 → 0.37.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.d.ts CHANGED
@@ -100,6 +100,12 @@ export interface WorkspaceToolsOptions {
100
100
  * name — explicit beats the heuristic, as it does for MCP servers.
101
101
  */
102
102
  readonly shellEnv?: "inherit" | Readonly<Record<string, string>>;
103
+ /** Astra F7: the env var NAMES the configured profiles authenticate with.
104
+ * The strip's suffix rules (`_API_KEY`, `_AUTH_TOKEN`) cannot see a name
105
+ * like `REVIEW_PROVIDER_TOKEN`, and only the config knows which names are
106
+ * secrets — so it says so. Ignored when `shellEnv` is "inherit", which is
107
+ * an explicit opt-in to the whole environment. */
108
+ readonly secretEnvNames?: readonly string[];
103
109
  /**
104
110
  * DC-54 — the bounds that keep a tool call finite. Every field is
105
111
  * optional and defaults to the constant beside it; a host embedding
@@ -166,6 +172,7 @@ export declare function editFileTool(opts: WorkspaceToolsOptions): Tool<{
166
172
  }[];
167
173
  expectedRevision?: string;
168
174
  }>;
175
+ export { SHELL_STRIP_EXACT, strippedShellEnv } from "./secret-env.js";
169
176
  export declare function shellTool(opts: WorkspaceToolsOptions): Tool<{
170
177
  command: string;
171
178
  timeoutMs?: number;
package/dist/index.js CHANGED
@@ -27,7 +27,9 @@ import { tmpdir } from "node:os";
27
27
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
28
28
  import { defineTool } from "@vincemakes/kiso-core";
29
29
  // WR-1/WR-1A — the revision-guard primitives (unit-tested in wr1a-coda):
30
+ import { strippedShellEnv } from "./secret-env.js";
30
31
  import { contentRevision, normalizeRevision, postEffectEscape, precondition, publishNewFile, revalidateBeforeRename } from "./wr1.js";
32
+ import { describeSearchMiss } from "./search-miss.js";
31
33
  /**
32
34
  * TUI2-R1 (C) — THE SHELL PROGRESS SIDECAR.
33
35
  *
@@ -75,6 +77,12 @@ const DEFAULT_SHELL_TIMEOUT_MS = 30_000;
75
77
  // red line: every truncation names its continuation — the model always
76
78
  // has a path to the full content.
77
79
  const DEFAULT_READ_LINES = 200;
80
+ /** The default window's SECOND bound, and the one lines cannot express: 200
81
+ * lines of minified source is megabytes, 200 lines of prose is a few KB.
82
+ * Whichever binds first wins, and the cut is always at a line boundary. An
83
+ * explicit `limit` is the caller saying what they want and is not capped
84
+ * here — the 100k output cap still applies to it. */
85
+ const DEFAULT_READ_CHARS = 16_000;
78
86
  /** R3: how many files a search may read before it hands the event loop
79
87
  * back. Small enough that the 200ms motion cadence never misses a beat,
80
88
  * large enough that the yield costs nothing on a small tree. */
@@ -304,13 +312,17 @@ const INODE_SCAN_MS = 2_000;
304
312
  /** The "… N more lines" note — the actionable continuation: the exact
305
313
  * line the next read must start at, so the model can always reach the
306
314
  * full content in ranges (the red line). */
307
- function moreLinesNote(nextOffset, remaining) {
308
- return `\n… ${remaining} more ${remaining === 1 ? "line" : "lines"} (call again with offset=${nextOffset})`;
315
+ function moreLinesNote(nextOffset, remaining, limit) {
316
+ // BOTH parameters. Naming only `offset` was an instruction to read the
317
+ // rest of the file: with `limit` absent the read runs to EOF, so a model
318
+ // following its own continuation note defeated the window from the second
319
+ // read onward. Measured before the fix: 7.3% of real reads took that path.
320
+ return `\n… ${remaining} more ${remaining === 1 ? "line" : "lines"} (call again with offset=${nextOffset} limit=${limit})`;
309
321
  }
310
322
  export function readFileTool(opts) {
311
323
  return defineTool({
312
324
  name: "read_file",
313
- description: "Read a workspace file or a range (offset/limit; default: the first 200 lines, with a continuation note). The final [rev:X] line identifies the version read.",
325
+ description: "Read a workspace file or a range. Without `limit`: 200 lines or 16000 chars from `offset` (default 1), whichever binds, then a note naming the next offset and limit. The final [rev:X] line identifies the version read.",
314
326
  parameters: {
315
327
  type: "object",
316
328
  properties: {
@@ -397,21 +409,44 @@ export function readFileTool(opts) {
397
409
  errorKind: "invalid_input",
398
410
  };
399
411
  }
400
- const end = limit === undefined ? total : Math.min(start + limit - 1, total);
401
- // DEFAULT: the head 200 lines; a larger file ends with the
402
- // honest continuation note (small files ≤ 200 lines are
403
- // byte-identical to the pre-token-round behavior).
412
+ // THE DEFAULT WINDOW applies whenever `limit` is ABSENT, from
413
+ // `offset ?? 1`. It used to apply only when BOTH were absent,
414
+ // so an offset alone read to the end of the file — and since
415
+ // the note named only `offset`, a model following its own
416
+ // continuation note left the window behind after one read.
404
417
  let text;
405
418
  let note = "";
406
- if (offset === undefined && limit === undefined) {
407
- text = total <= DEFAULT_READ_LINES ? content : parts.slice(0, DEFAULT_READ_LINES).join("\n");
408
- if (total > DEFAULT_READ_LINES)
409
- note = moreLinesNote(DEFAULT_READ_LINES + 1, total - DEFAULT_READ_LINES);
419
+ if (limit === undefined) {
420
+ const lastLine = Math.min(start + DEFAULT_READ_LINES - 1, total);
421
+ const slice = parts.slice(start - 1, lastLine);
422
+ let body = slice.join("\n");
423
+ let shown = slice.length;
424
+ if (body.length > DEFAULT_READ_CHARS) {
425
+ const cut = body.lastIndexOf("\n", DEFAULT_READ_CHARS);
426
+ if (cut > 0) {
427
+ body = body.slice(0, cut);
428
+ shown = body.split("\n").length;
429
+ }
430
+ else {
431
+ // No newline inside the budget: the first line alone
432
+ // is over it. One WHOLE line is the smallest honest
433
+ // answer — a cut mid-line is a lie about the file.
434
+ body = slice[0] ?? "";
435
+ shown = 1;
436
+ }
437
+ }
438
+ // The whole file from line 1 is returned VERBATIM, trailing
439
+ // newline included: `parts.join` would drop it.
440
+ text = start === 1 && shown === total ? content : body;
441
+ const next = start + shown;
442
+ if (next <= total)
443
+ note = moreLinesNote(next, total - next + 1, DEFAULT_READ_LINES);
410
444
  }
411
445
  else {
446
+ const end = Math.min(start + limit - 1, total);
412
447
  text = parts.slice(start - 1, end).join("\n");
413
448
  if (end < total)
414
- note = moreLinesNote(end + 1, total - end);
449
+ note = moreLinesNote(end + 1, total - end, limit);
415
450
  }
416
451
  // The output cap's cut must STAY actionable: cut at a line
417
452
  // boundary and name the exact next offset (the generic cap()
@@ -662,7 +697,12 @@ export function searchTextTool(opts) {
662
697
  // CX-1 F4 (audit F4): the walk-and-match runs on its OWN thread, which
663
698
  // the deadline and the abort both TERMINATE. A catastrophic regex used
664
699
  // to block this loop — no budget check, timer or abort could run.
665
- const outcome = await runSearchWorker({ token: 0, root: searchRootReal, single, pattern, flags, excluded, maxFileBytes, maxFiles, deadline, maxMatches: MAX_SEARCH_MATCHES, sniffBytes: BINARY_SNIFF_BYTES }, deadline, ctx.signal);
700
+ const outcome = await runSearchWorker(
701
+ // The workspace root is realpath'd with the SAME helper the search
702
+ // root uses: `full` is walked from a realpath'd root, and making
703
+ // a path relative between a resolved and an unresolved base
704
+ // yields `../..` the moment a symlink sits between them.
705
+ { token: 0, root: searchRootReal, workspaceRoot: realOrSelf(opts.workspaceRoot), single, pattern, flags, excluded, maxFileBytes, maxFiles, deadline, maxMatches: MAX_SEARCH_MATCHES, sniffBytes: BINARY_SNIFF_BYTES }, deadline, ctx.signal);
666
706
  if (outcome.kind === "aborted")
667
707
  return { content: "search_text aborted", isError: true, errorKind: "fatal" };
668
708
  if (outcome.kind === "error")
@@ -945,7 +985,14 @@ export function editFileTool(opts) {
945
985
  // WR-1A ④: the WORLD lacks the pattern (the input is
946
986
  // fine) and nothing ran — precondition; the note never
947
987
  // rides an edit that wrote nothing.
948
- return precondition(hunks.length === 1 && edits === undefined ? `edit_file: pattern not found in ${path}` : `edit_file: pattern not found in ${path} (hunk ${i + 1})`);
988
+ // The headline says WHAT failed; the detail says WHERE.
989
+ // A refusal that names the divergence costs one line
990
+ // here and saves a whole file read at the caller.
991
+ const headline = hunks.length === 1 && edits === undefined
992
+ ? `edit_file: pattern not found in ${path}`
993
+ : `edit_file: pattern not found in ${path} (hunk ${i + 1})`;
994
+ const detail = describeSearchMiss(text, h.search);
995
+ return precondition(detail ? `${headline}\n${detail}` : headline);
949
996
  }
950
997
  spans.push({ start: at, end: at + h.search.length, replace: h.replace });
951
998
  }
@@ -995,37 +1042,15 @@ export function editFileTool(opts) {
995
1042
  },
996
1043
  });
997
1044
  }
998
- /**
999
- * bootstrap #3 (finding #7): the explicit credential list stripped from shell
1000
- * children — the agent's own provider surface (both families' keys, base
1001
- * URLs, and model choices) plus the generic API-key / auth-token patterns
1002
- * that cover other providers. Everything else in the environment passes
1003
- * through untouched.
1004
- */
1005
- const SHELL_STRIP_EXACT = new Set([
1006
- "ANTHROPIC_API_KEY",
1007
- "OPENAI_API_KEY",
1008
- "ANTHROPIC_BASE_URL",
1009
- "OPENAI_BASE_URL",
1010
- "ANTHROPIC_MODEL",
1011
- "OPENAI_MODEL",
1012
- ]);
1013
- function strippedShellEnv(env) {
1014
- const out = {};
1015
- for (const [key, value] of Object.entries(env)) {
1016
- if (SHELL_STRIP_EXACT.has(key))
1017
- continue;
1018
- if (key.endsWith("_API_KEY") || key.endsWith("_AUTH_TOKEN"))
1019
- continue;
1020
- if (value !== undefined)
1021
- out[key] = value;
1022
- }
1023
- return out;
1024
- }
1045
+ // Astra F7: the strip is ONE implementation, shared with the MCP
1046
+ // extension's stdio children (./secret-env.ts). It used to live here
1047
+ // with a hand-kept copy over there; the copy never learned the
1048
+ // declared names, which is how MCP children kept leaking.
1049
+ export { SHELL_STRIP_EXACT, strippedShellEnv } from "./secret-env.js";
1025
1050
  export function shellTool(opts) {
1026
1051
  return defineTool({
1027
1052
  name: "shell",
1028
- description: "Run a shell command with the workspace as the working directory. A side effect — approval required. Fails loudly on timeout or non-zero exit.",
1053
+ description: "Run a shell command through /bin/sh with the workspace root as the working directory: builds, tests, git, package managers, curl for HTTP APIs, system queries. Side effects are real; the human may be asked to approve the run. Fails loudly on timeout or non-zero exit.",
1029
1054
  parameters: {
1030
1055
  type: "object",
1031
1056
  properties: {
@@ -1035,7 +1060,7 @@ export function shellTool(opts) {
1035
1060
  required: ["command"],
1036
1061
  additionalProperties: false,
1037
1062
  },
1038
- promptSnippet: "shell — real system commands only (builds, tests, git)",
1063
+ promptSnippet: "shell — any command the task needs (builds, tests, git, curl, system queries)",
1039
1064
  promptGuidelines: ["commands run in the workspace root; on failure read the error and adjust — never repeat blindly"],
1040
1065
  execute: async ({ command, timeoutMs }, ctx) => {
1041
1066
  const timeout = timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
@@ -1058,7 +1083,7 @@ export function shellTool(opts) {
1058
1083
  stdio: ["ignore", "pipe", "pipe"],
1059
1084
  env: opts.shellEnv === "inherit"
1060
1085
  ? process.env
1061
- : { ...strippedShellEnv(process.env), ...(opts.shellEnv ?? {}) },
1086
+ : { ...strippedShellEnv(process.env, opts.secretEnvNames), ...(opts.shellEnv ?? {}) },
1062
1087
  });
1063
1088
  let stdout = "";
1064
1089
  let stderr = "";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * When a search does not match, say WHERE it stopped matching.
3
+ *
4
+ * `edit_file: pattern not found in src/report.js (hunk 2)` is true and
5
+ * useless. The tool has already established, by failing, that the text is
6
+ * not there — but it also knows, or can cheaply find out, how much of the
7
+ * search DID match and what the file has instead. Withholding that leaves
8
+ * one recourse: read the whole file again.
9
+ *
10
+ * This is not a guess about what callers need. Ten refused edits in one
11
+ * measured session were all `pattern not found`, none stale, none
12
+ * overlapping, and in every one of them a long prefix matched before the
13
+ * search ran into text the caller had not written yet — 93 of 223
14
+ * characters, 94 of 286, 306 of 913. Four more searched for an import
15
+ * line with the new symbol ALREADY IN IT. The failure has one shape: the
16
+ * search describes the file as it will be, not as it is. A message that
17
+ * names the divergence answers that in one line; the current one costs a
18
+ * whole file read to discover.
19
+ */
20
+ /**
21
+ * The detail lines for a failed search, or "" when there is nothing useful
22
+ * to say. The caller owns the headline; this is what follows it.
23
+ */
24
+ export declare function describeSearchMiss(text: string, search: string): string;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * When a search does not match, say WHERE it stopped matching.
3
+ *
4
+ * `edit_file: pattern not found in src/report.js (hunk 2)` is true and
5
+ * useless. The tool has already established, by failing, that the text is
6
+ * not there — but it also knows, or can cheaply find out, how much of the
7
+ * search DID match and what the file has instead. Withholding that leaves
8
+ * one recourse: read the whole file again.
9
+ *
10
+ * This is not a guess about what callers need. Ten refused edits in one
11
+ * measured session were all `pattern not found`, none stale, none
12
+ * overlapping, and in every one of them a long prefix matched before the
13
+ * search ran into text the caller had not written yet — 93 of 223
14
+ * characters, 94 of 286, 306 of 913. Four more searched for an import
15
+ * line with the new symbol ALREADY IN IT. The failure has one shape: the
16
+ * search describes the file as it will be, not as it is. A message that
17
+ * names the divergence answers that in one line; the current one costs a
18
+ * whole file read to discover.
19
+ */
20
+ /** The longest prefix of `needle` that occurs in `hay`, by length.
21
+ *
22
+ * Monotone — if a prefix occurs then so does every shorter one — so this
23
+ * binary-searches instead of walking. A linear walk is O(m) substring
24
+ * searches, which on a large file and a long search is the kind of cost
25
+ * that turns a better error message into a worse tool.
26
+ */
27
+ function longestMatchingPrefix(hay, needle) {
28
+ let lo = 0;
29
+ let hi = needle.length;
30
+ while (lo < hi) {
31
+ const mid = (lo + hi + 1) >> 1;
32
+ if (hay.includes(needle.slice(0, mid)))
33
+ lo = mid;
34
+ else
35
+ hi = mid - 1;
36
+ }
37
+ return lo;
38
+ }
39
+ /** 1-based line number of an offset. */
40
+ function lineAt(text, offset) {
41
+ let n = 1;
42
+ for (let i = 0; i < offset && i < text.length; i += 1)
43
+ if (text.charCodeAt(i) === 10)
44
+ n += 1;
45
+ return n;
46
+ }
47
+ /** A fragment for a one-line message: escaped, and bounded. */
48
+ function fragment(s, max = 60) {
49
+ const cut = s.slice(0, max);
50
+ const shown = JSON.stringify(cut).slice(1, -1); // drop the quotes, keep \n and \t visible
51
+ return s.length > max ? `${shown}…` : shown;
52
+ }
53
+ /**
54
+ * The detail lines for a failed search, or "" when there is nothing useful
55
+ * to say. The caller owns the headline; this is what follows it.
56
+ */
57
+ export function describeSearchMiss(text, search) {
58
+ if (search.length === 0)
59
+ return "";
60
+ const matched = longestMatchingPrefix(text, search);
61
+ if (matched === 0) {
62
+ const firstLine = search.split("\n", 1)[0] ?? "";
63
+ return ` no part of it appears in the file — it begins "${fragment(firstLine)}"`;
64
+ }
65
+ // First occurrence, matching the tool's own first-occurrence semantics.
66
+ const at = text.indexOf(search.slice(0, matched));
67
+ const endOfMatch = at + matched;
68
+ const line = lineAt(text, at);
69
+ const endLine = lineAt(text, endOfMatch);
70
+ const head = ` ${matched} of ${search.length} characters matched, from line ${line} to line ${endLine}`;
71
+ const rest = search.slice(matched);
72
+ // RUNNING PAST THE END IS THE COMMON CASE AND ITS OWN SENTENCE. Four of
73
+ // the ten refusals in the measured session ended exactly here, and
74
+ // rendering that as `the file then has: ""` buries the one fact worth
75
+ // having: there is no more file. A caller that appended what it meant
76
+ // to ADD onto the end of what it meant to FIND reads its own mistake
77
+ // off this line.
78
+ if (endOfMatch >= text.length) {
79
+ return [
80
+ head,
81
+ ` the file ENDS there — your search continues for ${rest.length} more characters: "${fragment(rest)}"`,
82
+ ].join("\n");
83
+ }
84
+ return [
85
+ head,
86
+ ` the file then has: "${fragment(text.slice(endOfMatch))}"`,
87
+ ` your search wanted: "${fragment(rest)}"`,
88
+ ].join("\n");
89
+ }
@@ -18,6 +18,12 @@
18
18
  export interface SearchRequest {
19
19
  readonly token: number;
20
20
  readonly root: string;
21
+ /** The WORKSPACE root, which is not always the search root: a search under
22
+ * `packages/runtime` must still name `packages/runtime/src/run.ts` so the
23
+ * result can be handed to `read_file` unchanged. Realpath'd by the
24
+ * caller, because `full` is walked from a realpath'd root and a mixed
25
+ * pair produces `../..` the moment a symlink is involved. */
26
+ readonly workspaceRoot: string;
21
27
  /** a single file to scan instead of walking `root` */
22
28
  readonly single: string | null;
23
29
  readonly pattern: string;
@@ -16,7 +16,7 @@
16
16
  * message from a superseded worker is ignored.
17
17
  */
18
18
  import { open, readdir } from "node:fs/promises";
19
- import { join, relative } from "node:path";
19
+ import { basename, join, relative } from "node:path";
20
20
  import { isMainThread, parentPort } from "node:worker_threads";
21
21
  export async function runSearch(req) {
22
22
  const regex = new RegExp(req.pattern, req.flags);
@@ -77,8 +77,11 @@ export async function runSearch(req) {
77
77
  for (const [i, line] of text.split("\n").entries()) {
78
78
  if (regex.test(line)) {
79
79
  totalMatches += 1;
80
+ // WORKSPACE-RELATIVE, not absolute: `read_file` refuses an
81
+ // absolute path, so an absolute hit here is a result the
82
+ // model cannot feed back without rewriting it by hand.
80
83
  if (matches.length < req.maxMatches)
81
- matches.push(`${full}:${i + 1}: ${line.trim().slice(0, 160)}`);
84
+ matches.push(`${relative(req.workspaceRoot, full) || basename(full)}:${i + 1}: ${line.trim().slice(0, 160)}`);
82
85
  }
83
86
  }
84
87
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * THE child-environment strip — ONE implementation, both children.
3
+ *
4
+ * bootstrap #3 (finding #7) established the rule for shell children: the
5
+ * agent's own provider surface (both families' keys, base URLs and model
6
+ * choices) plus the generic API-key / auth-token patterns that cover other
7
+ * providers. Everything else passes through untouched.
8
+ *
9
+ * Astra F7 then showed the rule was incomplete AND duplicated. Incomplete:
10
+ * a profile's `apiKeyEnv` can be ANY name — `REVIEW_PROVIDER_TOKEN` ends in
11
+ * neither suffix — so the key a profile authenticates with survived into
12
+ * the child. Duplicated: the MCP extension carried its own copy of the list
13
+ * with a "keep in sync" comment, and a comment is not a mechanism; the copy
14
+ * did not learn the declared names and MCP stdio children kept leaking.
15
+ *
16
+ * The names must come from the caller because only the config knows which
17
+ * env vars are secrets. No names declared = exactly the pre-F7 behaviour,
18
+ * which is what a standalone extension load gets.
19
+ *
20
+ * This module imports NOTHING on purpose: the MCP extension is an esbuild
21
+ * bundle, and a shared rule must not drag a package graph across with it.
22
+ */
23
+ /** The explicit credential list stripped from every child. */
24
+ export declare const SHELL_STRIP_EXACT: ReadonlySet<string>;
25
+ /** The environment a child process may see: `env` minus the exact list,
26
+ * minus every DECLARED secret name, minus the two suffix families. */
27
+ export declare function strippedShellEnv(env: Record<string, string | undefined>, secretNames?: readonly string[]): Record<string, string | undefined>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * THE child-environment strip — ONE implementation, both children.
3
+ *
4
+ * bootstrap #3 (finding #7) established the rule for shell children: the
5
+ * agent's own provider surface (both families' keys, base URLs and model
6
+ * choices) plus the generic API-key / auth-token patterns that cover other
7
+ * providers. Everything else passes through untouched.
8
+ *
9
+ * Astra F7 then showed the rule was incomplete AND duplicated. Incomplete:
10
+ * a profile's `apiKeyEnv` can be ANY name — `REVIEW_PROVIDER_TOKEN` ends in
11
+ * neither suffix — so the key a profile authenticates with survived into
12
+ * the child. Duplicated: the MCP extension carried its own copy of the list
13
+ * with a "keep in sync" comment, and a comment is not a mechanism; the copy
14
+ * did not learn the declared names and MCP stdio children kept leaking.
15
+ *
16
+ * The names must come from the caller because only the config knows which
17
+ * env vars are secrets. No names declared = exactly the pre-F7 behaviour,
18
+ * which is what a standalone extension load gets.
19
+ *
20
+ * This module imports NOTHING on purpose: the MCP extension is an esbuild
21
+ * bundle, and a shared rule must not drag a package graph across with it.
22
+ */
23
+ /** The explicit credential list stripped from every child. */
24
+ export const SHELL_STRIP_EXACT = new Set([
25
+ "ANTHROPIC_API_KEY",
26
+ "OPENAI_API_KEY",
27
+ "ANTHROPIC_BASE_URL",
28
+ "OPENAI_BASE_URL",
29
+ "ANTHROPIC_MODEL",
30
+ "OPENAI_MODEL",
31
+ ]);
32
+ /** The environment a child process may see: `env` minus the exact list,
33
+ * minus every DECLARED secret name, minus the two suffix families. */
34
+ export function strippedShellEnv(env, secretNames = []) {
35
+ const declared = new Set(secretNames);
36
+ const out = {};
37
+ for (const [key, value] of Object.entries(env)) {
38
+ if (SHELL_STRIP_EXACT.has(key))
39
+ continue;
40
+ if (declared.has(key))
41
+ continue;
42
+ if (key.endsWith("_API_KEY") || key.endsWith("_AUTH_TOKEN"))
43
+ continue;
44
+ if (value !== undefined)
45
+ out[key] = value;
46
+ }
47
+ return out;
48
+ }
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tools-node",
3
- "version": "0.34.0",
4
- "description": "kiso coding tools for Node hosts — read file, list directory, search text, write/edit file, shell command.",
3
+ "version": "0.37.0",
4
+ "description": "kiso coding tools for Node hosts \u2014 read file, list directory, search text, write/edit file, shell command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "exports": {
8
8
  ".": {
9
9
  "types": "./dist/index.d.ts",
10
10
  "default": "./dist/index.js"
11
+ },
12
+ "./secret-env": {
13
+ "types": "./dist/secret-env.d.ts",
14
+ "default": "./dist/secret-env.js"
11
15
  }
12
16
  },
13
17
  "files": [
@@ -21,7 +25,7 @@
21
25
  "test": "vitest run"
22
26
  },
23
27
  "dependencies": {
24
- "@vincemakes/kiso-core": "0.34.0"
28
+ "@vincemakes/kiso-core": "0.37.0"
25
29
  },
26
30
  "devDependencies": {
27
31
  "@types/node": "^26.1.2",