@kiran_nandi_123/conxa 1.0.3 → 1.0.5
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/lib/browser.js +53 -9
- package/lib/cli.js +145 -44
- package/lib/config.js +18 -5
- package/lib/resolver/git.js +21 -2
- package/lib/run.js +12 -8
- package/lib/server.js +162 -13
- package/package.json +1 -1
- package/scripts/install.sh +17 -3
package/lib/browser.js
CHANGED
|
@@ -6,19 +6,36 @@ const { getPluginConfig, getPluginDir, getAuthJson } = require("./config");
|
|
|
6
6
|
|
|
7
7
|
// ─── Browser cache (per-slug, 5-min idle timeout) ────────────────────────────
|
|
8
8
|
|
|
9
|
-
const _cache
|
|
9
|
+
const _cache = new Map(); // slug → { browser, context, idleTimer }
|
|
10
|
+
const _holds = new Map(); // slug → hold count
|
|
11
|
+
const _inflight = new Map(); // slug → Promise<{browser,context}> (dedup concurrent launches)
|
|
10
12
|
const BROWSER_IDLE_MS = 5 * 60 * 1000;
|
|
13
|
+
const BROWSER_MAX = 5;
|
|
11
14
|
|
|
12
15
|
function _scheduleCleanup(slug) {
|
|
13
16
|
const entry = _cache.get(slug);
|
|
14
17
|
if (!entry) return;
|
|
15
18
|
clearTimeout(entry.idleTimer);
|
|
16
19
|
entry.idleTimer = setTimeout(async () => {
|
|
20
|
+
if ((_holds.get(slug) || 0) > 0) {
|
|
21
|
+
_scheduleCleanup(slug); // still held — defer
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
17
24
|
console.error(`[browser-cache] Idle timeout for ${slug} — closing browser`);
|
|
18
25
|
const b = entry.browser;
|
|
19
26
|
_cache.delete(slug);
|
|
20
27
|
if (b) await b.close().catch(() => {});
|
|
21
28
|
}, BROWSER_IDLE_MS);
|
|
29
|
+
entry.idleTimer.unref?.();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function holdBrowser(slug) {
|
|
33
|
+
_holds.set(slug, (_holds.get(slug) || 0) + 1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function releaseBrowserHold(slug) {
|
|
37
|
+
const n = (_holds.get(slug) || 0) - 1;
|
|
38
|
+
if (n <= 0) _holds.delete(slug); else _holds.set(slug, n);
|
|
22
39
|
}
|
|
23
40
|
|
|
24
41
|
async function getCachedBrowser(slug) {
|
|
@@ -33,11 +50,35 @@ async function getCachedBrowser(slug) {
|
|
|
33
50
|
_cache.delete(slug);
|
|
34
51
|
}
|
|
35
52
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
53
|
+
// Dedup concurrent launch requests for the same slug
|
|
54
|
+
if (_inflight.has(slug)) {
|
|
55
|
+
console.error(`[browser-cache] Waiting for in-flight browser launch for ${slug}`);
|
|
56
|
+
return _inflight.get(slug);
|
|
57
|
+
}
|
|
58
|
+
const launch = (async () => {
|
|
59
|
+
try {
|
|
60
|
+
const { browser, context } = await getAuthContext(slug, false);
|
|
61
|
+
// Evict LRU entry if at capacity (skip held browsers)
|
|
62
|
+
if (_cache.size >= BROWSER_MAX) {
|
|
63
|
+
for (const [evictSlug, evictEntry] of _cache.entries()) {
|
|
64
|
+
if ((_holds.get(evictSlug) || 0) > 0) continue;
|
|
65
|
+
clearTimeout(evictEntry.idleTimer);
|
|
66
|
+
_cache.delete(evictSlug);
|
|
67
|
+
if (evictEntry.browser) evictEntry.browser.close().catch(() => {});
|
|
68
|
+
console.error(`[browser-cache] Evicted ${evictSlug} (cache limit ${BROWSER_MAX})`);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
_cache.set(slug, { browser, context, idleTimer: null });
|
|
73
|
+
_scheduleCleanup(slug);
|
|
74
|
+
console.error(`[browser-cache] Launched new browser for ${slug}`);
|
|
75
|
+
return { browser, context, cached: false };
|
|
76
|
+
} finally {
|
|
77
|
+
_inflight.delete(slug);
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
_inflight.set(slug, launch);
|
|
81
|
+
return launch;
|
|
41
82
|
}
|
|
42
83
|
|
|
43
84
|
// ─── Session management ───────────────────────────────────────────────────────
|
|
@@ -76,6 +117,9 @@ async function getAuthContext(slug, headless) {
|
|
|
76
117
|
console.error(`[auth:${slug}] No auth.json — starting manual login`);
|
|
77
118
|
}
|
|
78
119
|
|
|
120
|
+
const isCI = process.env.CI || process.env.DISPLAY === "" || (!process.env.DISPLAY && process.platform === "linux");
|
|
121
|
+
if (isCI) throw new Error(`[auth:${slug}] Cannot open login browser in headless/CI environment. Pre-generate auth.json and place it at ${authJson}`);
|
|
122
|
+
|
|
79
123
|
console.error(`[auth:${slug}] Opening login browser — waiting for user to authenticate...`);
|
|
80
124
|
const loginBrowser = await chromium.launch({ headless: false });
|
|
81
125
|
const loginCtx = await loginBrowser.newContext();
|
|
@@ -92,8 +136,8 @@ async function getAuthContext(slug, headless) {
|
|
|
92
136
|
}
|
|
93
137
|
|
|
94
138
|
const state = await loginCtx.storageState();
|
|
95
|
-
fs.mkdirSync(path.dirname(authJson), { recursive: true });
|
|
96
|
-
fs.writeFileSync(authJson, JSON.stringify(state, null, 2));
|
|
139
|
+
fs.mkdirSync(path.dirname(authJson), { recursive: true, mode: 0o700 });
|
|
140
|
+
fs.writeFileSync(authJson, JSON.stringify(state, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
97
141
|
console.error(`[auth:${slug}] Session saved to auth.json — closing login browser`);
|
|
98
142
|
await loginBrowser.close();
|
|
99
143
|
|
|
@@ -126,4 +170,4 @@ async function gracefulShutdown() {
|
|
|
126
170
|
process.exit(0);
|
|
127
171
|
}
|
|
128
172
|
|
|
129
|
-
module.exports = { getCachedBrowser, isAuthenticated, getAuthContext, gracefulShutdown };
|
|
173
|
+
module.exports = { getCachedBrowser, holdBrowser, releaseBrowserHold, isAuthenticated, getAuthContext, gracefulShutdown };
|
package/lib/cli.js
CHANGED
|
@@ -34,12 +34,53 @@ const SERVER_JS = path.join(RUNTIME_DIR, "server.js");
|
|
|
34
34
|
|
|
35
35
|
function readRegistry() {
|
|
36
36
|
if (!fs.existsSync(REGISTRY_PATH)) return {};
|
|
37
|
-
try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8")); }
|
|
37
|
+
try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8")); }
|
|
38
|
+
catch (e) {
|
|
39
|
+
const bak = REGISTRY_PATH + ".bak";
|
|
40
|
+
try { fs.renameSync(REGISTRY_PATH, bak); } catch (_) {}
|
|
41
|
+
process.stderr.write(`[conxa] Warning: registry.json was corrupt (${e.message}) — backed up to registry.json.bak\n`);
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _atomicWrite(filePath, content) {
|
|
47
|
+
const tmp = filePath + `.tmp.${process.pid}`;
|
|
48
|
+
fs.writeFileSync(tmp, content, "utf8");
|
|
49
|
+
fs.renameSync(tmp, filePath);
|
|
38
50
|
}
|
|
39
51
|
|
|
40
52
|
function writeRegistry(reg) {
|
|
41
53
|
fs.mkdirSync(CONXA_HOME, { recursive: true });
|
|
42
|
-
|
|
54
|
+
_atomicWrite(REGISTRY_PATH, JSON.stringify(reg, null, 2));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Concurrency lock ─────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
const _LOCK_FILE = path.join(os.homedir(), ".conxa", "install.lock");
|
|
60
|
+
const _LOCK_STALE = 30 * 1000; // steal lock after 30 s
|
|
61
|
+
|
|
62
|
+
function _acquireLock() {
|
|
63
|
+
fs.mkdirSync(CONXA_HOME, { recursive: true });
|
|
64
|
+
try {
|
|
65
|
+
const fd = fs.openSync(_LOCK_FILE, "wx");
|
|
66
|
+
fs.writeSync(fd, String(process.pid));
|
|
67
|
+
fs.closeSync(fd);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (e.code !== "EEXIST") throw e;
|
|
70
|
+
try {
|
|
71
|
+
const stat = fs.statSync(_LOCK_FILE);
|
|
72
|
+
if (Date.now() - stat.mtimeMs < _LOCK_STALE)
|
|
73
|
+
throw new Error("Another conxa install/uninstall is already running.");
|
|
74
|
+
fs.unlinkSync(_LOCK_FILE);
|
|
75
|
+
_acquireLock();
|
|
76
|
+
} catch (e2) {
|
|
77
|
+
if (e2.message.startsWith("Another")) throw e2;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _releaseLock() {
|
|
83
|
+
try { fs.unlinkSync(_LOCK_FILE); } catch (_) {}
|
|
43
84
|
}
|
|
44
85
|
|
|
45
86
|
// ─── Claude Code integration ─────────────────────────────────────────────────
|
|
@@ -48,11 +89,11 @@ function _registerGlobalMcp() {
|
|
|
48
89
|
let claudeJson = {};
|
|
49
90
|
try { claudeJson = JSON.parse(fs.readFileSync(CLAUDE_JSON, "utf8")); } catch (_) {}
|
|
50
91
|
const existing = claudeJson.mcpServers && claudeJson.mcpServers.conxa;
|
|
51
|
-
if (existing && existing.args && existing.args[0] === SERVER_JS) return;
|
|
92
|
+
if (existing && existing.type === "stdio" && existing.command === "node" && existing.args && existing.args[0] === SERVER_JS) return;
|
|
52
93
|
if (!claudeJson.mcpServers) claudeJson.mcpServers = {};
|
|
53
94
|
claudeJson.mcpServers.conxa = { type: "stdio", command: "node", args: [SERVER_JS] };
|
|
54
95
|
try {
|
|
55
|
-
|
|
96
|
+
_atomicWrite(CLAUDE_JSON, JSON.stringify(claudeJson, null, 2) + "\n");
|
|
56
97
|
process.stderr.write(`[conxa] Registered conxa MCP server in ${CLAUDE_JSON}\n`);
|
|
57
98
|
} catch (e) {
|
|
58
99
|
process.stderr.write(`[conxa] Warning: could not update .claude.json: ${e.message}\n`);
|
|
@@ -80,7 +121,7 @@ function updateGlobalClaudeMd(reg) {
|
|
|
80
121
|
const entries = Object.values(reg);
|
|
81
122
|
const pluginLines = entries.length === 0
|
|
82
123
|
? "- (no plugins installed)"
|
|
83
|
-
: entries.map(e => `- ${e.slug} →
|
|
124
|
+
: entries.map(e => `- ${e.slug} → ${path.join(CONXA_HOME, "plugins", e.slug, "CLAUDE.md")}`).join("\n");
|
|
84
125
|
|
|
85
126
|
const content = [
|
|
86
127
|
"# Conxa Runtime",
|
|
@@ -103,6 +144,11 @@ function updateGlobalClaudeMd(reg) {
|
|
|
103
144
|
"2. Collect any required inputs from the user's message (or ask once if missing)",
|
|
104
145
|
"3. Call execute_plan immediately — do not explain, do not confirm",
|
|
105
146
|
"",
|
|
147
|
+
"## Installing New Plugins",
|
|
148
|
+
"If the user asks to install a new conxa plugin (e.g. 'install the Vercel plugin'):",
|
|
149
|
+
"- Call the install_plugin MCP tool with the plugin ref (e.g. 'cannonboldoff-hue/vercel')",
|
|
150
|
+
"- No terminal or command needed — the MCP tool handles everything",
|
|
151
|
+
"",
|
|
106
152
|
"## Installed Plugins",
|
|
107
153
|
pluginLines,
|
|
108
154
|
"",
|
|
@@ -137,6 +183,7 @@ function regenerateIndex(reg) {
|
|
|
137
183
|
function copyDirSync(src, dst) {
|
|
138
184
|
fs.mkdirSync(dst, { recursive: true });
|
|
139
185
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
186
|
+
if (entry.isSymbolicLink()) continue;
|
|
140
187
|
const s = path.join(src, entry.name);
|
|
141
188
|
const d = path.join(dst, entry.name);
|
|
142
189
|
if (entry.isDirectory()) copyDirSync(s, d);
|
|
@@ -146,39 +193,60 @@ function copyDirSync(src, dst) {
|
|
|
146
193
|
|
|
147
194
|
// ─── init ─────────────────────────────────────────────────────────────────────
|
|
148
195
|
|
|
196
|
+
function _cliVersion() {
|
|
197
|
+
try { return require("../package.json").version; } catch (_) { return null; }
|
|
198
|
+
}
|
|
199
|
+
|
|
149
200
|
function init() {
|
|
201
|
+
const cliVersion = _cliVersion();
|
|
150
202
|
if (fs.existsSync(VERSION_JSON)) {
|
|
151
203
|
const v = JSON.parse(fs.readFileSync(VERSION_JSON, "utf8"));
|
|
152
|
-
|
|
153
|
-
|
|
204
|
+
if (!cliVersion || v.version === cliVersion) {
|
|
205
|
+
process.stderr.write(`[conxa] Runtime already at v${v.version}\n`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
process.stderr.write(`[conxa] Updating runtime v${v.version} → v${cliVersion}...\n`);
|
|
209
|
+
} else {
|
|
210
|
+
process.stderr.write(`[conxa] Bootstrapping runtime at ${RUNTIME_DIR} ...\n`);
|
|
154
211
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
212
|
+
|
|
213
|
+
// Safe-swap: copy to RUNTIME_DIR.new/, run npm install there, then rename
|
|
214
|
+
// so a failed install never leaves a half-updated runtime.
|
|
215
|
+
const RUNTIME_NEW = RUNTIME_DIR + ".new";
|
|
216
|
+
const RUNTIME_OLD = RUNTIME_DIR + ".old";
|
|
217
|
+
if (fs.existsSync(RUNTIME_NEW)) fs.rmSync(RUNTIME_NEW, { recursive: true, force: true });
|
|
218
|
+
fs.mkdirSync(RUNTIME_NEW, { recursive: true });
|
|
219
|
+
|
|
163
220
|
for (const entry of fs.readdirSync(__dirname, { withFileTypes: true })) {
|
|
164
|
-
if (entry.name === "node_modules" || entry.name === ".bootstrapped") continue;
|
|
221
|
+
if (entry.name === "node_modules" || entry.name === ".bootstrapped" || entry.isSymbolicLink()) continue;
|
|
165
222
|
const src = path.join(__dirname, entry.name);
|
|
166
|
-
const dst = path.join(
|
|
223
|
+
const dst = path.join(RUNTIME_NEW, entry.name);
|
|
167
224
|
if (entry.isDirectory()) copyDirSync(src, dst);
|
|
168
225
|
else fs.copyFileSync(src, dst);
|
|
169
226
|
}
|
|
170
227
|
|
|
171
228
|
process.stderr.write("[conxa] Running npm install...\n");
|
|
172
|
-
|
|
229
|
+
try {
|
|
230
|
+
execSync("npm install --prefer-offline --silent", { cwd: RUNTIME_NEW, stdio: ["ignore", "pipe", "inherit"] });
|
|
231
|
+
} catch (e) {
|
|
232
|
+
// Retry once without --prefer-offline in case cache is stale
|
|
233
|
+
process.stderr.write("[conxa] npm install failed, retrying without cache...\n");
|
|
234
|
+
execSync("npm install --silent", { cwd: RUNTIME_NEW, stdio: ["ignore", "pipe", "inherit"] });
|
|
235
|
+
}
|
|
173
236
|
|
|
174
237
|
process.stderr.write("[conxa] Installing Playwright Chromium...\n");
|
|
175
|
-
execSync("npx playwright install chromium", { cwd:
|
|
238
|
+
execSync("npx playwright install chromium", { cwd: RUNTIME_NEW, stdio: ["ignore", "pipe", "inherit"] });
|
|
239
|
+
|
|
240
|
+
// Atomic swap: old → .old, new → active
|
|
241
|
+
if (fs.existsSync(RUNTIME_DIR)) {
|
|
242
|
+
if (fs.existsSync(RUNTIME_OLD)) fs.rmSync(RUNTIME_OLD, { recursive: true, force: true });
|
|
243
|
+
fs.renameSync(RUNTIME_DIR, RUNTIME_OLD);
|
|
244
|
+
}
|
|
245
|
+
fs.renameSync(RUNTIME_NEW, RUNTIME_DIR);
|
|
246
|
+
if (fs.existsSync(RUNTIME_OLD)) fs.rmSync(RUNTIME_OLD, { recursive: true, force: true });
|
|
176
247
|
|
|
177
|
-
const pkg = fs.existsSync(path.join(RUNTIME_DIR, "package.json"))
|
|
178
|
-
? JSON.parse(fs.readFileSync(path.join(RUNTIME_DIR, "package.json"), "utf8"))
|
|
179
|
-
: {};
|
|
180
248
|
fs.writeFileSync(VERSION_JSON, JSON.stringify({
|
|
181
|
-
version:
|
|
249
|
+
version: cliVersion || "1.0.0",
|
|
182
250
|
installed_at: new Date().toISOString(),
|
|
183
251
|
node_version: process.version,
|
|
184
252
|
}, null, 2));
|
|
@@ -196,11 +264,12 @@ function init() {
|
|
|
196
264
|
fs.writeFileSync(path.join(CONXA_HOME, ".bootstrapped"), "1", "utf8");
|
|
197
265
|
|
|
198
266
|
process.stderr.write("[conxa] Bootstrap complete.\n");
|
|
267
|
+
if (cliVersion) process.stderr.write(`[conxa] Runtime is now v${cliVersion}. Restart Claude Code Desktop to apply.\n`);
|
|
199
268
|
}
|
|
200
269
|
|
|
201
270
|
// ─── install ──────────────────────────────────────────────────────────────────
|
|
202
271
|
|
|
203
|
-
function _installFromLocalDir(pluginDir) {
|
|
272
|
+
function _installFromLocalDir(pluginDir, sourceRef = null) {
|
|
204
273
|
if (!pluginDir) throw new Error("install: plugin directory path required");
|
|
205
274
|
const absDir = path.resolve(pluginDir);
|
|
206
275
|
if (!fs.existsSync(absDir)) throw new Error(`Plugin directory not found: ${absDir}`);
|
|
@@ -213,10 +282,31 @@ function _installFromLocalDir(pluginDir) {
|
|
|
213
282
|
if (!cfg.target_url) throw new Error("plugin.json missing: target_url");
|
|
214
283
|
if (!cfg.protected_url) throw new Error("plugin.json missing: protected_url");
|
|
215
284
|
|
|
285
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(cfg.slug))
|
|
286
|
+
throw new Error(`plugin.json slug "${cfg.slug}" contains invalid characters`);
|
|
287
|
+
for (const s of (cfg.skills || [])) {
|
|
288
|
+
const p = s.path || `skills/${s.slug}`;
|
|
289
|
+
if (p.includes("..") || path.isAbsolute(p))
|
|
290
|
+
throw new Error(`Skill path "${p}" is not allowed`);
|
|
291
|
+
}
|
|
292
|
+
|
|
216
293
|
const slug = cfg.slug;
|
|
217
294
|
const destDir = path.join(PLUGINS_DIR, slug);
|
|
218
295
|
|
|
219
296
|
process.stderr.write(`[conxa] Installing plugin '${slug}' from ${absDir}...\n`);
|
|
297
|
+
if (fs.existsSync(destDir)) {
|
|
298
|
+
// Preserve auth/ across reinstall — save and restore
|
|
299
|
+
const authDir = path.join(destDir, "auth");
|
|
300
|
+
const authSave = fs.existsSync(authDir) ? fs.readdirSync(authDir)
|
|
301
|
+
.filter(f => f !== "credentials.example.json")
|
|
302
|
+
.reduce((m, f) => { m[f] = fs.readFileSync(path.join(authDir, f)); return m; }, {}) : {};
|
|
303
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
304
|
+
if (Object.keys(authSave).length) {
|
|
305
|
+
fs.mkdirSync(path.join(destDir, "auth"), { recursive: true, mode: 0o700 });
|
|
306
|
+
for (const [name, buf] of Object.entries(authSave))
|
|
307
|
+
fs.writeFileSync(path.join(destDir, "auth", name), buf, { mode: 0o600 });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
220
310
|
fs.mkdirSync(destDir, { recursive: true });
|
|
221
311
|
|
|
222
312
|
// Copy plugin manifest
|
|
@@ -250,6 +340,7 @@ function _installFromLocalDir(pluginDir) {
|
|
|
250
340
|
protected_url: cfg.protected_url,
|
|
251
341
|
skills: skillsList,
|
|
252
342
|
installed_at: new Date().toISOString(),
|
|
343
|
+
...(sourceRef ? { source_ref: sourceRef } : {}),
|
|
253
344
|
};
|
|
254
345
|
const reg = readRegistry();
|
|
255
346
|
reg[slug] = entry;
|
|
@@ -274,9 +365,11 @@ function _ensureInitialized() {
|
|
|
274
365
|
// before delegating to _installFromLocalDir.
|
|
275
366
|
async function install(ref) {
|
|
276
367
|
if (!ref) throw new Error("install: <ref> required (local dir, owner/repo, or plugin_id)");
|
|
368
|
+
_acquireLock();
|
|
369
|
+
try {
|
|
277
370
|
_ensureInitialized();
|
|
278
371
|
if (fs.existsSync(ref) && fs.statSync(ref).isDirectory()) {
|
|
279
|
-
return _installFromLocalDir(ref);
|
|
372
|
+
return _installFromLocalDir(ref, null);
|
|
280
373
|
}
|
|
281
374
|
// Look in cache first (already downloaded). cache.stagedDir() takes a
|
|
282
375
|
// plugin_id+version pair; we accept either "id" or "id@version".
|
|
@@ -285,16 +378,17 @@ async function install(ref) {
|
|
|
285
378
|
const ver = at > 0 ? ref.slice(at + 1) : null;
|
|
286
379
|
const cache = require("./resolver/cache");
|
|
287
380
|
const staged = cache.stagedDir(id, ver);
|
|
288
|
-
if (staged) return _installFromLocalDir(staged);
|
|
381
|
+
if (staged) return _installFromLocalDir(staged, ref);
|
|
289
382
|
// git resolver handles owner/repo and full https URLs. It stages into cache/
|
|
290
383
|
// and returns the staged directory path.
|
|
291
384
|
const git = require("./resolver/git");
|
|
292
385
|
const resolved = await git.resolve(ref);
|
|
293
|
-
if (resolved && resolved.staged_dir) return _installFromLocalDir(resolved.staged_dir);
|
|
386
|
+
if (resolved && resolved.staged_dir) return _installFromLocalDir(resolved.staged_dir, ref);
|
|
294
387
|
// Registry resolver is contract-only today; falls through when no hosted
|
|
295
388
|
// registry is configured. When implemented it would download a tarball into
|
|
296
389
|
// cache/ and return the staged path.
|
|
297
390
|
throw new Error(`install: could not resolve '${ref}'`);
|
|
391
|
+
} finally { _releaseLock(); }
|
|
298
392
|
}
|
|
299
393
|
|
|
300
394
|
// ─── search ───────────────────────────────────────────────────────────────────
|
|
@@ -339,21 +433,24 @@ function registryLogout(url) {
|
|
|
339
433
|
|
|
340
434
|
function uninstall(slug) {
|
|
341
435
|
if (!slug) throw new Error("uninstall: slug required");
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
436
|
+
_acquireLock();
|
|
437
|
+
try {
|
|
438
|
+
const destDir = path.join(PLUGINS_DIR, slug);
|
|
439
|
+
if (fs.existsSync(destDir)) {
|
|
440
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
441
|
+
process.stderr.write(`[conxa] Removed plugin directory: ${destDir}\n`);
|
|
442
|
+
}
|
|
443
|
+
const reg = readRegistry();
|
|
444
|
+
if (reg[slug]) {
|
|
445
|
+
delete reg[slug];
|
|
446
|
+
writeRegistry(reg);
|
|
447
|
+
updateGlobalClaudeMd(reg);
|
|
448
|
+
regenerateIndex(reg);
|
|
449
|
+
process.stderr.write(`[conxa] Removed '${slug}' from registry\n`);
|
|
450
|
+
} else {
|
|
451
|
+
process.stderr.write(`[conxa] Plugin '${slug}' was not in registry\n`);
|
|
452
|
+
}
|
|
453
|
+
} finally { _releaseLock(); }
|
|
357
454
|
}
|
|
358
455
|
|
|
359
456
|
// ─── list ─────────────────────────────────────────────────────────────────────
|
|
@@ -376,7 +473,11 @@ async function runCli(argv) {
|
|
|
376
473
|
const [cmd, ...rest] = argv;
|
|
377
474
|
try {
|
|
378
475
|
switch (cmd) {
|
|
379
|
-
case "init":
|
|
476
|
+
case "init":
|
|
477
|
+
case "update":
|
|
478
|
+
if (cmd === "update" && fs.existsSync(VERSION_JSON)) fs.unlinkSync(VERSION_JSON);
|
|
479
|
+
init();
|
|
480
|
+
break;
|
|
380
481
|
case "install": await install(rest[0]); break;
|
|
381
482
|
case "uninstall": uninstall(rest[0]); break;
|
|
382
483
|
case "list": list(); break;
|
|
@@ -387,7 +488,7 @@ async function runCli(argv) {
|
|
|
387
488
|
else throw new Error("registry: login <url> <token> | logout <url>");
|
|
388
489
|
break;
|
|
389
490
|
default:
|
|
390
|
-
process.stderr.write("Usage: conxa <init|install <ref>|uninstall <slug>|list|search <q>|registry login|registry logout>\n");
|
|
491
|
+
process.stderr.write("Usage: conxa <init|update|install <ref>|uninstall <slug>|list|search <q>|registry login|registry logout>\n");
|
|
391
492
|
process.exit(1);
|
|
392
493
|
}
|
|
393
494
|
} catch (e) {
|
package/lib/config.js
CHANGED
|
@@ -21,18 +21,31 @@ function getAuthJson(slug) {
|
|
|
21
21
|
|
|
22
22
|
function getPluginConfig(slug) {
|
|
23
23
|
const cfgPath = path.join(getPluginDir(slug), "plugin.json");
|
|
24
|
-
return JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
24
|
+
try { return JSON.parse(fs.readFileSync(cfgPath, "utf8")); }
|
|
25
|
+
catch (e) { throw new Error(`Plugin "${slug}" has a malformed plugin.json: ${e.message}`); }
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
function getRegistry() {
|
|
28
29
|
if (!fs.existsSync(REGISTRY_PATH)) return {};
|
|
29
|
-
try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8")); }
|
|
30
|
+
try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8")); }
|
|
31
|
+
catch (e) {
|
|
32
|
+
const bak = REGISTRY_PATH + ".bak";
|
|
33
|
+
try { fs.renameSync(REGISTRY_PATH, bak); } catch (_) {}
|
|
34
|
+
process.stderr.write(`[conxa] Warning: registry.json was corrupt (${e.message}) — backed up to registry.json.bak\n`);
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
function getRegistryAuth() {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
const fromEnv = process.env.CONXA_REGISTRY_TOKEN;
|
|
41
|
+
let stored = { registries: [] };
|
|
42
|
+
if (fs.existsSync(REGISTRY_AUTH_PATH)) {
|
|
43
|
+
try { stored = JSON.parse(fs.readFileSync(REGISTRY_AUTH_PATH, "utf8")); }
|
|
44
|
+
catch (_) { stored = { registries: [] }; }
|
|
45
|
+
}
|
|
46
|
+
if (fromEnv && !stored.registries.some(r => r.token === fromEnv))
|
|
47
|
+
stored.registries = [{ name: "env", url: "*", token: fromEnv }, ...stored.registries];
|
|
48
|
+
return stored;
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
function writeRegistryAuth(payload) {
|
package/lib/resolver/git.js
CHANGED
|
@@ -21,7 +21,10 @@ function _parseRef(ref) {
|
|
|
21
21
|
const text = String(ref || "").trim();
|
|
22
22
|
if (!text) return null;
|
|
23
23
|
let m = _GH_OWNER_REPO.exec(text);
|
|
24
|
-
if (m)
|
|
24
|
+
if (m) {
|
|
25
|
+
const repo = m[2].replace(/\.git$/, "");
|
|
26
|
+
return { url: `https://github.com/${m[1]}/${repo}.git`, version: m[3] || null, id: `${m[1]}/${repo}` };
|
|
27
|
+
}
|
|
25
28
|
m = _GIT_URL.exec(text);
|
|
26
29
|
if (m) {
|
|
27
30
|
const url = `${m[1]}.git`;
|
|
@@ -31,16 +34,31 @@ function _parseRef(ref) {
|
|
|
31
34
|
return null;
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
function _ensureGit() {
|
|
38
|
+
try { execFileSync("git", ["--version"], { stdio: "ignore" }); }
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (e.code === "ENOENT") throw new Error("git is not installed — install it to use owner/repo plugin refs");
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
function _clone(url, version, destDir) {
|
|
35
46
|
const args = ["clone", "--depth", "1"];
|
|
36
47
|
if (version) args.push("--branch", version);
|
|
37
48
|
args.push(url, destDir);
|
|
38
|
-
|
|
49
|
+
try {
|
|
50
|
+
execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"], timeout: 60000 });
|
|
51
|
+
} catch (e) {
|
|
52
|
+
// Clean up partial clone so the next attempt starts fresh
|
|
53
|
+
try { require("fs").rmSync(destDir, { recursive: true, force: true }); } catch (_) {}
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
async function resolve(pluginRef) {
|
|
42
59
|
const parsed = _parseRef(pluginRef);
|
|
43
60
|
if (!parsed) return null;
|
|
61
|
+
_ensureGit();
|
|
44
62
|
const version = parsed.version || "main";
|
|
45
63
|
const staged = cache.stagedDir(parsed.id, version);
|
|
46
64
|
if (staged) return { staged_dir: staged, plugin_id: parsed.id, version, source: "git" };
|
|
@@ -50,6 +68,7 @@ async function resolve(pluginRef) {
|
|
|
50
68
|
} catch (e) {
|
|
51
69
|
// Retry without branch if version refspec is wrong (lets us still clone HEAD)
|
|
52
70
|
if (parsed.version) {
|
|
71
|
+
console.error(`[git] Branch "${parsed.version}" not found — falling back to default branch`);
|
|
53
72
|
try {
|
|
54
73
|
const fallback = cache.ensureStagedDir(parsed.id, "main");
|
|
55
74
|
_clone(parsed.url, null, fallback);
|
package/lib/run.js
CHANGED
|
@@ -5,14 +5,17 @@ const { CONXA_HOME } = require("./config");
|
|
|
5
5
|
|
|
6
6
|
// ─── Retry budget (L0) ────────────────────────────────────────────────────────
|
|
7
7
|
|
|
8
|
-
const _retryBudget = new Map();
|
|
8
|
+
const _retryBudget = new Map(); // key → { count, ts }
|
|
9
9
|
const RETRY_BUDGET_MAX = 5;
|
|
10
|
+
const RETRY_BUDGET_TTL = 10 * 60 * 1000; // reset after 10 min idle
|
|
10
11
|
|
|
11
12
|
function checkRetryBudget(slug, stepIndex) {
|
|
12
|
-
const key
|
|
13
|
-
const
|
|
14
|
-
_retryBudget.
|
|
15
|
-
|
|
13
|
+
const key = `${slug}:${stepIndex}`;
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
const prev = _retryBudget.get(key);
|
|
16
|
+
const count = (prev && now - prev.ts < RETRY_BUDGET_TTL) ? prev.count + 1 : 1;
|
|
17
|
+
_retryBudget.set(key, { count, ts: now });
|
|
18
|
+
if (count > RETRY_BUDGET_MAX) {
|
|
16
19
|
appendRecoveryEvent({ event: "retry_budget_exhausted", slug, step_index: stepIndex });
|
|
17
20
|
console.error(`[recovery] L0 budget exhausted for ${key}`);
|
|
18
21
|
return false;
|
|
@@ -32,8 +35,10 @@ const RECOVERY_LOG_MAX = 10 * 1024 * 1024;
|
|
|
32
35
|
|
|
33
36
|
function appendRecoveryEvent(event) {
|
|
34
37
|
try {
|
|
35
|
-
if (fs.existsSync(RECOVERY_LOG) && fs.statSync(RECOVERY_LOG).size > RECOVERY_LOG_MAX)
|
|
38
|
+
if (fs.existsSync(RECOVERY_LOG) && fs.statSync(RECOVERY_LOG).size > RECOVERY_LOG_MAX) {
|
|
39
|
+
if (fs.existsSync(RECOVERY_LOG + ".1")) fs.renameSync(RECOVERY_LOG + ".1", RECOVERY_LOG + ".2");
|
|
36
40
|
fs.renameSync(RECOVERY_LOG, RECOVERY_LOG + ".1");
|
|
41
|
+
}
|
|
37
42
|
fs.appendFileSync(RECOVERY_LOG, JSON.stringify({ ts: new Date().toISOString(), ...event }) + "\n");
|
|
38
43
|
} catch (_) {}
|
|
39
44
|
}
|
|
@@ -224,9 +229,8 @@ async function runPlan(page, steps, inputs, startFrom, slug) {
|
|
|
224
229
|
for (let i = startFrom; i < steps.length; i++) {
|
|
225
230
|
const step = steps[i];
|
|
226
231
|
|
|
227
|
-
// Settle: wait for DOM
|
|
232
|
+
// Settle: wait for DOM before each step
|
|
228
233
|
await page.waitForLoadState("domcontentloaded", { timeout: 5000 }).catch(() => {});
|
|
229
|
-
await page.waitForLoadState("networkidle", { timeout: 3000 }).catch(() => {});
|
|
230
234
|
|
|
231
235
|
// Pre-step screenshot (interactive steps only)
|
|
232
236
|
const isInteractive = INTERACTIVE.has(step.type);
|
package/lib/server.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
|
+
|
|
4
|
+
// Require Node >=20
|
|
5
|
+
const _nodeMajor = parseInt(process.version.slice(1), 10);
|
|
6
|
+
if (_nodeMajor < 20) {
|
|
7
|
+
process.stderr.write(`[conxa] Node.js ${process.version} is too old — requires >=20. Update Node and run: npx -y @kiran_nandi_123/conxa init\n`);
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
|
|
4
12
|
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
5
13
|
const { CallToolRequestSchema, ListToolsRequestSchema } = require("@modelcontextprotocol/sdk/types.js");
|
|
@@ -12,20 +20,124 @@ const _PID_FILE = path.join(os.homedir(), ".conxa", "runtime", "server.pid");
|
|
|
12
20
|
try { fs.writeFileSync(_PID_FILE, String(process.pid)); } catch (_) {}
|
|
13
21
|
const _cleanPid = () => { try { fs.unlinkSync(_PID_FILE); } catch (_) {} };
|
|
14
22
|
process.on("exit", _cleanPid);
|
|
15
|
-
process.on("SIGINT", () => process.exit(0));
|
|
16
|
-
process.on("SIGTERM", () => process.exit(0));
|
|
17
23
|
|
|
18
24
|
const { getPluginConfig, getPluginDir, getAuthJson, getRegistry } = require("./config");
|
|
19
|
-
const { getCachedBrowser, isAuthenticated, getAuthContext, gracefulShutdown } = require("./browser");
|
|
25
|
+
const { getCachedBrowser, holdBrowser, releaseBrowserHold, isAuthenticated, getAuthContext, gracefulShutdown } = require("./browser");
|
|
20
26
|
const {
|
|
21
27
|
appendRecoveryEvent, interpolate, enrichStepsWithRecovery,
|
|
22
28
|
waitForUrlState, runPlan, runSkill, checkRetryBudget, clearRetryBudget,
|
|
23
29
|
} = require("./run");
|
|
24
30
|
|
|
31
|
+
// ─── Version helpers ──────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const CONXA_HOME = path.join(os.homedir(), ".conxa");
|
|
34
|
+
const VERSION_JSON = path.join(CONXA_HOME, "runtime", "version.json");
|
|
35
|
+
const UPDATE_CHECK = path.join(CONXA_HOME, "last_update_check.json");
|
|
36
|
+
const NPM_PACKAGE = "@kiran_nandi_123/conxa";
|
|
37
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
function _semverGte(installed, required) {
|
|
40
|
+
// Strip leading 'v', split on '.', ignore pre-release suffix (pre-release < release)
|
|
41
|
+
const parse = v => String(v || "0").replace(/^v/i, "").split(".").map((p, i) =>
|
|
42
|
+
i < 3 ? (parseInt(p, 10) || 0) : 0
|
|
43
|
+
);
|
|
44
|
+
const hasPre = v => /[-+]/.test(String(v || "").replace(/^v/i, ""));
|
|
45
|
+
const a = parse(installed), b = parse(required);
|
|
46
|
+
for (let i = 0; i < 3; i++) {
|
|
47
|
+
if (a[i] !== b[i]) return a[i] > b[i];
|
|
48
|
+
}
|
|
49
|
+
// Equal version numbers: pre-release is less than release
|
|
50
|
+
return !hasPre(installed) || hasPre(required);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function _installedVersion() {
|
|
54
|
+
try { return JSON.parse(fs.readFileSync(VERSION_JSON, "utf8")).version; } catch (_) { return null; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function _shouldCheckToday() {
|
|
58
|
+
try {
|
|
59
|
+
const { last_check } = JSON.parse(fs.readFileSync(UPDATE_CHECK, "utf8"));
|
|
60
|
+
return (Date.now() - last_check) > ONE_DAY_MS;
|
|
61
|
+
} catch (_) { return true; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let _needsRestart = false;
|
|
65
|
+
|
|
66
|
+
function _startupUpdateCheck() {
|
|
67
|
+
if (!_shouldCheckToday()) return;
|
|
68
|
+
const https = require("https");
|
|
69
|
+
const req = https.get(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`, { timeout: 5000 }, (res) => {
|
|
70
|
+
let data = "";
|
|
71
|
+
res.on("data", chunk => { data += chunk; });
|
|
72
|
+
res.on("end", () => {
|
|
73
|
+
try {
|
|
74
|
+
fs.writeFileSync(UPDATE_CHECK, JSON.stringify({ last_check: Date.now() }));
|
|
75
|
+
const latest = JSON.parse(data).version;
|
|
76
|
+
const installed = _installedVersion();
|
|
77
|
+
if (!latest || !installed || installed === latest) return;
|
|
78
|
+
console.error(`[conxa] Auto-updating runtime v${installed} → v${latest}...`);
|
|
79
|
+
const { spawn } = require("child_process");
|
|
80
|
+
const proc = spawn(
|
|
81
|
+
process.execPath, ["-e", `require("child_process").execFileSync(process.execPath,[require.resolve("${NPM_PACKAGE}/lib/cli.js"),"init"],{stdio:"inherit"})`],
|
|
82
|
+
{ detached: true, stdio: "ignore" }
|
|
83
|
+
);
|
|
84
|
+
proc.unref();
|
|
85
|
+
_needsRestart = true;
|
|
86
|
+
console.error(`[conxa] Update running in background. Restart Claude Code Desktop to apply v${latest}.`);
|
|
87
|
+
} catch (_) {}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
req.on("error", () => {});
|
|
91
|
+
req.on("timeout", () => req.destroy());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_startupUpdateCheck();
|
|
95
|
+
|
|
96
|
+
async function _pluginUpdateCheck() {
|
|
97
|
+
if (!_shouldCheckToday()) return;
|
|
98
|
+
const registry = getRegistry();
|
|
99
|
+
const https = require("https");
|
|
100
|
+
for (const [slug, entry] of Object.entries(registry)) {
|
|
101
|
+
const ref = entry.source_ref;
|
|
102
|
+
if (!ref || !/^[^/]+\/[^/]+$/.test(ref.split("@")[0])) continue;
|
|
103
|
+
if (ref.includes("@")) continue; // version-pinned — don't auto-update
|
|
104
|
+
const ownerRepo = ref.split("@")[0];
|
|
105
|
+
const [owner, repo] = ownerRepo.split("/");
|
|
106
|
+
await new Promise((resolve) => {
|
|
107
|
+
const url = `https://raw.githubusercontent.com/${owner}/${repo}/main/plugin.json`;
|
|
108
|
+
const req = https.get(url, { timeout: 5000 }, (res) => {
|
|
109
|
+
let data = "";
|
|
110
|
+
res.on("data", chunk => { data += chunk; });
|
|
111
|
+
res.on("end", () => {
|
|
112
|
+
try {
|
|
113
|
+
const latest = JSON.parse(data).version;
|
|
114
|
+
if (!latest || latest === entry.version) return resolve();
|
|
115
|
+
console.error(`[conxa] Plugin update available: ${slug} v${entry.version} → v${latest}. Run: conxa install ${ownerRepo}`);
|
|
116
|
+
resolve();
|
|
117
|
+
} catch (_) { resolve(); }
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
req.on("error", () => resolve());
|
|
121
|
+
req.on("timeout", () => { req.destroy(); resolve(); });
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_pluginUpdateCheck().catch(() => {});
|
|
127
|
+
|
|
25
128
|
// ─── Registry helpers ─────────────────────────────────────────────────────────
|
|
26
129
|
|
|
27
|
-
|
|
130
|
+
let _skillIndexCache = null;
|
|
131
|
+
let _skillIndexMtime = 0;
|
|
132
|
+
|
|
133
|
+
// Returns { slug → { pluginSlug, skill, skillDir } } — cached until registry.json changes
|
|
28
134
|
function buildSkillIndex() {
|
|
135
|
+
const { REGISTRY_PATH } = require("./config");
|
|
136
|
+
try {
|
|
137
|
+
const mtime = fs.existsSync(REGISTRY_PATH) ? fs.statSync(REGISTRY_PATH).mtimeMs : 0;
|
|
138
|
+
if (_skillIndexCache && mtime === _skillIndexMtime) return _skillIndexCache;
|
|
139
|
+
_skillIndexMtime = mtime;
|
|
140
|
+
} catch (_) {}
|
|
29
141
|
const registry = getRegistry();
|
|
30
142
|
const index = {};
|
|
31
143
|
for (const [pluginSlug, entry] of Object.entries(registry)) {
|
|
@@ -35,9 +147,17 @@ function buildSkillIndex() {
|
|
|
35
147
|
index[key] = { pluginSlug, skill, skillDir: path.join(pluginDir, skill.path || `skills/${skill.slug}`) };
|
|
36
148
|
}
|
|
37
149
|
}
|
|
150
|
+
_skillIndexCache = index;
|
|
38
151
|
return index;
|
|
39
152
|
}
|
|
40
153
|
|
|
154
|
+
function invalidateSkillIndex() { _skillIndexCache = null; }
|
|
155
|
+
|
|
156
|
+
function _restartNotice() {
|
|
157
|
+
if (!_needsRestart) return null;
|
|
158
|
+
return "⚠️ Conxa runtime update is installing in the background. Restart Claude Code Desktop to apply it, then retry.";
|
|
159
|
+
}
|
|
160
|
+
|
|
41
161
|
function resolveSkill(pluginSlug, skillSlug, index) {
|
|
42
162
|
// Exact match with plugin prefix
|
|
43
163
|
if (pluginSlug) {
|
|
@@ -49,12 +169,14 @@ function resolveSkill(pluginSlug, skillSlug, index) {
|
|
|
49
169
|
return v;
|
|
50
170
|
}
|
|
51
171
|
}
|
|
52
|
-
// Slug-only: match across all plugins
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
172
|
+
// Slug-only: match across all plugins — warn if ambiguous
|
|
173
|
+
const norm = s => s.replace(/-/g, "_");
|
|
174
|
+
const matches = Object.values(index).filter(v =>
|
|
175
|
+
v.skill.slug === skillSlug || norm(v.skill.slug) === norm(skillSlug)
|
|
176
|
+
);
|
|
177
|
+
if (matches.length > 1)
|
|
178
|
+
console.error(`[conxa] Warning: skill "${skillSlug}" matches multiple plugins (${matches.map(v => v.pluginSlug).join(", ")}) — using first match. Specify plugin: to disambiguate.`);
|
|
179
|
+
return matches[0] || null;
|
|
58
180
|
}
|
|
59
181
|
|
|
60
182
|
// ─── MCP server ───────────────────────────────────────────────────────────────
|
|
@@ -169,10 +291,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
169
291
|
return { tools };
|
|
170
292
|
});
|
|
171
293
|
|
|
294
|
+
|
|
295
|
+
function _checkPluginRuntimeVersion(pluginSlug) {
|
|
296
|
+
try {
|
|
297
|
+
const pluginDir = getPluginDir(pluginSlug);
|
|
298
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "plugin.json"), "utf8"));
|
|
299
|
+
const minVer = manifest.runtime_min_version;
|
|
300
|
+
if (!minVer) return null;
|
|
301
|
+
const installed = _installedVersion();
|
|
302
|
+
if (!installed || _semverGte(installed, minVer)) return null;
|
|
303
|
+
return `Plugin "${pluginSlug}" requires conxa runtime >=${minVer} (installed: ${installed}). Run: npx -y ${NPM_PACKAGE} install ${pluginSlug}`;
|
|
304
|
+
} catch (_) { return null; }
|
|
305
|
+
}
|
|
306
|
+
|
|
172
307
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
173
308
|
const { name, arguments: args } = request.params;
|
|
174
309
|
const skillIndex = buildSkillIndex();
|
|
175
310
|
|
|
311
|
+
// Surface restart notice on first call after a background update completes
|
|
312
|
+
const restartMsg = _restartNotice();
|
|
313
|
+
if (restartMsg) return { content: [{ type: "text", text: restartMsg }] };
|
|
314
|
+
|
|
176
315
|
// ── list_skills ───────────────────────────────────────────────────────────
|
|
177
316
|
if (name === "list_skills") {
|
|
178
317
|
const filterPlugin = args && args.plugin ? String(args.plugin) : null;
|
|
@@ -243,7 +382,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
243
382
|
try {
|
|
244
383
|
const installRef = version && !ref.includes("@") ? `${ref}@${version}` : ref;
|
|
245
384
|
const entry = await cli.install(installRef);
|
|
246
|
-
|
|
385
|
+
invalidateSkillIndex();
|
|
386
|
+
return { content: [{ type: "text", text: `Installed ${entry.slug} v${entry.version}. Skills: ${(entry.skills || []).map(s => s.slug).join(", ")}. Call list_skills to see available skills.` }] };
|
|
247
387
|
} catch (e) {
|
|
248
388
|
return { content: [{ type: "text", text: `install_plugin failed: ${e.message}` }] };
|
|
249
389
|
}
|
|
@@ -256,6 +396,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
256
396
|
const cli = require("./cli");
|
|
257
397
|
try {
|
|
258
398
|
cli.uninstall(slug);
|
|
399
|
+
invalidateSkillIndex();
|
|
259
400
|
return { content: [{ type: "text", text: `Uninstalled ${slug}` }] };
|
|
260
401
|
} catch (e) {
|
|
261
402
|
return { content: [{ type: "text", text: `uninstall_plugin failed: ${e.message}` }] };
|
|
@@ -312,6 +453,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
312
453
|
const found = resolveSkill(pluginArg, slug, skillIndex);
|
|
313
454
|
if (!found) return { content: [{ type: "text", text: `Skill not found: ${slug}. Call list_skills.` }] };
|
|
314
455
|
const { skillDir, pluginSlug } = found;
|
|
456
|
+
const versionErr = _checkPluginRuntimeVersion(pluginSlug);
|
|
457
|
+
if (versionErr) return { content: [{ type: "text", text: versionErr }] };
|
|
315
458
|
const rawExec = fs.existsSync(path.join(skillDir, "execution.json"))
|
|
316
459
|
? JSON.parse(fs.readFileSync(path.join(skillDir, "execution.json"), "utf8")) : null;
|
|
317
460
|
const rawRec = fs.existsSync(path.join(skillDir, "recovery.json"))
|
|
@@ -329,6 +472,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
329
472
|
|
|
330
473
|
// Determine which plugin to use for auth (first skill's plugin)
|
|
331
474
|
const primaryPlugin = resolved[0].pluginSlug;
|
|
475
|
+
const uniquePlugins = [...new Set(resolved.map(r => r.pluginSlug))];
|
|
476
|
+
if (uniquePlugins.length > 1)
|
|
477
|
+
console.error(`[execute_plan] Warning: plan spans ${uniquePlugins.length} plugins (${uniquePlugins.join(", ")}) — only ${primaryPlugin} auth context will be used`);
|
|
332
478
|
|
|
333
479
|
// ── Layer 0: Retry budget gate ────────────────────────────────────────
|
|
334
480
|
if (resumeFrom > 0) {
|
|
@@ -344,6 +490,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
344
490
|
} catch (authErr) {
|
|
345
491
|
return { content: [{ type: "text", text: String(authErr) }] };
|
|
346
492
|
}
|
|
493
|
+
holdBrowser(primaryPlugin);
|
|
347
494
|
|
|
348
495
|
const runtimeLog = { consoleErrors: [], pageErrors: [], failedRequests: [] };
|
|
349
496
|
const page = await _context.newPage();
|
|
@@ -399,8 +546,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
399
546
|
// ── Success ───────────────────────────────────────────────────────────
|
|
400
547
|
const authJson = getAuthJson(primaryPlugin);
|
|
401
548
|
const state = await _context.storageState();
|
|
402
|
-
fs.mkdirSync(path.dirname(authJson), { recursive: true });
|
|
403
|
-
fs.writeFileSync(authJson, JSON.stringify(state, null, 2));
|
|
549
|
+
fs.mkdirSync(path.dirname(authJson), { recursive: true, mode: 0o700 });
|
|
550
|
+
fs.writeFileSync(authJson, JSON.stringify(state, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
404
551
|
const shot = await page.screenshot({ type: "png" }).catch(() => null);
|
|
405
552
|
const url = page.url();
|
|
406
553
|
await page.close().catch(() => {});
|
|
@@ -411,6 +558,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
411
558
|
console.error(`[execute_plan] Done. URL: ${url}`);
|
|
412
559
|
const content = [{ type: "text", text: `Done. URL: ${url}` }];
|
|
413
560
|
if (shot) content.push({ type: "image", data: shot.toString("base64"), mimeType: "image/png" });
|
|
561
|
+
releaseBrowserHold(primaryPlugin);
|
|
414
562
|
return { content };
|
|
415
563
|
|
|
416
564
|
} catch (err) {
|
|
@@ -500,6 +648,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
500
648
|
if (runtimeLog.failedRequests.length > 0) l5.push(`Failed requests:\n${JSON.stringify(runtimeLog.failedRequests,null, 2)}`);
|
|
501
649
|
content.push({ type: "text", text: l5.join("\n") });
|
|
502
650
|
|
|
651
|
+
releaseBrowserHold(primaryPlugin);
|
|
503
652
|
return { content };
|
|
504
653
|
}
|
|
505
654
|
}
|
package/package.json
CHANGED
package/scripts/install.sh
CHANGED
|
@@ -14,12 +14,26 @@ if ! command -v node &>/dev/null; then
|
|
|
14
14
|
if command -v brew &>/dev/null; then
|
|
15
15
|
brew install node
|
|
16
16
|
else
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
NODE_VER="20.18.0"
|
|
18
|
+
echo "[conxa] Downloading Node.js ${NODE_VER}..."
|
|
19
|
+
curl -fsSL "https://nodejs.org/dist/v${NODE_VER}/node-v${NODE_VER}.pkg" -o /tmp/_conxa_node.pkg
|
|
20
|
+
sudo installer -pkg /tmp/_conxa_node.pkg -target /
|
|
21
|
+
rm -f /tmp/_conxa_node.pkg
|
|
19
22
|
fi
|
|
20
|
-
|
|
23
|
+
elif command -v apt-get &>/dev/null; then
|
|
21
24
|
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
|
|
22
25
|
sudo apt-get install -y nodejs
|
|
26
|
+
elif command -v yum &>/dev/null; then
|
|
27
|
+
curl -fsSL https://rpm.nodesource.com/setup_lts.x | sudo bash -
|
|
28
|
+
sudo yum install -y nodejs
|
|
29
|
+
else
|
|
30
|
+
NODE_VER="20.18.0"
|
|
31
|
+
ARCH=$(uname -m)
|
|
32
|
+
[[ "$ARCH" == "x86_64" ]] && ARCH="x64"
|
|
33
|
+
[[ "$ARCH" == "aarch64" ]] && ARCH="arm64"
|
|
34
|
+
echo "[conxa] Downloading Node.js binary..."
|
|
35
|
+
curl -fsSL "https://nodejs.org/dist/v${NODE_VER}/node-v${NODE_VER}-linux-${ARCH}.tar.xz" \
|
|
36
|
+
| sudo tar -xJ -C /usr/local --strip-components=1
|
|
23
37
|
fi
|
|
24
38
|
fi
|
|
25
39
|
|