@synap-core/cli 1.2.0 → 1.5.0
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/README.md +26 -0
- package/dist/commands/agents.d.ts +31 -0
- package/dist/commands/agents.js +478 -0
- package/dist/commands/agents.js.map +1 -0
- package/dist/commands/connect.d.ts +18 -1
- package/dist/commands/connect.js +154 -74
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/connections.d.ts +9 -0
- package/dist/commands/connections.js +161 -0
- package/dist/commands/connections.js.map +1 -0
- package/dist/commands/data.d.ts +43 -0
- package/dist/commands/data.js +387 -0
- package/dist/commands/data.js.map +1 -0
- package/dist/commands/finish.js +41 -8
- package/dist/commands/finish.js.map +1 -1
- package/dist/commands/infra.d.ts +21 -0
- package/dist/commands/infra.js +262 -0
- package/dist/commands/infra.js.map +1 -0
- package/dist/commands/init.js +188 -10
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/knowledge.d.ts +36 -0
- package/dist/commands/knowledge.js +123 -0
- package/dist/commands/knowledge.js.map +1 -0
- package/dist/commands/openclaw.d.ts +2 -0
- package/dist/commands/openclaw.js +300 -23
- package/dist/commands/openclaw.js.map +1 -1
- package/dist/commands/pods.d.ts +17 -0
- package/dist/commands/pods.js +371 -0
- package/dist/commands/pods.js.map +1 -0
- package/dist/commands/status.d.ts +14 -1
- package/dist/commands/status.js +78 -220
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/update.d.ts +11 -2
- package/dist/commands/update.js +116 -5
- package/dist/commands/update.js.map +1 -1
- package/dist/index.js +370 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/agents-config.d.ts +20 -0
- package/dist/lib/agents-config.js +45 -0
- package/dist/lib/agents-config.js.map +1 -0
- package/dist/lib/auth.d.ts +4 -0
- package/dist/lib/auth.js +4 -0
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/browser-auth.d.ts +35 -0
- package/dist/lib/browser-auth.js +170 -0
- package/dist/lib/browser-auth.js.map +1 -0
- package/dist/lib/hub-client.d.ts +17 -0
- package/dist/lib/hub-client.js +115 -0
- package/dist/lib/hub-client.js.map +1 -0
- package/dist/lib/openclaw.js +30 -19
- package/dist/lib/openclaw.js.map +1 -1
- package/dist/lib/pod.d.ts +32 -1
- package/dist/lib/pod.js +121 -9
- package/dist/lib/pod.js.map +1 -1
- package/dist/lib/skills-installer.d.ts +18 -0
- package/dist/lib/skills-installer.js +97 -0
- package/dist/lib/skills-installer.js.map +1 -0
- package/dist/lib/targets.d.ts +65 -0
- package/dist/lib/targets.js +673 -0
- package/dist/lib/targets.js.map +1 -0
- package/package.json +5 -3
- package/skills/README.md +91 -0
- package/skills/synap/README.md +76 -0
- package/skills/synap/SKILL.md +882 -0
- package/skills/synap/capture.md +170 -0
- package/skills/synap/governance.md +206 -0
- package/skills/synap/linking.md +128 -0
- package/skills/synap/scripts/orient.sh +28 -0
- package/skills/synap-schema/SKILL.md +231 -0
- package/skills/synap-schema/property-types.md +228 -0
- package/skills/synap-ui/SKILL.md +295 -0
- package/skills/synap-ui/bento-recipes.md +608 -0
- package/skills/synap-ui/view-types.md +259 -0
- package/skills/synap-ui/widget-catalog.md +305 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connect targets — where `synap connect --target=X` can drop credentials,
|
|
3
|
+
* skills, and MCP configs for each supported AI surface.
|
|
4
|
+
*
|
|
5
|
+
* Each target knows:
|
|
6
|
+
* - how to describe itself to the user
|
|
7
|
+
* - where its skills directory lives (if it reads Agent Skills)
|
|
8
|
+
* - where its MCP config lives (if it supports MCP)
|
|
9
|
+
* - how to render a one-shot install for the given pod + API key
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import chalk from "chalk";
|
|
15
|
+
import prompts from "prompts";
|
|
16
|
+
import { log } from "../utils/logger.js";
|
|
17
|
+
import { installSkills, SKILL_NAMES } from "./skills-installer.js";
|
|
18
|
+
const CLAUDE_DESKTOP_MACOS = path.join(os.homedir(), "Library", "Application Support", "Claude");
|
|
19
|
+
const CLAUDE_DESKTOP_WINDOWS = path.join(process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), "Claude");
|
|
20
|
+
const CLAUDE_DESKTOP_LINUX = path.join(os.homedir(), ".config", "Claude");
|
|
21
|
+
function claudeDesktopBaseDir() {
|
|
22
|
+
switch (process.platform) {
|
|
23
|
+
case "darwin":
|
|
24
|
+
return CLAUDE_DESKTOP_MACOS;
|
|
25
|
+
case "win32":
|
|
26
|
+
return CLAUDE_DESKTOP_WINDOWS;
|
|
27
|
+
default:
|
|
28
|
+
return CLAUDE_DESKTOP_LINUX;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export const TARGETS = {
|
|
32
|
+
"claude-code": {
|
|
33
|
+
name: "claude-code",
|
|
34
|
+
label: "Claude Code",
|
|
35
|
+
supports: { skills: true, mcp: true },
|
|
36
|
+
skillsDir: () => path.join(os.homedir(), ".claude", "skills"),
|
|
37
|
+
mcpConfigPath: () => path.join(os.homedir(), ".claude", "settings.json"),
|
|
38
|
+
},
|
|
39
|
+
"claude-desktop": {
|
|
40
|
+
name: "claude-desktop",
|
|
41
|
+
label: "Claude Desktop",
|
|
42
|
+
// Claude Desktop reads Agent Skills from its app-support skills/ dir
|
|
43
|
+
// (alongside claude_desktop_config.json). The "upload via claude.ai" path
|
|
44
|
+
// is still valid for cross-device sync, but local drop-in works and is
|
|
45
|
+
// what `synap connect` automates here. Path layout matches Claude Code's
|
|
46
|
+
// `~/.claude/skills/<skill>/SKILL.md` — same `installSkills` helper.
|
|
47
|
+
supports: { skills: true, mcp: true },
|
|
48
|
+
mcpConfigPath: () => path.join(claudeDesktopBaseDir(), "claude_desktop_config.json"),
|
|
49
|
+
skillsDir: () => path.join(claudeDesktopBaseDir(), "skills"),
|
|
50
|
+
},
|
|
51
|
+
cursor: {
|
|
52
|
+
name: "cursor",
|
|
53
|
+
label: "Cursor",
|
|
54
|
+
supports: { skills: false, mcp: true },
|
|
55
|
+
mcpConfigPath: () => path.join(os.homedir(), ".cursor", "mcp.json"),
|
|
56
|
+
},
|
|
57
|
+
raycast: {
|
|
58
|
+
name: "raycast",
|
|
59
|
+
label: "Raycast",
|
|
60
|
+
description: "Native extension with 9 AI tools + commands (credentials auto-read from CLI config)",
|
|
61
|
+
supports: { skills: false, mcp: false },
|
|
62
|
+
},
|
|
63
|
+
openclaw: {
|
|
64
|
+
name: "openclaw",
|
|
65
|
+
label: "OpenClaw",
|
|
66
|
+
supports: { skills: true, mcp: false },
|
|
67
|
+
},
|
|
68
|
+
openwebui: {
|
|
69
|
+
name: "openwebui",
|
|
70
|
+
label: "Open WebUI",
|
|
71
|
+
description: "Register Synap as a model source and tool server in Open WebUI",
|
|
72
|
+
supports: { skills: false, mcp: true },
|
|
73
|
+
},
|
|
74
|
+
codex: {
|
|
75
|
+
name: "codex",
|
|
76
|
+
label: "OpenAI Codex",
|
|
77
|
+
description: "OpenAI Codex CLI — MCP server + instructions",
|
|
78
|
+
supports: { skills: true, mcp: true },
|
|
79
|
+
mcpConfigPath: () => path.join(os.homedir(), ".codex", "config.yaml"),
|
|
80
|
+
skillsDir: () => path.join(os.homedir(), ".codex"),
|
|
81
|
+
},
|
|
82
|
+
generic: {
|
|
83
|
+
name: "generic",
|
|
84
|
+
label: "Generic MCP Client",
|
|
85
|
+
description: "Output MCP connection config for any MCP-compatible client",
|
|
86
|
+
supports: { skills: false, mcp: true },
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
export function isTargetName(value) {
|
|
90
|
+
return value in TARGETS;
|
|
91
|
+
}
|
|
92
|
+
export function listTargets() {
|
|
93
|
+
log.heading("Supported targets");
|
|
94
|
+
for (const t of Object.values(TARGETS)) {
|
|
95
|
+
const caps = [];
|
|
96
|
+
if (t.supports.skills)
|
|
97
|
+
caps.push("skills");
|
|
98
|
+
if (t.supports.mcp)
|
|
99
|
+
caps.push("mcp");
|
|
100
|
+
log.info(`${chalk.bold(t.name.padEnd(16))} ${t.label} ${chalk.dim(`(${caps.join(", ") || "—"})`)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Run the install flow for a target. Returns true on success.
|
|
105
|
+
* Throws only on unexpected errors — recoverable failures are logged + return false.
|
|
106
|
+
*/
|
|
107
|
+
export async function installForTarget(target, cfg) {
|
|
108
|
+
const info = TARGETS[target];
|
|
109
|
+
if (!info) {
|
|
110
|
+
log.error(`Unknown target: ${target}`);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
log.heading(`Installing for ${info.label}`);
|
|
114
|
+
switch (target) {
|
|
115
|
+
case "claude-code":
|
|
116
|
+
return installClaudeCode(info, cfg);
|
|
117
|
+
case "claude-desktop":
|
|
118
|
+
return installClaudeDesktop(info, cfg);
|
|
119
|
+
case "cursor":
|
|
120
|
+
return installCursor(info, cfg);
|
|
121
|
+
case "raycast":
|
|
122
|
+
return installRaycast(cfg);
|
|
123
|
+
case "openclaw":
|
|
124
|
+
return installOpenclaw(cfg);
|
|
125
|
+
case "openwebui":
|
|
126
|
+
return installOpenWebUI(cfg);
|
|
127
|
+
case "codex":
|
|
128
|
+
return installCodex(info, cfg);
|
|
129
|
+
case "generic":
|
|
130
|
+
return installGeneric(cfg);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// ─── Claude Code ─────────────────────────────────────────────────────────────
|
|
134
|
+
async function installClaudeCode(info, cfg) {
|
|
135
|
+
// ── What to install? ────────────────────────────────────────────────────
|
|
136
|
+
// Skills = CLAUDE.md context files (instructions, schema, UI patterns)
|
|
137
|
+
// MCP = live tool server (read/write entities, search, memory, etc.)
|
|
138
|
+
// Both is the recommended default — skills give the model Synap knowledge,
|
|
139
|
+
// MCP gives it the actual tools to act on data.
|
|
140
|
+
const { mode } = await prompts({
|
|
141
|
+
type: "select",
|
|
142
|
+
name: "mode",
|
|
143
|
+
message: "What do you want to install?",
|
|
144
|
+
choices: [
|
|
145
|
+
{
|
|
146
|
+
title: "Both — Skills + MCP tools (recommended)",
|
|
147
|
+
description: "Skills give Claude context about Synap; MCP tools let it read & write data",
|
|
148
|
+
value: "both",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
title: "MCP tools only",
|
|
152
|
+
description: "Live tool server — search, create, update entities, memory, channels",
|
|
153
|
+
value: "mcp",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
title: "Skills only",
|
|
157
|
+
description: "Context files (CLAUDE.md) — no live data access",
|
|
158
|
+
value: "skills",
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
initial: 0,
|
|
162
|
+
});
|
|
163
|
+
if (!mode)
|
|
164
|
+
return false;
|
|
165
|
+
// ── Skills ──────────────────────────────────────────────────────────────
|
|
166
|
+
if (mode === "both" || mode === "skills") {
|
|
167
|
+
const dir = info.skillsDir?.();
|
|
168
|
+
if (dir) {
|
|
169
|
+
const installed = await installSkills({
|
|
170
|
+
destDir: dir,
|
|
171
|
+
skills: cfg.skills ?? SKILL_NAMES,
|
|
172
|
+
});
|
|
173
|
+
if (installed) {
|
|
174
|
+
log.success(`Skills installed to ~/.claude/skills/`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ── MCP ─────────────────────────────────────────────────────────────────
|
|
179
|
+
if (mode === "both" || mode === "mcp") {
|
|
180
|
+
await writeClaudeCodeEnv(cfg);
|
|
181
|
+
log.success("MCP server 'synap' added to ~/.claude/settings.json");
|
|
182
|
+
if (cfg.workspaceId) {
|
|
183
|
+
log.dim(`Scoped to workspace: ${cfg.workspaceId}`);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
log.dim("Not scoped — all workspaces accessible.");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
// Skills-only: still write env vars (pod URL useful for skills referencing
|
|
191
|
+
// SYNAP_POD_URL) but don't add the mcpServers entry
|
|
192
|
+
await writeClaudeCodeEnv({ ...cfg, workspaceId: cfg.workspaceId }, { writeMcp: false });
|
|
193
|
+
}
|
|
194
|
+
log.blank();
|
|
195
|
+
log.success("Claude Code config updated.");
|
|
196
|
+
log.dim("Restart Claude Code (or open a new window) to pick up the MCP server and env vars.");
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Fetch the pod's accessible workspaces and prompt the user to pick one for
|
|
201
|
+
* MCP URL scoping. Falls back silently to the workspaceId already in cfg
|
|
202
|
+
* if the fetch fails or the user skips.
|
|
203
|
+
*/
|
|
204
|
+
export async function resolveWorkspaceId(cfg) {
|
|
205
|
+
let workspaceList = [];
|
|
206
|
+
try {
|
|
207
|
+
const res = await fetch(`${cfg.podUrl.replace(/\/$/, "")}/api/hub/workspaces`, {
|
|
208
|
+
headers: { Authorization: `Bearer ${cfg.apiKey}` },
|
|
209
|
+
});
|
|
210
|
+
if (res.ok) {
|
|
211
|
+
const body = await res.json();
|
|
212
|
+
workspaceList = body.workspaces ?? [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch { /* pod unreachable — fall through */ }
|
|
216
|
+
// Single workspace or no workspaces — no need to prompt
|
|
217
|
+
if (workspaceList.length <= 1) {
|
|
218
|
+
return workspaceList[0]?.id ?? cfg.workspaceId;
|
|
219
|
+
}
|
|
220
|
+
log.blank();
|
|
221
|
+
log.info(`Found ${workspaceList.length} workspaces on this pod.`);
|
|
222
|
+
log.dim("Pinning a workspace scopes the MCP so Claude Code sees only that workspace.");
|
|
223
|
+
const defaultIdx = cfg.workspaceId
|
|
224
|
+
? workspaceList.findIndex((w) => w.id === cfg.workspaceId)
|
|
225
|
+
: -1;
|
|
226
|
+
const { selected } = await prompts({
|
|
227
|
+
type: "select",
|
|
228
|
+
name: "selected",
|
|
229
|
+
message: "Scope MCP to one workspace?",
|
|
230
|
+
choices: [
|
|
231
|
+
{ title: chalk.dim("All workspaces (no scoping)"), value: "" },
|
|
232
|
+
...workspaceList.map((w) => ({
|
|
233
|
+
title: `${chalk.bold(w.name)} ${chalk.dim(w.id)}`,
|
|
234
|
+
value: w.id,
|
|
235
|
+
})),
|
|
236
|
+
],
|
|
237
|
+
initial: defaultIdx >= 0 ? defaultIdx + 1 : 0, // +1 because "All" is first
|
|
238
|
+
});
|
|
239
|
+
return selected || undefined;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Write (or update) Synap env vars AND optionally the MCP server entry in ~/.claude/settings.json.
|
|
243
|
+
* Called both by installClaudeCode and by `synap pods use` to propagate switches.
|
|
244
|
+
*
|
|
245
|
+
* Claude Code reads mcpServers natively from settings.json using the HTTP transport
|
|
246
|
+
* format: { url, headers }. The URL optionally carries ?workspaceId= so all tool
|
|
247
|
+
* calls are pre-scoped to one workspace without the model having to pass it.
|
|
248
|
+
*
|
|
249
|
+
* @param writeMcp — set false to skip the mcpServers entry (skills-only installs)
|
|
250
|
+
*/
|
|
251
|
+
export async function writeClaudeCodeEnv(cfg, { writeMcp = true } = {}) {
|
|
252
|
+
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
253
|
+
const settingsDir = path.dirname(settingsPath);
|
|
254
|
+
let settings = {};
|
|
255
|
+
if (fs.existsSync(settingsPath)) {
|
|
256
|
+
try {
|
|
257
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
258
|
+
}
|
|
259
|
+
catch { /* start fresh */ }
|
|
260
|
+
}
|
|
261
|
+
// ── Resolve human user ID and provision an agent-owned API key ──────────
|
|
262
|
+
// SYNAP_USER_ID = human user (for entity attribution)
|
|
263
|
+
// SYNAP_HUB_API_KEY = agent-owned key (identity encoded in the key itself)
|
|
264
|
+
const podBase = cfg.podUrl.replace(/\/$/, "");
|
|
265
|
+
let effectiveApiKey = cfg.apiKey;
|
|
266
|
+
// Resolve human user ID, then provision an agent-owned key.
|
|
267
|
+
// Both steps are required — fail loudly if either is unreachable.
|
|
268
|
+
const meRes = await fetch(`${podBase}/api/hub/users/me`, {
|
|
269
|
+
headers: { Authorization: `Bearer ${cfg.apiKey}` },
|
|
270
|
+
signal: AbortSignal.timeout(5000),
|
|
271
|
+
});
|
|
272
|
+
if (!meRes.ok) {
|
|
273
|
+
throw new Error(`Could not reach pod at ${cfg.podUrl} (HTTP ${meRes.status}). Check your pod URL and API key.`);
|
|
274
|
+
}
|
|
275
|
+
const me = await meRes.json();
|
|
276
|
+
if (!me.id)
|
|
277
|
+
throw new Error("/api/hub/users/me returned no user ID — is the pod running?");
|
|
278
|
+
const agentSetupRes = await fetch(`${podBase}/api/hub/setup/agent`, {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
|
|
281
|
+
body: JSON.stringify({ agentType: "claude-code", idempotent: false }),
|
|
282
|
+
signal: AbortSignal.timeout(10000),
|
|
283
|
+
});
|
|
284
|
+
if (!agentSetupRes.ok) {
|
|
285
|
+
const text = await agentSetupRes.text().catch(() => agentSetupRes.statusText);
|
|
286
|
+
throw new Error(`Failed to provision agent key for claude-code (HTTP ${agentSetupRes.status}): ${text}\nEnsure your API key has hub-protocol.write scope.`);
|
|
287
|
+
}
|
|
288
|
+
const agentSetup = await agentSetupRes.json();
|
|
289
|
+
if (!agentSetup.hubApiKey)
|
|
290
|
+
throw new Error("setup/agent succeeded but returned no hubApiKey — check pod logs.");
|
|
291
|
+
effectiveApiKey = agentSetup.hubApiKey;
|
|
292
|
+
// Enroll the agent user into the human user's workspaces using the HUMAN key
|
|
293
|
+
// (agent key is not yet active). When no workspace is scoped, this gives the
|
|
294
|
+
// agent access to all workspaces the human belongs to.
|
|
295
|
+
if (agentSetup.agentUserId) {
|
|
296
|
+
const enrollRes = await fetch(`${podBase}/api/hub/workspaces/enroll-agent`, {
|
|
297
|
+
method: "POST",
|
|
298
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
|
|
299
|
+
body: JSON.stringify({
|
|
300
|
+
agentUserId: agentSetup.agentUserId,
|
|
301
|
+
...(cfg.workspaceId ? { workspaceId: cfg.workspaceId } : {}),
|
|
302
|
+
role: "editor",
|
|
303
|
+
}),
|
|
304
|
+
signal: AbortSignal.timeout(10000),
|
|
305
|
+
});
|
|
306
|
+
if (enrollRes.ok) {
|
|
307
|
+
const enrolled = await enrollRes.json();
|
|
308
|
+
if (enrolled.enrolled?.length) {
|
|
309
|
+
log.dim(` Agent enrolled in ${enrolled.enrolled.length} workspace(s).`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Non-fatal — enrollment failure doesn't block the connect flow
|
|
313
|
+
}
|
|
314
|
+
const env = (settings.env ?? {});
|
|
315
|
+
env["SYNAP_POD_URL"] = cfg.podUrl;
|
|
316
|
+
env["SYNAP_HUB_API_KEY"] = effectiveApiKey;
|
|
317
|
+
env["SYNAP_USER_ID"] = me.id;
|
|
318
|
+
delete env["SYNAP_AGENT_USER_ID"];
|
|
319
|
+
if (cfg.workspaceId)
|
|
320
|
+
env["SYNAP_WORKSPACE_ID"] = cfg.workspaceId;
|
|
321
|
+
else
|
|
322
|
+
delete env["SYNAP_WORKSPACE_ID"];
|
|
323
|
+
if (me.scopes?.length)
|
|
324
|
+
env["SYNAP_KEY_SCOPES"] = me.scopes.join(",");
|
|
325
|
+
settings.env = env;
|
|
326
|
+
// ── MCP server entry (HTTP transport, native Claude Code format) ─────────
|
|
327
|
+
if (writeMcp) {
|
|
328
|
+
// Append ?workspaceId= when one is set so every tool call is pre-scoped.
|
|
329
|
+
const mcpUrl = cfg.workspaceId
|
|
330
|
+
? `${podBase}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
|
|
331
|
+
: `${podBase}/mcp`;
|
|
332
|
+
const mcpServers = (settings.mcpServers ?? {});
|
|
333
|
+
mcpServers["synap"] = {
|
|
334
|
+
url: mcpUrl,
|
|
335
|
+
headers: { Authorization: `Bearer ${effectiveApiKey}` },
|
|
336
|
+
};
|
|
337
|
+
settings.mcpServers = mcpServers;
|
|
338
|
+
}
|
|
339
|
+
if (!fs.existsSync(settingsDir)) {
|
|
340
|
+
fs.mkdirSync(settingsDir, { recursive: true });
|
|
341
|
+
}
|
|
342
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
|
|
343
|
+
}
|
|
344
|
+
// ─── Agent key provisioning helper ──────────────────────────────────────────
|
|
345
|
+
/**
|
|
346
|
+
* Provision an agent-owned API key for the given surface via POST /api/hub/setup/agent.
|
|
347
|
+
* Throws on failure — callers must not silently fall back to a human key.
|
|
348
|
+
*/
|
|
349
|
+
async function provisionAgentKey(podUrl, humanApiKey, agentType) {
|
|
350
|
+
const podBase = podUrl.replace(/\/$/, "");
|
|
351
|
+
let res;
|
|
352
|
+
try {
|
|
353
|
+
res = await fetch(`${podBase}/api/hub/setup/agent`, {
|
|
354
|
+
method: "POST",
|
|
355
|
+
headers: {
|
|
356
|
+
"Content-Type": "application/json",
|
|
357
|
+
Authorization: `Bearer ${humanApiKey}`,
|
|
358
|
+
},
|
|
359
|
+
body: JSON.stringify({ agentType, idempotent: false }),
|
|
360
|
+
signal: AbortSignal.timeout(10000),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
throw new Error(`Could not reach pod to provision agent key for ${agentType}: ${err.message}\n` +
|
|
365
|
+
`Ensure the pod is reachable and try again.`);
|
|
366
|
+
}
|
|
367
|
+
if (!res.ok) {
|
|
368
|
+
const text = await res.text().catch(() => res.statusText);
|
|
369
|
+
throw new Error(`Failed to provision agent key for ${agentType} (HTTP ${res.status}): ${text}\n` +
|
|
370
|
+
`Ensure your API key has hub-protocol.write scope.`);
|
|
371
|
+
}
|
|
372
|
+
const body = await res.json();
|
|
373
|
+
if (!body.hubApiKey) {
|
|
374
|
+
throw new Error(`setup/agent succeeded but returned no hubApiKey for ${agentType}. ` +
|
|
375
|
+
`This is a server-side bug — check the pod logs.`);
|
|
376
|
+
}
|
|
377
|
+
return body.hubApiKey;
|
|
378
|
+
}
|
|
379
|
+
// ─── Claude Desktop ──────────────────────────────────────────────────────────
|
|
380
|
+
async function installClaudeDesktop(info, cfg) {
|
|
381
|
+
const mcpPath = info.mcpConfigPath?.();
|
|
382
|
+
if (!mcpPath)
|
|
383
|
+
return false;
|
|
384
|
+
// Claude Desktop's claude_desktop_config.json is STDIO-ONLY — the HTTP
|
|
385
|
+
// `{ url, headers }` format is silently ignored. We write a stdio bridge
|
|
386
|
+
// via the community-maintained `mcp-remote` npm package, which translates
|
|
387
|
+
// stdio MCP messages into HTTPS calls against the pod's /mcp endpoint.
|
|
388
|
+
//
|
|
389
|
+
// Reference: https://www.npmjs.com/package/mcp-remote
|
|
390
|
+
const effectiveApiKey = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "claude-desktop");
|
|
391
|
+
const desktopMcpUrl = cfg.workspaceId
|
|
392
|
+
? `${cfg.podUrl.replace(/\/$/, "")}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
|
|
393
|
+
: `${cfg.podUrl.replace(/\/$/, "")}/mcp`;
|
|
394
|
+
writeMcpServerEntry(mcpPath, "synap", {
|
|
395
|
+
command: "npx",
|
|
396
|
+
args: ["-y", "mcp-remote", desktopMcpUrl, "--header", `Authorization: Bearer ${effectiveApiKey}`],
|
|
397
|
+
});
|
|
398
|
+
log.blank();
|
|
399
|
+
log.success(`MCP server 'synap' added to ${path.relative(os.homedir(), mcpPath)}`);
|
|
400
|
+
log.dim("The entry uses 'npx mcp-remote' as a stdio bridge — Claude Desktop spawns it.");
|
|
401
|
+
// Drop the three synap skill packages directly into Claude Desktop's
|
|
402
|
+
// app-support skills/ dir. Same on-disk layout as Claude Code
|
|
403
|
+
// (skills/<name>/SKILL.md), discovered on app launch.
|
|
404
|
+
const skillsDir = info.skillsDir?.();
|
|
405
|
+
if (skillsDir) {
|
|
406
|
+
const installed = await installSkills({
|
|
407
|
+
destDir: skillsDir,
|
|
408
|
+
skills: cfg.skills ?? SKILL_NAMES,
|
|
409
|
+
});
|
|
410
|
+
if (installed) {
|
|
411
|
+
log.success(`Synap skills installed to ${path.relative(os.homedir(), skillsDir)}/ (${(cfg.skills ?? SKILL_NAMES).join(", ")})`);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
log.warn("Skill install reported no files written — verify ~/Library/Application Support/Claude/skills/ exists after relaunch.");
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
log.blank();
|
|
418
|
+
log.info("Next steps:");
|
|
419
|
+
log.dim(" 1. Fully quit Claude Desktop (Cmd+Q), then relaunch.");
|
|
420
|
+
log.dim(" 2. Look for the MCP tools icon under the input box — 'synap' should appear.");
|
|
421
|
+
log.dim(" 3. Skills auto-load on launch from the app-support skills/ dir.");
|
|
422
|
+
log.dim(" 4. If tools don't load, check ~/Library/Logs/Claude/mcp*.log");
|
|
423
|
+
log.blank();
|
|
424
|
+
log.dim("Tip: to sync skills across devices via your Claude account, also upload them at https://claude.ai → Settings → Skills.");
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
// ─── Cursor ──────────────────────────────────────────────────────────────────
|
|
428
|
+
async function installCursor(info, cfg) {
|
|
429
|
+
const mcpPath = info.mcpConfigPath?.();
|
|
430
|
+
if (!mcpPath)
|
|
431
|
+
return false;
|
|
432
|
+
const effectiveApiKey = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "cursor");
|
|
433
|
+
const cursorMcpUrl = cfg.workspaceId
|
|
434
|
+
? `${cfg.podUrl.replace(/\/$/, "")}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
|
|
435
|
+
: `${cfg.podUrl.replace(/\/$/, "")}/mcp`;
|
|
436
|
+
writeMcpServerEntry(mcpPath, "synap", {
|
|
437
|
+
url: cursorMcpUrl,
|
|
438
|
+
headers: { Authorization: `Bearer ${effectiveApiKey}` },
|
|
439
|
+
});
|
|
440
|
+
log.blank();
|
|
441
|
+
log.success(`MCP server 'synap' added to ${path.relative(os.homedir(), mcpPath)}`);
|
|
442
|
+
if (cfg.workspaceId)
|
|
443
|
+
log.dim(`Scoped to workspace: ${cfg.workspaceId}`);
|
|
444
|
+
log.dim("Restart Cursor. The synap tool set will appear in agent mode.");
|
|
445
|
+
log.blank();
|
|
446
|
+
log.info("Cursor reads Claude-format skills from ~/.claude/skills/ too.");
|
|
447
|
+
log.dim("Run `synap connect --target=claude-code` additionally to install skills.");
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
// ─── Raycast ─────────────────────────────────────────────────────────────────
|
|
451
|
+
async function installRaycast(cfg) {
|
|
452
|
+
// Raycast reads credentials from ~/.synap/config.json (Tier 0 — highest priority).
|
|
453
|
+
// The pod setup step already wrote them there. No manual pasting needed.
|
|
454
|
+
// Behavioral guidance is built into the extension via ai.instructions in its package.json —
|
|
455
|
+
// Raycast AI automatically uses it when the extension's tools are active.
|
|
456
|
+
log.success("Credentials already in ~/.synap/config.json — Raycast picks them up automatically.");
|
|
457
|
+
log.blank();
|
|
458
|
+
log.info("1. Install the Synap extension from the Raycast Store:");
|
|
459
|
+
log.dim(" Open Raycast → search 'Raycast Store' → search 'Synap' → Install");
|
|
460
|
+
log.blank();
|
|
461
|
+
log.info("2. Commands available immediately after install:");
|
|
462
|
+
log.dim(" Search Synap — search your knowledge graph");
|
|
463
|
+
log.dim(" Quick Capture — capture selected text or clipboard as an entity");
|
|
464
|
+
log.dim(" Capture Browser Tab — save current browser tab");
|
|
465
|
+
log.dim(" Create Task — create a task directly");
|
|
466
|
+
log.dim(" Synap Status — menu bar: pending tasks + proposals");
|
|
467
|
+
log.blank();
|
|
468
|
+
log.info("3. Raycast AI has 9 native Synap tools (no MCP, no extra setup):");
|
|
469
|
+
log.dim(" search-entities · get-tasks · create-entity · update-entity · get-entity");
|
|
470
|
+
log.dim(" store-memory · recall-memory · send-to-channel · get-recent");
|
|
471
|
+
log.blank();
|
|
472
|
+
log.info("4. Behavioral guidance is built into the extension.");
|
|
473
|
+
log.dim(" Raycast AI knows how to use Synap — search before answering,");
|
|
474
|
+
log.dim(" save proactively, link entities, persist facts to memory.");
|
|
475
|
+
log.dim(" No system prompt to paste.");
|
|
476
|
+
log.blank();
|
|
477
|
+
log.dim(` Pod: ${cfg.podUrl}`);
|
|
478
|
+
if (cfg.workspaceId)
|
|
479
|
+
log.dim(` Workspace: ${cfg.workspaceId}`);
|
|
480
|
+
log.dim(" To switch pods later: synap switch — Raycast picks it up immediately.");
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
// ─── OpenClaw (wraps existing flow with saved pod config) ───────────────────
|
|
484
|
+
async function installOpenclaw(cfg) {
|
|
485
|
+
const { detectOpenClaw, readOpenClawConfig, writeOpenClawConfig, setConfigValue } = await import("./openclaw.js");
|
|
486
|
+
const oc = detectOpenClaw();
|
|
487
|
+
if (!oc.found) {
|
|
488
|
+
log.warn("OpenClaw not detected on this machine.");
|
|
489
|
+
log.dim(" Install it first: https://openclaw.dev");
|
|
490
|
+
log.dim(" Or run `synap init` to set up from scratch.");
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
const ocConfig = readOpenClawConfig() ?? {};
|
|
494
|
+
setConfigValue(ocConfig, "synap.podUrl", cfg.podUrl);
|
|
495
|
+
setConfigValue(ocConfig, "synap.hubApiKey", cfg.apiKey);
|
|
496
|
+
if (cfg.workspaceId)
|
|
497
|
+
setConfigValue(ocConfig, "synap.workspaceId", cfg.workspaceId);
|
|
498
|
+
if (cfg.agentUserId)
|
|
499
|
+
setConfigValue(ocConfig, "synap.agentUserId", cfg.agentUserId);
|
|
500
|
+
writeOpenClawConfig(ocConfig);
|
|
501
|
+
log.success("Wrote OpenClaw config");
|
|
502
|
+
log.blank();
|
|
503
|
+
log.info("Install the skills:");
|
|
504
|
+
for (const name of cfg.skills ?? SKILL_NAMES) {
|
|
505
|
+
log.dim(` openclaw skills install ${name}`);
|
|
506
|
+
}
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
// ─── Open WebUI ──────────────────────────────────────────────────────────────
|
|
510
|
+
async function installOpenWebUI(cfg) {
|
|
511
|
+
const podBase = cfg.podUrl.replace(/\/$/, "");
|
|
512
|
+
const mcpUrl = cfg.workspaceId
|
|
513
|
+
? `${podBase}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
|
|
514
|
+
: `${podBase}/mcp`;
|
|
515
|
+
const podHost = new URL(cfg.podUrl).host;
|
|
516
|
+
log.info("Open WebUI connects to Synap in two ways:");
|
|
517
|
+
log.blank();
|
|
518
|
+
log.info("1. Model source (configured by Eve at install time):");
|
|
519
|
+
log.dim(` Base URL: http://${podHost}/v1`);
|
|
520
|
+
log.blank();
|
|
521
|
+
log.info("2. MCP tool server:");
|
|
522
|
+
log.dim(" Admin → Tools → Add Tool Server");
|
|
523
|
+
log.dim(` URL: ${mcpUrl}`);
|
|
524
|
+
log.dim(` Header: Authorization: Bearer ${cfg.apiKey}`);
|
|
525
|
+
log.blank();
|
|
526
|
+
const synapDir = path.join(os.homedir(), ".synap");
|
|
527
|
+
if (!fs.existsSync(synapDir)) {
|
|
528
|
+
fs.mkdirSync(synapDir, { recursive: true });
|
|
529
|
+
}
|
|
530
|
+
const refFile = path.join(synapDir, "openwebui-mcp.json");
|
|
531
|
+
const refData = {
|
|
532
|
+
modelSource: { baseUrl: `http://${podHost}/v1` },
|
|
533
|
+
mcpToolServer: {
|
|
534
|
+
url: mcpUrl,
|
|
535
|
+
headers: { Authorization: `Bearer ${cfg.apiKey}` },
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
fs.writeFileSync(refFile, JSON.stringify(refData, null, 2) + "\n", { mode: 0o600 });
|
|
539
|
+
log.success(`Connection details saved to ~/.synap/openwebui-mcp.json`);
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
// ─── Generic MCP client ───────────────────────────────────────────────────────
|
|
543
|
+
async function installGeneric(cfg) {
|
|
544
|
+
const podBase = cfg.podUrl.replace(/\/$/, "");
|
|
545
|
+
const mcpUrl = cfg.workspaceId
|
|
546
|
+
? `${podBase}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
|
|
547
|
+
: `${podBase}/mcp`;
|
|
548
|
+
const httpConfig = {
|
|
549
|
+
synap: {
|
|
550
|
+
url: mcpUrl,
|
|
551
|
+
transport: "http",
|
|
552
|
+
headers: { Authorization: `Bearer ${cfg.apiKey}` },
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
log.info("Universal MCP connection config:");
|
|
556
|
+
log.blank();
|
|
557
|
+
log.info("HTTP direct:");
|
|
558
|
+
log.dim(JSON.stringify(httpConfig, null, 2));
|
|
559
|
+
log.blank();
|
|
560
|
+
log.info("stdio bridge (mcp-remote):");
|
|
561
|
+
log.dim(` command: npx -y mcp-remote ${mcpUrl} --header "Authorization: Bearer ${cfg.apiKey}"`);
|
|
562
|
+
log.blank();
|
|
563
|
+
log.info("Environment variable form:");
|
|
564
|
+
log.dim(` MCP_SERVER_URL=${mcpUrl}`);
|
|
565
|
+
log.dim(` MCP_API_KEY=${cfg.apiKey}`);
|
|
566
|
+
log.blank();
|
|
567
|
+
const synapDir = path.join(os.homedir(), ".synap");
|
|
568
|
+
if (!fs.existsSync(synapDir)) {
|
|
569
|
+
fs.mkdirSync(synapDir, { recursive: true });
|
|
570
|
+
}
|
|
571
|
+
const configFile = path.join(synapDir, "mcp-config.json");
|
|
572
|
+
fs.writeFileSync(configFile, JSON.stringify(httpConfig, null, 2) + "\n", { mode: 0o600 });
|
|
573
|
+
log.success(`MCP config written to ~/.synap/mcp-config.json`);
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
// ─── Codex ───────────────────────────────────────────────────────────────────
|
|
577
|
+
async function installCodex(info, cfg) {
|
|
578
|
+
const configPath = info.mcpConfigPath?.() ?? path.join(os.homedir(), ".codex", "config.yaml");
|
|
579
|
+
const configDir = path.dirname(configPath);
|
|
580
|
+
if (!fs.existsSync(configDir)) {
|
|
581
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
582
|
+
}
|
|
583
|
+
// Read existing config or start fresh
|
|
584
|
+
let existing = "";
|
|
585
|
+
if (fs.existsSync(configPath)) {
|
|
586
|
+
try {
|
|
587
|
+
existing = fs.readFileSync(configPath, "utf-8");
|
|
588
|
+
}
|
|
589
|
+
catch { /* ignore */ }
|
|
590
|
+
}
|
|
591
|
+
// Build MCP server entry
|
|
592
|
+
const effectiveApiKey = await provisionAgentKey(cfg.podUrl, cfg.apiKey, "codex");
|
|
593
|
+
const podBase = cfg.podUrl.replace(/\/$/, "");
|
|
594
|
+
const mcpUrl = cfg.workspaceId
|
|
595
|
+
? `${podBase}/mcp?workspaceId=${encodeURIComponent(cfg.workspaceId)}`
|
|
596
|
+
: `${podBase}/mcp`;
|
|
597
|
+
// Inject/replace the synap mcpServers block using simple string manipulation
|
|
598
|
+
// (avoid requiring a YAML parser dependency)
|
|
599
|
+
const mcpBlock = [
|
|
600
|
+
"mcpServers:",
|
|
601
|
+
` - name: synap`,
|
|
602
|
+
` url: "${mcpUrl}"`,
|
|
603
|
+
` headers:`,
|
|
604
|
+
` Authorization: "Bearer ${effectiveApiKey}"`,
|
|
605
|
+
].join("\n");
|
|
606
|
+
let updated;
|
|
607
|
+
if (/^mcpServers:/m.test(existing)) {
|
|
608
|
+
// Replace existing mcpServers block (everything from mcpServers: to the next top-level key or EOF)
|
|
609
|
+
updated = existing.replace(/^mcpServers:[\s\S]*?(?=\n[a-zA-Z]|\s*$)/m, mcpBlock);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
updated = existing ? `${existing.trimEnd()}\n\n${mcpBlock}\n` : `${mcpBlock}\n`;
|
|
613
|
+
}
|
|
614
|
+
fs.writeFileSync(configPath, updated, { mode: 0o600 });
|
|
615
|
+
// Skills → append synap context to ~/.codex/instructions.md
|
|
616
|
+
const instructionsPath = path.join(configDir, "instructions.md");
|
|
617
|
+
const marker = "<!-- synap-skill -->";
|
|
618
|
+
let instructions = "";
|
|
619
|
+
if (fs.existsSync(instructionsPath)) {
|
|
620
|
+
try {
|
|
621
|
+
instructions = fs.readFileSync(instructionsPath, "utf-8");
|
|
622
|
+
}
|
|
623
|
+
catch { /* ignore */ }
|
|
624
|
+
}
|
|
625
|
+
if (!instructions.includes(marker)) {
|
|
626
|
+
const snippet = [
|
|
627
|
+
"",
|
|
628
|
+
marker,
|
|
629
|
+
"## Synap pod access",
|
|
630
|
+
`You have access to a Synap data pod via MCP tools (server: synap).`,
|
|
631
|
+
`Pod: ${cfg.podUrl}`,
|
|
632
|
+
cfg.workspaceId ? `Default workspace: ${cfg.workspaceId}` : "Access: pod-wide",
|
|
633
|
+
"Use the synap MCP tools to search, read, and write entities, memory, and documents.",
|
|
634
|
+
"Always call orient (or list workspaces) first to discover workspace IDs.",
|
|
635
|
+
"",
|
|
636
|
+
].join("\n");
|
|
637
|
+
fs.writeFileSync(instructionsPath, (instructions.trimEnd() + snippet).trimStart() + "\n", { mode: 0o600 });
|
|
638
|
+
}
|
|
639
|
+
log.success(`Codex config updated: ${configPath}`);
|
|
640
|
+
if (cfg.workspaceId) {
|
|
641
|
+
log.dim(`Scoped to workspace: ${cfg.workspaceId}`);
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
log.dim("Not scoped — all workspaces accessible.");
|
|
645
|
+
}
|
|
646
|
+
log.blank();
|
|
647
|
+
log.dim("Restart Codex CLI to pick up the new MCP server.");
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
export function writeMcpServerEntry(configPath, serverName, server) {
|
|
651
|
+
const dir = path.dirname(configPath);
|
|
652
|
+
if (!fs.existsSync(dir)) {
|
|
653
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
654
|
+
}
|
|
655
|
+
let config = {};
|
|
656
|
+
if (fs.existsSync(configPath)) {
|
|
657
|
+
try {
|
|
658
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
// corrupt or unreadable — back up and start fresh
|
|
662
|
+
const backup = `${configPath}.bak-${Date.now()}`;
|
|
663
|
+
fs.copyFileSync(configPath, backup);
|
|
664
|
+
log.warn(`Existing config unreadable; backed up to ${path.basename(backup)}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
config.mcpServers = config.mcpServers ?? {};
|
|
668
|
+
config.mcpServers[serverName] = server;
|
|
669
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", {
|
|
670
|
+
mode: 0o600,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
//# sourceMappingURL=targets.js.map
|