patchcord 0.6.10 → 0.6.12

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "patchcord",
3
3
  "description": "Cross-machine agent messaging. Messages from other agents land in the inbox and wake the agent to reply.",
4
- "version": "0.6.10",
4
+ "version": "0.6.12",
5
5
  "author": {
6
6
  "name": "ppravdin"
7
7
  },
package/bin/patchcord.mjs CHANGED
@@ -10,6 +10,7 @@ const HOME = homedir();
10
10
 
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
  const pluginRoot = join(__dirname, "..");
13
+ import { resolveProjectBearer, harnessContext } from "../scripts/lib/resolve-project-bearer.mjs";
13
14
  const cmd = process.argv[2];
14
15
 
15
16
  if (cmd === "--version" || cmd === "-v") {
@@ -134,13 +135,7 @@ if (cmd === "plugin-path") {
134
135
  // opencode (per-project) + windsurf, gemini, zed, openclaw, antigravity,
135
136
  // cline, kimi (global). Each tool stores the bearer in its own shape.
136
137
  function _kimiContextRequested(options = {}) {
137
- const forceTool = (process.env.PATCHCORD_FORCE_TOOL || process.env.PATCHCORD_TOOL || "").toLowerCase();
138
- return options.preferKimi === true
139
- || forceTool === "kimi"
140
- || Boolean(process.env.KIMI_CLI)
141
- || Boolean(process.env.KIMI_SESSION_ID)
142
- || Boolean(process.env.KIMI_CONFIG_FILE)
143
- || Boolean(process.env.KIMI_MCP_CONFIG_FILE);
138
+ return harnessContext(process.env, options).preferKimi;
144
139
  }
145
140
 
146
141
  // Strip legacy Python kimi-cli per-project artifacts. Kimi Code (Node) uses
@@ -182,7 +177,73 @@ function _purgeLegacyKimiProjectConfig(dir) {
182
177
  } catch {}
183
178
  }
184
179
 
185
- // Insert-or-update mcp_servers.patchcord in a Hermes ~/.hermes/config.yaml,
180
+ // Cursor indexes skills from multiple trees. Leftover Codex globals
181
+ // (~/.agents/skills/patchcord*) and Claude colon-named plugin skills bundled
182
+ // inside cursor-agent's node_modules/patchcord both register duplicate slash
183
+ // commands (/patchcordinbox vs /patchcord-inbox). Keep only skills-cursor.
184
+ function _purgeCursorSkillDuplicates() {
185
+ const staleNames = [
186
+ "patchcord",
187
+ "patchcord-inbox",
188
+ "patchcord-wait",
189
+ "patchcord-subscribe",
190
+ "patchcordinbox",
191
+ "patchcordwait",
192
+ "patchcordsubscribe",
193
+ "patchcord:inbox",
194
+ "patchcord:wait",
195
+ "patchcord:subscribe",
196
+ ];
197
+ for (const root of [
198
+ join(HOME, ".agents", "skills"),
199
+ join(HOME, ".cursor", "skills-cursor"),
200
+ ]) {
201
+ for (const name of staleNames) {
202
+ const d = join(root, name);
203
+ if (!existsSync(d)) continue;
204
+ try { rmSync(d, { recursive: true, force: true }); } catch {}
205
+ }
206
+ }
207
+ // cursor-agent ships patchcord@* with Claude plugin skills/ — Cursor must not
208
+ // index those; only per-project skills-cursor copies are valid for Cursor CLI.
209
+ const cursorAgentRoot = join(HOME, ".local", "share", "cursor-agent");
210
+ if (existsSync(cursorAgentRoot)) {
211
+ const walk = (dir, depth = 0) => {
212
+ if (depth > 8) return;
213
+ let entries;
214
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
215
+ for (const ent of entries) {
216
+ const p = join(dir, ent.name);
217
+ if (ent.isDirectory()) {
218
+ if (ent.name === "patchcord" && dir.endsWith("node_modules")) {
219
+ for (const pluginSkill of ["inbox", "wait", "subscribe"]) {
220
+ const sd = join(p, "skills", pluginSkill);
221
+ if (existsSync(sd)) {
222
+ try { rmSync(sd, { recursive: true, force: true }); } catch {}
223
+ }
224
+ }
225
+ continue;
226
+ }
227
+ walk(p, depth + 1);
228
+ }
229
+ }
230
+ };
231
+ walk(cursorAgentRoot);
232
+ }
233
+ const syncManifest = join(HOME, ".cursor", "skills-cursor", ".sync-manifest.json");
234
+ if (existsSync(syncManifest)) {
235
+ try {
236
+ const obj = JSON.parse(readFileSync(syncManifest, "utf-8"));
237
+ if (obj?.skills) {
238
+ for (const stale of ["patchcord", "patchcordinbox", "patchcordwait", "patchcordsubscribe", "patchcord:inbox", "patchcord:wait", "patchcord:subscribe"]) {
239
+ delete obj.skills[stale];
240
+ }
241
+ writeFileSync(syncManifest, JSON.stringify(obj, null, 2) + "\n");
242
+ }
243
+ } catch {}
244
+ }
245
+ }
246
+
186
247
  // preserving the rest of the user's YAML. Block-style only (what Hermes writes).
187
248
  function upsertHermesConfig(existing, url, token) {
188
249
  const block = [
@@ -268,7 +329,9 @@ function readGrokTomlShape(path) {
268
329
 
269
330
  async function _resolveBearer(options = {}) {
270
331
  const { readFileSync } = await import("fs");
271
- const preferKimi = _kimiContextRequested(options);
332
+
333
+ const projectFound = resolveProjectBearer(process.cwd(), options);
334
+ if (projectFound) return projectFound;
272
335
 
273
336
  // Strict JSON first; fall back to JSONC stripping for zed/gemini-style
274
337
  // settings (which allow // /* */ trailing commas). The fallback strips
@@ -357,30 +420,6 @@ async function _resolveBearer(options = {}) {
357
420
  } catch { return null; }
358
421
  };
359
422
 
360
- // Per-project (walk up from cwd). First win.
361
- const kimiProjectReader = (cwd) => readJsonAt(join(cwd, ".kimi", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
362
- const kimiCodeProjectReader = (cwd) => readJsonAt(join(cwd, ".kimi-code", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
363
- const defaultProjectReaders = [
364
- (cwd) => readJsonAt(join(cwd, ".mcp.json"), ["mcpServers", "patchcord"], "claude_code"),
365
- (cwd) => readJsonAt(join(cwd, ".cursor", "mcp.json"), ["mcpServers", "patchcord"], "cursor"),
366
- (cwd) => readJsonAt(join(cwd, ".vscode", "mcp.json"), ["servers", "patchcord"], "vscode"),
367
- (cwd) => readJsonAt(join(cwd, "opencode.json"), ["mcp", "patchcord"], "opencode"),
368
- (cwd) => readJsonAt(join(cwd, ".agents", "mcp_config.json"), ["mcpServers", "patchcord"], "antigravity"),
369
- (cwd) => readGrokTomlShape(join(cwd, ".grok", "config.toml")),
370
- (cwd) => readCodexTomlShape(join(cwd, ".codex", "config.toml")),
371
- ];
372
- const projectReaders = preferKimi
373
- ? [kimiProjectReader, kimiCodeProjectReader, ...defaultProjectReaders]
374
- : [...defaultProjectReaders, kimiProjectReader, kimiCodeProjectReader];
375
- let dir = process.cwd();
376
- while (dir && dir !== "/") {
377
- for (const r of projectReaders) {
378
- const found = r(dir);
379
- if (found) { found.scope = "project"; return found; }
380
- }
381
- dir = dirname(dir);
382
- }
383
-
384
423
  // Global fallbacks. Order by likelihood for current install base.
385
424
  const zedPath = process.platform === "darwin"
386
425
  ? join(HOME, "Library", "Application Support", "Zed", "settings.json")
@@ -1809,6 +1848,7 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
1809
1848
  const cursorSkillsRoot = join(HOME, ".cursor", "skills-cursor");
1810
1849
  const hasCursorAgent = run("which cursor-agent");
1811
1850
  if (hasCursorAgent || existsSync(cursorSkillsRoot)) {
1851
+ _purgeCursorSkillDuplicates();
1812
1852
  mkdirSync(cursorSkillsRoot, { recursive: true });
1813
1853
  const cursorInboxDir = join(cursorSkillsRoot, "patchcord-inbox");
1814
1854
  const cursorWaitDir = join(cursorSkillsRoot, "patchcord-wait");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "scripts": {
6
6
  "version": "node scripts/sync-plugin-version.mjs && git add .claude-plugin/plugin.json"
@@ -0,0 +1,200 @@
1
+ // Shared per-project bearer resolution for patchcord.mjs and subscribe.mjs.
2
+ // When multiple tool configs exist in one directory (common: .mcp.json from
3
+ // Claude Code + .cursor/mcp.json from Cursor), pick the config matching the
4
+ // invoking harness instead of always preferring .mcp.json first.
5
+
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+
9
+ function stripJsoncOutsideStrings(raw) {
10
+ let out = "";
11
+ let i = 0;
12
+ let inStr = false;
13
+ let strCh = "";
14
+ let prev = "";
15
+ while (i < raw.length) {
16
+ const c = raw[i];
17
+ if (inStr) {
18
+ out += c;
19
+ if (c === strCh && prev !== "\\") inStr = false;
20
+ prev = c;
21
+ i++;
22
+ continue;
23
+ }
24
+ if (c === '"' || c === "'") {
25
+ inStr = true;
26
+ strCh = c;
27
+ out += c;
28
+ prev = c;
29
+ i++;
30
+ continue;
31
+ }
32
+ if (c === "/" && raw[i + 1] === "/") {
33
+ while (i < raw.length && raw[i] !== "\n") i++;
34
+ continue;
35
+ }
36
+ if (c === "/" && raw[i + 1] === "*") {
37
+ i += 2;
38
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
39
+ i += 2;
40
+ continue;
41
+ }
42
+ out += c;
43
+ prev = c;
44
+ i++;
45
+ }
46
+ return out.replace(/,(\s*[}\]])/g, "$1");
47
+ }
48
+
49
+ function parseJsonc(raw) {
50
+ try {
51
+ return JSON.parse(raw);
52
+ } catch {}
53
+ return JSON.parse(stripJsoncOutsideStrings(raw));
54
+ }
55
+
56
+ function extractBearer(entry) {
57
+ if (!entry) return null;
58
+ const auth = entry?.headers?.Authorization;
59
+ const url = entry.url || entry.httpUrl || entry.serverUrl;
60
+ if (!auth || !url) return null;
61
+ return {
62
+ token: auth.replace(/^Bearer\s+/i, ""),
63
+ baseUrl: url.replace(/\/mcp(\/bearer)?$/, ""),
64
+ };
65
+ }
66
+
67
+ function readJsonAt(path, keyPath, tool) {
68
+ if (!existsSync(path)) return null;
69
+ try {
70
+ const obj = parseJsonc(readFileSync(path, "utf-8"));
71
+ const entry = keyPath.reduce((o, k) => o?.[k], obj);
72
+ const b = extractBearer(entry);
73
+ return b ? { ...b, configFile: path, tool } : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function readCodexTomlShape(path) {
80
+ if (!existsSync(path)) return null;
81
+ try {
82
+ const content = readFileSync(path, "utf-8");
83
+ const block = content.match(/\[mcp_servers\.patchcord[-\w]*\]([\s\S]*?)(?=\n\[|$)/);
84
+ if (!block) return null;
85
+ const urlMatch = block[1].match(/url\s*=\s*"([^"]+)"/);
86
+ const tokenMatch = block[1].match(/Bearer\s+([^\s"]+)/);
87
+ if (!urlMatch || !tokenMatch) return null;
88
+ return {
89
+ token: tokenMatch[1],
90
+ baseUrl: urlMatch[1].replace(/\/mcp(\/bearer)?$/, ""),
91
+ configFile: path,
92
+ tool: "codex",
93
+ };
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function readGrokTomlShape(path) {
100
+ if (!existsSync(path)) return null;
101
+ try {
102
+ const content = readFileSync(path, "utf-8");
103
+ const header = content.match(/^\[mcp_servers\.patchcord(?:[-\w]*)?\]\s*$/m);
104
+ if (!header) return null;
105
+ const rest = content.slice(header.index + header[0].length);
106
+ const nextSection = rest.search(/^\[(?!mcp_servers\.patchcord(?:\.|[-\w]*\]))/m);
107
+ const block = nextSection === -1 ? rest : rest.slice(0, nextSection);
108
+ const urlMatch = block.match(/^\s*url\s*=\s*["']([^"']+)["']/m);
109
+ const tokenMatch = block.match(/["']?Authorization["']?\s*=\s*["']Bearer\s+([^"']+)["']/i);
110
+ if (!urlMatch || !tokenMatch) return null;
111
+ return {
112
+ token: tokenMatch[1],
113
+ baseUrl: urlMatch[1].replace(/\/mcp(\/bearer)?$/, ""),
114
+ configFile: path,
115
+ tool: "grok",
116
+ };
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ const claudeReader = (cwd) => readJsonAt(join(cwd, ".mcp.json"), ["mcpServers", "patchcord"], "claude_code");
123
+ const cursorReader = (cwd) => readJsonAt(join(cwd, ".cursor", "mcp.json"), ["mcpServers", "patchcord"], "cursor");
124
+ const vscodeReader = (cwd) => readJsonAt(join(cwd, ".vscode", "mcp.json"), ["servers", "patchcord"], "vscode");
125
+ const opencodeReader = (cwd) => readJsonAt(join(cwd, "opencode.json"), ["mcp", "patchcord"], "opencode");
126
+ const antigravityReader = (cwd) => readJsonAt(join(cwd, ".agents", "mcp_config.json"), ["mcpServers", "patchcord"], "antigravity");
127
+ const grokReader = (cwd) => readGrokTomlShape(join(cwd, ".grok", "config.toml"));
128
+ const codexReader = (cwd) => readCodexTomlShape(join(cwd, ".codex", "config.toml"));
129
+ const kimiReader = (cwd) => readJsonAt(join(cwd, ".kimi", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
130
+ const kimiCodeReader = (cwd) => readJsonAt(join(cwd, ".kimi-code", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
131
+
132
+ /** Detect which harness launched this process (env + explicit overrides). */
133
+ export function harnessContext(env = process.env, options = {}) {
134
+ const forceTool = (env.PATCHCORD_FORCE_TOOL || env.PATCHCORD_TOOL || "").toLowerCase();
135
+ const preferKimi =
136
+ options.preferKimi === true
137
+ || forceTool === "kimi"
138
+ || Boolean(env.KIMI_CLI)
139
+ || Boolean(env.KIMI_SESSION_ID)
140
+ || Boolean(env.KIMI_CONFIG_FILE)
141
+ || Boolean(env.KIMI_MCP_CONFIG_FILE);
142
+ const preferCursor =
143
+ options.preferCursor === true
144
+ || forceTool === "cursor"
145
+ || Boolean(env.CURSOR_AGENT);
146
+ return { forceTool, preferKimi, preferCursor };
147
+ }
148
+
149
+ /** Ordered per-project readers for the active harness. */
150
+ export function projectReadersForContext(ctx) {
151
+ const defaultReaders = [
152
+ claudeReader,
153
+ cursorReader,
154
+ vscodeReader,
155
+ opencodeReader,
156
+ antigravityReader,
157
+ grokReader,
158
+ codexReader,
159
+ ];
160
+ const kimiReaders = [kimiReader, kimiCodeReader];
161
+
162
+ if (ctx.preferKimi) {
163
+ return [...kimiReaders, ...defaultReaders];
164
+ }
165
+ if (ctx.preferCursor) {
166
+ // Cursor CLI/IDE: .cursor/mcp.json must win over stale .mcp.json in the
167
+ // same repo (e.g. leftover Claude Code token from an old provision).
168
+ return [
169
+ cursorReader,
170
+ vscodeReader,
171
+ opencodeReader,
172
+ antigravityReader,
173
+ grokReader,
174
+ codexReader,
175
+ claudeReader,
176
+ ...kimiReaders,
177
+ ];
178
+ }
179
+ return [...defaultReaders, ...kimiReaders];
180
+ }
181
+
182
+ /** Walk up from startDir; return first matching project bearer or null. */
183
+ export function resolveProjectBearer(startDir, options = {}) {
184
+ const ctx = harnessContext(process.env, options);
185
+ const readers = projectReadersForContext(ctx);
186
+ let dir = startDir;
187
+ while (dir && dir !== "/") {
188
+ for (const r of readers) {
189
+ const found = r(dir);
190
+ if (found) {
191
+ found.scope = "project";
192
+ return found;
193
+ }
194
+ }
195
+ const parent = dirname(dir);
196
+ if (parent === dir) break;
197
+ dir = parent;
198
+ }
199
+ return null;
200
+ }
@@ -13,6 +13,7 @@ import { request as httpRequest } from "node:http";
13
13
  import { URL } from "node:url";
14
14
  import { dirname } from "node:path";
15
15
  import { connect as wsConnect } from "./lib/ws.mjs";
16
+ import { resolveProjectBearer } from "./lib/resolve-project-bearer.mjs";
16
17
 
17
18
  // --- Hermes webhook bridge mode -------------------------------------------
18
19
  // Default mode writes "PATCHCORD: ..." lines to stdout for Claude Code's
@@ -70,35 +71,15 @@ function die(msg, code = 1) {
70
71
 
71
72
  function readMcpConfig(cwd) {
72
73
  // Prefer env vars injected by patchcord.mjs, which already ran _resolveBearer()
73
- // and supports all 12 tool configs (Claude Code, OpenCode, Codex, Cursor, Kimi, etc.).
74
+ // and supports all tool configs with harness-aware ordering.
74
75
  if (process.env.PATCHCORD_BASE_URL && process.env.PATCHCORD_BEARER_TOKEN) {
75
76
  return { baseUrl: process.env.PATCHCORD_BASE_URL, token: process.env.PATCHCORD_BEARER_TOKEN };
76
77
  }
77
- // Fallback: direct invocation — try .mcp.json walking up from cwd.
78
- let dir = cwd;
79
- while (dir && dir !== "/") {
80
- const path = `${dir}/.mcp.json`;
81
- if (existsSync(path)) {
82
- let json;
83
- try {
84
- json = JSON.parse(readFileSync(path, "utf8"));
85
- } catch (e) {
86
- die(`.mcp.json parse error: ${e.message}`);
87
- }
88
- const pc = json?.mcpServers?.patchcord;
89
- if (!pc?.url || !pc?.headers?.Authorization) {
90
- die(".mcp.json missing mcpServers.patchcord.url or Authorization");
91
- }
92
- let baseUrl = pc.url.replace(/\/mcp\/bearer$/, "").replace(/\/mcp$/, "");
93
- const auth = pc.headers.Authorization;
94
- const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth;
95
- return { baseUrl, token };
96
- }
97
- const parent = dirname(dir);
98
- if (parent === dir) break;
99
- dir = parent;
78
+ const found = resolveProjectBearer(cwd);
79
+ if (!found) {
80
+ die(`no patchcord config found — run from a project directory or use 'patchcord subscribe'`);
100
81
  }
101
- die(`no patchcord config found run from a project directory or use 'patchcord subscribe'`);
82
+ return { baseUrl: found.baseUrl, token: found.token };
102
83
  }
103
84
 
104
85
  function httpJson(urlStr, { method = "GET", headers = {}, body = null } = {}) {