@kiran_nandi_123/conxa 1.0.0
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 +44 -0
- package/bin/conxa.js +7 -0
- package/lib/browser.js +129 -0
- package/lib/cli.js +398 -0
- package/lib/config.js +48 -0
- package/lib/package.json +16 -0
- package/lib/resolver/cache.js +89 -0
- package/lib/resolver/git.js +76 -0
- package/lib/resolver/installed.js +65 -0
- package/lib/resolver/registry.js +88 -0
- package/lib/run.js +367 -0
- package/lib/runtime.js +6 -0
- package/lib/search.js +51 -0
- package/lib/server.js +516 -0
- package/package.json +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# conxa
|
|
2
|
+
|
|
3
|
+
CLI for installing and running Conxa automation plugins on top of the
|
|
4
|
+
shared `conxa` MCP runtime.
|
|
5
|
+
|
|
6
|
+
## Install plugins
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
npx -y conxa install <plugin_id>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Plugin refs accepted:
|
|
13
|
+
|
|
14
|
+
- `acme/hr-onboarding` — GitHub `owner/repo` (cloned via git)
|
|
15
|
+
- `acme/hr-onboarding@v1.0.0` — pinned version (git tag)
|
|
16
|
+
- `https://github.com/acme/hr-onboarding` — full git URL
|
|
17
|
+
- `./my-plugin` — local directory
|
|
18
|
+
|
|
19
|
+
The first install bootstraps `~/.conxa/runtime/`, registers the `conxa`
|
|
20
|
+
MCP server in `~/.claude/settings.json`, and imports the per-plugin
|
|
21
|
+
CLAUDE.md instructions into your global `~/.claude/CLAUDE.md`. After
|
|
22
|
+
that, Claude Code discovers every installed plugin through a single MCP
|
|
23
|
+
connection — no per-plugin setup.
|
|
24
|
+
|
|
25
|
+
## Other commands
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
conxa list # list installed plugins
|
|
29
|
+
conxa search <query> # search installed + cached + registry plugins
|
|
30
|
+
conxa uninstall <slug> # remove an installed plugin
|
|
31
|
+
conxa init # explicit runtime bootstrap (auto-runs on install)
|
|
32
|
+
conxa registry login <url> <token>
|
|
33
|
+
conxa registry logout <url>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Architecture
|
|
37
|
+
|
|
38
|
+
All installed plugins share one MCP server (`conxa`), one Chromium auth
|
|
39
|
+
cache, and one runtime install. Plugins themselves are data-only
|
|
40
|
+
packages — recorded workflows compiled into deterministic
|
|
41
|
+
`execution.json` + `recovery.json` files, with a 5-layer recovery
|
|
42
|
+
cascade implemented inside the runtime.
|
|
43
|
+
|
|
44
|
+
See https://conxa.ai for the broader platform.
|
package/bin/conxa.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// Thin shim — all logic lives in lib/cli.js. The same cli.js is also installed
|
|
4
|
+
// into ~/.conxa/runtime/ by `conxa init` so the in-runtime server.js can shell
|
|
5
|
+
// to it directly without needing the npm bin on $PATH.
|
|
6
|
+
const { runCli } = require("../lib/cli.js");
|
|
7
|
+
runCli(process.argv.slice(2));
|
package/lib/browser.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { chromium } = require("playwright");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { getPluginConfig, getPluginDir, getAuthJson } = require("./config");
|
|
6
|
+
|
|
7
|
+
// ─── Browser cache (per-slug, 5-min idle timeout) ────────────────────────────
|
|
8
|
+
|
|
9
|
+
const _cache = new Map(); // slug → { browser, context, idleTimer }
|
|
10
|
+
const BROWSER_IDLE_MS = 5 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
function _scheduleCleanup(slug) {
|
|
13
|
+
const entry = _cache.get(slug);
|
|
14
|
+
if (!entry) return;
|
|
15
|
+
clearTimeout(entry.idleTimer);
|
|
16
|
+
entry.idleTimer = setTimeout(async () => {
|
|
17
|
+
console.error(`[browser-cache] Idle timeout for ${slug} — closing browser`);
|
|
18
|
+
const b = entry.browser;
|
|
19
|
+
_cache.delete(slug);
|
|
20
|
+
if (b) await b.close().catch(() => {});
|
|
21
|
+
}, BROWSER_IDLE_MS);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function getCachedBrowser(slug) {
|
|
25
|
+
const entry = _cache.get(slug);
|
|
26
|
+
if (entry && entry.browser && entry.context) {
|
|
27
|
+
try {
|
|
28
|
+
entry.context.pages(); // throws if context is closed
|
|
29
|
+
_scheduleCleanup(slug);
|
|
30
|
+
console.error(`[browser-cache] Reusing cached browser for ${slug}`);
|
|
31
|
+
return { browser: entry.browser, context: entry.context, cached: true };
|
|
32
|
+
} catch (_) {
|
|
33
|
+
_cache.delete(slug);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
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 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Session management ───────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function isAuthenticated(page, protectedUrl) {
|
|
46
|
+
try {
|
|
47
|
+
const u = new URL(page.url());
|
|
48
|
+
return u.hostname === new URL(protectedUrl).hostname && !u.pathname.startsWith("/login");
|
|
49
|
+
} catch (_) { return false; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function getAuthContext(slug, headless) {
|
|
53
|
+
const cfg = getPluginConfig(slug);
|
|
54
|
+
const protectedUrl = cfg.protected_url;
|
|
55
|
+
const targetUrl = cfg.target_url;
|
|
56
|
+
const authJson = getAuthJson(slug);
|
|
57
|
+
|
|
58
|
+
if (fs.existsSync(authJson)) {
|
|
59
|
+
let stored;
|
|
60
|
+
try { stored = JSON.parse(fs.readFileSync(authJson, "utf8")); } catch (_) {}
|
|
61
|
+
if (stored) {
|
|
62
|
+
const browser = await chromium.launch({ headless: headless !== false });
|
|
63
|
+
const context = await browser.newContext({ storageState: stored });
|
|
64
|
+
const page = await context.newPage();
|
|
65
|
+
await page.goto(protectedUrl, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
|
|
66
|
+
await page.waitForTimeout(1500);
|
|
67
|
+
if (isAuthenticated(page, protectedUrl)) {
|
|
68
|
+
await page.close();
|
|
69
|
+
console.error(`[auth:${slug}] Session restored from auth.json`);
|
|
70
|
+
return { browser, context };
|
|
71
|
+
}
|
|
72
|
+
await browser.close();
|
|
73
|
+
console.error(`[auth:${slug}] Stored session expired — starting manual login`);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
console.error(`[auth:${slug}] No auth.json — starting manual login`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.error(`[auth:${slug}] Opening login browser — waiting for user to authenticate...`);
|
|
80
|
+
const loginBrowser = await chromium.launch({ headless: false });
|
|
81
|
+
const loginCtx = await loginBrowser.newContext();
|
|
82
|
+
const loginPage = await loginCtx.newPage();
|
|
83
|
+
await loginPage.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
84
|
+
try {
|
|
85
|
+
await loginPage.waitForURL(
|
|
86
|
+
url => url.href.startsWith(protectedUrl) && !url.href.includes("/login"),
|
|
87
|
+
{ timeout: 300000 }
|
|
88
|
+
);
|
|
89
|
+
} catch (_) {
|
|
90
|
+
await loginBrowser.close();
|
|
91
|
+
throw new Error("Authentication timed out after 5 minutes. Please try again.");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const state = await loginCtx.storageState();
|
|
95
|
+
fs.mkdirSync(path.dirname(authJson), { recursive: true });
|
|
96
|
+
fs.writeFileSync(authJson, JSON.stringify(state, null, 2));
|
|
97
|
+
console.error(`[auth:${slug}] Session saved to auth.json — closing login browser`);
|
|
98
|
+
await loginBrowser.close();
|
|
99
|
+
|
|
100
|
+
console.error(`[auth:${slug}] Relaunching authenticated browser...`);
|
|
101
|
+
const browser = await chromium.launch({ headless: headless !== false });
|
|
102
|
+
const context = await browser.newContext({ storageState: state });
|
|
103
|
+
const page = await context.newPage();
|
|
104
|
+
await page.goto(protectedUrl, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
|
|
105
|
+
await page.waitForTimeout(1500);
|
|
106
|
+
if (!isAuthenticated(page, protectedUrl)) {
|
|
107
|
+
await browser.close();
|
|
108
|
+
throw new Error("Authenticated navigation failed after login — unexpected error.");
|
|
109
|
+
}
|
|
110
|
+
await page.close();
|
|
111
|
+
console.error(`[auth:${slug}] Authenticated context ready`);
|
|
112
|
+
return { browser, context };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Graceful shutdown ────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
async function gracefulShutdown() {
|
|
118
|
+
for (const [slug, entry] of _cache.entries()) {
|
|
119
|
+
clearTimeout(entry.idleTimer);
|
|
120
|
+
if (entry.browser) {
|
|
121
|
+
console.error(`[browser-cache] SIGINT/SIGTERM — closing browser for ${slug}`);
|
|
122
|
+
await entry.browser.close().catch(() => {});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
_cache.clear();
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { getCachedBrowser, isAuthenticated, getAuthContext, gracefulShutdown };
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* cli.js — Conxa runtime manager
|
|
5
|
+
*
|
|
6
|
+
* Commands:
|
|
7
|
+
* init Bootstrap ~/.conxa/runtime/ (idempotent)
|
|
8
|
+
* install <ref> Install a plugin. <ref> is a local dir, "owner/repo",
|
|
9
|
+
* "owner/repo@v1.0.0", or an https git URL. Resolver
|
|
10
|
+
* chain: installed → cache → git → registry.
|
|
11
|
+
* uninstall <slug> Remove an installed plugin
|
|
12
|
+
* list Print all installed plugins
|
|
13
|
+
* search <query> Search installed + cached + registry plugins
|
|
14
|
+
* registry login <url> <tok> Save credentials for a private registry
|
|
15
|
+
* registry logout <url> Remove credentials for a registry
|
|
16
|
+
*/
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const os = require("os");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const { execSync } = require("child_process");
|
|
21
|
+
|
|
22
|
+
const CONXA_HOME = path.join(os.homedir(), ".conxa");
|
|
23
|
+
const RUNTIME_DIR = path.join(CONXA_HOME, "runtime");
|
|
24
|
+
const PLUGINS_DIR = path.join(CONXA_HOME, "plugins");
|
|
25
|
+
const REGISTRY_PATH = path.join(CONXA_HOME, "registry.json");
|
|
26
|
+
const CONXA_CLAUDE_MD = path.join(CONXA_HOME, "CLAUDE.md");
|
|
27
|
+
const CONXA_INDEX_MD = path.join(CONXA_HOME, "index.md");
|
|
28
|
+
const VERSION_JSON = path.join(RUNTIME_DIR, "version.json");
|
|
29
|
+
const SETTINGS_JSON = path.join(os.homedir(), ".claude", "settings.json");
|
|
30
|
+
const GLOBAL_CLAUDE_MD = path.join(os.homedir(), ".claude", "CLAUDE.md");
|
|
31
|
+
const SERVER_JS = path.join(RUNTIME_DIR, "server.js");
|
|
32
|
+
|
|
33
|
+
// ─── Registry helpers ─────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function readRegistry() {
|
|
36
|
+
if (!fs.existsSync(REGISTRY_PATH)) return {};
|
|
37
|
+
try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8")); } catch (_) { return {}; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeRegistry(reg) {
|
|
41
|
+
fs.mkdirSync(CONXA_HOME, { recursive: true });
|
|
42
|
+
fs.writeFileSync(REGISTRY_PATH, JSON.stringify(reg, null, 2));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Claude Code integration ─────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function _registerGlobalMcp() {
|
|
48
|
+
let settings = {};
|
|
49
|
+
try { settings = JSON.parse(fs.readFileSync(SETTINGS_JSON, "utf8")); } catch (_) {}
|
|
50
|
+
const existing = settings.mcpServers && settings.mcpServers.conxa;
|
|
51
|
+
if (existing && existing.args && existing.args[0] === SERVER_JS) return;
|
|
52
|
+
if (!settings.mcpServers) settings.mcpServers = {};
|
|
53
|
+
settings.mcpServers.conxa = { command: "node", args: [SERVER_JS] };
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(path.dirname(SETTINGS_JSON), { recursive: true });
|
|
56
|
+
fs.writeFileSync(SETTINGS_JSON, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
57
|
+
process.stderr.write(`[conxa] Registered conxa MCP server in ${SETTINGS_JSON}\n`);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
process.stderr.write(`[conxa] Warning: could not update settings.json: ${e.message}\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _registerGlobalClaudeMd() {
|
|
64
|
+
const importLine = `@${CONXA_CLAUDE_MD}`;
|
|
65
|
+
let existing = "";
|
|
66
|
+
try { existing = fs.readFileSync(GLOBAL_CLAUDE_MD, "utf8"); } catch (_) {}
|
|
67
|
+
if (existing.includes(importLine)) return;
|
|
68
|
+
try {
|
|
69
|
+
fs.mkdirSync(path.dirname(GLOBAL_CLAUDE_MD), { recursive: true });
|
|
70
|
+
const separator = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
71
|
+
fs.appendFileSync(GLOBAL_CLAUDE_MD, `${separator}\n${importLine}\n`, "utf8");
|
|
72
|
+
process.stderr.write(`[conxa] Registered ~/.conxa/CLAUDE.md in ${GLOBAL_CLAUDE_MD}\n`);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
process.stderr.write(`[conxa] Warning: could not update ~/.claude/CLAUDE.md: ${e.message}\n`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Discovery file helpers ───────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function updateGlobalClaudeMd(reg) {
|
|
81
|
+
const entries = Object.values(reg);
|
|
82
|
+
const pluginLines = entries.length === 0
|
|
83
|
+
? "- (no plugins installed)"
|
|
84
|
+
: entries.map(e => `- ${e.slug} → ~/.conxa/plugins/${e.slug}/CLAUDE.md`).join("\n");
|
|
85
|
+
|
|
86
|
+
const content = [
|
|
87
|
+
"# Conxa Runtime",
|
|
88
|
+
"",
|
|
89
|
+
"You have the Conxa automation runtime. It executes real Playwright browser workflows.",
|
|
90
|
+
"",
|
|
91
|
+
"## Mandatory Rules",
|
|
92
|
+
"- NEVER use computer_use, computer-use, or any built-in browser tool",
|
|
93
|
+
"- NEVER ask the user about authentication — auth is 100% automatic",
|
|
94
|
+
"- Always call list_skills before execute_plan",
|
|
95
|
+
"",
|
|
96
|
+
"## Discovery Flow",
|
|
97
|
+
"1. Call list_skills to see all installed plugins and available skills",
|
|
98
|
+
"2. For plugin-specific instructions, read ~/.conxa/plugins/{slug}/CLAUDE.md",
|
|
99
|
+
"3. For skill details, read ~/.conxa/plugins/{slug}/index.md",
|
|
100
|
+
"",
|
|
101
|
+
"## Installed Plugins",
|
|
102
|
+
pluginLines,
|
|
103
|
+
"",
|
|
104
|
+
].join("\n");
|
|
105
|
+
|
|
106
|
+
fs.mkdirSync(CONXA_HOME, { recursive: true });
|
|
107
|
+
fs.writeFileSync(CONXA_CLAUDE_MD, content, "utf8");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function regenerateIndex(reg) {
|
|
111
|
+
const entries = Object.values(reg);
|
|
112
|
+
const rows = entries.map(e => {
|
|
113
|
+
const skills = (e.skills || []).map(s => s.slug).join(", ") || "—";
|
|
114
|
+
return `| ${e.slug} | ${e.name || e.slug} | ${skills} | ${e.target_url || "—"} |`;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const lines = [
|
|
118
|
+
"# Conxa Plugin Index",
|
|
119
|
+
"",
|
|
120
|
+
"| Slug | Name | Skills | Target |",
|
|
121
|
+
"|------|------|--------|--------|",
|
|
122
|
+
...rows,
|
|
123
|
+
"",
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
fs.mkdirSync(CONXA_HOME, { recursive: true });
|
|
127
|
+
fs.writeFileSync(CONXA_INDEX_MD, lines.join("\n"), "utf8");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Copy directory recursively ───────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function copyDirSync(src, dst) {
|
|
133
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
134
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
135
|
+
const s = path.join(src, entry.name);
|
|
136
|
+
const d = path.join(dst, entry.name);
|
|
137
|
+
if (entry.isDirectory()) copyDirSync(s, d);
|
|
138
|
+
else fs.copyFileSync(s, d);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── init ─────────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
function init() {
|
|
145
|
+
if (fs.existsSync(VERSION_JSON)) {
|
|
146
|
+
const v = JSON.parse(fs.readFileSync(VERSION_JSON, "utf8"));
|
|
147
|
+
process.stderr.write(`[conxa] Runtime already bootstrapped at ${RUNTIME_DIR} (v${v.version})\n`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
process.stderr.write(`[conxa] Bootstrapping runtime at ${RUNTIME_DIR} ...\n`);
|
|
151
|
+
|
|
152
|
+
// All runtime files live alongside cli.js in both layouts: the in-repo
|
|
153
|
+
// template tree (app/storage/plugin_templates/runtime/) and the published
|
|
154
|
+
// npm package (packages/conxa-cli/lib/). Copy the whole directory so new
|
|
155
|
+
// files (resolver/, search.js, etc.) ship automatically without having to
|
|
156
|
+
// maintain a hardcoded allow-list.
|
|
157
|
+
fs.mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
158
|
+
for (const entry of fs.readdirSync(__dirname, { withFileTypes: true })) {
|
|
159
|
+
if (entry.name === "node_modules" || entry.name === ".bootstrapped") continue;
|
|
160
|
+
const src = path.join(__dirname, entry.name);
|
|
161
|
+
const dst = path.join(RUNTIME_DIR, entry.name);
|
|
162
|
+
if (entry.isDirectory()) copyDirSync(src, dst);
|
|
163
|
+
else fs.copyFileSync(src, dst);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
process.stderr.write("[conxa] Running npm install...\n");
|
|
167
|
+
execSync("npm install --prefer-offline --silent", { cwd: RUNTIME_DIR, stdio: ["ignore", "pipe", "inherit"] });
|
|
168
|
+
|
|
169
|
+
process.stderr.write("[conxa] Installing Playwright Chromium...\n");
|
|
170
|
+
execSync("npx playwright install chromium", { cwd: RUNTIME_DIR, stdio: ["ignore", "pipe", "inherit"] });
|
|
171
|
+
|
|
172
|
+
const pkg = fs.existsSync(path.join(RUNTIME_DIR, "package.json"))
|
|
173
|
+
? JSON.parse(fs.readFileSync(path.join(RUNTIME_DIR, "package.json"), "utf8"))
|
|
174
|
+
: {};
|
|
175
|
+
fs.writeFileSync(VERSION_JSON, JSON.stringify({
|
|
176
|
+
version: pkg.version || "1.0.0",
|
|
177
|
+
installed_at: new Date().toISOString(),
|
|
178
|
+
node_version: process.version,
|
|
179
|
+
}, null, 2));
|
|
180
|
+
|
|
181
|
+
// Write initial global CLAUDE.md and index.md with empty registry
|
|
182
|
+
updateGlobalClaudeMd({});
|
|
183
|
+
regenerateIndex({});
|
|
184
|
+
|
|
185
|
+
// Register the shared MCP server + global CLAUDE.md import so Claude Code
|
|
186
|
+
// picks up `conxa` and per-plugin instructions on its next launch.
|
|
187
|
+
_registerGlobalMcp();
|
|
188
|
+
_registerGlobalClaudeMd();
|
|
189
|
+
|
|
190
|
+
// Write bootstrap flag so subsequent plugin installs skip re-init
|
|
191
|
+
fs.writeFileSync(path.join(CONXA_HOME, ".bootstrapped"), "1", "utf8");
|
|
192
|
+
|
|
193
|
+
process.stderr.write("[conxa] Bootstrap complete.\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── install ──────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function _installFromLocalDir(pluginDir) {
|
|
199
|
+
if (!pluginDir) throw new Error("install: plugin directory path required");
|
|
200
|
+
const absDir = path.resolve(pluginDir);
|
|
201
|
+
if (!fs.existsSync(absDir)) throw new Error(`Plugin directory not found: ${absDir}`);
|
|
202
|
+
|
|
203
|
+
const cfgPath = path.join(absDir, "plugin.json");
|
|
204
|
+
if (!fs.existsSync(cfgPath)) throw new Error(`No plugin.json found in ${absDir}`);
|
|
205
|
+
|
|
206
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
207
|
+
if (!cfg.slug) throw new Error("plugin.json missing: slug");
|
|
208
|
+
if (!cfg.target_url) throw new Error("plugin.json missing: target_url");
|
|
209
|
+
if (!cfg.protected_url) throw new Error("plugin.json missing: protected_url");
|
|
210
|
+
|
|
211
|
+
const slug = cfg.slug;
|
|
212
|
+
const destDir = path.join(PLUGINS_DIR, slug);
|
|
213
|
+
|
|
214
|
+
process.stderr.write(`[conxa] Installing plugin '${slug}' from ${absDir}...\n`);
|
|
215
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
216
|
+
|
|
217
|
+
// Copy plugin manifest
|
|
218
|
+
fs.copyFileSync(cfgPath, path.join(destDir, "plugin.json"));
|
|
219
|
+
|
|
220
|
+
// Copy discovery files
|
|
221
|
+
for (const name of ["CLAUDE.md", "index.md", "schema.json", "README.md"]) {
|
|
222
|
+
const src = path.join(absDir, name);
|
|
223
|
+
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(destDir, name));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Copy skills/
|
|
227
|
+
const skillsSrc = path.join(absDir, "skills");
|
|
228
|
+
if (fs.existsSync(skillsSrc)) copyDirSync(skillsSrc, path.join(destDir, "skills"));
|
|
229
|
+
|
|
230
|
+
// Copy auth/credentials.example.json (never auth.json)
|
|
231
|
+
const credsEx = path.join(absDir, "auth", "credentials.example.json");
|
|
232
|
+
if (fs.existsSync(credsEx)) {
|
|
233
|
+
fs.mkdirSync(path.join(destDir, "auth"), { recursive: true });
|
|
234
|
+
fs.copyFileSync(credsEx, path.join(destDir, "auth", "credentials.example.json"));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Update master registry
|
|
238
|
+
const skillsList = (cfg.skills || []).map(s => ({ slug: s.slug, path: s.path || `skills/${s.slug}` }));
|
|
239
|
+
const entry = {
|
|
240
|
+
slug,
|
|
241
|
+
name: cfg.name,
|
|
242
|
+
version: cfg.version || "1.0.0",
|
|
243
|
+
path: destDir,
|
|
244
|
+
target_url: cfg.target_url,
|
|
245
|
+
protected_url: cfg.protected_url,
|
|
246
|
+
skills: skillsList,
|
|
247
|
+
installed_at: new Date().toISOString(),
|
|
248
|
+
};
|
|
249
|
+
const reg = readRegistry();
|
|
250
|
+
reg[slug] = entry;
|
|
251
|
+
writeRegistry(reg);
|
|
252
|
+
|
|
253
|
+
// Regenerate global discovery files
|
|
254
|
+
updateGlobalClaudeMd(reg);
|
|
255
|
+
regenerateIndex(reg);
|
|
256
|
+
|
|
257
|
+
process.stderr.write(`[conxa] Plugin '${slug}' installed. Skills: ${skillsList.map(s => s.slug).join(", ")}\n`);
|
|
258
|
+
return entry;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function _ensureInitialized() {
|
|
262
|
+
if (fs.existsSync(VERSION_JSON)) return;
|
|
263
|
+
init();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// `install <ref>` — accept a local dir, git ref, or registry plugin_id.
|
|
267
|
+
// Local dirs install directly; everything else resolves through the chain
|
|
268
|
+
// (cache → git → registry) which stages a directory under ~/.conxa/cache/
|
|
269
|
+
// before delegating to _installFromLocalDir.
|
|
270
|
+
async function install(ref) {
|
|
271
|
+
if (!ref) throw new Error("install: <ref> required (local dir, owner/repo, or plugin_id)");
|
|
272
|
+
_ensureInitialized();
|
|
273
|
+
if (fs.existsSync(ref) && fs.statSync(ref).isDirectory()) {
|
|
274
|
+
return _installFromLocalDir(ref);
|
|
275
|
+
}
|
|
276
|
+
// Look in cache first (already downloaded). cache.stagedDir() takes a
|
|
277
|
+
// plugin_id+version pair; we accept either "id" or "id@version".
|
|
278
|
+
const at = ref.lastIndexOf("@");
|
|
279
|
+
const id = at > 0 ? ref.slice(0, at) : ref;
|
|
280
|
+
const ver = at > 0 ? ref.slice(at + 1) : null;
|
|
281
|
+
const cache = require("./resolver/cache");
|
|
282
|
+
const staged = cache.stagedDir(id, ver);
|
|
283
|
+
if (staged) return _installFromLocalDir(staged);
|
|
284
|
+
// git resolver handles owner/repo and full https URLs. It stages into cache/
|
|
285
|
+
// and returns the staged directory path.
|
|
286
|
+
const git = require("./resolver/git");
|
|
287
|
+
const resolved = await git.resolve(ref);
|
|
288
|
+
if (resolved && resolved.staged_dir) return _installFromLocalDir(resolved.staged_dir);
|
|
289
|
+
// Registry resolver is contract-only today; falls through when no hosted
|
|
290
|
+
// registry is configured. When implemented it would download a tarball into
|
|
291
|
+
// cache/ and return the staged path.
|
|
292
|
+
throw new Error(`install: could not resolve '${ref}'`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─── search ───────────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
async function search(query) {
|
|
298
|
+
const results = await require("./search").search(query, 20);
|
|
299
|
+
if (results.length === 0) {
|
|
300
|
+
process.stderr.write(`[conxa] No matches for '${query}'.\n`);
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
for (const r of results) {
|
|
304
|
+
const tags = (r.tags || []).join(",") || "—";
|
|
305
|
+
process.stderr.write(` ${r.plugin_id || r.slug} v${r.version} [${r.source}] ${r.name} (tags: ${tags})\n`);
|
|
306
|
+
}
|
|
307
|
+
return results;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ─── registry login / logout ──────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
function registryLogin(url, token, name) {
|
|
313
|
+
if (!url || !token) throw new Error("registry login: <url> <token> required");
|
|
314
|
+
const { getRegistryAuth, writeRegistryAuth } = require("./config");
|
|
315
|
+
const auth = getRegistryAuth();
|
|
316
|
+
const regs = Array.isArray(auth.registries) ? auth.registries : [];
|
|
317
|
+
const idx = regs.findIndex(r => r.url === url);
|
|
318
|
+
const entry = { name: name || url, url, token };
|
|
319
|
+
if (idx >= 0) regs[idx] = entry; else regs.push(entry);
|
|
320
|
+
writeRegistryAuth({ registries: regs });
|
|
321
|
+
process.stderr.write(`[conxa] Saved credentials for ${url}\n`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function registryLogout(url) {
|
|
325
|
+
if (!url) throw new Error("registry logout: <url> required");
|
|
326
|
+
const { getRegistryAuth, writeRegistryAuth } = require("./config");
|
|
327
|
+
const auth = getRegistryAuth();
|
|
328
|
+
const regs = Array.isArray(auth.registries) ? auth.registries.filter(r => r.url !== url) : [];
|
|
329
|
+
writeRegistryAuth({ registries: regs });
|
|
330
|
+
process.stderr.write(`[conxa] Removed credentials for ${url}\n`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── uninstall ────────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
function uninstall(slug) {
|
|
336
|
+
if (!slug) throw new Error("uninstall: slug required");
|
|
337
|
+
const destDir = path.join(PLUGINS_DIR, slug);
|
|
338
|
+
if (fs.existsSync(destDir)) {
|
|
339
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
340
|
+
process.stderr.write(`[conxa] Removed plugin directory: ${destDir}\n`);
|
|
341
|
+
}
|
|
342
|
+
const reg = readRegistry();
|
|
343
|
+
if (reg[slug]) {
|
|
344
|
+
delete reg[slug];
|
|
345
|
+
writeRegistry(reg);
|
|
346
|
+
updateGlobalClaudeMd(reg);
|
|
347
|
+
regenerateIndex(reg);
|
|
348
|
+
process.stderr.write(`[conxa] Removed '${slug}' from registry\n`);
|
|
349
|
+
} else {
|
|
350
|
+
process.stderr.write(`[conxa] Plugin '${slug}' was not in registry\n`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ─── list ─────────────────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
function list() {
|
|
357
|
+
const reg = readRegistry();
|
|
358
|
+
const entries = Object.values(reg);
|
|
359
|
+
if (entries.length === 0) {
|
|
360
|
+
process.stderr.write("[conxa] No plugins installed.\n");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
for (const e of entries) {
|
|
364
|
+
process.stderr.write(` ${e.slug} v${e.version} skills: ${(e.skills || []).map(s => s.slug).join(", ")}\n`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ─── CLI entry point ──────────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
async function runCli(argv) {
|
|
371
|
+
const [cmd, ...rest] = argv;
|
|
372
|
+
try {
|
|
373
|
+
switch (cmd) {
|
|
374
|
+
case "init": init(); break;
|
|
375
|
+
case "install": await install(rest[0]); break;
|
|
376
|
+
case "uninstall": uninstall(rest[0]); break;
|
|
377
|
+
case "list": list(); break;
|
|
378
|
+
case "search": await search(rest.join(" ")); break;
|
|
379
|
+
case "registry":
|
|
380
|
+
if (rest[0] === "login") registryLogin(rest[1], rest[2], rest[3]);
|
|
381
|
+
else if (rest[0] === "logout") registryLogout(rest[1]);
|
|
382
|
+
else throw new Error("registry: login <url> <token> | logout <url>");
|
|
383
|
+
break;
|
|
384
|
+
default:
|
|
385
|
+
process.stderr.write("Usage: conxa <init|install <ref>|uninstall <slug>|list|search <q>|registry login|registry logout>\n");
|
|
386
|
+
process.exit(1);
|
|
387
|
+
}
|
|
388
|
+
} catch (e) {
|
|
389
|
+
process.stderr.write(`[conxa] Error: ${e.message}\n`);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (require.main === module) {
|
|
395
|
+
runCli(process.argv.slice(2));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
module.exports = { init, install, uninstall, list, search, registryLogin, registryLogout, runCli };
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const CONXA_HOME = path.join(os.homedir(), ".conxa");
|
|
7
|
+
const REGISTRY_PATH = path.join(CONXA_HOME, "registry.json");
|
|
8
|
+
const CONXA_CLAUDE_MD = path.join(CONXA_HOME, "CLAUDE.md");
|
|
9
|
+
const CONXA_INDEX_MD = path.join(CONXA_HOME, "index.md");
|
|
10
|
+
const CACHE_DIR = path.join(CONXA_HOME, "cache");
|
|
11
|
+
const AUTH_DIR = path.join(CONXA_HOME, "auth");
|
|
12
|
+
const REGISTRY_AUTH_PATH = path.join(AUTH_DIR, "registry.json");
|
|
13
|
+
|
|
14
|
+
function getPluginDir(slug) {
|
|
15
|
+
return path.join(CONXA_HOME, "plugins", slug);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getAuthJson(slug) {
|
|
19
|
+
return path.join(CONXA_HOME, "plugins", slug, "auth", "auth.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getPluginConfig(slug) {
|
|
23
|
+
const cfgPath = path.join(getPluginDir(slug), "plugin.json");
|
|
24
|
+
return JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getRegistry() {
|
|
28
|
+
if (!fs.existsSync(REGISTRY_PATH)) return {};
|
|
29
|
+
try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8")); } catch (_) { return {}; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
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: [] }; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeRegistryAuth(payload) {
|
|
39
|
+
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
|
40
|
+
fs.writeFileSync(REGISTRY_AUTH_PATH, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
CONXA_HOME, REGISTRY_PATH, CONXA_CLAUDE_MD, CONXA_INDEX_MD,
|
|
45
|
+
CACHE_DIR, AUTH_DIR, REGISTRY_AUTH_PATH,
|
|
46
|
+
getPluginDir, getAuthJson, getPluginConfig, getRegistry,
|
|
47
|
+
getRegistryAuth, writeRegistryAuth,
|
|
48
|
+
};
|
package/lib/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "conxa-runtime",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Conxa shared runtime — MCP server for all installed plugins",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node server.js"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
11
|
+
"playwright": "^1.45.0"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
}
|
|
16
|
+
}
|