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.
Files changed (3) hide show
  1. package/lib.ts +253 -15
  2. package/package.json +3 -2
  3. 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): "claude" | "gemini" {
13
- if (value !== "claude" && value !== "gemini") {
14
- throw new Error(`Unknown provider "${value}". Must be "claude" or "gemini".`);
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(): "claude" | "gemini" {
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: "claude" | "gemini",
147
+ provider: Provider,
41
148
  cwd: string,
42
149
  homeDir: string,
43
150
  useGlobal: boolean,
44
151
  ): string | null {
45
- const configPath = provider === "gemini"
46
- ? join(useGlobal ? join(homeDir, ".gemini") : join(cwd, ".gemini"), "settings.json")
47
- : join(cwd, ".mcp.json");
48
- const mcpKey = provider === "gemini" ? "pairai" : "pairai-channel";
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.5",
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://git.quis.app/quis.app/connect_ai"
10
+ "url": "https://github.com/pairaipro/pairai",
11
+ "directory": "channel"
11
12
  },
12
13
  "keywords": [
13
14
  "ai",