@claudexor/cli 1.0.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/LICENSE +21 -0
- package/README.md +22 -0
- package/dist/args.d.ts +12 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +88 -0
- package/dist/args.js.map +1 -0
- package/dist/attachment-resolver.d.ts +11 -0
- package/dist/attachment-resolver.d.ts.map +1 -0
- package/dist/attachment-resolver.js +61 -0
- package/dist/attachment-resolver.js.map +1 -0
- package/dist/capabilities.d.ts +13 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +90 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/claudexord.d.ts +3 -0
- package/dist/claudexord.d.ts.map +1 -0
- package/dist/claudexord.js +564 -0
- package/dist/claudexord.js.map +1 -0
- package/dist/cli-io.d.ts +28 -0
- package/dist/cli-io.d.ts.map +1 -0
- package/dist/cli-io.js +37 -0
- package/dist/cli-io.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1459 -0
- package/dist/cli.js.map +1 -0
- package/dist/command-registry.d.ts +105 -0
- package/dist/command-registry.d.ts.map +1 -0
- package/dist/command-registry.js +516 -0
- package/dist/command-registry.js.map +1 -0
- package/dist/daemon-lifecycle.d.ts +18 -0
- package/dist/daemon-lifecycle.d.ts.map +1 -0
- package/dist/daemon-lifecycle.js +62 -0
- package/dist/daemon-lifecycle.js.map +1 -0
- package/dist/daemon-run.d.ts +86 -0
- package/dist/daemon-run.d.ts.map +1 -0
- package/dist/daemon-run.js +269 -0
- package/dist/daemon-run.js.map +1 -0
- package/dist/decision.d.ts +18 -0
- package/dist/decision.d.ts.map +1 -0
- package/dist/decision.js +46 -0
- package/dist/decision.js.map +1 -0
- package/dist/live.d.ts +26 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +311 -0
- package/dist/live.js.map +1 -0
- package/dist/mcp-runner.d.ts +41 -0
- package/dist/mcp-runner.d.ts.map +1 -0
- package/dist/mcp-runner.js +306 -0
- package/dist/mcp-runner.js.map +1 -0
- package/dist/ops-commands.d.ts +11 -0
- package/dist/ops-commands.d.ts.map +1 -0
- package/dist/ops-commands.js +358 -0
- package/dist/ops-commands.js.map +1 -0
- package/dist/orchestrate-options.d.ts +9 -0
- package/dist/orchestrate-options.d.ts.map +1 -0
- package/dist/orchestrate-options.js +16 -0
- package/dist/orchestrate-options.js.map +1 -0
- package/dist/orphan-reaper.d.ts +9 -0
- package/dist/orphan-reaper.d.ts.map +1 -0
- package/dist/orphan-reaper.js +97 -0
- package/dist/orphan-reaper.js.map +1 -0
- package/dist/orphan-sweeper.d.ts +7 -0
- package/dist/orphan-sweeper.d.ts.map +1 -0
- package/dist/orphan-sweeper.js +289 -0
- package/dist/orphan-sweeper.js.map +1 -0
- package/dist/plugins.d.ts +44 -0
- package/dist/plugins.d.ts.map +1 -0
- package/dist/plugins.js +1383 -0
- package/dist/plugins.js.map +1 -0
- package/dist/primary-output.d.ts +14 -0
- package/dist/primary-output.d.ts.map +1 -0
- package/dist/primary-output.js +34 -0
- package/dist/primary-output.js.map +1 -0
- package/dist/registry.d.ts +26 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +71 -0
- package/dist/registry.js.map +1 -0
- package/dist/release.d.ts +14 -0
- package/dist/release.d.ts.map +1 -0
- package/dist/release.js +49 -0
- package/dist/release.js.map +1 -0
- package/dist/repl.d.ts +26 -0
- package/dist/repl.d.ts.map +1 -0
- package/dist/repl.js +277 -0
- package/dist/repl.js.map +1 -0
- package/dist/review-command.d.ts +3 -0
- package/dist/review-command.d.ts.map +1 -0
- package/dist/review-command.js +83 -0
- package/dist/review-command.js.map +1 -0
- package/dist/reviewer-options.d.ts +18 -0
- package/dist/reviewer-options.d.ts.map +1 -0
- package/dist/reviewer-options.js +110 -0
- package/dist/reviewer-options.js.map +1 -0
- package/dist/run-options.d.ts +8 -0
- package/dist/run-options.d.ts.map +1 -0
- package/dist/run-options.js +50 -0
- package/dist/run-options.js.map +1 -0
- package/dist/run-secret-scan.d.ts +2 -0
- package/dist/run-secret-scan.d.ts.map +1 -0
- package/dist/run-secret-scan.js +5 -0
- package/dist/run-secret-scan.js.map +1 -0
- package/dist/settings-command.d.ts +3 -0
- package/dist/settings-command.d.ts.map +1 -0
- package/dist/settings-command.js +202 -0
- package/dist/settings-command.js.map +1 -0
- package/dist/settings-display.d.ts +3 -0
- package/dist/settings-display.d.ts.map +1 -0
- package/dist/settings-display.js +33 -0
- package/dist/settings-display.js.map +1 -0
- package/dist/settings-service.d.ts +15 -0
- package/dist/settings-service.d.ts.map +1 -0
- package/dist/settings-service.js +91 -0
- package/dist/settings-service.js.map +1 -0
- package/dist/setup-jobs.d.ts +28 -0
- package/dist/setup-jobs.d.ts.map +1 -0
- package/dist/setup-jobs.js +477 -0
- package/dist/setup-jobs.js.map +1 -0
- package/dist/spec.d.ts +84 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/spec.js +438 -0
- package/dist/spec.js.map +1 -0
- package/dist/trust-command.d.ts +3 -0
- package/dist/trust-command.d.ts.map +1 -0
- package/dist/trust-command.js +80 -0
- package/dist/trust-command.js.map +1 -0
- package/dist/trust-services.d.ts +7 -0
- package/dist/trust-services.d.ts.map +1 -0
- package/dist/trust-services.js +35 -0
- package/dist/trust-services.js.map +1 -0
- package/package.json +59 -0
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,1383 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { copyFileSync, existsSync, lstatSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { hostFallbackExamples, recoveryVerbs } from "./command-registry.js";
|
|
6
|
+
import { CLAUDEXOR_VERSION, ensureDir, sha256, userConfigDir, userHomeDir, writeJson, } from "@claudexor/util";
|
|
7
|
+
export const PLUGIN_HOSTS = ["cursor", "claude", "codex", "opencode"];
|
|
8
|
+
export const PLUGIN_TARGETS = [...PLUGIN_HOSTS, "all"];
|
|
9
|
+
export const PLUGIN_VERBS = ["install", "status", "doctor", "repair", "uninstall"];
|
|
10
|
+
const STATE_VERSION = 1;
|
|
11
|
+
const MARKER = "claudexor:managed host-plugin-lifecycle";
|
|
12
|
+
const MCP_NAME = "claudexor";
|
|
13
|
+
function managedComment(kind = "md") {
|
|
14
|
+
return kind === "js"
|
|
15
|
+
? `// ${MARKER}; version=${CLAUDEXOR_VERSION}\n`
|
|
16
|
+
: `<!-- ${MARKER}; version=${CLAUDEXOR_VERSION} -->\n`;
|
|
17
|
+
}
|
|
18
|
+
function jsonText(value) {
|
|
19
|
+
return JSON.stringify(value, null, 2) + "\n";
|
|
20
|
+
}
|
|
21
|
+
function generatedMcpEnv(runtime) {
|
|
22
|
+
return {
|
|
23
|
+
CLAUDEXOR_CONFIG_DIR: runtime.configDir,
|
|
24
|
+
// MANAGED is drift-detection CONTENT (versions the generated artifact
|
|
25
|
+
// text for status/doctor). PLUGIN_VERSION is ALSO read at runtime:
|
|
26
|
+
// `mcp serve` compares it with the CLI version and warns on stderr when
|
|
27
|
+
// the installed artifacts are stale (repair + host reload fixes it).
|
|
28
|
+
CLAUDEXOR_MANAGED: MARKER,
|
|
29
|
+
CLAUDEXOR_PLUGIN_VERSION: CLAUDEXOR_VERSION,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function mcpServers(runtime) {
|
|
33
|
+
return {
|
|
34
|
+
mcpServers: {
|
|
35
|
+
[MCP_NAME]: {
|
|
36
|
+
command: runtime.nodePath,
|
|
37
|
+
args: [runtime.cliPath, "mcp", "serve"],
|
|
38
|
+
env: generatedMcpEnv(runtime),
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function opencodeMcpEntry(runtime) {
|
|
44
|
+
return {
|
|
45
|
+
type: "local",
|
|
46
|
+
command: [runtime.nodePath, runtime.cliPath, "mcp", "serve"],
|
|
47
|
+
environment: generatedMcpEnv(runtime),
|
|
48
|
+
enabled: true,
|
|
49
|
+
// Tool-DISCOVERY timeout only (OpenCode semantics). Tool EXECUTION time
|
|
50
|
+
// is governed by OpenCode's global MCP execution timeout — long verbs
|
|
51
|
+
// (agent/best-of) can exceed it; the generated README and INTEGRATIONS.md
|
|
52
|
+
// carry the guidance (raise experimental.mcp_timeout, or prefer the CLI
|
|
53
|
+
// for long runs). The runId trailer keeps abandoned calls recoverable.
|
|
54
|
+
timeout: 5000,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** `claudexor inspect <runId>`, `follow <runId>`, ... — from the registry's recovery verbs. */
|
|
58
|
+
function recoveryVerbLine() {
|
|
59
|
+
const verbs = recoveryVerbs();
|
|
60
|
+
const parts = verbs.map((verb, i) => (i === 0 ? `\`claudexor ${verb} <runId>\`` : `\`${verb} <runId>\``));
|
|
61
|
+
return parts.length > 1 ? `${parts.slice(0, -1).join(", ")}, or ${parts[parts.length - 1]}` : (parts[0] ?? "");
|
|
62
|
+
}
|
|
63
|
+
function skillText(host) {
|
|
64
|
+
const namespace = host === "claude" ? "claudexor@skills-dir" : "claudexor";
|
|
65
|
+
return [
|
|
66
|
+
"---",
|
|
67
|
+
"name: claudexor",
|
|
68
|
+
"description: Use Claudexor for harness-agnostic planning, runs, races, and review through its local CLI/MCP bridge.",
|
|
69
|
+
"---",
|
|
70
|
+
managedComment().trimEnd(),
|
|
71
|
+
"# Claudexor",
|
|
72
|
+
"",
|
|
73
|
+
"Use Claudexor when a task benefits from a harness-agnostic control plane, cross-harness review, best-of-N race, or evidence-backed planning.",
|
|
74
|
+
"",
|
|
75
|
+
"Claudexor owns orchestration. This host integration is a bridge to the local CLI and MCP server; it does not add host-local business logic.",
|
|
76
|
+
"",
|
|
77
|
+
"Prefer these routes:",
|
|
78
|
+
"",
|
|
79
|
+
"- MCP tool `claudexor_status` to check available harnesses.",
|
|
80
|
+
"- MCP tool `claudexor_capabilities` for the full machine-readable catalog (harness health, modes, mutability matrix).",
|
|
81
|
+
"- MCP tool `claudexor_ask` for read-only answers.",
|
|
82
|
+
"- MCP tool `claudexor_explore` for bounded read-only research synthesis.",
|
|
83
|
+
"- MCP tool `claudexor_plan` for read-only implementation plans.",
|
|
84
|
+
"- MCP tool `claudexor_run` for a single agent run.",
|
|
85
|
+
"- MCP tool `claudexor_best_of` for best-of-N attempts.",
|
|
86
|
+
"- MCP tool `claudexor_create` for create-from-scratch runs.",
|
|
87
|
+
"- MCP tool `claudexor_orchestrate` for a typed orchestration plan (SUGGEST autonomy over MCP: it plans, it does not execute).",
|
|
88
|
+
"- MCP tools `claudexor_runs` / `claudexor_inspect` / `claudexor_apply_check` to recover a lost run handle (read-only).",
|
|
89
|
+
"",
|
|
90
|
+
"When the host cannot call MCP tools, ask the user to run the local CLI explicitly:",
|
|
91
|
+
"",
|
|
92
|
+
...hostFallbackExamples().map((example) => `\`${example}\``),
|
|
93
|
+
"",
|
|
94
|
+
"MCP support is one-shot and honest: tools return the final Claudexor output, not a live Claudexor thread. Use an explicit `repoPath` when the host cwd may not be the target project.",
|
|
95
|
+
"",
|
|
96
|
+
"Readiness semantics: a harness is usable only when `claudexor_status` reports it `ok` (doctor-backed). An installed binary, a stored key, or an auth file alone is NOT readiness — do not retry a degraded harness expecting different results; surface the doctor reasons instead.",
|
|
97
|
+
"",
|
|
98
|
+
"Host timeouts: mutating tools can run for many minutes and hosts often cap tool calls. The `runId:` trailer arrives in EVERY result — if the host times out, the run continues daemon-side; recover it with `claudexor_inspect` or the CLI.",
|
|
99
|
+
"",
|
|
100
|
+
`Mutating runs (agent/best-of/create) are daemon-tracked and end with a \`runId:\` trailer — use ${recoveryVerbLine()} for evidence, live progress, delivery, or unblocking a blocked run. Structured results carry \`applyEligibility\`: apply only when \`eligible\` is true; otherwise follow \`requiredAction\`.`,
|
|
101
|
+
"",
|
|
102
|
+
"Safety rules:",
|
|
103
|
+
"",
|
|
104
|
+
"- NEVER paste live credentials into prompts — every ingress hard-blocks secret-like values (typed `inline_secret_rejected`). Store keys with `claudexor secrets set` and reference them.",
|
|
105
|
+
"- NEVER auto-answer `claudexor decision` for a blocked run: risk acceptance (accept-risk/override) is the HUMAN operator's call. Report the blocked state and the decision options.",
|
|
106
|
+
"- Exit codes: 0 = success terminal; 1 = failed/blocked/cancelled run; 2 = usage error (unknown verb/flag/mode). `--json` errors come as `{ok:false, exitCode, error}` on stdout.",
|
|
107
|
+
"",
|
|
108
|
+
`Host namespace: ${namespace}`,
|
|
109
|
+
"",
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
|
112
|
+
function commandText(host) {
|
|
113
|
+
const frontmatter = (host === "claude" || host === "cursor" || host === "opencode"
|
|
114
|
+
? ["---", "description: Use Claudexor CLI/MCP for harness-agnostic coding workflows", "---"]
|
|
115
|
+
: []);
|
|
116
|
+
return [
|
|
117
|
+
...frontmatter,
|
|
118
|
+
managedComment().trimEnd(),
|
|
119
|
+
"Use Claudexor for this request when cross-harness planning, review, best-of-N race, or evidence-backed execution is useful.",
|
|
120
|
+
"",
|
|
121
|
+
"First prefer the available MCP tools named `claudexor_*`. If MCP tools are unavailable, tell the user the exact local CLI command to run, such as:",
|
|
122
|
+
"",
|
|
123
|
+
...hostFallbackExamples().map((example) => `- \`${example.replace('"..."', '"$ARGUMENTS"')}\``),
|
|
124
|
+
"",
|
|
125
|
+
"Do not claim live thread parity through MCP. Ask for an explicit repo path if the target project is ambiguous.",
|
|
126
|
+
"",
|
|
127
|
+
].filter((line, i, all) => line !== "" || all[i - 1] !== "").join("\n");
|
|
128
|
+
}
|
|
129
|
+
function readmeText(host) {
|
|
130
|
+
return [
|
|
131
|
+
managedComment().trimEnd(),
|
|
132
|
+
`# Claudexor ${host} integration`,
|
|
133
|
+
"",
|
|
134
|
+
`This directory is generated by \`claudexor plugin install ${host}\`.`,
|
|
135
|
+
"",
|
|
136
|
+
"It packages Claudexor instructions and MCP configuration for the host. All orchestration remains in the local Claudexor CLI and engine.",
|
|
137
|
+
"",
|
|
138
|
+
"Long-running MCP tools (claudexor_run, claudexor_best_of, claudexor_create) are daemon-tracked: every result carries a `runId:` trailer, so a call abandoned by a host timeout stays recoverable via `claudexor inspect <runId>` / `claudexor follow <runId>`.",
|
|
139
|
+
"",
|
|
140
|
+
].join("\n");
|
|
141
|
+
}
|
|
142
|
+
function manifest(kind) {
|
|
143
|
+
const description = `Claudexor control plane host integration (${MARKER})`;
|
|
144
|
+
if (kind === "cursor") {
|
|
145
|
+
// Cursor requires literal path ARRAYS for `commands`/`skills`, never glob strings,
|
|
146
|
+
// and `skills` must name the skill DIRECTORY (not its SKILL.md). Glob strings register
|
|
147
|
+
// nothing, which left the plugin loading empty on install. `displayName`/`publisher`
|
|
148
|
+
// are non-schema and dropped; the root `mcp.json` is auto-detected (kept for the test
|
|
149
|
+
// and as the explicit MCP pointer).
|
|
150
|
+
return jsonText({
|
|
151
|
+
name: "claudexor",
|
|
152
|
+
description,
|
|
153
|
+
version: CLAUDEXOR_VERSION,
|
|
154
|
+
author: { name: "Claudexor" },
|
|
155
|
+
commands: ["commands/claudexor.md"],
|
|
156
|
+
skills: ["skills/claudexor"],
|
|
157
|
+
mcpServers: "./mcp.json",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (kind === "codex") {
|
|
161
|
+
return jsonText({
|
|
162
|
+
name: "claudexor",
|
|
163
|
+
version: CLAUDEXOR_VERSION,
|
|
164
|
+
description,
|
|
165
|
+
author: { name: "Claudexor" },
|
|
166
|
+
skills: "./skills/",
|
|
167
|
+
mcpServers: "./.mcp.json",
|
|
168
|
+
interface: {
|
|
169
|
+
displayName: "Claudexor",
|
|
170
|
+
shortDescription: "Harness-agnostic coding through the local Claudexor CLI.",
|
|
171
|
+
longDescription: "Use Claudexor for local planning, runs, races, and review through generated skills and one-shot MCP tools.",
|
|
172
|
+
developerName: "Claudexor",
|
|
173
|
+
category: "Productivity",
|
|
174
|
+
capabilities: ["Productivity"],
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const base = {
|
|
179
|
+
name: "claudexor",
|
|
180
|
+
version: CLAUDEXOR_VERSION,
|
|
181
|
+
description,
|
|
182
|
+
interface: {
|
|
183
|
+
displayName: "Claudexor",
|
|
184
|
+
description: "Harness-agnostic coding through the local Claudexor CLI and MCP server.",
|
|
185
|
+
},
|
|
186
|
+
claudexor: {
|
|
187
|
+
managed: true,
|
|
188
|
+
marker: MARKER,
|
|
189
|
+
version: CLAUDEXOR_VERSION,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
if (kind === "claude") {
|
|
193
|
+
return jsonText({
|
|
194
|
+
...base,
|
|
195
|
+
author: { name: "Claudexor" },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return jsonText(base);
|
|
199
|
+
}
|
|
200
|
+
function opencodePluginText(runtime) {
|
|
201
|
+
const hint = "Use Claudexor MCP tools or run `claudexor plan/agent/best-of` when cross-harness orchestration, review, or evidence-backed execution is useful.";
|
|
202
|
+
return `${managedComment("js")}export const ClaudexorPlugin = async () => {\n const hint = ${JSON.stringify(hint)};\n return {\n \"experimental.chat.system.transform\": async (_input, output) => {\n if (!output || typeof output !== \"object\") return;\n const current = typeof output.system === \"string\" ? output.system : typeof output.prompt === \"string\" ? output.prompt : \"\";\n if (current.includes(\"Claudexor\")) return;\n if (typeof output.system === \"string\") output.system = output.system + \"\\n\\n\" + hint;\n else if (typeof output.prompt === \"string\") output.prompt = output.prompt + \"\\n\\n\" + hint;\n },\n };\n};\n\nexport default ClaudexorPlugin;\n\n// OpenCode hook note: uses experimental.chat.system.transform because OpenCode does not expose tui.prompt.append as a plugin hook.\n// MCP command: ${runtime.nodePath} ${runtime.cliPath} mcp serve\n`;
|
|
203
|
+
}
|
|
204
|
+
const HOST_DEFINITIONS = {
|
|
205
|
+
claude: {
|
|
206
|
+
host: "claude",
|
|
207
|
+
displayName: "Claude Code",
|
|
208
|
+
installState: "installed",
|
|
209
|
+
root: (home) => join(home, ".claude"),
|
|
210
|
+
artifacts: (home, runtime) => {
|
|
211
|
+
const root = join(home, ".claude", "skills", "claudexor");
|
|
212
|
+
return [
|
|
213
|
+
{ path: join(root, ".claude-plugin", "plugin.json"), content: manifest("claude"), description: "Claude plugin manifest" },
|
|
214
|
+
{ path: join(root, "skills", "claudexor", "SKILL.md"), content: skillText("claude"), description: "Claude skill" },
|
|
215
|
+
{ path: join(root, "commands", "claudexor.md"), content: commandText("claude"), description: "Claude command" },
|
|
216
|
+
{ path: join(root, ".mcp.json"), content: jsonText(mcpServers(runtime)), description: "Claude MCP config" },
|
|
217
|
+
{ path: join(root, "README.md"), content: readmeText("claude"), description: "Claude integration README" },
|
|
218
|
+
];
|
|
219
|
+
},
|
|
220
|
+
legacy: (home) => [
|
|
221
|
+
{
|
|
222
|
+
path: join(home, ".claude", "plugins", "claudexor"),
|
|
223
|
+
root: join(home, ".claude"),
|
|
224
|
+
expectedFiles: [".claude-plugin/plugin.json", "commands/claudexor.md"],
|
|
225
|
+
verifier: legacyThinShimVerifier,
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
reloadNote: "Start a new Claude Code session; skills-directory plugins auto-load from ~/.claude/skills.",
|
|
229
|
+
},
|
|
230
|
+
codex: {
|
|
231
|
+
host: "codex",
|
|
232
|
+
displayName: "Codex",
|
|
233
|
+
installState: "registered",
|
|
234
|
+
root: (home) => join(home, ".codex"),
|
|
235
|
+
artifacts: (home, runtime) => {
|
|
236
|
+
const root = join(home, ".codex", "plugins", "claudexor");
|
|
237
|
+
return [
|
|
238
|
+
{ path: join(root, ".codex-plugin", "plugin.json"), content: manifest("codex"), description: "Codex plugin manifest" },
|
|
239
|
+
{ path: join(root, "skills", "claudexor", "SKILL.md"), content: skillText("codex"), description: "Codex skill" },
|
|
240
|
+
{ path: join(root, ".mcp.json"), content: jsonText(mcpServers(runtime)), description: "Codex MCP config" },
|
|
241
|
+
{ path: join(root, "README.md"), content: readmeText("codex"), description: "Codex integration README" },
|
|
242
|
+
];
|
|
243
|
+
},
|
|
244
|
+
config: "codex-marketplace",
|
|
245
|
+
legacy: (home) => [
|
|
246
|
+
{
|
|
247
|
+
path: join(home, ".agents", "skills", "claudexor"),
|
|
248
|
+
root: join(home, ".agents"),
|
|
249
|
+
expectedFiles: ["SKILL.md"],
|
|
250
|
+
verifier: legacyCodexSkillVerifier,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
reloadNote: "Restart Codex, open Plugins, choose the personal marketplace, then install/enable Claudexor.",
|
|
254
|
+
},
|
|
255
|
+
cursor: {
|
|
256
|
+
host: "cursor",
|
|
257
|
+
displayName: "Cursor",
|
|
258
|
+
installState: "installed",
|
|
259
|
+
root: (home) => join(home, ".cursor"),
|
|
260
|
+
artifacts: (home, runtime) => {
|
|
261
|
+
const root = join(home, ".cursor", "plugins", "local", "claudexor");
|
|
262
|
+
return [
|
|
263
|
+
{ path: join(root, ".cursor-plugin", "plugin.json"), content: manifest("cursor"), description: "Cursor plugin manifest" },
|
|
264
|
+
{ path: join(root, "skills", "claudexor", "SKILL.md"), content: skillText("cursor"), description: "Cursor skill" },
|
|
265
|
+
{ path: join(root, "commands", "claudexor.md"), content: commandText("cursor"), description: "Cursor command" },
|
|
266
|
+
{ path: join(root, "mcp.json"), content: jsonText(mcpServers(runtime)), description: "Cursor MCP config" },
|
|
267
|
+
{ path: join(root, "README.md"), content: readmeText("cursor"), description: "Cursor integration README" },
|
|
268
|
+
];
|
|
269
|
+
},
|
|
270
|
+
legacy: (home) => [
|
|
271
|
+
{
|
|
272
|
+
path: join(home, ".cursor", "plugins", "local", "claudexor"),
|
|
273
|
+
root: join(home, ".cursor"),
|
|
274
|
+
expectedFiles: [".cursor-plugin/plugin.json", "commands/claudexor.md"],
|
|
275
|
+
verifier: legacyThinShimVerifier,
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
reloadNote: "Reload Cursor and enable the local plugin if it is not auto-enabled; Claudexor does not edit Cursor SQLite/binary state.",
|
|
279
|
+
},
|
|
280
|
+
opencode: {
|
|
281
|
+
host: "opencode",
|
|
282
|
+
displayName: "OpenCode",
|
|
283
|
+
installState: "installed",
|
|
284
|
+
root: (home) => join(home, ".config", "opencode"),
|
|
285
|
+
artifacts: (home, runtime) => {
|
|
286
|
+
const root = join(home, ".config", "opencode");
|
|
287
|
+
return [
|
|
288
|
+
{ path: join(root, "skills", "claudexor", "SKILL.md"), content: skillText("opencode"), description: "OpenCode skill" },
|
|
289
|
+
{ path: join(root, "commands", "claudexor.md"), content: commandText("opencode"), description: "OpenCode command" },
|
|
290
|
+
{ path: join(root, "plugins", "claudexor.js"), content: opencodePluginText(runtime), description: "OpenCode JS plugin" },
|
|
291
|
+
];
|
|
292
|
+
},
|
|
293
|
+
config: "opencode-mcp",
|
|
294
|
+
legacy: (home) => [
|
|
295
|
+
{
|
|
296
|
+
path: join(home, ".config", "opencode", "claudexor"),
|
|
297
|
+
root: join(home, ".config", "opencode"),
|
|
298
|
+
expectedFiles: ["AGENTS.md"],
|
|
299
|
+
verifier: legacyOpenCodeAgentVerifier,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
reloadNote: "Restart OpenCode; global plugins, commands, skills, and MCP config load from ~/.config/opencode. The JS plugin uses OpenCode's experimental.chat.system.transform hook.",
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
function runtimePaths() {
|
|
306
|
+
const home = userHomeDir();
|
|
307
|
+
const configDir = userConfigDir();
|
|
308
|
+
const warnings = [];
|
|
309
|
+
const allowTestOverrides = process.env.VITEST === "true" || process.env.NODE_ENV === "test";
|
|
310
|
+
const envNode = allowTestOverrides ? process.env.CLAUDEXOR_NODE_PATH?.trim() : undefined;
|
|
311
|
+
const bundledNode = join(home, ".claudexor", "node", "bin", "node");
|
|
312
|
+
const nodePath = envNode || (existsSync(bundledNode) ? bundledNode : process.execPath);
|
|
313
|
+
if (!isAbsolute(nodePath) || !existsSync(nodePath) || !statSync(nodePath).isFile()) {
|
|
314
|
+
throw new Error(`unable to resolve a safe Node executable for plugin MCP config: ${nodePath}`);
|
|
315
|
+
}
|
|
316
|
+
if (!envNode && nodePath !== bundledNode)
|
|
317
|
+
warnings.push(`using current node instead of ${bundledNode}`);
|
|
318
|
+
if (!allowTestOverrides && process.env.CLAUDEXOR_NODE_PATH?.trim())
|
|
319
|
+
warnings.push("ignored CLAUDEXOR_NODE_PATH outside tests");
|
|
320
|
+
const envCli = allowTestOverrides ? process.env.CLAUDEXOR_CLI_PATH?.trim() : undefined;
|
|
321
|
+
const distCli = join(dirname(fileURLToPath(import.meta.url)), "cli.js");
|
|
322
|
+
const argvCli = process.argv[1] && existsSync(resolve(process.argv[1])) ? resolve(process.argv[1]) : "";
|
|
323
|
+
const cliPath = envCli || (existsSync(distCli) ? distCli : argvCli);
|
|
324
|
+
if (!cliPath || !isAbsolute(cliPath) || !existsSync(cliPath) || !statSync(cliPath).isFile()) {
|
|
325
|
+
throw new Error("unable to resolve a safe absolute claudexor CLI entrypoint");
|
|
326
|
+
}
|
|
327
|
+
if (!allowTestOverrides && process.env.CLAUDEXOR_CLI_PATH?.trim())
|
|
328
|
+
warnings.push("ignored CLAUDEXOR_CLI_PATH outside tests");
|
|
329
|
+
return { home, configDir, nodePath, cliPath, backupStamp: safeTimestamp(), warnings };
|
|
330
|
+
}
|
|
331
|
+
function stateFilePath(configDir = userConfigDir()) {
|
|
332
|
+
return join(configDir, "plugins", "state.json");
|
|
333
|
+
}
|
|
334
|
+
function emptyState() {
|
|
335
|
+
return { version: STATE_VERSION, hosts: {} };
|
|
336
|
+
}
|
|
337
|
+
function loadState(configDir = userConfigDir()) {
|
|
338
|
+
const path = stateFilePath(configDir);
|
|
339
|
+
const text = readText(path);
|
|
340
|
+
if (text === null)
|
|
341
|
+
return emptyState();
|
|
342
|
+
let parsed;
|
|
343
|
+
try {
|
|
344
|
+
parsed = JSON.parse(text);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
throw new Error(`plugin state is not valid JSON: ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
348
|
+
}
|
|
349
|
+
if (!parsed || typeof parsed !== "object" || parsed.version !== STATE_VERSION) {
|
|
350
|
+
throw new Error(`plugin state has unsupported shape: ${path}`);
|
|
351
|
+
}
|
|
352
|
+
const state = parsed;
|
|
353
|
+
state.hosts ??= {};
|
|
354
|
+
return state;
|
|
355
|
+
}
|
|
356
|
+
function saveState(state, configDir = userConfigDir()) {
|
|
357
|
+
writeJsonFile(stateFilePath(configDir), state);
|
|
358
|
+
}
|
|
359
|
+
function hostState(state, host) {
|
|
360
|
+
const existing = state.hosts[host];
|
|
361
|
+
if (existing) {
|
|
362
|
+
existing.artifacts ??= {};
|
|
363
|
+
existing.configEntries ??= {};
|
|
364
|
+
return existing;
|
|
365
|
+
}
|
|
366
|
+
const created = { artifacts: {}, configEntries: {}, updatedAt: new Date().toISOString() };
|
|
367
|
+
state.hosts[host] = created;
|
|
368
|
+
return created;
|
|
369
|
+
}
|
|
370
|
+
function hashText(text) {
|
|
371
|
+
return sha256(text);
|
|
372
|
+
}
|
|
373
|
+
function readText(path) {
|
|
374
|
+
assertReadableLeaf(path);
|
|
375
|
+
try {
|
|
376
|
+
return readFileSync(path, "utf8");
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT")
|
|
380
|
+
return null;
|
|
381
|
+
throw new Error(`unable to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function readJsonFile(path) {
|
|
385
|
+
const text = readText(path);
|
|
386
|
+
if (text === null)
|
|
387
|
+
return undefined;
|
|
388
|
+
try {
|
|
389
|
+
return JSON.parse(text);
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
throw new Error(`${path} is not strict JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function writeFile(path, text) {
|
|
396
|
+
ensureSafeWriteParent(path);
|
|
397
|
+
assertSafeLeaf(path);
|
|
398
|
+
writeFileSync(path, text, { encoding: "utf8", mode: 0o600 });
|
|
399
|
+
}
|
|
400
|
+
function writeJsonFile(path, value) {
|
|
401
|
+
ensureSafeWriteParent(path);
|
|
402
|
+
assertSafeLeaf(path);
|
|
403
|
+
writeJson(path, value);
|
|
404
|
+
}
|
|
405
|
+
function nearestExistingAncestor(path) {
|
|
406
|
+
let current = path;
|
|
407
|
+
while (!existsSync(current)) {
|
|
408
|
+
const parent = dirname(current);
|
|
409
|
+
if (parent === current)
|
|
410
|
+
throw new Error(`unable to find an existing ancestor for ${path}`);
|
|
411
|
+
current = parent;
|
|
412
|
+
}
|
|
413
|
+
return current;
|
|
414
|
+
}
|
|
415
|
+
function ensureSafeWriteParent(path) {
|
|
416
|
+
const parent = dirname(path);
|
|
417
|
+
const existing = nearestExistingAncestor(parent);
|
|
418
|
+
const root = findManagingRoot(path);
|
|
419
|
+
if (root) {
|
|
420
|
+
const rootAnchor = existsSync(root) ? root : nearestExistingAncestor(root);
|
|
421
|
+
assertExistingDirectoryChain(rootAnchor, existing, path);
|
|
422
|
+
const realAnchor = realpathSync(rootAnchor);
|
|
423
|
+
const realExisting = realpathSync(existing);
|
|
424
|
+
if (!isPathInside(realAnchor, realExisting)) {
|
|
425
|
+
throw new Error(`${path} resolves outside managed host/config root`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
assertExistingDirectoryChain(existing, existing, path);
|
|
430
|
+
}
|
|
431
|
+
ensureDir(parent);
|
|
432
|
+
}
|
|
433
|
+
function assertExistingDirectoryChain(anchor, existing, managedPath) {
|
|
434
|
+
if (!isPathInside(anchor, existing))
|
|
435
|
+
throw new Error(`${managedPath} resolves outside managed host/config root`);
|
|
436
|
+
let current = anchor;
|
|
437
|
+
const rel = relative(anchor, existing);
|
|
438
|
+
for (const part of ["", ...rel.split(/[\\/]/).filter(Boolean)]) {
|
|
439
|
+
if (part)
|
|
440
|
+
current = join(current, part);
|
|
441
|
+
const st = lstatSync(current);
|
|
442
|
+
if (st.isSymbolicLink()) {
|
|
443
|
+
throw new Error(`${current} is a symlink; refusing to create plugin directories through symlinks`);
|
|
444
|
+
}
|
|
445
|
+
if (!st.isDirectory())
|
|
446
|
+
throw new Error(`${current} is not a directory`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function assertReadableLeaf(path) {
|
|
450
|
+
try {
|
|
451
|
+
lstatSync(path);
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT")
|
|
455
|
+
return;
|
|
456
|
+
throw err;
|
|
457
|
+
}
|
|
458
|
+
const parent = dirname(path);
|
|
459
|
+
const root = findManagingRoot(path);
|
|
460
|
+
if (root) {
|
|
461
|
+
const anchor = existsSync(root) ? root : nearestExistingAncestor(root);
|
|
462
|
+
assertExistingDirectoryChain(anchor, parent, path);
|
|
463
|
+
const realParent = realpathSync(parent);
|
|
464
|
+
const realRoot = realpathSync(root);
|
|
465
|
+
if (!isPathInside(realRoot, realParent))
|
|
466
|
+
throw new Error(`${path} resolves outside managed host/config root`);
|
|
467
|
+
}
|
|
468
|
+
rejectSymlinkPath(path);
|
|
469
|
+
if (!lstatSync(path).isFile())
|
|
470
|
+
throw new Error(`${path} is not a regular file`);
|
|
471
|
+
}
|
|
472
|
+
function rejectSymlinkPath(path) {
|
|
473
|
+
let st;
|
|
474
|
+
try {
|
|
475
|
+
st = lstatSync(path);
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT")
|
|
479
|
+
return;
|
|
480
|
+
throw err;
|
|
481
|
+
}
|
|
482
|
+
if (st.isSymbolicLink())
|
|
483
|
+
throw new Error(`${path} is a symlink; refusing to manage plugin files through symlinks`);
|
|
484
|
+
}
|
|
485
|
+
function assertSafeLeaf(path) {
|
|
486
|
+
const parent = dirname(path);
|
|
487
|
+
const realParent = realpathSync(parent);
|
|
488
|
+
const root = findManagingRoot(path);
|
|
489
|
+
if (root && !isPathInside(realpathSync(root), realParent)) {
|
|
490
|
+
throw new Error(`${path} resolves outside managed host/config root`);
|
|
491
|
+
}
|
|
492
|
+
rejectSymlinkPath(path);
|
|
493
|
+
try {
|
|
494
|
+
const st = lstatSync(path);
|
|
495
|
+
if (!st.isFile())
|
|
496
|
+
throw new Error(`${path} is not a regular file`);
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT")
|
|
500
|
+
return;
|
|
501
|
+
throw err;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function findManagingRoot(path) {
|
|
505
|
+
const home = userHomeDir();
|
|
506
|
+
const stateRoot = dirname(stateFilePath());
|
|
507
|
+
if (isPathInside(stateRoot, path))
|
|
508
|
+
return stateRoot;
|
|
509
|
+
for (const host of PLUGIN_HOSTS) {
|
|
510
|
+
const root = HOST_DEFINITIONS[host].root(home);
|
|
511
|
+
if (isPathInside(root, path))
|
|
512
|
+
return root;
|
|
513
|
+
}
|
|
514
|
+
return isPathInside(home, path) ? home : null;
|
|
515
|
+
}
|
|
516
|
+
function listTreeEntries(root) {
|
|
517
|
+
if (!existsSync(root))
|
|
518
|
+
return [];
|
|
519
|
+
if (lstatSync(root).isSymbolicLink())
|
|
520
|
+
throw new Error(`${root} is a symlink; refusing recursive plugin cleanup`);
|
|
521
|
+
const out = [];
|
|
522
|
+
const walk = (dir) => {
|
|
523
|
+
for (const name of readdirSync(dir)) {
|
|
524
|
+
const path = join(dir, name);
|
|
525
|
+
const rel = relative(root, path);
|
|
526
|
+
const st = lstatSync(path);
|
|
527
|
+
if (st.isSymbolicLink())
|
|
528
|
+
throw new Error(`${path} is a symlink; refusing recursive plugin cleanup`);
|
|
529
|
+
if (st.isDirectory()) {
|
|
530
|
+
out.push({ rel, kind: "dir" });
|
|
531
|
+
walk(path);
|
|
532
|
+
}
|
|
533
|
+
else if (st.isFile()) {
|
|
534
|
+
out.push({ rel, kind: "file" });
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
out.push({ rel, kind: "other" });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
walk(root);
|
|
542
|
+
return out.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
543
|
+
}
|
|
544
|
+
function expectedLegacyDirs(files) {
|
|
545
|
+
const dirs = new Set();
|
|
546
|
+
for (const file of files) {
|
|
547
|
+
let dir = dirname(file);
|
|
548
|
+
while (dir && dir !== ".") {
|
|
549
|
+
dirs.add(dir);
|
|
550
|
+
dir = dirname(dir);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return dirs;
|
|
554
|
+
}
|
|
555
|
+
function assertLegacyTargetInRoot(target) {
|
|
556
|
+
assertExistingDirectoryChain(target.root, target.path, target.path);
|
|
557
|
+
const root = realpathSync(target.root);
|
|
558
|
+
const path = realpathSync(target.path);
|
|
559
|
+
if (!isPathInside(root, path))
|
|
560
|
+
throw new Error(`${target.path} resolves outside ${target.root}; refusing legacy cleanup`);
|
|
561
|
+
}
|
|
562
|
+
function isOldThinShimManifest(text) {
|
|
563
|
+
try {
|
|
564
|
+
const obj = JSON.parse(text);
|
|
565
|
+
const keys = Object.keys(obj).sort();
|
|
566
|
+
return (keys.join(",") === "description,name,version" &&
|
|
567
|
+
obj.name === "claudexor" &&
|
|
568
|
+
typeof obj.version === "string" &&
|
|
569
|
+
obj.description === "Claudexor control plane (thin shim)");
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const OLD_LEGACY_THIN_SHIM = `Use the local \`claudexor\` CLI for harness-agnostic, evidence-driven coding.
|
|
576
|
+
It orchestrates Codex/Claude/Cursor/OpenCode with best-of-n tournaments,
|
|
577
|
+
cross-family review, and budget balancing. Prefer it for multi-harness work.
|
|
578
|
+
|
|
579
|
+
- \`claudexor ask "<question>"\` read-only answer/explanation
|
|
580
|
+
- \`claudexor explore "<question>"\` read-only exploration/synthesis
|
|
581
|
+
- \`claudexor run "<task>"\` Agent run (native parity + artifacts)
|
|
582
|
+
- \`claudexor race "<task>" --n 4\` Best-of-N tournament + cross-family review
|
|
583
|
+
- \`claudexor plan "<task>"\` read-only plan
|
|
584
|
+
- \`claudexor create "<task>"\` create a new project
|
|
585
|
+
- \`claudexor inspect <run_id>\` inspect artifacts under .claudexor/runs
|
|
586
|
+
|
|
587
|
+
These plugins are thin shims: they call the local CLI; all orchestration lives in claudexor.`;
|
|
588
|
+
const OLD_LEGACY_CLAUDE_COMMAND = `---
|
|
589
|
+
description: Run Claudexor
|
|
590
|
+
---
|
|
591
|
+
${OLD_LEGACY_THIN_SHIM}
|
|
592
|
+
`;
|
|
593
|
+
const OLD_LEGACY_CURSOR_COMMAND = `---
|
|
594
|
+
name: claudexor
|
|
595
|
+
description: Run Claudexor
|
|
596
|
+
---
|
|
597
|
+
${OLD_LEGACY_THIN_SHIM}
|
|
598
|
+
`;
|
|
599
|
+
function exactLegacyText(text, expected) {
|
|
600
|
+
return text === expected || (expected.endsWith("\n") && text === expected.slice(0, -1));
|
|
601
|
+
}
|
|
602
|
+
function legacyThinShimVerifier(file, text) {
|
|
603
|
+
if (file.endsWith("plugin.json"))
|
|
604
|
+
return isOldThinShimManifest(text);
|
|
605
|
+
if (file === "commands/claudexor.md") {
|
|
606
|
+
return exactLegacyText(text, OLD_LEGACY_CLAUDE_COMMAND) || exactLegacyText(text, OLD_LEGACY_CURSOR_COMMAND);
|
|
607
|
+
}
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
function legacyCodexSkillVerifier(file, text) {
|
|
611
|
+
const expected = "---\nname: claudexor\ndescription: Harness-agnostic coding via the claudexor CLI\n---\n";
|
|
612
|
+
return file === "SKILL.md" && exactLegacyText(text, expected);
|
|
613
|
+
}
|
|
614
|
+
function legacyOpenCodeAgentVerifier(file, text) {
|
|
615
|
+
const expected = "These plugins are thin shims\nHarness-agnostic coding via the claudexor CLI\n";
|
|
616
|
+
return file === "AGENTS.md" && exactLegacyText(text, expected);
|
|
617
|
+
}
|
|
618
|
+
function isLegacyOwned(target) {
|
|
619
|
+
if (!existsSync(target.path))
|
|
620
|
+
return false;
|
|
621
|
+
assertLegacyTargetInRoot(target);
|
|
622
|
+
const entries = listTreeEntries(target.path);
|
|
623
|
+
const files = entries.filter((e) => e.kind === "file").map((e) => e.rel);
|
|
624
|
+
if (files.length === 0)
|
|
625
|
+
return false;
|
|
626
|
+
const expected = new Set(target.expectedFiles);
|
|
627
|
+
const expectedDirs = expectedLegacyDirs(target.expectedFiles);
|
|
628
|
+
if (entries.some((e) => e.kind === "other"))
|
|
629
|
+
return false;
|
|
630
|
+
if (entries.some((e) => e.kind === "dir" && !expectedDirs.has(e.rel)))
|
|
631
|
+
return false;
|
|
632
|
+
if (files.length !== expected.size || files.some((f) => !expected.has(f)))
|
|
633
|
+
return false;
|
|
634
|
+
return files.every((f) => {
|
|
635
|
+
const text = readText(join(target.path, f));
|
|
636
|
+
if (text === null)
|
|
637
|
+
return false;
|
|
638
|
+
return target.verifier(f, text);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
function backupRoot(def, runtime) {
|
|
642
|
+
return join(def.root(runtime.home), ".claudexor-backups", runtime.backupStamp);
|
|
643
|
+
}
|
|
644
|
+
function safeTimestamp() {
|
|
645
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
646
|
+
}
|
|
647
|
+
function backupFile(def, runtime, path) {
|
|
648
|
+
assertSafeLeaf(path);
|
|
649
|
+
const dest = join(backupRoot(def, runtime), relative(runtime.home, path));
|
|
650
|
+
ensureSafeWriteParent(dest);
|
|
651
|
+
assertSafeLeaf(dest);
|
|
652
|
+
copyFileSync(path, dest);
|
|
653
|
+
return dest;
|
|
654
|
+
}
|
|
655
|
+
function hasManagedMarker(text) {
|
|
656
|
+
return text.includes(MARKER) || text.includes('"marker": "claudexor:managed host-plugin-lifecycle"');
|
|
657
|
+
}
|
|
658
|
+
function artifactOwned(state, host, artifact, current, force) {
|
|
659
|
+
const entry = state.hosts[host]?.artifacts[artifact.path];
|
|
660
|
+
return entry?.hash === hashText(current) || (entry !== undefined && hasManagedMarker(current)) || current === artifact.content || (force && hasManagedMarker(current));
|
|
661
|
+
}
|
|
662
|
+
function isPathInside(root, path) {
|
|
663
|
+
const rel = relative(root, path);
|
|
664
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
665
|
+
}
|
|
666
|
+
function recordArtifact(state, host, artifact) {
|
|
667
|
+
const hs = hostState(state, host);
|
|
668
|
+
hs.artifacts[artifact.path] = {
|
|
669
|
+
host,
|
|
670
|
+
path: artifact.path,
|
|
671
|
+
hash: hashText(artifact.content),
|
|
672
|
+
description: artifact.description,
|
|
673
|
+
updatedAt: new Date().toISOString(),
|
|
674
|
+
};
|
|
675
|
+
hs.updatedAt = new Date().toISOString();
|
|
676
|
+
}
|
|
677
|
+
function recordConfig(state, host, path, key, value) {
|
|
678
|
+
const hs = hostState(state, host);
|
|
679
|
+
hs.configEntries[key] = {
|
|
680
|
+
host,
|
|
681
|
+
path,
|
|
682
|
+
key,
|
|
683
|
+
hash: hashText(jsonText(value)),
|
|
684
|
+
updatedAt: new Date().toISOString(),
|
|
685
|
+
};
|
|
686
|
+
hs.updatedAt = new Date().toISOString();
|
|
687
|
+
}
|
|
688
|
+
function desiredCodexMarketplaceEntry() {
|
|
689
|
+
return {
|
|
690
|
+
name: "claudexor",
|
|
691
|
+
source: { source: "local", path: "./.codex/plugins/claudexor" },
|
|
692
|
+
policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
|
|
693
|
+
category: "Productivity",
|
|
694
|
+
interface: {
|
|
695
|
+
displayName: "Claudexor",
|
|
696
|
+
description: "Harness-agnostic coding through the local Claudexor CLI and MCP server.",
|
|
697
|
+
},
|
|
698
|
+
claudexor: { managed: true, marker: MARKER, version: CLAUDEXOR_VERSION },
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
function validateConfigEntry(stateEntry, host, path, key, res) {
|
|
702
|
+
if (!stateEntry)
|
|
703
|
+
return true;
|
|
704
|
+
if (stateEntry.host === host && stateEntry.path === path && stateEntry.key === key)
|
|
705
|
+
return true;
|
|
706
|
+
res.errors.push(`${stateFilePath()} contains out-of-scope ${host} config state for ${key}; remove that state entry or clean it manually`);
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
function configEntryOwned(stateEntry, current, desired, force, requireState = false) {
|
|
710
|
+
if (requireState && !stateEntry)
|
|
711
|
+
return false;
|
|
712
|
+
const currentText = JSON.stringify(current);
|
|
713
|
+
const hasMarker = currentText.includes(MARKER);
|
|
714
|
+
const exactMarkedDesired = !requireState && hasMarker && jsonText(current) === jsonText(desired);
|
|
715
|
+
return stateEntry?.hash === hashText(jsonText(current)) || exactMarkedDesired || (stateEntry !== undefined && hasMarker) || (force && hasMarker);
|
|
716
|
+
}
|
|
717
|
+
function codexMarketplaceEntries(plugins) {
|
|
718
|
+
return plugins
|
|
719
|
+
.map((value, index) => ({ index, value }))
|
|
720
|
+
.filter(({ value }) => value && typeof value === "object" && value.name === "claudexor");
|
|
721
|
+
}
|
|
722
|
+
function mergeCodexMarketplace(state, dryRun, force, def, runtime, res) {
|
|
723
|
+
const path = join(userHomeDir(), ".agents", "plugins", "marketplace.json");
|
|
724
|
+
const desired = desiredCodexMarketplaceEntry();
|
|
725
|
+
const parsed = readJsonFile(path);
|
|
726
|
+
const base = parsed === undefined ? { name: "personal", interface: { displayName: "Personal" }, plugins: [] } : parsed;
|
|
727
|
+
if (!base || typeof base !== "object" || !Array.isArray(base.plugins)) {
|
|
728
|
+
res.errors.push(`${path} is not a Codex marketplace JSON object with plugins[]`);
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
const obj = base;
|
|
732
|
+
const matches = codexMarketplaceEntries(obj.plugins);
|
|
733
|
+
if (matches.length > 0) {
|
|
734
|
+
const stateEntry = state.hosts.codex?.configEntries["codex-marketplace"];
|
|
735
|
+
if (!validateConfigEntry(stateEntry, "codex", path, "codex-marketplace", res))
|
|
736
|
+
return false;
|
|
737
|
+
if (matches.some(({ value }) => !configEntryOwned(stateEntry, value, desired, force))) {
|
|
738
|
+
res.errors.push(`${path} already has an unowned claudexor marketplace entry`);
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
if (matches.length === 1 && jsonText(matches[0]?.value) === jsonText(desired)) {
|
|
742
|
+
recordConfig(state, "codex", path, "codex-marketplace", desired);
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
const insertAt = matches[0]?.index ?? obj.plugins.length;
|
|
746
|
+
obj.plugins = obj.plugins.filter((p) => !(p && typeof p === "object" && p.name === "claudexor"));
|
|
747
|
+
obj.plugins.splice(insertAt, 0, desired);
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
obj.plugins.push(desired);
|
|
751
|
+
}
|
|
752
|
+
if (!dryRun) {
|
|
753
|
+
if (existsSync(path)) {
|
|
754
|
+
const b = backupFile(def, runtime, path);
|
|
755
|
+
res.notes.push(`backed up marketplace to ${b}`);
|
|
756
|
+
}
|
|
757
|
+
writeJsonFile(path, obj);
|
|
758
|
+
recordConfig(state, "codex", path, "codex-marketplace", desired);
|
|
759
|
+
}
|
|
760
|
+
res.changed = true;
|
|
761
|
+
res.actions.push(`${dryRun ? "would update" : "updated"} Codex personal marketplace`);
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
function chooseOpenCodeConfigPath() {
|
|
765
|
+
const root = join(userHomeDir(), ".config", "opencode");
|
|
766
|
+
const json = join(root, "opencode.json");
|
|
767
|
+
const jsonc = join(root, "opencode.jsonc");
|
|
768
|
+
if (existsSync(json))
|
|
769
|
+
return json;
|
|
770
|
+
if (existsSync(jsonc))
|
|
771
|
+
return jsonc;
|
|
772
|
+
return json;
|
|
773
|
+
}
|
|
774
|
+
function openCodeConfigObject(path, parsed, res) {
|
|
775
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
776
|
+
res.errors.push(`${path} is not an OpenCode JSON config object`);
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
const obj = parsed;
|
|
780
|
+
if (obj.mcp === undefined)
|
|
781
|
+
obj.mcp = {};
|
|
782
|
+
if (!obj.mcp || typeof obj.mcp !== "object" || Array.isArray(obj.mcp)) {
|
|
783
|
+
res.errors.push(`${path} has a non-object mcp field`);
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
return { obj, mcp: obj.mcp };
|
|
787
|
+
}
|
|
788
|
+
function mergeOpenCodeMcp(state, dryRun, force, def, runtime, res) {
|
|
789
|
+
const path = chooseOpenCodeConfigPath();
|
|
790
|
+
const desired = opencodeMcpEntry(runtime);
|
|
791
|
+
const parsed = readJsonFile(path);
|
|
792
|
+
const base = parsed === undefined ? { $schema: "https://opencode.ai/config.json", mcp: {} } : parsed;
|
|
793
|
+
const validated = openCodeConfigObject(path, base, res);
|
|
794
|
+
if (!validated)
|
|
795
|
+
return false;
|
|
796
|
+
const { obj, mcp } = validated;
|
|
797
|
+
const current = mcp[MCP_NAME];
|
|
798
|
+
if (current !== undefined) {
|
|
799
|
+
const stateEntry = state.hosts.opencode?.configEntries["opencode-mcp"];
|
|
800
|
+
if (!validateConfigEntry(stateEntry, "opencode", path, "opencode-mcp", res))
|
|
801
|
+
return false;
|
|
802
|
+
const owned = configEntryOwned(stateEntry, current, desired, force);
|
|
803
|
+
if (!owned) {
|
|
804
|
+
res.errors.push(`${path} already has an unowned mcp.claudexor entry`);
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
if (jsonText(current) === jsonText(desired)) {
|
|
808
|
+
recordConfig(state, "opencode", path, "opencode-mcp", desired);
|
|
809
|
+
return true;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
mcp[MCP_NAME] = desired;
|
|
813
|
+
if (!dryRun) {
|
|
814
|
+
if (existsSync(path)) {
|
|
815
|
+
const b = backupFile(def, runtime, path);
|
|
816
|
+
res.notes.push(`backed up OpenCode config to ${b}`);
|
|
817
|
+
}
|
|
818
|
+
writeJsonFile(path, obj);
|
|
819
|
+
recordConfig(state, "opencode", path, "opencode-mcp", desired);
|
|
820
|
+
}
|
|
821
|
+
res.changed = true;
|
|
822
|
+
res.actions.push(`${dryRun ? "would update" : "updated"} OpenCode MCP config`);
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
825
|
+
function checkConfig(host, state, res, runtime) {
|
|
826
|
+
if (host === "codex") {
|
|
827
|
+
const path = join(userHomeDir(), ".agents", "plugins", "marketplace.json");
|
|
828
|
+
const parsed = readJsonFile(path);
|
|
829
|
+
if (parsed === undefined) {
|
|
830
|
+
res.notes.push("Codex personal marketplace is missing");
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
const desired = desiredCodexMarketplaceEntry();
|
|
834
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Array.isArray(parsed.plugins)) {
|
|
835
|
+
res.errors.push(`${path} is not a Codex marketplace JSON object with plugins[]`);
|
|
836
|
+
return false;
|
|
837
|
+
}
|
|
838
|
+
const plugins = parsed.plugins;
|
|
839
|
+
const matches = codexMarketplaceEntries(plugins);
|
|
840
|
+
const stateEntry = state.hosts.codex?.configEntries["codex-marketplace"];
|
|
841
|
+
if (!validateConfigEntry(stateEntry, "codex", path, "codex-marketplace", res))
|
|
842
|
+
return false;
|
|
843
|
+
if (matches.length === 1 && jsonText(matches[0]?.value) === jsonText(desired)) {
|
|
844
|
+
recordConfig(state, "codex", path, "codex-marketplace", desired);
|
|
845
|
+
return true;
|
|
846
|
+
}
|
|
847
|
+
if (matches.some(({ value }) => !configEntryOwned(stateEntry, value, desired, false))) {
|
|
848
|
+
res.errors.push(`${path} has an unowned claudexor marketplace entry`);
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
res.notes.push(matches.length > 0 ? "Codex marketplace entry is drifted" : "Codex marketplace entry is missing");
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
if (host === "opencode") {
|
|
855
|
+
const path = chooseOpenCodeConfigPath();
|
|
856
|
+
const parsed = readJsonFile(path);
|
|
857
|
+
if (parsed === undefined) {
|
|
858
|
+
res.notes.push("OpenCode config is missing");
|
|
859
|
+
return false;
|
|
860
|
+
}
|
|
861
|
+
const validated = openCodeConfigObject(path, parsed, res);
|
|
862
|
+
if (!validated)
|
|
863
|
+
return false;
|
|
864
|
+
const current = validated.mcp[MCP_NAME];
|
|
865
|
+
const desired = opencodeMcpEntry(runtime);
|
|
866
|
+
const stateEntry = state.hosts.opencode?.configEntries["opencode-mcp"];
|
|
867
|
+
if (!validateConfigEntry(stateEntry, "opencode", path, "opencode-mcp", res))
|
|
868
|
+
return false;
|
|
869
|
+
if (current !== undefined && !configEntryOwned(stateEntry, current, desired, false)) {
|
|
870
|
+
res.errors.push(`${path} has an unowned mcp.claudexor entry`);
|
|
871
|
+
return false;
|
|
872
|
+
}
|
|
873
|
+
if (jsonText(current) === jsonText(desired)) {
|
|
874
|
+
recordConfig(state, "opencode", path, "opencode-mcp", desired);
|
|
875
|
+
return true;
|
|
876
|
+
}
|
|
877
|
+
res.notes.push(current !== undefined ? "OpenCode MCP entry is drifted" : "OpenCode MCP entry is missing");
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
if (host === "cursor") {
|
|
881
|
+
const ideState = join(userHomeDir(), ".cursor", "ide_state.json");
|
|
882
|
+
if (existsSync(ideState)) {
|
|
883
|
+
try {
|
|
884
|
+
readJsonFile(ideState);
|
|
885
|
+
res.notes.push("Cursor JSON state detected and parseable; no SQLite/binary enablement was touched");
|
|
886
|
+
}
|
|
887
|
+
catch (err) {
|
|
888
|
+
res.warnings.push(`Cursor JSON state was not parseable and was not changed: ${err instanceof Error ? err.message : String(err)}`);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
res.notes.push("Cursor plugin files are managed directly; no stable JSON registration file was found");
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
function removeConfig(host, state, dryRun, force, def, runtime, res) {
|
|
898
|
+
if (host === "codex") {
|
|
899
|
+
const path = join(userHomeDir(), ".agents", "plugins", "marketplace.json");
|
|
900
|
+
const parsed = readJsonFile(path);
|
|
901
|
+
if (parsed === undefined)
|
|
902
|
+
return true;
|
|
903
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.plugins)) {
|
|
904
|
+
res.errors.push(`${path} is not a Codex marketplace JSON object with plugins[]`);
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
const obj = parsed;
|
|
908
|
+
const stateEntry = state.hosts.codex?.configEntries["codex-marketplace"];
|
|
909
|
+
if (!validateConfigEntry(stateEntry, "codex", path, "codex-marketplace", res))
|
|
910
|
+
return false;
|
|
911
|
+
const matches = codexMarketplaceEntries(obj.plugins);
|
|
912
|
+
if (matches.length === 0)
|
|
913
|
+
return true;
|
|
914
|
+
if (matches.some(({ value }) => !configEntryOwned(stateEntry, value, desiredCodexMarketplaceEntry(), force, true))) {
|
|
915
|
+
res.errors.push(`${path} has an unowned claudexor marketplace entry`);
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
obj.plugins = obj.plugins.filter((p) => !(p && typeof p === "object" && p.name === "claudexor"));
|
|
919
|
+
if (!dryRun) {
|
|
920
|
+
const b = backupFile(def, runtime, path);
|
|
921
|
+
res.notes.push(`backed up marketplace to ${b}`);
|
|
922
|
+
writeJsonFile(path, obj);
|
|
923
|
+
delete hostState(state, "codex").configEntries["codex-marketplace"];
|
|
924
|
+
}
|
|
925
|
+
res.changed = true;
|
|
926
|
+
res.actions.push(`${dryRun ? "would remove" : "removed"} Codex marketplace entry`);
|
|
927
|
+
}
|
|
928
|
+
if (host === "opencode") {
|
|
929
|
+
const path = chooseOpenCodeConfigPath();
|
|
930
|
+
const parsed = readJsonFile(path);
|
|
931
|
+
if (parsed === undefined)
|
|
932
|
+
return true;
|
|
933
|
+
const validated = openCodeConfigObject(path, parsed, res);
|
|
934
|
+
if (!validated)
|
|
935
|
+
return false;
|
|
936
|
+
const { obj, mcp } = validated;
|
|
937
|
+
const current = mcp[MCP_NAME];
|
|
938
|
+
if (current === undefined)
|
|
939
|
+
return true;
|
|
940
|
+
const stateEntry = state.hosts.opencode?.configEntries["opencode-mcp"];
|
|
941
|
+
if (!validateConfigEntry(stateEntry, "opencode", path, "opencode-mcp", res))
|
|
942
|
+
return false;
|
|
943
|
+
const owned = configEntryOwned(stateEntry, current, opencodeMcpEntry(runtime), force, true);
|
|
944
|
+
if (!owned) {
|
|
945
|
+
res.errors.push(`${path} has an unowned mcp.claudexor entry`);
|
|
946
|
+
return false;
|
|
947
|
+
}
|
|
948
|
+
if (!dryRun) {
|
|
949
|
+
const b = backupFile(def, runtime, path);
|
|
950
|
+
res.notes.push(`backed up OpenCode config to ${b}`);
|
|
951
|
+
delete mcp[MCP_NAME];
|
|
952
|
+
writeJsonFile(path, obj);
|
|
953
|
+
delete hostState(state, "opencode").configEntries["opencode-mcp"];
|
|
954
|
+
}
|
|
955
|
+
res.changed = true;
|
|
956
|
+
res.actions.push(`${dryRun ? "would remove" : "removed"} OpenCode MCP entry`);
|
|
957
|
+
}
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
function checkArtifacts(def, artifacts, state, res) {
|
|
961
|
+
let all = true;
|
|
962
|
+
let any = false;
|
|
963
|
+
let drift = false;
|
|
964
|
+
let blocked = false;
|
|
965
|
+
for (const artifact of artifacts) {
|
|
966
|
+
const current = readText(artifact.path);
|
|
967
|
+
if (current === null) {
|
|
968
|
+
all = false;
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
any = true;
|
|
972
|
+
if (current === artifact.content) {
|
|
973
|
+
recordArtifact(state, def.host, artifact);
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
all = false;
|
|
977
|
+
if (artifactOwned(state, def.host, artifact, current, false)) {
|
|
978
|
+
drift = true;
|
|
979
|
+
res.notes.push(`${artifact.description} is drifted`);
|
|
980
|
+
}
|
|
981
|
+
else {
|
|
982
|
+
blocked = true;
|
|
983
|
+
res.errors.push(`${artifact.path} exists and is not Claudexor-owned`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return { all, any, drift, blocked };
|
|
987
|
+
}
|
|
988
|
+
function applyArtifacts(def, artifacts, state, dryRun, force, runtime, res) {
|
|
989
|
+
for (const artifact of artifacts) {
|
|
990
|
+
const current = readText(artifact.path);
|
|
991
|
+
if (current === artifact.content) {
|
|
992
|
+
recordArtifact(state, def.host, artifact);
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
if (current !== null && !artifactOwned(state, def.host, artifact, current, force)) {
|
|
996
|
+
res.errors.push(`${artifact.path} exists and is not Claudexor-owned`);
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
if (!dryRun) {
|
|
1000
|
+
if (current !== null) {
|
|
1001
|
+
const b = backupFile(def, runtime, artifact.path);
|
|
1002
|
+
res.notes.push(`backed up ${artifact.description} to ${b}`);
|
|
1003
|
+
}
|
|
1004
|
+
writeFile(artifact.path, artifact.content);
|
|
1005
|
+
recordArtifact(state, def.host, artifact);
|
|
1006
|
+
}
|
|
1007
|
+
res.changed = true;
|
|
1008
|
+
res.actions.push(`${dryRun ? "would write" : "wrote"} ${artifact.description}`);
|
|
1009
|
+
}
|
|
1010
|
+
return true;
|
|
1011
|
+
}
|
|
1012
|
+
function obsoleteArtifactRoots(host, home) {
|
|
1013
|
+
switch (host) {
|
|
1014
|
+
case "claude":
|
|
1015
|
+
return [join(home, ".claude", "skills", "claudexor"), join(home, ".claude", "plugins", "claudexor")];
|
|
1016
|
+
case "codex":
|
|
1017
|
+
return [join(home, ".codex", "plugins", "claudexor"), join(home, ".agents", "skills", "claudexor")];
|
|
1018
|
+
case "cursor":
|
|
1019
|
+
return [join(home, ".cursor", "plugins", "local", "claudexor")];
|
|
1020
|
+
case "opencode":
|
|
1021
|
+
return [join(home, ".config", "opencode", "skills", "claudexor"), join(home, ".config", "opencode", "claudexor")];
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
function artifactStatePathAllowed(def, artifacts, path) {
|
|
1025
|
+
if (artifacts.some((a) => a.path === path))
|
|
1026
|
+
return true;
|
|
1027
|
+
return obsoleteArtifactRoots(def.host, userHomeDir()).some((root) => isPathInside(root, path));
|
|
1028
|
+
}
|
|
1029
|
+
function removeObsoleteStateArtifacts(def, artifacts, state, dryRun, _force, res) {
|
|
1030
|
+
const hs = hostState(state, def.host);
|
|
1031
|
+
const allowed = new Set(artifacts.map((a) => a.path));
|
|
1032
|
+
for (const [path, entry] of Object.entries(hs.artifacts)) {
|
|
1033
|
+
if (entry.host !== def.host || entry.path !== path || !artifactStatePathAllowed(def, artifacts, path)) {
|
|
1034
|
+
res.errors.push(`${stateFilePath()} contains out-of-scope ${def.host} artifact state for ${path}; remove that state entry or clean it manually`);
|
|
1035
|
+
return false;
|
|
1036
|
+
}
|
|
1037
|
+
if (allowed.has(path))
|
|
1038
|
+
continue;
|
|
1039
|
+
const current = readText(path);
|
|
1040
|
+
if (current === null) {
|
|
1041
|
+
delete hs.artifacts[path];
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
const owned = entry.hash === hashText(current);
|
|
1045
|
+
if (!owned) {
|
|
1046
|
+
res.errors.push(`${path} is obsolete Claudexor state but no longer matches ownership evidence`);
|
|
1047
|
+
return false;
|
|
1048
|
+
}
|
|
1049
|
+
if (!dryRun) {
|
|
1050
|
+
rmSync(path, { force: true });
|
|
1051
|
+
delete hs.artifacts[path];
|
|
1052
|
+
}
|
|
1053
|
+
res.changed = true;
|
|
1054
|
+
res.actions.push(`${dryRun ? "would remove" : "removed"} obsolete Claudexor artifact ${path}`);
|
|
1055
|
+
}
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
1058
|
+
function removeArtifacts(def, artifacts, state, dryRun, force, res) {
|
|
1059
|
+
if (!removeObsoleteStateArtifacts(def, artifacts, state, dryRun, force, res))
|
|
1060
|
+
return false;
|
|
1061
|
+
const hs = hostState(state, def.host);
|
|
1062
|
+
const paths = artifacts.map((a) => a.path).sort((a, b) => b.length - a.length);
|
|
1063
|
+
for (const path of paths) {
|
|
1064
|
+
const current = readText(path);
|
|
1065
|
+
if (current === null) {
|
|
1066
|
+
delete hs.artifacts[path];
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
const desired = artifacts.find((a) => a.path === path);
|
|
1070
|
+
const owned = hs.artifacts[path]?.hash === hashText(current) || current === desired?.content || (force && hasManagedMarker(current));
|
|
1071
|
+
if (!owned) {
|
|
1072
|
+
res.errors.push(`${path} exists but no longer matches Claudexor ownership state`);
|
|
1073
|
+
return false;
|
|
1074
|
+
}
|
|
1075
|
+
if (!dryRun) {
|
|
1076
|
+
rmSync(path, { force: true });
|
|
1077
|
+
delete hs.artifacts[path];
|
|
1078
|
+
}
|
|
1079
|
+
res.changed = true;
|
|
1080
|
+
res.actions.push(`${dryRun ? "would remove" : "removed"} ${path}`);
|
|
1081
|
+
}
|
|
1082
|
+
pruneEmptyDirs(def.root(userHomeDir()));
|
|
1083
|
+
return true;
|
|
1084
|
+
}
|
|
1085
|
+
function pruneEmptyDirs(_root) {
|
|
1086
|
+
// Deliberately conservative: removing files is enough for uninstall; avoid
|
|
1087
|
+
// recursive directory cleanup in user config roots.
|
|
1088
|
+
}
|
|
1089
|
+
function removeVerifiedLegacy(def, dryRun, res) {
|
|
1090
|
+
for (const legacy of def.legacy(userHomeDir())) {
|
|
1091
|
+
if (!existsSync(legacy.path))
|
|
1092
|
+
continue;
|
|
1093
|
+
if (isLegacyOwned(legacy)) {
|
|
1094
|
+
if (!dryRun)
|
|
1095
|
+
rmSync(legacy.path, { recursive: true, force: true });
|
|
1096
|
+
res.changed = true;
|
|
1097
|
+
res.actions.push(`${dryRun ? "would remove" : "removed"} verified legacy shim ${legacy.path}`);
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
const entries = listTreeEntries(legacy.path);
|
|
1101
|
+
const files = entries.filter((e) => e.kind === "file").map((e) => e.rel);
|
|
1102
|
+
const hasModernMarker = files.some((f) => readText(join(legacy.path, f))?.includes(MARKER));
|
|
1103
|
+
if (hasModernMarker)
|
|
1104
|
+
continue;
|
|
1105
|
+
const hasLegacyShape = files.some((f) => legacy.expectedFiles.includes(f));
|
|
1106
|
+
if (hasLegacyShape) {
|
|
1107
|
+
res.errors.push(`${legacy.path} looks like a legacy Claudexor path but is not verified as old shim content`);
|
|
1108
|
+
return false;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return true;
|
|
1112
|
+
}
|
|
1113
|
+
async function mcpSelfTest(runtime) {
|
|
1114
|
+
return await new Promise((resolve) => {
|
|
1115
|
+
const child = spawn(runtime.nodePath, [runtime.cliPath, "mcp", "serve"], {
|
|
1116
|
+
cwd: process.cwd(),
|
|
1117
|
+
env: mcpSelfTestEnv(runtime),
|
|
1118
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1119
|
+
});
|
|
1120
|
+
let stdout = "";
|
|
1121
|
+
let pending = "";
|
|
1122
|
+
const lines = [];
|
|
1123
|
+
let stderr = "";
|
|
1124
|
+
let sentToolsList = false;
|
|
1125
|
+
let settled = false;
|
|
1126
|
+
const timer = setTimeout(() => {
|
|
1127
|
+
settled = true;
|
|
1128
|
+
child.kill("SIGKILL");
|
|
1129
|
+
resolve("MCP self-test timed out");
|
|
1130
|
+
}, 5000);
|
|
1131
|
+
child.stdout.on("data", (d) => {
|
|
1132
|
+
if (settled)
|
|
1133
|
+
return;
|
|
1134
|
+
const chunk = String(d);
|
|
1135
|
+
stdout += chunk;
|
|
1136
|
+
pending += chunk;
|
|
1137
|
+
const frames = pending.split("\n");
|
|
1138
|
+
pending = frames.pop() ?? "";
|
|
1139
|
+
try {
|
|
1140
|
+
for (const frame of frames.filter(Boolean))
|
|
1141
|
+
lines.push(JSON.parse(frame));
|
|
1142
|
+
const init = lines.find((line) => line && typeof line === "object" && line.id === 1);
|
|
1143
|
+
if (init?.result?.serverInfo?.name && !sentToolsList) {
|
|
1144
|
+
sentToolsList = true;
|
|
1145
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }) + "\n");
|
|
1146
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }) + "\n");
|
|
1147
|
+
}
|
|
1148
|
+
const tools = lines.find((line) => line && typeof line === "object" && line.id === 2);
|
|
1149
|
+
if (tools && !settled) {
|
|
1150
|
+
settled = true;
|
|
1151
|
+
const listed = tools.result?.tools;
|
|
1152
|
+
clearTimeout(timer);
|
|
1153
|
+
child.kill("SIGTERM");
|
|
1154
|
+
if (Array.isArray(listed) && listed.some((t) => t.name === "claudexor_status")) {
|
|
1155
|
+
resolve(null);
|
|
1156
|
+
}
|
|
1157
|
+
else {
|
|
1158
|
+
resolve("MCP self-test returned an unexpected tools-list response");
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
catch (err) {
|
|
1163
|
+
if (!settled && stdout.includes("\n")) {
|
|
1164
|
+
settled = true;
|
|
1165
|
+
clearTimeout(timer);
|
|
1166
|
+
child.kill("SIGTERM");
|
|
1167
|
+
resolve(`MCP self-test response parse failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
child.stderr.on("data", (d) => { stderr += String(d); });
|
|
1172
|
+
child.on("error", (err) => {
|
|
1173
|
+
if (settled)
|
|
1174
|
+
return;
|
|
1175
|
+
settled = true;
|
|
1176
|
+
clearTimeout(timer);
|
|
1177
|
+
resolve(`MCP self-test failed to start: ${err.message}`);
|
|
1178
|
+
});
|
|
1179
|
+
child.on("exit", (code) => {
|
|
1180
|
+
if (settled)
|
|
1181
|
+
return;
|
|
1182
|
+
settled = true;
|
|
1183
|
+
clearTimeout(timer);
|
|
1184
|
+
if (!stdout)
|
|
1185
|
+
resolve(`MCP self-test exited before response (${code ?? "signal"}): ${stderr.trim()}`);
|
|
1186
|
+
else
|
|
1187
|
+
resolve(`MCP self-test exited before tools-list completed (${code ?? "signal"}): ${stderr.trim()}`);
|
|
1188
|
+
});
|
|
1189
|
+
child.stdin.write(JSON.stringify({
|
|
1190
|
+
jsonrpc: "2.0",
|
|
1191
|
+
id: 1,
|
|
1192
|
+
method: "initialize",
|
|
1193
|
+
params: {
|
|
1194
|
+
protocolVersion: "2025-06-18",
|
|
1195
|
+
capabilities: {},
|
|
1196
|
+
clientInfo: { name: "claudexor-plugin-doctor", version: CLAUDEXOR_VERSION },
|
|
1197
|
+
},
|
|
1198
|
+
}) + "\n");
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
function mcpSelfTestEnv(runtime) {
|
|
1202
|
+
const env = {};
|
|
1203
|
+
for (const key of ["HOME", "PATH", "CLAUDEXOR_CONFIG_DIR", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL"]) {
|
|
1204
|
+
const value = process.env[key];
|
|
1205
|
+
if (value)
|
|
1206
|
+
env[key] = value;
|
|
1207
|
+
}
|
|
1208
|
+
Object.assign(env, generatedMcpEnv(runtime));
|
|
1209
|
+
return env;
|
|
1210
|
+
}
|
|
1211
|
+
function verbNote(def, verb) {
|
|
1212
|
+
if (verb === "uninstall")
|
|
1213
|
+
return `${def.displayName} integration removed from managed Claudexor files/config; restart the host to unload cached plugin state.`;
|
|
1214
|
+
return def.reloadNote;
|
|
1215
|
+
}
|
|
1216
|
+
function initialResult(def, verb, runtime) {
|
|
1217
|
+
return {
|
|
1218
|
+
host: def.host,
|
|
1219
|
+
state: "missing",
|
|
1220
|
+
ok: false,
|
|
1221
|
+
changed: false,
|
|
1222
|
+
path: def.root(runtime.home),
|
|
1223
|
+
actions: [],
|
|
1224
|
+
notes: [verbNote(def, verb), ...runtime.warnings],
|
|
1225
|
+
warnings: [],
|
|
1226
|
+
errors: [],
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
async function runHost(def, verb, options, state, runtime) {
|
|
1230
|
+
const dryRun = options.dryRun === true;
|
|
1231
|
+
const force = options.force === true;
|
|
1232
|
+
const res = initialResult(def, verb, runtime);
|
|
1233
|
+
const artifacts = def.artifacts(runtime.home, runtime);
|
|
1234
|
+
try {
|
|
1235
|
+
if (verb === "status" || verb === "doctor") {
|
|
1236
|
+
const art = checkArtifacts(def, artifacts, state, res);
|
|
1237
|
+
const configOk = checkConfig(def.host, state, res, runtime);
|
|
1238
|
+
const hasConfig = def.config !== undefined;
|
|
1239
|
+
if (res.errors.length > 0 || art.blocked)
|
|
1240
|
+
res.state = "blocked";
|
|
1241
|
+
else if (art.all && configOk)
|
|
1242
|
+
res.state = def.installState;
|
|
1243
|
+
else if (art.drift)
|
|
1244
|
+
res.state = "drifted";
|
|
1245
|
+
else if (art.any || (hasConfig && configOk))
|
|
1246
|
+
res.state = "partial";
|
|
1247
|
+
else
|
|
1248
|
+
res.state = "missing";
|
|
1249
|
+
if (verb === "doctor" && dryRun && (res.state === "installed" || res.state === "registered")) {
|
|
1250
|
+
res.actions.push("would run MCP initialize/initialized/tools-list self-test");
|
|
1251
|
+
}
|
|
1252
|
+
else if (verb === "doctor" && (res.state === "installed" || res.state === "registered")) {
|
|
1253
|
+
const mcpError = await mcpSelfTest(runtime);
|
|
1254
|
+
if (mcpError) {
|
|
1255
|
+
res.errors.push(mcpError);
|
|
1256
|
+
res.state = "blocked";
|
|
1257
|
+
}
|
|
1258
|
+
else {
|
|
1259
|
+
res.actions.push("MCP initialize/tools-list self-test passed");
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
res.ok = res.errors.length === 0 && (res.state === "installed" || res.state === "registered");
|
|
1263
|
+
return res;
|
|
1264
|
+
}
|
|
1265
|
+
if (verb === "uninstall") {
|
|
1266
|
+
if (!removeVerifiedLegacy(def, dryRun, res)) {
|
|
1267
|
+
res.state = "blocked";
|
|
1268
|
+
return res;
|
|
1269
|
+
}
|
|
1270
|
+
if (!removeConfig(def.host, state, dryRun, force, def, runtime, res)) {
|
|
1271
|
+
res.state = "blocked";
|
|
1272
|
+
return res;
|
|
1273
|
+
}
|
|
1274
|
+
if (!removeArtifacts(def, artifacts, state, dryRun, force, res)) {
|
|
1275
|
+
res.state = "blocked";
|
|
1276
|
+
return res;
|
|
1277
|
+
}
|
|
1278
|
+
res.state = "missing";
|
|
1279
|
+
res.ok = res.errors.length === 0;
|
|
1280
|
+
return res;
|
|
1281
|
+
}
|
|
1282
|
+
if (!removeVerifiedLegacy(def, dryRun, res)) {
|
|
1283
|
+
res.state = "blocked";
|
|
1284
|
+
return res;
|
|
1285
|
+
}
|
|
1286
|
+
if (!removeObsoleteStateArtifacts(def, artifacts, state, dryRun, force, res)) {
|
|
1287
|
+
res.state = "blocked";
|
|
1288
|
+
return res;
|
|
1289
|
+
}
|
|
1290
|
+
if (!applyArtifacts(def, artifacts, state, dryRun, force, runtime, res)) {
|
|
1291
|
+
res.state = "blocked";
|
|
1292
|
+
return res;
|
|
1293
|
+
}
|
|
1294
|
+
if (def.config === "codex-marketplace" && !mergeCodexMarketplace(state, dryRun, force, def, runtime, res)) {
|
|
1295
|
+
res.state = "blocked";
|
|
1296
|
+
return res;
|
|
1297
|
+
}
|
|
1298
|
+
if (def.config === "opencode-mcp" && !mergeOpenCodeMcp(state, dryRun, force, def, runtime, res)) {
|
|
1299
|
+
res.state = "blocked";
|
|
1300
|
+
return res;
|
|
1301
|
+
}
|
|
1302
|
+
if (def.host === "cursor")
|
|
1303
|
+
checkConfig("cursor", state, res, runtime);
|
|
1304
|
+
res.state = def.installState;
|
|
1305
|
+
// a dry-run must be legible even when nothing needs doing — otherwise an
|
|
1306
|
+
// already-current host prints only its status and reads as if --dry-run was
|
|
1307
|
+
// ignored. Disclose that the lifecycle was evaluated and found no changes.
|
|
1308
|
+
if (dryRun && res.actions.length === 0) {
|
|
1309
|
+
res.actions.push("no changes needed — Claudexor-owned files and config are already current");
|
|
1310
|
+
}
|
|
1311
|
+
res.ok = res.errors.length === 0;
|
|
1312
|
+
return res;
|
|
1313
|
+
}
|
|
1314
|
+
catch (err) {
|
|
1315
|
+
res.state = "blocked";
|
|
1316
|
+
res.errors.push(err instanceof Error ? err.message : String(err));
|
|
1317
|
+
return res;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
function targets(target) {
|
|
1321
|
+
return target === "all" ? PLUGIN_HOSTS : [target];
|
|
1322
|
+
}
|
|
1323
|
+
function exitCodeFor(verb, result) {
|
|
1324
|
+
// `status` reports, but drift/blocked are actionable problems that must be
|
|
1325
|
+
// scriptable: exit 1 so CI/agents can gate on them. Absence states
|
|
1326
|
+
// (missing/partial/installed/registered) stay 0 — not-installed is not an error.
|
|
1327
|
+
if (verb === "status") {
|
|
1328
|
+
return result.results.some((r) => r.state === "drifted" || r.state === "blocked") ? 1 : 0;
|
|
1329
|
+
}
|
|
1330
|
+
return result.results.every((r) => r.ok) ? 0 : 1;
|
|
1331
|
+
}
|
|
1332
|
+
export async function runPluginCommand(verb, target, options = {}) {
|
|
1333
|
+
if (!PLUGIN_VERBS.includes(verb))
|
|
1334
|
+
throw new Error(`unknown plugin command '${verb}'`);
|
|
1335
|
+
if (!PLUGIN_TARGETS.includes(target))
|
|
1336
|
+
throw new Error(`unknown plugin target '${target}' (expected ${PLUGIN_TARGETS.join("|")})`);
|
|
1337
|
+
const runtime = runtimePaths();
|
|
1338
|
+
const state = loadState(runtime.configDir);
|
|
1339
|
+
const results = [];
|
|
1340
|
+
for (const host of targets(target)) {
|
|
1341
|
+
results.push(await runHost(HOST_DEFINITIONS[host], verb, options, state, runtime));
|
|
1342
|
+
}
|
|
1343
|
+
if (!options.dryRun && (verb === "install" || verb === "repair" || verb === "uninstall"))
|
|
1344
|
+
saveState(state, runtime.configDir);
|
|
1345
|
+
const result = {
|
|
1346
|
+
verb,
|
|
1347
|
+
target,
|
|
1348
|
+
dryRun: options.dryRun === true,
|
|
1349
|
+
results,
|
|
1350
|
+
ok: results.every((r) => r.ok),
|
|
1351
|
+
exitCode: 0,
|
|
1352
|
+
};
|
|
1353
|
+
result.exitCode = exitCodeFor(verb, result);
|
|
1354
|
+
return result;
|
|
1355
|
+
}
|
|
1356
|
+
export function formatPluginResult(result) {
|
|
1357
|
+
const lines = [];
|
|
1358
|
+
for (const r of result.results) {
|
|
1359
|
+
const marker = r.ok ? "ok" : result.verb === "status" ? "warn" : "blocked";
|
|
1360
|
+
lines.push(`[${marker}] ${r.host}: ${r.state}${r.changed ? " (changed)" : ""}`);
|
|
1361
|
+
for (const action of r.actions)
|
|
1362
|
+
lines.push(` - ${action}`);
|
|
1363
|
+
for (const note of r.notes)
|
|
1364
|
+
lines.push(` note: ${note}`);
|
|
1365
|
+
for (const warning of r.warnings)
|
|
1366
|
+
lines.push(` warning: ${warning}`);
|
|
1367
|
+
for (const error of r.errors)
|
|
1368
|
+
lines.push(` error: ${error}`);
|
|
1369
|
+
}
|
|
1370
|
+
return lines.join("\n");
|
|
1371
|
+
}
|
|
1372
|
+
export function pluginCommandErrorResult(verb, target, dryRun, exitCode, error) {
|
|
1373
|
+
return {
|
|
1374
|
+
verb: verb ?? null,
|
|
1375
|
+
target: target ?? null,
|
|
1376
|
+
dryRun,
|
|
1377
|
+
results: [],
|
|
1378
|
+
ok: false,
|
|
1379
|
+
exitCode,
|
|
1380
|
+
error,
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
//# sourceMappingURL=plugins.js.map
|