getclaros 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +253 -2
- package/dist/bridge.js +72 -0
- package/dist/cli.js +456 -0
- package/dist/clients.js +428 -0
- package/dist/config.js +77 -0
- package/dist/peers.js +104 -0
- package/dist/query.js +39 -0
- package/dist/setup.js +636 -0
- package/dist/ui.js +67 -0
- package/package.json +26 -4
package/dist/clients.js
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// Agent clients' MCP configs: where they live, which servers they hold, and minimal edits to them.
|
|
2
|
+
// Formats (checked against the official docs on 2026-10-22):
|
|
3
|
+
// - Claude Code https://code.claude.com/docs/en/mcp
|
|
4
|
+
// project `.mcp.json` {mcpServers: {name: {type?, command, args, env} | {type: "http", url, headers}}};
|
|
5
|
+
// `~/.claude.json` user scope at top-level `mcpServers`, local scope at `projects["<abs path>"].mcpServers`.
|
|
6
|
+
// No `type` means stdio.
|
|
7
|
+
// - Claude Desktop https://modelcontextprotocol.io/docs/develop/connect-local-servers
|
|
8
|
+
// macOS ~/Library/Application Support/Claude/claude_desktop_config.json, Windows %APPDATA%\Claude\…;
|
|
9
|
+
// {mcpServers: {name: {command, args, env}}}. Linux: ~/.config/Claude/… (community builds; not in the docs).
|
|
10
|
+
// - Cursor https://cursor.com/docs/context/mcp
|
|
11
|
+
// ~/.cursor/mcp.json and <project>/.cursor/mcp.json; {mcpServers: {name: {command, args, env, envFile} | {url, headers}}}.
|
|
12
|
+
// - VS Code https://code.visualstudio.com/docs/agents/reference/mcp-configuration
|
|
13
|
+
// <project>/.vscode/mcp.json and the profile's mcp.json (macOS ~/Library/Application Support/Code/User/mcp.json);
|
|
14
|
+
// JSONC {servers: {name: {type: "stdio", command, args, env, envFile} | {type: "http"|"sse", url, headers}}, inputs}.
|
|
15
|
+
// The old settings.json "mcp" key is migrated into mcp.json by VS Code itself
|
|
16
|
+
// (https://code.visualstudio.com/updates/v1_102), so settings.json is not edited.
|
|
17
|
+
// - Codex https://learn.chatgpt.com/docs/extend/mcp?surface=cli
|
|
18
|
+
// ~/.codex/config.toml ($CODEX_HOME) and trusted <project>/.codex/config.toml;
|
|
19
|
+
// [mcp_servers.<name>] command, args, env (table), cwd, enabled | url, bearer_token_env_var, http_headers.
|
|
20
|
+
// JSON/JSONC files are edited with jsonc-parser (VS Code's own; zero deps): only the touched values change, comments
|
|
21
|
+
// and formatting stay. TOML is edited by a small scanner below: only `command` / `args` values of the touched table
|
|
22
|
+
// are replaced (or our own table appended / removed); every other byte, comments included, stays.
|
|
23
|
+
// A remote (url) entry is rewritten into a stdio one running `guard --remote <url> --header "K: V"…`: its static
|
|
24
|
+
// headers move into args (they stay in the same file; ${VAR} / ${input:…} substitution works in args too), and undo
|
|
25
|
+
// rebuilds the url entry from them. No static header means OAuth (or a token from elsewhere): not guarded yet.
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { chmodSync, existsSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { applyEdits, findNodeAtLocation, modify, parse, parseTree } from "jsonc-parser";
|
|
31
|
+
export const CLAROS = "claros"; // the name of the Claros MCP server entry init adds
|
|
32
|
+
// how the configs launch us; overridable to dogfood an unpublished build (e.g. "node /path/to/dist/cli.js")
|
|
33
|
+
export const launcher = () => (process.env.GETCLAROS_LAUNCHER || "npx -y getclaros").trim().split(/\s+/);
|
|
34
|
+
export function locations(project) {
|
|
35
|
+
const h = homedir(), plat = process.platform;
|
|
36
|
+
const appData = process.env.APPDATA || join(h, "AppData", "Roaming"), xdg = process.env.XDG_CONFIG_HOME || join(h, ".config");
|
|
37
|
+
const support = (app) => plat === "darwin" ? join(h, "Library", "Application Support", app)
|
|
38
|
+
: plat === "win32" ? join(appData, app) : join(xdg, app);
|
|
39
|
+
const all = [
|
|
40
|
+
{ client: "Claude Code", path: join(project, ".mcp.json"), key: "mcpServers", user: false },
|
|
41
|
+
{ client: "Claude Code", path: join(h, ".claude.json"), key: "mcpServers", user: true, perProject: true },
|
|
42
|
+
{ client: "Claude Desktop", path: join(support("Claude"), "claude_desktop_config.json"), key: "mcpServers", user: true },
|
|
43
|
+
{ client: "Cursor", path: join(h, ".cursor", "mcp.json"), key: "mcpServers", user: true },
|
|
44
|
+
{ client: "Cursor", path: join(project, ".cursor", "mcp.json"), key: "mcpServers", user: false },
|
|
45
|
+
{ client: "VS Code", path: join(support("Code"), "User", "mcp.json"), key: "servers", user: true },
|
|
46
|
+
{ client: "VS Code", path: join(project, ".vscode", "mcp.json"), key: "servers", user: false },
|
|
47
|
+
{ client: "Codex", path: join(process.env.CODEX_HOME || join(h, ".codex"), "config.toml"), key: "mcp_servers", user: true, toml: true },
|
|
48
|
+
{ client: "Codex", path: join(project, ".codex", "config.toml"), key: "mcp_servers", user: false, toml: true },
|
|
49
|
+
];
|
|
50
|
+
return all.filter((l, i) => all.findIndex((x) => x.path === l.path) === i); // project = home → one entry
|
|
51
|
+
}
|
|
52
|
+
/** Every existing config file with its servers. Never writes. */
|
|
53
|
+
export function detect(project) {
|
|
54
|
+
return locations(project).filter((l) => existsSync(l.path)).map((l) => load({ toml: false, perProject: false, ...l, text: readFileSync(l.path, "utf8"), servers: [] }));
|
|
55
|
+
}
|
|
56
|
+
/** (Re)parses f.text into f.servers. */
|
|
57
|
+
export function load(f) {
|
|
58
|
+
f.servers = [];
|
|
59
|
+
f.error = undefined;
|
|
60
|
+
const add = (scope, name, entry) => {
|
|
61
|
+
if (!entry || typeof entry !== "object")
|
|
62
|
+
return;
|
|
63
|
+
const argv = argvOf(entry);
|
|
64
|
+
const remote = !entry.command && !!entry.url;
|
|
65
|
+
f.servers.push({ file: f, scope, name, entry, remote, guarded: guardAt(argv) > 0,
|
|
66
|
+
ours: name === CLAROS && isClarosEntry(argv), gap: remote ? remoteGap(entry) : "" });
|
|
67
|
+
};
|
|
68
|
+
if (f.toml) {
|
|
69
|
+
try {
|
|
70
|
+
const ts = tomlScan(f.text);
|
|
71
|
+
for (const t of ts.filter((x) => !x.array && x.path.length === 2 && x.path[0] === f.key)) {
|
|
72
|
+
const h = ts.find((x) => isHeaders(x, f.key, t.path[1])); // [mcp_servers.<name>.http_headers]
|
|
73
|
+
add([f.key], t.path[1], { ...Object.fromEntries(t.keys.map((k) => [k.key, k.value])),
|
|
74
|
+
...(h ? { http_headers: Object.fromEntries(h.keys.map((k) => [k.key, k.value])) } : {}) });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
f.error = e.message;
|
|
79
|
+
}
|
|
80
|
+
return f;
|
|
81
|
+
}
|
|
82
|
+
const errors = [];
|
|
83
|
+
const doc = parse(f.text, errors, { allowTrailingComma: true }) ?? {};
|
|
84
|
+
if (errors.length || typeof doc !== "object") {
|
|
85
|
+
f.error = "not valid JSON";
|
|
86
|
+
return f;
|
|
87
|
+
}
|
|
88
|
+
const scopes = [[f.key], ...(f.perProject ? Object.keys(doc.projects ?? {}).map((p) => ["projects", p, f.key]) : [])];
|
|
89
|
+
for (const s of scopes) {
|
|
90
|
+
const obj = s.reduce((o, k) => o?.[k], doc);
|
|
91
|
+
if (obj && typeof obj === "object")
|
|
92
|
+
for (const [n, e] of Object.entries(obj))
|
|
93
|
+
add(s, n, e);
|
|
94
|
+
}
|
|
95
|
+
return f;
|
|
96
|
+
}
|
|
97
|
+
// ---- wrapping ----
|
|
98
|
+
export const argvOf = (e) => e?.command ? [String(e.command), ...(Array.isArray(e.args) ? e.args.map(String) : [])] : [];
|
|
99
|
+
/** index of "guard" in a guarded command line (`… getclaros guard --server x -- original`), else -1 */
|
|
100
|
+
export function guardAt(argv) {
|
|
101
|
+
for (let i = 1; i < argv.length; i++) {
|
|
102
|
+
if (argv[i] === "guard" && /getclaros|[\\/]dist[\\/]cli\.js$/.test(argv[i - 1]) &&
|
|
103
|
+
(argv.indexOf("--", i) > i || argv.indexOf("--remote", i) > i))
|
|
104
|
+
return i;
|
|
105
|
+
}
|
|
106
|
+
return -1;
|
|
107
|
+
}
|
|
108
|
+
/** A remote entry's static headers: `headers` (JSON clients), `http_headers` (Codex). */
|
|
109
|
+
const headersOf = (e) => Object.entries(e.headers ?? e.http_headers ?? {}).map(([k, v]) => [k, String(v)]);
|
|
110
|
+
export function remoteGap(e) {
|
|
111
|
+
if (/sse/i.test(String(e.type ?? "")))
|
|
112
|
+
return "legacy SSE – not guarded yet";
|
|
113
|
+
if (e.bearer_token_env_var || e.env_http_headers || e.headersHelper)
|
|
114
|
+
return "token from an env var or helper – not guarded yet";
|
|
115
|
+
return headersOf(e).length ? "" : "needs OAuth – not guarded yet";
|
|
116
|
+
}
|
|
117
|
+
/** The url and headers a remote guard command line carries. */
|
|
118
|
+
function remoteOf(argv) {
|
|
119
|
+
const g = guardAt(argv), headers = [];
|
|
120
|
+
for (let i = g; i < argv.length; i++) {
|
|
121
|
+
if (argv[i] !== "--header")
|
|
122
|
+
continue;
|
|
123
|
+
const h = argv[++i] ?? "", c = h.indexOf(":");
|
|
124
|
+
headers.push([h.slice(0, c).trim(), h.slice(c + 1).trim()]);
|
|
125
|
+
}
|
|
126
|
+
return { url: argv[argv.indexOf("--remote", g) + 1], headers };
|
|
127
|
+
}
|
|
128
|
+
const isClarosEntry = (argv) => guardAt(argv) < 0 && argv.at(-1) === "mcp" && /getclaros|[\\/]dist[\\/]cli\.js$/.test(argv.at(-2) ?? "");
|
|
129
|
+
export const clarosEntry = (f) => {
|
|
130
|
+
const [command, ...args] = [...launcher(), "mcp"];
|
|
131
|
+
return f.client === "VS Code" ? { type: "stdio", command, args } : { command, args };
|
|
132
|
+
};
|
|
133
|
+
// ---- edits: each returns the new text; call load(f) after changing f.text ----
|
|
134
|
+
const fmt = (text) => {
|
|
135
|
+
const ind = text.match(/^([ \t]+)\S/m)?.[1] ?? " ";
|
|
136
|
+
return { insertSpaces: !ind.startsWith("\t"), tabSize: ind.startsWith("\t") ? 1 : ind.length, eol: text.includes("\r\n") ? "\r\n" : "\n" };
|
|
137
|
+
};
|
|
138
|
+
const jset = (text, path, value) => applyEdits(text, modify(text, path, value, { formattingOptions: fmt(text) }));
|
|
139
|
+
const apply = (text, edits) => edits.sort((a, b) => b[0] - a[0]).reduce((t, [a, b, r]) => t.slice(0, a) + r + t.slice(b), text);
|
|
140
|
+
const q = (x) => JSON.stringify(x); // a JSON string is also a TOML basic string
|
|
141
|
+
/** Where a server's command value and args array sit in the file text. */
|
|
142
|
+
function spans(f, s) {
|
|
143
|
+
if (f.toml) {
|
|
144
|
+
const keys = tomlTable(f.text, f.key, s.name)?.keys;
|
|
145
|
+
const c = keys?.find((k) => k.key === "command"), a = keys?.find((k) => k.key === "args");
|
|
146
|
+
if (!c)
|
|
147
|
+
throw new Error(`[${f.key}.${s.name}] has no command to edit`);
|
|
148
|
+
return { cmd: [c.from, c.to], args: a?.items && { open: a.from, items: a.items },
|
|
149
|
+
addArgs: (text, args) => {
|
|
150
|
+
const c2 = tomlTable(text, f.key, s.name).keys.find((k) => k.key === "command"), eol = text.indexOf("\n", c2.to);
|
|
151
|
+
return eol < 0 ? `${text}\nargs = ${tomlArray(args)}\n` : apply(text, [[eol + 1, eol + 1, `args = ${tomlArray(args)}\n`]]);
|
|
152
|
+
} };
|
|
153
|
+
}
|
|
154
|
+
const root = parseTree(f.text), at = (k) => findNodeAtLocation(root, [...s.scope, s.name, k]);
|
|
155
|
+
const c = at("command"), a = at("args");
|
|
156
|
+
if (!c)
|
|
157
|
+
throw new Error(`${s.name} has no command to edit`);
|
|
158
|
+
return { cmd: [c.offset, c.offset + c.length], args: a?.type === "array" ? { open: a.offset,
|
|
159
|
+
items: (a.children ?? []).map((n) => [n.offset, n.offset + n.length]) } : undefined,
|
|
160
|
+
addArgs: (text, args) => jset(text, [...s.scope, s.name, "args"], args) };
|
|
161
|
+
}
|
|
162
|
+
/** command → the launcher; the launcher's args, `guard --server <name> --` and the original command go in front of
|
|
163
|
+
* the original args. Only inserts: the original args array keeps its layout and comments, the entry its env. */
|
|
164
|
+
export function wrapServer(f, s) {
|
|
165
|
+
if (s.remote)
|
|
166
|
+
return wrapRemote(f, s);
|
|
167
|
+
const L = launcher(), sp = spans(f, s), front = [...L.slice(1), "guard", "--server", s.name, "--", argvOf(s.entry)[0]];
|
|
168
|
+
if (!sp.args)
|
|
169
|
+
return sp.addArgs(apply(f.text, [[sp.cmd[0], sp.cmd[1], q(L[0])]]), front);
|
|
170
|
+
const at = sp.args.open + 1, sep = sp.args.items.length ? (/\s/.test(f.text[at]) ? "," : ", ") : "";
|
|
171
|
+
return apply(f.text, [[sp.cmd[0], sp.cmd[1], q(L[0])], [at, at, front.map(q).join(", ") + sep]]);
|
|
172
|
+
}
|
|
173
|
+
/** The inverse, from whatever the entry holds now: drops everything up to the original command. */
|
|
174
|
+
export function unwrapServer(f, s) {
|
|
175
|
+
if (argvOf(s.entry).indexOf("--remote", guardAt(argvOf(s.entry))) > 0)
|
|
176
|
+
return unwrapRemote(f, s);
|
|
177
|
+
const argv = argvOf(s.entry), d = argv.indexOf("--", guardAt(argv)), sp = spans(f, s); // args[d] = the original command
|
|
178
|
+
const it = sp.args?.items;
|
|
179
|
+
if (!it || it.length <= d)
|
|
180
|
+
throw new Error(`${s.name}: not a guard command line`);
|
|
181
|
+
let end = it[d][1];
|
|
182
|
+
if (it.length > d + 1) {
|
|
183
|
+
end = f.text.indexOf(",", end) + 1;
|
|
184
|
+
if (f.text[end] === " ")
|
|
185
|
+
end++;
|
|
186
|
+
}
|
|
187
|
+
return apply(f.text, [[sp.cmd[0], sp.cmd[1], q(argv[d + 1])], [it[0][0], end, ""]]);
|
|
188
|
+
}
|
|
189
|
+
const lineOf = (t, from, to) => {
|
|
190
|
+
const e = t.indexOf("\n", to);
|
|
191
|
+
return [t.lastIndexOf("\n", from - 1) + 1, e < 0 ? t.length : e + 1];
|
|
192
|
+
};
|
|
193
|
+
/** url (+ headers) → command + args running the guard against that url; other keys (env, timeouts) stay. */
|
|
194
|
+
function wrapRemote(f, s) {
|
|
195
|
+
if (s.gap)
|
|
196
|
+
throw new Error(`${s.name}: ${s.gap}`);
|
|
197
|
+
const L = launcher(), args = [...L.slice(1), "guard", "--server", s.name, "--remote", String(s.entry.url),
|
|
198
|
+
...headersOf(s.entry).flatMap(([k, v]) => ["--header", `${k}: ${v}`])];
|
|
199
|
+
if (f.toml) {
|
|
200
|
+
const ts = tomlScan(f.text), t = ts.find((x) => isTable(x, f.key, s.name)), eol = fmt(f.text).eol;
|
|
201
|
+
const url = t.keys.find((k) => k.key === "url"), h = t.keys.find((k) => k.key === "http_headers");
|
|
202
|
+
const sub = ts.find((x) => isHeaders(x, f.key, s.name));
|
|
203
|
+
const [from, end] = lineOf(f.text, url.from, url.to); // `url = …` → `command = …`, a comment after it stays
|
|
204
|
+
return apply(f.text, [[from, url.to, `command = ${q(L[0])}`], // the header line's removal goes before args' insert
|
|
205
|
+
...(h ? [[...lineOf(f.text, h.from, h.to), ""]] : []), ...(sub ? [[sub.start, sub.end, ""]] : []),
|
|
206
|
+
[end, end, `${f.text[end - 1] === "\n" ? "" : eol}args = ${tomlArray(args)}${eol}`]]);
|
|
207
|
+
}
|
|
208
|
+
const at = [...s.scope, s.name];
|
|
209
|
+
let t = f.text;
|
|
210
|
+
for (const k of ["url", "headers"])
|
|
211
|
+
t = jset(t, [...at, k], undefined);
|
|
212
|
+
t = jset(t, [...at, "type"], f.client === "VS Code" ? "stdio" : undefined);
|
|
213
|
+
return jset(jset(t, [...at, "command"], L[0]), [...at, "args"], args);
|
|
214
|
+
}
|
|
215
|
+
/** The inverse: the url entry back from the guard's command line (the backup restores it byte for byte). */
|
|
216
|
+
function unwrapRemote(f, s) {
|
|
217
|
+
const { url, headers } = remoteOf(argvOf(s.entry));
|
|
218
|
+
if (f.toml) {
|
|
219
|
+
const keys = tomlTable(f.text, f.key, s.name).keys, eol = fmt(f.text).eol;
|
|
220
|
+
const c = keys.find((k) => k.key === "command"), a = keys.find((k) => k.key === "args");
|
|
221
|
+
const h = headers.length ? `http_headers = { ${headers.map(([k, v]) => `${q(k)} = ${q(v)}`).join(", ")} }${eol}` : "";
|
|
222
|
+
return apply(f.text, [[lineOf(f.text, c.from, c.to)[0], c.to, `url = ${q(url)}${a || !h ? "" : eol + h.trimEnd()}`],
|
|
223
|
+
...(a ? [[...lineOf(f.text, a.from, a.to), h]] : [])]);
|
|
224
|
+
}
|
|
225
|
+
const at = [...s.scope, s.name];
|
|
226
|
+
let t = jset(jset(f.text, [...at, "command"], undefined), [...at, "args"], undefined);
|
|
227
|
+
t = jset(t, [...at, "type"], ["VS Code", "Claude Code"].includes(f.client) ? "http" : undefined);
|
|
228
|
+
t = jset(t, [...at, "url"], url);
|
|
229
|
+
return headers.length ? jset(t, [...at, "headers"], Object.fromEntries(headers)) : t;
|
|
230
|
+
}
|
|
231
|
+
/** Adds our Claros entry at the file's top-level server map. */
|
|
232
|
+
export function addClaros(f) {
|
|
233
|
+
if (!f.toml) { // after the last server, on its own line; jsonc's modify would reformat the neighbours
|
|
234
|
+
const obj = findNodeAtLocation(parseTree(f.text), [f.key]), last = obj?.children?.at(-1);
|
|
235
|
+
if (obj?.type !== "object" || !last)
|
|
236
|
+
return jset(f.text, [f.key, CLAROS], clarosEntry(f));
|
|
237
|
+
const at = last.offset + last.length, indent = /^[ \t]*/.exec(f.text.slice(f.text.lastIndexOf("\n", last.offset) + 1))[0];
|
|
238
|
+
return apply(f.text, [[at, at, `,${fmt(f.text).eol}${indent}${q(CLAROS)}: ${JSON.stringify(clarosEntry(f))}`]]);
|
|
239
|
+
}
|
|
240
|
+
const [command, ...args] = [...launcher(), "mcp"];
|
|
241
|
+
const sep = f.text === "" || f.text.endsWith("\n\n") ? "" : f.text.endsWith("\n") ? "\n" : "\n\n";
|
|
242
|
+
return `${f.text}${sep}[${f.key}.${CLAROS}]\ncommand = ${JSON.stringify(command)}\nargs = ${tomlArray(args)}\n`;
|
|
243
|
+
}
|
|
244
|
+
export function removeEntry(f, s) {
|
|
245
|
+
if (!f.toml) { // the property and the comma before it (or after it, when first): the inverse of addClaros
|
|
246
|
+
const props = findNodeAtLocation(parseTree(f.text), s.scope)?.children ?? [];
|
|
247
|
+
const k = props.findIndex((p) => p.children?.[0]?.value === s.name), p = props[k];
|
|
248
|
+
if (k < 0)
|
|
249
|
+
return f.text;
|
|
250
|
+
if (k > 0)
|
|
251
|
+
return apply(f.text, [[props[k - 1].offset + props[k - 1].length, p.offset + p.length, ""]]);
|
|
252
|
+
return props.length > 1 ? apply(f.text, [[p.offset, props[1].offset, ""]]) : jset(f.text, [...s.scope, s.name], undefined);
|
|
253
|
+
}
|
|
254
|
+
const ts = tomlScan(f.text), i = ts.findIndex((x) => isTable(x, f.key, s.name));
|
|
255
|
+
if (i < 0)
|
|
256
|
+
return f.text;
|
|
257
|
+
// its own sub-tables ([mcp_servers.claros.env]) go too; the blank line we added before it as well
|
|
258
|
+
let end = i + 1;
|
|
259
|
+
while (end < ts.length && ts[end].path[0] === f.key && ts[end].path[1] === s.name)
|
|
260
|
+
end++;
|
|
261
|
+
const from = ts[i].start, to = ts[end - 1].end; // comments after its last key stay
|
|
262
|
+
const before = f.text.slice(0, from).replace(/\n\n$/, "\n");
|
|
263
|
+
return before + f.text.slice(to);
|
|
264
|
+
}
|
|
265
|
+
// ---- files ----
|
|
266
|
+
export const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
267
|
+
/** Write via a temp file + rename in the same directory, keeping the file's mode; follows a symlinked config. */
|
|
268
|
+
export function writeAtomic(path, text) {
|
|
269
|
+
const real = realpathSync(path), mode = statSync(real).mode & 0o777, tmp = `${real}.getclaros-${process.pid}.tmp`;
|
|
270
|
+
writeFileSync(tmp, text, { mode });
|
|
271
|
+
chmodSync(tmp, mode);
|
|
272
|
+
renameSync(tmp, real);
|
|
273
|
+
}
|
|
274
|
+
const isTable = (x, key, name) => !x.array && x.path.length === 2 && x.path[0] === key && x.path[1] === name;
|
|
275
|
+
const isHeaders = (x, key, name) => !x.array && x.path.length === 3 && x.path[0] === key && x.path[1] === name && x.path[2] === "http_headers";
|
|
276
|
+
const tomlTable = (t, key, name) => tomlScan(t).find((x) => isTable(x, key, name));
|
|
277
|
+
const tomlArray = (a) => `[${a.map((x) => JSON.stringify(x)).join(", ")}]`; // JSON strings are TOML basic strings
|
|
278
|
+
export function tomlScan(t) {
|
|
279
|
+
const tables = [{ path: [], array: false, start: 0, end: 0, keys: [] }];
|
|
280
|
+
let i = 0, items = []; // element spans of the array value() read last
|
|
281
|
+
const ws = () => { while (t[i] === " " || t[i] === "\t")
|
|
282
|
+
i++; };
|
|
283
|
+
const eol = () => {
|
|
284
|
+
ws();
|
|
285
|
+
if (t[i] === "#")
|
|
286
|
+
while (i < t.length && t[i] !== "\n")
|
|
287
|
+
i++;
|
|
288
|
+
if (t[i] === "\r")
|
|
289
|
+
i++;
|
|
290
|
+
if (i < t.length && t[i] !== "\n")
|
|
291
|
+
throw new Error(`TOML: unexpected "${t[i]}" at ${i}`);
|
|
292
|
+
i++;
|
|
293
|
+
};
|
|
294
|
+
const keyPath = (stop) => {
|
|
295
|
+
const parts = [];
|
|
296
|
+
for (;;) {
|
|
297
|
+
ws();
|
|
298
|
+
if (t[i] === '"' || t[i] === "'")
|
|
299
|
+
parts.push(value());
|
|
300
|
+
else {
|
|
301
|
+
const m = /^[A-Za-z0-9_-]+/.exec(t.slice(i, i + 256));
|
|
302
|
+
if (!m)
|
|
303
|
+
throw new Error(`TOML: bad key at ${i}`);
|
|
304
|
+
parts.push(m[0]);
|
|
305
|
+
i += m[0].length;
|
|
306
|
+
}
|
|
307
|
+
ws();
|
|
308
|
+
if (t[i] === ".") {
|
|
309
|
+
i++;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (!stop.includes(t[i]))
|
|
313
|
+
throw new Error(`TOML: expected ${stop} at ${i}`);
|
|
314
|
+
return parts;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
const gap = () => {
|
|
318
|
+
for (;;) {
|
|
319
|
+
while (/\s/.test(t[i] ?? ""))
|
|
320
|
+
i++;
|
|
321
|
+
if (t[i] !== "#")
|
|
322
|
+
return;
|
|
323
|
+
while (i < t.length && t[i] !== "\n")
|
|
324
|
+
i++;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
function value() {
|
|
328
|
+
const c = t[i];
|
|
329
|
+
if (t.startsWith('"""', i) || t.startsWith("'''", i)) {
|
|
330
|
+
const q = t.slice(i, i + 3), end = t.indexOf(q, i + 3);
|
|
331
|
+
if (end < 0)
|
|
332
|
+
throw new Error("TOML: unterminated string");
|
|
333
|
+
let e = end + 3;
|
|
334
|
+
while (t[e] === q[0])
|
|
335
|
+
e++; // up to two quotes may end the content
|
|
336
|
+
const raw = t.slice(i + 3, e - 3).replace(/^\r?\n/, "");
|
|
337
|
+
i = e;
|
|
338
|
+
return q === "'''" ? raw : unescape(raw.replace(/\\\r?\n\s*/g, ""));
|
|
339
|
+
}
|
|
340
|
+
if (c === "'") {
|
|
341
|
+
const end = t.indexOf("'", i + 1);
|
|
342
|
+
const v = t.slice(i + 1, end);
|
|
343
|
+
i = end + 1;
|
|
344
|
+
return v;
|
|
345
|
+
}
|
|
346
|
+
if (c === '"') {
|
|
347
|
+
let j = i + 1;
|
|
348
|
+
while (j < t.length && t[j] !== '"')
|
|
349
|
+
j += t[j] === "\\" ? 2 : 1;
|
|
350
|
+
const v = unescape(t.slice(i + 1, j));
|
|
351
|
+
i = j + 1;
|
|
352
|
+
return v;
|
|
353
|
+
}
|
|
354
|
+
if (c === "[") {
|
|
355
|
+
i++;
|
|
356
|
+
const out = [], spans = [];
|
|
357
|
+
for (;;) {
|
|
358
|
+
gap();
|
|
359
|
+
if (t[i] === "]") {
|
|
360
|
+
i++;
|
|
361
|
+
items = spans;
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
const from = i;
|
|
365
|
+
out.push(value());
|
|
366
|
+
spans.push([from, i]);
|
|
367
|
+
gap();
|
|
368
|
+
if (t[i] === ",")
|
|
369
|
+
i++;
|
|
370
|
+
else if (t[i] !== "]")
|
|
371
|
+
throw new Error(`TOML: bad array at ${i}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (c === "{") {
|
|
375
|
+
i++;
|
|
376
|
+
const out = {};
|
|
377
|
+
for (;;) {
|
|
378
|
+
gap();
|
|
379
|
+
if (t[i] === "}") {
|
|
380
|
+
i++;
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
const k = keyPath("=");
|
|
384
|
+
i++;
|
|
385
|
+
ws();
|
|
386
|
+
out[k.join(".")] = value();
|
|
387
|
+
gap();
|
|
388
|
+
if (t[i] === ",")
|
|
389
|
+
i++;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const m = /^[^\s,\]}#]+/.exec(t.slice(i, i + 256));
|
|
393
|
+
if (!m)
|
|
394
|
+
throw new Error(`TOML: bad value at ${i}`);
|
|
395
|
+
i += m[0].length;
|
|
396
|
+
return m[0] === "true" ? true : m[0] === "false" ? false : Number.isNaN(Number(m[0].replace(/_/g, ""))) ? m[0] : Number(m[0].replace(/_/g, ""));
|
|
397
|
+
}
|
|
398
|
+
while (i < t.length) {
|
|
399
|
+
const lineStart = i;
|
|
400
|
+
ws();
|
|
401
|
+
if (t[i] === "\n" || t[i] === "\r" || t[i] === "#" || i >= t.length) {
|
|
402
|
+
eol();
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (t[i] === "[") {
|
|
406
|
+
const array = t[i + 1] === "[";
|
|
407
|
+
i += array ? 2 : 1;
|
|
408
|
+
const path = keyPath("]");
|
|
409
|
+
i += array ? 2 : 1;
|
|
410
|
+
tables.push({ path, array, start: lineStart, end: 0, keys: [] });
|
|
411
|
+
eol();
|
|
412
|
+
tables.at(-1).end = Math.min(i, t.length);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const k = keyPath("=");
|
|
416
|
+
i++;
|
|
417
|
+
ws();
|
|
418
|
+
const from = i, v = value();
|
|
419
|
+
tables.at(-1).keys.push({ key: k.join("."), value: v, from, to: i, ...(Array.isArray(v) ? { items } : {}) });
|
|
420
|
+
eol();
|
|
421
|
+
tables.at(-1).end = Math.min(i, t.length);
|
|
422
|
+
}
|
|
423
|
+
return tables;
|
|
424
|
+
}
|
|
425
|
+
function unescape(s) {
|
|
426
|
+
const map = { b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", e: "\x1b", '"': '"', "\\": "\\" };
|
|
427
|
+
return s.replace(/\\(u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|x[0-9a-fA-F]{2}|.)/g, (_, e) => e.length > 1 ? String.fromCodePoint(parseInt(e.slice(1), 16)) : map[e] ?? e);
|
|
428
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Where `getclaros init` keeps the Claros URL and key: one file, 0600, in a 0700 directory
|
|
2
|
+
// ($XDG_CONFIG_HOME or ~/.config)/getclaros/config.json. The guard and the Claros MCP bridge read it, so the key never
|
|
3
|
+
// lands in an agent client's config (project files like .mcp.json are often committed).
|
|
4
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
// The web app says where its server is (GET <web>/.well-known/claros.json → {"api": …}), so a moved server needs no
|
|
8
|
+
// new package: the CLI asks the web origin when it has no URL, and asks again when the saved one stops answering.
|
|
9
|
+
export const DEFAULT_WEB = "https://claros-eta.vercel.app";
|
|
10
|
+
const LEGACY_URL = "https://claros-server.onrender.com"; // configs from before discovery: follow the web app
|
|
11
|
+
const trim = (u) => u.replace(/\/+$/, "");
|
|
12
|
+
export const cfgDir = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "getclaros");
|
|
13
|
+
export function loadConfig() {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(join(cfgDir(), "config.json"), "utf8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function saveConfig(c) {
|
|
22
|
+
const dir = cfgDir(), file = join(dir, "config.json"), tmp = `${file}.${process.pid}.tmp`;
|
|
23
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
24
|
+
chmodSync(dir, 0o700);
|
|
25
|
+
writeFileSync(tmp, JSON.stringify(c, null, 2) + "\n", { mode: 0o600 });
|
|
26
|
+
renameSync(tmp, file);
|
|
27
|
+
return file;
|
|
28
|
+
}
|
|
29
|
+
/** <web>/.well-known/claros.json → the server URL. Throws why not. */
|
|
30
|
+
export async function discover(web) {
|
|
31
|
+
const r = await fetch(`${web}/.well-known/claros.json`, { signal: AbortSignal.timeout(10_000) });
|
|
32
|
+
if (!r.ok)
|
|
33
|
+
throw new Error(`HTTP ${r.status}`);
|
|
34
|
+
const api = (await r.json().catch(() => null))?.api;
|
|
35
|
+
if (typeof api !== "string" || !/^https?:\/\/\S+$/.test(api))
|
|
36
|
+
throw new Error("no server URL in it");
|
|
37
|
+
return trim(api);
|
|
38
|
+
}
|
|
39
|
+
/** --url / CLAROS_URL → saved (unless --web / CLAROS_WEB names another web origin) → discovered from the web origin
|
|
40
|
+
* (--web / CLAROS_WEB / saved / DEFAULT_WEB) → throws. A discovered URL replaces the saved one. */
|
|
41
|
+
export async function resolve(flags = {}) {
|
|
42
|
+
const s = loadConfig();
|
|
43
|
+
const key = flags.key ?? process.env.CLAROS_KEY ?? s.key ?? "";
|
|
44
|
+
const asked = flags.web ?? process.env.CLAROS_WEB;
|
|
45
|
+
const web = trim(asked ?? s.web ?? DEFAULT_WEB);
|
|
46
|
+
const fixed = flags.url ?? process.env.CLAROS_URL;
|
|
47
|
+
if (fixed)
|
|
48
|
+
return { url: trim(fixed), key, web, source: "pinned" };
|
|
49
|
+
if (s.url && (!asked || trim(asked) === s.web)) {
|
|
50
|
+
return { url: trim(s.url), key, web, source: s.web || trim(s.url) === LEGACY_URL ? "saved" : "pinned" };
|
|
51
|
+
}
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = await discover(web);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
throw new Error(`couldn't find the Claros server: ${web}/.well-known/claros.json: ${e.message}. ` +
|
|
58
|
+
"Pass --url <claros server> or set CLAROS_URL (or --web / CLAROS_WEB for another web app).");
|
|
59
|
+
}
|
|
60
|
+
if (s.key || s.url)
|
|
61
|
+
saveConfig({ ...s, url, web });
|
|
62
|
+
return { url, key, web, source: "discovered" };
|
|
63
|
+
}
|
|
64
|
+
/** After a connection failure: asks the web origin again. true = the server moved (r.url and the saved config are
|
|
65
|
+
* updated); false = same URL, discovery failed, or the URL is pinned. */
|
|
66
|
+
export async function relocate(r) {
|
|
67
|
+
if (r.source === "pinned")
|
|
68
|
+
return false;
|
|
69
|
+
const url = await discover(r.web).catch(() => r.url);
|
|
70
|
+
if (url === r.url)
|
|
71
|
+
return false;
|
|
72
|
+
r.url = url;
|
|
73
|
+
const s = loadConfig();
|
|
74
|
+
if (s.key || s.url)
|
|
75
|
+
saveConfig({ ...s, url, web: r.web });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
package/dist/peers.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Cross-server lookups: a rule may need a fact another guarded server holds (an order total read by a shop server's
|
|
2
|
+
// read tool while the agent calls a refund tool). Each guard serves its wrapped server's read tools to the user's other
|
|
3
|
+
// guards on a Unix socket, <cfgDir>/run/<hash of server name>.sock (0600, in a 0700 dir). A peer must present the same
|
|
4
|
+
// Claros key (its sha256), so only guards of the same user and workspace ask, and only read tools are ever called.
|
|
5
|
+
// One JSON line per connection: {auth, tool, arguments} → {result: <the server's JSON-RPC answer>} | {error}.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { chmodSync, mkdirSync, unlinkSync } from "node:fs";
|
|
8
|
+
import { createConnection, createServer } from "node:net";
|
|
9
|
+
import { tmpdir, userInfo } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { cfgDir } from "./config.js";
|
|
12
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
13
|
+
// macOS caps a socket path at 104 bytes; a long HOME falls back to a per-user dir under the temp dir
|
|
14
|
+
const runDir = () => join(cfgDir(), "run").length < 80 ? join(cfgDir(), "run") : join(tmpdir(), `getclaros-${userInfo().uid}`);
|
|
15
|
+
export const sockPath = (server) => join(runDir(), sha(server).slice(0, 16) + ".sock");
|
|
16
|
+
function oneLine(sock, onLine) {
|
|
17
|
+
let buf = "", done = false;
|
|
18
|
+
sock.on("data", (c) => {
|
|
19
|
+
buf += c;
|
|
20
|
+
const i = buf.indexOf("\n");
|
|
21
|
+
if (i >= 0 && !done) {
|
|
22
|
+
done = true;
|
|
23
|
+
onLine(buf.slice(0, i));
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** Serve `server`'s read tools to peers. `call` runs one tool on the wrapped server. Returns a stop function. */
|
|
28
|
+
export function serve(server, key, isRead, call, log) {
|
|
29
|
+
// ponytail: Unix sockets only; Windows named pipes when someone guards there
|
|
30
|
+
if (process.platform === "win32" || !key)
|
|
31
|
+
return () => { };
|
|
32
|
+
const path = sockPath(server), auth = sha(key);
|
|
33
|
+
const srv = createServer((sock) => {
|
|
34
|
+
sock.setEncoding("utf8");
|
|
35
|
+
oneLine(sock, async (line) => {
|
|
36
|
+
let req, out;
|
|
37
|
+
try {
|
|
38
|
+
req = JSON.parse(line);
|
|
39
|
+
if (req?.auth !== auth)
|
|
40
|
+
throw new Error("not the same Claros key");
|
|
41
|
+
if (!isRead(String(req.tool)))
|
|
42
|
+
throw new Error(`${req.tool} isn't a read tool of ${server}`);
|
|
43
|
+
out = { result: await call(String(req.tool), req.arguments ?? {}) };
|
|
44
|
+
log(`${server} ${req.tool} → read for another guard`);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
out = { error: e.message };
|
|
48
|
+
}
|
|
49
|
+
sock.end(JSON.stringify(out) + "\n");
|
|
50
|
+
});
|
|
51
|
+
sock.on("error", () => { });
|
|
52
|
+
});
|
|
53
|
+
const listen = () => srv.listen(path, () => { try {
|
|
54
|
+
chmodSync(path, 0o600);
|
|
55
|
+
}
|
|
56
|
+
catch { /* gone */ } });
|
|
57
|
+
srv.on("error", (e) => {
|
|
58
|
+
if (e.code !== "EADDRINUSE")
|
|
59
|
+
return log(`lookups for other guards unavailable (${e.message})`);
|
|
60
|
+
// a live guard of the same server answers there already; a dead one left its socket behind
|
|
61
|
+
const probe = createConnection(path);
|
|
62
|
+
probe.on("connect", () => { probe.destroy(); log(`another guard of ${server} answers lookups for it`); });
|
|
63
|
+
probe.on("error", () => {
|
|
64
|
+
try {
|
|
65
|
+
unlinkSync(path);
|
|
66
|
+
}
|
|
67
|
+
catch { /* raced */ }
|
|
68
|
+
listen();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
mkdirSync(runDir(), { recursive: true, mode: 0o700 });
|
|
72
|
+
listen();
|
|
73
|
+
const stop = () => { if (srv.listening) {
|
|
74
|
+
srv.close();
|
|
75
|
+
try {
|
|
76
|
+
unlinkSync(path);
|
|
77
|
+
}
|
|
78
|
+
catch { /* gone */ }
|
|
79
|
+
} };
|
|
80
|
+
process.on("exit", stop);
|
|
81
|
+
return stop;
|
|
82
|
+
}
|
|
83
|
+
/** Ask the guard of `server` to run its read tool. Throws when no guard serves it or it refuses. */
|
|
84
|
+
export function ask(server, key, tool, args, timeout) {
|
|
85
|
+
return new Promise((ok, no) => {
|
|
86
|
+
const sock = createConnection(sockPath(server));
|
|
87
|
+
sock.setEncoding("utf8");
|
|
88
|
+
const t = setTimeout(() => { sock.destroy(); no(new Error(`reading ${tool} on ${server} timed out after ${timeout}ms`)); }, timeout);
|
|
89
|
+
sock.on("connect", () => sock.write(JSON.stringify({ auth: sha(key), tool, arguments: args }) + "\n"));
|
|
90
|
+
sock.on("error", () => { clearTimeout(t); no(new Error(`couldn't read with ${tool}: ${server} isn't running under getclaros guard here`)); });
|
|
91
|
+
oneLine(sock, (line) => {
|
|
92
|
+
clearTimeout(t);
|
|
93
|
+
sock.destroy();
|
|
94
|
+
let r;
|
|
95
|
+
try {
|
|
96
|
+
r = JSON.parse(line);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return no(new Error(`bad answer from the ${server} guard`));
|
|
100
|
+
}
|
|
101
|
+
return r.error ? no(new Error(`${server}: ${r.error}`)) : ok(r.result);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
package/dist/query.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const get = (o, path) => path.split(".").reduce((v, k) => (v && typeof v === "object" ? v[k] : undefined), o);
|
|
2
|
+
const same = (a, b) => a === b || (a != null && b != null && String(a) === String(b));
|
|
3
|
+
/** A read tool's result → its rows: structuredContent, else the JSON text; `rows` is the array's path in an object. */
|
|
4
|
+
export function rowsOf(tool, m, rows) {
|
|
5
|
+
if (m?.error || m?.result?.isError)
|
|
6
|
+
throw new Error(`couldn't read the records: ${tool} returned an error`);
|
|
7
|
+
let v = m.result?.structuredContent;
|
|
8
|
+
if (v === undefined) {
|
|
9
|
+
const text = (m.result?.content ?? []).filter((c) => c?.type === "text").map((c) => c.text).join("\n");
|
|
10
|
+
try {
|
|
11
|
+
v = JSON.parse(text);
|
|
12
|
+
}
|
|
13
|
+
catch { /* below */ }
|
|
14
|
+
}
|
|
15
|
+
if (rows)
|
|
16
|
+
v = get(v, rows);
|
|
17
|
+
if (!Array.isArray(v))
|
|
18
|
+
throw new Error(`couldn't read the records: ${tool} returned no rows`);
|
|
19
|
+
return v;
|
|
20
|
+
}
|
|
21
|
+
/** Keep the rows matching every `where` field (a list = any of its values), then count / first / last / min / max
|
|
22
|
+
* of `field`. min/max compare numbers when every value is one, else strings (ISO dates sort right). */
|
|
23
|
+
export function reduceRows(rows, r) {
|
|
24
|
+
const kept = rows.filter((row) => Object.entries(r.where ?? {}).every(([k, want]) => (Array.isArray(want) ? want : [want]).some((w) => same(get(row, k), w))));
|
|
25
|
+
if (r.op === "count")
|
|
26
|
+
return kept.length;
|
|
27
|
+
const vals = kept.map((row) => get(row, r.field)).filter((v) => v != null && v !== "");
|
|
28
|
+
if (r.op === "first" || r.op === "last") {
|
|
29
|
+
const row = r.op === "first" ? kept[0] : kept.at(-1);
|
|
30
|
+
return row === undefined ? null : get(row, r.field) ?? null;
|
|
31
|
+
}
|
|
32
|
+
if (!vals.length)
|
|
33
|
+
return null;
|
|
34
|
+
const num = vals.every((v) => typeof v === "number");
|
|
35
|
+
return vals.reduce((a, b) => {
|
|
36
|
+
const gt = num ? b > a : String(b) > String(a);
|
|
37
|
+
return (r.op === "max" ? gt : !gt && !same(a, b)) ? b : a;
|
|
38
|
+
});
|
|
39
|
+
}
|