patchcord 0.6.10 → 0.6.11

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.11",
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
@@ -268,7 +263,9 @@ function readGrokTomlShape(path) {
268
263
 
269
264
  async function _resolveBearer(options = {}) {
270
265
  const { readFileSync } = await import("fs");
271
- const preferKimi = _kimiContextRequested(options);
266
+
267
+ const projectFound = resolveProjectBearer(process.cwd(), options);
268
+ if (projectFound) return projectFound;
272
269
 
273
270
  // Strict JSON first; fall back to JSONC stripping for zed/gemini-style
274
271
  // settings (which allow // /* */ trailing commas). The fallback strips
@@ -357,30 +354,6 @@ async function _resolveBearer(options = {}) {
357
354
  } catch { return null; }
358
355
  };
359
356
 
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
357
  // Global fallbacks. Order by likelihood for current install base.
385
358
  const zedPath = process.platform === "darwin"
386
359
  ? join(HOME, "Library", "Application Support", "Zed", "settings.json")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.10",
3
+ "version": "0.6.11",
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 } = {}) {