scribeclock 0.0.0-stage → 0.1.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.
@@ -0,0 +1,77 @@
1
+ import { authError } from "../cli/errors.js";
2
+ import { refreshTokens, expiresAt } from "./oauth.js";
3
+ /** Access tokens expire after 8 h; refresh this long before that so a call never lands on a dead token. */
4
+ const REFRESH_SKEW_MS = 60 * 1000;
5
+ export class TokenProvider {
6
+ opts;
7
+ cached;
8
+ constructor(opts) {
9
+ this.opts = opts;
10
+ }
11
+ get origin() {
12
+ if (this.opts.env.SCRIBECLOCK_TOKEN)
13
+ return "env";
14
+ return this.cached ? "store" : "none";
15
+ }
16
+ async credentials() {
17
+ if (this.cached !== undefined)
18
+ return this.cached;
19
+ this.cached = await this.opts.store.load(this.opts.profile);
20
+ return this.cached;
21
+ }
22
+ now() {
23
+ return (this.opts.now ?? (() => new Date()))();
24
+ }
25
+ isExpiring(creds) {
26
+ if (!creds.expires_at)
27
+ return false;
28
+ return new Date(creds.expires_at).getTime() - this.now().getTime() < REFRESH_SKEW_MS;
29
+ }
30
+ async getToken() {
31
+ const envToken = this.opts.env.SCRIBECLOCK_TOKEN?.trim();
32
+ if (envToken)
33
+ return envToken;
34
+ const creds = await this.credentials();
35
+ if (!creds)
36
+ return null;
37
+ if (creds.kind === "oauth" && creds.refresh_token && this.isExpiring(creds)) {
38
+ const refreshed = await this.refresh();
39
+ if (refreshed)
40
+ return refreshed;
41
+ }
42
+ return creds.access_token;
43
+ }
44
+ /** Rotating refresh: both tokens are replaced and saved. A reused/revoked refresh token logs out. */
45
+ async refresh() {
46
+ if (this.opts.env.SCRIBECLOCK_TOKEN)
47
+ return null;
48
+ const creds = await this.credentials();
49
+ if (!creds || creds.kind !== "oauth" || !creds.refresh_token || !creds.token_endpoint)
50
+ return null;
51
+ const result = await refreshTokens(this.opts.fetch, creds.token_endpoint, creds.refresh_token, creds.client_id);
52
+ if (!result.ok) {
53
+ if (result.error === "invalid_grant" || result.error === "invalid_client") {
54
+ await this.opts.store.clear(this.opts.profile);
55
+ this.cached = null;
56
+ throw authError("Your ScribeClock session was revoked or expired.", "Run `scribeclock login` to sign in again.");
57
+ }
58
+ return null;
59
+ }
60
+ const next = {
61
+ ...creds,
62
+ access_token: result.tokens.access_token,
63
+ refresh_token: result.tokens.refresh_token ?? creds.refresh_token,
64
+ expires_at: expiresAt(result.tokens.expires_in, this.now()),
65
+ scopes: result.tokens.scope ? result.tokens.scope.split(/\s+/).filter(Boolean) : creds.scopes,
66
+ saved_at: this.now().toISOString(),
67
+ };
68
+ await this.opts.store.save(this.opts.profile, next);
69
+ this.cached = next;
70
+ this.opts.onRefreshed?.(next);
71
+ return next.access_token;
72
+ }
73
+ /** Forget the in-memory copy (after login/logout). */
74
+ invalidate() {
75
+ this.cached = undefined;
76
+ }
77
+ }
@@ -0,0 +1,73 @@
1
+ import { createInterface } from "node:readline";
2
+ import { CliError } from "./errors.js";
3
+ /**
4
+ * Asks before anything destructive or sensitive. `--yes` answers for the person; without a terminal
5
+ * the command refuses instead of hanging or guessing.
6
+ */
7
+ export async function confirm(ctx, question, opts = {}) {
8
+ if (ctx.flags.yes)
9
+ return true;
10
+ if (!ctx.stdin.isTTY || !ctx.out.isTTY || ctx.flags.json) {
11
+ throw new CliError(`${question.replace(/\?\s*$/, "")} — this needs confirmation and no interactive terminal is attached.`, {
12
+ id: "confirmation_required",
13
+ hint: "Re-run with --yes to confirm, or --dry-run to see what would happen.",
14
+ });
15
+ }
16
+ const label = opts.danger ? ctx.out.red(question) : question;
17
+ const answer = await ask(ctx, `${label} ${ctx.out.dim("[y/N]")} `);
18
+ return /^y(es)?$/i.test(answer.trim());
19
+ }
20
+ /**
21
+ * Reads a secret from the terminal without echoing it (raw mode; Backspace edits, Ctrl-C aborts).
22
+ * Falls back to a normal read when stdin is not a TTY.
23
+ */
24
+ export function askHidden(ctx, prompt) {
25
+ const input = ctx.stdin.stream;
26
+ if (typeof input.setRawMode !== "function" || !ctx.stdin.isTTY)
27
+ return ask(ctx, prompt);
28
+ return new Promise((resolve, reject) => {
29
+ ctx.out.stderr.write(prompt);
30
+ const wasRaw = Boolean(input.isRaw);
31
+ input.setRawMode(true);
32
+ input.resume();
33
+ input.setEncoding("utf8");
34
+ let value = "";
35
+ const done = (err) => {
36
+ input.removeListener("data", onData);
37
+ input.setRawMode(wasRaw);
38
+ input.pause();
39
+ ctx.out.stderr.write("\n");
40
+ if (err)
41
+ reject(err);
42
+ else
43
+ resolve(value);
44
+ };
45
+ const onData = (chunk) => {
46
+ for (const ch of chunk) {
47
+ if (ch === "\u0003")
48
+ return done(new CliError("Cancelled.", { id: "cancelled" }));
49
+ if (ch === "\r" || ch === "\n")
50
+ return done();
51
+ if (ch === "\u007f" || ch === "\b")
52
+ value = value.slice(0, -1);
53
+ else if (ch >= " ")
54
+ value += ch;
55
+ }
56
+ };
57
+ input.on("data", onData);
58
+ });
59
+ }
60
+ export function ask(ctx, prompt) {
61
+ return new Promise((resolve) => {
62
+ const rl = createInterface({ input: ctx.stdin.stream, output: ctx.out.stderr, terminal: true });
63
+ rl.question(prompt, (answer) => {
64
+ rl.close();
65
+ resolve(answer);
66
+ });
67
+ });
68
+ }
69
+ /** Describes a planned call for --dry-run output and confirmation prompts, with secrets masked. */
70
+ export function describeCall(tool, args, mask) {
71
+ const shown = mask(args);
72
+ return `${tool} ${JSON.stringify(shown)}`;
73
+ }
@@ -0,0 +1,48 @@
1
+ import { EXIT } from "../types.js";
2
+ export class CliError extends Error {
3
+ code;
4
+ hint;
5
+ id;
6
+ details;
7
+ constructor(message, options = {}) {
8
+ super(message);
9
+ this.name = "CliError";
10
+ this.code = options.code ?? EXIT.ERROR;
11
+ this.hint = options.hint;
12
+ this.id = options.id ?? defaultId(this.code);
13
+ this.details = options.details;
14
+ }
15
+ }
16
+ function defaultId(code) {
17
+ switch (code) {
18
+ case EXIT.USAGE:
19
+ return "usage";
20
+ case EXIT.AUTH:
21
+ return "not_authenticated";
22
+ case EXIT.CONFLICT:
23
+ return "conflict";
24
+ case EXIT.SCOPE:
25
+ return "insufficient_scope";
26
+ default:
27
+ return "error";
28
+ }
29
+ }
30
+ export function usageError(message, hint) {
31
+ return new CliError(message, { code: EXIT.USAGE, hint, id: "usage" });
32
+ }
33
+ export function authError(message = "You are not logged in.", hint = "Run `scribeclock login`, or set SCRIBECLOCK_TOKEN to a personal access token.") {
34
+ return new CliError(message, { code: EXIT.AUTH, hint, id: "not_authenticated" });
35
+ }
36
+ export function scopeError(required, granted) {
37
+ const need = required ?? "an additional";
38
+ const want = Array.from(new Set([...(granted ?? []), ...(required ? [required] : [])])).join(",");
39
+ return new CliError(`This token does not include the ${need} scope.`, {
40
+ code: EXIT.SCOPE,
41
+ id: "insufficient_scope",
42
+ hint: `Log in again with that permission: scribeclock login --scopes ${want || "<scopes>"} (or create a personal access token with it at https://scribeclock.com/dashboard/settings/ai-agents).`,
43
+ details: { required_scope: required, granted_scopes: granted },
44
+ });
45
+ }
46
+ export function isCliError(e) {
47
+ return e instanceof CliError || (typeof e === "object" && e !== null && e.name === "CliError");
48
+ }
@@ -0,0 +1,185 @@
1
+ /** Tokens never reach a terminal or a log: anything shaped like one is replaced before writing. */
2
+ const TOKEN_PATTERN = /scmcp_(?:[a-z]_)?[A-Za-z0-9_-]{8,}/g;
3
+ export function scrubTokens(text) {
4
+ return text.replace(TOKEN_PATTERN, (m) => `${m.slice(0, 6)}••••`);
5
+ }
6
+ export class Output {
7
+ stdout;
8
+ stderr;
9
+ color;
10
+ quiet;
11
+ json;
12
+ isTTY;
13
+ constructor(opts = {}) {
14
+ this.stdout = opts.stdout ?? process.stdout;
15
+ this.stderr = opts.stderr ?? process.stderr;
16
+ const env = opts.env ?? process.env;
17
+ this.isTTY = Boolean(this.stdout.isTTY);
18
+ this.quiet = Boolean(opts.quiet);
19
+ this.json = Boolean(opts.json);
20
+ if (opts.color === false)
21
+ this.color = false;
22
+ else if (opts.color === true)
23
+ this.color = true;
24
+ else if ("NO_COLOR" in env && env.NO_COLOR !== "")
25
+ this.color = false;
26
+ else if (env.FORCE_COLOR && env.FORCE_COLOR !== "0")
27
+ this.color = true;
28
+ else
29
+ this.color = this.isTTY && env.TERM !== "dumb";
30
+ }
31
+ /** Main result text (stdout). */
32
+ log(text = "") {
33
+ this.stdout.write(scrubTokens(text) + "\n");
34
+ }
35
+ /** Raw stdout write without a trailing newline (progress, prompts). */
36
+ write(text) {
37
+ this.stdout.write(scrubTokens(text));
38
+ }
39
+ /** Secondary information (stderr), hidden with --quiet. */
40
+ info(text) {
41
+ if (this.quiet)
42
+ return;
43
+ this.stderr.write(scrubTokens(text) + "\n");
44
+ }
45
+ warn(text) {
46
+ this.stderr.write(scrubTokens(this.yellow("warning: ") + text) + "\n");
47
+ }
48
+ error(text) {
49
+ this.stderr.write(scrubTokens(this.red("error: ") + text) + "\n");
50
+ }
51
+ jsonOut(value) {
52
+ this.stdout.write(scrubTokens(JSON.stringify(value, null, 2)) + "\n");
53
+ }
54
+ /** NDJSON line (one object per line) for pipes. */
55
+ ndjson(value) {
56
+ this.stdout.write(scrubTokens(JSON.stringify(value)) + "\n");
57
+ }
58
+ paint(code, text) {
59
+ return this.color ? `\u001b[${code}m${text}\u001b[0m` : text;
60
+ }
61
+ bold(t) {
62
+ return this.paint("1", t);
63
+ }
64
+ dim(t) {
65
+ return this.paint("2", t);
66
+ }
67
+ red(t) {
68
+ return this.paint("31", t);
69
+ }
70
+ green(t) {
71
+ return this.paint("32", t);
72
+ }
73
+ yellow(t) {
74
+ return this.paint("33", t);
75
+ }
76
+ blue(t) {
77
+ return this.paint("34", t);
78
+ }
79
+ magenta(t) {
80
+ return this.paint("35", t);
81
+ }
82
+ cyan(t) {
83
+ return this.paint("36", t);
84
+ }
85
+ heading(text) {
86
+ this.log(this.bold(text));
87
+ }
88
+ /** Two-column key/value block; empty values are skipped. */
89
+ kv(pairs, indent = 2) {
90
+ const shown = pairs.filter(([, v]) => v !== undefined && v !== null && v !== "");
91
+ const width = Math.max(0, ...shown.map(([k]) => k.length));
92
+ for (const [k, v] of shown) {
93
+ this.log(`${" ".repeat(indent)}${this.dim(k.padEnd(width))} ${formatCell(v)}`);
94
+ }
95
+ }
96
+ /** Aligned table. Cells are stringified; long cells are truncated to keep rows on one line. */
97
+ table(headers, rows, opts = {}) {
98
+ const indent = " ".repeat(opts.indent ?? 0);
99
+ const termWidth = this.stdout.columns ?? 120;
100
+ const maxWidth = opts.maxWidth ?? Math.max(24, Math.min(64, Math.floor(termWidth / Math.max(1, headers.length)) - 2));
101
+ const cells = rows.map((r) => r.map((c) => truncate(formatCell(c), maxWidth)));
102
+ const widths = headers.map((h, i) => Math.max(visibleLength(h), ...cells.map((r) => visibleLength(r[i] ?? ""))));
103
+ const line = (cols) => indent + cols.map((c, i) => padEnd(c ?? "", widths[i])).join(" ").trimEnd();
104
+ this.log(this.dim(line(headers)));
105
+ for (const r of cells)
106
+ this.log(line(r));
107
+ }
108
+ }
109
+ export function formatCell(v) {
110
+ if (v === null || v === undefined)
111
+ return "";
112
+ if (typeof v === "string")
113
+ return v;
114
+ if (typeof v === "number" || typeof v === "boolean")
115
+ return String(v);
116
+ if (v instanceof Date)
117
+ return v.toISOString();
118
+ if (Array.isArray(v))
119
+ return v.map(formatCell).join(", ");
120
+ return JSON.stringify(v);
121
+ }
122
+ const ANSI = /\u001b\[[0-9;]*m/g;
123
+ export function visibleLength(s) {
124
+ return s.replace(ANSI, "").length;
125
+ }
126
+ export function padEnd(s, width) {
127
+ const pad = width - visibleLength(s);
128
+ return pad > 0 ? s + " ".repeat(pad) : s;
129
+ }
130
+ export function truncate(s, max) {
131
+ const flat = s.replace(/\s+/g, " ");
132
+ return visibleLength(flat) <= max ? flat : flat.slice(0, Math.max(0, max - 1)) + "…";
133
+ }
134
+ /** Replace secret-looking values in a vault result unless the person asked to reveal them. */
135
+ const SECRET_KEYS = /^(password|secret|value|access_token|refresh_token|client_secret|api_key|apikey|private_key|token)$/i;
136
+ export function maskSecrets(value, reveal = false) {
137
+ if (reveal)
138
+ return value;
139
+ const walk = (v) => {
140
+ if (Array.isArray(v))
141
+ return v.map((x) => walk(x));
142
+ if (v && typeof v === "object") {
143
+ const out = {};
144
+ for (const [k, val] of Object.entries(v)) {
145
+ if (typeof val === "string" && SECRET_KEYS.test(k))
146
+ out[k] = mask(val);
147
+ else if (k === "env" || k === "dotenv" || k === "env_text")
148
+ out[k] = typeof val === "string" ? maskDotenv(val) : walk(val);
149
+ else if (k === "map" || k === "values" || k === "variables")
150
+ out[k] = maskMap(val);
151
+ else
152
+ out[k] = walk(val);
153
+ }
154
+ return out;
155
+ }
156
+ return v;
157
+ };
158
+ return walk(value);
159
+ }
160
+ export function mask(value) {
161
+ if (!value)
162
+ return value;
163
+ return "••••" + (value.length > 8 ? value.slice(-2) : "");
164
+ }
165
+ function maskMap(v) {
166
+ if (!v || typeof v !== "object" || Array.isArray(v))
167
+ return v;
168
+ const out = {};
169
+ for (const [k, val] of Object.entries(v))
170
+ out[k] = typeof val === "string" ? mask(val) : val;
171
+ return out;
172
+ }
173
+ /** KEY=value lines → KEY=•••• (comments and blank lines untouched). */
174
+ export function maskDotenv(text) {
175
+ return text
176
+ .split("\n")
177
+ .map((line) => {
178
+ const m = /^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=)(.*)$/.exec(line);
179
+ if (!m)
180
+ return line;
181
+ const value = m[2].trim().replace(/^["']|["']$/g, "");
182
+ return `${m[1]}${value ? mask(value) : ""}`;
183
+ })
184
+ .join("\n");
185
+ }
@@ -0,0 +1,230 @@
1
+ import { usageError } from "./errors.js";
2
+ /**
3
+ * Pulls the global flags out of argv wherever they appear and leaves everything else (command
4
+ * words, positionals and command-specific flags) in order. Tokens after `--` are passthrough.
5
+ */
6
+ export function parseGlobals(argv) {
7
+ const flags = {
8
+ json: false,
9
+ yes: false,
10
+ dryRun: false,
11
+ debug: false,
12
+ color: null,
13
+ quiet: false,
14
+ help: false,
15
+ version: false,
16
+ reveal: false,
17
+ };
18
+ const rest = [];
19
+ const passthrough = [];
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const t = argv[i];
22
+ if (t === "--") {
23
+ passthrough.push(...argv.slice(i + 1));
24
+ break;
25
+ }
26
+ const eq = t.indexOf("=");
27
+ const name = t.startsWith("--") && eq > 0 ? t.slice(0, eq) : t;
28
+ const inlineValue = t.startsWith("--") && eq > 0 ? t.slice(eq + 1) : undefined;
29
+ const takeValue = () => {
30
+ if (inlineValue !== undefined)
31
+ return inlineValue;
32
+ const next = argv[i + 1];
33
+ if (next === undefined || (next.startsWith("-") && next.length > 1))
34
+ throw usageError(`${name} needs a value.`);
35
+ i++;
36
+ return next;
37
+ };
38
+ switch (name) {
39
+ case "--json": {
40
+ // `call <tool> --json '{...}'` passes INPUT json; leave it for the command.
41
+ const next = argv[i + 1];
42
+ if (inlineValue === undefined && next && /^\s*[[{]/.test(next)) {
43
+ rest.push(t);
44
+ break;
45
+ }
46
+ flags.json = true;
47
+ break;
48
+ }
49
+ case "--org":
50
+ flags.org = takeValue();
51
+ break;
52
+ case "--url":
53
+ flags.url = takeValue();
54
+ break;
55
+ case "-y":
56
+ case "--yes":
57
+ flags.yes = true;
58
+ break;
59
+ case "--dry-run":
60
+ flags.dryRun = true;
61
+ break;
62
+ case "--debug":
63
+ flags.debug = true;
64
+ break;
65
+ case "--no-color":
66
+ flags.color = false;
67
+ break;
68
+ case "--color":
69
+ flags.color = true;
70
+ break;
71
+ case "-q":
72
+ case "--quiet":
73
+ flags.quiet = true;
74
+ break;
75
+ case "-h":
76
+ case "--help":
77
+ flags.help = true;
78
+ break;
79
+ case "-V":
80
+ case "--version":
81
+ flags.version = true;
82
+ break;
83
+ case "--reveal":
84
+ flags.reveal = true;
85
+ break;
86
+ default:
87
+ rest.push(t);
88
+ }
89
+ }
90
+ return { flags, rest, passthrough };
91
+ }
92
+ /** Parses a built-in command's own flags. Unknown flags are an error unless allowUnknown is set. */
93
+ export function parseFlags(tokens, specs, opts = {}) {
94
+ const flags = {};
95
+ const positionals = [];
96
+ const unknown = [];
97
+ const byName = new Map();
98
+ for (const s of specs) {
99
+ byName.set(`--${s.name}`, s);
100
+ if (s.alias)
101
+ byName.set(`-${s.alias}`, s);
102
+ if (s.default !== undefined)
103
+ flags[s.name] = s.default;
104
+ }
105
+ for (let i = 0; i < tokens.length; i++) {
106
+ const t = tokens[i];
107
+ if (!t.startsWith("-") || t === "-") {
108
+ positionals.push(t);
109
+ continue;
110
+ }
111
+ const eq = t.startsWith("--") ? t.indexOf("=") : -1;
112
+ const key = eq > 0 ? t.slice(0, eq) : t;
113
+ const inline = eq > 0 ? t.slice(eq + 1) : undefined;
114
+ let spec = byName.get(key);
115
+ let negated = false;
116
+ if (!spec && key.startsWith("--no-")) {
117
+ spec = byName.get(`--${key.slice(5)}`);
118
+ negated = Boolean(spec && spec.type === "boolean");
119
+ if (!negated)
120
+ spec = undefined;
121
+ }
122
+ if (!spec) {
123
+ if (!opts.allowUnknown) {
124
+ const suggestion = closest(key.replace(/^-+/, ""), specs.map((s) => s.name));
125
+ throw usageError(`Unknown option ${key}.`, suggestion ? `Did you mean --${suggestion}?` : "Run with --help to see the options.");
126
+ }
127
+ unknown.push(t);
128
+ const next = tokens[i + 1];
129
+ if (inline === undefined && next !== undefined && !next.startsWith("-")) {
130
+ unknown.push(next);
131
+ i++;
132
+ }
133
+ continue;
134
+ }
135
+ if (spec.type === "boolean") {
136
+ if (inline !== undefined)
137
+ flags[spec.name] = parseBool(inline, spec.name);
138
+ else
139
+ flags[spec.name] = !negated;
140
+ continue;
141
+ }
142
+ let value = inline;
143
+ if (value === undefined) {
144
+ const next = tokens[i + 1];
145
+ if (next === undefined || (next.startsWith("--") && next.length > 2))
146
+ throw usageError(`--${spec.name} needs a value.`);
147
+ value = next;
148
+ i++;
149
+ }
150
+ if (spec.choices && !spec.choices.includes(value)) {
151
+ throw usageError(`--${spec.name} must be one of: ${spec.choices.join(", ")} (got "${value}").`);
152
+ }
153
+ if (spec.type === "number") {
154
+ const n = Number(value);
155
+ if (!Number.isFinite(n))
156
+ throw usageError(`--${spec.name} must be a number (got "${value}").`);
157
+ flags[spec.name] = n;
158
+ }
159
+ else if (spec.type === "array") {
160
+ const prev = Array.isArray(flags[spec.name]) ? flags[spec.name] : [];
161
+ flags[spec.name] = [...prev, ...splitList(value)];
162
+ }
163
+ else {
164
+ flags[spec.name] = value;
165
+ }
166
+ }
167
+ for (const s of specs) {
168
+ if (s.required && flags[s.name] === undefined)
169
+ throw usageError(`--${s.name} is required.`, s.description);
170
+ }
171
+ return { flags, positionals, unknown };
172
+ }
173
+ export function parseBool(value, name) {
174
+ const v = value.trim().toLowerCase();
175
+ if (["true", "yes", "y", "1", "on"].includes(v))
176
+ return true;
177
+ if (["false", "no", "n", "0", "off"].includes(v))
178
+ return false;
179
+ throw usageError(`${name} expects true or false (got "${value}").`);
180
+ }
181
+ export function splitList(value) {
182
+ const trimmed = value.trim();
183
+ if (trimmed.startsWith("[")) {
184
+ try {
185
+ const parsed = JSON.parse(trimmed);
186
+ if (Array.isArray(parsed))
187
+ return parsed.map(String);
188
+ }
189
+ catch {
190
+ /* fall through to comma split */
191
+ }
192
+ }
193
+ return trimmed
194
+ .split(",")
195
+ .map((s) => s.trim())
196
+ .filter(Boolean);
197
+ }
198
+ /** Nearest candidate by a cheap edit distance, for "did you mean" hints. */
199
+ export function closest(input, candidates, max = 3) {
200
+ let best;
201
+ let bestD = Infinity;
202
+ for (const c of candidates) {
203
+ const d = levenshtein(input.toLowerCase(), c.toLowerCase());
204
+ if (d < bestD) {
205
+ bestD = d;
206
+ best = c;
207
+ }
208
+ }
209
+ if (best && bestD <= max)
210
+ return best;
211
+ const sub = candidates.find((c) => c.toLowerCase().includes(input.toLowerCase()) || input.toLowerCase().includes(c.toLowerCase()));
212
+ return sub;
213
+ }
214
+ export function levenshtein(a, b) {
215
+ const m = a.length;
216
+ const n = b.length;
217
+ if (!m)
218
+ return n;
219
+ if (!n)
220
+ return m;
221
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
222
+ for (let i = 1; i <= m; i++) {
223
+ const cur = [i];
224
+ for (let j = 1; j <= n; j++) {
225
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
226
+ }
227
+ prev = cur;
228
+ }
229
+ return prev[n];
230
+ }
@@ -0,0 +1,49 @@
1
+ const CF_WRITE = new Set(["cloudflare_dns_upsert", "cloudflare_dns_delete", "cloudflare_purge_cache"]);
2
+ const VAULT_SENSITIVE = new Set(["vault_get_env", "vault_put_env", "vault_put_credential", "vault_delete_secret"]);
3
+ /**
4
+ * Which tools need a confirmation and masking. Names come first (the catalog may grow, so new
5
+ * `delete_*` / `vault_*` / `cloudflare_*` tools are covered automatically); annotations are the
6
+ * fallback for anything else the server marks destructive.
7
+ */
8
+ export function sensitivity(name, def, args = {}) {
9
+ if (name === "delete_organization_file") {
10
+ return { confirm: true, reason: "permanently deletes a file", maskOutput: false, maskArgs: false, serverDryRun: { flag: "confirm" } };
11
+ }
12
+ if (/^delete_/.test(name))
13
+ return { confirm: true, reason: "deletes data", maskOutput: false, maskArgs: false };
14
+ if (VAULT_SENSITIVE.has(name) || (/^vault_/.test(name) && name !== "vault_list_secrets")) {
15
+ const reads = name === "vault_get_env";
16
+ return { confirm: true, reason: reads ? "reads vault values (audited)" : name.includes("delete") ? "wipes a vault secret" : "writes vault secrets", maskOutput: true, maskArgs: !reads };
17
+ }
18
+ if (CF_WRITE.has(name))
19
+ return { confirm: true, reason: "changes Cloudflare live", maskOutput: false, maskArgs: false };
20
+ if (name === "cloudflare_request") {
21
+ const method = String(args.method ?? "GET").toUpperCase();
22
+ const write = !["GET", "HEAD"].includes(method);
23
+ return { confirm: write, reason: write ? `sends a live ${method} to the Cloudflare API` : undefined, maskOutput: false, maskArgs: false };
24
+ }
25
+ if (/^cloudflare_dns_/.test(name))
26
+ return { confirm: true, reason: "changes DNS live", maskOutput: false, maskArgs: false };
27
+ const a = def?.annotations;
28
+ if (a && a.destructiveHint === true && a.readOnlyHint !== true && a.openWorldHint === true) {
29
+ return { confirm: true, reason: "changes an external system", maskOutput: false, maskArgs: false };
30
+ }
31
+ return { confirm: false, maskOutput: false, maskArgs: false };
32
+ }
33
+ export function isReadOnly(def) {
34
+ return def?.annotations?.readOnlyHint === true;
35
+ }
36
+ const ARG_SECRET_KEYS = /^(password|secret|value|dotenv|content|content_base64|token|api_key)$/i;
37
+ /** Hide secret-looking argument values when a call is echoed. */
38
+ export function maskArgs(args) {
39
+ const out = {};
40
+ for (const [k, v] of Object.entries(args)) {
41
+ if (typeof v === "string" && ARG_SECRET_KEYS.test(k))
42
+ out[k] = `<${v.length} chars>`;
43
+ else if (k === "entries" && Array.isArray(v))
44
+ out[k] = v.map((e) => (e && typeof e === "object" ? { ...e, value: "<hidden>" } : e));
45
+ else
46
+ out[k] = v;
47
+ }
48
+ return out;
49
+ }