codexport 0.2.0 → 0.3.1
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 +262 -21
- 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.
|
|
14
|
+
const VERSION = "0.3.1";
|
|
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",
|
|
@@ -410,10 +412,31 @@ function rewritePortableConfig(canonical, sourceRoot, sourceEnv = {}) {
|
|
|
410
412
|
continue;
|
|
411
413
|
const server = rawServer;
|
|
412
414
|
mergeSourceEnvForMcp(name, server, sourceEnv);
|
|
413
|
-
|
|
415
|
+
rewriteManagedMcpServer(name, server, sourceRoot, sourceHome);
|
|
414
416
|
}
|
|
415
417
|
return stringifyToml(parsed);
|
|
416
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
|
+
}
|
|
417
440
|
function mergeSourceEnvForMcp(name, server, sourceEnv) {
|
|
418
441
|
if (name !== "kagi-mcp")
|
|
419
442
|
return;
|
|
@@ -439,25 +462,12 @@ function rewritePortableTableKeys(table, sourceRoot, sourceHome) {
|
|
|
439
462
|
table[key] = value;
|
|
440
463
|
}
|
|
441
464
|
}
|
|
442
|
-
function
|
|
465
|
+
function rewriteManagedMcpServer(name, server, sourceRoot, sourceHome) {
|
|
443
466
|
if (typeof server.url === "string")
|
|
444
467
|
return;
|
|
445
|
-
const command = typeof server.command === "string" ? server.command : undefined;
|
|
446
468
|
const args = Array.isArray(server.args) ? server.args : [];
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
server.command = launcher.command;
|
|
450
|
-
server.args = launcher.args.map((arg) => rewritePortablePath(arg, sourceRoot, sourceHome));
|
|
451
|
-
}
|
|
452
|
-
else if (command && isAbsoluteAnyPlatform(command)) {
|
|
453
|
-
server.enabled = false;
|
|
454
|
-
}
|
|
455
|
-
else if (command) {
|
|
456
|
-
server.command = rewritePortableCommand(command, sourceRoot);
|
|
457
|
-
}
|
|
458
|
-
if (!launcher && args.length) {
|
|
459
|
-
server.args = args.map((arg) => typeof arg === "string" ? rewritePortablePath(arg, sourceRoot, sourceHome) : arg);
|
|
460
|
-
}
|
|
469
|
+
server.command = "npx";
|
|
470
|
+
server.args = ["-y", MANAGED_MCP_PACKAGE, "mcp", "run", name];
|
|
461
471
|
if (server.env && typeof server.env === "object" && !Array.isArray(server.env)) {
|
|
462
472
|
for (const [key, value] of Object.entries(server.env)) {
|
|
463
473
|
if (typeof value === "string") {
|
|
@@ -489,9 +499,46 @@ function portableMcpLauncher(name, command, args, sourceHome, server) {
|
|
|
489
499
|
}
|
|
490
500
|
const npmPackage = npmPackageForPortableMcp(name, commandName);
|
|
491
501
|
if (npmPackage) {
|
|
492
|
-
const remainingArgs =
|
|
502
|
+
const remainingArgs = packageLauncherArgs(commandName, args);
|
|
493
503
|
return { command: "npx", args: ["-y", npmPackage, ...remainingArgs] };
|
|
494
504
|
}
|
|
505
|
+
const uvTool = uvToolForPortableMcp(name, commandName);
|
|
506
|
+
if (uvTool) {
|
|
507
|
+
const remainingArgs = allStrings(args) ? args : [];
|
|
508
|
+
return {
|
|
509
|
+
command: "uvx",
|
|
510
|
+
args: ["--from", uvTool.packageName, uvTool.binaryName, ...remainingArgs],
|
|
511
|
+
repair: {
|
|
512
|
+
whenMissing: "uvx",
|
|
513
|
+
command: platform() === "win32" ? "py" : "python3",
|
|
514
|
+
args: ["-m", "pip", "install", "--user", "uv"]
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
if (name === "fff" || commandName === "fff-mcp") {
|
|
519
|
+
const remainingArgs = allStrings(args) ? args : [];
|
|
520
|
+
return {
|
|
521
|
+
command: "fff-mcp",
|
|
522
|
+
args: remainingArgs,
|
|
523
|
+
repair: {
|
|
524
|
+
whenMissing: "fff-mcp",
|
|
525
|
+
command: "__codexport_install_fff_mcp",
|
|
526
|
+
args: []
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
if (name === "gitquarry-mcp" || commandName === "gitquarry-mcp") {
|
|
531
|
+
const remainingArgs = allStrings(args) ? args : [];
|
|
532
|
+
return {
|
|
533
|
+
command: "gitquarry-mcp",
|
|
534
|
+
args: remainingArgs,
|
|
535
|
+
repair: {
|
|
536
|
+
whenMissing: "gitquarry-mcp",
|
|
537
|
+
command: "cargo",
|
|
538
|
+
args: ["install", "--git", "https://github.com/Microck/gitquarry-mcp.git", "--locked"]
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
}
|
|
495
542
|
return undefined;
|
|
496
543
|
}
|
|
497
544
|
function mcpHasRequiredPortableEnv(name, command, server) {
|
|
@@ -513,16 +560,44 @@ function ensurePortablePathEnv(server) {
|
|
|
513
560
|
}
|
|
514
561
|
function npmPackageForPortableMcp(name, commandName) {
|
|
515
562
|
const knownPackages = {
|
|
563
|
+
"camofox-browser-mcp": "camofox-browser-mcp",
|
|
516
564
|
"dora": "@butttons/dora",
|
|
565
|
+
"grep-app": "@247arjun/mcp-grep",
|
|
517
566
|
"kagi-mcp": "kagi-mcp",
|
|
567
|
+
"keywords-everywhere": "mcp-keywords-everywhere",
|
|
568
|
+
"mcp-grep": "@247arjun/mcp-grep",
|
|
569
|
+
"mcp-vnc": "@hrrrsn/mcp-vnc",
|
|
518
570
|
"opensrc-mcp": "opensrc-mcp",
|
|
519
571
|
"opensrc-mcp-stdio": "opensrc-mcp",
|
|
520
572
|
"perplexity-webui": "perplexity-webui-mcp",
|
|
521
573
|
"perplexity-webui-mcp": "perplexity-webui-mcp",
|
|
522
|
-
"
|
|
574
|
+
"qmd": "qmd-cli",
|
|
575
|
+
"reddit-mcp-buddy": "reddit-mcp-buddy",
|
|
576
|
+
"xcodebuildmcp": "xcodebuildmcp"
|
|
523
577
|
};
|
|
524
578
|
return knownPackages[name] ?? knownPackages[commandName];
|
|
525
579
|
}
|
|
580
|
+
function packageLauncherArgs(commandName, args) {
|
|
581
|
+
if (!allStrings(args))
|
|
582
|
+
return [];
|
|
583
|
+
const remainingArgs = args;
|
|
584
|
+
const [entrypoint, ...afterEntrypoint] = remainingArgs;
|
|
585
|
+
if ((commandName === "node" || commandName === "bun") && typeof entrypoint === "string") {
|
|
586
|
+
const normalized = normalizePathForCompare(entrypoint);
|
|
587
|
+
if (isAbsoluteAnyPlatform(entrypoint) || normalized.endsWith(".js") || normalized.endsWith(".mjs") || normalized.endsWith(".ts")) {
|
|
588
|
+
return afterEntrypoint;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return remainingArgs;
|
|
592
|
+
}
|
|
593
|
+
function uvToolForPortableMcp(name, commandName) {
|
|
594
|
+
const knownTools = {
|
|
595
|
+
"discord-py-self": { packageName: "discord-py-self-mcp", binaryName: "discord-py-self-mcp" },
|
|
596
|
+
"discord-py-self-mcp": { packageName: "discord-py-self-mcp", binaryName: "discord-py-self-mcp" },
|
|
597
|
+
"markitdown-mcp": { packageName: "markitdown-mcp", binaryName: "markitdown-mcp" }
|
|
598
|
+
};
|
|
599
|
+
return knownTools[name] ?? knownTools[commandName];
|
|
600
|
+
}
|
|
526
601
|
function rewritePortableCommand(command, sourceRoot) {
|
|
527
602
|
const sourceRelative = rewriteSourceRootPath(command, sourceRoot);
|
|
528
603
|
if (sourceRelative !== command)
|
|
@@ -691,6 +766,10 @@ async function applyBundle(ctx, bundle) {
|
|
|
691
766
|
if (configEntry) {
|
|
692
767
|
const canonicalConfig = decodeFile(configEntry).toString("utf8");
|
|
693
768
|
const localMcpText = await readTextIfExists(path.join(ctx.stateDir, MCPS_LOCAL_FILE));
|
|
769
|
+
const manifest = buildMcpManifest(canonicalConfig, bundle.sourceRoot, bundle.sourceEnv);
|
|
770
|
+
if (manifest) {
|
|
771
|
+
await writeJsonAtomic(path.join(ctx.stateDir, MCP_MANIFEST_FILE), manifest);
|
|
772
|
+
}
|
|
694
773
|
const generated = mergeTomlText(canonicalConfig, localMcpText, { ...localConfig, codexDir: ctx.codexDir }, bundle.sourceRoot, bundle.sourceEnv);
|
|
695
774
|
const configPath = path.join(ctx.codexDir, "config.toml");
|
|
696
775
|
if (await pathExists(configPath)) {
|
|
@@ -708,6 +787,147 @@ async function applyBundle(ctx, bundle) {
|
|
|
708
787
|
await writeLocalConfig(ctx, { ...localConfig, lastRevision: bundle.revision, codexDir: ctx.codexDir });
|
|
709
788
|
await writeJsonAtomic(path.join(ctx.stateDir, APPLIED_FILES_FILE), bundle.files.map((file) => file.path));
|
|
710
789
|
}
|
|
790
|
+
async function commandMcpRun(ctx, name) {
|
|
791
|
+
const manifest = await readJsonIfExists(path.join(ctx.stateDir, MCP_MANIFEST_FILE));
|
|
792
|
+
if (!manifest?.servers?.[name]) {
|
|
793
|
+
throw new CliError(`No managed MCP named ${name}. Run codexport sync --apply first.`, 1);
|
|
794
|
+
}
|
|
795
|
+
const localConfig = await readLocalConfig(ctx);
|
|
796
|
+
const sourceHome = inferHomeFromCodexDir(manifest.sourceRoot);
|
|
797
|
+
const server = structuredClone(manifest.servers[name]);
|
|
798
|
+
mergeSourceEnvForMcp(name, server, manifest.sourceEnv ?? {});
|
|
799
|
+
rewritePortableTableKeys(server, manifest.sourceRoot, sourceHome);
|
|
800
|
+
if (typeof server.url === "string") {
|
|
801
|
+
throw new CliError(`MCP ${name} is URL-based and should not be launched through codexport mcp run.`, 2);
|
|
802
|
+
}
|
|
803
|
+
const command = typeof server.command === "string" ? expandPathVariables(server.command, { ...localConfig, codexDir: ctx.codexDir }) : undefined;
|
|
804
|
+
const args = Array.isArray(server.args)
|
|
805
|
+
? server.args.map((arg) => typeof arg === "string" ? expandPathVariables(rewritePortablePath(arg, manifest.sourceRoot, sourceHome), { ...localConfig, codexDir: ctx.codexDir }) : String(arg))
|
|
806
|
+
: [];
|
|
807
|
+
if (!command)
|
|
808
|
+
throw new CliError(`MCP ${name} has no command.`, 1);
|
|
809
|
+
const launcher = mcpHasRequiredPortableEnv(name, command, server)
|
|
810
|
+
? portableMcpLauncher(name, command, args, sourceHome, server)
|
|
811
|
+
: undefined;
|
|
812
|
+
const runCommandName = launcher?.command ?? rewritePortableCommand(command, manifest.sourceRoot);
|
|
813
|
+
const runArgs = launcher?.args ?? args;
|
|
814
|
+
const childEnv = { ...process.env, ...portableServerEnv(server, manifest.sourceRoot, sourceHome, { ...localConfig, codexDir: ctx.codexDir }) };
|
|
815
|
+
await repairMcpLauncherIfNeeded(launcher, childEnv);
|
|
816
|
+
await repairGitquarryEnvIfNeeded(name, childEnv);
|
|
817
|
+
await runCommandWithEnv(runCommandName, runArgs, childEnv);
|
|
818
|
+
}
|
|
819
|
+
function portableServerEnv(server, sourceRoot, sourceHome, localConfig) {
|
|
820
|
+
const env = server.env && typeof server.env === "object" && !Array.isArray(server.env) ? server.env : {};
|
|
821
|
+
const out = {};
|
|
822
|
+
for (const [key, value] of Object.entries(env)) {
|
|
823
|
+
if (typeof value === "string") {
|
|
824
|
+
out[key] = expandPathVariables(rewritePortablePath(value, sourceRoot, sourceHome), localConfig);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
async function repairMcpLauncherIfNeeded(launcher, env) {
|
|
830
|
+
if (!launcher?.repair)
|
|
831
|
+
return;
|
|
832
|
+
if (await executableExists(launcher.repair.whenMissing, env))
|
|
833
|
+
return;
|
|
834
|
+
if (launcher.repair.command === "__codexport_install_fff_mcp") {
|
|
835
|
+
await installFffMcp(env);
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
await runCommandWithEnv(launcher.repair.command, launcher.repair.args, env);
|
|
839
|
+
}
|
|
840
|
+
if (!(await executableExists(launcher.repair.whenMissing, env))) {
|
|
841
|
+
throw new CliError(`MCP repair completed but ${launcher.repair.whenMissing} is still not on PATH.`, 1);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async function installFffMcp(env) {
|
|
845
|
+
const target = fffReleaseTarget();
|
|
846
|
+
const releasesResponse = await fetch("https://api.github.com/repos/dmtrKovalenko/fff.nvim/releases");
|
|
847
|
+
if (!releasesResponse.ok) {
|
|
848
|
+
throw new CliError(`Failed to fetch FFF MCP releases: HTTP ${releasesResponse.status}.`, 1);
|
|
849
|
+
}
|
|
850
|
+
const releases = await releasesResponse.json();
|
|
851
|
+
const assetName = `fff-mcp-${target}${platform() === "win32" ? ".exe" : ""}`;
|
|
852
|
+
const release = releases.find((item) => item.assets?.some((asset) => asset.name === assetName));
|
|
853
|
+
const asset = release?.assets?.find((item) => item.name === assetName);
|
|
854
|
+
if (!asset?.browser_download_url) {
|
|
855
|
+
throw new CliError(`No FFF MCP release asset found for ${target}.`, 1);
|
|
856
|
+
}
|
|
857
|
+
const binaryResponse = await fetch(asset.browser_download_url);
|
|
858
|
+
if (!binaryResponse.ok) {
|
|
859
|
+
throw new CliError(`Failed to download ${assetName}: HTTP ${binaryResponse.status}.`, 1);
|
|
860
|
+
}
|
|
861
|
+
const installHome = env.HOME ?? env.USERPROFILE ?? homedir();
|
|
862
|
+
const installDir = path.join(installHome, ".local", "bin");
|
|
863
|
+
const binaryPath = path.join(installDir, platform() === "win32" ? "fff-mcp.exe" : "fff-mcp");
|
|
864
|
+
await ensureDir(installDir);
|
|
865
|
+
await writeFileReplacingExisting(binaryPath, Buffer.from(await binaryResponse.arrayBuffer()), { mode: 0o755 });
|
|
866
|
+
if (platform() !== "win32")
|
|
867
|
+
await chmod(binaryPath, 0o755);
|
|
868
|
+
const pathValue = env.PATH ?? "";
|
|
869
|
+
if (!pathValue.split(path.delimiter).includes(installDir)) {
|
|
870
|
+
env.PATH = [installDir, pathValue].filter(Boolean).join(path.delimiter);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
function fffReleaseTarget() {
|
|
874
|
+
const os = platform();
|
|
875
|
+
const arch = process.arch;
|
|
876
|
+
if (os === "linux") {
|
|
877
|
+
if (arch === "x64")
|
|
878
|
+
return "x86_64-unknown-linux-musl";
|
|
879
|
+
if (arch === "arm64")
|
|
880
|
+
return "aarch64-unknown-linux-musl";
|
|
881
|
+
}
|
|
882
|
+
if (os === "darwin") {
|
|
883
|
+
if (arch === "x64")
|
|
884
|
+
return "x86_64-apple-darwin";
|
|
885
|
+
if (arch === "arm64")
|
|
886
|
+
return "aarch64-apple-darwin";
|
|
887
|
+
}
|
|
888
|
+
if (os === "win32") {
|
|
889
|
+
if (arch === "x64")
|
|
890
|
+
return "x86_64-pc-windows-msvc";
|
|
891
|
+
if (arch === "arm64")
|
|
892
|
+
return "aarch64-pc-windows-msvc";
|
|
893
|
+
}
|
|
894
|
+
throw new CliError(`Unsupported FFF MCP platform: ${os}/${arch}.`, 1);
|
|
895
|
+
}
|
|
896
|
+
async function repairGitquarryEnvIfNeeded(name, env) {
|
|
897
|
+
if (name !== "gitquarry-mcp")
|
|
898
|
+
return;
|
|
899
|
+
const current = env.GITQUARRY_CLI_PATH;
|
|
900
|
+
if (current && await executableExists(current, env))
|
|
901
|
+
return;
|
|
902
|
+
if (!(await executableExists("gitquarry", env))) {
|
|
903
|
+
await runCommandWithEnv("npm", ["install", "-g", "gitquarry"], env);
|
|
904
|
+
}
|
|
905
|
+
const resolved = await resolveExecutable("gitquarry", env);
|
|
906
|
+
if (!resolved) {
|
|
907
|
+
throw new CliError("MCP repair could not find gitquarry after installing the gitquarry npm package.", 1);
|
|
908
|
+
}
|
|
909
|
+
env.GITQUARRY_CLI_PATH = resolved;
|
|
910
|
+
}
|
|
911
|
+
async function executableExists(command, env) {
|
|
912
|
+
return Boolean(await resolveExecutable(command, env));
|
|
913
|
+
}
|
|
914
|
+
async function resolveExecutable(command, env) {
|
|
915
|
+
if (isAbsoluteAnyPlatform(command) || command.includes(path.sep) || command.includes("/") || command.includes("\\")) {
|
|
916
|
+
return await pathExists(command) ? command : undefined;
|
|
917
|
+
}
|
|
918
|
+
const pathValue = env.PATH ?? process.env.PATH ?? "";
|
|
919
|
+
const extensions = platform() === "win32"
|
|
920
|
+
? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")
|
|
921
|
+
: [""];
|
|
922
|
+
for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
|
|
923
|
+
for (const extension of extensions) {
|
|
924
|
+
const candidate = path.join(dir, `${command}${extension}`);
|
|
925
|
+
if (await pathExists(candidate))
|
|
926
|
+
return candidate;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return undefined;
|
|
930
|
+
}
|
|
711
931
|
async function copyDirectory(source, target) {
|
|
712
932
|
await ensureDir(target);
|
|
713
933
|
for (const entry of await readdir(source, { withFileTypes: true })) {
|
|
@@ -749,6 +969,23 @@ function runCommand(command, args) {
|
|
|
749
969
|
});
|
|
750
970
|
});
|
|
751
971
|
}
|
|
972
|
+
function runCommandWithEnv(command, args, env) {
|
|
973
|
+
return new Promise((resolve, reject) => {
|
|
974
|
+
const child = spawn(command, args, { stdio: "inherit", env });
|
|
975
|
+
child.on("error", (error) => {
|
|
976
|
+
const message = error.code === "ENOENT"
|
|
977
|
+
? `MCP launcher program not found: ${command}. Install it on this follower or add it to PATH.`
|
|
978
|
+
: asError(error).message;
|
|
979
|
+
reject(new CliError(message, 1));
|
|
980
|
+
});
|
|
981
|
+
child.on("exit", (code) => {
|
|
982
|
+
if (code === 0)
|
|
983
|
+
resolve();
|
|
984
|
+
else
|
|
985
|
+
reject(new CliError(`${command} ${args.join(" ")} exited with ${code}`, code ?? 1));
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
}
|
|
752
989
|
async function installMasterService(ctx, port, dryRun) {
|
|
753
990
|
const command = `${process.execPath} ${realpathSync(fileURLToPath(import.meta.url))} master serve --port ${port}`;
|
|
754
991
|
if (platform() === "linux") {
|
|
@@ -1016,6 +1253,10 @@ async function main(argv) {
|
|
|
1016
1253
|
program.command("apply")
|
|
1017
1254
|
.description("Apply the last staged bundle.")
|
|
1018
1255
|
.action(async (_options, command) => commandApply(contextFromCommand(command)));
|
|
1256
|
+
const mcp = program.command("mcp").description("Run managed follower MCP launchers.");
|
|
1257
|
+
mcp.command("run <name>")
|
|
1258
|
+
.description("Run a synced MCP through codexport's managed launcher.")
|
|
1259
|
+
.action(async (name, _options, command) => commandMcpRun(contextFromCommand(command), name));
|
|
1019
1260
|
const hook = program.command("hook").description("Manage follower Codex hooks.");
|
|
1020
1261
|
hook.command("install")
|
|
1021
1262
|
.description("Install a follower-only Codex SessionStart sync hook.")
|
|
@@ -1047,4 +1288,4 @@ if (isCliEntrypoint()) {
|
|
|
1047
1288
|
process.exit(exitCode);
|
|
1048
1289
|
});
|
|
1049
1290
|
}
|
|
1050
|
-
export { applyBundle, buildBundle, buildJoinLink, computeRevision, defaultContext, installHook, mergeTomlText, parseJoinLink, verifyBundle };
|
|
1291
|
+
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.
|