codexport 0.1.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +138 -29
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { homedir, platform } from "node:os";
11
11
  import path from "node:path";
12
12
  import { spawn } from "node:child_process";
13
13
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
14
- const VERSION = "0.1.9";
14
+ const VERSION = "0.3.0";
15
15
  const DEFAULT_PORT = 17342;
16
16
  const DEFAULT_TIMEOUT_MS = 5_000;
17
17
  const CODEXPORT_DIR = ".codexport";
@@ -21,6 +21,8 @@ const MCPS_LOCAL_FILE = "mcps.local.toml";
21
21
  const LAST_BUNDLE_FILE = "last-bundle.json";
22
22
  const CACHE_BUNDLE_FILE = "bundle.json";
23
23
  const APPLIED_FILES_FILE = "applied-files.json";
24
+ const MCP_MANIFEST_FILE = "mcp-manifest.json";
25
+ const MANAGED_MCP_PACKAGE = "codexport@latest";
24
26
  const INCLUDE_ROOTS = [
25
27
  "AGENTS.md",
26
28
  "RTK.md",
@@ -50,6 +52,11 @@ const EXCLUDE_PARTS = new Set([
50
52
  ".sqlite",
51
53
  ".sqlite3"
52
54
  ]);
55
+ const MCP_ENV_EXPORT_NAMES = [
56
+ "KAGI_API_KEY",
57
+ "KAGI_SESSION_TOKEN",
58
+ "KAGI_CLI_PROFILE"
59
+ ];
53
60
  class CliError extends Error {
54
61
  exitCode;
55
62
  details;
@@ -232,26 +239,37 @@ async function walkIncluded(root, absolute, files) {
232
239
  });
233
240
  }
234
241
  }
235
- function computeRevision(files) {
242
+ function computeRevision(files, sourceEnv = {}) {
236
243
  const normalized = files.map((file) => ({
237
244
  path: file.path,
238
245
  mode: file.mode,
239
246
  kind: file.kind,
240
247
  contentHash: sha256(Buffer.from(file.content, "base64"))
241
248
  }));
242
- return sha256(JSON.stringify(normalized));
249
+ return sha256(JSON.stringify({ files: normalized, sourceEnv }));
243
250
  }
244
251
  async function buildBundle(codexDir) {
245
252
  const files = await collectFiles(codexDir);
246
- const revision = computeRevision(files);
253
+ const sourceEnv = collectSourceEnv();
254
+ const revision = computeRevision(files, sourceEnv);
247
255
  return {
248
256
  version: 1,
249
257
  builtAt: new Date().toISOString(),
250
258
  sourceRoot: codexDir,
251
259
  revision,
252
- files
260
+ files,
261
+ sourceEnv
253
262
  };
254
263
  }
264
+ function collectSourceEnv() {
265
+ const sourceEnv = {};
266
+ for (const name of MCP_ENV_EXPORT_NAMES) {
267
+ const value = process.env[name];
268
+ if (value)
269
+ sourceEnv[name] = value;
270
+ }
271
+ return sourceEnv;
272
+ }
255
273
  async function saveMasterBundle(ctx, bundle) {
256
274
  await writeJsonAtomic(path.join(ctx.stateDir, LAST_BUNDLE_FILE), bundle);
257
275
  }
@@ -327,7 +345,7 @@ function verifyBundle(bundle) {
327
345
  if (bundle.version !== 1 || !Array.isArray(bundle.files)) {
328
346
  throw new CliError("Bundle has an unsupported format.", 1);
329
347
  }
330
- const actualRevision = computeRevision(bundle.files);
348
+ const actualRevision = computeRevision(bundle.files, bundle.sourceEnv ?? {});
331
349
  if (bundle.revision !== actualRevision) {
332
350
  throw new CliError(`Bundle revision mismatch. Expected ${bundle.revision}, computed ${actualRevision}.`, 1);
333
351
  }
@@ -361,8 +379,8 @@ function extractTomlTableNames(text, prefix) {
361
379
  }
362
380
  return names;
363
381
  }
364
- function mergeTomlText(canonical, localMcpText, localConfig, sourceRoot) {
365
- const expandedCanonical = expandPathVariables(rewritePortableConfig(canonical, sourceRoot), localConfig);
382
+ function mergeTomlText(canonical, localMcpText, localConfig, sourceRoot, sourceEnv = {}) {
383
+ const expandedCanonical = expandPathVariables(rewritePortableConfig(canonical, sourceRoot, sourceEnv), localConfig);
366
384
  if (!localMcpText?.trim())
367
385
  return expandedCanonical;
368
386
  const canonicalMcps = extractTomlTableNames(canonical, "mcp_servers");
@@ -374,7 +392,7 @@ function mergeTomlText(canonical, localMcpText, localConfig, sourceRoot) {
374
392
  }
375
393
  return `${expandedCanonical.trimEnd()}\n\n# Follower-local MCP overlay from ~/.codexport/mcps.local.toml\n${localMcpText.trim()}\n`;
376
394
  }
377
- function rewritePortableConfig(canonical, sourceRoot) {
395
+ function rewritePortableConfig(canonical, sourceRoot, sourceEnv = {}) {
378
396
  let parsed;
379
397
  try {
380
398
  parsed = parseTomlObject(canonical, "canonical config.toml");
@@ -393,10 +411,42 @@ function rewritePortableConfig(canonical, sourceRoot) {
393
411
  if (!rawServer || typeof rawServer !== "object" || Array.isArray(rawServer))
394
412
  continue;
395
413
  const server = rawServer;
396
- rewritePortableMcpServer(name, server, sourceRoot, sourceHome);
414
+ mergeSourceEnvForMcp(name, server, sourceEnv);
415
+ rewriteManagedMcpServer(name, server, sourceRoot, sourceHome);
397
416
  }
398
417
  return stringifyToml(parsed);
399
418
  }
419
+ function buildMcpManifest(canonical, sourceRoot, sourceEnv = {}) {
420
+ let parsed;
421
+ try {
422
+ parsed = parseTomlObject(canonical, "canonical config.toml");
423
+ }
424
+ catch {
425
+ return undefined;
426
+ }
427
+ const mcpServers = parsed.mcp_servers;
428
+ if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers))
429
+ return undefined;
430
+ const servers = {};
431
+ for (const [name, rawServer] of Object.entries(mcpServers)) {
432
+ if (!rawServer || typeof rawServer !== "object" || Array.isArray(rawServer))
433
+ continue;
434
+ const server = structuredClone(rawServer);
435
+ mergeSourceEnvForMcp(name, server, sourceEnv);
436
+ servers[name] = server;
437
+ }
438
+ return { version: 1, sourceRoot, sourceEnv, servers };
439
+ }
440
+ function mergeSourceEnvForMcp(name, server, sourceEnv) {
441
+ if (name !== "kagi-mcp")
442
+ return;
443
+ const env = server.env && typeof server.env === "object" && !Array.isArray(server.env) ? server.env : {};
444
+ for (const key of ["KAGI_API_KEY", "KAGI_SESSION_TOKEN", "KAGI_CLI_PROFILE"]) {
445
+ if (typeof env[key] !== "string" && sourceEnv[key])
446
+ env[key] = sourceEnv[key];
447
+ }
448
+ server.env = env;
449
+ }
400
450
  function rewritePortableTableKeys(table, sourceRoot, sourceHome) {
401
451
  const rewritten = {};
402
452
  for (const [key, value] of Object.entries(table)) {
@@ -412,25 +462,12 @@ function rewritePortableTableKeys(table, sourceRoot, sourceHome) {
412
462
  table[key] = value;
413
463
  }
414
464
  }
415
- function rewritePortableMcpServer(_name, server, sourceRoot, sourceHome) {
465
+ function rewriteManagedMcpServer(name, server, sourceRoot, sourceHome) {
416
466
  if (typeof server.url === "string")
417
467
  return;
418
- const command = typeof server.command === "string" ? server.command : undefined;
419
468
  const args = Array.isArray(server.args) ? server.args : [];
420
- const launcher = command && mcpHasRequiredPortableEnv(_name, command, server) ? portableMcpLauncher(_name, command, args, sourceHome) : undefined;
421
- if (launcher) {
422
- server.command = launcher.command;
423
- server.args = launcher.args.map((arg) => rewritePortablePath(arg, sourceRoot, sourceHome));
424
- }
425
- else if (command && isAbsoluteAnyPlatform(command)) {
426
- server.enabled = false;
427
- }
428
- else if (command) {
429
- server.command = rewritePortableCommand(command, sourceRoot);
430
- }
431
- if (!launcher && args.length) {
432
- server.args = args.map((arg) => typeof arg === "string" ? rewritePortablePath(arg, sourceRoot, sourceHome) : arg);
433
- }
469
+ server.command = "npx";
470
+ server.args = ["-y", MANAGED_MCP_PACKAGE, "mcp", "run", name];
434
471
  if (server.env && typeof server.env === "object" && !Array.isArray(server.env)) {
435
472
  for (const [key, value] of Object.entries(server.env)) {
436
473
  if (typeof value === "string") {
@@ -442,11 +479,20 @@ function rewritePortableMcpServer(_name, server, sourceRoot, sourceHome) {
442
479
  ensurePortablePathEnv(server);
443
480
  }
444
481
  }
445
- function portableMcpLauncher(name, command, args, sourceHome) {
482
+ function portableMcpLauncher(name, command, args, sourceHome, server) {
446
483
  const commandName = basenameAnyPlatform(command);
447
484
  if (commandName === "npx" || commandName === "bunx" || commandName === "uvx") {
448
485
  return allStrings(args) ? { command: commandName, args: args } : undefined;
449
486
  }
487
+ if (name === "kagi-mcp" || commandName === "kagi-mcp") {
488
+ const env = server.env && typeof server.env === "object" && !Array.isArray(server.env) ? server.env : {};
489
+ if (typeof env.KAGI_API_KEY === "string" && env.KAGI_API_KEY.length > 0) {
490
+ return { command: "npx", args: ["-y", "kagi-mcp"] };
491
+ }
492
+ if (typeof env.KAGI_SESSION_TOKEN === "string" && env.KAGI_SESSION_TOKEN.length > 0) {
493
+ return { command: "npx", args: ["-y", "kagi-cli", "mcp"] };
494
+ }
495
+ }
450
496
  const nodePackage = nodePackageFromServer(command, args) ?? workspacePackageFromServer(command, args, sourceHome);
451
497
  if (nodePackage) {
452
498
  return { command: "npx", args: ["-y", nodePackage.packageName, ...nodePackage.remainingArgs] };
@@ -462,7 +508,8 @@ function mcpHasRequiredPortableEnv(name, command, server) {
462
508
  if (name !== "kagi-mcp" && basenameAnyPlatform(command) !== "kagi-mcp")
463
509
  return true;
464
510
  const env = server.env && typeof server.env === "object" && !Array.isArray(server.env) ? server.env : undefined;
465
- return typeof env?.KAGI_API_KEY === "string" && env.KAGI_API_KEY.length > 0;
511
+ return (typeof env?.KAGI_API_KEY === "string" && env.KAGI_API_KEY.length > 0)
512
+ || (typeof env?.KAGI_SESSION_TOKEN === "string" && env.KAGI_SESSION_TOKEN.length > 0);
466
513
  }
467
514
  function ensurePortablePathEnv(server) {
468
515
  const env = server.env && typeof server.env === "object" && !Array.isArray(server.env) ? server.env : {};
@@ -654,7 +701,11 @@ async function applyBundle(ctx, bundle) {
654
701
  if (configEntry) {
655
702
  const canonicalConfig = decodeFile(configEntry).toString("utf8");
656
703
  const localMcpText = await readTextIfExists(path.join(ctx.stateDir, MCPS_LOCAL_FILE));
657
- const generated = mergeTomlText(canonicalConfig, localMcpText, { ...localConfig, codexDir: ctx.codexDir }, bundle.sourceRoot);
704
+ const manifest = buildMcpManifest(canonicalConfig, bundle.sourceRoot, bundle.sourceEnv);
705
+ if (manifest) {
706
+ await writeJsonAtomic(path.join(ctx.stateDir, MCP_MANIFEST_FILE), manifest);
707
+ }
708
+ const generated = mergeTomlText(canonicalConfig, localMcpText, { ...localConfig, codexDir: ctx.codexDir }, bundle.sourceRoot, bundle.sourceEnv);
658
709
  const configPath = path.join(ctx.codexDir, "config.toml");
659
710
  if (await pathExists(configPath)) {
660
711
  const backupPath = `${configPath}.codexport-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
@@ -671,6 +722,43 @@ async function applyBundle(ctx, bundle) {
671
722
  await writeLocalConfig(ctx, { ...localConfig, lastRevision: bundle.revision, codexDir: ctx.codexDir });
672
723
  await writeJsonAtomic(path.join(ctx.stateDir, APPLIED_FILES_FILE), bundle.files.map((file) => file.path));
673
724
  }
725
+ async function commandMcpRun(ctx, name) {
726
+ const manifest = await readJsonIfExists(path.join(ctx.stateDir, MCP_MANIFEST_FILE));
727
+ if (!manifest?.servers?.[name]) {
728
+ throw new CliError(`No managed MCP named ${name}. Run codexport sync --apply first.`, 1);
729
+ }
730
+ const localConfig = await readLocalConfig(ctx);
731
+ const sourceHome = inferHomeFromCodexDir(manifest.sourceRoot);
732
+ const server = structuredClone(manifest.servers[name]);
733
+ mergeSourceEnvForMcp(name, server, manifest.sourceEnv ?? {});
734
+ rewritePortableTableKeys(server, manifest.sourceRoot, sourceHome);
735
+ if (typeof server.url === "string") {
736
+ throw new CliError(`MCP ${name} is URL-based and should not be launched through codexport mcp run.`, 2);
737
+ }
738
+ const command = typeof server.command === "string" ? expandPathVariables(server.command, { ...localConfig, codexDir: ctx.codexDir }) : undefined;
739
+ const args = Array.isArray(server.args)
740
+ ? server.args.map((arg) => typeof arg === "string" ? expandPathVariables(rewritePortablePath(arg, manifest.sourceRoot, sourceHome), { ...localConfig, codexDir: ctx.codexDir }) : String(arg))
741
+ : [];
742
+ if (!command)
743
+ throw new CliError(`MCP ${name} has no command.`, 1);
744
+ const launcher = mcpHasRequiredPortableEnv(name, command, server)
745
+ ? portableMcpLauncher(name, command, args, sourceHome, server)
746
+ : undefined;
747
+ const runCommandName = launcher?.command ?? rewritePortableCommand(command, manifest.sourceRoot);
748
+ const runArgs = launcher?.args ?? args;
749
+ const childEnv = { ...process.env, ...portableServerEnv(server, manifest.sourceRoot, sourceHome, { ...localConfig, codexDir: ctx.codexDir }) };
750
+ await runCommandWithEnv(runCommandName, runArgs, childEnv);
751
+ }
752
+ function portableServerEnv(server, sourceRoot, sourceHome, localConfig) {
753
+ const env = server.env && typeof server.env === "object" && !Array.isArray(server.env) ? server.env : {};
754
+ const out = {};
755
+ for (const [key, value] of Object.entries(env)) {
756
+ if (typeof value === "string") {
757
+ out[key] = expandPathVariables(rewritePortablePath(value, sourceRoot, sourceHome), localConfig);
758
+ }
759
+ }
760
+ return out;
761
+ }
674
762
  async function copyDirectory(source, target) {
675
763
  await ensureDir(target);
676
764
  for (const entry of await readdir(source, { withFileTypes: true })) {
@@ -712,6 +800,23 @@ function runCommand(command, args) {
712
800
  });
713
801
  });
714
802
  }
803
+ function runCommandWithEnv(command, args, env) {
804
+ return new Promise((resolve, reject) => {
805
+ const child = spawn(command, args, { stdio: "inherit", env });
806
+ child.on("error", (error) => {
807
+ const message = error.code === "ENOENT"
808
+ ? `MCP launcher program not found: ${command}. Install it on this follower or add it to PATH.`
809
+ : asError(error).message;
810
+ reject(new CliError(message, 1));
811
+ });
812
+ child.on("exit", (code) => {
813
+ if (code === 0)
814
+ resolve();
815
+ else
816
+ reject(new CliError(`${command} ${args.join(" ")} exited with ${code}`, code ?? 1));
817
+ });
818
+ });
819
+ }
715
820
  async function installMasterService(ctx, port, dryRun) {
716
821
  const command = `${process.execPath} ${realpathSync(fileURLToPath(import.meta.url))} master serve --port ${port}`;
717
822
  if (platform() === "linux") {
@@ -979,6 +1084,10 @@ async function main(argv) {
979
1084
  program.command("apply")
980
1085
  .description("Apply the last staged bundle.")
981
1086
  .action(async (_options, command) => commandApply(contextFromCommand(command)));
1087
+ const mcp = program.command("mcp").description("Run managed follower MCP launchers.");
1088
+ mcp.command("run <name>")
1089
+ .description("Run a synced MCP through codexport's managed launcher.")
1090
+ .action(async (name, _options, command) => commandMcpRun(contextFromCommand(command), name));
982
1091
  const hook = program.command("hook").description("Manage follower Codex hooks.");
983
1092
  hook.command("install")
984
1093
  .description("Install a follower-only Codex SessionStart sync hook.")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexport",
3
- "version": "0.1.9",
3
+ "version": "0.3.0",
4
4
  "description": "sync a canonical Codex setup from one master machine to follower machines",
5
5
  "author": "Microck <contact@micr.dev>",
6
6
  "license": "MIT",