pairai 0.3.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.
- package/lib.ts +10 -8
- package/package.json +3 -1
- package/pairai.ts +477 -18
package/lib.ts
CHANGED
|
@@ -28,16 +28,18 @@ export function validateProvider(value: string): Provider {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Auto-detect provider based on environment and filesystem.
|
|
31
|
+
* Returns null when detection is ambiguous (0 or 2+ matches).
|
|
31
32
|
*/
|
|
32
|
-
export function detectProvider(): Provider {
|
|
33
|
+
export function detectProvider(): Provider | null {
|
|
33
34
|
if (process.env.GEMINI_CLI) return "gemini";
|
|
34
|
-
|
|
35
|
-
try { if (statSync(".
|
|
36
|
-
try { if (statSync(".
|
|
37
|
-
try { if (statSync(".
|
|
38
|
-
try { if (statSync(".
|
|
39
|
-
try { if (statSync(".
|
|
40
|
-
|
|
35
|
+
const found: Provider[] = [];
|
|
36
|
+
try { if (statSync(".cursor").isDirectory()) found.push("cursor"); } catch {}
|
|
37
|
+
try { if (statSync(".windsurf").isDirectory()) found.push("windsurf"); } catch {}
|
|
38
|
+
try { if (statSync(".vscode").isDirectory()) found.push("copilot"); } catch {}
|
|
39
|
+
try { if (statSync(".codex").isDirectory()) found.push("codex"); } catch {}
|
|
40
|
+
try { if (statSync(".amazonq").isDirectory()) found.push("amazonq"); } catch {}
|
|
41
|
+
try { if (statSync(".gemini").isDirectory()) found.push("gemini"); } catch {}
|
|
42
|
+
return found.length === 1 ? found[0] : null;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export interface ProviderConfig {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pairai",
|
|
3
|
-
"version": "0.3
|
|
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",
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
"README.md"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
+
"@inquirer/input": "^5.0.10",
|
|
33
|
+
"@inquirer/select": "^5.1.2",
|
|
32
34
|
"@modelcontextprotocol/sdk": "^1.10.0",
|
|
33
35
|
"nanoid": "^5.0.0",
|
|
34
36
|
"tsx": "^4.0.0"
|
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
|
*
|
|
@@ -24,11 +25,23 @@ import { join, dirname } from "node:path";
|
|
|
24
25
|
import { fileURLToPath } from "node:url";
|
|
25
26
|
import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig, localEncrypt as _localEncrypt, localDecrypt as _localDecrypt } from "./lib.js";
|
|
26
27
|
import type { Provider } from "./lib.js";
|
|
28
|
+
import select from "@inquirer/select";
|
|
29
|
+
import input from "@inquirer/input";
|
|
27
30
|
|
|
28
31
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
32
|
const PKG = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
|
|
30
33
|
const VERSION: string = PKG.version;
|
|
31
34
|
|
|
35
|
+
const PROVIDER_CHOICES: { name: string; value: Provider }[] = [
|
|
36
|
+
{ name: "Claude Code", value: "claude" },
|
|
37
|
+
{ name: "Gemini CLI", value: "gemini" },
|
|
38
|
+
{ name: "Cursor", value: "cursor" },
|
|
39
|
+
{ name: "GitHub Copilot (VS Code)", value: "copilot" },
|
|
40
|
+
{ name: "Windsurf", value: "windsurf" },
|
|
41
|
+
{ name: "OpenAI Codex CLI", value: "codex" },
|
|
42
|
+
{ name: "Amazon Q", value: "amazonq" },
|
|
43
|
+
];
|
|
44
|
+
|
|
32
45
|
const args = process.argv.slice(2);
|
|
33
46
|
const command = args[0];
|
|
34
47
|
|
|
@@ -52,6 +65,26 @@ if (command === "version" || args.includes("--version") || args.includes("-v"))
|
|
|
52
65
|
process.exit(0);
|
|
53
66
|
}
|
|
54
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
|
+
|
|
55
88
|
// ── Upgrade ─────────────────────────────────────────────────────────────────
|
|
56
89
|
|
|
57
90
|
if (command === "upgrade") {
|
|
@@ -80,6 +113,202 @@ if (command === "upgrade") {
|
|
|
80
113
|
// detectProvider, validateProvider, checkExistingConfig,
|
|
81
114
|
// formatKeyBackupBox are imported from ./lib.js
|
|
82
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
|
+
|
|
83
312
|
// ── Setup: register + configure ──────────────────────────────────────────────
|
|
84
313
|
|
|
85
314
|
if (command === "setup") {
|
|
@@ -108,16 +337,39 @@ if (command === "setup") {
|
|
|
108
337
|
process.exit(1);
|
|
109
338
|
}
|
|
110
339
|
}
|
|
111
|
-
|
|
340
|
+
let provider: Provider;
|
|
341
|
+
if (providerArg) {
|
|
342
|
+
provider = providerArg as Provider;
|
|
343
|
+
} else {
|
|
344
|
+
const detected = detectProvider();
|
|
345
|
+
if (detected) {
|
|
346
|
+
provider = detected;
|
|
347
|
+
} else if (process.stdin.isTTY) {
|
|
348
|
+
provider = await select({
|
|
349
|
+
message: "Select your AI tool:",
|
|
350
|
+
choices: PROVIDER_CHOICES,
|
|
351
|
+
});
|
|
352
|
+
} else {
|
|
353
|
+
console.error('Cannot auto-detect provider. Use --provider flag (e.g. npx pairai setup "My Agent" --provider cursor)');
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
112
357
|
const globalIdx = rest.indexOf("--global");
|
|
113
358
|
const useGlobal = globalIdx !== -1 ? (rest.splice(globalIdx, 1), true) : false;
|
|
114
|
-
|
|
359
|
+
let agentName = rest.find((a) => !a.startsWith("--"));
|
|
115
360
|
|
|
116
361
|
const forceIdx = rest.indexOf("--force");
|
|
117
362
|
const useForce = forceIdx !== -1 ? (rest.splice(forceIdx, 1), true) : false;
|
|
118
363
|
if (!agentName) {
|
|
119
|
-
|
|
120
|
-
|
|
364
|
+
if (process.stdin.isTTY) {
|
|
365
|
+
agentName = await input({
|
|
366
|
+
message: 'What should we call your agent? Other agents and users will see this name. (e.g. "Alice\'s Assistant", "Travel Bot")',
|
|
367
|
+
validate: (v) => v.trim().length > 0 && v.trim().length <= 64 ? true : "Name must be 1-64 characters",
|
|
368
|
+
});
|
|
369
|
+
} else {
|
|
370
|
+
console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
121
373
|
}
|
|
122
374
|
|
|
123
375
|
// Check for existing config to avoid accidental overwrites
|
|
@@ -226,6 +478,7 @@ if (command !== "serve") {
|
|
|
226
478
|
console.error("Usage:");
|
|
227
479
|
console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
|
|
228
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");
|
|
229
482
|
console.error(" npx pairai upgrade — update to latest version");
|
|
230
483
|
console.error(" npx pairai version — show current version");
|
|
231
484
|
console.error("");
|
|
@@ -289,7 +542,10 @@ const API_PREFIX = "/api/v1";
|
|
|
289
542
|
|
|
290
543
|
async function hubGet(path: string) {
|
|
291
544
|
const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, { headers, signal: AbortSignal.timeout(HUB_TIMEOUT_MS) });
|
|
292
|
-
if (!res.ok)
|
|
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
|
+
}
|
|
293
549
|
return res.json();
|
|
294
550
|
}
|
|
295
551
|
|
|
@@ -300,7 +556,10 @@ async function hubPost(path: string, body?: unknown) {
|
|
|
300
556
|
body: body ? JSON.stringify(body) : undefined,
|
|
301
557
|
signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
|
|
302
558
|
});
|
|
303
|
-
if (!res.ok)
|
|
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
|
+
}
|
|
304
563
|
return res.json();
|
|
305
564
|
}
|
|
306
565
|
|
|
@@ -342,7 +601,9 @@ async function loadAgentInfo() {
|
|
|
342
601
|
const me = (await hubGet("/agents/me")) as { id: string; name: string; publicKey?: string };
|
|
343
602
|
myAgentId = me.id;
|
|
344
603
|
myPublicKey = me.publicKey ?? "";
|
|
345
|
-
} catch {
|
|
604
|
+
} catch (err) {
|
|
605
|
+
console.error("[pairai] failed to load agent info:", (err as Error).message);
|
|
606
|
+
}
|
|
346
607
|
}
|
|
347
608
|
|
|
348
609
|
async function loadPublicKeys() {
|
|
@@ -351,7 +612,9 @@ async function loadPublicKeys() {
|
|
|
351
612
|
for (const c of conns) {
|
|
352
613
|
if (c.publicKey) pubKeyCache.set(c.agentId, c.publicKey);
|
|
353
614
|
}
|
|
354
|
-
} catch {
|
|
615
|
+
} catch (err) {
|
|
616
|
+
console.error("[pairai] failed to load public keys:", (err as Error).message);
|
|
617
|
+
}
|
|
355
618
|
}
|
|
356
619
|
|
|
357
620
|
function localEncrypt(plaintext: string, taskId: string, recipientPubKeys: Record<string, string>) {
|
|
@@ -377,6 +640,13 @@ const instructions = [
|
|
|
377
640
|
"The channel server polls for updates automatically — you don't need to poll manually.",
|
|
378
641
|
"When the user asks about updates, new messages, or pending work, use pairai_check_updates (not pairai_list_tasks).",
|
|
379
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
|
+
"",
|
|
380
650
|
"Notification attributes:",
|
|
381
651
|
" task_id — the task this message belongs to",
|
|
382
652
|
" task_title — short description of the task",
|
|
@@ -477,6 +747,17 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
477
747
|
required: ["code"],
|
|
478
748
|
},
|
|
479
749
|
},
|
|
750
|
+
{
|
|
751
|
+
name: "pairai_connect_directly",
|
|
752
|
+
description: "Connect directly to an agent that has auto-accept enabled — no pairing code needed. Use pairai_discover_agents to find agents with autoAccept: true.",
|
|
753
|
+
inputSchema: {
|
|
754
|
+
type: "object" as const,
|
|
755
|
+
properties: {
|
|
756
|
+
agent_id: { type: "string", description: "ID of the agent to connect with" },
|
|
757
|
+
},
|
|
758
|
+
required: ["agent_id"],
|
|
759
|
+
},
|
|
760
|
+
},
|
|
480
761
|
{
|
|
481
762
|
name: "pairai_update_profile",
|
|
482
763
|
description: "Update your agent's profile — name, description, capabilities, and metadata. Returns the updated profile.",
|
|
@@ -488,6 +769,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
488
769
|
capabilities: { type: "array", items: { type: "string" }, description: "List of capabilities, e.g. ['scheduling', 'code-review']" },
|
|
489
770
|
metadata: { type: "object", description: "Arbitrary JSON metadata (max 4KB)" },
|
|
490
771
|
discoverable: { type: "boolean", description: "Whether to appear in the public agent directory" },
|
|
772
|
+
autoAccept: { type: "boolean", description: "Whether to accept direct connections without pairing codes" },
|
|
491
773
|
defaultApprovalRule: { type: "string", enum: ["auto", "require"], description: "Default approval rule for new connections" },
|
|
492
774
|
},
|
|
493
775
|
},
|
|
@@ -639,6 +921,90 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
639
921
|
required: ["target_agent_id", "title"],
|
|
640
922
|
},
|
|
641
923
|
},
|
|
924
|
+
{
|
|
925
|
+
name: "pairai_delete_message",
|
|
926
|
+
description: "Delete (tombstone) a message you sent. The message content is replaced with [deleted].",
|
|
927
|
+
inputSchema: {
|
|
928
|
+
type: "object" as const,
|
|
929
|
+
properties: {
|
|
930
|
+
task_id: { type: "string", description: "Task ID" },
|
|
931
|
+
message_id: { type: "string", description: "Message ID to delete" },
|
|
932
|
+
},
|
|
933
|
+
required: ["task_id", "message_id"],
|
|
934
|
+
},
|
|
935
|
+
},
|
|
936
|
+
{
|
|
937
|
+
name: "pairai_delete_file",
|
|
938
|
+
description: "Delete a file you uploaded. Removes from disk and tombstones the associated message.",
|
|
939
|
+
inputSchema: {
|
|
940
|
+
type: "object" as const,
|
|
941
|
+
properties: {
|
|
942
|
+
file_id: { type: "string", description: "File ID to delete" },
|
|
943
|
+
},
|
|
944
|
+
required: ["file_id"],
|
|
945
|
+
},
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
name: "pairai_delete_task",
|
|
949
|
+
description: "Permanently delete a terminal task (completed, failed, cancelled) and all its messages and files.",
|
|
950
|
+
inputSchema: {
|
|
951
|
+
type: "object" as const,
|
|
952
|
+
properties: {
|
|
953
|
+
task_id: { type: "string", description: "Task ID to delete" },
|
|
954
|
+
},
|
|
955
|
+
required: ["task_id"],
|
|
956
|
+
},
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
name: "pairai_rotate_api_key",
|
|
960
|
+
description: "Generate a new API key. WARNING: old key immediately invalidated. Save the new key before doing anything else.",
|
|
961
|
+
inputSchema: {
|
|
962
|
+
type: "object" as const,
|
|
963
|
+
properties: {},
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
{
|
|
967
|
+
name: "pairai_delete_account",
|
|
968
|
+
description: "PERMANENTLY delete your agent and ALL associated data. IRREVERSIBLE.",
|
|
969
|
+
inputSchema: {
|
|
970
|
+
type: "object" as const,
|
|
971
|
+
properties: {},
|
|
972
|
+
},
|
|
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
|
+
},
|
|
986
|
+
{
|
|
987
|
+
name: "pairai_block_agent",
|
|
988
|
+
description: "Block an agent. They cannot discover or connect with you. Disconnects if connected.",
|
|
989
|
+
inputSchema: {
|
|
990
|
+
type: "object" as const,
|
|
991
|
+
properties: {
|
|
992
|
+
agent_id: { type: "string", description: "ID of the agent to block" },
|
|
993
|
+
},
|
|
994
|
+
required: ["agent_id"],
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
name: "pairai_unblock_agent",
|
|
999
|
+
description: "Unblock a previously blocked agent.",
|
|
1000
|
+
inputSchema: {
|
|
1001
|
+
type: "object" as const,
|
|
1002
|
+
properties: {
|
|
1003
|
+
agent_id: { type: "string", description: "ID of the agent to unblock" },
|
|
1004
|
+
},
|
|
1005
|
+
required: ["agent_id"],
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
642
1008
|
],
|
|
643
1009
|
}));
|
|
644
1010
|
|
|
@@ -742,11 +1108,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
742
1108
|
await hubPatch(`/tasks/${args.task_id}`, { status: args.status });
|
|
743
1109
|
return { content: [{ type: "text" as const, text: `Status → ${args.status}` }] };
|
|
744
1110
|
} catch (err) {
|
|
745
|
-
const
|
|
746
|
-
if (msg.includes("409") || msg.includes("400")) {
|
|
747
|
-
return { content: [{ type: "text" as const, text: `Cannot update status — ${msg}` }] };
|
|
748
|
-
}
|
|
749
|
-
throw err;
|
|
1111
|
+
return { content: [{ type: "text" as const, text: `Cannot update status: ${(err as Error).message}` }], isError: true };
|
|
750
1112
|
}
|
|
751
1113
|
}
|
|
752
1114
|
|
|
@@ -809,6 +1171,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
809
1171
|
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
810
1172
|
}
|
|
811
1173
|
|
|
1174
|
+
if (name === "pairai_connect_directly") {
|
|
1175
|
+
const { agent_id } = args as { agent_id: string };
|
|
1176
|
+
const data = await hubPost(`/connect/${agent_id}`);
|
|
1177
|
+
// Refresh public keys after new connection
|
|
1178
|
+
await loadPublicKeys();
|
|
1179
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
812
1182
|
if (name === "pairai_update_profile") {
|
|
813
1183
|
const body: Record<string, unknown> = {};
|
|
814
1184
|
if (args.name !== undefined) body.name = args.name;
|
|
@@ -816,6 +1186,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
816
1186
|
if (args.capabilities !== undefined) body.capabilities = args.capabilities;
|
|
817
1187
|
if (args.metadata !== undefined) body.metadata = args.metadata;
|
|
818
1188
|
if (args.discoverable !== undefined) body.discoverable = args.discoverable === "true" || args.discoverable === true;
|
|
1189
|
+
if (args.autoAccept !== undefined) body.autoAccept = args.autoAccept === "true" || args.autoAccept === true;
|
|
819
1190
|
if (args.defaultApprovalRule !== undefined) body.defaultApprovalRule = args.defaultApprovalRule;
|
|
820
1191
|
const data = await hubPatch("/agents/me", body);
|
|
821
1192
|
return { content: [{ type: "text" as const, text: "Profile updated.\n" + JSON.stringify(data, null, 2) }] };
|
|
@@ -880,12 +1251,12 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
880
1251
|
|
|
881
1252
|
if (name === "pairai_list_tasks") {
|
|
882
1253
|
await loadPublicKeys();
|
|
883
|
-
const
|
|
1254
|
+
const qs = args.status ? `?status=${args.status}` : "";
|
|
1255
|
+
const data = (await hubGet(`/tasks${qs}`)) as Array<{
|
|
884
1256
|
id: string; status: string; title: string; encrypted?: boolean;
|
|
885
1257
|
description?: string; descriptionKeys?: any; senderSignature?: string; initiatorAgentId?: string;
|
|
886
1258
|
}>;
|
|
887
|
-
const
|
|
888
|
-
const decrypted = filtered.map((t) => {
|
|
1259
|
+
const decrypted = data.map((t) => {
|
|
889
1260
|
if (t.encrypted) {
|
|
890
1261
|
const desc = decryptTaskDescription(t, t.id);
|
|
891
1262
|
return { ...t, title: desc.split("\n")[0] || t.title, description: desc };
|
|
@@ -915,6 +1286,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
915
1286
|
}
|
|
916
1287
|
const decryptedMsgs = msgs.map((m) => {
|
|
917
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
|
+
}
|
|
918
1293
|
try {
|
|
919
1294
|
const d = decryptMessage(m, data.id);
|
|
920
1295
|
return { ...m, content: d.content, contentType: d.contentType };
|
|
@@ -1112,6 +1487,84 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1112
1487
|
return { content: [{ type: "text" as const, text: `Encrypted task created. ID: ${taskId}` }] };
|
|
1113
1488
|
}
|
|
1114
1489
|
|
|
1490
|
+
if (name === "pairai_delete_message") {
|
|
1491
|
+
const { task_id, message_id } = args as { task_id: string; message_id: string };
|
|
1492
|
+
try {
|
|
1493
|
+
await hubDelete(`/tasks/${task_id}/messages/${message_id}`);
|
|
1494
|
+
return { content: [{ type: "text" as const, text: "Message deleted." }] };
|
|
1495
|
+
} catch (err) {
|
|
1496
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if (name === "pairai_delete_file") {
|
|
1501
|
+
const { file_id } = args as { file_id: string };
|
|
1502
|
+
try {
|
|
1503
|
+
await hubDelete(`/files/${file_id}`);
|
|
1504
|
+
return { content: [{ type: "text" as const, text: "File deleted." }] };
|
|
1505
|
+
} catch (err) {
|
|
1506
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (name === "pairai_delete_task") {
|
|
1511
|
+
const { task_id } = args as { task_id: string };
|
|
1512
|
+
try {
|
|
1513
|
+
const result = (await hubDelete(`/tasks/${task_id}`)) as { deletedMessages?: number; deletedFiles?: number };
|
|
1514
|
+
return { content: [{ type: "text" as const, text: `Task deleted. ${result.deletedMessages ?? 0} message(s) and ${result.deletedFiles ?? 0} file(s) removed.` }] };
|
|
1515
|
+
} catch (err) {
|
|
1516
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
if (name === "pairai_rotate_api_key") {
|
|
1521
|
+
try {
|
|
1522
|
+
const result = (await hubPost("/agents/me/rotate-key")) as { apiKey: string };
|
|
1523
|
+
return { content: [{ type: "text" as const, text: `New API key: ${result.apiKey}\n\nWARNING: Your old key is now invalid. Save this key immediately — it will not be shown again.` }] };
|
|
1524
|
+
} catch (err) {
|
|
1525
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
if (name === "pairai_delete_account") {
|
|
1530
|
+
try {
|
|
1531
|
+
await hubDelete("/agents/me");
|
|
1532
|
+
return { content: [{ type: "text" as const, text: "Account deleted." }] };
|
|
1533
|
+
} catch (err) {
|
|
1534
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
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
|
+
|
|
1548
|
+
if (name === "pairai_block_agent") {
|
|
1549
|
+
const { agent_id } = args as { agent_id: string };
|
|
1550
|
+
try {
|
|
1551
|
+
await hubPost("/agents/me/block", { agentId: agent_id });
|
|
1552
|
+
return { content: [{ type: "text" as const, text: "Agent blocked." }] };
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
if (name === "pairai_unblock_agent") {
|
|
1559
|
+
const { agent_id } = args as { agent_id: string };
|
|
1560
|
+
try {
|
|
1561
|
+
await hubDelete(`/agents/me/block/${agent_id}`);
|
|
1562
|
+
return { content: [{ type: "text" as const, text: "Agent unblocked." }] };
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1115
1568
|
throw new Error(`Unknown tool: ${name}`);
|
|
1116
1569
|
});
|
|
1117
1570
|
|
|
@@ -1127,6 +1580,12 @@ function decryptMessage(
|
|
|
1127
1580
|
if (msg.contentType !== "encrypted" || !msg.encryptedKeys || !msg.senderSignature || !PRIVATE_KEY) {
|
|
1128
1581
|
return { content: msg.content, contentType: msg.contentType };
|
|
1129
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
|
+
}
|
|
1130
1589
|
try {
|
|
1131
1590
|
const keys = typeof msg.encryptedKeys === "string" ? JSON.parse(msg.encryptedKeys) : msg.encryptedKeys;
|
|
1132
1591
|
const senderPub = msg.senderAgentId === myAgentId ? myPublicKey : pubKeyCache.get(msg.senderAgentId);
|
|
@@ -1207,7 +1666,7 @@ async function poll() {
|
|
|
1207
1666
|
const decryptedMessages = (taskMsgs ?? []).map((m) => {
|
|
1208
1667
|
// Encrypted file messages: content is a file ID (short nanoid), not ciphertext
|
|
1209
1668
|
if (m.contentType === "encrypted" && m.encryptedKeys && m.content.length < 30) {
|
|
1210
|
-
return
|
|
1669
|
+
return `[File attachment — use pairai_download_file with task_id: "${task.id}", file_id: "${m.content}"]`;
|
|
1211
1670
|
}
|
|
1212
1671
|
try {
|
|
1213
1672
|
const d = decryptMessage(m, task.id);
|
|
@@ -1260,7 +1719,7 @@ async function poll() {
|
|
|
1260
1719
|
const isEncryptedFile = msg.contentType === "encrypted" && msg.encryptedKeys && msg.content.length < 30;
|
|
1261
1720
|
let decrypted: { content: string; contentType: string };
|
|
1262
1721
|
if (isEncryptedFile) {
|
|
1263
|
-
decrypted = { content:
|
|
1722
|
+
decrypted = { content: `[File attachment — use pairai_download_file with task_id: "${unread.taskId}", file_id: "${msg.content}"]`, contentType: "text" };
|
|
1264
1723
|
} else {
|
|
1265
1724
|
try {
|
|
1266
1725
|
decrypted = decryptMessage(msg, unread.taskId);
|