codexport 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -12,6 +12,8 @@
12
12
 
13
13
  the master serves a content-hashed bundle from its `~/.codex` directory. followers pin the master's fingerprint on join, fetch updates over a Tailscale-reachable HTTP address, and apply updates at Codex `SessionStart` through a short best-effort hook.
14
14
 
15
+ MCPs are exported as full definitions, including env needed by supported tools. command-based MCPs are written through a managed launcher, so followers run `npx -y codexport@latest mcp run <name>` and let `codexport` translate master-local paths into repairable package, Python tool, or source-built launchers.
16
+
15
17
  [npm](https://www.npmjs.com/package/codexport) | [github](https://github.com/Microck/codexport)
16
18
 
17
19
  ## why
@@ -21,6 +23,8 @@ if you keep a carefully tuned Codex setup on one machine and want the same defau
21
23
  - keep the master as the canonical Codex configuration source
22
24
  - let followers preserve local MCPs, local skills, trust entries, and path overrides
23
25
  - sync auth-bearing files through the private Tailscale path instead of a plaintext GitHub commit
26
+ - export every master MCP definition instead of dropping machine-local entries
27
+ - repair known MCP launchers on followers through npm, uvx, released binaries, or source builds
24
28
  - refresh followers at Codex session startup without interrupting active sessions
25
29
  - use content-hash revisions and pinned master fingerprints instead of blind file copies
26
30
 
@@ -113,6 +117,28 @@ present.
113
117
  runtime state such as logs, caches, sessions, history, compact handoffs, and
114
118
  SQLite databases is excluded.
115
119
 
120
+ ## MCP export and repair
121
+
122
+ all master MCP definitions are exported into `~/.codexport/mcp-manifest.json` on followers. generated command MCP entries in `~/.codex/config.toml` point at the managed launcher:
123
+
124
+ ```toml
125
+ [mcp_servers.example]
126
+ command = "npx"
127
+ args = [ "-y", "codexport@latest", "mcp", "run", "example" ]
128
+ ```
129
+
130
+ when Codex starts an MCP, `codexport mcp run` reads the original manifest entry, restores transferred environment values, rewrites master paths to follower paths, and chooses a runnable target:
131
+
132
+ | source shape | follower action |
133
+ | --- | --- |
134
+ | npm-backed MCPs | run with `npx -y <package>` |
135
+ | Python MCP tools | run with `uvx --from <package> <binary>` and install `uv` when missing |
136
+ | FFF MCP | download the matching upstream release binary when missing |
137
+ | GitQuarry MCP | build `gitquarry-mcp` from its public source when missing and repair `GITQUARRY_CLI_PATH` |
138
+ | URL MCPs | keep the URL config unchanged |
139
+
140
+ unsupported local binaries are still kept in the manifest and generated config. if a follower cannot repair one, startup fails with the missing tool and repair step instead of silently removing the MCP.
141
+
116
142
  ## local follower state
117
143
 
118
144
  follower-local state lives under `~/.codexport`:
@@ -150,6 +176,7 @@ the follower's `local.toml` before writing the generated `~/.codex/config.toml`.
150
176
  | `codexport follower join` | enroll a follower from a join link or explicit master URL |
151
177
  | `codexport sync` | fetch the latest master bundle |
152
178
  | `codexport apply` | apply the last staged bundle |
179
+ | `codexport mcp run <name>` | run or repair a synced command MCP from the follower manifest |
153
180
  | `codexport hook install` | install the follower-only Codex `SessionStart` hook |
154
181
  | `codexport status` | report role, master URL, fingerprint, revision, and reachability |
155
182
 
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.3.0";
14
+ const VERSION = "0.3.2";
15
15
  const DEFAULT_PORT = 17342;
16
16
  const DEFAULT_TIMEOUT_MS = 5_000;
17
17
  const CODEXPORT_DIR = ".codexport";
@@ -497,11 +497,52 @@ function portableMcpLauncher(name, command, args, sourceHome, server) {
497
497
  if (nodePackage) {
498
498
  return { command: "npx", args: ["-y", nodePackage.packageName, ...nodePackage.remainingArgs] };
499
499
  }
500
+ if (name === "qmd" || commandName === "qmd") {
501
+ const remainingArgs = allStrings(args) ? args : [];
502
+ return { command: "npx", args: ["-y", "-p", "@tobilu/qmd", "qmd", ...remainingArgs] };
503
+ }
500
504
  const npmPackage = npmPackageForPortableMcp(name, commandName);
501
505
  if (npmPackage) {
502
- const remainingArgs = allStrings(args) ? args : [];
506
+ const remainingArgs = packageLauncherArgs(commandName, args);
503
507
  return { command: "npx", args: ["-y", npmPackage, ...remainingArgs] };
504
508
  }
509
+ const uvTool = uvToolForPortableMcp(name, commandName);
510
+ if (uvTool) {
511
+ const remainingArgs = allStrings(args) ? args : [];
512
+ return {
513
+ command: "uvx",
514
+ args: ["--from", uvTool.packageName, uvTool.binaryName, ...remainingArgs],
515
+ repair: {
516
+ whenMissing: "uvx",
517
+ command: "__codexport_install_uv",
518
+ args: []
519
+ }
520
+ };
521
+ }
522
+ if (name === "fff" || commandName === "fff-mcp") {
523
+ const remainingArgs = allStrings(args) ? args : [];
524
+ return {
525
+ command: "fff-mcp",
526
+ args: remainingArgs,
527
+ repair: {
528
+ whenMissing: "fff-mcp",
529
+ command: "__codexport_install_fff_mcp",
530
+ args: []
531
+ }
532
+ };
533
+ }
534
+ if (name === "gitquarry-mcp" || commandName === "gitquarry-mcp") {
535
+ const remainingArgs = allStrings(args) ? args : [];
536
+ return {
537
+ command: "gitquarry-mcp",
538
+ args: remainingArgs,
539
+ repair: {
540
+ whenMissing: "gitquarry-mcp",
541
+ command: "cargo",
542
+ args: ["install", "--git", "https://github.com/Microck/gitquarry-mcp.git", "--locked"]
543
+ }
544
+ };
545
+ }
505
546
  return undefined;
506
547
  }
507
548
  function mcpHasRequiredPortableEnv(name, command, server) {
@@ -523,16 +564,43 @@ function ensurePortablePathEnv(server) {
523
564
  }
524
565
  function npmPackageForPortableMcp(name, commandName) {
525
566
  const knownPackages = {
567
+ "camofox-browser-mcp": "camofox-browser-mcp",
526
568
  "dora": "@butttons/dora",
569
+ "grep-app": "@247arjun/mcp-grep",
527
570
  "kagi-mcp": "kagi-mcp",
571
+ "keywords-everywhere": "mcp-keywords-everywhere",
572
+ "mcp-grep": "@247arjun/mcp-grep",
573
+ "mcp-vnc": "@hrrrsn/mcp-vnc",
528
574
  "opensrc-mcp": "opensrc-mcp",
529
575
  "opensrc-mcp-stdio": "opensrc-mcp",
530
576
  "perplexity-webui": "perplexity-webui-mcp",
531
577
  "perplexity-webui-mcp": "perplexity-webui-mcp",
532
- "reddit-mcp-buddy": "reddit-mcp-buddy"
578
+ "reddit-mcp-buddy": "reddit-mcp-buddy",
579
+ "xcodebuildmcp": "xcodebuildmcp"
533
580
  };
534
581
  return knownPackages[name] ?? knownPackages[commandName];
535
582
  }
583
+ function packageLauncherArgs(commandName, args) {
584
+ if (!allStrings(args))
585
+ return [];
586
+ const remainingArgs = args;
587
+ const [entrypoint, ...afterEntrypoint] = remainingArgs;
588
+ if ((commandName === "node" || commandName === "bun") && typeof entrypoint === "string") {
589
+ const normalized = normalizePathForCompare(entrypoint);
590
+ if (isAbsoluteAnyPlatform(entrypoint) || normalized.endsWith(".js") || normalized.endsWith(".mjs") || normalized.endsWith(".ts")) {
591
+ return afterEntrypoint;
592
+ }
593
+ }
594
+ return remainingArgs;
595
+ }
596
+ function uvToolForPortableMcp(name, commandName) {
597
+ const knownTools = {
598
+ "discord-py-self": { packageName: "discord-py-self-mcp", binaryName: "discord-py-self-mcp" },
599
+ "discord-py-self-mcp": { packageName: "discord-py-self-mcp", binaryName: "discord-py-self-mcp" },
600
+ "markitdown-mcp": { packageName: "markitdown-mcp", binaryName: "markitdown-mcp" }
601
+ };
602
+ return knownTools[name] ?? knownTools[commandName];
603
+ }
536
604
  function rewritePortableCommand(command, sourceRoot) {
537
605
  const sourceRelative = rewriteSourceRootPath(command, sourceRoot);
538
606
  if (sourceRelative !== command)
@@ -741,12 +809,15 @@ async function commandMcpRun(ctx, name) {
741
809
  : [];
742
810
  if (!command)
743
811
  throw new CliError(`MCP ${name} has no command.`, 1);
812
+ ensurePortablePathEnv(server);
744
813
  const launcher = mcpHasRequiredPortableEnv(name, command, server)
745
814
  ? portableMcpLauncher(name, command, args, sourceHome, server)
746
815
  : undefined;
747
816
  const runCommandName = launcher?.command ?? rewritePortableCommand(command, manifest.sourceRoot);
748
817
  const runArgs = launcher?.args ?? args;
749
818
  const childEnv = { ...process.env, ...portableServerEnv(server, manifest.sourceRoot, sourceHome, { ...localConfig, codexDir: ctx.codexDir }) };
819
+ await repairMcpLauncherIfNeeded(launcher, childEnv);
820
+ await repairGitquarryEnvIfNeeded(name, childEnv);
750
821
  await runCommandWithEnv(runCommandName, runArgs, childEnv);
751
822
  }
752
823
  function portableServerEnv(server, sourceRoot, sourceHome, localConfig) {
@@ -759,6 +830,133 @@ function portableServerEnv(server, sourceRoot, sourceHome, localConfig) {
759
830
  }
760
831
  return out;
761
832
  }
833
+ async function repairMcpLauncherIfNeeded(launcher, env) {
834
+ if (!launcher?.repair)
835
+ return;
836
+ if (await executableExists(launcher.repair.whenMissing, env))
837
+ return;
838
+ if (launcher.repair.command === "__codexport_install_fff_mcp") {
839
+ await installFffMcp(env);
840
+ }
841
+ else if (launcher.repair.command === "__codexport_install_uv") {
842
+ await installUv(env);
843
+ }
844
+ else {
845
+ await runCommandWithEnv(launcher.repair.command, launcher.repair.args, env);
846
+ }
847
+ if (!(await executableExists(launcher.repair.whenMissing, env))) {
848
+ throw new CliError(`MCP repair completed but ${launcher.repair.whenMissing} is still not on PATH.`, 1);
849
+ }
850
+ }
851
+ async function installUv(env) {
852
+ if (platform() === "win32") {
853
+ await runCommandWithEnv("powershell.exe", [
854
+ "-NoProfile",
855
+ "-ExecutionPolicy",
856
+ "Bypass",
857
+ "-Command",
858
+ "irm https://astral.sh/uv/install.ps1 | iex"
859
+ ], env);
860
+ }
861
+ else {
862
+ await runCommandWithEnv("sh", ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"], env);
863
+ }
864
+ const installHome = env.HOME ?? env.USERPROFILE ?? homedir();
865
+ const uvBinDir = platform() === "win32"
866
+ ? path.join(installHome, ".local", "bin")
867
+ : path.join(installHome, ".local", "bin");
868
+ const pathValue = env.PATH ?? "";
869
+ if (!pathValue.split(path.delimiter).includes(uvBinDir)) {
870
+ env.PATH = [uvBinDir, pathValue].filter(Boolean).join(path.delimiter);
871
+ }
872
+ }
873
+ async function installFffMcp(env) {
874
+ const target = fffReleaseTarget();
875
+ const releasesResponse = await fetch("https://api.github.com/repos/dmtrKovalenko/fff.nvim/releases");
876
+ if (!releasesResponse.ok) {
877
+ throw new CliError(`Failed to fetch FFF MCP releases: HTTP ${releasesResponse.status}.`, 1);
878
+ }
879
+ const releases = await releasesResponse.json();
880
+ const assetName = `fff-mcp-${target}${platform() === "win32" ? ".exe" : ""}`;
881
+ const release = releases.find((item) => item.assets?.some((asset) => asset.name === assetName));
882
+ const asset = release?.assets?.find((item) => item.name === assetName);
883
+ if (!asset?.browser_download_url) {
884
+ throw new CliError(`No FFF MCP release asset found for ${target}.`, 1);
885
+ }
886
+ const binaryResponse = await fetch(asset.browser_download_url);
887
+ if (!binaryResponse.ok) {
888
+ throw new CliError(`Failed to download ${assetName}: HTTP ${binaryResponse.status}.`, 1);
889
+ }
890
+ const installHome = env.HOME ?? env.USERPROFILE ?? homedir();
891
+ const installDir = path.join(installHome, ".local", "bin");
892
+ const binaryPath = path.join(installDir, platform() === "win32" ? "fff-mcp.exe" : "fff-mcp");
893
+ await ensureDir(installDir);
894
+ await writeFileReplacingExisting(binaryPath, Buffer.from(await binaryResponse.arrayBuffer()), { mode: 0o755 });
895
+ if (platform() !== "win32")
896
+ await chmod(binaryPath, 0o755);
897
+ const pathValue = env.PATH ?? "";
898
+ if (!pathValue.split(path.delimiter).includes(installDir)) {
899
+ env.PATH = [installDir, pathValue].filter(Boolean).join(path.delimiter);
900
+ }
901
+ }
902
+ function fffReleaseTarget() {
903
+ const os = platform();
904
+ const arch = process.arch;
905
+ if (os === "linux") {
906
+ if (arch === "x64")
907
+ return "x86_64-unknown-linux-musl";
908
+ if (arch === "arm64")
909
+ return "aarch64-unknown-linux-musl";
910
+ }
911
+ if (os === "darwin") {
912
+ if (arch === "x64")
913
+ return "x86_64-apple-darwin";
914
+ if (arch === "arm64")
915
+ return "aarch64-apple-darwin";
916
+ }
917
+ if (os === "win32") {
918
+ if (arch === "x64")
919
+ return "x86_64-pc-windows-msvc";
920
+ if (arch === "arm64")
921
+ return "aarch64-pc-windows-msvc";
922
+ }
923
+ throw new CliError(`Unsupported FFF MCP platform: ${os}/${arch}.`, 1);
924
+ }
925
+ async function repairGitquarryEnvIfNeeded(name, env) {
926
+ if (name !== "gitquarry-mcp")
927
+ return;
928
+ const current = env.GITQUARRY_CLI_PATH;
929
+ if (current && await executableExists(current, env))
930
+ return;
931
+ if (!(await executableExists("gitquarry", env))) {
932
+ await runCommandWithEnv("npm", ["install", "-g", "gitquarry"], env);
933
+ }
934
+ const resolved = await resolveExecutable("gitquarry", env);
935
+ if (!resolved) {
936
+ throw new CliError("MCP repair could not find gitquarry after installing the gitquarry npm package.", 1);
937
+ }
938
+ env.GITQUARRY_CLI_PATH = resolved;
939
+ }
940
+ async function executableExists(command, env) {
941
+ return Boolean(await resolveExecutable(command, env));
942
+ }
943
+ async function resolveExecutable(command, env) {
944
+ if (isAbsoluteAnyPlatform(command) || command.includes(path.sep) || command.includes("/") || command.includes("\\")) {
945
+ return await pathExists(command) ? command : undefined;
946
+ }
947
+ const pathValue = env.PATH ?? process.env.PATH ?? "";
948
+ const extensions = platform() === "win32"
949
+ ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")
950
+ : [""];
951
+ for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
952
+ for (const extension of extensions) {
953
+ const candidate = path.join(dir, `${command}${extension}`);
954
+ if (await pathExists(candidate))
955
+ return candidate;
956
+ }
957
+ }
958
+ return undefined;
959
+ }
762
960
  async function copyDirectory(source, target) {
763
961
  await ensureDir(target);
764
962
  for (const entry of await readdir(source, { withFileTypes: true })) {
@@ -1119,4 +1317,4 @@ if (isCliEntrypoint()) {
1119
1317
  process.exit(exitCode);
1120
1318
  });
1121
1319
  }
1122
- export { applyBundle, buildBundle, buildJoinLink, computeRevision, defaultContext, installHook, mergeTomlText, parseJoinLink, verifyBundle };
1320
+ export { applyBundle, buildBundle, buildJoinLink, computeRevision, defaultContext, installHook, mergeTomlText, parseJoinLink, portableMcpLauncher, verifyBundle };
package/docs/prd.md CHANGED
@@ -161,10 +161,20 @@ should make them portable through:
161
161
  - path variables such as home and workspace root
162
162
  - secret transfer over the Tailscale bundle
163
163
  - local overlays for follower-specific additions
164
+ - a managed follower launcher that can repair known package, Python, binary,
165
+ and source-built MCPs
164
166
 
165
167
  Canonical MCP names are reserved. Local MCPs may add new names. A same-name
166
168
  local MCP should fail by default unless an explicit override is configured.
167
169
 
170
+ The product goal is full export, not best-effort pruning. Command MCPs should
171
+ remain present on followers even when their master command path is local to the
172
+ master. Followers should route command MCPs through `codexport mcp run <name>`,
173
+ store the original definitions in `~/.codexport/mcp-manifest.json`, and let the
174
+ managed launcher translate or repair the runtime. If an MCP cannot be repaired,
175
+ the failure should be explicit and actionable rather than silently disabling the
176
+ MCP.
177
+
168
178
  ### Skills
169
179
 
170
180
  Master skills are canonical and synced by default.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexport",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",