patchcord 0.5.54 → 0.5.55

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/bin/patchcord.mjs CHANGED
@@ -74,6 +74,12 @@ Usage:
74
74
  patchcord --token <token> [--server <url>] Headless / self-hosted setup
75
75
  patchcord --full Same + full statusline
76
76
  patchcord --rename <new-name> [--agent-type <type>] Rename this agent
77
+ patchcord whoami Show your identity + project
78
+ patchcord whoami --propose "<text>" Propose an update to your whoami
79
+ patchcord whoami --approve <id> Approve a pending proposal
80
+ patchcord whoami --reject <id> Reject a pending proposal
81
+ patchcord agents List every agent (with whoami)
82
+ patchcord agents <name> Show one agent's whoami
77
83
  patchcord subscribe Start the realtime listener
78
84
  patchcord update Update to the latest version
79
85
  patchcord --version Show installed version
@@ -88,6 +94,312 @@ if (cmd === "plugin-path") {
88
94
  process.exit(0);
89
95
  }
90
96
 
97
+ // Shared bearer/base-url resolver. Project-local configs win over globals.
98
+ // Supports all 11 installer targets: claude_code, codex, cursor, vscode,
99
+ // opencode (per-project) + windsurf, gemini, zed, openclaw, antigravity,
100
+ // cline (global). Each tool stores the bearer in its own shape.
101
+ async function _resolveBearer() {
102
+ const { readFileSync } = await import("fs");
103
+
104
+ // Strict JSON first; fall back to JSONC stripping for zed/gemini-style
105
+ // settings (which allow // /* */ trailing commas). The fallback strips
106
+ // ONLY outside string literals so URLs like "https://..." survive.
107
+ const _stripJsoncOutsideStrings = (raw) => {
108
+ let out = "";
109
+ let i = 0;
110
+ let inStr = false;
111
+ let strCh = "";
112
+ let prev = "";
113
+ while (i < raw.length) {
114
+ const c = raw[i];
115
+ if (inStr) {
116
+ out += c;
117
+ if (c === strCh && prev !== "\\") inStr = false;
118
+ prev = c;
119
+ i++;
120
+ continue;
121
+ }
122
+ if (c === '"' || c === "'") {
123
+ inStr = true;
124
+ strCh = c;
125
+ out += c;
126
+ prev = c;
127
+ i++;
128
+ continue;
129
+ }
130
+ if (c === "/" && raw[i + 1] === "/") {
131
+ while (i < raw.length && raw[i] !== "\n") i++;
132
+ continue;
133
+ }
134
+ if (c === "/" && raw[i + 1] === "*") {
135
+ i += 2;
136
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
137
+ i += 2;
138
+ continue;
139
+ }
140
+ out += c;
141
+ prev = c;
142
+ i++;
143
+ }
144
+ return out.replace(/,(\s*[}\]])/g, "$1");
145
+ };
146
+ const _parseJsonc = (raw) => {
147
+ try { return JSON.parse(raw); } catch {}
148
+ return JSON.parse(_stripJsoncOutsideStrings(raw));
149
+ };
150
+
151
+ const _extractBearer = (entry) => {
152
+ if (!entry) return null;
153
+ const auth = entry?.headers?.Authorization;
154
+ // Each tool has its own url key; check all known variants.
155
+ const url = entry.url || entry.httpUrl || entry.serverUrl;
156
+ if (!auth || !url) return null;
157
+ return {
158
+ token: auth.replace(/^Bearer\s+/i, ""),
159
+ baseUrl: url.replace(/\/mcp(\/bearer)?$/, ""),
160
+ };
161
+ };
162
+
163
+ // Shape readers: each returns {token, baseUrl, configFile, tool} or null.
164
+ const readJsonAt = (path, keyPath, tool) => {
165
+ if (!existsSync(path)) return null;
166
+ try {
167
+ const obj = _parseJsonc(readFileSync(path, "utf-8"));
168
+ const entry = keyPath.reduce((o, k) => o?.[k], obj);
169
+ const b = _extractBearer(entry);
170
+ return b ? { ...b, configFile: path, tool } : null;
171
+ } catch { return null; }
172
+ };
173
+ const readCodexTomlShape = (path) => {
174
+ if (!existsSync(path)) return null;
175
+ try {
176
+ const content = readFileSync(path, "utf-8");
177
+ const block = content.match(/\[mcp_servers\.patchcord[-\w]*\]([\s\S]*?)(?=\n\[|$)/);
178
+ if (!block) return null;
179
+ const urlMatch = block[1].match(/url\s*=\s*"([^"]+)"/);
180
+ const tokenMatch = block[1].match(/Bearer\s+([^\s"]+)/);
181
+ if (!urlMatch || !tokenMatch) return null;
182
+ return {
183
+ token: tokenMatch[1],
184
+ baseUrl: urlMatch[1].replace(/\/mcp(\/bearer)?$/, ""),
185
+ configFile: path,
186
+ tool: "codex",
187
+ };
188
+ } catch { return null; }
189
+ };
190
+
191
+ // Per-project (walk up from cwd). First win.
192
+ const projectReaders = [
193
+ (cwd) => readJsonAt(join(cwd, ".mcp.json"), ["mcpServers", "patchcord"], "claude_code"),
194
+ (cwd) => readJsonAt(join(cwd, ".cursor", "mcp.json"), ["mcpServers", "patchcord"], "cursor"),
195
+ (cwd) => readJsonAt(join(cwd, ".vscode", "mcp.json"), ["servers", "patchcord"], "vscode"),
196
+ (cwd) => readJsonAt(join(cwd, "opencode.json"), ["mcp", "patchcord"], "opencode"),
197
+ (cwd) => readCodexTomlShape(join(cwd, ".codex", "config.toml")),
198
+ ];
199
+ let dir = process.cwd();
200
+ while (dir && dir !== "/") {
201
+ for (const r of projectReaders) {
202
+ const found = r(dir);
203
+ if (found) return found;
204
+ }
205
+ dir = dirname(dir);
206
+ }
207
+
208
+ // Global fallbacks. Order by likelihood for current install base.
209
+ const zedPath = process.platform === "darwin"
210
+ ? join(HOME, "Library", "Application Support", "Zed", "settings.json")
211
+ : join(HOME, ".config", "zed", "settings.json");
212
+
213
+ // Cline lives in VS Code globalStorage — try stable / insiders / cursor.
214
+ const clinePaths = (() => {
215
+ const variants = process.platform === "darwin"
216
+ ? [
217
+ join(HOME, "Library", "Application Support", "Code", "User", "globalStorage"),
218
+ join(HOME, "Library", "Application Support", "Code - Insiders", "User", "globalStorage"),
219
+ join(HOME, "Library", "Application Support", "Cursor", "User", "globalStorage"),
220
+ ]
221
+ : process.platform === "win32"
222
+ ? [
223
+ join(process.env.APPDATA || join(HOME, "AppData", "Roaming"), "Code", "User", "globalStorage"),
224
+ join(process.env.APPDATA || join(HOME, "AppData", "Roaming"), "Code - Insiders", "User", "globalStorage"),
225
+ join(process.env.APPDATA || join(HOME, "AppData", "Roaming"), "Cursor", "User", "globalStorage"),
226
+ ]
227
+ : [
228
+ join(HOME, ".config", "Code", "User", "globalStorage"),
229
+ join(HOME, ".config", "Code - Insiders", "User", "globalStorage"),
230
+ join(HOME, ".config", "Cursor", "User", "globalStorage"),
231
+ ];
232
+ return variants.map((b) =>
233
+ join(b, "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json")
234
+ );
235
+ })();
236
+
237
+ const globalCandidates = [
238
+ () => readJsonAt(join(HOME, ".codeium", "windsurf", "mcp_config.json"), ["mcpServers", "patchcord"], "windsurf"),
239
+ () => readJsonAt(join(HOME, ".gemini", "settings.json"), ["mcpServers", "patchcord"], "gemini"),
240
+ () => readJsonAt(zedPath, ["context_servers", "patchcord"], "zed"),
241
+ () => readJsonAt(join(HOME, ".openclaw", "openclaw.json"), ["mcp", "servers", "patchcord"], "openclaw"),
242
+ () => readJsonAt(join(HOME, ".gemini", "antigravity", "mcp_config.json"), ["mcpServers", "patchcord"], "antigravity"),
243
+ ...clinePaths.map((p) => () => readJsonAt(p, ["mcpServers", "patchcord"], "cline")),
244
+ ];
245
+ for (const r of globalCandidates) {
246
+ const found = r();
247
+ if (found) return found;
248
+ }
249
+
250
+ return null;
251
+ }
252
+
253
+ async function _httpJSON(method, url, token, body) {
254
+ const tmpResp = `/tmp/patchcord-${process.pid}-${Date.now()}.json`;
255
+ const dataArg = body ? `-d '${JSON.stringify(body).replace(/'/g, "'\\''")}' ` : "";
256
+ const headers = `-H "Authorization: Bearer ${token}" -H "Content-Type: application/json" `;
257
+ const statusCode = run(
258
+ `curl -s -o "${tmpResp}" -w "%{http_code}" -X ${method} ${headers}${dataArg}"${url}"`
259
+ );
260
+ const { readFileSync } = await import("fs");
261
+ let respBody = "";
262
+ try { respBody = readFileSync(tmpResp, "utf-8"); } catch {}
263
+ try { (await import("fs")).unlinkSync(tmpResp); } catch {}
264
+ let json = null;
265
+ try { json = JSON.parse(respBody); } catch {}
266
+ return { status: statusCode, body: respBody, json };
267
+ }
268
+
269
+ // ── whoami ────────────────────────────────────────────────────
270
+ if (cmd === "whoami") {
271
+ const args = process.argv.slice(3);
272
+ const found = await _resolveBearer();
273
+ if (!found) {
274
+ console.error("No patchcord config found in current directory or any parent. Run `npx patchcord@latest` from a project directory first.");
275
+ process.exit(1);
276
+ }
277
+ const { token, baseUrl } = found;
278
+ if (!isSafeToken(token) || !isSafeUrl(baseUrl)) {
279
+ console.error(`Invalid patchcord URL or token in config.`);
280
+ process.exit(1);
281
+ }
282
+
283
+ // --propose "<text>"
284
+ const proposeIdx = args.indexOf("--propose");
285
+ if (proposeIdx !== -1) {
286
+ const text = (args[proposeIdx + 1] || "").trim();
287
+ if (!text) {
288
+ console.error("Usage: patchcord whoami --propose \"<text>\"");
289
+ process.exit(1);
290
+ }
291
+ if (text.length > 300) {
292
+ console.error(`Whoami text exceeds 300 characters (got ${text.length}).`);
293
+ process.exit(1);
294
+ }
295
+ const { status, json, body } = await _httpJSON("POST", `${baseUrl}/api/agent/whoami/propose`, token, { text });
296
+ if (status !== "200") {
297
+ console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
298
+ process.exit(1);
299
+ }
300
+ console.log(`proposal_id: ${json.proposal_id}`);
301
+ console.log(`status: ${json.status}`);
302
+ console.log(`awaiting human approval. another agent or the dashboard must approve.`);
303
+ process.exit(0);
304
+ }
305
+
306
+ // --approve <id> / --reject <id>
307
+ const approveIdx = args.indexOf("--approve");
308
+ const rejectIdx = args.indexOf("--reject");
309
+ if (approveIdx !== -1 || rejectIdx !== -1) {
310
+ const isApprove = approveIdx !== -1;
311
+ const idx = isApprove ? approveIdx : rejectIdx;
312
+ const id = (args[idx + 1] || "").trim();
313
+ if (!id) {
314
+ console.error(`Usage: patchcord whoami --${isApprove ? "approve" : "reject"} <proposal-id>`);
315
+ process.exit(1);
316
+ }
317
+ const { status, json, body } = await _httpJSON(
318
+ "POST",
319
+ `${baseUrl}/api/agent/whoami/approve`,
320
+ token,
321
+ { proposal_id: id, action: isApprove ? "approve" : "reject" },
322
+ );
323
+ if (status !== "200") {
324
+ console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
325
+ process.exit(1);
326
+ }
327
+ console.log(`✓ ${json.status}: ${json.agent} (proposal ${json.proposal_id})`);
328
+ process.exit(0);
329
+ }
330
+
331
+ // plain whoami (no flags)
332
+ const { status, json, body } = await _httpJSON("GET", `${baseUrl}/api/agent/whoami`, token);
333
+ if (status !== "200") {
334
+ console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
335
+ process.exit(1);
336
+ }
337
+ console.log(`agent: ${json.agent}`);
338
+ console.log(`namespace: ${json.namespace}`);
339
+ if (json.project_summary) console.log(`project: ${json.project_summary}`);
340
+ if (json.whoami) {
341
+ console.log(`self: ${json.whoami}`);
342
+ } else {
343
+ console.log(`self: (empty — propose with: patchcord whoami --propose "<text>")`);
344
+ }
345
+ console.log();
346
+ console.log(`tip: \`patchcord agents\` returns whoami for all peers.`);
347
+ process.exit(0);
348
+ }
349
+
350
+ // ── agents [name] ────────────────────────────────────────────
351
+ if (cmd === "agents") {
352
+ const name = (process.argv[3] || "").trim().toLowerCase();
353
+ const found = await _resolveBearer();
354
+ if (!found) {
355
+ console.error("No patchcord config found in current directory or any parent. Run `npx patchcord@latest` from a project directory first.");
356
+ process.exit(1);
357
+ }
358
+ const { token, baseUrl } = found;
359
+ if (!isSafeToken(token) || !isSafeUrl(baseUrl)) {
360
+ console.error(`Invalid patchcord URL or token in config.`);
361
+ process.exit(1);
362
+ }
363
+
364
+ if (name) {
365
+ if (!/^[a-z][a-z0-9-]{0,49}$/.test(name)) {
366
+ console.error("Invalid agent name. Lowercase letters, digits, and dashes only.");
367
+ process.exit(1);
368
+ }
369
+ const { status, json, body } = await _httpJSON("GET", `${baseUrl}/api/agent/whoami/${name}`, token);
370
+ if (status === "404") {
371
+ console.error(`agent '${name}' not found in your namespace.`);
372
+ process.exit(1);
373
+ }
374
+ if (status !== "200") {
375
+ console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
376
+ process.exit(1);
377
+ }
378
+ const tag = json.is_global ? " (global)" : "";
379
+ console.log(`${json.agent}${tag}: ${json.whoami || "(empty)"}`);
380
+ if (json.status || json.last_seen) {
381
+ console.log(`status: ${json.status || "?"} last_seen: ${json.last_seen || "?"}`);
382
+ }
383
+ process.exit(0);
384
+ }
385
+
386
+ const { status, json, body } = await _httpJSON("GET", `${baseUrl}/api/agent/agents`, token);
387
+ if (status !== "200") {
388
+ console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
389
+ process.exit(1);
390
+ }
391
+ if (!json.agents || json.agents.length === 0) {
392
+ console.log("(no agents in this namespace)");
393
+ process.exit(0);
394
+ }
395
+ for (const a of json.agents) {
396
+ const tag = a.is_global ? " (global)" : "";
397
+ const text = a.whoami || "(empty)";
398
+ console.log(`${a.agent}${tag}: ${text}`);
399
+ }
400
+ process.exit(0);
401
+ }
402
+
91
403
  // ── subscribe ─────────────────────────────────────────────────
92
404
  // Thin wrapper around scripts/subscribe.mjs so users see a clean
93
405
  // "npx patchcord subscribe" in their Claude Code tool log instead
@@ -1276,8 +1588,12 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
1276
1588
  mkdirSync(codexDir, { recursive: true });
1277
1589
  const configPath = join(codexDir, "config.toml");
1278
1590
  let existing = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "";
1279
- // Remove old patchcord config block if present
1280
- existing = existing.replace(/\[mcp_servers\.patchcord[-\w]*\]\n(?:(?!\[)[^\n]*\n?)*/g, "").replace(/\n{3,}/g, "\n\n").trim();
1591
+ // Remove old patchcord config blocks AND any sub-tables like
1592
+ // [mcp_servers.patchcord.http_headers]. The character class [\w.-]
1593
+ // covers patchcord-codex, patchcord.http_headers, etc. We consume
1594
+ // through to the next "[" so the entire block (including subsections)
1595
+ // is removed in one pass.
1596
+ existing = existing.replace(/\[mcp_servers\.patchcord[\w.-]*\][^\[]*/g, "").replace(/\n{3,}/g, "\n\n").trim();
1281
1597
  existing = existing.trimEnd() + `\n\n[mcp_servers.patchcord-codex]\nurl = "${serverUrl}/mcp"\nhttp_headers = { "Authorization" = "Bearer ${token}", "X-Patchcord-Machine" = "${hostname}" }\ntool_timeout_sec = 300\n`;
1282
1598
  writeFileSync(configPath, existing);
1283
1599
  // Clean up any PATCHCORD_TOKEN we previously wrote to .env
@@ -1605,5 +1921,5 @@ if (cmd === "skill") {
1605
1921
  process.exit(0);
1606
1922
  }
1607
1923
 
1608
- console.error(`Unknown command: ${cmd}. Available: subscribe, --rename, --token, --tool`);
1924
+ console.error(`Unknown command: ${cmd}. Available: whoami, agents, subscribe, update, --rename, --token, --agent-type, --version, --help`);
1609
1925
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.5.54",
3
+ "version": "0.5.55",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": "ppravdin",
6
6
  "license": "MIT",
@@ -112,6 +112,29 @@ Use the path from the sender's message.
112
112
 
113
113
  Send the returned `path` to the other agent in your message so they can download it.
114
114
 
115
+ ## Identity (`patchcord whoami` / `patchcord agents`)
116
+
117
+ `whoami` and `agents` are CLI commands, not MCP tools. They use the bearer token from the project's `.mcp.json` — same namespace scope, no extra setup. Cheap to call (input tokens only), don't bloat MCP.
118
+
119
+ - **Run `patchcord whoami` once per session.** Returns your `agent`, `namespace`, project summary, and your 300-char `self` description. Use it on first turn after `/clear` or a fresh session to orient.
120
+ - **Run `patchcord agents`** to see the full roster (every peer's whoami). One call, ~3KB, complete picture of the namespace.
121
+ - **Run `patchcord agents <name>`** when an unknown agent messages you and you want to know who they are before acting on their request.
122
+
123
+ ### Updating your own whoami (gated)
124
+
125
+ 300-char hard limit. Goes through a human-approval flow.
126
+
127
+ - `patchcord whoami --propose "<text>"` — submits a pending proposal. Returns a `proposal_id`. The server also creates a normal patchcord message to `human` showing the diff.
128
+ - Another agent (NOT you) or the human via dashboard must approve. Self-approval is blocked server-side — you can propose updates to your own whoami, but you cannot approve them.
129
+ - To approve someone else's proposal: `patchcord whoami --approve <proposal-id>`. To reject: `patchcord whoami --reject <id>`.
130
+ - The human sees your proposal in their inbox/dashboard. They will approve when ready. Do not retry — proposals are idempotent.
131
+
132
+ ### Hard rules
133
+
134
+ - You may NEVER update another agent's whoami. The `--propose` flow only writes your own.
135
+ - Namespace scope is enforced server-side: `patchcord agents <name>` returns 404 if the name isn't in your namespace (global agents like claudeai/chatgpt are excepted on cloud).
136
+ - whoami text describes WHO you are and how you coordinate (e.g. "backend systems. sends every change to codex-backend for review"). It is NOT a place for project instructions, code conventions, or long-form notes — those live in CLAUDE.md and project docs.
137
+
115
138
  ## Threads
116
139
 
117
140
  Named threads group related messages between a pair of agents. Use them for multi-turn tasks that need their own context (e.g. "auth-migration", "deploy-review").