pairai 0.2.5 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib.ts +253 -15
- package/package.json +3 -2
- package/pairai.ts +646 -192
package/lib.ts
CHANGED
|
@@ -1,30 +1,137 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure/testable functions extracted from pairai CLI.
|
|
3
|
-
* Imported by both pairai.ts and unit tests.
|
|
3
|
+
* Imported by both pairai.ts, bridge, and unit tests.
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { existsSync, readFileSync, statSync, openSync, writeSync, closeSync, unlinkSync, mkdirSync, constants as fsConstants } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import {
|
|
9
|
+
publicEncrypt, privateDecrypt, sign, verify,
|
|
10
|
+
randomBytes, createCipheriv, createDecipheriv,
|
|
11
|
+
constants as cryptoConstants,
|
|
12
|
+
} from "node:crypto";
|
|
13
|
+
|
|
14
|
+
export type Provider = "claude" | "gemini" | "cursor" | "copilot" | "windsurf" | "codex" | "amazonq";
|
|
15
|
+
|
|
16
|
+
const VALID_PROVIDERS: Provider[] = ["claude", "gemini", "cursor", "copilot", "windsurf", "codex", "amazonq"];
|
|
7
17
|
|
|
8
18
|
/**
|
|
9
19
|
* Validate the --provider flag value.
|
|
10
20
|
* Returns the validated provider or throws with a message.
|
|
11
21
|
*/
|
|
12
|
-
export function validateProvider(value: string):
|
|
13
|
-
if (value
|
|
14
|
-
throw new Error(`Unknown provider "${value}". Must be
|
|
22
|
+
export function validateProvider(value: string): Provider {
|
|
23
|
+
if (!VALID_PROVIDERS.includes(value as Provider)) {
|
|
24
|
+
throw new Error(`Unknown provider "${value}". Must be one of: ${VALID_PROVIDERS.join(", ")}.`);
|
|
15
25
|
}
|
|
16
|
-
return value;
|
|
26
|
+
return value as Provider;
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
/**
|
|
20
30
|
* Auto-detect provider based on environment and filesystem.
|
|
21
31
|
*/
|
|
22
|
-
export function detectProvider():
|
|
32
|
+
export function detectProvider(): Provider {
|
|
23
33
|
if (process.env.GEMINI_CLI) return "gemini";
|
|
34
|
+
try { if (statSync(".cursor").isDirectory()) return "cursor"; } catch {}
|
|
35
|
+
try { if (statSync(".windsurf").isDirectory()) return "windsurf"; } catch {}
|
|
36
|
+
try { if (statSync(".vscode").isDirectory()) return "copilot"; } catch {}
|
|
37
|
+
try { if (statSync(".codex").isDirectory()) return "codex"; } catch {}
|
|
38
|
+
try { if (statSync(".amazonq").isDirectory()) return "amazonq"; } catch {}
|
|
24
39
|
try { if (statSync(".gemini").isDirectory()) return "gemini"; } catch {}
|
|
25
40
|
return "claude";
|
|
26
41
|
}
|
|
27
42
|
|
|
43
|
+
export interface ProviderConfig {
|
|
44
|
+
/** Config file path (project-level or global) */
|
|
45
|
+
configPath: string;
|
|
46
|
+
/** MCP server key name in the config */
|
|
47
|
+
mcpKey: string;
|
|
48
|
+
/** Format: "json" or "toml" */
|
|
49
|
+
format: "json" | "toml";
|
|
50
|
+
/** Whether this provider only supports global config */
|
|
51
|
+
globalOnly: boolean;
|
|
52
|
+
/** Post-setup instruction */
|
|
53
|
+
instruction: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get the config file path, format, and setup instructions for a provider.
|
|
58
|
+
*/
|
|
59
|
+
export function getProviderConfig(
|
|
60
|
+
provider: Provider,
|
|
61
|
+
cwd: string,
|
|
62
|
+
homeDir: string,
|
|
63
|
+
useGlobal: boolean,
|
|
64
|
+
): ProviderConfig {
|
|
65
|
+
switch (provider) {
|
|
66
|
+
case "claude":
|
|
67
|
+
return {
|
|
68
|
+
configPath: join(cwd, ".mcp.json"),
|
|
69
|
+
mcpKey: "pairai-channel",
|
|
70
|
+
format: "json",
|
|
71
|
+
globalOnly: false,
|
|
72
|
+
instruction: "Start Claude Code in this directory",
|
|
73
|
+
};
|
|
74
|
+
case "gemini": {
|
|
75
|
+
const dir = useGlobal ? join(homeDir, ".gemini") : join(cwd, ".gemini");
|
|
76
|
+
return {
|
|
77
|
+
configPath: join(dir, "settings.json"),
|
|
78
|
+
mcpKey: "pairai",
|
|
79
|
+
format: "json",
|
|
80
|
+
globalOnly: false,
|
|
81
|
+
instruction: "Restart Gemini CLI to activate the pairai MCP server",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
case "cursor": {
|
|
85
|
+
const dir = useGlobal ? join(homeDir, ".cursor") : join(cwd, ".cursor");
|
|
86
|
+
return {
|
|
87
|
+
configPath: join(dir, "mcp.json"),
|
|
88
|
+
mcpKey: "pairai",
|
|
89
|
+
format: "json",
|
|
90
|
+
globalOnly: false,
|
|
91
|
+
instruction: "Restart Cursor to activate the pairai MCP server",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
case "copilot":
|
|
95
|
+
return {
|
|
96
|
+
configPath: join(cwd, ".vscode", "mcp.json"),
|
|
97
|
+
mcpKey: "pairai",
|
|
98
|
+
format: "json",
|
|
99
|
+
globalOnly: false,
|
|
100
|
+
instruction: "Reload VS Code window (Ctrl+Shift+P → Developer: Reload Window)",
|
|
101
|
+
};
|
|
102
|
+
case "windsurf":
|
|
103
|
+
return {
|
|
104
|
+
configPath: join(homeDir, ".codeium", "windsurf", "mcp_config.json"),
|
|
105
|
+
mcpKey: "pairai",
|
|
106
|
+
format: "json",
|
|
107
|
+
globalOnly: true,
|
|
108
|
+
instruction: "Restart Windsurf to activate the pairai MCP server",
|
|
109
|
+
};
|
|
110
|
+
case "codex": {
|
|
111
|
+
const dir = useGlobal ? join(homeDir, ".codex") : join(cwd, ".codex");
|
|
112
|
+
return {
|
|
113
|
+
configPath: join(dir, "config.toml"),
|
|
114
|
+
mcpKey: "pairai",
|
|
115
|
+
format: "toml",
|
|
116
|
+
globalOnly: false,
|
|
117
|
+
instruction: "Restart Codex CLI to activate the pairai MCP server",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
case "amazonq": {
|
|
121
|
+
const path = useGlobal
|
|
122
|
+
? join(homeDir, ".aws", "amazonq", "default.json")
|
|
123
|
+
: join(cwd, ".amazonq", "default.json");
|
|
124
|
+
return {
|
|
125
|
+
configPath: path,
|
|
126
|
+
mcpKey: "pairai",
|
|
127
|
+
format: "json",
|
|
128
|
+
globalOnly: false,
|
|
129
|
+
instruction: "Restart Amazon Q to activate the pairai MCP server",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
28
135
|
/**
|
|
29
136
|
* Replace any `pairai@x.y.z` version pin in a config string with a new version.
|
|
30
137
|
*/
|
|
@@ -37,21 +144,26 @@ export function updateVersionInConfig(content: string, latest: string): string {
|
|
|
37
144
|
* Returns the path if config exists with a pairai entry, null otherwise.
|
|
38
145
|
*/
|
|
39
146
|
export function checkExistingConfig(
|
|
40
|
-
provider:
|
|
147
|
+
provider: Provider,
|
|
41
148
|
cwd: string,
|
|
42
149
|
homeDir: string,
|
|
43
150
|
useGlobal: boolean,
|
|
44
151
|
): string | null {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
152
|
+
const cfg = getProviderConfig(provider, cwd, homeDir, useGlobal);
|
|
153
|
+
if (!existsSync(cfg.configPath)) return null;
|
|
154
|
+
|
|
155
|
+
if (cfg.format === "toml") {
|
|
156
|
+
try {
|
|
157
|
+
const content = readFileSync(cfg.configPath, "utf-8");
|
|
158
|
+
if (content.includes(`[mcp_servers.${cfg.mcpKey}]`)) return cfg.configPath;
|
|
159
|
+
} catch {}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
49
162
|
|
|
50
|
-
if (!existsSync(configPath)) return null;
|
|
51
163
|
try {
|
|
52
|
-
const existing = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
164
|
+
const existing = JSON.parse(readFileSync(cfg.configPath, "utf-8"));
|
|
53
165
|
const servers = existing.mcpServers ?? {};
|
|
54
|
-
if (servers[mcpKey]) return configPath;
|
|
166
|
+
if (servers[cfg.mcpKey]) return cfg.configPath;
|
|
55
167
|
} catch {}
|
|
56
168
|
return null;
|
|
57
169
|
}
|
|
@@ -78,3 +190,129 @@ export function formatKeyBackupBox(keyPath: string): string[] {
|
|
|
78
190
|
out.push(` \u2514${"\u2500".repeat(w + 2)}\u2518`);
|
|
79
191
|
return out;
|
|
80
192
|
}
|
|
193
|
+
|
|
194
|
+
// ── Polling lock ─────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
const STALE_THRESHOLD_MS = 60_000; // 60 seconds
|
|
197
|
+
|
|
198
|
+
function lockPath(agentId: string, lockDir?: string): string {
|
|
199
|
+
const dir = lockDir ?? join(homedir(), ".pairai", "locks");
|
|
200
|
+
mkdirSync(dir, { recursive: true });
|
|
201
|
+
return join(dir, `${agentId}.lock`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Try to acquire an exclusive lock for this agent.
|
|
206
|
+
* Uses atomic O_CREAT|O_EXCL file creation + stale lock detection.
|
|
207
|
+
* Returns true if lock acquired, false if another live process holds it.
|
|
208
|
+
*/
|
|
209
|
+
export function acquireLock(agentId: string, lockDir?: string): boolean {
|
|
210
|
+
const path = lockPath(agentId, lockDir);
|
|
211
|
+
|
|
212
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
213
|
+
try {
|
|
214
|
+
const fd = openSync(path, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY);
|
|
215
|
+
writeSync(fd, String(process.pid));
|
|
216
|
+
closeSync(fd);
|
|
217
|
+
return true;
|
|
218
|
+
} catch (err: any) {
|
|
219
|
+
if (err.code !== "EEXIST") throw err;
|
|
220
|
+
|
|
221
|
+
// Lock file exists — check if holder is alive and not stale
|
|
222
|
+
try {
|
|
223
|
+
const pid = parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
224
|
+
const stat = statSync(path);
|
|
225
|
+
const age = Date.now() - stat.mtimeMs;
|
|
226
|
+
|
|
227
|
+
// If PID is alive and lock is fresh, we can't acquire
|
|
228
|
+
if (!isNaN(pid) && age < STALE_THRESHOLD_MS) {
|
|
229
|
+
try {
|
|
230
|
+
process.kill(pid, 0); // signal 0 = check if alive
|
|
231
|
+
return false; // process is alive, lock is valid
|
|
232
|
+
} catch {
|
|
233
|
+
// process is dead — reclaim
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Stale or dead process — remove and retry
|
|
238
|
+
unlinkSync(path);
|
|
239
|
+
} catch {
|
|
240
|
+
// Can't read/stat lock file — try to remove and retry
|
|
241
|
+
try { unlinkSync(path); } catch {}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Release the lock for this agent. Safe to call multiple times.
|
|
250
|
+
*/
|
|
251
|
+
export function releaseLock(agentId: string, lockDir?: string): void {
|
|
252
|
+
const path = lockPath(agentId, lockDir);
|
|
253
|
+
try { unlinkSync(path); } catch {}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Crypto ──────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Encrypt plaintext with AES-256-GCM, wrap key with RSA-OAEP for each recipient,
|
|
260
|
+
* sign (taskId + ciphertext) with RSA-PSS.
|
|
261
|
+
*/
|
|
262
|
+
export function localEncrypt(
|
|
263
|
+
plaintext: string,
|
|
264
|
+
taskId: string,
|
|
265
|
+
senderPrivateKey: string,
|
|
266
|
+
recipientPubKeys: Record<string, string>,
|
|
267
|
+
): { ciphertext: string; signature: string; encryptedKeys: Record<string, string> } {
|
|
268
|
+
const key = randomBytes(32);
|
|
269
|
+
const iv = randomBytes(12);
|
|
270
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
271
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
272
|
+
const tag = cipher.getAuthTag();
|
|
273
|
+
const ciphertext = Buffer.concat([iv, encrypted, tag]).toString("base64");
|
|
274
|
+
|
|
275
|
+
const signature = sign(null, Buffer.from(taskId + ciphertext), {
|
|
276
|
+
key: senderPrivateKey,
|
|
277
|
+
padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
|
|
278
|
+
saltLength: 32,
|
|
279
|
+
}).toString("base64");
|
|
280
|
+
|
|
281
|
+
const encryptedKeys: Record<string, string> = {};
|
|
282
|
+
for (const [id, pub] of Object.entries(recipientPubKeys)) {
|
|
283
|
+
encryptedKeys[id] = publicEncrypt(
|
|
284
|
+
{ key: pub, oaepHash: "sha256", padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING },
|
|
285
|
+
key,
|
|
286
|
+
).toString("base64");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { ciphertext, signature, encryptedKeys };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Verify signature, unwrap AES key with own private key, decrypt AES-256-GCM.
|
|
294
|
+
*/
|
|
295
|
+
export function localDecrypt(
|
|
296
|
+
ciphertext: string,
|
|
297
|
+
sig: string,
|
|
298
|
+
taskId: string,
|
|
299
|
+
senderPub: string,
|
|
300
|
+
myEncKey: string,
|
|
301
|
+
myPrivateKey: string,
|
|
302
|
+
): string {
|
|
303
|
+
const valid = verify(null, Buffer.from(taskId + ciphertext), {
|
|
304
|
+
key: senderPub,
|
|
305
|
+
padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
|
|
306
|
+
saltLength: 32,
|
|
307
|
+
}, Buffer.from(sig, "base64"));
|
|
308
|
+
if (!valid) throw new Error("Signature verification failed");
|
|
309
|
+
|
|
310
|
+
const aesKey = privateDecrypt(
|
|
311
|
+
{ key: myPrivateKey, oaepHash: "sha256", padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING },
|
|
312
|
+
Buffer.from(myEncKey, "base64"),
|
|
313
|
+
);
|
|
314
|
+
const data = Buffer.from(ciphertext, "base64");
|
|
315
|
+
const decipher = createDecipheriv("aes-256-gcm", aesKey, data.subarray(0, 12));
|
|
316
|
+
decipher.setAuthTag(data.subarray(-16));
|
|
317
|
+
return Buffer.concat([decipher.update(data.subarray(12, -16)), decipher.final()]).toString("utf8");
|
|
318
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pairai",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "pairai CLI — connect AI agents to collaborate via the pairai hub",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://pairai.pro",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "https://
|
|
10
|
+
"url": "https://github.com/pairaipro/pairai",
|
|
11
|
+
"directory": "channel"
|
|
11
12
|
},
|
|
12
13
|
"keywords": [
|
|
13
14
|
"ai",
|