@kiran_nandi_123/conxa 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -7
- package/lib/browser.js +53 -9
- package/lib/cli.js +140 -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.ps1 +11 -1
package/README.md
CHANGED
|
@@ -3,13 +3,30 @@
|
|
|
3
3
|
CLI for installing and running Conxa automation plugins on top of the
|
|
4
4
|
shared `conxa` MCP runtime.
|
|
5
5
|
|
|
6
|
-
## Install plugins
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
## Install plugins
|
|
7
|
+
|
|
8
|
+
PowerShell on Windows:
|
|
9
|
+
|
|
10
|
+
```powershell
|
|
11
|
+
npx.cmd -y "@kiran_nandi_123/conxa" install "cannonboldoff-hue/render"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Generic shell:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
npx -y "@kiran_nandi_123/conxa" install <plugin_id>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Avoid running downloaded scripts inline with `irm` + `scriptblock`. If you need
|
|
21
|
+
the PowerShell installer, download and inspect it first:
|
|
22
|
+
|
|
23
|
+
```powershell
|
|
24
|
+
irm "https://cdn.jsdelivr.net/npm/@kiran_nandi_123/conxa/scripts/install.ps1" -OutFile ".\install-conxa.ps1"
|
|
25
|
+
Get-Content ".\install-conxa.ps1"
|
|
26
|
+
powershell -ExecutionPolicy Bypass -File ".\install-conxa.ps1" "cannonboldoff-hue/render"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Plugin refs accepted:
|
|
13
30
|
|
|
14
31
|
- `acme/hr-onboarding` — GitHub `owner/repo` (cloned via git)
|
|
15
32
|
- `acme/hr-onboarding@v1.0.0` — pinned version (git tag)
|
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",
|
|
@@ -142,6 +183,7 @@ function regenerateIndex(reg) {
|
|
|
142
183
|
function copyDirSync(src, dst) {
|
|
143
184
|
fs.mkdirSync(dst, { recursive: true });
|
|
144
185
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
186
|
+
if (entry.isSymbolicLink()) continue;
|
|
145
187
|
const s = path.join(src, entry.name);
|
|
146
188
|
const d = path.join(dst, entry.name);
|
|
147
189
|
if (entry.isDirectory()) copyDirSync(s, d);
|
|
@@ -151,39 +193,60 @@ function copyDirSync(src, dst) {
|
|
|
151
193
|
|
|
152
194
|
// ─── init ─────────────────────────────────────────────────────────────────────
|
|
153
195
|
|
|
196
|
+
function _cliVersion() {
|
|
197
|
+
try { return require("../package.json").version; } catch (_) { return null; }
|
|
198
|
+
}
|
|
199
|
+
|
|
154
200
|
function init() {
|
|
201
|
+
const cliVersion = _cliVersion();
|
|
155
202
|
if (fs.existsSync(VERSION_JSON)) {
|
|
156
203
|
const v = JSON.parse(fs.readFileSync(VERSION_JSON, "utf8"));
|
|
157
|
-
|
|
158
|
-
|
|
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`);
|
|
159
211
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
|
|
168
220
|
for (const entry of fs.readdirSync(__dirname, { withFileTypes: true })) {
|
|
169
|
-
if (entry.name === "node_modules" || entry.name === ".bootstrapped") continue;
|
|
221
|
+
if (entry.name === "node_modules" || entry.name === ".bootstrapped" || entry.isSymbolicLink()) continue;
|
|
170
222
|
const src = path.join(__dirname, entry.name);
|
|
171
|
-
const dst = path.join(
|
|
223
|
+
const dst = path.join(RUNTIME_NEW, entry.name);
|
|
172
224
|
if (entry.isDirectory()) copyDirSync(src, dst);
|
|
173
225
|
else fs.copyFileSync(src, dst);
|
|
174
226
|
}
|
|
175
227
|
|
|
176
228
|
process.stderr.write("[conxa] Running npm install...\n");
|
|
177
|
-
|
|
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
|
+
}
|
|
178
236
|
|
|
179
237
|
process.stderr.write("[conxa] Installing Playwright Chromium...\n");
|
|
180
|
-
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 });
|
|
181
247
|
|
|
182
|
-
const pkg = fs.existsSync(path.join(RUNTIME_DIR, "package.json"))
|
|
183
|
-
? JSON.parse(fs.readFileSync(path.join(RUNTIME_DIR, "package.json"), "utf8"))
|
|
184
|
-
: {};
|
|
185
248
|
fs.writeFileSync(VERSION_JSON, JSON.stringify({
|
|
186
|
-
version:
|
|
249
|
+
version: cliVersion || "1.0.0",
|
|
187
250
|
installed_at: new Date().toISOString(),
|
|
188
251
|
node_version: process.version,
|
|
189
252
|
}, null, 2));
|
|
@@ -201,11 +264,12 @@ function init() {
|
|
|
201
264
|
fs.writeFileSync(path.join(CONXA_HOME, ".bootstrapped"), "1", "utf8");
|
|
202
265
|
|
|
203
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`);
|
|
204
268
|
}
|
|
205
269
|
|
|
206
270
|
// ─── install ──────────────────────────────────────────────────────────────────
|
|
207
271
|
|
|
208
|
-
function _installFromLocalDir(pluginDir) {
|
|
272
|
+
function _installFromLocalDir(pluginDir, sourceRef = null) {
|
|
209
273
|
if (!pluginDir) throw new Error("install: plugin directory path required");
|
|
210
274
|
const absDir = path.resolve(pluginDir);
|
|
211
275
|
if (!fs.existsSync(absDir)) throw new Error(`Plugin directory not found: ${absDir}`);
|
|
@@ -218,10 +282,31 @@ function _installFromLocalDir(pluginDir) {
|
|
|
218
282
|
if (!cfg.target_url) throw new Error("plugin.json missing: target_url");
|
|
219
283
|
if (!cfg.protected_url) throw new Error("plugin.json missing: protected_url");
|
|
220
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
|
+
|
|
221
293
|
const slug = cfg.slug;
|
|
222
294
|
const destDir = path.join(PLUGINS_DIR, slug);
|
|
223
295
|
|
|
224
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
|
+
}
|
|
225
310
|
fs.mkdirSync(destDir, { recursive: true });
|
|
226
311
|
|
|
227
312
|
// Copy plugin manifest
|
|
@@ -255,6 +340,7 @@ function _installFromLocalDir(pluginDir) {
|
|
|
255
340
|
protected_url: cfg.protected_url,
|
|
256
341
|
skills: skillsList,
|
|
257
342
|
installed_at: new Date().toISOString(),
|
|
343
|
+
...(sourceRef ? { source_ref: sourceRef } : {}),
|
|
258
344
|
};
|
|
259
345
|
const reg = readRegistry();
|
|
260
346
|
reg[slug] = entry;
|
|
@@ -279,9 +365,11 @@ function _ensureInitialized() {
|
|
|
279
365
|
// before delegating to _installFromLocalDir.
|
|
280
366
|
async function install(ref) {
|
|
281
367
|
if (!ref) throw new Error("install: <ref> required (local dir, owner/repo, or plugin_id)");
|
|
368
|
+
_acquireLock();
|
|
369
|
+
try {
|
|
282
370
|
_ensureInitialized();
|
|
283
371
|
if (fs.existsSync(ref) && fs.statSync(ref).isDirectory()) {
|
|
284
|
-
return _installFromLocalDir(ref);
|
|
372
|
+
return _installFromLocalDir(ref, null);
|
|
285
373
|
}
|
|
286
374
|
// Look in cache first (already downloaded). cache.stagedDir() takes a
|
|
287
375
|
// plugin_id+version pair; we accept either "id" or "id@version".
|
|
@@ -290,16 +378,17 @@ async function install(ref) {
|
|
|
290
378
|
const ver = at > 0 ? ref.slice(at + 1) : null;
|
|
291
379
|
const cache = require("./resolver/cache");
|
|
292
380
|
const staged = cache.stagedDir(id, ver);
|
|
293
|
-
if (staged) return _installFromLocalDir(staged);
|
|
381
|
+
if (staged) return _installFromLocalDir(staged, ref);
|
|
294
382
|
// git resolver handles owner/repo and full https URLs. It stages into cache/
|
|
295
383
|
// and returns the staged directory path.
|
|
296
384
|
const git = require("./resolver/git");
|
|
297
385
|
const resolved = await git.resolve(ref);
|
|
298
|
-
if (resolved && resolved.staged_dir) return _installFromLocalDir(resolved.staged_dir);
|
|
386
|
+
if (resolved && resolved.staged_dir) return _installFromLocalDir(resolved.staged_dir, ref);
|
|
299
387
|
// Registry resolver is contract-only today; falls through when no hosted
|
|
300
388
|
// registry is configured. When implemented it would download a tarball into
|
|
301
389
|
// cache/ and return the staged path.
|
|
302
390
|
throw new Error(`install: could not resolve '${ref}'`);
|
|
391
|
+
} finally { _releaseLock(); }
|
|
303
392
|
}
|
|
304
393
|
|
|
305
394
|
// ─── search ───────────────────────────────────────────────────────────────────
|
|
@@ -344,21 +433,24 @@ function registryLogout(url) {
|
|
|
344
433
|
|
|
345
434
|
function uninstall(slug) {
|
|
346
435
|
if (!slug) throw new Error("uninstall: slug required");
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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(); }
|
|
362
454
|
}
|
|
363
455
|
|
|
364
456
|
// ─── list ─────────────────────────────────────────────────────────────────────
|
|
@@ -381,7 +473,11 @@ async function runCli(argv) {
|
|
|
381
473
|
const [cmd, ...rest] = argv;
|
|
382
474
|
try {
|
|
383
475
|
switch (cmd) {
|
|
384
|
-
case "init":
|
|
476
|
+
case "init":
|
|
477
|
+
case "update":
|
|
478
|
+
if (cmd === "update" && fs.existsSync(VERSION_JSON)) fs.unlinkSync(VERSION_JSON);
|
|
479
|
+
init();
|
|
480
|
+
break;
|
|
385
481
|
case "install": await install(rest[0]); break;
|
|
386
482
|
case "uninstall": uninstall(rest[0]); break;
|
|
387
483
|
case "list": list(); break;
|
|
@@ -392,7 +488,7 @@ async function runCli(argv) {
|
|
|
392
488
|
else throw new Error("registry: login <url> <token> | logout <url>");
|
|
393
489
|
break;
|
|
394
490
|
default:
|
|
395
|
-
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");
|
|
396
492
|
process.exit(1);
|
|
397
493
|
}
|
|
398
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.ps1
CHANGED
|
@@ -6,4 +6,14 @@ if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
|
|
6
6
|
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
$NpxCommand = Get-Command npx.cmd -ErrorAction SilentlyContinue
|
|
10
|
+
if (-not $NpxCommand) {
|
|
11
|
+
$NpxCommand = Get-Command npx -ErrorAction SilentlyContinue
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (-not $NpxCommand) {
|
|
15
|
+
throw "[conxa] npx not found. Install Node.js LTS, then retry."
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
$NpxPath = if ($NpxCommand.Source) { $NpxCommand.Source } else { $NpxCommand.Path }
|
|
19
|
+
& $NpxPath -y "@kiran_nandi_123/conxa" install $PluginId
|