@akms/mcp-ssh 0.1.7 → 0.1.8

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/lib/cli.cjs CHANGED
@@ -2,7 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  var stdio_js = require('@modelcontextprotocol/sdk/server/stdio.js');
5
- var fs6 = require('fs');
5
+ var fs7 = require('fs');
6
6
  var os5 = require('os');
7
7
  var path = require('path');
8
8
  var child_process = require('child_process');
@@ -11,12 +11,14 @@ var stream = require('stream');
11
11
  var string_decoder = require('string_decoder');
12
12
  var ssh2 = require('ssh2');
13
13
  var timers = require('timers');
14
+ var util = require('util');
15
+ var smolToml = require('smol-toml');
14
16
  var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
15
17
  var zod = require('zod');
16
18
 
17
19
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
18
20
 
19
- var fs6__default = /*#__PURE__*/_interopDefault(fs6);
21
+ var fs7__default = /*#__PURE__*/_interopDefault(fs7);
20
22
  var os5__default = /*#__PURE__*/_interopDefault(os5);
21
23
  var path__default = /*#__PURE__*/_interopDefault(path);
22
24
  var ssh2__default = /*#__PURE__*/_interopDefault(ssh2);
@@ -36,7 +38,7 @@ var CWD_MARKER = "__AKMS_SSH_CWD__";
36
38
  var MAX_DIRECTORY_ENTRIES = 1e3;
37
39
  var SERVER_NAME = "akms-mcp-ssh";
38
40
  var PACKAGE_NAME = "@akms/mcp-ssh";
39
- var SERVER_VERSION = "0.1.7";
41
+ var SERVER_VERSION = "0.1.8";
40
42
 
41
43
  // src/_defs/command-rules.ts
42
44
  var CRITICAL_PATH_PATTERN = new RegExp(
@@ -622,7 +624,7 @@ function findExecutable(name) {
622
624
  for (const directory of directories) {
623
625
  for (const suffix of suffixes) {
624
626
  const candidate = path__default.default.join(directory, `${name}${suffix}`);
625
- if (fs6__default.default.existsSync(candidate) == true && fs6__default.default.statSync(candidate).isFile() == true) {
627
+ if (fs7__default.default.existsSync(candidate) == true && fs7__default.default.statSync(candidate).isFile() == true) {
626
628
  return candidate;
627
629
  }
628
630
  }
@@ -763,7 +765,7 @@ function loadHostProfile(env = process.env) {
763
765
  if (keyPath != null) {
764
766
  const expandedKeyPath = expandHomePath(keyPath);
765
767
  profile.privateKeyPath = expandedKeyPath;
766
- if (fs6__default.default.existsSync(expandedKeyPath) == false) {
768
+ if (fs7__default.default.existsSync(expandedKeyPath) == false) {
767
769
  _logger.warn(`${SINGLE_HOST_ENV.KEY} does not exist; connections will fail`, {
768
770
  private_key_path: expandedKeyPath
769
771
  });
@@ -795,7 +797,7 @@ function loadHostProfile(env = process.env) {
795
797
  if (jumpKeyPath != null) {
796
798
  profile.jump.privateKeyPath = expandHomePath(jumpKeyPath);
797
799
  profile.jump.passphrase = readEnv(env, SINGLE_HOST_ENV.JUMP_PASSPHRASE);
798
- if (fs6__default.default.existsSync(profile.jump.privateKeyPath) == false) {
800
+ if (fs7__default.default.existsSync(profile.jump.privateKeyPath) == false) {
799
801
  _logger.warn(`${SINGLE_HOST_ENV.JUMP_KEY} does not exist; connections will fail`, {
800
802
  private_key_path: profile.jump.privateKeyPath
801
803
  });
@@ -1281,14 +1283,14 @@ function inspectRemotePath(remotePath, policy, access) {
1281
1283
  function toRealPath(inputPath) {
1282
1284
  const _logger = $logger.child({ context: "toRealPath" });
1283
1285
  try {
1284
- return fs6__default.default.realpathSync.native(inputPath);
1286
+ return fs7__default.default.realpathSync.native(inputPath);
1285
1287
  } catch (ex) {
1286
1288
  _logger.debug("path does not resolve, falling back to its parent directory", {
1287
1289
  reason: ex instanceof Error ? ex.name : "unknown"
1288
1290
  });
1289
1291
  }
1290
1292
  try {
1291
- const parentRealPath = fs6__default.default.realpathSync.native(path__default.default.dirname(inputPath));
1293
+ const parentRealPath = fs7__default.default.realpathSync.native(path__default.default.dirname(inputPath));
1292
1294
  return path__default.default.join(parentRealPath, path__default.default.basename(inputPath));
1293
1295
  } catch (ex) {
1294
1296
  _logger.debug("parent directory does not resolve either, comparing lexically", {
@@ -1380,7 +1382,7 @@ function readPrivateKey(label, keyPath) {
1380
1382
  return cached;
1381
1383
  }
1382
1384
  try {
1383
- const contents = fs6__default.default.readFileSync(keyPath);
1385
+ const contents = fs7__default.default.readFileSync(keyPath);
1384
1386
  PRIVATE_KEY_CACHE.set(keyPath, contents);
1385
1387
  return contents;
1386
1388
  } catch (ex) {
@@ -2335,7 +2337,7 @@ async function writeRemoteFile(connection, args) {
2335
2337
  }
2336
2338
  async function uploadFile(connection, localPath, remotePath) {
2337
2339
  requirePermittedPath(connection, remotePath, "write");
2338
- const localStats = fs6__default.default.statSync(localPath);
2340
+ const localStats = fs7__default.default.statSync(localPath);
2339
2341
  if (localStats.isFile() == false) {
2340
2342
  throw new Error(`local path is not a file: ${localPath}`);
2341
2343
  }
@@ -2372,7 +2374,7 @@ async function downloadFile(connection, remotePath, localPath) {
2372
2374
  });
2373
2375
  });
2374
2376
  });
2375
- const localStats = fs6__default.default.statSync(localPath);
2377
+ const localStats = fs7__default.default.statSync(localPath);
2376
2378
  const _logger = $logger.child({
2377
2379
  context: "downloadFile",
2378
2380
  profile: connection.profile.name,
@@ -3202,6 +3204,61 @@ async function runConnectionCheck(profile, output) {
3202
3204
  };
3203
3205
  }
3204
3206
 
3207
+ // src/modules/manage/copy-entry.ts
3208
+ async function copyEntryToClient(prompter, source, targets, name, entry) {
3209
+ const destinations = targets.filter((target) => target.id !== source.id);
3210
+ if (destinations.length === 0) {
3211
+ return;
3212
+ }
3213
+ for (; ; ) {
3214
+ const items = destinations.map((target) => ({
3215
+ value: target.id,
3216
+ key: target.id,
3217
+ label: target.label,
3218
+ hint: target.detected ? target.location : `${target.location} \xB7 not found on this machine`
3219
+ }));
3220
+ items.push({ separator: true }, { value: "back", key: "b", label: "Back" });
3221
+ const picked = await prompter.select(`Copy ${name} to which client?`, items, { cancelValue: "back" });
3222
+ const destination = destinations.find((target) => target.id === picked);
3223
+ if (destination == null) {
3224
+ return;
3225
+ }
3226
+ const exists = destination.hasEntry?.(name) ?? Object.hasOwn(destination.readEntries(), name);
3227
+ if (exists) {
3228
+ try {
3229
+ const overwrite = await prompter.askYesNo(
3230
+ `${name} already exists in ${destination.label} (${destination.location}). Overwrite it?`,
3231
+ false
3232
+ );
3233
+ if (overwrite == false) {
3234
+ prompter.write(" Not copied; the destination entry was kept.\n\n");
3235
+ return;
3236
+ }
3237
+ } catch (ex) {
3238
+ if (ex instanceof PromptBackError == false) {
3239
+ throw ex;
3240
+ }
3241
+ continue;
3242
+ }
3243
+ }
3244
+ const result = destination.writeEntry(name, {
3245
+ command: entry.command,
3246
+ args: [...entry.args],
3247
+ env: { ...entry.env }
3248
+ }, { replace: true });
3249
+ if (result.status === "written") {
3250
+ prompter.write(` ${prompter.paint.success(`Copied ${name} to ${destination.label}`)} ${prompter.paint.dim(result.detail)}
3251
+ `);
3252
+ prompter.write(prompter.paint.dim(" Restart the destination client (or reconnect it from its MCP menu) to pick up the change.\n\n"));
3253
+ } else {
3254
+ prompter.write(` Not copied: ${result.detail}
3255
+
3256
+ `);
3257
+ }
3258
+ return;
3259
+ }
3260
+ }
3261
+
3205
3262
  // src/modules/manage/entry.ts
3206
3263
  var MANAGED_ENV_NAMES = [
3207
3264
  SINGLE_HOST_ENV.HOST,
@@ -3432,7 +3489,7 @@ function findPrivateKeys() {
3432
3489
  const sshDirectory = path__default.default.join(os5__default.default.homedir(), ".ssh");
3433
3490
  let names = [];
3434
3491
  try {
3435
- names = fs6__default.default.readdirSync(sshDirectory);
3492
+ names = fs7__default.default.readdirSync(sshDirectory);
3436
3493
  } catch {
3437
3494
  return [];
3438
3495
  }
@@ -3444,14 +3501,14 @@ function findPrivateKeys() {
3444
3501
  const fullPath = path__default.default.join(sshDirectory, name);
3445
3502
  let head = "";
3446
3503
  try {
3447
- const stat = fs6__default.default.statSync(fullPath);
3504
+ const stat = fs7__default.default.statSync(fullPath);
3448
3505
  if (stat.isFile() == false || stat.size > MAX_KEY_FILE_BYTES) {
3449
3506
  continue;
3450
3507
  }
3451
- const descriptor = fs6__default.default.openSync(fullPath, "r");
3508
+ const descriptor = fs7__default.default.openSync(fullPath, "r");
3452
3509
  const buffer = Buffer.alloc(64);
3453
- const bytesRead = fs6__default.default.readSync(descriptor, buffer, 0, buffer.length, 0);
3454
- fs6__default.default.closeSync(descriptor);
3510
+ const bytesRead = fs7__default.default.readSync(descriptor, buffer, 0, buffer.length, 0);
3511
+ fs7__default.default.closeSync(descriptor);
3455
3512
  head = buffer.subarray(0, bytesRead).toString("utf8");
3456
3513
  } catch {
3457
3514
  continue;
@@ -3463,7 +3520,7 @@ function findPrivateKeys() {
3463
3520
  }
3464
3521
  let hint = "";
3465
3522
  try {
3466
- const publicKey = fs6__default.default.readFileSync(`${fullPath}.pub`, "utf8").trim().split(/\s+/);
3523
+ const publicKey = fs7__default.default.readFileSync(`${fullPath}.pub`, "utf8").trim().split(/\s+/);
3467
3524
  hint = publicKey[0].replace(/^ssh-/, "");
3468
3525
  if (publicKey.length > 2) {
3469
3526
  hint += ` \xB7 ${publicKey.slice(2).join(" ")}`;
@@ -3532,7 +3589,7 @@ async function askKeyPath(prompter, current) {
3532
3589
  }
3533
3590
  for (; ; ) {
3534
3591
  const typed = await prompter.askRequired("Private key path");
3535
- if (fs6__default.default.existsSync(expandHomePath(typed)) == true) {
3592
+ if (fs7__default.default.existsSync(expandHomePath(typed)) == true) {
3536
3593
  return typed;
3537
3594
  }
3538
3595
  prompter.write(` ${expandHomePath(typed)} does not exist.
@@ -4066,6 +4123,65 @@ async function runHostForm(prompter, output, options) {
4066
4123
  prompter.transient = previousTransient;
4067
4124
  }
4068
4125
  }
4126
+ function readExisting(filePath) {
4127
+ try {
4128
+ return fs7__default.default.readFileSync(filePath, "utf8");
4129
+ } catch (error) {
4130
+ if (error.code === "ENOENT") {
4131
+ return null;
4132
+ }
4133
+ throw error;
4134
+ }
4135
+ }
4136
+ function updateConfigFile(filePath, update) {
4137
+ let destination = filePath;
4138
+ let symbolicLink = false;
4139
+ try {
4140
+ symbolicLink = fs7__default.default.lstatSync(filePath).isSymbolicLink();
4141
+ } catch (error) {
4142
+ if (error.code !== "ENOENT") {
4143
+ throw error;
4144
+ }
4145
+ }
4146
+ if (symbolicLink) {
4147
+ destination = fs7__default.default.realpathSync(filePath);
4148
+ }
4149
+ const before = readExisting(destination);
4150
+ const updated = update(before ?? "");
4151
+ if (before != null) {
4152
+ fs7__default.default.accessSync(destination, fs7__default.default.constants.W_OK);
4153
+ }
4154
+ fs7__default.default.mkdirSync(path__default.default.dirname(destination), { recursive: true });
4155
+ const temporary = path__default.default.join(path__default.default.dirname(destination), `.${path__default.default.basename(destination)}.${crypto.randomUUID()}.tmp`);
4156
+ const mode = before == null ? 384 : fs7__default.default.statSync(destination).mode & 511;
4157
+ let descriptor;
4158
+ let ownsTemporary = false;
4159
+ try {
4160
+ descriptor = fs7__default.default.openSync(temporary, "wx", mode);
4161
+ ownsTemporary = true;
4162
+ fs7__default.default.writeFileSync(descriptor, updated, "utf8");
4163
+ fs7__default.default.fsyncSync(descriptor);
4164
+ fs7__default.default.closeSync(descriptor);
4165
+ descriptor = void 0;
4166
+ if (readExisting(destination) !== before) {
4167
+ throw new Error("The config changed while saving; reload it and try again.");
4168
+ }
4169
+ fs7__default.default.renameSync(temporary, destination);
4170
+ ownsTemporary = false;
4171
+ } finally {
4172
+ try {
4173
+ if (descriptor != null) {
4174
+ fs7__default.default.closeSync(descriptor);
4175
+ }
4176
+ } finally {
4177
+ if (ownsTemporary) {
4178
+ fs7__default.default.unlinkSync(temporary);
4179
+ }
4180
+ }
4181
+ }
4182
+ }
4183
+
4184
+ // src/modules/manage/targets/json-file.ts
4069
4185
  function toMcpServerEntry(value) {
4070
4186
  if (typeof value !== "object" || value == null) {
4071
4187
  return null;
@@ -4078,7 +4194,7 @@ function toMcpServerEntry(value) {
4078
4194
  if (Array.isArray(record.args) == true) {
4079
4195
  args = record.args.filter((item) => typeof item === "string");
4080
4196
  }
4081
- const env = {};
4197
+ const env = /* @__PURE__ */ Object.create(null);
4082
4198
  if (typeof record.env === "object" && record.env != null) {
4083
4199
  for (const [key, item] of Object.entries(record.env)) {
4084
4200
  if (typeof item === "string") {
@@ -4088,16 +4204,22 @@ function toMcpServerEntry(value) {
4088
4204
  }
4089
4205
  return { command: record.command, args, env };
4090
4206
  }
4091
- function readMcpServersJson(document) {
4092
- if (typeof document !== "object" || document == null || Array.isArray(document) == true) {
4207
+ function readRawMcpServersJson(document) {
4208
+ if (typeof document !== "object" || document == null || Array.isArray(document)) {
4093
4209
  throw new Error("the config file is not a JSON object");
4094
4210
  }
4095
- const entries = {};
4096
4211
  const servers = document.mcpServers;
4097
- if (typeof servers !== "object" || servers == null) {
4098
- return entries;
4212
+ if (servers === void 0) {
4213
+ return {};
4214
+ }
4215
+ if (typeof servers !== "object" || servers == null || Array.isArray(servers)) {
4216
+ throw new Error("mcpServers is not a JSON object");
4099
4217
  }
4100
- for (const [name, value] of Object.entries(servers)) {
4218
+ return servers;
4219
+ }
4220
+ function readMcpServersJson(document) {
4221
+ const entries = /* @__PURE__ */ Object.create(null);
4222
+ for (const [name, value] of Object.entries(readRawMcpServersJson(document))) {
4101
4223
  const entry = toMcpServerEntry(value);
4102
4224
  if (entry != null) {
4103
4225
  entries[name] = entry;
@@ -4105,11 +4227,11 @@ function readMcpServersJson(document) {
4105
4227
  }
4106
4228
  return entries;
4107
4229
  }
4108
- function readDocument(filePath) {
4109
- if (fs6__default.default.existsSync(filePath) == false) {
4230
+ function readDocument(filePath, contents) {
4231
+ if (contents == null && fs7__default.default.existsSync(filePath) == false) {
4110
4232
  return {};
4111
4233
  }
4112
- const text = fs6__default.default.readFileSync(filePath, "utf8");
4234
+ const text = contents ?? fs7__default.default.readFileSync(filePath, "utf8");
4113
4235
  if (text.trim() === "") {
4114
4236
  return {};
4115
4237
  }
@@ -4130,39 +4252,35 @@ function readDocument(filePath) {
4130
4252
  }
4131
4253
  function createJsonFileTarget(options) {
4132
4254
  const { id, label, filePath } = options;
4133
- function writeDocument(document) {
4134
- fs6__default.default.mkdirSync(path__default.default.dirname(filePath), { recursive: true });
4135
- fs6__default.default.writeFileSync(filePath, `${JSON.stringify(document, null, 4)}
4136
- `, "utf8");
4137
- }
4138
4255
  return {
4139
4256
  id,
4140
4257
  label,
4141
4258
  location: filePath,
4142
- detected: fs6__default.default.existsSync(filePath),
4259
+ detected: fs7__default.default.existsSync(filePath),
4143
4260
  readEntries() {
4144
4261
  const document = readDocument(filePath);
4145
4262
  return readMcpServersJson(document);
4146
4263
  },
4264
+ hasEntry(name) {
4265
+ return Object.hasOwn(readRawMcpServersJson(readDocument(filePath)), name);
4266
+ },
4147
4267
  writeEntry(name, entry) {
4148
- const document = readDocument(filePath);
4149
- let servers = document.mcpServers;
4150
- if (typeof servers !== "object" || servers == null || Array.isArray(servers) == true) {
4151
- servers = {};
4152
- }
4153
- servers[name] = entry;
4154
- document.mcpServers = servers;
4155
- writeDocument(document);
4268
+ updateConfigFile(filePath, (contents) => {
4269
+ const document = readDocument(filePath, contents);
4270
+ const servers = Object.assign(/* @__PURE__ */ Object.create(null), readRawMcpServersJson(document));
4271
+ servers[name] = entry;
4272
+ document.mcpServers = servers;
4273
+ return JSON.stringify(document, null, 4) + "\n";
4274
+ });
4156
4275
  return { status: "written", detail: filePath };
4157
4276
  },
4158
4277
  removeEntry(name) {
4159
- const document = readDocument(filePath);
4160
- const servers = document.mcpServers;
4161
- if (typeof servers !== "object" || servers == null || Array.isArray(servers) == true) {
4162
- return { status: "written", detail: filePath };
4163
- }
4164
- delete servers[name];
4165
- writeDocument(document);
4278
+ updateConfigFile(filePath, (contents) => {
4279
+ const document = readDocument(filePath, contents);
4280
+ const servers = readRawMcpServersJson(document);
4281
+ delete servers[name];
4282
+ return JSON.stringify(document, null, 4) + "\n";
4283
+ });
4166
4284
  return { status: "written", detail: filePath };
4167
4285
  }
4168
4286
  };
@@ -4214,11 +4332,11 @@ function runClaude(executable, args) {
4214
4332
  }
4215
4333
  return { status: "written", detail: CLAUDE_CODE_CONFIG_PATH };
4216
4334
  }
4217
- function readClaudeEntries() {
4218
- if (fs6__default.default.existsSync(CLAUDE_CODE_CONFIG_PATH) == false) {
4335
+ function readRawClaudeEntries() {
4336
+ if (fs7__default.default.existsSync(CLAUDE_CODE_CONFIG_PATH) == false) {
4219
4337
  return {};
4220
4338
  }
4221
- const text = fs6__default.default.readFileSync(CLAUDE_CODE_CONFIG_PATH, "utf8");
4339
+ const text = fs7__default.default.readFileSync(CLAUDE_CODE_CONFIG_PATH, "utf8");
4222
4340
  let document;
4223
4341
  try {
4224
4342
  document = JSON.parse(text);
@@ -4229,7 +4347,7 @@ function readClaudeEntries() {
4229
4347
  }
4230
4348
  throw new Error(`${CLAUDE_CODE_CONFIG_PATH} is not valid JSON: ${detail}`);
4231
4349
  }
4232
- return readMcpServersJson(document);
4350
+ return readRawMcpServersJson(document);
4233
4351
  }
4234
4352
  function createClaudeCodeTarget() {
4235
4353
  const executable = findExecutable("claude");
@@ -4237,28 +4355,35 @@ function createClaudeCodeTarget() {
4237
4355
  id: "claude-code",
4238
4356
  label: "Claude Code (user scope)",
4239
4357
  location: CLAUDE_CODE_CONFIG_PATH,
4240
- detected: executable != null || fs6__default.default.existsSync(CLAUDE_CODE_CONFIG_PATH) == true,
4241
- readEntries: readClaudeEntries,
4358
+ detected: executable != null || fs7__default.default.existsSync(CLAUDE_CODE_CONFIG_PATH) == true,
4359
+ readEntries() {
4360
+ return readMcpServersJson({ mcpServers: readRawClaudeEntries() });
4361
+ },
4362
+ hasEntry(name) {
4363
+ return Object.hasOwn(readRawClaudeEntries(), name);
4364
+ },
4242
4365
  writeEntry(name, entry) {
4243
4366
  if (executable == null) {
4244
4367
  return {
4245
4368
  status: "unavailable",
4246
- detail: `the claude CLI is not on PATH \u2014 run this yourself:
4247
- ${formatClaudeAddCommand(name, entry)}`
4369
+ detail: "the claude CLI is not on PATH \u2014 install it, or use Print to get a command to run yourself"
4248
4370
  };
4249
4371
  }
4250
- const previous = readClaudeEntries()[name];
4251
- if (previous != null) {
4372
+ const previousEntries = readRawClaudeEntries();
4373
+ const hadPrevious = Object.hasOwn(previousEntries, name);
4374
+ const previous = previousEntries[name];
4375
+ if (hadPrevious) {
4252
4376
  const removed = runClaude(executable, ["mcp", "remove", name, "-s", "user"]);
4253
4377
  if (removed.status === "unavailable") {
4254
4378
  return removed;
4255
4379
  }
4256
4380
  }
4257
4381
  const added = runClaude(executable, ["mcp", "add-json", name, toClaudeAddJsonPayload(entry), "-s", "user"]);
4258
- if (added.status === "unavailable" && previous != null) {
4259
- runClaude(executable, ["mcp", "add-json", name, toClaudeAddJsonPayload(previous), "-s", "user"]);
4382
+ if (added.status === "unavailable" && hadPrevious) {
4383
+ const restored = runClaude(executable, ["mcp", "add-json", name, JSON.stringify(previous), "-s", "user"]);
4384
+ const recovery = restored.status === "written" ? "the previous entry was restored" : `could not restore the previous entry: ${restored.detail}`;
4260
4385
  return { status: "unavailable", detail: `${added.detail}
4261
- the previous entry was restored` };
4386
+ ${recovery}` };
4262
4387
  }
4263
4388
  return added;
4264
4389
  },
@@ -4275,194 +4400,121 @@ function createClaudeCodeTarget() {
4275
4400
  };
4276
4401
  }
4277
4402
 
4278
- // src/modules/manage/targets/toml-subset.ts
4279
- var BARE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
4280
- var ANY_HEADER_PATTERN = /^\s*\[/;
4281
- var SERVER_HEADER_PATTERN = /^\s*\[\s*mcp_servers\s*\.\s*([A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*")\s*((?:\.[^\]]*)?)\]\s*(?:#.*)?$/;
4282
- var KEY_VALUE_PATTERN = /^\s*([A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*")\s*=\s*(.*)$/;
4283
- var OWNED_TABLE_KEYS = ["command", "args", "env"];
4284
- var TomlValueParser = class {
4285
- constructor(text) {
4286
- this.text = text;
4287
- this.position = 0;
4288
- }
4289
- parse() {
4290
- const value = this.parseValue();
4291
- return value;
4292
- }
4293
- skipWhitespace() {
4294
- while (this.position < this.text.length && /\s/.test(this.text[this.position]) == true) {
4295
- this.position += 1;
4296
- }
4297
- }
4298
- parseValue() {
4299
- this.skipWhitespace();
4300
- const current = this.text[this.position];
4301
- if (current === '"') {
4302
- return this.parseBasicString();
4303
- }
4304
- if (current === "'") {
4305
- return this.parseLiteralString();
4306
- }
4307
- if (current === "[") {
4308
- return this.parseArray();
4309
- }
4310
- if (current === "{") {
4311
- return this.parseInlineTable();
4312
- }
4313
- throw new Error(`unsupported toml value at offset ${this.position}`);
4314
- }
4315
- parseBasicString() {
4316
- this.position += 1;
4317
- let result = "";
4318
- while (this.position < this.text.length) {
4319
- const current = this.text[this.position];
4320
- if (current === '"') {
4321
- this.position += 1;
4322
- return result;
4323
- }
4324
- if (current !== "\\") {
4325
- result += current;
4326
- this.position += 1;
4327
- continue;
4328
- }
4329
- const escaped = this.text[this.position + 1];
4330
- this.position += 2;
4331
- if (escaped === "n") {
4332
- result += "\n";
4333
- } else if (escaped === "t") {
4334
- result += " ";
4335
- } else if (escaped === "r") {
4336
- result += "\r";
4337
- } else if (escaped === "u" || escaped === "U") {
4338
- let length = 4;
4339
- if (escaped === "U") {
4340
- length = 8;
4403
+ // src/modules/manage/targets/toml-lines.ts
4404
+ function lineState() {
4405
+ return { quote: null, multiline: false, depth: 0 };
4406
+ }
4407
+ function scanTomlLine(line, state) {
4408
+ for (let index = 0; index < line.length; index += 1) {
4409
+ const character = line[index];
4410
+ if (state.quote != null) {
4411
+ if (character === "\\" && state.quote === '"') {
4412
+ index += 1;
4413
+ } else if (character === state.quote) {
4414
+ if (!state.multiline) {
4415
+ state.quote = null;
4416
+ } else if (line.slice(index, index + 3) === state.quote.repeat(3)) {
4417
+ while (line[index + 1] === character) {
4418
+ index += 1;
4419
+ }
4420
+ state.quote = null;
4421
+ state.multiline = false;
4341
4422
  }
4342
- const hex = this.text.slice(this.position, this.position + length);
4343
- result += String.fromCodePoint(Number.parseInt(hex, 16));
4344
- this.position += length;
4345
- } else {
4346
- result += escaped;
4347
4423
  }
4424
+ continue;
4348
4425
  }
4349
- throw new Error("unterminated toml string");
4350
- }
4351
- parseLiteralString() {
4352
- const end = this.text.indexOf("'", this.position + 1);
4353
- if (end < 0) {
4354
- throw new Error("unterminated toml literal string");
4426
+ if (character === "#") {
4427
+ break;
4355
4428
  }
4356
- const result = this.text.slice(this.position + 1, end);
4357
- this.position = end + 1;
4358
- return result;
4359
- }
4360
- parseArray() {
4361
- this.position += 1;
4362
- const items = [];
4363
- for (; ; ) {
4364
- this.skipWhitespace();
4365
- if (this.position >= this.text.length) {
4366
- throw new Error("unterminated toml array");
4367
- }
4368
- if (this.text[this.position] === "]") {
4369
- this.position += 1;
4370
- return items;
4371
- }
4372
- items.push(this.parseValue());
4373
- this.skipWhitespace();
4374
- if (this.text[this.position] === ",") {
4375
- this.position += 1;
4429
+ if (character === '"' || character === "'") {
4430
+ state.quote = character;
4431
+ state.multiline = line.slice(index, index + 3) === character.repeat(3);
4432
+ if (state.multiline) {
4433
+ index += 2;
4376
4434
  }
4435
+ } else if (character === "[" || character === "{") {
4436
+ state.depth += 1;
4437
+ } else if (character === "]" || character === "}") {
4438
+ state.depth -= 1;
4377
4439
  }
4378
4440
  }
4379
- parseInlineTable() {
4380
- this.position += 1;
4381
- const table = {};
4382
- for (; ; ) {
4383
- this.skipWhitespace();
4384
- if (this.position >= this.text.length) {
4385
- throw new Error("unterminated toml inline table");
4386
- }
4387
- if (this.text[this.position] === "}") {
4388
- this.position += 1;
4389
- return table;
4390
- }
4391
- let key;
4392
- if (this.text[this.position] === '"') {
4393
- key = this.parseBasicString();
4394
- } else {
4395
- const match = /^[A-Za-z0-9_-]+/.exec(this.text.slice(this.position));
4396
- if (match == null) {
4397
- throw new Error(`invalid inline table key at offset ${this.position}`);
4398
- }
4399
- key = match[0];
4400
- this.position += key.length;
4401
- }
4402
- this.skipWhitespace();
4403
- if (this.text[this.position] !== "=") {
4404
- throw new Error(`expected '=' at offset ${this.position}`);
4405
- }
4406
- this.position += 1;
4407
- table[key] = this.parseValue();
4408
- this.skipWhitespace();
4409
- if (this.text[this.position] === ",") {
4410
- this.position += 1;
4411
- }
4412
- }
4441
+ }
4442
+ function isTable(value) {
4443
+ return value != null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
4444
+ }
4445
+ function parseConfigToml(text) {
4446
+ let document;
4447
+ try {
4448
+ document = smolToml.parse(text, { integersAsBigInt: true });
4449
+ } catch (error) {
4450
+ const location = error instanceof smolToml.TomlError ? ` at line ${error.line}, column ${error.column}` : "";
4451
+ throw new Error(`Invalid TOML${location}; fix the config before changing it.`);
4413
4452
  }
4414
- };
4415
- function unquoteKey(rawKey) {
4416
- if (rawKey.startsWith('"') == false) {
4417
- return rawKey;
4453
+ if (Object.hasOwn(document, "mcp_servers") && !isTable(document.mcp_servers)) {
4454
+ throw new Error("mcp_servers must be a TOML table.");
4418
4455
  }
4419
- const parsed = new TomlValueParser(rawKey).parse();
4420
- return parsed;
4456
+ return document;
4421
4457
  }
4422
- function bracketBalance(text) {
4423
- let balance = 0;
4424
- let quote = null;
4425
- for (let index = 0; index < text.length; index += 1) {
4426
- const current = text[index];
4427
- if (quote != null) {
4428
- if (current === "\\" && quote === '"') {
4429
- index += 1;
4430
- } else if (current === quote) {
4431
- quote = null;
4432
- }
4433
- continue;
4434
- }
4435
- if (current === '"' || current === "'") {
4436
- quote = current;
4437
- } else if (current === "#") {
4438
- break;
4439
- } else if (current === "[" || current === "{") {
4440
- balance += 1;
4441
- } else if (current === "]" || current === "}") {
4442
- balance -= 1;
4458
+ function readTomlEntries(text) {
4459
+ return readMcpServersJson({ mcpServers: parseConfigToml(text).mcp_servers ?? {} });
4460
+ }
4461
+ function hasTomlEntry(text, name) {
4462
+ return Object.hasOwn(parseConfigToml(text).mcp_servers ?? {}, name);
4463
+ }
4464
+ function normalize(document) {
4465
+ const servers = document.mcp_servers;
4466
+ if (servers == null) {
4467
+ return;
4468
+ }
4469
+ for (const server of Object.values(servers)) {
4470
+ if (isTable(server) && isTable(server.env) && Object.keys(server.env).length === 0) {
4471
+ delete server.env;
4443
4472
  }
4444
4473
  }
4445
- return balance;
4474
+ if (Object.keys(servers).length === 0) {
4475
+ delete document.mcp_servers;
4476
+ }
4446
4477
  }
4447
- function stripComment(text) {
4448
- let quote = null;
4449
- for (let index = 0; index < text.length; index += 1) {
4450
- const current = text[index];
4451
- if (quote != null) {
4452
- if (current === "\\" && quote === '"') {
4453
- index += 1;
4454
- } else if (current === quote) {
4455
- quote = null;
4456
- }
4457
- continue;
4458
- }
4459
- if (current === '"' || current === "'") {
4460
- quote = current;
4461
- } else if (current === "#") {
4462
- return text.slice(0, index);
4463
- }
4478
+ function verifyTomlEdit(before, after, name, entry) {
4479
+ const expected = parseConfigToml(before);
4480
+ const servers = expected.mcp_servers ?? /* @__PURE__ */ Object.create(null);
4481
+ if (entry == null) {
4482
+ delete servers[name];
4483
+ } else {
4484
+ const previous = Object.hasOwn(servers, name) && isTable(servers[name]) ? servers[name] : {};
4485
+ Object.defineProperty(servers, name, {
4486
+ value: Object.assign(/* @__PURE__ */ Object.create(null), previous, { command: entry.command, args: [...entry.args], env: Object.assign(/* @__PURE__ */ Object.create(null), entry.env) }),
4487
+ configurable: true,
4488
+ enumerable: true,
4489
+ writable: true
4490
+ });
4491
+ }
4492
+ expected.mcp_servers = servers;
4493
+ let actual;
4494
+ try {
4495
+ actual = parseConfigToml(after);
4496
+ } catch {
4497
+ throw new Error("Cannot safely edit this TOML layout. Use [mcp_servers.<name>] tables; nothing was written.");
4464
4498
  }
4465
- return text;
4499
+ normalize(expected);
4500
+ normalize(actual);
4501
+ if (!util.isDeepStrictEqual(expected, actual)) {
4502
+ throw new Error("Cannot safely edit this TOML layout without changing other settings; nothing was written.");
4503
+ }
4504
+ return after;
4505
+ }
4506
+
4507
+ // src/modules/manage/targets/toml-subset.ts
4508
+ var BARE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
4509
+ var ANY_HEADER_PATTERN = /^\s*\[/;
4510
+ var SERVER_HEADER_PATTERN = /^\s*\[\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')\s*\.\s*([A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')\s*((?:\.[^\]]*)?)\]\s*(?:#.*)?$/;
4511
+ var KEY_VALUE_PATTERN = /^\s*([A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')\s*=\s*(.*)$/;
4512
+ var OWNED_TABLE_KEYS = ["command", "args", "env"];
4513
+ function unquoteKey(rawKey) {
4514
+ if (rawKey.startsWith('"') || rawKey.startsWith("'")) {
4515
+ return parseConfigToml(`value = ${rawKey}`).value;
4516
+ }
4517
+ return rawKey;
4466
4518
  }
4467
4519
  function readStatements(body) {
4468
4520
  const statements = [];
@@ -4474,28 +4526,25 @@ function readStatements(body) {
4474
4526
  continue;
4475
4527
  }
4476
4528
  const start = index;
4477
- let rawValue = stripComment(match[2]);
4478
- let balance = bracketBalance(rawValue);
4529
+ const state = lineState();
4530
+ scanTomlLine(match[2], state);
4479
4531
  index += 1;
4480
- while (balance > 0 && index < body.length) {
4481
- const continuation = stripComment(body[index]);
4482
- rawValue += `
4483
- ${continuation}`;
4484
- balance += bracketBalance(continuation);
4532
+ while ((state.quote != null || state.depth > 0) && index < body.length) {
4533
+ scanTomlLine(body[index], state);
4485
4534
  index += 1;
4486
4535
  }
4487
- statements.push({ key: unquoteKey(match[1]), rawValue, start, end: index });
4536
+ statements.push({ key: unquoteKey(match[1]), start, end: index });
4488
4537
  }
4489
4538
  return statements;
4490
4539
  }
4491
4540
  function splitSections(lines) {
4492
4541
  const sections = [{ header: null, serverName: null, subPath: "", body: [] }];
4493
- let balance = 0;
4542
+ const state = lineState();
4494
4543
  for (const line of lines) {
4495
4544
  const current = sections[sections.length - 1];
4496
- if (balance > 0) {
4545
+ if (state.quote != null || state.depth > 0) {
4497
4546
  current.body.push(line);
4498
- balance += bracketBalance(stripComment(line));
4547
+ scanTomlLine(line, state);
4499
4548
  continue;
4500
4549
  }
4501
4550
  if (ANY_HEADER_PATTERN.test(line) == true) {
@@ -4510,73 +4559,18 @@ function splitSections(lines) {
4510
4559
  continue;
4511
4560
  }
4512
4561
  current.body.push(line);
4513
- const keyValueMatch = KEY_VALUE_PATTERN.exec(line);
4514
- if (keyValueMatch != null) {
4515
- balance = bracketBalance(stripComment(keyValueMatch[2]));
4516
- }
4562
+ scanTomlLine(line, state);
4517
4563
  }
4518
4564
  return sections;
4519
4565
  }
4520
- function parseStatementValue(statement) {
4521
- try {
4522
- const value = new TomlValueParser(statement.rawValue).parse();
4523
- return value;
4524
- } catch {
4525
- return null;
4526
- }
4566
+ function hasMcpServerToml(text, name) {
4567
+ return hasTomlEntry(text, name);
4527
4568
  }
4528
4569
  function parseMcpServersToml(text) {
4529
- const sections = splitSections(text.split(/\r?\n/));
4530
- const entries = {};
4531
- const envTables = {};
4532
- for (const section of sections) {
4533
- if (section.serverName == null) {
4534
- continue;
4535
- }
4536
- if (section.subPath === "env") {
4537
- const envTable = {};
4538
- for (const statement of readStatements(section.body)) {
4539
- const value = parseStatementValue(statement);
4540
- if (typeof value === "string") {
4541
- envTable[statement.key] = value;
4542
- }
4543
- }
4544
- envTables[section.serverName] = envTable;
4545
- continue;
4546
- }
4547
- if (section.subPath !== "") {
4548
- continue;
4549
- }
4550
- let command = null;
4551
- let args = [];
4552
- const env = {};
4553
- for (const statement of readStatements(section.body)) {
4554
- const value = parseStatementValue(statement);
4555
- if (statement.key === "command" && typeof value === "string") {
4556
- command = value;
4557
- } else if (statement.key === "args" && Array.isArray(value) == true) {
4558
- args = value.filter((item) => typeof item === "string");
4559
- } else if (statement.key === "env" && value != null && typeof value === "object" && Array.isArray(value) == false) {
4560
- for (const [key, item] of Object.entries(value)) {
4561
- if (typeof item === "string") {
4562
- env[key] = item;
4563
- }
4564
- }
4565
- }
4566
- }
4567
- if (command != null) {
4568
- entries[section.serverName] = { command, args, env };
4569
- }
4570
- }
4571
- for (const [name, envTable] of Object.entries(envTables)) {
4572
- if (entries[name] != null) {
4573
- Object.assign(entries[name].env, envTable);
4574
- }
4575
- }
4576
- return entries;
4570
+ return readTomlEntries(text);
4577
4571
  }
4578
4572
  function quoteTomlString(value) {
4579
- const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
4573
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
4580
4574
  return `"${escaped}"`;
4581
4575
  }
4582
4576
  function formatTomlKey(key) {
@@ -4620,6 +4614,11 @@ function joinSections(sections, eol) {
4620
4614
  return lines.join(eol);
4621
4615
  }
4622
4616
  function writeMcpServerToml(text, name, entry) {
4617
+ parseConfigToml(text);
4618
+ const updated = editMcpServerToml(text, name, entry);
4619
+ return verifyTomlEdit(text, updated, name, entry);
4620
+ }
4621
+ function editMcpServerToml(text, name, entry) {
4623
4622
  let eol = "\n";
4624
4623
  if (text.includes("\r\n") == true) {
4625
4624
  eol = "\r\n";
@@ -4692,34 +4691,36 @@ function writeMcpServerToml(text, name, entry) {
4692
4691
  var CODEX_CONFIG_PATH = path__default.default.join(os5__default.default.homedir(), ".codex", "config.toml");
4693
4692
  function createCodexTarget() {
4694
4693
  function readText() {
4695
- if (fs6__default.default.existsSync(CODEX_CONFIG_PATH) == false) {
4694
+ if (fs7__default.default.existsSync(CODEX_CONFIG_PATH) == false) {
4696
4695
  return "";
4697
4696
  }
4698
- return fs6__default.default.readFileSync(CODEX_CONFIG_PATH, "utf8");
4699
- }
4700
- function writeText(text) {
4701
- fs6__default.default.mkdirSync(path__default.default.dirname(CODEX_CONFIG_PATH), { recursive: true });
4702
- fs6__default.default.writeFileSync(CODEX_CONFIG_PATH, text, "utf8");
4703
- return { status: "written", detail: CODEX_CONFIG_PATH };
4697
+ return fs7__default.default.readFileSync(CODEX_CONFIG_PATH, "utf8");
4704
4698
  }
4705
4699
  return {
4706
4700
  id: "codex",
4707
4701
  label: "Codex CLI",
4708
4702
  location: CODEX_CONFIG_PATH,
4709
- detected: findExecutable("codex") != null || fs6__default.default.existsSync(CODEX_CONFIG_PATH) == true,
4703
+ detected: findExecutable("codex") != null || fs7__default.default.existsSync(CODEX_CONFIG_PATH) == true,
4710
4704
  readEntries() {
4711
4705
  const text = readText();
4712
4706
  return parseMcpServersToml(text);
4713
4707
  },
4714
- writeEntry(name, entry) {
4715
- const text = readText();
4716
- const updated = writeMcpServerToml(text, name, entry);
4717
- return writeText(updated);
4708
+ hasEntry(name) {
4709
+ return hasMcpServerToml(readText(), name);
4710
+ },
4711
+ writeEntry(name, entry, options) {
4712
+ updateConfigFile(CODEX_CONFIG_PATH, (original) => {
4713
+ let text = original;
4714
+ if (options?.replace) {
4715
+ text = writeMcpServerToml(text, name, null);
4716
+ }
4717
+ return writeMcpServerToml(text, name, entry);
4718
+ });
4719
+ return { status: "written", detail: CODEX_CONFIG_PATH };
4718
4720
  },
4719
4721
  removeEntry(name) {
4720
- const text = readText();
4721
- const updated = writeMcpServerToml(text, name, null);
4722
- return writeText(updated);
4722
+ updateConfigFile(CODEX_CONFIG_PATH, (text) => writeMcpServerToml(text, name, null));
4723
+ return { status: "written", detail: CODEX_CONFIG_PATH };
4723
4724
  }
4724
4725
  };
4725
4726
  }
@@ -4927,13 +4928,14 @@ function buildHostItems(names, entries) {
4927
4928
  };
4928
4929
  });
4929
4930
  }
4930
- async function manageHost(prompter, output, target, entries, name) {
4931
+ async function manageHost(prompter, output, target, targets, entries, name) {
4931
4932
  const action = await prompter.select(
4932
4933
  name,
4933
4934
  [
4934
4935
  { value: "edit", key: "e", label: "Edit", hint: "open its settings" },
4935
4936
  { value: "check", key: "c", label: "Check the connection", hint: "connect now and offer to pin the host key" },
4936
4937
  { value: "print", key: "p", label: "Print the entry", hint: "JSON, Codex TOML and a claude command, ready to paste" },
4938
+ ...targets.some((candidate) => candidate.id !== target.id) ? [{ value: "copy", key: "o", label: "Copy to another client", hint: "keep this entry and copy its settings" }] : [],
4937
4939
  { value: "remove", key: "r", label: "Remove", hint: `from ${target.label}` },
4938
4940
  { separator: true },
4939
4941
  { value: "back", key: "b", label: "Back" }
@@ -4952,6 +4954,10 @@ async function manageHost(prompter, output, target, entries, name) {
4952
4954
  printHost(prompter, name, entries[name]);
4953
4955
  return;
4954
4956
  }
4957
+ if (action === "copy") {
4958
+ await copyEntryToClient(prompter, target, targets, name, entries[name]);
4959
+ return;
4960
+ }
4955
4961
  if (action === "remove") {
4956
4962
  const remove = await prompter.askYesNo(`Remove ${name} from ${target.location}?`, false);
4957
4963
  if (remove == true) {
@@ -5053,7 +5059,7 @@ ${paint.bold(target.label)} ${paint.dim(target.location)}
5053
5059
  continue;
5054
5060
  }
5055
5061
  if (picked.startsWith(HOST_VALUE_PREFIX) == true) {
5056
- await manageHost(prompter, output, target, entries, picked.slice(HOST_VALUE_PREFIX.length));
5062
+ await manageHost(prompter, output, target, targets, entries, picked.slice(HOST_VALUE_PREFIX.length));
5057
5063
  }
5058
5064
  } catch (ex) {
5059
5065
  if (ex instanceof PromptClosedError) {
@@ -5506,7 +5512,7 @@ ${file.content}`);
5506
5512
  const { profile, session: openSession } = resolveProfile(context, { session });
5507
5513
  const resolvedLocalPath = expandHomePath(localPath);
5508
5514
  requirePermittedLocalPath(resolvedLocalPath, "read");
5509
- if (fs6__default.default.existsSync(resolvedLocalPath) == false) {
5515
+ if (fs7__default.default.existsSync(resolvedLocalPath) == false) {
5510
5516
  return textResult(`local file not found: ${resolvedLocalPath}`);
5511
5517
  }
5512
5518
  const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
@@ -5546,7 +5552,7 @@ ${file.content}`);
5546
5552
  const { profile, session: openSession } = resolveProfile(context, { session });
5547
5553
  const resolvedLocalPath = expandHomePath(localPath);
5548
5554
  requirePermittedLocalPath(resolvedLocalPath, "write");
5549
- fs6__default.default.mkdirSync(path__default.default.dirname(resolvedLocalPath), { recursive: true });
5555
+ fs7__default.default.mkdirSync(path__default.default.dirname(resolvedLocalPath), { recursive: true });
5550
5556
  const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
5551
5557
  return await downloadFile(connection, remotePath, resolvedLocalPath);
5552
5558
  });