pairai 0.4.2 → 0.4.3

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 (2) hide show
  1. package/package.json +1 -1
  2. package/pairai.ts +271 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pairai",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "pairai CLI — connect AI agents to collaborate via the pairai hub",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/pairai.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * Commands:
6
6
  * npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]
7
7
  * npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]
8
+ * npx pairai uninstall [--provider ...] [--delete-agent] — remove MCP config, save credentials to ~/.pairai/agents/
8
9
  * npx pairai upgrade — update to latest version (preserves keys and config)
9
10
  * npx pairai version — show current version
10
11
  *
@@ -64,6 +65,26 @@ if (command === "version" || args.includes("--version") || args.includes("-v"))
64
65
  process.exit(0);
65
66
  }
66
67
 
68
+ // ── Help ────────────────────────────────────────────────────────────────────
69
+
70
+ if (command === "help" || args.includes("--help") || args.includes("-h")) {
71
+ console.log(`pairai v${VERSION}\n`);
72
+ console.log("Commands:");
73
+ console.log(' setup "Agent Name" [--hub URL] [--provider ...] [--global] [--force]');
74
+ console.log(" serve [--provider ...] — start the MCP channel server");
75
+ console.log(" uninstall [--provider ...] [--delete-agent]");
76
+ console.log(" upgrade — update to latest version");
77
+ console.log(" version — show version");
78
+ console.log("\nProviders: claude, gemini, cursor, copilot, windsurf, codex, amazonq");
79
+ console.log("\nEnvironment variables:");
80
+ console.log(" PAIRAI_HUB_URL Hub URL (default: https://pairai.pro)");
81
+ console.log(" PAIRAI_AGENT_CRED Agent API key");
82
+ console.log(" PAIRAI_KEY_FILE Path to RSA private key .pem");
83
+ console.log(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
84
+ console.log(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
85
+ process.exit(0);
86
+ }
87
+
67
88
  // ── Upgrade ─────────────────────────────────────────────────────────────────
68
89
 
69
90
  if (command === "upgrade") {
@@ -92,6 +113,202 @@ if (command === "upgrade") {
92
113
  // detectProvider, validateProvider, checkExistingConfig,
93
114
  // formatKeyBackupBox are imported from ./lib.js
94
115
 
116
+ // ── Uninstall: remove MCP config, preserve keys and credentials ─────────────
117
+
118
+ if (command === "uninstall") {
119
+ const rest = args.slice(1);
120
+ const providerIdx = rest.indexOf("--provider");
121
+ const providerArg = providerIdx !== -1 ? rest.splice(providerIdx, 2)[1] : undefined;
122
+ if (providerArg) {
123
+ try { validateProvider(providerArg); } catch (e) {
124
+ console.error(` ${(e as Error).message}`);
125
+ process.exit(1);
126
+ }
127
+ }
128
+ const deleteAgent = rest.includes("--delete-agent");
129
+
130
+ // Resolve provider (detect or ask)
131
+ let provider: Provider;
132
+ if (providerArg) {
133
+ provider = providerArg as Provider;
134
+ } else {
135
+ const detected = detectProvider();
136
+ if (detected) {
137
+ provider = detected;
138
+ } else if (process.stdin.isTTY) {
139
+ provider = await select({
140
+ message: "Which AI tool was pairai configured for?",
141
+ choices: PROVIDER_CHOICES,
142
+ });
143
+ } else {
144
+ console.error('Cannot auto-detect provider. Use --provider flag (e.g. npx pairai uninstall --provider claude)');
145
+ process.exit(1);
146
+ }
147
+ }
148
+
149
+ console.log(`\n pairai uninstall (provider: ${provider})\n`);
150
+
151
+ const cwd = process.cwd();
152
+ const home = homedir();
153
+ let removed = 0;
154
+ let savedCredentials = false;
155
+
156
+ // Collect both project-level and user/global-level config paths
157
+ const scopes: Array<{ label: string; cfg: ReturnType<typeof getProviderConfig> }> = [];
158
+ scopes.push({ label: "project", cfg: getProviderConfig(provider, cwd, home, false) });
159
+ if (!getProviderConfig(provider, cwd, home, false).globalOnly) {
160
+ scopes.push({ label: "user", cfg: getProviderConfig(provider, cwd, home, true) });
161
+ }
162
+ // For claude, also check ~/.mcp.json (user-scope global config)
163
+ if (provider === "claude") {
164
+ const userMcpJson = join(home, ".mcp.json");
165
+ scopes.push({
166
+ label: "user (~/.mcp.json)",
167
+ cfg: { configPath: userMcpJson, mcpKey: "pairai-channel", format: "json" as const, globalOnly: true, instruction: "" },
168
+ });
169
+ }
170
+
171
+ for (const { label, cfg } of scopes) {
172
+ if (!existsSync(cfg.configPath)) continue;
173
+
174
+ try {
175
+ if (cfg.format === "toml") {
176
+ const content = readFileSync(cfg.configPath, "utf-8");
177
+ // Remove the TOML block: [mcp_servers.<key>] through next section or EOF
178
+ const sectionHeader = `[mcp_servers.${cfg.mcpKey}]`;
179
+ if (!content.includes(sectionHeader)) continue;
180
+
181
+ // Extract credentials before removing
182
+ const hubMatch = content.match(/PAIRAI_HUB_URL\s*=\s*"([^"]+)"/);
183
+ const keyMatch = content.match(/PAIRAI_AGENT_CRED\s*=\s*"([^"]+)"/);
184
+ const pemMatch = content.match(/PAIRAI_KEY_FILE\s*=\s*"([^"]+)"/);
185
+
186
+ // Save recovery file
187
+ if (keyMatch && pemMatch) {
188
+ const agentId = pemMatch[1]!.split("/").pop()?.replace(".pem", "") ?? "unknown";
189
+ saveRecovery(agentId, hubMatch?.[1] ?? "https://pairai.pro", keyMatch[1]!, pemMatch[1]!);
190
+ savedCredentials = true;
191
+ }
192
+
193
+ // Remove the section
194
+ const regex = new RegExp(`\\n?\\[mcp_servers\\.${cfg.mcpKey}\\][\\s\\S]*?(?=\\n\\[|$)`, "g");
195
+ const cleaned = content.replace(regex, "").trim();
196
+ if (cleaned) {
197
+ writeFileSync(cfg.configPath, cleaned + "\n");
198
+ } else {
199
+ // Config file is now empty — remove it
200
+ const { unlinkSync } = await import("node:fs");
201
+ unlinkSync(cfg.configPath);
202
+ }
203
+ console.log(` Removed from ${label}: ${cfg.configPath}`);
204
+ removed++;
205
+ } else {
206
+ // JSON config
207
+ const content = readFileSync(cfg.configPath, "utf-8");
208
+ const parsed = JSON.parse(content);
209
+ const servers = parsed.mcpServers ?? parsed.mcp_servers ?? {};
210
+ if (!servers[cfg.mcpKey]) continue;
211
+
212
+ // Extract credentials before removing
213
+ const entry = servers[cfg.mcpKey];
214
+ const env = entry.env ?? {};
215
+ const hubUrl = env.PAIRAI_HUB_URL ?? env.PAIRAI_URL ?? "https://pairai.pro";
216
+ const apiKey = env.PAIRAI_AGENT_CRED ?? env.PAIRAI_API_KEY;
217
+ const keyFile = env.PAIRAI_KEY_FILE ?? env.PAIRAI_PRIVATE_KEY_PATH;
218
+
219
+ if (apiKey && keyFile) {
220
+ const agentId = keyFile.split("/").pop()?.replace(".pem", "") ?? "unknown";
221
+ saveRecovery(agentId, hubUrl, apiKey, keyFile);
222
+ savedCredentials = true;
223
+ }
224
+
225
+ // Remove the entry
226
+ delete servers[cfg.mcpKey];
227
+
228
+ // If mcpServers is now empty, remove it too
229
+ const serverKey = parsed.mcpServers ? "mcpServers" : "mcp_servers";
230
+ if (Object.keys(servers).length === 0) {
231
+ delete parsed[serverKey];
232
+ }
233
+
234
+ if (Object.keys(parsed).length === 0) {
235
+ const { unlinkSync } = await import("node:fs");
236
+ unlinkSync(cfg.configPath);
237
+ console.log(` Removed (empty): ${cfg.configPath}`);
238
+ } else {
239
+ writeFileSync(cfg.configPath, JSON.stringify(parsed, null, 2) + "\n");
240
+ console.log(` Removed from ${label}: ${cfg.configPath}`);
241
+ }
242
+ removed++;
243
+ }
244
+ } catch (err) {
245
+ console.error(` Warning: Could not clean ${cfg.configPath}: ${(err as Error).message}`);
246
+ }
247
+ }
248
+
249
+ // Clean up lock files
250
+ const lockDir = join(home, ".pairai", "locks");
251
+ if (existsSync(lockDir)) {
252
+ try {
253
+ const { readdirSync, unlinkSync: unlinkLock } = await import("node:fs");
254
+ for (const f of readdirSync(lockDir)) {
255
+ unlinkLock(join(lockDir, f));
256
+ }
257
+ console.log(` Cleaned lock files: ${lockDir}`);
258
+ } catch {}
259
+ }
260
+
261
+ // Optionally delete agent from hub
262
+ if (deleteAgent) {
263
+ // Read the recovery file to get credentials
264
+ const recoveryDir = join(home, ".pairai", "agents");
265
+ if (existsSync(recoveryDir)) {
266
+ const { readdirSync: readDir } = await import("node:fs");
267
+ for (const f of readDir(recoveryDir)) {
268
+ if (!f.endsWith(".json")) continue;
269
+ try {
270
+ const recovery = JSON.parse(readFileSync(join(recoveryDir, f), "utf-8"));
271
+ console.log(`\n Deleting agent ${f.replace(".json", "")} from ${recovery.hubUrl}...`);
272
+ const res = await fetch(`${recovery.hubUrl}/agents/me`, {
273
+ method: "DELETE",
274
+ headers: { Authorization: `Bearer ${recovery.apiKey}` },
275
+ });
276
+ if (res.ok) {
277
+ console.log(` Agent deleted from hub.`);
278
+ } else {
279
+ console.log(` Could not delete: ${res.status} ${await res.text()}`);
280
+ }
281
+ } catch (err) {
282
+ console.error(` Warning: ${(err as Error).message}`);
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ if (removed === 0) {
289
+ console.log(" No pairai config found to remove.");
290
+ }
291
+
292
+ console.log();
293
+ if (savedCredentials) {
294
+ console.log(` Credentials saved to ~/.pairai/agents/ (for re-registration without new setup)`);
295
+ }
296
+ console.log(` Private keys preserved in ~/.pairai/keys/ (never auto-deleted)`);
297
+ if (!deleteAgent) {
298
+ console.log(` Agent still registered on hub. To also delete: npx pairai uninstall --delete-agent`);
299
+ }
300
+ console.log();
301
+ process.exit(0);
302
+ }
303
+
304
+ function saveRecovery(agentId: string, hubUrl: string, apiKey: string, keyFile: string) {
305
+ const dir = join(homedir(), ".pairai", "agents");
306
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
307
+ const recoveryPath = join(dir, `${agentId}.json`);
308
+ if (existsSync(recoveryPath)) return; // don't overwrite existing recovery
309
+ writeFileSync(recoveryPath, JSON.stringify({ hubUrl, apiKey, keyFile, savedAt: new Date().toISOString() }, null, 2) + "\n", { mode: 0o600 });
310
+ }
311
+
95
312
  // ── Setup: register + configure ──────────────────────────────────────────────
96
313
 
97
314
  if (command === "setup") {
@@ -261,6 +478,7 @@ if (command !== "serve") {
261
478
  console.error("Usage:");
262
479
  console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
263
480
  console.error(" npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]");
481
+ console.error(" npx pairai uninstall [--provider ...] [--delete-agent] — remove MCP config, preserve keys");
264
482
  console.error(" npx pairai upgrade — update to latest version");
265
483
  console.error(" npx pairai version — show current version");
266
484
  console.error("");
@@ -324,7 +542,10 @@ const API_PREFIX = "/api/v1";
324
542
 
325
543
  async function hubGet(path: string) {
326
544
  const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, { headers, signal: AbortSignal.timeout(HUB_TIMEOUT_MS) });
327
- if (!res.ok) throw new Error(`GET ${path}: ${res.status}`);
545
+ if (!res.ok) {
546
+ const body = await res.json().catch(() => ({})) as { error?: string };
547
+ throw new Error(body.error ?? `GET ${path}: ${res.status}`);
548
+ }
328
549
  return res.json();
329
550
  }
330
551
 
@@ -335,7 +556,10 @@ async function hubPost(path: string, body?: unknown) {
335
556
  body: body ? JSON.stringify(body) : undefined,
336
557
  signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
337
558
  });
338
- if (!res.ok) throw new Error(`POST ${path}: ${res.status}`);
559
+ if (!res.ok) {
560
+ const respBody = await res.json().catch(() => ({})) as { error?: string };
561
+ throw new Error(respBody.error ?? `POST ${path}: ${res.status}`);
562
+ }
339
563
  return res.json();
340
564
  }
341
565
 
@@ -416,6 +640,13 @@ const instructions = [
416
640
  "The channel server polls for updates automatically — you don't need to poll manually.",
417
641
  "When the user asks about updates, new messages, or pending work, use pairai_check_updates (not pairai_list_tasks).",
418
642
  "",
643
+ "Connecting with other agents:",
644
+ " - To find agents: use pairai_discover_agents (search by name, description, or capability tag)",
645
+ " - To connect: use pairai_connect_directly with the agent's ID (works instantly if they have autoAccept)",
646
+ " - To collaborate: use pairai_create_task to send work, then pairai_reply to exchange messages",
647
+ " - The full flow is: discover → connect → create task → exchange messages → complete",
648
+ " - Featured agents on the hub: Reviewer (code/spec review), Artist (image generation), Polyglot (translation)",
649
+ "",
419
650
  "Notification attributes:",
420
651
  " task_id — the task this message belongs to",
421
652
  " task_title — short description of the task",
@@ -740,6 +971,18 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
740
971
  properties: {},
741
972
  },
742
973
  },
974
+ {
975
+ name: "pairai_report_usage",
976
+ description: "Report API cost for a task. Deducts from the initiator's credits. Only the target agent (specialist) can call this.",
977
+ inputSchema: {
978
+ type: "object" as const,
979
+ properties: {
980
+ task_id: { type: "string", description: "Task ID" },
981
+ cost: { type: "number", description: "Cost in USD (e.g. 0.0023)" },
982
+ },
983
+ required: ["task_id", "cost"],
984
+ },
985
+ },
743
986
  {
744
987
  name: "pairai_block_agent",
745
988
  description: "Block an agent. They cannot discover or connect with you. Disconnects if connected.",
@@ -865,11 +1108,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
865
1108
  await hubPatch(`/tasks/${args.task_id}`, { status: args.status });
866
1109
  return { content: [{ type: "text" as const, text: `Status → ${args.status}` }] };
867
1110
  } catch (err) {
868
- const msg = (err as Error).message;
869
- if (msg.includes("409") || msg.includes("400")) {
870
- return { content: [{ type: "text" as const, text: `Cannot update status — ${msg}` }] };
871
- }
872
- throw err;
1111
+ return { content: [{ type: "text" as const, text: `Cannot update status: ${(err as Error).message}` }], isError: true };
873
1112
  }
874
1113
  }
875
1114
 
@@ -1012,12 +1251,12 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1012
1251
 
1013
1252
  if (name === "pairai_list_tasks") {
1014
1253
  await loadPublicKeys();
1015
- const data = (await hubGet("/tasks")) as Array<{
1254
+ const qs = args.status ? `?status=${args.status}` : "";
1255
+ const data = (await hubGet(`/tasks${qs}`)) as Array<{
1016
1256
  id: string; status: string; title: string; encrypted?: boolean;
1017
1257
  description?: string; descriptionKeys?: any; senderSignature?: string; initiatorAgentId?: string;
1018
1258
  }>;
1019
- const filtered = args.status ? data.filter((t) => t.status === args.status) : data;
1020
- const decrypted = filtered.map((t) => {
1259
+ const decrypted = data.map((t) => {
1021
1260
  if (t.encrypted) {
1022
1261
  const desc = decryptTaskDescription(t, t.id);
1023
1262
  return { ...t, title: desc.split("\n")[0] || t.title, description: desc };
@@ -1047,6 +1286,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1047
1286
  }
1048
1287
  const decryptedMsgs = msgs.map((m) => {
1049
1288
  if (data.encrypted) {
1289
+ // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1290
+ if (m.contentType === "encrypted" && m.encryptedKeys && m.content && m.content.length < 30 && !/[/+=]/.test(m.content)) {
1291
+ return { ...m, content: `[Encrypted file — use pairai_download_file with task_id: "${data.id}", file_id: "${m.content}"]`, contentType: "file" };
1292
+ }
1050
1293
  try {
1051
1294
  const d = decryptMessage(m, data.id);
1052
1295
  return { ...m, content: d.content, contentType: d.contentType };
@@ -1292,6 +1535,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1292
1535
  }
1293
1536
  }
1294
1537
 
1538
+ if (name === "pairai_report_usage") {
1539
+ const { task_id, cost } = args as { task_id: string; cost: number };
1540
+ try {
1541
+ const result = await hubPost(`/tasks/${task_id}/usage`, { cost });
1542
+ return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
1543
+ } catch (err) {
1544
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1545
+ }
1546
+ }
1547
+
1295
1548
  if (name === "pairai_block_agent") {
1296
1549
  const { agent_id } = args as { agent_id: string };
1297
1550
  try {
@@ -1327,6 +1580,12 @@ function decryptMessage(
1327
1580
  if (msg.contentType !== "encrypted" || !msg.encryptedKeys || !msg.senderSignature || !PRIVATE_KEY) {
1328
1581
  return { content: msg.content, contentType: msg.contentType };
1329
1582
  }
1583
+ // Encrypted file messages: content is a file ID (nanoid), not ciphertext.
1584
+ // The signature covers the encrypted file data on disk, not the file ID reference.
1585
+ // Don't attempt to decrypt — the file is retrieved and decrypted via download_file.
1586
+ if (msg.content && msg.content.length < 30 && !/[/+=]/.test(msg.content)) {
1587
+ return { content: `[Encrypted file attachment — file_id: ${msg.content}]`, contentType: "file" };
1588
+ }
1330
1589
  try {
1331
1590
  const keys = typeof msg.encryptedKeys === "string" ? JSON.parse(msg.encryptedKeys) : msg.encryptedKeys;
1332
1591
  const senderPub = msg.senderAgentId === myAgentId ? myPublicKey : pubKeyCache.get(msg.senderAgentId);
@@ -1407,7 +1666,7 @@ async function poll() {
1407
1666
  const decryptedMessages = (taskMsgs ?? []).map((m) => {
1408
1667
  // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1409
1668
  if (m.contentType === "encrypted" && m.encryptedKeys && m.content.length < 30) {
1410
- return "[File attachment — use pairai_download_file to retrieve]";
1669
+ return `[File attachment — use pairai_download_file with task_id: "${task.id}", file_id: "${m.content}"]`;
1411
1670
  }
1412
1671
  try {
1413
1672
  const d = decryptMessage(m, task.id);
@@ -1460,7 +1719,7 @@ async function poll() {
1460
1719
  const isEncryptedFile = msg.contentType === "encrypted" && msg.encryptedKeys && msg.content.length < 30;
1461
1720
  let decrypted: { content: string; contentType: string };
1462
1721
  if (isEncryptedFile) {
1463
- decrypted = { content: "[File attachment — use pairai_download_file to retrieve]", contentType: "text" };
1722
+ decrypted = { content: `[File attachment — use pairai_download_file with task_id: "${unread.taskId}", file_id: "${msg.content}"]`, contentType: "text" };
1464
1723
  } else {
1465
1724
  try {
1466
1725
  decrypted = decryptMessage(msg, unread.taskId);