@evantahler/mcpx 0.18.2 → 0.18.5

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 (53) hide show
  1. package/package.json +63 -62
  2. package/src/cli.ts +46 -54
  3. package/src/client/browser.ts +15 -15
  4. package/src/client/debug-fetch.ts +53 -56
  5. package/src/client/elicitation.ts +279 -291
  6. package/src/client/http.ts +1 -1
  7. package/src/client/manager.ts +481 -514
  8. package/src/client/oauth.ts +272 -282
  9. package/src/client/sse.ts +1 -1
  10. package/src/client/stdio.ts +7 -7
  11. package/src/client/trace.ts +146 -152
  12. package/src/client/transport-options.ts +20 -20
  13. package/src/commands/add.ts +160 -165
  14. package/src/commands/allow.ts +141 -142
  15. package/src/commands/auth.ts +86 -90
  16. package/src/commands/check-update.ts +49 -53
  17. package/src/commands/deny.ts +114 -117
  18. package/src/commands/exec.ts +218 -222
  19. package/src/commands/index.ts +41 -41
  20. package/src/commands/info.ts +48 -50
  21. package/src/commands/list.ts +49 -49
  22. package/src/commands/ping.ts +47 -50
  23. package/src/commands/prompt.ts +40 -50
  24. package/src/commands/remove.ts +54 -56
  25. package/src/commands/resource.ts +31 -36
  26. package/src/commands/search.ts +35 -39
  27. package/src/commands/servers.ts +44 -48
  28. package/src/commands/skill.ts +89 -95
  29. package/src/commands/task.ts +50 -60
  30. package/src/commands/upgrade.ts +191 -208
  31. package/src/commands/with-command.ts +27 -29
  32. package/src/config/env.ts +26 -28
  33. package/src/config/loader.ts +99 -102
  34. package/src/config/schemas.ts +78 -87
  35. package/src/constants.ts +17 -17
  36. package/src/context.ts +51 -51
  37. package/src/lib/client-settings.ts +127 -140
  38. package/src/lib/input.ts +23 -26
  39. package/src/output/format-output.ts +12 -16
  40. package/src/output/format-table.ts +39 -42
  41. package/src/output/formatter.ts +790 -814
  42. package/src/output/logger.ts +140 -152
  43. package/src/sdk.ts +283 -291
  44. package/src/search/index.ts +50 -54
  45. package/src/search/indexer.ts +65 -65
  46. package/src/search/keyword.ts +54 -54
  47. package/src/search/semantic.ts +39 -39
  48. package/src/search/staleness.ts +3 -3
  49. package/src/search/types.ts +4 -4
  50. package/src/update/background.ts +51 -51
  51. package/src/update/cache.ts +21 -21
  52. package/src/update/checker.ts +81 -86
  53. package/src/validation/schema.ts +52 -58
@@ -1,179 +1,174 @@
1
1
  import type { Command } from "commander";
2
- import type { ServerConfig } from "../config/schemas.ts";
2
+ import { resolveResourceUrl, tryOAuthIfSupported } from "../client/oauth.ts";
3
3
  import { loadRawAuth, loadRawServers, saveServers } from "../config/loader.ts";
4
- import { tryOAuthIfSupported, resolveResourceUrl } from "../client/oauth.ts";
4
+ import type { ServerConfig } from "../config/schemas.ts";
5
5
  import { runIndex } from "./index.ts";
6
6
 
7
7
  export function registerAddCommand(program: Command) {
8
- program
9
- .command("add <name>")
10
- .description("add an MCP server to your config")
11
- .option("--command <cmd>", "command to run (stdio server)")
12
- .option("--args <args>", "comma-separated arguments for the command")
13
- .option("--env <vars>", "comma-separated KEY=VAL environment variables")
14
- .option("--cwd <dir>", "working directory for the command")
15
- .option("--url <url>", "server URL (HTTP server)")
16
- .option("--header <h>", "header in Key:Value format (repeatable)", collect, [])
17
- .option("--transport <type>", 'transport for HTTP servers: "sse" or "streamable-http"')
18
- .option("--allowed-tools <tools>", "comma-separated list of allowed tools")
19
- .option("--disabled-tools <tools>", "comma-separated list of disabled tools")
20
- .option("-f, --force", "overwrite if server already exists")
21
- .option("--no-auth", "skip automatic OAuth authentication after adding an HTTP server")
22
- .option("--no-index", "skip rebuilding the search index after adding")
23
- .action(
24
- async (
25
- name: string,
26
- options: {
27
- command?: string;
28
- args?: string;
29
- env?: string;
30
- cwd?: string;
31
- url?: string;
32
- header?: string[];
33
- transport?: string;
34
- allowedTools?: string;
35
- disabledTools?: string;
36
- force?: boolean;
37
- auth?: boolean;
38
- index?: boolean;
39
- },
40
- ) => {
41
- const hasCommand = !!options.command;
42
- const hasUrl = !!options.url;
43
-
44
- if (!hasCommand && !hasUrl) {
45
- console.error("Must specify --command (stdio) or --url (http)");
46
- process.exit(1);
47
- }
48
- if (hasCommand && hasUrl) {
49
- console.error("Cannot specify both --command and --url");
50
- process.exit(1);
51
- }
52
-
53
- const configFlag = program.opts().config;
54
- const { configDir, servers } = await loadRawServers(configFlag);
55
-
56
- if (servers.mcpServers[name] && !options.force) {
57
- console.error(`Server "${name}" already exists (use --force to overwrite)`);
58
- process.exit(1);
59
- }
60
-
61
- let config: ServerConfig;
62
-
63
- if (hasCommand) {
64
- config = buildStdioConfig(options);
65
- } else {
66
- config = buildHttpConfig(options);
67
- }
68
-
69
- if (hasUrl && options.transport) {
70
- if (options.transport !== "sse" && options.transport !== "streamable-http") {
71
- console.error('--transport must be "sse" or "streamable-http"');
72
- process.exit(1);
73
- }
74
- (config as { transport: string }).transport = options.transport;
75
- }
76
-
77
- // Common options
78
- if (options.allowedTools) {
79
- config.allowedTools = options.allowedTools.split(",").map((t) => t.trim());
80
- }
81
- if (options.disabledTools) {
82
- config.disabledTools = options.disabledTools.split(",").map((t) => t.trim());
83
- }
84
-
85
- // For HTTP servers, resolve the canonical resource URL before saving.
86
- // Some servers (e.g. hf.co → huggingface.co) advertise a different canonical
87
- // URL in their OAuth protected resource metadata, and the SDK enforces that the
88
- // stored URL matches this canonical URL during the OAuth token flow.
89
- let effectiveUrl = options.url!;
90
- if (hasUrl && options.auth !== false) {
91
- const canonical = await resolveResourceUrl(effectiveUrl);
92
- if (canonical !== effectiveUrl) {
93
- (config as { url: string }).url = canonical;
94
- effectiveUrl = canonical;
95
- console.log(`Resolved canonical URL: ${canonical}`);
96
- }
97
- }
98
-
99
- servers.mcpServers[name] = config;
100
- await saveServers(configDir, servers);
101
- console.log(`Added server "${name}" to ${configDir}/servers.json`);
102
-
103
- // Auto-auth: probe for OAuth support and run the flow if supported
104
- if (hasUrl && options.auth !== false) {
105
- const auth = await loadRawAuth(configDir);
106
- const formatOptions = {
107
- json: !!program.opts().json,
108
- verbose: !!program.opts().verbose,
109
- showSecrets: false,
110
- };
111
- try {
112
- await tryOAuthIfSupported(name, effectiveUrl, configDir, auth, formatOptions);
113
- } catch {
114
- console.error(`Warning: OAuth authentication failed. Run: mcpx auth ${name}`);
115
- }
116
- }
117
-
118
- // Commander treats --no-index as index=false (default true)
119
- if (options.index !== false) {
120
- await runIndex(program);
121
- }
122
- },
123
- );
8
+ program
9
+ .command("add <name>")
10
+ .description("add an MCP server to your config")
11
+ .option("--command <cmd>", "command to run (stdio server)")
12
+ .option("--args <args>", "comma-separated arguments for the command")
13
+ .option("--env <vars>", "comma-separated KEY=VAL environment variables")
14
+ .option("--cwd <dir>", "working directory for the command")
15
+ .option("--url <url>", "server URL (HTTP server)")
16
+ .option("--header <h>", "header in Key:Value format (repeatable)", collect, [])
17
+ .option("--transport <type>", 'transport for HTTP servers: "sse" or "streamable-http"')
18
+ .option("--allowed-tools <tools>", "comma-separated list of allowed tools")
19
+ .option("--disabled-tools <tools>", "comma-separated list of disabled tools")
20
+ .option("-f, --force", "overwrite if server already exists")
21
+ .option("--no-auth", "skip automatic OAuth authentication after adding an HTTP server")
22
+ .option("--no-index", "skip rebuilding the search index after adding")
23
+ .action(
24
+ async (
25
+ name: string,
26
+ options: {
27
+ command?: string;
28
+ args?: string;
29
+ env?: string;
30
+ cwd?: string;
31
+ url?: string;
32
+ header?: string[];
33
+ transport?: string;
34
+ allowedTools?: string;
35
+ disabledTools?: string;
36
+ force?: boolean;
37
+ auth?: boolean;
38
+ index?: boolean;
39
+ },
40
+ ) => {
41
+ const hasCommand = !!options.command;
42
+ const hasUrl = !!options.url;
43
+
44
+ if (!hasCommand && !hasUrl) {
45
+ console.error("Must specify --command (stdio) or --url (http)");
46
+ process.exit(1);
47
+ }
48
+ if (hasCommand && hasUrl) {
49
+ console.error("Cannot specify both --command and --url");
50
+ process.exit(1);
51
+ }
52
+
53
+ const configFlag = program.opts().config;
54
+ const { configDir, servers } = await loadRawServers(configFlag);
55
+
56
+ if (servers.mcpServers[name] && !options.force) {
57
+ console.error(`Server "${name}" already exists (use --force to overwrite)`);
58
+ process.exit(1);
59
+ }
60
+
61
+ let config: ServerConfig;
62
+
63
+ if (hasCommand) {
64
+ config = buildStdioConfig(options);
65
+ } else {
66
+ config = buildHttpConfig(options);
67
+ }
68
+
69
+ if (hasUrl && options.transport) {
70
+ if (options.transport !== "sse" && options.transport !== "streamable-http") {
71
+ console.error('--transport must be "sse" or "streamable-http"');
72
+ process.exit(1);
73
+ }
74
+ (config as { transport: string }).transport = options.transport;
75
+ }
76
+
77
+ // Common options
78
+ if (options.allowedTools) {
79
+ config.allowedTools = options.allowedTools.split(",").map((t) => t.trim());
80
+ }
81
+ if (options.disabledTools) {
82
+ config.disabledTools = options.disabledTools.split(",").map((t) => t.trim());
83
+ }
84
+
85
+ // For HTTP servers, resolve the canonical resource URL before saving.
86
+ // Some servers (e.g. hf.co → huggingface.co) advertise a different canonical
87
+ // URL in their OAuth protected resource metadata, and the SDK enforces that the
88
+ // stored URL matches this canonical URL during the OAuth token flow.
89
+ let effectiveUrl = options.url!;
90
+ if (hasUrl && options.auth !== false) {
91
+ const canonical = await resolveResourceUrl(effectiveUrl);
92
+ if (canonical !== effectiveUrl) {
93
+ (config as { url: string }).url = canonical;
94
+ effectiveUrl = canonical;
95
+ console.log(`Resolved canonical URL: ${canonical}`);
96
+ }
97
+ }
98
+
99
+ servers.mcpServers[name] = config;
100
+ await saveServers(configDir, servers);
101
+ console.log(`Added server "${name}" to ${configDir}/servers.json`);
102
+
103
+ // Auto-auth: probe for OAuth support and run the flow if supported
104
+ if (hasUrl && options.auth !== false) {
105
+ const auth = await loadRawAuth(configDir);
106
+ const formatOptions = {
107
+ json: !!program.opts().json,
108
+ verbose: !!program.opts().verbose,
109
+ showSecrets: false,
110
+ };
111
+ try {
112
+ await tryOAuthIfSupported(name, effectiveUrl, configDir, auth, formatOptions);
113
+ } catch {
114
+ console.error(`Warning: OAuth authentication failed. Run: mcpx auth ${name}`);
115
+ }
116
+ }
117
+
118
+ // Commander treats --no-index as index=false (default true)
119
+ if (options.index !== false) {
120
+ await runIndex(program);
121
+ }
122
+ },
123
+ );
124
124
  }
125
125
 
126
126
  function collect(value: string, previous: string[]): string[] {
127
- return previous.concat([value]);
127
+ return previous.concat([value]);
128
128
  }
129
129
 
130
- function buildStdioConfig(options: {
131
- command?: string;
132
- args?: string;
133
- env?: string;
134
- cwd?: string;
135
- }): ServerConfig {
136
- const config: Record<string, unknown> = { command: options.command! };
137
-
138
- if (options.args) {
139
- config.args = options.args.split(",").map((a) => a.trim());
140
- }
141
-
142
- if (options.env) {
143
- const env: Record<string, string> = {};
144
- for (const pair of options.env.split(",")) {
145
- const eqIdx = pair.indexOf("=");
146
- if (eqIdx === -1) {
147
- console.error(`Invalid env format "${pair}", expected KEY=VAL`);
148
- process.exit(1);
149
- }
150
- env[pair.slice(0, eqIdx).trim()] = pair.slice(eqIdx + 1).trim();
151
- }
152
- config.env = env;
153
- }
154
-
155
- if (options.cwd) {
156
- config.cwd = options.cwd;
157
- }
158
-
159
- return config as ServerConfig;
130
+ function buildStdioConfig(options: { command?: string; args?: string; env?: string; cwd?: string }): ServerConfig {
131
+ const config: Record<string, unknown> = { command: options.command! };
132
+
133
+ if (options.args) {
134
+ config.args = options.args.split(",").map((a) => a.trim());
135
+ }
136
+
137
+ if (options.env) {
138
+ const env: Record<string, string> = {};
139
+ for (const pair of options.env.split(",")) {
140
+ const eqIdx = pair.indexOf("=");
141
+ if (eqIdx === -1) {
142
+ console.error(`Invalid env format "${pair}", expected KEY=VAL`);
143
+ process.exit(1);
144
+ }
145
+ env[pair.slice(0, eqIdx).trim()] = pair.slice(eqIdx + 1).trim();
146
+ }
147
+ config.env = env;
148
+ }
149
+
150
+ if (options.cwd) {
151
+ config.cwd = options.cwd;
152
+ }
153
+
154
+ return config as unknown as ServerConfig;
160
155
  }
161
156
 
162
157
  function buildHttpConfig(options: { url?: string; header?: string[] }): ServerConfig {
163
- const config: Record<string, unknown> = { url: options.url! };
164
-
165
- if (options.header && options.header.length > 0) {
166
- const headers: Record<string, string> = {};
167
- for (const h of options.header) {
168
- const colonIdx = h.indexOf(":");
169
- if (colonIdx === -1) {
170
- console.error(`Invalid header format "${h}", expected Key:Value`);
171
- process.exit(1);
172
- }
173
- headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
174
- }
175
- config.headers = headers;
176
- }
177
-
178
- return config as ServerConfig;
158
+ const config: Record<string, unknown> = { url: options.url! };
159
+
160
+ if (options.header && options.header.length > 0) {
161
+ const headers: Record<string, string> = {};
162
+ for (const h of options.header) {
163
+ const colonIdx = h.indexOf(":");
164
+ if (colonIdx === -1) {
165
+ console.error(`Invalid header format "${h}", expected Key:Value`);
166
+ process.exit(1);
167
+ }
168
+ headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
169
+ }
170
+ config.headers = headers;
171
+ }
172
+
173
+ return config as unknown as ServerConfig;
179
174
  }
@@ -1,163 +1,162 @@
1
+ import { bold, dim, green, yellow } from "ansis";
1
2
  import type { Command } from "commander";
2
- import { bold, cyan, dim, green, yellow } from "ansis";
3
3
  import {
4
- type Client,
5
- type Scope,
6
- resolveSettingsPath,
7
- readClientSettings,
8
- writeClientSettings,
9
- execPattern,
10
- readOnlyPatterns,
11
- allExecPattern,
12
- allowCommandPattern,
13
- denyCommandPattern,
14
- addPatterns,
15
- getMcpxPatterns,
4
+ addPatterns,
5
+ allExecPattern,
6
+ allowCommandPattern,
7
+ type Client,
8
+ denyCommandPattern,
9
+ execPattern,
10
+ getMcpxPatterns,
11
+ readClientSettings,
12
+ readOnlyPatterns,
13
+ resolveSettingsPath,
14
+ type Scope,
15
+ writeClientSettings,
16
16
  } from "../lib/client-settings.ts";
17
17
  import { formatOutput } from "../output/format-output.ts";
18
18
  import type { FormatOptions } from "../output/formatter.ts";
19
19
 
20
20
  export function registerAllowCommand(program: Command) {
21
- program
22
- .command("allow")
23
- .description("add permission rules for mcpx commands (Claude Code or Cursor)")
24
- .argument("[server]", "server name to allow")
25
- .argument("[tools...]", "specific tool names to allow")
26
- .option("--all", "allow all mcpx exec calls")
27
- .option("--all-read", "allow read-only commands (search, info, list, servers, ping, etc.)")
28
- .option("--list", "show current mcpx-related permissions")
29
- .option("--cursor", "target Cursor settings instead of Claude Code")
30
- .option("--local", "write to local settings (default)")
31
- .option("--project", "write to project settings (shared)")
32
- .option("--global", "write to global settings")
33
- .option("--dry-run", "show patterns without writing")
34
- .action(
35
- async (
36
- server: string | undefined,
37
- tools: string[],
38
- options: {
39
- all?: boolean;
40
- allRead?: boolean;
41
- list?: boolean;
42
- cursor?: boolean;
43
- local?: boolean;
44
- project?: boolean;
45
- global?: boolean;
46
- dryRun?: boolean;
47
- },
48
- ) => {
49
- const formatOptions: FormatOptions = { json: program.opts().json };
50
- const client: Client = options.cursor ? "cursor" : "claude";
21
+ program
22
+ .command("allow")
23
+ .description("add permission rules for mcpx commands (Claude Code or Cursor)")
24
+ .argument("[server]", "server name to allow")
25
+ .argument("[tools...]", "specific tool names to allow")
26
+ .option("--all", "allow all mcpx exec calls")
27
+ .option("--all-read", "allow read-only commands (search, info, list, servers, ping, etc.)")
28
+ .option("--list", "show current mcpx-related permissions")
29
+ .option("--cursor", "target Cursor settings instead of Claude Code")
30
+ .option("--local", "write to local settings (default)")
31
+ .option("--project", "write to project settings (shared)")
32
+ .option("--global", "write to global settings")
33
+ .option("--dry-run", "show patterns without writing")
34
+ .action(
35
+ async (
36
+ server: string | undefined,
37
+ tools: string[],
38
+ options: {
39
+ all?: boolean;
40
+ allRead?: boolean;
41
+ list?: boolean;
42
+ cursor?: boolean;
43
+ local?: boolean;
44
+ project?: boolean;
45
+ global?: boolean;
46
+ dryRun?: boolean;
47
+ },
48
+ ) => {
49
+ const formatOptions: FormatOptions = { json: program.opts().json };
50
+ const client: Client = options.cursor ? "cursor" : "claude";
51
51
 
52
- // --list mode: show current permissions across all scopes
53
- if (options.list) {
54
- // Cursor maps local and project to the same file, so only show unique scopes
55
- const scopes: Scope[] =
56
- client === "cursor" ? ["local", "global"] : ["local", "project", "global"];
57
- const results: { scope: Scope; path: string; patterns: string[] }[] = [];
52
+ // --list mode: show current permissions across all scopes
53
+ if (options.list) {
54
+ // Cursor maps local and project to the same file, so only show unique scopes
55
+ const scopes: Scope[] = client === "cursor" ? ["local", "global"] : ["local", "project", "global"];
56
+ const results: { scope: Scope; path: string; patterns: string[] }[] = [];
58
57
 
59
- for (const scope of scopes) {
60
- const path = resolveSettingsPath(scope, client);
61
- const settings = await readClientSettings(path);
62
- const patterns = getMcpxPatterns(settings, client);
63
- results.push({ scope, path, patterns });
64
- }
58
+ for (const scope of scopes) {
59
+ const path = resolveSettingsPath(scope, client);
60
+ const settings = await readClientSettings(path);
61
+ const patterns = getMcpxPatterns(settings, client);
62
+ results.push({ scope, path, patterns });
63
+ }
65
64
 
66
- console.log(
67
- formatOutput(
68
- results.map((r) => ({ scope: r.scope, path: r.path, patterns: r.patterns })),
69
- () => {
70
- const lines: string[] = [];
71
- for (const r of results) {
72
- lines.push(bold(`${r.scope}`) + dim(` (${r.path})`));
73
- if (r.patterns.length === 0) {
74
- lines.push(` ${dim("(none)")}`);
75
- } else {
76
- for (const p of r.patterns) {
77
- lines.push(` ${green("✓")} ${p}`);
78
- }
79
- }
80
- lines.push("");
81
- }
82
- return lines.join("\n").trimEnd();
83
- },
84
- formatOptions,
85
- ),
86
- );
87
- return;
88
- }
65
+ console.log(
66
+ formatOutput(
67
+ results.map((r) => ({ scope: r.scope, path: r.path, patterns: r.patterns })),
68
+ () => {
69
+ const lines: string[] = [];
70
+ for (const r of results) {
71
+ lines.push(bold(`${r.scope}`) + dim(` (${r.path})`));
72
+ if (r.patterns.length === 0) {
73
+ lines.push(` ${dim("(none)")}`);
74
+ } else {
75
+ for (const p of r.patterns) {
76
+ lines.push(` ${green("✓")} ${p}`);
77
+ }
78
+ }
79
+ lines.push("");
80
+ }
81
+ return lines.join("\n").trimEnd();
82
+ },
83
+ formatOptions,
84
+ ),
85
+ );
86
+ return;
87
+ }
89
88
 
90
- // Build the list of patterns to add
91
- const patterns: string[] = [];
89
+ // Build the list of patterns to add
90
+ const patterns: string[] = [];
92
91
 
93
- if (options.all) {
94
- patterns.push(allExecPattern(client));
95
- }
92
+ if (options.all) {
93
+ patterns.push(allExecPattern(client));
94
+ }
96
95
 
97
- if (options.allRead) {
98
- patterns.push(...readOnlyPatterns(client));
99
- }
96
+ if (options.allRead) {
97
+ patterns.push(...readOnlyPatterns(client));
98
+ }
100
99
 
101
- if (server && tools.length > 0) {
102
- for (const tool of tools) {
103
- patterns.push(execPattern(server, tool, client));
104
- }
105
- } else if (server) {
106
- patterns.push(execPattern(server, undefined, client));
107
- }
100
+ if (server && tools.length > 0) {
101
+ for (const tool of tools) {
102
+ patterns.push(execPattern(server, tool, client));
103
+ }
104
+ } else if (server) {
105
+ patterns.push(execPattern(server, undefined, client));
106
+ }
108
107
 
109
- if (patterns.length === 0) {
110
- console.error("error: specify a server, --all, or --all-read. See 'mcpx allow --help'.");
111
- process.exit(1);
112
- }
108
+ if (patterns.length === 0) {
109
+ console.error("error: specify a server, --all, or --all-read. See 'mcpx allow --help'.");
110
+ process.exit(1);
111
+ }
113
112
 
114
- // Always include allow/deny command patterns so the agent can self-manage
115
- patterns.push(allowCommandPattern(client));
116
- patterns.push(denyCommandPattern(client));
113
+ // Always include allow/deny command patterns so the agent can self-manage
114
+ patterns.push(allowCommandPattern(client));
115
+ patterns.push(denyCommandPattern(client));
117
116
 
118
- const scope: Scope = options.global ? "global" : options.project ? "project" : "local";
119
- const path = resolveSettingsPath(scope, client);
117
+ const scope: Scope = options.global ? "global" : options.project ? "project" : "local";
118
+ const path = resolveSettingsPath(scope, client);
120
119
 
121
- if (options.dryRun) {
122
- console.log(
123
- formatOutput(
124
- { scope, path, patterns },
125
- () => {
126
- const lines: string[] = [];
127
- lines.push(bold("Dry run") + dim(` — would write to ${path}:`));
128
- for (const p of patterns) {
129
- lines.push(` ${yellow("+")} ${p}`);
130
- }
131
- return lines.join("\n");
132
- },
133
- formatOptions,
134
- ),
135
- );
136
- return;
137
- }
120
+ if (options.dryRun) {
121
+ console.log(
122
+ formatOutput(
123
+ { scope, path, patterns },
124
+ () => {
125
+ const lines: string[] = [];
126
+ lines.push(bold("Dry run") + dim(` — would write to ${path}:`));
127
+ for (const p of patterns) {
128
+ lines.push(` ${yellow("+")} ${p}`);
129
+ }
130
+ return lines.join("\n");
131
+ },
132
+ formatOptions,
133
+ ),
134
+ );
135
+ return;
136
+ }
138
137
 
139
- const settings = await readClientSettings(path);
140
- const { settings: updated, added } = addPatterns(settings, patterns);
141
- await writeClientSettings(path, updated);
138
+ const settings = await readClientSettings(path);
139
+ const { settings: updated, added } = addPatterns(settings, patterns);
140
+ await writeClientSettings(path, updated);
142
141
 
143
- console.log(
144
- formatOutput(
145
- { scope, path, added, total: (updated.permissions?.allow ?? []).length },
146
- () => {
147
- const lines: string[] = [];
148
- if (added.length === 0) {
149
- lines.push(dim("All patterns already present — no changes."));
150
- } else {
151
- lines.push(bold(`Added ${added.length} permission(s)`) + dim(` → ${path}`));
152
- for (const p of added) {
153
- lines.push(` ${green("+")} ${p}`);
154
- }
155
- }
156
- return lines.join("\n");
157
- },
158
- formatOptions,
159
- ),
160
- );
161
- },
162
- );
142
+ console.log(
143
+ formatOutput(
144
+ { scope, path, added, total: (updated.permissions?.allow ?? []).length },
145
+ () => {
146
+ const lines: string[] = [];
147
+ if (added.length === 0) {
148
+ lines.push(dim("All patterns already present — no changes."));
149
+ } else {
150
+ lines.push(bold(`Added ${added.length} permission(s)`) + dim(` → ${path}`));
151
+ for (const p of added) {
152
+ lines.push(` ${green("+")} ${p}`);
153
+ }
154
+ }
155
+ return lines.join("\n");
156
+ },
157
+ formatOptions,
158
+ ),
159
+ );
160
+ },
161
+ );
163
162
  }