@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 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
- npx -y conxa install <plugin_id>
10
- ```
11
-
12
- Plugin refs accepted:
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 = new Map(); // slug → { browser, context, idleTimer }
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
- const { browser, context } = await getAuthContext(slug, false);
37
- _cache.set(slug, { browser, context, idleTimer: null });
38
- _scheduleCleanup(slug);
39
- console.error(`[browser-cache] Launched new browser for ${slug}`);
40
- return { browser, context, cached: false };
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")); } catch (_) { return {}; }
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
- fs.writeFileSync(REGISTRY_PATH, JSON.stringify(reg, null, 2));
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
- fs.writeFileSync(CLAUDE_JSON, JSON.stringify(claudeJson, null, 2) + "\n", "utf8");
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} → ~/.conxa/plugins/${e.slug}/CLAUDE.md`).join("\n");
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
- process.stderr.write(`[conxa] Runtime already bootstrapped at ${RUNTIME_DIR} (v${v.version})\n`);
158
- return;
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
- process.stderr.write(`[conxa] Bootstrapping runtime at ${RUNTIME_DIR} ...\n`);
161
-
162
- // All runtime files live alongside cli.js in both layouts: the in-repo
163
- // template tree (app/storage/plugin_templates/runtime/) and the published
164
- // npm package (packages/conxa-cli/lib/). Copy the whole directory so new
165
- // files (resolver/, search.js, etc.) ship automatically without having to
166
- // maintain a hardcoded allow-list.
167
- fs.mkdirSync(RUNTIME_DIR, { recursive: true });
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(RUNTIME_DIR, entry.name);
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
- execSync("npm install --prefer-offline --silent", { cwd: RUNTIME_DIR, stdio: ["ignore", "pipe", "inherit"] });
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: RUNTIME_DIR, stdio: ["ignore", "pipe", "inherit"] });
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: pkg.version || "1.0.0",
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
- const destDir = path.join(PLUGINS_DIR, slug);
348
- if (fs.existsSync(destDir)) {
349
- fs.rmSync(destDir, { recursive: true, force: true });
350
- process.stderr.write(`[conxa] Removed plugin directory: ${destDir}\n`);
351
- }
352
- const reg = readRegistry();
353
- if (reg[slug]) {
354
- delete reg[slug];
355
- writeRegistry(reg);
356
- updateGlobalClaudeMd(reg);
357
- regenerateIndex(reg);
358
- process.stderr.write(`[conxa] Removed '${slug}' from registry\n`);
359
- } else {
360
- process.stderr.write(`[conxa] Plugin '${slug}' was not in registry\n`);
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": init(); break;
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")); } catch (_) { return {}; }
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
- if (!fs.existsSync(REGISTRY_AUTH_PATH)) return { registries: [] };
34
- try { return JSON.parse(fs.readFileSync(REGISTRY_AUTH_PATH, "utf8")); }
35
- catch (_) { return { registries: [] }; }
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) {
@@ -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) return { url: `https://github.com/${m[1]}/${m[2]}.git`, version: m[3] || null, id: `${m[1]}/${m[2]}` };
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
- execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] });
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 = `${slug}:${stepIndex}`;
13
- const attempts = (_retryBudget.get(key) || 0) + 1;
14
- _retryBudget.set(key, attempts);
15
- if (attempts > RETRY_BUDGET_MAX) {
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 + network before each step
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
- // Returns { slug → { pluginSlug, skill, skillDir } } for fast lookup
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
- for (const v of Object.values(index)) {
54
- if (v.skill.slug === skillSlug || v.skill.slug.replace(/-/g, "_") === skillSlug.replace(/-/g, "_"))
55
- return v;
56
- }
57
- return null;
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
- return { content: [{ type: "text", text: `Installed ${entry.slug} v${entry.version}. Skills: ${(entry.skills || []).map(s => s.slug).join(", ")}` }] };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kiran_nandi_123/conxa",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Conxa CLI — install and manage shared-runtime automation plugins",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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
- npx -y "@kiran_nandi_123/conxa" install $PluginId
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