@aipermission/mcp 0.2.37 → 0.2.39

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/init.js CHANGED
@@ -1,13 +1,25 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { execFile } from "node:child_process";
3
3
  import { createRequire } from "node:module";
4
- import os from "node:os";
5
4
  import path from "node:path";
6
5
  import readline from "node:readline/promises";
7
6
  import { promisify } from "node:util";
8
7
  import { pathToFileURL } from "node:url";
9
8
  import { stdin as input, stdout as output } from "node:process";
9
+ import { parse as parseTOML } from "smol-toml";
10
+ import { parseCommandFlags } from "./cli-flags.js";
10
11
  import { DEFAULT_API_URL, normalizeLocalAPIURL } from "./local-url.js";
12
+ import { adaptMCPServerConfig, getClient, MCP_PROVIDERS, resolveMCPConfigTarget, resolveMCPPrintTarget } from "./client-registry.js";
13
+ import { commitSkillInstallation, prepareSkillInstallation } from "./install-skill.js";
14
+ import {
15
+ atomicWritePrivateFile,
16
+ privateLockPath,
17
+ privateStagingIgnorePath,
18
+ privateStagingPath,
19
+ privateTemporaryIgnorePath,
20
+ privateTemporaryPath,
21
+ withPrivateFileLock,
22
+ } from "./private-file.js";
11
23
 
12
24
  const require = createRequire(import.meta.url);
13
25
  const execFileAsync = promisify(execFile);
@@ -26,86 +38,70 @@ const color = {
26
38
  yellow: useColor ? "\x1b[33m" : "",
27
39
  };
28
40
 
29
- const providers = [
30
- {
31
- id: "codex",
32
- label: "OpenAI Codex",
33
- description: "Writes ~/.codex/config.toml",
34
- },
35
- {
36
- id: "claude-code",
37
- label: "Claude Code",
38
- description: "Writes .mcp.json in the current project",
39
- },
40
- {
41
- id: "cursor",
42
- label: "Cursor",
43
- description: "Writes .cursor/mcp.json in the current project",
44
- },
45
- {
46
- id: "vscode",
47
- label: "VS Code",
48
- description: "Writes .vscode/mcp.json in the current project",
49
- },
50
- {
51
- id: "windsurf",
52
- label: "Windsurf",
53
- description: "Writes ~/.codeium/windsurf/mcp_config.json",
54
- },
55
- {
56
- id: "antigravity",
57
- label: "Google Antigravity",
58
- description: "Writes ~/.gemini/antigravity/mcp_config.json",
59
- },
60
- {
61
- id: "gemini",
62
- label: "Gemini CLI",
63
- description: "Writes ~/.gemini/settings.json",
64
- },
65
- {
66
- id: "custom",
67
- label: "Custom / copy-paste",
68
- description: "Prints config snippets only",
69
- },
70
- ];
71
-
72
41
  export async function runInit(argv = []) {
73
- const flags = parseFlags(argv);
74
- assertProviderSelectionAvailable(flags.provider, Boolean(input.isTTY && output.isTTY));
75
- const stdinToken = flags.tokenStdin ? (await readStdin()).trim() : "";
42
+ return runConfiguration("init", argv);
43
+ }
44
+
45
+ export async function runSetup(argv = []) {
46
+ return runConfiguration("setup", argv);
47
+ }
48
+
49
+ async function runConfiguration(command, argv) {
50
+ const flags = parseCommandFlags(command, argv);
51
+ const interactive = Boolean(input.isTTY && output.isTTY);
52
+ assertProviderSelectionAvailable(flags.provider, interactive);
76
53
  const rl = readline.createInterface({ input, output });
77
54
  try {
78
55
  const provider = flags.provider
79
56
  ? findProvider(flags.provider)
80
- : await selectProvider("Which AI client should use this token?", providers);
81
- const name = sanitizeName(
82
- flags.name || (await ask(rl, "MCP server name", "aipermission"))
83
- );
57
+ : await selectProvider("Which AI client should use this token?", MCP_PROVIDERS);
58
+ const name = sanitizeName(flags.name || (interactive ? await ask(rl, "MCP server name", "aipermission") : "aipermission"));
84
59
  const apiUrl = normalizeURL(flags.apiUrl || DEFAULT_API_URL);
85
- const token = await resolveToken({ ...flags, stdinToken }, rl);
60
+ const outputTarget =
61
+ provider.id === "custom"
62
+ ? undefined
63
+ : flags.print
64
+ ? resolveMCPPrintTarget(provider.id, flags.scope)
65
+ : resolveMCPConfigTarget(provider.id, flags.scope);
66
+ const shouldPrepareSkill = command === "setup" && (!flags.print || provider.id === "custom");
67
+ const preparedSkill = shouldPrepareSkill ? await prepareSetupSkill(provider.id, flags) : undefined;
68
+ if (provider.id === "custom" || flags.print) {
69
+ const skillResult = preparedSkill ? await reportInstalledSkill(preparedSkill) : undefined;
70
+ printPlaceholderConfigNotice();
71
+ printProviderConfig(name, provider.id, apiUrl, outputTarget);
72
+ if (command === "setup" && flags.print && provider.id !== "custom") {
73
+ console.error("No files were changed. Run install-skill separately to install the native operator skill.");
74
+ }
75
+ return { provider: provider.id, name, printed: true, skill: skillResult };
76
+ }
86
77
 
78
+ const stdinToken = flags.tokenStdin ? (await readStdin()).trim() : "";
79
+ const token = await resolveToken({ ...flags, stdinToken }, rl);
87
80
  if (!token) {
88
81
  throw new Error("API token is required.");
89
82
  }
90
-
91
- const config = buildMCPServerConfig({ apiUrl, token });
92
- if (provider.id === "custom" || flags.print) {
93
- printTokenConfigWarning();
94
- printCustomConfig(name, config);
95
- return;
96
- }
97
-
98
- const result = await writeProviderConfig(provider.id, name, config, { force: Boolean(flags.force) });
83
+ const config = adaptMCPServerConfig(provider.id, buildMCPServerConfig({ apiUrl, token }));
84
+ const skillResult = preparedSkill ? await reportInstalledSkill(preparedSkill) : undefined;
85
+ const result = await writeProviderConfig(provider.id, name, config, {
86
+ force: Boolean(flags.force),
87
+ scope: flags.scope,
88
+ homeDir: flags.home,
89
+ projectDir: flags.projectDir,
90
+ });
99
91
  console.log("");
100
92
  console.log(`${color.green}Configured ${provider.label}${color.reset}`);
101
93
  console.log(`${color.dim}Name:${color.reset} ${name}`);
102
94
  console.log(`${color.dim}Path:${color.reset} ${result.path}`);
95
+ console.log(`${color.dim}Scope:${color.reset} ${result.scope}`);
103
96
  if (result.gitExcluded) {
104
97
  console.log(`${color.dim}Git:${color.reset} added ${result.gitExcludeEntry} to .git/info/exclude`);
105
98
  }
106
99
  console.log("");
107
- console.log(`${color.yellow}Keep this config private:${color.reset} it contains an AIPermission bearer token. If it is committed, revoke the token.`);
100
+ console.log(
101
+ `${color.yellow}Keep this config private:${color.reset} it contains an AIPermission bearer token. If it is committed, revoke the token.`,
102
+ );
108
103
  console.log(`${color.yellow}Restart the AI client so it reloads MCP servers.${color.reset}`);
104
+ return { provider: provider.id, name, config: result, skill: skillResult };
109
105
  } finally {
110
106
  rl.close();
111
107
  }
@@ -118,46 +114,39 @@ export function assertProviderSelectionAvailable(provider, interactive) {
118
114
  }
119
115
 
120
116
  export function parseFlags(argv) {
121
- const result = {};
122
- for (let i = 0; i < argv.length; i += 1) {
123
- const arg = argv[i];
124
- if (!arg.startsWith("--")) {
125
- continue;
126
- }
127
- const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
128
- const key = rawKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
129
- if (key === "print") {
130
- result.print = true;
131
- continue;
132
- }
133
- if (key === "force") {
134
- result.force = true;
135
- continue;
136
- }
137
- if (key === "tokenStdin") {
138
- result.tokenStdin = true;
139
- continue;
140
- }
141
- if (key === "token") {
142
- throw new Error("--token is not supported; use the hidden prompt or --token-stdin");
143
- }
144
- result[key] = inlineValue ?? argv[i + 1] ?? "";
145
- if (inlineValue === undefined) {
146
- i += 1;
147
- }
117
+ return parseCommandFlags("init", argv);
118
+ }
119
+
120
+ async function prepareSetupSkill(client, flags) {
121
+ return prepareSkillInstallation({
122
+ client,
123
+ scope: flags.skillScope || flags.scope,
124
+ source: flags.skillSource,
125
+ homeDir: flags.home,
126
+ projectDir: flags.projectDir,
127
+ });
128
+ }
129
+
130
+ async function reportInstalledSkill(prepared) {
131
+ const result = await commitSkillInstallation(prepared);
132
+ if (!result.path) {
133
+ console.log("");
134
+ console.log(result.content);
135
+ return result;
148
136
  }
137
+ console.log("");
138
+ console.log(`${color.green}Installed native operator skill${color.reset}`);
139
+ console.log(`${color.dim}Path:${color.reset} ${result.path}`);
140
+ console.log(`${color.dim}Scope:${color.reset} ${result.scope}`);
149
141
  return result;
150
142
  }
151
143
 
152
144
  function findProvider(idOrLabel) {
153
- const normalized = String(idOrLabel).trim().toLowerCase();
154
- const provider = providers.find(
155
- (item) => item.id === normalized || item.label.toLowerCase() === normalized
156
- );
157
- if (!provider) {
145
+ try {
146
+ return getClient(idOrLabel);
147
+ } catch {
158
148
  throw new Error(`Unknown provider: ${idOrLabel}`);
159
149
  }
160
- return provider;
161
150
  }
162
151
 
163
152
  async function selectProvider(title, items) {
@@ -175,18 +164,12 @@ async function selectProvider(title, items) {
175
164
  output.write(`\x1b[${renderedLines}A`);
176
165
  output.write("\x1b[J");
177
166
  }
178
- const lines = [
179
- `${color.bold}${color.cyan}${title}${color.reset}`,
180
- `${color.dim}Use ↑/↓ and Enter.${color.reset}`,
181
- "",
182
- ];
167
+ const lines = [`${color.bold}${color.cyan}${title}${color.reset}`, `${color.dim}Use ↑/↓ and Enter.${color.reset}`, ""];
183
168
  for (let i = 0; i < items.length; i += 1) {
184
169
  const selected = i === index;
185
170
  const marker = selected ? `${color.green}›${color.reset}` : " ";
186
171
  const label = selected ? `${color.bold}${items[i].label}${color.reset}` : items[i].label;
187
- lines.push(
188
- `${marker} ${label} ${color.dim}- ${items[i].description}${color.reset}`
189
- );
172
+ lines.push(`${marker} ${label} ${color.dim}- ${items[i].description}${color.reset}`);
190
173
  }
191
174
  output.write("\x1b[?25l");
192
175
  output.write(`${lines.join("\n")}\n`);
@@ -210,7 +193,7 @@ async function selectProvider(title, items) {
210
193
  };
211
194
  const onData = (buffer) => {
212
195
  const value = buffer.toString("utf8");
213
- const keys = value.match(/\u001b\[[AB]|\r|\n|\u0003|./g) || [];
196
+ const keys = splitInputKeys(value);
214
197
  for (const key of keys) {
215
198
  if (key === "\u0003") {
216
199
  cleanup();
@@ -313,94 +296,113 @@ export function buildMCPServerConfig({ apiUrl, token }) {
313
296
  }
314
297
 
315
298
  export async function writeProviderConfig(providerID, name, config, options = {}) {
316
- if (providerID === "codex") {
317
- const filePath = path.join(os.homedir(), ".codex", "config.toml");
318
- await writeCodexConfig(filePath, name, config);
319
- return { path: filePath };
320
- }
321
- if (providerID === "claude-code") {
322
- const filePath = path.join(process.cwd(), ".mcp.json");
323
- await assertProjectConfigWritable(filePath, options);
324
- await writeJSONMCPConfig(filePath, name, config, "mcpServers");
325
- return { path: filePath, ...(await protectGitIgnoredConfig(filePath)) };
326
- }
327
- if (providerID === "cursor") {
328
- const filePath = path.join(process.cwd(), ".cursor", "mcp.json");
329
- await assertProjectConfigWritable(filePath, options);
330
- await writeJSONMCPConfig(filePath, name, config, "mcpServers");
331
- return { path: filePath, ...(await protectGitIgnoredConfig(filePath)) };
332
- }
333
- if (providerID === "vscode") {
334
- const filePath = path.join(process.cwd(), ".vscode", "mcp.json");
335
- await assertProjectConfigWritable(filePath, options);
336
- await writeJSONMCPConfig(filePath, name, config, "servers");
337
- return { path: filePath, ...(await protectGitIgnoredConfig(filePath)) };
338
- }
339
- if (providerID === "windsurf") {
340
- const filePath = path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
341
- await writeJSONMCPConfig(filePath, name, config, "mcpServers");
342
- return { path: filePath };
343
- }
344
- if (providerID === "antigravity") {
345
- const filePath = path.join(os.homedir(), ".gemini", "antigravity", "mcp_config.json");
346
- await writeJSONMCPConfig(filePath, name, config, "mcpServers");
347
- return { path: filePath };
348
- }
349
- if (providerID === "gemini") {
350
- const filePath = path.join(os.homedir(), ".gemini", "settings.json");
351
- await writeJSONMCPConfig(filePath, name, config, "mcpServers");
352
- return { path: filePath };
353
- }
354
- throw new Error(`Unsupported provider: ${providerID}`);
355
- }
356
-
357
- export async function writeJSONMCPConfig(filePath, name, config, rootKey) {
358
- await fs.mkdir(path.dirname(filePath), { recursive: true });
359
- let root = {};
360
- try {
361
- root = JSON.parse(await fs.readFile(filePath, "utf8"));
362
- } catch (error) {
363
- if (error.code !== "ENOENT") {
364
- throw new Error(`Could not read JSON config at ${filePath}: ${error.message}`);
365
- }
299
+ const projectRoot = options.projectDir || process.cwd();
300
+ const target = resolveMCPConfigTarget(providerID, options.scope, {
301
+ homeDir: options.homeDir,
302
+ projectDir: projectRoot,
303
+ env: options.env,
304
+ });
305
+ const providerConfig = adaptMCPServerConfig(providerID, config);
306
+ let protection = {};
307
+ if (target.projectConfig) {
308
+ await assertProjectConfigWritable(target.path, options);
309
+ protection = await protectGitIgnoredConfig(target.path, projectRoot, { allowTracked: Boolean(options.force) });
366
310
  }
367
- if (!root || typeof root !== "object" || Array.isArray(root)) {
368
- root = {};
311
+ const trustedRoot = target.trustedRoot;
312
+ const writeOptions = {
313
+ trustedRoot,
314
+ beforeWrite: target.projectConfig
315
+ ? async () => {
316
+ await assertProjectConfigWritable(target.path, options);
317
+ await options.beforeWrite?.();
318
+ await assertProjectConfigWritable(target.path, options);
319
+ }
320
+ : options.beforeWrite,
321
+ };
322
+ if (target.format === "toml") {
323
+ await writeTOMLMCPConfig(target.path, name, providerConfig, writeOptions);
324
+ } else {
325
+ await writeJSONMCPConfig(target.path, name, providerConfig, target.rootKey, writeOptions);
369
326
  }
370
- root[rootKey] =
371
- root[rootKey] && typeof root[rootKey] === "object" && !Array.isArray(root[rootKey])
372
- ? root[rootKey]
373
- : {};
374
- root[rootKey][name] = config;
375
- await writePrivateFile(filePath, `${JSON.stringify(root, null, 2)}\n`);
327
+ return { path: target.path, scope: target.scope, ...protection };
376
328
  }
377
329
 
378
- async function writeCodexConfig(filePath, name, config) {
379
- await fs.mkdir(path.dirname(filePath), { recursive: true });
380
- let current = "";
381
- try {
382
- current = await fs.readFile(filePath, "utf8");
383
- } catch (error) {
384
- if (error.code !== "ENOENT") {
385
- throw error;
330
+ export async function writeJSONMCPConfig(filePath, name, config, rootKey, options = {}) {
331
+ await withPrivateFileLock(
332
+ filePath,
333
+ async () => {
334
+ await options.beforeWrite?.();
335
+ let root = {};
336
+ try {
337
+ root = JSON.parse(await fs.readFile(filePath, "utf8"));
338
+ } catch (error) {
339
+ if (error.code !== "ENOENT") {
340
+ redactParseError(error);
341
+ throw new Error(`Could not parse JSON config at ${filePath}; the existing file was left unchanged`, {
342
+ cause: error,
343
+ });
344
+ }
345
+ }
346
+ if (!root || typeof root !== "object" || Array.isArray(root)) root = {};
347
+ const currentServers = root[rootKey];
348
+ const servers =
349
+ currentServers && typeof currentServers === "object" && !Array.isArray(currentServers)
350
+ ? { ...currentServers }
351
+ : Object.create(null);
352
+ Object.defineProperty(servers, name, { value: config, enumerable: true, configurable: true, writable: true });
353
+ root[rootKey] = servers;
354
+ await options.beforeWrite?.();
355
+ await writePrivateFile(filePath, `${JSON.stringify(root, null, 2)}\n`, options);
356
+ },
357
+ options,
358
+ );
359
+ }
360
+
361
+ function splitInputKeys(value) {
362
+ const keys = [];
363
+ for (let index = 0; index < value.length; index += 1) {
364
+ const sequence = value.slice(index, index + 3);
365
+ if (sequence === "\u001b[A" || sequence === "\u001b[B") {
366
+ keys.push(sequence);
367
+ index += 2;
368
+ continue;
386
369
  }
370
+ keys.push(value[index]);
387
371
  }
372
+ return keys;
373
+ }
388
374
 
389
- const next = removeCodexServer(current, name).trimEnd();
390
- const block = codexServerBlock(name, config);
391
- await writePrivateFile(filePath, `${next ? `${next}\n\n` : ""}${block}\n`);
375
+ export async function writeTOMLMCPConfig(filePath, name, config, options = {}) {
376
+ await withPrivateFileLock(
377
+ filePath,
378
+ async () => {
379
+ await options.beforeWrite?.();
380
+ let current = "";
381
+ try {
382
+ current = await fs.readFile(filePath, "utf8");
383
+ } catch (error) {
384
+ if (error.code !== "ENOENT") throw error;
385
+ }
386
+ const next = removeTOMLServer(current, name).trimEnd();
387
+ const block = tomlServerBlock(name, config);
388
+ const outputContent = `${next ? `${next}\n\n` : ""}${block}\n`;
389
+ parseTOMLDocument(outputContent, filePath);
390
+ await options.beforeWrite?.();
391
+ await writePrivateFile(filePath, outputContent, options);
392
+ },
393
+ options,
394
+ );
392
395
  }
393
396
 
394
- async function writePrivateFile(filePath, contents) {
395
- await fs.writeFile(filePath, contents, { mode: 0o600 });
396
- await fs.chmod(filePath, 0o600);
397
+ async function writePrivateFile(filePath, contents, options = {}) {
398
+ await atomicWritePrivateFile(filePath, contents, options);
397
399
  }
398
400
 
399
401
  async function assertProjectConfigWritable(filePath, options = {}) {
400
402
  if (options.force) {
401
403
  return;
402
404
  }
403
- const tracked = await gitTrackedPath(filePath);
405
+ const tracked = await gitTrackedPath(filePath, options.projectDir || process.cwd());
404
406
  if (!tracked) {
405
407
  return;
406
408
  }
@@ -408,12 +410,12 @@ async function assertProjectConfigWritable(filePath, options = {}) {
408
410
  [
409
411
  `Refusing to write AIPERMISSION_API_TOKEN into tracked git file: ${tracked}`,
410
412
  "Use --print to copy the config manually, untrack/ignore that file, or rerun with --force if you intentionally accept commit risk.",
411
- ].join("\n")
413
+ ].join("\n"),
412
414
  );
413
415
  }
414
416
 
415
- async function protectGitIgnoredConfig(filePath) {
416
- const repository = await discoverGitRepository(process.cwd());
417
+ async function protectGitIgnoredConfig(filePath, startDir = process.cwd(), options = {}) {
418
+ const repository = await discoverGitRepository(startDir);
417
419
  if (!repository) {
418
420
  return {};
419
421
  }
@@ -421,30 +423,72 @@ async function protectGitIgnoredConfig(filePath) {
421
423
  if (relativePath.startsWith("../") || path.isAbsolute(relativePath)) {
422
424
  return {};
423
425
  }
424
- const excludePath = path.join(repository.gitDir, "info", "exclude");
425
- let current = "";
426
+ const excludePath = repository.excludePath;
427
+ const temporaryRelativePath = path.relative(repository.workTree, privateTemporaryIgnorePath(filePath)).split(path.sep).join("/");
428
+ const stagingRelativePath = path.relative(repository.workTree, privateStagingIgnorePath(filePath)).split(path.sep).join("/");
429
+ const lockRelativePath = path.relative(repository.workTree, privateLockPath(filePath)).split(path.sep).join("/");
430
+ const ignoreEntries = [
431
+ gitIgnoreLiteral(relativePath),
432
+ gitIgnoreWildcardPath(temporaryRelativePath),
433
+ gitIgnoreWildcardPath(stagingRelativePath),
434
+ gitIgnoreLiteral(lockRelativePath),
435
+ ];
426
436
  try {
427
- current = await fs.readFile(excludePath, "utf8");
437
+ await withPrivateFileLock(
438
+ excludePath,
439
+ async () => {
440
+ let current = "";
441
+ try {
442
+ current = await fs.readFile(excludePath, "utf8");
443
+ } catch (error) {
444
+ if (error.code !== "ENOENT") throw error;
445
+ }
446
+ const entries = new Set(current.split(/\r?\n/));
447
+ const missingEntries = ignoreEntries.filter((entry) => !entries.has(entry));
448
+ if (missingEntries.length === 0) return;
449
+ const prefix = current && !current.endsWith("\n") ? "\n" : "";
450
+ await atomicWritePrivateFile(excludePath, `${current}${prefix}${missingEntries.join("\n")}\n`, {
451
+ trustedRoot: path.dirname(excludePath),
452
+ });
453
+ },
454
+ { trustedRoot: path.dirname(excludePath) },
455
+ );
456
+ await assertGitIgnored(repository, [
457
+ ...(options.allowTracked ? [] : [relativePath]),
458
+ privateTemporaryCheckPath(filePath, repository.workTree),
459
+ privateStagingCheckPath(filePath, repository.workTree),
460
+ lockRelativePath,
461
+ ]);
428
462
  } catch (error) {
429
- if (error.code !== "ENOENT") {
430
- return {};
431
- }
463
+ throw new Error(`Could not protect MCP config with local Git excludes: ${error.message}`, { cause: error });
432
464
  }
433
- const entries = current.split(/\r?\n/).map((line) => line.trim());
434
- if (!entries.includes(relativePath)) {
435
- const prefix = current && !current.endsWith("\n") ? "\n" : "";
436
- try {
437
- await fs.mkdir(path.dirname(excludePath), { recursive: true });
438
- await fs.appendFile(excludePath, `${prefix}${relativePath}\n`, { mode: 0o600 });
439
- } catch {
440
- return {};
441
- }
442
- }
443
- return { gitExcluded: true, gitExcludeEntry: relativePath };
465
+ return {
466
+ gitExcluded: true,
467
+ gitExcludeEntry: relativePath,
468
+ gitExcludeTemporaryEntry: temporaryRelativePath,
469
+ gitExcludeStagingEntry: stagingRelativePath,
470
+ gitExcludeLockEntry: lockRelativePath,
471
+ };
444
472
  }
445
473
 
446
- async function gitTrackedPath(filePath) {
447
- const repository = await discoverGitRepository(process.cwd());
474
+ export async function inspectProjectConfigProtection(filePath, startDir = process.cwd()) {
475
+ const repository = await discoverGitRepository(startDir);
476
+ if (!repository) return { repository: false };
477
+ const relativePath = path.relative(repository.workTree, filePath).split(path.sep).join("/");
478
+ if (relativePath.startsWith("../") || path.isAbsolute(relativePath)) return { repository: false };
479
+ const tracked = await gitTrackedPath(filePath, startDir);
480
+ if (tracked) throw new Error(`MCP config is tracked by Git: ${tracked}`);
481
+ await assertGitIgnored(repository, [
482
+ relativePath,
483
+ privateTemporaryCheckPath(filePath, repository.workTree),
484
+ privateStagingCheckPath(filePath, repository.workTree),
485
+ path.relative(repository.workTree, privateLockPath(filePath)).split(path.sep).join("/"),
486
+ ]);
487
+ return { repository: true, relativePath };
488
+ }
489
+
490
+ async function gitTrackedPath(filePath, startDir = process.cwd()) {
491
+ const repository = await discoverGitRepository(startDir);
448
492
  if (!repository) {
449
493
  return "";
450
494
  }
@@ -453,44 +497,108 @@ async function gitTrackedPath(filePath) {
453
497
  return "";
454
498
  }
455
499
  try {
456
- await execFileAsync("git", ["-C", repository.workTree, "ls-files", "--error-unmatch", "--", relativePath], { windowsHide: true });
500
+ await execFileAsync("git", ["-C", repository.workTree, "ls-files", "--error-unmatch", "--", relativePath], {
501
+ windowsHide: true,
502
+ });
457
503
  return relativePath;
458
- } catch {
459
- return "";
504
+ } catch (error) {
505
+ if (error.code === 1) return "";
506
+ throw new Error(`Could not verify whether MCP config is tracked by Git: ${gitErrorMessage(error)}`, { cause: error });
460
507
  }
461
508
  }
462
509
 
463
510
  async function discoverGitRepository(startDir) {
464
511
  try {
465
- const { stdout } = await execFileAsync(
466
- "git",
467
- ["-C", path.resolve(startDir), "rev-parse", "--show-toplevel", "--absolute-git-dir"],
468
- { encoding: "utf8", windowsHide: true }
469
- );
512
+ const { stdout } = await execFileAsync("git", ["-C", path.resolve(startDir), "rev-parse", "--show-toplevel", "--absolute-git-dir"], {
513
+ encoding: "utf8",
514
+ windowsHide: true,
515
+ });
470
516
  const [workTree, gitDir] = stdout.trim().split(/\r?\n/);
471
517
  if (!workTree || !gitDir) {
472
518
  return null;
473
519
  }
474
- return { workTree: path.resolve(workTree), gitDir: path.resolve(gitDir) };
475
- } catch {
476
- return null;
520
+ const { stdout: excludeOutput } = await execFileAsync(
521
+ "git",
522
+ ["-C", path.resolve(startDir), "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"],
523
+ { encoding: "utf8", windowsHide: true },
524
+ );
525
+ const excludePath = excludeOutput.trim();
526
+ if (!excludePath) throw new Error("Git did not return an exclude path");
527
+ return { workTree: path.resolve(workTree), gitDir: path.resolve(gitDir), excludePath: path.resolve(excludePath) };
528
+ } catch (error) {
529
+ if (/not a git repository/i.test(`${error.stderr || ""}\n${error.message || ""}`)) return null;
530
+ throw new Error(`Could not inspect Git repository: ${gitErrorMessage(error)}`, { cause: error });
531
+ }
532
+ }
533
+
534
+ async function assertGitIgnored(repository, relativePaths) {
535
+ for (const relativePath of relativePaths) {
536
+ try {
537
+ await execFileAsync("git", ["-C", repository.workTree, "check-ignore", "-q", "--", relativePath], {
538
+ windowsHide: true,
539
+ });
540
+ } catch (error) {
541
+ if (error.code === 1) {
542
+ throw new Error(`Git still permits sensitive MCP path: ${relativePath}`, { cause: error });
543
+ }
544
+ throw new Error(`Could not verify local Git exclusion: ${gitErrorMessage(error)}`, { cause: error });
545
+ }
477
546
  }
478
547
  }
479
548
 
480
- function removeCodexServer(source, name) {
481
- const main = `[mcp_servers.${tomlKey(name)}]`;
482
- const env = `[mcp_servers.${tomlKey(name)}.env]`;
549
+ function privateTemporaryCheckPath(filePath, workTree) {
550
+ return path.relative(workTree, privateTemporaryPath(filePath, "git-check")).split(path.sep).join("/");
551
+ }
552
+
553
+ function privateStagingCheckPath(filePath, workTree) {
554
+ return path.relative(workTree, privateStagingPath(filePath, "git-check")).split(path.sep).join("/");
555
+ }
556
+
557
+ function escapeGitIgnoreFragment(value) {
558
+ let result = "";
559
+ for (const character of value) {
560
+ result += ["\\", "*", "?", "[", "]", "#", "!", " "].includes(character) ? `\\${character}` : character;
561
+ }
562
+ return result;
563
+ }
564
+
565
+ function gitIgnoreLiteral(relativePath) {
566
+ return `/${escapeGitIgnoreFragment(relativePath)}`;
567
+ }
568
+
569
+ function gitIgnoreWildcardPath(relativePath) {
570
+ const wildcardIndex = relativePath.lastIndexOf("*");
571
+ if (wildcardIndex < 0) throw new Error(`Git ignore wildcard path is missing its generated wildcard: ${relativePath}`);
572
+ return `/${escapeGitIgnoreFragment(relativePath.slice(0, wildcardIndex))}*${escapeGitIgnoreFragment(
573
+ relativePath.slice(wildcardIndex + 1),
574
+ )}`;
575
+ }
576
+
577
+ function gitErrorMessage(error) {
578
+ return String(error.stderr || error.message || error).trim();
579
+ }
580
+
581
+ function redactParseError(error, format = "JSON") {
582
+ error.message = `${format} parsing failed`;
583
+ error.stack = `${error.name || "Error"}: ${error.message}`;
584
+ return error;
585
+ }
586
+
587
+ function removeTOMLServer(source, name) {
588
+ if (!source.trim()) return source;
589
+ parseTOMLDocument(source, "existing TOML config");
483
590
  const lines = source.split(/\r?\n/);
484
591
  const kept = [];
485
592
  let skipping = false;
593
+ const scanState = { multiline: "" };
486
594
  for (const line of lines) {
487
- const trimmed = line.trim();
488
- const isHeader = trimmed.startsWith("[") && trimmed.endsWith("]");
489
- if (trimmed === main || trimmed === env) {
595
+ const header = scanTOMLHeader(line, scanState);
596
+ const selected = header?.[0] === "mcp_servers" && header?.[1] === name;
597
+ if (selected) {
490
598
  skipping = true;
491
599
  continue;
492
600
  }
493
- if (isHeader && skipping) {
601
+ if (header && skipping) {
494
602
  skipping = false;
495
603
  }
496
604
  if (!skipping) {
@@ -500,10 +608,57 @@ function removeCodexServer(source, name) {
500
608
  return kept.join("\n");
501
609
  }
502
610
 
503
- function codexServerBlock(name, config) {
611
+ function scanTOMLHeader(line, state) {
612
+ if (state.multiline) {
613
+ if (hasMultilineDelimiter(line, state.multiline)) state.multiline = "";
614
+ return null;
615
+ }
616
+ const trimmed = line.trimStart();
617
+ if (trimmed.startsWith("[") && !trimmed.startsWith("[[")) {
618
+ try {
619
+ return findMarkerPath(parseTOML(`${line}\n__aipermission_header_marker = true\n`));
620
+ } catch {
621
+ return null;
622
+ }
623
+ }
624
+ for (const delimiter of ['"""', "'''"]) {
625
+ if (!hasMultilineDelimiter(line, delimiter)) continue;
626
+ if ((line.split(delimiter).length - 1) % 2 === 1) state.multiline = delimiter;
627
+ break;
628
+ }
629
+ return null;
630
+ }
631
+
632
+ function hasMultilineDelimiter(line, delimiter) {
633
+ const comment = line.indexOf("#");
634
+ return (comment < 0 ? line : line.slice(0, comment)).includes(delimiter);
635
+ }
636
+
637
+ function findMarkerPath(value, pathParts = []) {
638
+ if (!value || typeof value !== "object") return null;
639
+ if (value.__aipermission_header_marker === true) return pathParts;
640
+ for (const [key, nested] of Object.entries(value)) {
641
+ const match = findMarkerPath(nested, [...pathParts, key]);
642
+ if (match) return match;
643
+ }
644
+ return null;
645
+ }
646
+
647
+ function parseTOMLDocument(contents, location) {
648
+ try {
649
+ return parseTOML(contents);
650
+ } catch (error) {
651
+ redactParseError(error, "TOML");
652
+ throw new Error(`Could not parse TOML config at ${location}; the existing file was left unchanged`, { cause: error });
653
+ }
654
+ }
655
+
656
+ function tomlServerBlock(name, config) {
657
+ const fields = Object.entries(config)
658
+ .filter(([key]) => key !== "env")
659
+ .map(([key, value]) => `${key} = ${tomlValue(value)}`);
504
660
  return `[mcp_servers.${tomlKey(name)}]
505
- command = ${tomlString(config.command)}
506
- args = [${config.args.map(tomlString).join(", ")}]
661
+ ${fields.join("\n")}
507
662
  enabled = true
508
663
 
509
664
  [mcp_servers.${tomlKey(name)}.env]
@@ -512,17 +667,38 @@ AIPERMISSION_API_URL = ${tomlString(config.env.AIPERMISSION_API_URL)}
512
667
  AIPERMISSION_API_TOKEN = ${tomlString(config.env.AIPERMISSION_API_TOKEN)}`;
513
668
  }
514
669
 
515
- function printCustomConfig(name, config) {
670
+ function printProviderConfig(name, provider, apiUrl, target) {
671
+ const baseConfig = buildMCPServerConfig({ apiUrl, token: "YOUR_TOKEN_HERE" });
672
+ const previewConfig = provider === "custom" ? baseConfig : adaptMCPServerConfig(provider, baseConfig);
516
673
  console.log("");
517
674
  console.log(`${color.bold}${color.cyan}Copy-paste config:${color.reset}`);
518
675
  console.log("");
519
- console.log(JSON.stringify({ mcpServers: { [name]: config } }, null, 2));
676
+ if (target?.format === "toml") {
677
+ console.log(tomlPreviewServerBlock(name, previewConfig));
678
+ return;
679
+ }
680
+ console.log(JSON.stringify({ [target?.rootKey || "mcpServers"]: { [name]: previewConfig } }, null, 2));
681
+ }
682
+
683
+ // Keep stdout rendering separate from the secret-bearing TOML writer.
684
+ function tomlPreviewServerBlock(name, config) {
685
+ const fields = Object.entries(config)
686
+ .filter(([key]) => key !== "env")
687
+ .map(([key, value]) => `${key} = ${tomlValue(value)}`);
688
+ return `[mcp_servers.${tomlKey(name)}]
689
+ ${fields.join("\n")}
690
+ enabled = true
691
+
692
+ [mcp_servers.${tomlKey(name)}.env]
693
+ NODE_ENV = "production"
694
+ AIPERMISSION_API_URL = ${tomlString(config.env.AIPERMISSION_API_URL)}
695
+ AIPERMISSION_API_TOKEN = "YOUR_TOKEN_HERE"`;
520
696
  }
521
697
 
522
- function printTokenConfigWarning() {
698
+ function printPlaceholderConfigNotice() {
523
699
  console.log("");
524
- console.log(`${color.yellow}Warning:${color.reset} the printed config contains an AIPermission bearer token.`);
525
- console.log(`${color.yellow}Keep it private and revoke the token if it is shared or committed.${color.reset}`);
700
+ console.log(`${color.yellow}Preview:${color.reset} the printed config uses YOUR_TOKEN_HERE and contains no bearer token.`);
701
+ console.log(`${color.yellow}Replace the placeholder through the client's private environment or config mechanism.${color.reset}`);
526
702
  }
527
703
 
528
704
  export function sanitizeName(value) {
@@ -533,6 +709,9 @@ export function sanitizeName(value) {
533
709
  if (!name) {
534
710
  throw new Error("MCP server name is required.");
535
711
  }
712
+ if (["__proto__", "prototype", "constructor"].includes(name.toLowerCase())) {
713
+ throw new Error("MCP server name is reserved.");
714
+ }
536
715
  return name;
537
716
  }
538
717
 
@@ -551,6 +730,13 @@ export function tomlString(value) {
551
730
  return JSON.stringify(String(value));
552
731
  }
553
732
 
733
+ function tomlValue(value) {
734
+ if (typeof value === "string") return tomlString(value);
735
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
736
+ if (Array.isArray(value)) return `[${value.map(tomlValue).join(", ")}]`;
737
+ throw new Error(`Unsupported TOML MCP config value: ${typeof value}`);
738
+ }
739
+
554
740
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
555
741
  await runInit(process.argv.slice(2));
556
742
  }