@geravant/sinain 1.22.8 → 1.23.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/launcher.js +20 -0
- package/mcp-register.js +572 -0
- package/onboard.js +32 -8
- package/package.json +2 -1
- package/sinain-core/src/agent/loop.ts +14 -3
- package/sinain-core/src/escalation/escalator.ts +44 -8
- package/sinain-core/src/index.ts +11 -0
- package/sinain-core/src/server.ts +136 -20
- package/sinain-core/src/util/event-bus.ts +69 -0
- package/sinain-memory/graph_query.py +188 -33
package/launcher.js
CHANGED
|
@@ -56,6 +56,26 @@ const children = []; // { name, proc, pid }
|
|
|
56
56
|
await main();
|
|
57
57
|
|
|
58
58
|
async function main() {
|
|
59
|
+
// ── Platform guard (ENG-05, CONTEXT.md D-03) ─────────────────────────────
|
|
60
|
+
// Friendly blocker for non-macOS platforms. SINAIN_FAKE_PLATFORM enables
|
|
61
|
+
// env-var spoof testing without an actual Windows/Linux host.
|
|
62
|
+
const platform = process.env.SINAIN_FAKE_PLATFORM || os.platform();
|
|
63
|
+
if (platform !== "darwin") {
|
|
64
|
+
const isWindows = platform === "win32";
|
|
65
|
+
console.log("");
|
|
66
|
+
console.log(" ┌─────────────────────────────────────────────────────────┐");
|
|
67
|
+
console.log(" │ Sinain is macOS-only for this launch │");
|
|
68
|
+
console.log(" │ │");
|
|
69
|
+
console.log(" │ " + (isWindows
|
|
70
|
+
? "Windows support is in progress — star the repo for updates."
|
|
71
|
+
: "Linux support is planned — star the repo for updates. ") + " │");
|
|
72
|
+
console.log(" │ https://github.com/geravant/sinain-hud │");
|
|
73
|
+
console.log(" └─────────────────────────────────────────────────────────┘");
|
|
74
|
+
console.log("");
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
// ── End platform guard ────────────────────────────────────────────────────
|
|
78
|
+
|
|
59
79
|
setupSignalHandlers();
|
|
60
80
|
|
|
61
81
|
log("Preflight checks...");
|
package/mcp-register.js
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sinain mcp-register — detect and register the sinain MCP server
|
|
3
|
+
* into MCP-aware agents (Claude Code, Claude Desktop, Cursor, Codex, Goose, Junie).
|
|
4
|
+
*
|
|
5
|
+
* Designed to be called from two surfaces:
|
|
6
|
+
* - the wizard (`stepMcpInstall` from onboard.js)
|
|
7
|
+
* - a standalone CLI (`runMcpCli` from cli.js)
|
|
8
|
+
*
|
|
9
|
+
* Idempotency is the design contract for every backend: read existing config,
|
|
10
|
+
* upsert the `sinain` entry only, never duplicate. Re-running this module
|
|
11
|
+
* after upgrading the npm package is the canonical "re-point my paths" flow.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as p from "@clack/prompts";
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import os from "os";
|
|
18
|
+
import { execFileSync, execSync } from "child_process";
|
|
19
|
+
import { c, guard, cmdExists, PKG_DIR, HOME, SINAIN_DIR, ENV_PATH, IS_WINDOWS, IS_MAC } from "./config-shared.js";
|
|
20
|
+
|
|
21
|
+
// ── Paths to the bundled MCP server ─────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const MCP_SERVER_DIR = path.join(PKG_DIR, "sinain-mcp-server");
|
|
24
|
+
const MCP_ENTRY = path.join(MCP_SERVER_DIR, "index.ts");
|
|
25
|
+
const TSX_BIN = path.join(MCP_SERVER_DIR, "node_modules", ".bin", IS_WINDOWS ? "tsx.cmd" : "tsx");
|
|
26
|
+
|
|
27
|
+
const DEFAULT_ENV = {
|
|
28
|
+
SINAIN_CORE_URL: "http://localhost:9500",
|
|
29
|
+
SINAIN_WORKSPACE: path.join(HOME, ".openclaw", "workspace"),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function mcpServerReady() {
|
|
33
|
+
return fs.existsSync(MCP_ENTRY) && fs.existsSync(TSX_BIN);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ensureMcpServerDeps() {
|
|
37
|
+
if (mcpServerReady()) return true;
|
|
38
|
+
if (!fs.existsSync(MCP_ENTRY)) return false;
|
|
39
|
+
try {
|
|
40
|
+
execSync("npm install --silent", { cwd: MCP_SERVER_DIR, stdio: "pipe" });
|
|
41
|
+
return mcpServerReady();
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mcpServerPayload() {
|
|
48
|
+
return {
|
|
49
|
+
command: TSX_BIN,
|
|
50
|
+
args: [MCP_ENTRY],
|
|
51
|
+
env: { ...DEFAULT_ENV },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Backend: Claude Code (CLI-managed via `claude mcp`) ─────────────────────
|
|
56
|
+
|
|
57
|
+
function claudeListJson() {
|
|
58
|
+
try {
|
|
59
|
+
const out = execFileSync("claude", ["mcp", "list", "--json"], { stdio: "pipe", encoding: "utf-8" });
|
|
60
|
+
return JSON.parse(out);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function claudeAlreadyRegistered() {
|
|
67
|
+
const list = claudeListJson();
|
|
68
|
+
if (list && typeof list === "object") {
|
|
69
|
+
if (list.sinain) return true;
|
|
70
|
+
if (Array.isArray(list)) return list.some((e) => e?.name === "sinain");
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const out = execFileSync("claude", ["mcp", "list"], { stdio: "pipe", encoding: "utf-8" });
|
|
74
|
+
return /\bsinain\b/.test(out);
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function claudeRegister({ extraEnv = {} } = {}) {
|
|
81
|
+
if (!ensureMcpServerDeps()) {
|
|
82
|
+
throw new Error(`MCP server not built. Run: cd ${MCP_SERVER_DIR} && npm install`);
|
|
83
|
+
}
|
|
84
|
+
// Idempotent: remove first (no-op if absent), then add.
|
|
85
|
+
try { execFileSync("claude", ["mcp", "remove", "sinain"], { stdio: "pipe" }); } catch { /* not registered */ }
|
|
86
|
+
const env = { ...DEFAULT_ENV, ...extraEnv };
|
|
87
|
+
const args = ["mcp", "add", "sinain", "--scope", "user"];
|
|
88
|
+
for (const [k, v] of Object.entries(env)) args.push("--env", `${k}=${v}`);
|
|
89
|
+
args.push("--", TSX_BIN, MCP_ENTRY);
|
|
90
|
+
execFileSync("claude", args, { stdio: "pipe", env: { ...process.env, ...env } });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function claudeUnregister() {
|
|
94
|
+
try { execFileSync("claude", ["mcp", "remove", "sinain"], { stdio: "pipe" }); } catch { /* idempotent */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Backend: Codex (CLI-managed via `codex mcp`) ────────────────────────────
|
|
98
|
+
|
|
99
|
+
function codexAlreadyRegistered() {
|
|
100
|
+
try {
|
|
101
|
+
const out = execFileSync("codex", ["mcp", "list"], { stdio: "pipe", encoding: "utf-8" });
|
|
102
|
+
return /\bsinain\b/.test(out);
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function codexRegister() {
|
|
109
|
+
if (!ensureMcpServerDeps()) {
|
|
110
|
+
throw new Error(`MCP server not built. Run: cd ${MCP_SERVER_DIR} && npm install`);
|
|
111
|
+
}
|
|
112
|
+
try { execFileSync("codex", ["mcp", "remove", "sinain"], { stdio: "pipe" }); } catch { /* not registered */ }
|
|
113
|
+
const args = ["mcp", "add", "sinain"];
|
|
114
|
+
for (const [k, v] of Object.entries(DEFAULT_ENV)) args.push("--env", `${k}=${v}`);
|
|
115
|
+
args.push("--", TSX_BIN, MCP_ENTRY);
|
|
116
|
+
execFileSync("codex", args, { stdio: "pipe" });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function codexUnregister() {
|
|
120
|
+
try { execFileSync("codex", ["mcp", "remove", "sinain"], { stdio: "pipe" }); } catch { /* idempotent */ }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Backend: JSON-config agents (Claude Desktop, Cursor, Junie) ─────────────
|
|
124
|
+
//
|
|
125
|
+
// All three use the same `mcpServers` object shape. The only thing that
|
|
126
|
+
// varies is the file path. Read-merge-write keeps untouched entries intact.
|
|
127
|
+
|
|
128
|
+
function jsonConfigPath(agentId) {
|
|
129
|
+
switch (agentId) {
|
|
130
|
+
case "claude-desktop":
|
|
131
|
+
if (IS_MAC) return path.join(HOME, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
132
|
+
if (IS_WINDOWS) return path.join(process.env.APPDATA || path.join(HOME, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
133
|
+
return path.join(HOME, ".config", "Claude", "claude_desktop_config.json");
|
|
134
|
+
case "cursor":
|
|
135
|
+
return path.join(HOME, ".cursor", "mcp.json");
|
|
136
|
+
case "junie":
|
|
137
|
+
return path.join(HOME, ".junie", "mcp", "mcp.json");
|
|
138
|
+
default:
|
|
139
|
+
throw new Error(`Unknown JSON-config agent: ${agentId}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function jsonAlreadyRegistered(agentId) {
|
|
144
|
+
const file = jsonConfigPath(agentId);
|
|
145
|
+
if (!fs.existsSync(file)) return false;
|
|
146
|
+
try {
|
|
147
|
+
const json = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
148
|
+
return !!json?.mcpServers?.sinain;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function jsonRegister(agentId) {
|
|
155
|
+
if (!ensureMcpServerDeps()) {
|
|
156
|
+
throw new Error(`MCP server not built. Run: cd ${MCP_SERVER_DIR} && npm install`);
|
|
157
|
+
}
|
|
158
|
+
const file = jsonConfigPath(agentId);
|
|
159
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
160
|
+
let config = {};
|
|
161
|
+
if (fs.existsSync(file)) {
|
|
162
|
+
try {
|
|
163
|
+
config = JSON.parse(fs.readFileSync(file, "utf-8")) || {};
|
|
164
|
+
} catch (err) {
|
|
165
|
+
throw new Error(`Existing config at ${file} is not valid JSON: ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
config.mcpServers = config.mcpServers || {};
|
|
169
|
+
config.mcpServers.sinain = mcpServerPayload();
|
|
170
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function jsonUnregister(agentId) {
|
|
174
|
+
const file = jsonConfigPath(agentId);
|
|
175
|
+
if (!fs.existsSync(file)) return;
|
|
176
|
+
let config;
|
|
177
|
+
try { config = JSON.parse(fs.readFileSync(file, "utf-8")); } catch { return; }
|
|
178
|
+
if (config?.mcpServers?.sinain) {
|
|
179
|
+
delete config.mcpServers.sinain;
|
|
180
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Backend: Goose (YAML, hand-rolled splice with snippet fallback) ─────────
|
|
185
|
+
//
|
|
186
|
+
// Goose's config is YAML and we don't want a YAML dep just for this site.
|
|
187
|
+
// Strategy: detect a `sinain:` block under `extensions:` via line scanning;
|
|
188
|
+
// if present, splice replace; if absent, append. On any structural surprise,
|
|
189
|
+
// abort the write and return a printable snippet for the user to paste.
|
|
190
|
+
|
|
191
|
+
const GOOSE_CONFIG = path.join(HOME, ".config", "goose", "config.yaml");
|
|
192
|
+
|
|
193
|
+
function gooseSnippet() {
|
|
194
|
+
return [
|
|
195
|
+
"extensions:",
|
|
196
|
+
" sinain:",
|
|
197
|
+
" enabled: true",
|
|
198
|
+
` cmd: "${TSX_BIN}"`,
|
|
199
|
+
` args: ["${MCP_ENTRY}"]`,
|
|
200
|
+
" envs:",
|
|
201
|
+
` SINAIN_CORE_URL: "${DEFAULT_ENV.SINAIN_CORE_URL}"`,
|
|
202
|
+
` SINAIN_WORKSPACE: "${DEFAULT_ENV.SINAIN_WORKSPACE}"`,
|
|
203
|
+
].join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function gooseAlreadyRegistered() {
|
|
207
|
+
if (!fs.existsSync(GOOSE_CONFIG)) return false;
|
|
208
|
+
const content = fs.readFileSync(GOOSE_CONFIG, "utf-8");
|
|
209
|
+
return /^\s+sinain:/m.test(content);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function gooseRegister() {
|
|
213
|
+
if (!ensureMcpServerDeps()) {
|
|
214
|
+
throw new Error(`MCP server not built. Run: cd ${MCP_SERVER_DIR} && npm install`);
|
|
215
|
+
}
|
|
216
|
+
fs.mkdirSync(path.dirname(GOOSE_CONFIG), { recursive: true });
|
|
217
|
+
const exists = fs.existsSync(GOOSE_CONFIG);
|
|
218
|
+
const content = exists ? fs.readFileSync(GOOSE_CONFIG, "utf-8") : "";
|
|
219
|
+
|
|
220
|
+
// If the file is empty or only has top-level keys we recognise as safe to
|
|
221
|
+
// append to, we add the extensions block fresh.
|
|
222
|
+
if (!exists || !content.trim()) {
|
|
223
|
+
fs.writeFileSync(GOOSE_CONFIG, gooseSnippet() + "\n");
|
|
224
|
+
return { ok: true };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const hasExtensions = /^extensions:/m.test(content);
|
|
228
|
+
const hasSinain = /^\s+sinain:/m.test(content);
|
|
229
|
+
|
|
230
|
+
if (!hasExtensions) {
|
|
231
|
+
const sep = content.endsWith("\n") ? "" : "\n";
|
|
232
|
+
fs.writeFileSync(GOOSE_CONFIG, content + sep + "\n" + gooseSnippet() + "\n");
|
|
233
|
+
return { ok: true };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!hasSinain) {
|
|
237
|
+
// Insert `sinain:` block right after the `extensions:` line.
|
|
238
|
+
const lines = content.split("\n");
|
|
239
|
+
const idx = lines.findIndex((l) => /^extensions:/.test(l));
|
|
240
|
+
const block = gooseSnippet().split("\n").slice(1); // drop the `extensions:` header
|
|
241
|
+
lines.splice(idx + 1, 0, ...block);
|
|
242
|
+
fs.writeFileSync(GOOSE_CONFIG, lines.join("\n"));
|
|
243
|
+
return { ok: true };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Sinain entry already exists — replace it. We replace the `sinain:` block
|
|
247
|
+
// and every immediately-following line that's deeper-indented, stopping at
|
|
248
|
+
// the next sibling key or end of file.
|
|
249
|
+
const lines = content.split("\n");
|
|
250
|
+
const start = lines.findIndex((l) => /^\s+sinain:/.test(l));
|
|
251
|
+
if (start === -1) return { ok: false, reason: "structural-mismatch", snippet: gooseSnippet() };
|
|
252
|
+
const indentMatch = lines[start].match(/^(\s+)/);
|
|
253
|
+
const baseIndent = indentMatch ? indentMatch[1].length : 2;
|
|
254
|
+
let end = start + 1;
|
|
255
|
+
while (end < lines.length) {
|
|
256
|
+
const line = lines[end];
|
|
257
|
+
if (line.trim() === "") { end++; continue; }
|
|
258
|
+
const m = line.match(/^(\s*)/);
|
|
259
|
+
if ((m ? m[1].length : 0) <= baseIndent) break;
|
|
260
|
+
end++;
|
|
261
|
+
}
|
|
262
|
+
const replacement = gooseSnippet().split("\n").slice(1); // drop `extensions:` header
|
|
263
|
+
// re-indent replacement to baseIndent
|
|
264
|
+
const reindented = replacement.map((l) => l.replace(/^ {2}/, " ".repeat(baseIndent)));
|
|
265
|
+
lines.splice(start, end - start, ...reindented);
|
|
266
|
+
fs.writeFileSync(GOOSE_CONFIG, lines.join("\n"));
|
|
267
|
+
return { ok: true };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function gooseUnregister() {
|
|
271
|
+
if (!fs.existsSync(GOOSE_CONFIG)) return;
|
|
272
|
+
const content = fs.readFileSync(GOOSE_CONFIG, "utf-8");
|
|
273
|
+
if (!/^\s+sinain:/m.test(content)) return;
|
|
274
|
+
const lines = content.split("\n");
|
|
275
|
+
const start = lines.findIndex((l) => /^\s+sinain:/.test(l));
|
|
276
|
+
if (start === -1) return;
|
|
277
|
+
const baseIndent = (lines[start].match(/^(\s+)/)?.[1].length) || 2;
|
|
278
|
+
let end = start + 1;
|
|
279
|
+
while (end < lines.length) {
|
|
280
|
+
const line = lines[end];
|
|
281
|
+
if (line.trim() === "") { end++; continue; }
|
|
282
|
+
if ((line.match(/^(\s*)/)?.[1].length || 0) <= baseIndent) break;
|
|
283
|
+
end++;
|
|
284
|
+
}
|
|
285
|
+
lines.splice(start, end - start);
|
|
286
|
+
fs.writeFileSync(GOOSE_CONFIG, lines.join("\n"));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Agent registry ──────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
const AGENTS = [
|
|
292
|
+
{
|
|
293
|
+
id: "claude",
|
|
294
|
+
label: "Claude Code",
|
|
295
|
+
detect: () => cmdExists("claude"),
|
|
296
|
+
isRegistered: claudeAlreadyRegistered,
|
|
297
|
+
register: (opts) => claudeRegister(opts),
|
|
298
|
+
unregister: claudeUnregister,
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: "claude-desktop",
|
|
302
|
+
label: "Claude Desktop",
|
|
303
|
+
detect: () => fs.existsSync(path.dirname(jsonConfigPath("claude-desktop"))),
|
|
304
|
+
isRegistered: () => jsonAlreadyRegistered("claude-desktop"),
|
|
305
|
+
register: () => jsonRegister("claude-desktop"),
|
|
306
|
+
unregister: () => jsonUnregister("claude-desktop"),
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
id: "cursor",
|
|
310
|
+
label: "Cursor",
|
|
311
|
+
detect: () => cmdExists("cursor") || fs.existsSync(path.join(HOME, ".cursor")),
|
|
312
|
+
isRegistered: () => jsonAlreadyRegistered("cursor"),
|
|
313
|
+
register: () => jsonRegister("cursor"),
|
|
314
|
+
unregister: () => jsonUnregister("cursor"),
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
id: "codex",
|
|
318
|
+
label: "Codex",
|
|
319
|
+
detect: () => cmdExists("codex"),
|
|
320
|
+
isRegistered: codexAlreadyRegistered,
|
|
321
|
+
register: codexRegister,
|
|
322
|
+
unregister: codexUnregister,
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
id: "goose",
|
|
326
|
+
label: "Goose",
|
|
327
|
+
detect: () => cmdExists("goose") || fs.existsSync(GOOSE_CONFIG),
|
|
328
|
+
isRegistered: gooseAlreadyRegistered,
|
|
329
|
+
register: gooseRegister,
|
|
330
|
+
unregister: gooseUnregister,
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: "junie",
|
|
334
|
+
label: "Junie",
|
|
335
|
+
detect: () => cmdExists("junie") || fs.existsSync(path.join(HOME, ".junie")),
|
|
336
|
+
isRegistered: () => jsonAlreadyRegistered("junie"),
|
|
337
|
+
register: () => jsonRegister("junie"),
|
|
338
|
+
unregister: () => jsonUnregister("junie"),
|
|
339
|
+
},
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
function findAgent(id) {
|
|
343
|
+
return AGENTS.find((a) => a.id === id);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
export async function detectMcpAgents() {
|
|
349
|
+
return AGENTS.map((a) => {
|
|
350
|
+
const present = !!a.detect();
|
|
351
|
+
let alreadyRegistered = false;
|
|
352
|
+
if (present) {
|
|
353
|
+
try { alreadyRegistered = !!a.isRegistered(); } catch { /* ignore */ }
|
|
354
|
+
}
|
|
355
|
+
return { id: a.id, label: a.label, present, alreadyRegistered };
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function registerSinainMcp(agentId, opts = {}) {
|
|
360
|
+
const a = findAgent(agentId);
|
|
361
|
+
if (!a) throw new Error(`Unknown agent: ${agentId}`);
|
|
362
|
+
await a.register(opts);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function unregisterSinainMcp(agentId) {
|
|
366
|
+
const a = findAgent(agentId);
|
|
367
|
+
if (!a) throw new Error(`Unknown agent: ${agentId}`);
|
|
368
|
+
await a.unregister();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Wizard step ─────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
export async function stepMcpInstall(_existing, label = "MCP agents") {
|
|
374
|
+
p.log.step(label);
|
|
375
|
+
|
|
376
|
+
if (!mcpServerReady()) {
|
|
377
|
+
const s = p.spinner();
|
|
378
|
+
s.start("Preparing sinain MCP server...");
|
|
379
|
+
const ok = ensureMcpServerDeps();
|
|
380
|
+
s.stop(ok ? c.green("MCP server ready.") : c.yellow("MCP server deps not installed."));
|
|
381
|
+
if (!ok) {
|
|
382
|
+
p.note(
|
|
383
|
+
`Could not install dependencies for the bundled MCP server.\nRun manually: cd ${MCP_SERVER_DIR} && npm install`,
|
|
384
|
+
"MCP server",
|
|
385
|
+
);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const detectSpinner = p.spinner();
|
|
391
|
+
detectSpinner.start("Detecting MCP-aware agents...");
|
|
392
|
+
const agents = await detectMcpAgents();
|
|
393
|
+
detectSpinner.stop(c.green("Detection done."));
|
|
394
|
+
|
|
395
|
+
const detected = agents.filter((a) => a.present);
|
|
396
|
+
if (detected.length === 0) {
|
|
397
|
+
p.note(
|
|
398
|
+
"No MCP-aware agents detected on this machine.\nInstall Claude Code, Cursor, Codex, Goose, or Junie first.\nThen re-run: npx @geravant/sinain mcp install",
|
|
399
|
+
"Skipped",
|
|
400
|
+
);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Status summary
|
|
405
|
+
const summary = detected
|
|
406
|
+
.map((a) => `${a.label}: ${a.alreadyRegistered ? c.dim("registered") : c.green("detected")}`)
|
|
407
|
+
.join("\n");
|
|
408
|
+
p.note(summary, "Agents found");
|
|
409
|
+
|
|
410
|
+
// Multi-select: pre-check unregistered detected agents
|
|
411
|
+
const choice = guard(await p.multiselect({
|
|
412
|
+
message: "Register sinain MCP for:",
|
|
413
|
+
options: detected.map((a) => ({
|
|
414
|
+
value: a.id,
|
|
415
|
+
label: a.label,
|
|
416
|
+
hint: a.alreadyRegistered ? "already registered (re-register to refresh paths)" : undefined,
|
|
417
|
+
})),
|
|
418
|
+
initialValues: detected.filter((a) => !a.alreadyRegistered).map((a) => a.id),
|
|
419
|
+
required: false,
|
|
420
|
+
}));
|
|
421
|
+
|
|
422
|
+
if (!choice || choice.length === 0) {
|
|
423
|
+
p.log.info("No agents selected — skipping MCP registration.");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
for (const id of choice) {
|
|
428
|
+
const a = findAgent(id);
|
|
429
|
+
const s = p.spinner();
|
|
430
|
+
s.start(`Registering for ${a.label}...`);
|
|
431
|
+
try {
|
|
432
|
+
await a.register();
|
|
433
|
+
s.stop(c.green(`${a.label}: registered.`));
|
|
434
|
+
} catch (err) {
|
|
435
|
+
s.stop(c.yellow(`${a.label}: failed — ${err.message}`));
|
|
436
|
+
if (id === "goose") {
|
|
437
|
+
p.note(`Paste this into ${GOOSE_CONFIG}:\n\n${gooseSnippet()}`, "Goose snippet");
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Bonus: alternate Claude config dir (pclaude / CLAUDE_CONFIG_DIR)
|
|
443
|
+
if (process.env.CLAUDE_CONFIG_DIR && choice.includes("claude")) {
|
|
444
|
+
const altDir = process.env.CLAUDE_CONFIG_DIR;
|
|
445
|
+
const alsoRegister = guard(await p.confirm({
|
|
446
|
+
message: `Also register sinain for CLAUDE_CONFIG_DIR=${altDir}?`,
|
|
447
|
+
initialValue: true,
|
|
448
|
+
}));
|
|
449
|
+
if (alsoRegister) {
|
|
450
|
+
const s = p.spinner();
|
|
451
|
+
s.start(`Registering for Claude (${altDir})...`);
|
|
452
|
+
try {
|
|
453
|
+
const env = { ...process.env, CLAUDE_CONFIG_DIR: altDir };
|
|
454
|
+
const args = ["mcp", "remove", "sinain"];
|
|
455
|
+
try { execFileSync("claude", args, { stdio: "pipe", env }); } catch { /* not registered */ }
|
|
456
|
+
const addArgs = ["mcp", "add", "sinain", "--scope", "user"];
|
|
457
|
+
for (const [k, v] of Object.entries(DEFAULT_ENV)) addArgs.push("--env", `${k}=${v}`);
|
|
458
|
+
addArgs.push("--", TSX_BIN, MCP_ENTRY);
|
|
459
|
+
execFileSync("claude", addArgs, { stdio: "pipe", env });
|
|
460
|
+
s.stop(c.green(`Claude (${altDir}): registered.`));
|
|
461
|
+
} catch (err) {
|
|
462
|
+
s.stop(c.yellow(`Alt config dir: failed — ${err.message}`));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── Standalone CLI ──────────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
export async function runMcpCli(sub, args = []) {
|
|
471
|
+
switch (sub) {
|
|
472
|
+
case "install":
|
|
473
|
+
return cliInstall(args);
|
|
474
|
+
case "list":
|
|
475
|
+
return cliList();
|
|
476
|
+
case "remove":
|
|
477
|
+
return cliRemove(args);
|
|
478
|
+
default:
|
|
479
|
+
printMcpUsage();
|
|
480
|
+
process.exit(sub ? 1 : 0);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function cliInstall(args) {
|
|
485
|
+
const all = args.includes("--all");
|
|
486
|
+
const agentArg = args.find((a) => a.startsWith("--agent="));
|
|
487
|
+
const agentId = agentArg ? agentArg.slice("--agent=".length) : null;
|
|
488
|
+
|
|
489
|
+
if (agentId) {
|
|
490
|
+
const a = findAgent(agentId);
|
|
491
|
+
if (!a) { console.error(c.red(`Unknown agent: ${agentId}`)); process.exit(1); }
|
|
492
|
+
if (!a.detect()) { console.error(c.yellow(`${a.label} not detected on this machine.`)); process.exit(1); }
|
|
493
|
+
try {
|
|
494
|
+
await a.register();
|
|
495
|
+
console.log(c.green(`✓ ${a.label}: registered.`));
|
|
496
|
+
} catch (err) {
|
|
497
|
+
console.error(c.red(`✗ ${a.label}: ${err.message}`));
|
|
498
|
+
if (agentId === "goose") console.log(`\nPaste this into ${GOOSE_CONFIG}:\n\n${gooseSnippet()}`);
|
|
499
|
+
process.exit(1);
|
|
500
|
+
}
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (all) {
|
|
505
|
+
if (!mcpServerReady() && !ensureMcpServerDeps()) {
|
|
506
|
+
console.error(c.red(`MCP server deps not installed. Run: cd ${MCP_SERVER_DIR} && npm install`));
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
509
|
+
const agents = await detectMcpAgents();
|
|
510
|
+
const detected = agents.filter((a) => a.present);
|
|
511
|
+
if (detected.length === 0) {
|
|
512
|
+
console.log(c.yellow("No MCP-aware agents detected."));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
for (const a of detected) {
|
|
516
|
+
const backend = findAgent(a.id);
|
|
517
|
+
try {
|
|
518
|
+
await backend.register();
|
|
519
|
+
console.log(c.green(`✓ ${a.label}: registered.`));
|
|
520
|
+
} catch (err) {
|
|
521
|
+
console.error(c.yellow(`! ${a.label}: ${err.message}`));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Interactive
|
|
528
|
+
await stepMcpInstall({}, "MCP agents");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function cliList() {
|
|
532
|
+
const agents = await detectMcpAgents();
|
|
533
|
+
console.log();
|
|
534
|
+
console.log(c.bold(" Sinain MCP — agent status"));
|
|
535
|
+
console.log();
|
|
536
|
+
for (const a of agents) {
|
|
537
|
+
const present = a.present ? c.green("present") : c.dim("absent ");
|
|
538
|
+
const reg = a.alreadyRegistered ? c.green("✓ registered") : (a.present ? c.dim("· not registered") : c.dim("·"));
|
|
539
|
+
console.log(` ${present} ${a.label.padEnd(18)} ${reg}`);
|
|
540
|
+
}
|
|
541
|
+
console.log();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function cliRemove(args) {
|
|
545
|
+
const id = args[0];
|
|
546
|
+
if (!id) { console.error(c.red("Usage: sinain mcp remove <agent>")); process.exit(1); }
|
|
547
|
+
const a = findAgent(id);
|
|
548
|
+
if (!a) { console.error(c.red(`Unknown agent: ${id}`)); process.exit(1); }
|
|
549
|
+
try {
|
|
550
|
+
await a.unregister();
|
|
551
|
+
console.log(c.green(`✓ ${a.label}: removed.`));
|
|
552
|
+
} catch (err) {
|
|
553
|
+
console.error(c.red(`✗ ${a.label}: ${err.message}`));
|
|
554
|
+
process.exit(1);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function printMcpUsage() {
|
|
559
|
+
console.log(`
|
|
560
|
+
sinain mcp — register the sinain MCP server with your agents
|
|
561
|
+
|
|
562
|
+
Usage:
|
|
563
|
+
sinain mcp install Interactive install (multi-select)
|
|
564
|
+
sinain mcp install --all Register for every detected agent
|
|
565
|
+
sinain mcp install --agent=<id> Register for one agent
|
|
566
|
+
sinain mcp list Show agent status
|
|
567
|
+
sinain mcp remove <agent> Unregister sinain from <agent>
|
|
568
|
+
|
|
569
|
+
Supported agent IDs:
|
|
570
|
+
claude, claude-desktop, cursor, codex, goose, junie
|
|
571
|
+
`);
|
|
572
|
+
}
|
package/onboard.js
CHANGED
|
@@ -267,15 +267,39 @@ export async function runOnboard(args = {}) {
|
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
270
|
+
// OpenClaw gateway is opt-in: most users run sinain in standalone mode
|
|
271
|
+
// and never need a gateway. Default the prompt to "No" on fresh installs
|
|
272
|
+
// (when no openclaw profile already exists) so accepting all wizard
|
|
273
|
+
// defaults never silently provisions a gateway profile or starts a WS
|
|
274
|
+
// reconnect loop. Mirrors the Quick-path gate at line ~177.
|
|
275
|
+
const hasExistingGateway = (() => {
|
|
276
|
+
try {
|
|
277
|
+
const agentsPath = path.join(SINAIN_DIR, "agents.json");
|
|
278
|
+
if (!fs.existsSync(agentsPath)) return false;
|
|
279
|
+
const cfg = JSON.parse(fs.readFileSync(agentsPath, "utf-8"));
|
|
280
|
+
return !!cfg?.profiles?.openclaw;
|
|
281
|
+
} catch { return false; }
|
|
282
|
+
})();
|
|
283
|
+
const enableGateway = guard(await p.confirm({
|
|
284
|
+
message: "[3/6] Configure OpenClaw gateway?",
|
|
285
|
+
initialValue: hasExistingGateway,
|
|
286
|
+
}));
|
|
287
|
+
if (enableGateway) {
|
|
288
|
+
// stepGateway returns { envVars, agentsPatch }: tokens go to .env,
|
|
289
|
+
// URLs + session + escalation mode go to agents.json's openclaw profile.
|
|
290
|
+
const gatewayResult = await stepGateway(base, "[3/6] OpenClaw gateway");
|
|
291
|
+
Object.assign(vars, gatewayResult.envVars);
|
|
292
|
+
Object.assign(agentsPatch, gatewayResult.agentsPatch);
|
|
293
|
+
if (gatewayResult.agentsPatch.escalationMode === "off") {
|
|
294
|
+
p.log.info("Standalone mode (no gateway).");
|
|
295
|
+
} else {
|
|
296
|
+
p.log.success("Gateway configured.");
|
|
297
|
+
}
|
|
277
298
|
} else {
|
|
278
|
-
|
|
299
|
+
// Explicitly clear any inherited openclaw profile so the runtime doesn't
|
|
300
|
+
// auto-register the gateway or attempt WS reconnects (matches Quick path).
|
|
301
|
+
agentsPatch.openclawProfile = null;
|
|
302
|
+
p.log.info("Standalone mode (no gateway).");
|
|
279
303
|
}
|
|
280
304
|
|
|
281
305
|
const privacy = await stepPrivacy(base, "[4/6] Privacy mode");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geravant/sinain",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.1",
|
|
4
4
|
"description": "Ambient intelligence that sees what you see, hears what you hear, and acts on your behalf",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"config-shared.js",
|
|
20
20
|
"onboard.js",
|
|
21
21
|
"launcher.js",
|
|
22
|
+
"mcp-register.js",
|
|
22
23
|
"setup-overlay.js",
|
|
23
24
|
"setup-sck-capture.js",
|
|
24
25
|
"pack-prepare.js",
|