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 +27 -0
- package/dist/index.js +202 -4
- package/docs/prd.md +10 -0
- package/package.json +1 -1
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.
|
|
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 =
|
|
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.
|