toolmoat 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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +302 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +80 -0
  5. package/dist/clients/configs.d.ts +124 -0
  6. package/dist/clients/configs.js +403 -0
  7. package/dist/commands/approve.d.ts +15 -0
  8. package/dist/commands/approve.js +128 -0
  9. package/dist/commands/diff.d.ts +6 -0
  10. package/dist/commands/diff.js +114 -0
  11. package/dist/commands/pins.d.ts +18 -0
  12. package/dist/commands/pins.js +88 -0
  13. package/dist/commands/proxy.d.ts +24 -0
  14. package/dist/commands/proxy.js +144 -0
  15. package/dist/commands/rules.d.ts +15 -0
  16. package/dist/commands/rules.js +173 -0
  17. package/dist/commands/scan.d.ts +57 -0
  18. package/dist/commands/scan.js +323 -0
  19. package/dist/commands/unwrap.d.ts +5 -0
  20. package/dist/commands/unwrap.js +69 -0
  21. package/dist/commands/wrap.d.ts +131 -0
  22. package/dist/commands/wrap.js +518 -0
  23. package/dist/index.d.ts +20 -0
  24. package/dist/index.js +13 -0
  25. package/dist/patterns/indicators.d.ts +41 -0
  26. package/dist/patterns/indicators.js +85 -0
  27. package/dist/patterns/secrets.d.ts +20 -0
  28. package/dist/patterns/secrets.js +70 -0
  29. package/dist/proxy/audit.d.ts +53 -0
  30. package/dist/proxy/audit.js +79 -0
  31. package/dist/proxy/canonical.d.ts +13 -0
  32. package/dist/proxy/canonical.js +34 -0
  33. package/dist/proxy/diff.d.ts +22 -0
  34. package/dist/proxy/diff.js +124 -0
  35. package/dist/proxy/fingerprint.d.ts +34 -0
  36. package/dist/proxy/fingerprint.js +140 -0
  37. package/dist/proxy/firewall.d.ts +132 -0
  38. package/dist/proxy/firewall.js +665 -0
  39. package/dist/proxy/framing.d.ts +45 -0
  40. package/dist/proxy/framing.js +86 -0
  41. package/dist/proxy/lockfile.d.ts +58 -0
  42. package/dist/proxy/lockfile.js +258 -0
  43. package/dist/proxy/names.d.ts +27 -0
  44. package/dist/proxy/names.js +175 -0
  45. package/dist/proxy/policy.d.ts +72 -0
  46. package/dist/proxy/policy.js +86 -0
  47. package/dist/proxy/proxy.d.ts +18 -0
  48. package/dist/proxy/proxy.js +237 -0
  49. package/dist/proxy/spawn.d.ts +51 -0
  50. package/dist/proxy/spawn.js +110 -0
  51. package/dist/report/index.d.ts +35 -0
  52. package/dist/report/index.js +25 -0
  53. package/dist/report/json.d.ts +22 -0
  54. package/dist/report/json.js +40 -0
  55. package/dist/report/markdown.d.ts +12 -0
  56. package/dist/report/markdown.js +87 -0
  57. package/dist/report/sarif.d.ts +89 -0
  58. package/dist/report/sarif.js +142 -0
  59. package/dist/report/text.d.ts +16 -0
  60. package/dist/report/text.js +206 -0
  61. package/dist/rules/code.d.ts +15 -0
  62. package/dist/rules/code.js +729 -0
  63. package/dist/rules/config.d.ts +3 -0
  64. package/dist/rules/config.js +2164 -0
  65. package/dist/rules/index.d.ts +3 -0
  66. package/dist/rules/index.js +8 -0
  67. package/dist/rules/secrets.d.ts +2 -0
  68. package/dist/rules/secrets.js +108 -0
  69. package/dist/rules/text.d.ts +2 -0
  70. package/dist/rules/text.js +1698 -0
  71. package/dist/scan/discover.d.ts +37 -0
  72. package/dist/scan/discover.js +525 -0
  73. package/dist/scan/engine.d.ts +24 -0
  74. package/dist/scan/engine.js +111 -0
  75. package/dist/scan/ignore.d.ts +28 -0
  76. package/dist/scan/ignore.js +46 -0
  77. package/dist/types.d.ts +98 -0
  78. package/dist/types.js +25 -0
  79. package/dist/util/colors.d.ts +12 -0
  80. package/dist/util/colors.js +24 -0
  81. package/dist/util/frontmatter.d.ts +20 -0
  82. package/dist/util/frontmatter.js +136 -0
  83. package/dist/util/glob.d.ts +7 -0
  84. package/dist/util/glob.js +44 -0
  85. package/dist/util/hash.d.ts +3 -0
  86. package/dist/util/hash.js +24 -0
  87. package/dist/util/jsonc.d.ts +14 -0
  88. package/dist/util/jsonc.js +113 -0
  89. package/dist/util/paths.d.ts +9 -0
  90. package/dist/util/paths.js +37 -0
  91. package/dist/util/text.d.ts +29 -0
  92. package/dist/util/text.js +96 -0
  93. package/dist/util/toml-lite.d.ts +40 -0
  94. package/dist/util/toml-lite.js +507 -0
  95. package/dist/version.d.ts +1 -0
  96. package/dist/version.js +1 -0
  97. package/package.json +57 -4
@@ -0,0 +1,323 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { performance } from 'node:perf_hooks';
4
+ import { parseArgs } from 'node:util';
5
+ import { isReportFormat, REPORT_FORMATS, renderReport } from '../report/index.js';
6
+ import { allRules } from '../rules/index.js';
7
+ import { countBySeverity, scanFiles } from '../scan/engine.js';
8
+ import { emptyIgnore, isFindingIgnored, parseIgnoreFile } from '../scan/ignore.js';
9
+ import { atLeast, isSeverity, SEVERITIES } from '../types.js';
10
+ import { palette } from '../util/colors.js';
11
+ import { expandHome, toDisplayPath } from '../util/paths.js';
12
+ import { VERSION } from '../version.js';
13
+ export const help = `Usage: toolmoat scan [paths...] [options]
14
+
15
+ Scan agent skills, plugins, hooks and MCP server configs for malicious or
16
+ risky content. Runs fully offline. With no paths, scans the default agent
17
+ locations (Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Codex...).
18
+
19
+ Options:
20
+ -f, --format <format> text (default), json, sarif or markdown
21
+ -o, --output <file> Write the report to a file and print a one-line summary
22
+ --fail-on <severity> Exit 1 when a finding is at or above this severity:
23
+ critical, high (default), medium, low, info or none
24
+ --min-severity <sev> Hide findings below this severity (default: low,
25
+ or info with --verbose)
26
+ --ignore-file <file> Suppressions to apply (default: ./.toolmoatignore
27
+ if present; never read from scanned targets)
28
+ --no-ignore Apply no ignore file at all, e.g. when the current
29
+ directory is itself an untrusted download
30
+ --defaults Also scan the default agent locations when paths are given
31
+ --deep Look further: deeper directory levels and bundled files
32
+ --max-file-size <MiB> Skip files larger than this many MiB
33
+ -q, --quiet Only print findings (nothing when clean); with
34
+ --output, print nothing to stdout
35
+ -v, --verbose Show info findings, skipped files and rule errors
36
+ --color, --no-color Force or disable colors (NO_COLOR is honoured)
37
+
38
+ --fail-on also counts findings hidden by --min-severity, but never findings
39
+ suppressed by the ignore file.
40
+
41
+ Exit codes:
42
+ 0 no findings at or above --fail-on
43
+ 1 findings at or above --fail-on
44
+ 2 usage or runtime error, including a path that does not exist
45
+
46
+ Examples:
47
+ toolmoat scan
48
+ toolmoat scan ./skills --format sarif --output toolmoat.sarif
49
+ toolmoat scan . --fail-on medium --format markdown >> "$GITHUB_STEP_SUMMARY"`;
50
+ /** Loaded on first use, so the scan core can be driven with in-memory files and no filesystem walker. */
51
+ async function lazyDiscover(options) {
52
+ const mod = await import('../scan/discover.js');
53
+ return mod.discover(options);
54
+ }
55
+ /** The scan pipeline without any I/O of its own: discover, run the rules, filter, decide the exit code. */
56
+ export async function runScan(options, deps = { discover: lazyDiscover }) {
57
+ const started = performance.now();
58
+ const ignore = options.ignore ?? emptyIgnore();
59
+ const minSeverity = options.minSeverity ?? 'low';
60
+ const failOn = options.failOn ?? 'high';
61
+ const rules = deps.rules ?? allRules;
62
+ const discovered = await deps.discover({
63
+ paths: options.paths,
64
+ useDefaults: options.useDefaults,
65
+ cwd: options.cwd,
66
+ deep: options.deep,
67
+ maxFileBytes: options.maxFileBytes,
68
+ ignore,
69
+ });
70
+ const ruleErrors = [];
71
+ const all = scanFiles(discovered.files, {
72
+ rules,
73
+ onRuleError: (rule, file, error) => {
74
+ ruleErrors.push({ ruleId: rule.id, file: file.displayPath, message: error instanceof Error ? error.message : String(error) });
75
+ },
76
+ });
77
+ const active = all.filter((finding) => !isFindingIgnored(finding.ruleId, finding.file, ignore));
78
+ const shown = active.filter((finding) => atLeast(finding.severity, minSeverity));
79
+ const failed = failOn !== 'none' && active.some((finding) => atLeast(finding.severity, failOn));
80
+ const targets = new Set();
81
+ const filePaths = new Map();
82
+ for (const file of discovered.files) {
83
+ if (file.target)
84
+ targets.add(file.target);
85
+ if (!filePaths.has(file.displayPath))
86
+ filePaths.set(file.displayPath, file.path);
87
+ }
88
+ return {
89
+ result: {
90
+ findings: shown,
91
+ filesScanned: discovered.files.length,
92
+ targets: [...targets].sort(),
93
+ skipped: discovered.skipped,
94
+ rules,
95
+ durationMs: performance.now() - started,
96
+ },
97
+ exitCode: failed ? 1 : 0,
98
+ roots: discovered.roots,
99
+ hidden: active.length - shown.length,
100
+ suppressed: all.length - active.length,
101
+ ruleErrors,
102
+ filePaths,
103
+ };
104
+ }
105
+ class UsageError extends Error {
106
+ }
107
+ export async function run(argv, io = {}) {
108
+ const env = { ...defaultIo(), ...io };
109
+ let cli;
110
+ try {
111
+ cli = parseCli(argv);
112
+ }
113
+ catch (error) {
114
+ env.stderr(`toolmoat scan: ${usageMessage(error)}\nRun 'toolmoat scan --help' for usage.\n`);
115
+ return 2;
116
+ }
117
+ if (cli.help) {
118
+ env.stdout(`${help}\n`);
119
+ return 0;
120
+ }
121
+ // A mistyped path in CI must not turn into a silent, green "0 files scanned".
122
+ const missing = cli.paths.filter((p) => !fs.existsSync(path.resolve(env.cwd, expandHome(p))));
123
+ if (missing.length > 0) {
124
+ env.stderr(`toolmoat scan: path not found: ${missing.join(', ')}\n`);
125
+ return 2;
126
+ }
127
+ let ignore;
128
+ try {
129
+ ignore = cli.noIgnore ? { config: emptyIgnore() } : loadIgnore(cli.ignoreFile, env.cwd);
130
+ }
131
+ catch (error) {
132
+ env.stderr(`toolmoat scan: ${errorMessage(error)}\n`);
133
+ return 2;
134
+ }
135
+ let outcome;
136
+ try {
137
+ outcome = await runScan({
138
+ paths: cli.paths,
139
+ useDefaults: cli.useDefaults,
140
+ cwd: env.cwd,
141
+ deep: cli.deep,
142
+ maxFileBytes: cli.maxFileBytes,
143
+ ignore: ignore.config,
144
+ minSeverity: cli.minSeverity,
145
+ failOn: cli.failOn,
146
+ }, env.deps);
147
+ }
148
+ catch (error) {
149
+ env.stderr(`toolmoat scan: ${errorMessage(error)}\n`);
150
+ return 2;
151
+ }
152
+ reportRuleErrors(outcome.ruleErrors, cli.verbose, env);
153
+ const toFile = cli.output !== undefined;
154
+ const report = renderReport(cli.format, outcome.result, {
155
+ roots: outcome.roots,
156
+ cwd: env.cwd,
157
+ color: !toFile && cli.format === 'text' && (cli.color ?? env.color),
158
+ verbose: cli.verbose,
159
+ quiet: cli.quiet && !toFile,
160
+ minSeverity: cli.minSeverity,
161
+ hidden: outcome.hidden,
162
+ suppressed: outcome.suppressed,
163
+ ignoreFile: ignore.display,
164
+ filePaths: outcome.filePaths,
165
+ });
166
+ if (cli.output === undefined) {
167
+ env.stdout(report);
168
+ return outcome.exitCode;
169
+ }
170
+ const target = path.resolve(env.cwd, cli.output);
171
+ try {
172
+ fs.mkdirSync(path.dirname(target), { recursive: true });
173
+ fs.writeFileSync(target, report, 'utf8');
174
+ }
175
+ catch (error) {
176
+ env.stderr(`toolmoat scan: cannot write ${cli.output}: ${errorMessage(error)}\n`);
177
+ return 2;
178
+ }
179
+ if (!cli.quiet)
180
+ env.stdout(`${summaryLine(outcome, cli.format, toDisplayPath(target, env.cwd))}\n`);
181
+ return outcome.exitCode;
182
+ }
183
+ function defaultIo() {
184
+ return {
185
+ cwd: process.cwd(),
186
+ stdout: (text) => process.stdout.write(text),
187
+ stderr: (text) => process.stderr.write(text),
188
+ // Probe the shared palette so TTY, NO_COLOR and FORCE_COLOR are decided in one place.
189
+ color: palette(process.stdout).bold('x') !== 'x',
190
+ deps: { discover: lazyDiscover },
191
+ };
192
+ }
193
+ function parseCli(argv) {
194
+ const { values, positionals } = parseArgs({
195
+ args: argv,
196
+ allowPositionals: true,
197
+ strict: true,
198
+ options: {
199
+ format: { type: 'string', short: 'f' },
200
+ output: { type: 'string', short: 'o' },
201
+ 'fail-on': { type: 'string' },
202
+ 'min-severity': { type: 'string' },
203
+ 'ignore-file': { type: 'string' },
204
+ 'no-ignore': { type: 'boolean' },
205
+ defaults: { type: 'boolean' },
206
+ deep: { type: 'boolean' },
207
+ 'max-file-size': { type: 'string' },
208
+ quiet: { type: 'boolean', short: 'q' },
209
+ verbose: { type: 'boolean', short: 'v' },
210
+ color: { type: 'boolean' },
211
+ 'no-color': { type: 'boolean' },
212
+ help: { type: 'boolean', short: 'h' },
213
+ },
214
+ });
215
+ const format = values.format ?? 'text';
216
+ if (!isReportFormat(format))
217
+ throw new UsageError(`unknown format '${format}' (expected ${REPORT_FORMATS.join(', ')})`);
218
+ const failOn = values['fail-on'] ?? 'high';
219
+ if (failOn !== 'none' && !isSeverity(failOn)) {
220
+ throw new UsageError(`invalid --fail-on '${failOn}' (expected ${SEVERITIES.join(', ')} or none)`);
221
+ }
222
+ const verbose = values.verbose ?? false;
223
+ const minSeverity = values['min-severity'] ?? (verbose ? 'info' : 'low');
224
+ if (!isSeverity(minSeverity))
225
+ throw new UsageError(`invalid --min-severity '${minSeverity}' (expected ${SEVERITIES.join(', ')})`);
226
+ if (values.output === '')
227
+ throw new UsageError('--output needs a file name');
228
+ if (values['no-ignore'] && values['ignore-file'] !== undefined)
229
+ throw new UsageError('--ignore-file and --no-ignore cannot be combined');
230
+ const cli = {
231
+ help: values.help ?? false,
232
+ paths: positionals,
233
+ useDefaults: positionals.length === 0 || (values.defaults ?? false),
234
+ format,
235
+ failOn,
236
+ minSeverity,
237
+ noIgnore: values['no-ignore'] ?? false,
238
+ deep: values.deep ?? false,
239
+ quiet: values.quiet ?? false,
240
+ verbose,
241
+ };
242
+ if (values.output !== undefined)
243
+ cli.output = values.output;
244
+ if (values['ignore-file'] !== undefined)
245
+ cli.ignoreFile = values['ignore-file'];
246
+ if (values['max-file-size'] !== undefined)
247
+ cli.maxFileBytes = parseMiB(values['max-file-size']);
248
+ if (values['no-color'])
249
+ cli.color = false;
250
+ else if (values.color)
251
+ cli.color = true;
252
+ return cli;
253
+ }
254
+ function parseMiB(value) {
255
+ const mib = /^\d{1,6}(?:\.\d{1,6})?$/.test(value.trim()) ? Number(value) : Number.NaN;
256
+ if (!Number.isFinite(mib) || mib <= 0)
257
+ throw new UsageError(`invalid --max-file-size '${value}' (expected a positive number of MiB)`);
258
+ return Math.max(1, Math.round(mib * 1024 * 1024));
259
+ }
260
+ /**
261
+ * The user's suppressions: --ignore-file when given (it must exist), else
262
+ * .toolmoatignore in the cwd when present. Ignore files inside scanned
263
+ * targets are never consulted.
264
+ */
265
+ function loadIgnore(ignoreFile, cwd) {
266
+ if (ignoreFile !== undefined) {
267
+ const file = path.resolve(cwd, ignoreFile);
268
+ let text;
269
+ try {
270
+ text = fs.readFileSync(file, 'utf8');
271
+ }
272
+ catch (error) {
273
+ throw new Error(`cannot read ignore file ${ignoreFile}: ${errorMessage(error)}`);
274
+ }
275
+ return { config: parseIgnoreFile(text), display: toDisplayPath(file, cwd) };
276
+ }
277
+ const local = path.join(cwd, '.toolmoatignore');
278
+ let text;
279
+ try {
280
+ text = fs.readFileSync(local, 'utf8');
281
+ }
282
+ catch {
283
+ return { config: emptyIgnore() };
284
+ }
285
+ return { config: parseIgnoreFile(text), display: '.toolmoatignore' };
286
+ }
287
+ function reportRuleErrors(errors, verbose, io) {
288
+ if (errors.length === 0)
289
+ return;
290
+ if (verbose) {
291
+ for (const e of errors)
292
+ io.stderr(`toolmoat scan: rule ${e.ruleId} failed on ${e.file}: ${e.message}\n`);
293
+ return;
294
+ }
295
+ const noun = errors.length === 1 ? 'error' : 'errors';
296
+ io.stderr(`toolmoat scan: ${errors.length} rule ${noun} while scanning (run with --verbose for details)\n`);
297
+ }
298
+ /** One line for stdout when the report itself went to a file. */
299
+ export function summaryLine(outcome, format, written) {
300
+ const { result } = outcome;
301
+ const files = `${result.filesScanned} file${result.filesScanned === 1 ? '' : 's'}`;
302
+ let status = 'no issues found';
303
+ if (result.findings.length > 0) {
304
+ const counts = countBySeverity(result.findings);
305
+ const parts = SEVERITIES.filter((s) => counts[s] > 0).map((s) => `${counts[s]} ${s}`);
306
+ status = `${result.findings.length} finding${result.findings.length === 1 ? '' : 's'} (${parts.join(', ')})`;
307
+ }
308
+ return `toolmoat ${VERSION}: ${status} in ${files}; ${format} report written to ${written}`;
309
+ }
310
+ function usageMessage(error) {
311
+ if (error instanceof UsageError)
312
+ return error.message;
313
+ const code = error?.code;
314
+ const message = errorMessage(error);
315
+ if (code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
316
+ const option = /'([^']{1,200})'/.exec(message)?.[1];
317
+ return option ? `unknown option '${option}'` : message;
318
+ }
319
+ return message;
320
+ }
321
+ function errorMessage(error) {
322
+ return error instanceof Error ? error.message : String(error);
323
+ }
@@ -0,0 +1,5 @@
1
+ import type { ServerTransform } from './wrap.js';
2
+ export declare const help = "Usage: toolmoat unwrap [options]\n\nUndoes `toolmoat wrap`: every server whose command runs `toolmoat proxy` gets\nback the command and args that follow the \"--\" in its wrapped command line.\nOther keys (env, cwd, type, ...) are not touched.\n\nOptions:\n --client <id> Only this client (repeatable), as for wrap\n --config <file> Unwrap the servers in this config file (repeatable)\n --dry-run Show what would change and write nothing\n --force Rewrite JSON files that contain comments (the comments are lost)\n\nWithout --client or --config, every user-level config that exists is processed.\nCodex's config.toml is never edited: unwrap prints the lines to paste.\nRestart the client afterwards.";
3
+ /** The unwrap transform for JSON configs. */
4
+ export declare const unwrapTransform: ServerTransform;
5
+ export declare function run(argv: string[]): Promise<number>;
@@ -0,0 +1,69 @@
1
+ import { originalLaunch, readLaunch, splitWrapped } from '../clients/configs.js';
2
+ import { formatLaunch, parseTargetArgs, processTargets, selectTargets, summarize, UsageError } from './wrap.js';
3
+ export const help = `Usage: toolmoat unwrap [options]
4
+
5
+ Undoes \`toolmoat wrap\`: every server whose command runs \`toolmoat proxy\` gets
6
+ back the command and args that follow the "--" in its wrapped command line.
7
+ Other keys (env, cwd, type, ...) are not touched.
8
+
9
+ Options:
10
+ --client <id> Only this client (repeatable), as for wrap
11
+ --config <file> Unwrap the servers in this config file (repeatable)
12
+ --dry-run Show what would change and write nothing
13
+ --force Rewrite JSON files that contain comments (the comments are lost)
14
+
15
+ Without --client or --config, every user-level config that exists is processed.
16
+ Codex's config.toml is never edited: unwrap prints the lines to paste.
17
+ Restart the client afterwards.`;
18
+ const NOTHING_AFTER_SEPARATOR = 'wrapped, but nothing follows "--" to restore; fix it by hand';
19
+ /**
20
+ * Restores command and args in place. wrap puts a new args key before command
21
+ * when the original entry had none, so that order plus an empty argument list
22
+ * means the original had no args key at all.
23
+ */
24
+ function applyUnwrapped(server, original) {
25
+ const keys = Object.keys(server);
26
+ const argsFirst = keys.indexOf('args') < keys.indexOf('command');
27
+ server.command = original.command;
28
+ if (argsFirst && original.args.length === 0)
29
+ delete server.args;
30
+ else
31
+ server.args = original.args;
32
+ }
33
+ /** What unwrap does with one command line: restore the original, or say why not. */
34
+ function planUnwrap(launch) {
35
+ if (!splitWrapped(launch.command, launch.args))
36
+ return { status: 'unchanged', detail: 'not wrapped' };
37
+ const original = originalLaunch(launch);
38
+ if (!original)
39
+ return { status: 'skipped', detail: NOTHING_AFTER_SEPARATOR };
40
+ return { status: 'changed', detail: formatLaunch(original), after: original };
41
+ }
42
+ /** The unwrap transform for JSON configs. */
43
+ export const unwrapTransform = (entry) => {
44
+ const base = entry.project === undefined ? { name: entry.name } : { name: entry.name, project: entry.project };
45
+ const launch = readLaunch(entry.server);
46
+ if ('error' in launch)
47
+ return { ...base, status: 'unchanged', detail: 'not wrapped' };
48
+ const plan = planUnwrap(launch);
49
+ if (plan.after)
50
+ applyUnwrapped(entry.server, plan.after);
51
+ return { ...base, ...plan };
52
+ };
53
+ export async function run(argv) {
54
+ let args;
55
+ let selection;
56
+ try {
57
+ args = parseTargetArgs(argv, false);
58
+ selection = selectTargets(args.clients, args.configs, process.cwd());
59
+ }
60
+ catch (error) {
61
+ if (!(error instanceof UsageError))
62
+ throw error;
63
+ process.stderr.write(`toolmoat unwrap: ${error.message}\n\n${help}\n`);
64
+ return 2;
65
+ }
66
+ const unwrapToml = (name, launch) => ({ name, ...planUnwrap(launch) });
67
+ const reports = processTargets(selection.targets, unwrapTransform, unwrapToml, { dryRun: args.dryRun, force: args.force });
68
+ return summarize(reports, selection, args.dryRun, 'unwrap');
69
+ }
@@ -0,0 +1,131 @@
1
+ import type { ConfigFormat, Launch, ServerEntry, ServersKey } from '../clients/configs.js';
2
+ export declare const help = "Usage: toolmoat wrap [options]\n\nRoutes the stdio MCP servers in your client configs through `toolmoat proxy`,\nwhich pins tool definitions, guards outgoing secrets and keeps an audit log.\n\nOptions:\n --client <id> Only this client (repeatable): claude-desktop, claude-code, cursor,\n vscode, windsurf, codex, gemini, or one config id such as\n claude-code-project, cursor-project, vscode-project, gemini-project\n --config <file> Wrap the servers in this config file (repeatable)\n --lock <file> Lockfile the proxy should use instead of its default\n --mode <mode> Proxy mode: enforce or warn\n --dry-run Show what would change and write nothing\n --force Rewrite JSON files that contain comments (the comments are lost)\n\nWithout --client or --config, every user-level config that exists is wrapped:\nClaude Desktop, Claude Code (~/.claude.json, including its per-project servers),\nCursor, VS Code, Windsurf, Gemini CLI and Codex. Project configs (.mcp.json,\n.cursor/mcp.json, .vscode/mcp.json, .gemini/settings.json) are only wrapped when\nnamed, because they are usually committed and the wrapped command holds paths\nfrom this machine.\n\nEach file is copied once to <file>.toolmoat.bak before its first change and is\nreplaced atomically. Remote (url) servers are left alone. Servers that are\nalready wrapped are left alone too, unless the node or cli.js path they start\nno longer exists (say after switching Node versions): those are pointed at this\ninstall. Codex's config.toml is never edited: wrap prints the lines to paste.\nRestart the client afterwards. Undo with `toolmoat unwrap`.";
3
+ /** Raised for bad command-line input; run() turns it into exit code 2. */
4
+ export declare class UsageError extends Error {
5
+ }
6
+ /** The part of a wrapped command line that starts toolmoat, up to (not including) "proxy". */
7
+ export interface Launcher {
8
+ command: string;
9
+ prefix: string[];
10
+ }
11
+ export interface ProxyOptions {
12
+ lock?: string;
13
+ mode?: 'enforce' | 'warn';
14
+ }
15
+ /** Absolute path of the cli.js that is running this command. */
16
+ export declare function currentCliPath(): string;
17
+ /**
18
+ * How a wrapped server starts toolmoat: the running node with the running
19
+ * cli.js, or `npx -y toolmoat@<version>` when cli.js sits in the npx cache,
20
+ * which npm may clean at any time.
21
+ */
22
+ export declare function launcherFor(cliPath: string, nodePath?: string): Launcher;
23
+ /** The wrapped command line for a server. */
24
+ export declare function wrapLaunch(name: string, original: Launch, launcher: Launcher, options?: ProxyOptions): Launch;
25
+ /** A config file that wrap or unwrap will process. */
26
+ export interface ConfigTarget {
27
+ path: string;
28
+ label: string;
29
+ /** Short id that prefixes the proxy --name of servers wrapped in this file, e.g. "cursor-user". */
30
+ scope: string;
31
+ format: ConfigFormat;
32
+ serversKeys: readonly ServersKey[];
33
+ /** Project configs get a note that the file is usually committed. */
34
+ project: boolean;
35
+ /** Named on the command line, so a missing file is an error rather than silently skipped. */
36
+ explicit: boolean;
37
+ }
38
+ export interface Selection {
39
+ targets: ConfigTarget[];
40
+ /** Project configs that exist but were left out because nothing named them. */
41
+ projectHints: ConfigTarget[];
42
+ }
43
+ /** Resolves --client and --config into the config files to process. Throws UsageError for an unknown client. */
44
+ export declare function selectTargets(clients: readonly string[], configs: readonly string[], cwd: string): Selection;
45
+ export type ServerStatus = 'changed' | 'unchanged' | 'skipped';
46
+ export interface ServerReport {
47
+ name: string;
48
+ project?: string;
49
+ status: ServerStatus;
50
+ /** For changed servers, the original command line; otherwise why nothing changed. */
51
+ detail: string;
52
+ /** The command line after the change. */
53
+ after?: Launch;
54
+ /** For servers wrap newly wraps: the command line it had. */
55
+ original?: Launch;
56
+ }
57
+ export interface ConfigReport {
58
+ target: ConfigTarget;
59
+ servers: ServerReport[];
60
+ /** Why the file could not be read or written. Changed servers in it were not saved. */
61
+ error?: string;
62
+ written: boolean;
63
+ /** Backup created by this run. */
64
+ backup?: string;
65
+ /** For TOML configs: the lines the user should paste, because toolmoat never edits TOML. */
66
+ pasteLines?: string[];
67
+ }
68
+ /** Decides what happens to one server. It changes entry.server in place when the status is 'changed'. */
69
+ export type ServerTransform = (entry: ServerEntry) => ServerReport;
70
+ /** Reads a JSON config, applies the transform to every server and writes the file when something changed. */
71
+ export declare function processJsonTarget(target: ConfigTarget, transform: ServerTransform, options: {
72
+ dryRun: boolean;
73
+ force: boolean;
74
+ }): ConfigReport;
75
+ /**
76
+ * The node or cli.js path of a wrapped command line that no longer exists on
77
+ * this machine (after a Node version switch, a reinstall or a config synced
78
+ * from another machine), or null when the launcher is fine or not path-based.
79
+ */
80
+ export declare function missingLauncherPath(launch: Launch): string | null;
81
+ /** Whether `toolmoat proxy --name` accepts the name: it refuses an empty one, control characters and more than 200 characters. */
82
+ export declare function isProxyName(name: string): boolean;
83
+ /** What wrap does with one stdio command line: wrap it, point a stale wrapped one at this install, or leave it. */
84
+ export declare function planWrap(name: string, launch: Launch, launcher: Launcher, options: ProxyOptions): Pick<ServerReport, 'status' | 'detail' | 'after' | 'original'>;
85
+ /**
86
+ * The --name a wrapped server gets: the client, the project for per-project
87
+ * servers, then the server's own name ("cursor.github", "claude-code-user.my-app.db").
88
+ * The lockfile keys pins by name, so two clients that both call a server
89
+ * "github" must not share one entry, or each would hide the other's tools.
90
+ */
91
+ export declare function scopedName(scope: string, name: string, project?: string): string;
92
+ /** The wrap transform: replaces command and args of each stdio server with the proxy launch. */
93
+ export declare function wrapTransform(launcher: Launcher, options: ProxyOptions, scope?: string): ServerTransform;
94
+ /** Wrap's view of a Codex config.toml: never edited, so changed servers come with lines to paste. */
95
+ export declare function processTomlTarget(target: ConfigTarget, transform: (name: string, launch: Launch) => ServerReport): ConfigReport;
96
+ /** A command line for display: quoted where needed, secrets redacted, invisible characters escaped. */
97
+ export declare function formatLaunch(launch: Launch, max?: number): string;
98
+ /** Relative to the working directory when inside it, '~/...' inside the home directory, else absolute; forward slashes. */
99
+ export declare function shownPath(file: string): string;
100
+ /** Client names to restart, from the labels of the configs that were written. */
101
+ export declare function clientsToRestart(reports: readonly ConfigReport[]): string[];
102
+ export interface ParsedTargetArgs {
103
+ clients: string[];
104
+ configs: string[];
105
+ dryRun: boolean;
106
+ force: boolean;
107
+ lock?: string;
108
+ mode?: string;
109
+ }
110
+ /** Parses the options wrap and unwrap share (plus wrap's --lock and --mode when allowed). Throws UsageError. */
111
+ export declare function parseTargetArgs(argv: string[], withProxyOptions: boolean): ParsedTargetArgs;
112
+ /** Prints the per-config report shared by wrap and unwrap. */
113
+ export declare function printReports(reports: readonly ConfigReport[], verbs: {
114
+ done: string;
115
+ planned: string;
116
+ }, dryRun: boolean): void;
117
+ /** Counts the JSON-config servers that were (or, in a dry run, would be) changed. TOML changes are only printed. */
118
+ export declare function countChanged(reports: readonly ConfigReport[], dryRun: boolean): number;
119
+ /** Processes the selected targets; missing explicit files become error reports, missing default ones are dropped. */
120
+ export declare function processTargets(targets: readonly ConfigTarget[], jsonTransform: ServerTransform, tomlTransform: (name: string, launch: Launch) => ServerReport, options: {
121
+ dryRun: boolean;
122
+ force: boolean;
123
+ }): ConfigReport[];
124
+ export declare function run(argv: string[]): Promise<number>;
125
+ /** Names shared by newly wrapped servers that run different programs, with those programs. */
126
+ export declare function nameClashes(reports: readonly ConfigReport[]): {
127
+ name: string;
128
+ programs: string[];
129
+ }[];
130
+ /** Prints the reports and the closing summary, and returns the exit code. */
131
+ export declare function summarize(reports: readonly ConfigReport[], selection: Selection, dryRun: boolean, verb: 'wrap' | 'unwrap'): number;