forge-memory 0.1.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 ADDED
@@ -0,0 +1,28 @@
1
+ # forge-memory
2
+
3
+ Preferred Forge installer:
4
+
5
+ ```bash
6
+ npx forge-memory
7
+ ```
8
+
9
+ Development install from a Forge checkout:
10
+
11
+ ```bash
12
+ npx forge-memory --dev
13
+ ```
14
+
15
+ This package installs and manages the local Forge UI/runtime, then configures detected host adapters for OpenClaw, Hermes, and Codex. The Forge UI/runtime is always the base install; the adapter checkbox list only contains host integrations.
16
+
17
+ Useful commands:
18
+
19
+ ```bash
20
+ npx forge-memory configure
21
+ npx forge-memory status
22
+ npx forge-memory doctor
23
+ npx forge-memory ui
24
+ npx forge-memory restart
25
+ npx forge-memory pair-ios
26
+ ```
27
+
28
+ `configure` reruns the full guided flow using the current config as defaults.
@@ -0,0 +1,1031 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import fs from "node:fs";
5
+ import fsp from "node:fs/promises";
6
+ import net from "node:net";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import readline from "node:readline";
10
+ import { pathToFileURL } from "node:url";
11
+ import { createRequire } from "node:module";
12
+ import YAML from "yaml";
13
+ import qrcode from "qrcode-terminal";
14
+ import open from "open";
15
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const VERSION = "0.1.0";
21
+ const RUNTIME_PACKAGE = "forge-openclaw-plugin";
22
+ const RUNTIME_PACKAGE_VERSION = "0.2.61";
23
+ const DEFAULT_ORIGIN = "http://127.0.0.1";
24
+ const DEFAULT_PORT = 4317;
25
+ const DEFAULT_WEB_PORT = 3027;
26
+ const FORGE_PLUGIN_ID = "forge-openclaw-plugin";
27
+ const ADAPTERS = ["openclaw", "hermes", "codex"];
28
+
29
+ const color = {
30
+ dim: (value) => `\u001b[2m${value}\u001b[22m`,
31
+ green: (value) => `\u001b[32m${value}\u001b[39m`,
32
+ yellow: (value) => `\u001b[33m${value}\u001b[39m`,
33
+ red: (value) => `\u001b[31m${value}\u001b[39m`,
34
+ cyan: (value) => `\u001b[36m${value}\u001b[39m`,
35
+ bold: (value) => `\u001b[1m${value}\u001b[22m`
36
+ };
37
+
38
+ function parseArgs(argv) {
39
+ const flags = {
40
+ yes: false,
41
+ dev: false,
42
+ dryRun: false,
43
+ noStart: false,
44
+ json: false,
45
+ skipPairIos: false,
46
+ pairIos: false,
47
+ skipAdapters: false,
48
+ printUrl: false
49
+ };
50
+ const values = {};
51
+ const positionals = [];
52
+
53
+ for (let index = 0; index < argv.length; index += 1) {
54
+ const arg = argv[index];
55
+ if (!arg.startsWith("-")) {
56
+ positionals.push(arg);
57
+ continue;
58
+ }
59
+ if (arg === "--yes" || arg === "-y") flags.yes = true;
60
+ else if (arg === "--dev") flags.dev = true;
61
+ else if (arg === "--dry-run") flags.dryRun = true;
62
+ else if (arg === "--no-start") flags.noStart = true;
63
+ else if (arg === "--json") flags.json = true;
64
+ else if (arg === "--skip-pair-ios" || arg === "--no-pair-ios") flags.skipPairIos = true;
65
+ else if (arg === "--pair-ios") flags.pairIos = true;
66
+ else if (arg === "--skip-adapters") flags.skipAdapters = true;
67
+ else if (arg === "--print-url") flags.printUrl = true;
68
+ else if (arg.startsWith("--data-root=")) values.dataRoot = arg.slice("--data-root=".length);
69
+ else if (arg === "--data-root") values.dataRoot = argv[++index];
70
+ else if (arg.startsWith("--adapters=")) values.adapters = arg.slice("--adapters=".length);
71
+ else if (arg === "--adapters") values.adapters = argv[++index];
72
+ else if (arg.startsWith("--origin=")) values.origin = arg.slice("--origin=".length);
73
+ else if (arg === "--origin") values.origin = argv[++index];
74
+ else if (arg.startsWith("--port=")) values.port = arg.slice("--port=".length);
75
+ else if (arg === "--port") values.port = argv[++index];
76
+ else if (arg.startsWith("--web-port=")) values.webPort = arg.slice("--web-port=".length);
77
+ else if (arg === "--web-port") values.webPort = argv[++index];
78
+ else if (arg.startsWith("--repo=")) values.repo = arg.slice("--repo=".length);
79
+ else if (arg === "--repo") values.repo = argv[++index];
80
+ else if (arg === "--help" || arg === "-h") flags.help = true;
81
+ else if (arg === "--version" || arg === "-v") flags.version = true;
82
+ else throw new Error(`Unknown option: ${arg}`);
83
+ }
84
+
85
+ return {
86
+ command: positionals[0] ?? "install",
87
+ positionals,
88
+ flags,
89
+ values
90
+ };
91
+ }
92
+
93
+ function homeDir() {
94
+ return os.homedir();
95
+ }
96
+
97
+ function forgeHome() {
98
+ return path.join(homeDir(), ".forge");
99
+ }
100
+
101
+ function configPath() {
102
+ return path.join(forgeHome(), "config.json");
103
+ }
104
+
105
+ function runtimeStatePath() {
106
+ return path.join(forgeHome(), "run", "forge-memory-runtime.json");
107
+ }
108
+
109
+ function logPath() {
110
+ return path.join(forgeHome(), "logs", "forge-memory-runtime.log");
111
+ }
112
+
113
+ function runtimeInstallRoot() {
114
+ return path.join(forgeHome(), "runtime");
115
+ }
116
+
117
+ function defaultDataRoot() {
118
+ return forgeHome();
119
+ }
120
+
121
+ function normalizePort(value, fallback = DEFAULT_PORT) {
122
+ const parsed = Number(value);
123
+ return Number.isInteger(parsed) && parsed >= 0 && parsed <= 65535 ? parsed : fallback;
124
+ }
125
+
126
+ function normalizeAdapterList(value) {
127
+ if (!value || value.trim().toLowerCase() === "detected") return null;
128
+ if (value.trim().toLowerCase() === "none") return [];
129
+ return value
130
+ .split(",")
131
+ .map((entry) => entry.trim().toLowerCase())
132
+ .filter(Boolean)
133
+ .filter((entry) => ADAPTERS.includes(entry));
134
+ }
135
+
136
+ function baseUrl(config) {
137
+ const url = new URL(config.origin || DEFAULT_ORIGIN);
138
+ url.port = String(config.port || DEFAULT_PORT);
139
+ url.pathname = "/";
140
+ url.search = "";
141
+ url.hash = "";
142
+ return url.origin;
143
+ }
144
+
145
+ function webUrl(config) {
146
+ return `${baseUrl(config)}/forge/`;
147
+ }
148
+
149
+ async function readJson(filePath, fallback = null) {
150
+ try {
151
+ return JSON.parse(await fsp.readFile(filePath, "utf8"));
152
+ } catch {
153
+ return fallback;
154
+ }
155
+ }
156
+
157
+ async function backupIfExists(filePath) {
158
+ if (!fs.existsSync(filePath)) return null;
159
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
160
+ const backupPath = `${filePath}.bak-forge-memory-${stamp}`;
161
+ await fsp.copyFile(filePath, backupPath);
162
+ return backupPath;
163
+ }
164
+
165
+ async function writeJson(filePath, payload, { dryRun = false, backup = true } = {}) {
166
+ if (dryRun) return { filePath, backupPath: null, dryRun: true };
167
+ await fsp.mkdir(path.dirname(filePath), { recursive: true });
168
+ const backupPath = backup ? await backupIfExists(filePath) : null;
169
+ await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
170
+ return { filePath, backupPath, dryRun: false };
171
+ }
172
+
173
+ async function readConfig() {
174
+ const config = await readJson(configPath(), {});
175
+ return {
176
+ version: VERSION,
177
+ mode: config?.mode === "dev" ? "dev" : "packaged",
178
+ origin: typeof config?.origin === "string" ? config.origin : DEFAULT_ORIGIN,
179
+ port: normalizePort(config?.port, DEFAULT_PORT),
180
+ webPort: normalizePort(config?.webPort, DEFAULT_WEB_PORT),
181
+ dataRoot: typeof config?.dataRoot === "string" ? path.resolve(config.dataRoot) : defaultDataRoot(),
182
+ adapters: Array.isArray(config?.adapters) ? config.adapters.filter((entry) => ADAPTERS.includes(entry)) : [],
183
+ updatedAt: typeof config?.updatedAt === "string" ? config.updatedAt : null,
184
+ repo: typeof config?.repo === "string" ? config.repo : null
185
+ };
186
+ }
187
+
188
+ async function writeConfig(next, options) {
189
+ const payload = {
190
+ version: VERSION,
191
+ mode: next.mode,
192
+ origin: next.origin,
193
+ port: next.port,
194
+ webPort: next.webPort,
195
+ dataRoot: path.resolve(next.dataRoot),
196
+ adapters: next.adapters,
197
+ repo: next.repo ?? null,
198
+ updatedAt: new Date().toISOString()
199
+ };
200
+ return writeJson(configPath(), payload, options);
201
+ }
202
+
203
+ function commandExists(command) {
204
+ const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
205
+ shell: process.platform !== "win32",
206
+ stdio: "ignore"
207
+ });
208
+ return result.status === 0;
209
+ }
210
+
211
+ function runCapture(command, args, timeoutMs = 2_000) {
212
+ const result = spawnSync(command, args, {
213
+ encoding: "utf8",
214
+ timeout: timeoutMs
215
+ });
216
+ if (result.error || result.status !== 0) return null;
217
+ return `${result.stdout}${result.stderr}`.trim();
218
+ }
219
+
220
+ function detectOpenClaw() {
221
+ const installed = commandExists("openclaw") || fs.existsSync(path.join(homeDir(), ".openclaw"));
222
+ const version = commandExists("openclaw") ? runCapture("openclaw", ["--version"]) : null;
223
+ const config = path.join(homeDir(), ".openclaw", "openclaw.json");
224
+ return {
225
+ id: "openclaw",
226
+ label: "OpenClaw",
227
+ installed,
228
+ disabled: !installed,
229
+ status: installed ? (version || "detected") : "not found",
230
+ configPath: config,
231
+ hint: "Install OpenClaw first, then rerun npx forge-memory configure."
232
+ };
233
+ }
234
+
235
+ function detectHermes() {
236
+ const hermesRoot = path.join(homeDir(), ".hermes");
237
+ const hermesPython = path.join(hermesRoot, "hermes-agent", "venv", "bin", "python");
238
+ const installed = commandExists("hermes") || fs.existsSync(hermesPython) || fs.existsSync(hermesRoot);
239
+ const version = commandExists("hermes") ? runCapture("hermes", ["--version"]) : null;
240
+ return {
241
+ id: "hermes",
242
+ label: "Hermes",
243
+ installed,
244
+ disabled: !installed,
245
+ status: installed ? (version || "detected") : "not found",
246
+ configPath: path.join(hermesRoot, "forge", "config.json"),
247
+ pythonPath: hermesPython,
248
+ hint: "Install Hermes first, then rerun npx forge-memory configure."
249
+ };
250
+ }
251
+
252
+ function detectCodex() {
253
+ const codexRoot = path.join(homeDir(), ".codex");
254
+ const installed = commandExists("codex") || fs.existsSync(codexRoot);
255
+ const version = commandExists("codex") ? runCapture("codex", ["--version"]) : null;
256
+ return {
257
+ id: "codex",
258
+ label: "Codex",
259
+ installed,
260
+ disabled: !installed,
261
+ status: installed ? (version || "detected") : "not found",
262
+ configPath: path.join(codexRoot, "config.toml"),
263
+ hint: "Install Codex first, then rerun npx forge-memory configure."
264
+ };
265
+ }
266
+
267
+ function discover() {
268
+ return {
269
+ generatedAt: new Date().toISOString(),
270
+ adapters: [detectOpenClaw(), detectHermes(), detectCodex()]
271
+ };
272
+ }
273
+
274
+ function printBanner() {
275
+ console.log(color.bold("Forge Memory"));
276
+ console.log(color.dim(`Guided Forge installer ${VERSION}`));
277
+ console.log("");
278
+ }
279
+
280
+ async function promptLine(question, defaultValue) {
281
+ const suffix = defaultValue ? ` ${color.dim(`[${defaultValue}]`)}` : "";
282
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
283
+ return await new Promise((resolve) => {
284
+ rl.question(`${question}${suffix}: `, (answer) => {
285
+ rl.close();
286
+ resolve(answer.trim() || defaultValue || "");
287
+ });
288
+ });
289
+ }
290
+
291
+ async function promptYesNo(question, defaultValue = true) {
292
+ const answer = (await promptLine(`${question} ${defaultValue ? "[Y/n]" : "[y/N]"}`, "")).toLowerCase();
293
+ if (!answer) return defaultValue;
294
+ return answer === "y" || answer === "yes";
295
+ }
296
+
297
+ async function promptCheckbox(adapters, defaults) {
298
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
299
+ return defaults;
300
+ }
301
+ const rows = [
302
+ ...adapters.map((adapter) => ({
303
+ ...adapter,
304
+ selected: defaults.includes(adapter.id) && !adapter.disabled,
305
+ action: false
306
+ })),
307
+ {
308
+ id: "__skip",
309
+ label: "Skip adapter configuration",
310
+ installed: true,
311
+ disabled: false,
312
+ status: "configure later with npx forge-memory configure",
313
+ selected: false,
314
+ action: true
315
+ }
316
+ ];
317
+ let cursor = 0;
318
+
319
+ const render = () => {
320
+ process.stdout.write("\u001b[?25l");
321
+ process.stdout.write("\u001b[2J\u001b[H");
322
+ printBanner();
323
+ console.log("Select host adapters. Space toggles, arrows move, Enter confirms.\n");
324
+ for (let index = 0; index < rows.length; index += 1) {
325
+ const row = rows[index];
326
+ const prefix = index === cursor ? color.cyan(">") : " ";
327
+ const marker = row.action ? " " : row.selected ? "x" : " ";
328
+ const disabled = row.disabled ? color.dim(" disabled") : "";
329
+ const line = `${prefix} [${marker}] ${row.label} ${color.dim(`(${row.status})`)}${disabled}`;
330
+ console.log(row.disabled ? color.dim(line) : line);
331
+ if (row.disabled) console.log(color.dim(` ${row.hint}`));
332
+ }
333
+ };
334
+
335
+ return await new Promise((resolve) => {
336
+ const onData = (chunk) => {
337
+ const key = chunk.toString("utf8");
338
+ if (key === "\u0003") {
339
+ cleanup();
340
+ process.exit(130);
341
+ }
342
+ if (key === "\r" || key === "\n") {
343
+ const row = rows[cursor];
344
+ cleanup();
345
+ if (row?.id === "__skip") {
346
+ resolve([]);
347
+ return;
348
+ }
349
+ resolve(rows.filter((entry) => entry.selected && !entry.disabled && !entry.action).map((entry) => entry.id));
350
+ return;
351
+ }
352
+ if (key === " ") {
353
+ const row = rows[cursor];
354
+ if (row && !row.disabled && !row.action) row.selected = !row.selected;
355
+ render();
356
+ return;
357
+ }
358
+ if (key === "\u001b[A") {
359
+ cursor = Math.max(0, cursor - 1);
360
+ render();
361
+ return;
362
+ }
363
+ if (key === "\u001b[B") {
364
+ cursor = Math.min(rows.length - 1, cursor + 1);
365
+ render();
366
+ }
367
+ };
368
+ const cleanup = () => {
369
+ process.stdin.setRawMode(false);
370
+ process.stdin.off("data", onData);
371
+ process.stdout.write("\u001b[?25h");
372
+ process.stdout.write("\n");
373
+ };
374
+ process.stdin.setRawMode(true);
375
+ process.stdin.resume();
376
+ process.stdin.on("data", onData);
377
+ render();
378
+ });
379
+ }
380
+
381
+ async function isPortAvailable(port) {
382
+ return await new Promise((resolve) => {
383
+ const server = net.createServer();
384
+ server.once("error", () => resolve(false));
385
+ server.listen({ host: "127.0.0.1", port, exclusive: true }, () => {
386
+ server.close(() => resolve(true));
387
+ });
388
+ });
389
+ }
390
+
391
+ async function findFreePort(startPort) {
392
+ if (startPort === 0) {
393
+ return await new Promise((resolve, reject) => {
394
+ const server = net.createServer();
395
+ server.once("error", reject);
396
+ server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => {
397
+ const address = server.address();
398
+ const port = typeof address === "object" && address ? address.port : DEFAULT_PORT;
399
+ server.close(() => resolve(port));
400
+ });
401
+ });
402
+ }
403
+ for (let port = startPort; port < startPort + 30 && port <= 65535; port += 1) {
404
+ if (await isPortAvailable(port)) return port;
405
+ }
406
+ throw new Error(`No free localhost port found near ${startPort}`);
407
+ }
408
+
409
+ async function resolveDevDataRoot(repoRoot) {
410
+ const preferencePath = path.resolve(repoRoot, "..", "..", "data", "forge-runtime.json");
411
+ const monorepoDataRoot = path.resolve(repoRoot, "..", "..", "data", "forge");
412
+ const preference = await readJson(preferencePath, null);
413
+ if (typeof preference?.dataRoot === "string" && preference.dataRoot.trim()) {
414
+ return path.resolve(preference.dataRoot);
415
+ }
416
+ if (fs.existsSync(monorepoDataRoot)) return monorepoDataRoot;
417
+ return defaultDataRoot();
418
+ }
419
+
420
+ function findForgeRepo(start = process.cwd()) {
421
+ let current = path.resolve(start);
422
+ while (true) {
423
+ const packageJsonPath = path.join(current, "package.json");
424
+ if (fs.existsSync(packageJsonPath)) {
425
+ try {
426
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
427
+ if (parsed?.name === "forge" && fs.existsSync(path.join(current, "server", "src", "index.ts"))) {
428
+ return current;
429
+ }
430
+ } catch {
431
+ // keep walking
432
+ }
433
+ }
434
+ const parent = path.dirname(current);
435
+ if (parent === current) return null;
436
+ current = parent;
437
+ }
438
+ }
439
+
440
+ async function buildInstallConfig(parsed, currentConfig, discovery, command) {
441
+ const repo = parsed.values.repo ? path.resolve(parsed.values.repo) : findForgeRepo();
442
+ const mode = parsed.flags.dev ? "dev" : currentConfig.mode;
443
+ const detectedDefaults = discovery.adapters.filter((adapter) => adapter.installed).map((adapter) => adapter.id);
444
+ const currentDefaults = currentConfig.adapters.length > 0 ? currentConfig.adapters : detectedDefaults;
445
+ const adapterOverride = parsed.flags.skipAdapters ? [] : normalizeAdapterList(parsed.values.adapters);
446
+ const adapters = adapterOverride ?? (parsed.flags.yes ? currentDefaults : await promptCheckbox(discovery.adapters, currentDefaults));
447
+ const dataRootDefault =
448
+ parsed.values.dataRoot ??
449
+ (parsed.flags.dev && repo ? await resolveDevDataRoot(repo) : currentConfig.dataRoot || defaultDataRoot());
450
+ const dataRoot = parsed.flags.yes
451
+ ? dataRootDefault
452
+ : await promptLine("Forge data folder", dataRootDefault);
453
+ const portInput = parsed.values.port ?? currentConfig.port;
454
+ const port = await findFreePort(normalizePort(portInput, DEFAULT_PORT));
455
+ const webPort = await findFreePort(normalizePort(parsed.values.webPort ?? currentConfig.webPort, DEFAULT_WEB_PORT));
456
+
457
+ return {
458
+ version: VERSION,
459
+ mode: parsed.flags.dev ? "dev" : mode,
460
+ origin: parsed.values.origin ?? currentConfig.origin ?? DEFAULT_ORIGIN,
461
+ port,
462
+ webPort,
463
+ dataRoot: path.resolve(dataRoot),
464
+ adapters,
465
+ repo,
466
+ command
467
+ };
468
+ }
469
+
470
+ async function patchOpenClawConfig(config, options) {
471
+ const filePath = path.join(homeDir(), ".openclaw", "openclaw.json");
472
+ const payload = (await readJson(filePath, {})) ?? {};
473
+ const plugins = payload.plugins && typeof payload.plugins === "object" ? { ...payload.plugins } : {};
474
+ const entries = plugins.entries && typeof plugins.entries === "object" ? { ...plugins.entries } : {};
475
+ const currentEntry = entries[FORGE_PLUGIN_ID] && typeof entries[FORGE_PLUGIN_ID] === "object" ? { ...entries[FORGE_PLUGIN_ID] } : {};
476
+ const currentPluginConfig = currentEntry.config && typeof currentEntry.config === "object" ? { ...currentEntry.config } : {};
477
+ currentEntry.enabled = true;
478
+ currentEntry.config = {
479
+ ...currentPluginConfig,
480
+ origin: config.origin,
481
+ port: config.port,
482
+ dataRoot: config.dataRoot
483
+ };
484
+ entries[FORGE_PLUGIN_ID] = currentEntry;
485
+ plugins.entries = entries;
486
+ const next = { ...payload, plugins };
487
+ return writeJson(filePath, next, options);
488
+ }
489
+
490
+ async function patchHermesConfig(config, options) {
491
+ const forgeConfigPath = path.join(homeDir(), ".hermes", "forge", "config.json");
492
+ await writeJson(
493
+ forgeConfigPath,
494
+ {
495
+ origin: config.origin,
496
+ port: config.port,
497
+ dataRoot: config.dataRoot,
498
+ actorLabel: "",
499
+ updatedAt: new Date().toISOString()
500
+ },
501
+ options
502
+ );
503
+
504
+ const hermesYamlPath = path.join(homeDir(), ".hermes", "config.yaml");
505
+ if (!fs.existsSync(hermesYamlPath)) return { filePath: forgeConfigPath };
506
+ const raw = await fsp.readFile(hermesYamlPath, "utf8");
507
+ const doc = YAML.parseDocument(raw);
508
+ const root = doc.toJSON() ?? {};
509
+ if (!root.plugins || typeof root.plugins !== "object") root.plugins = {};
510
+ if (!Array.isArray(root.plugins.enabled)) root.plugins.enabled = [];
511
+ if (!root.plugins.enabled.includes("forge")) root.plugins.enabled.push("forge");
512
+ doc.contents = doc.createNode(root);
513
+ if (!options.dryRun) {
514
+ await backupIfExists(hermesYamlPath);
515
+ await fsp.writeFile(hermesYamlPath, String(doc), "utf8");
516
+ }
517
+ return { filePath: hermesYamlPath };
518
+ }
519
+
520
+ async function patchCodexConfig(config, options) {
521
+ const filePath = path.join(homeDir(), ".codex", "config.toml");
522
+ let source = fs.existsSync(filePath) ? await fsp.readFile(filePath, "utf8") : "";
523
+ const block = [
524
+ "[mcp_servers.forge]",
525
+ 'command = "npx"',
526
+ 'args = ["forge-memory", "mcp"]',
527
+ "",
528
+ "[mcp_servers.forge.env]",
529
+ `FORGE_ORIGIN = "${config.origin}"`,
530
+ `FORGE_PORT = "${config.port}"`,
531
+ 'FORGE_ACTOR_LABEL = "codex"',
532
+ 'FORGE_TIMEOUT_MS = "15000"',
533
+ `FORGE_DATA_ROOT = "${config.dataRoot.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`,
534
+ ""
535
+ ].join("\n");
536
+ const pattern = /(?:^|\n)\[mcp_servers\.forge\][\s\S]*?(?=\n\[[^\]]+\]|\s*$)/m;
537
+ if (pattern.test(source)) {
538
+ source = source.replace(pattern, `\n${block}`.trimEnd());
539
+ } else {
540
+ source = `${source.trimEnd()}\n\n${block}`.trimStart();
541
+ }
542
+ if (!options.dryRun) {
543
+ await fsp.mkdir(path.dirname(filePath), { recursive: true });
544
+ await backupIfExists(filePath);
545
+ await fsp.writeFile(filePath, source.endsWith("\n") ? source : `${source}\n`, "utf8");
546
+ }
547
+ return { filePath };
548
+ }
549
+
550
+ async function runCommand(command, args, { cwd, dryRun = false, env = process.env } = {}) {
551
+ if (dryRun) {
552
+ return { ok: true, dryRun: true, command, args, cwd };
553
+ }
554
+ return await new Promise((resolve) => {
555
+ const child = spawn(command, args, { cwd, env, stdio: "inherit" });
556
+ child.once("error", (error) => resolve({ ok: false, error }));
557
+ child.once("exit", (code) => resolve({ ok: code === 0, code }));
558
+ });
559
+ }
560
+
561
+ async function installOpenClawAdapter(config, options) {
562
+ await patchOpenClawConfig(config, options);
563
+ if (!commandExists("openclaw")) {
564
+ return { adapter: "openclaw", ok: false, skipped: true, message: "openclaw command not found" };
565
+ }
566
+ const installTarget = config.mode === "dev" && config.repo ? path.join(config.repo, "openclaw-plugin") : FORGE_PLUGIN_ID;
567
+ const installArgs = config.mode === "dev"
568
+ ? ["plugins", "install", "--link", "--dangerously-force-unsafe-install", installTarget]
569
+ : ["plugins", "install", "--dangerously-force-unsafe-install", installTarget];
570
+ const installResult = await runCommand("openclaw", installArgs, options);
571
+ if (!installResult.ok) return { adapter: "openclaw", ok: false, message: "OpenClaw plugin install failed" };
572
+ await runCommand("openclaw", ["plugins", "enable", FORGE_PLUGIN_ID], options);
573
+ await runCommand("openclaw", ["gateway", "restart"], options);
574
+ return { adapter: "openclaw", ok: true };
575
+ }
576
+
577
+ async function installHermesAdapter(config, options) {
578
+ await patchHermesConfig(config, options);
579
+ const pythonPath = path.join(homeDir(), ".hermes", "hermes-agent", "venv", "bin", "python");
580
+ if (!fs.existsSync(pythonPath)) {
581
+ return { adapter: "hermes", ok: false, skipped: true, message: "Hermes Python environment not found" };
582
+ }
583
+ const target = config.mode === "dev" && config.repo
584
+ ? ["-m", "pip", "install", "--upgrade", "-e", path.join(config.repo, "plugins", "forge-hermes")]
585
+ : ["-m", "pip", "install", "--upgrade", "forge-hermes-plugin"];
586
+ const result = await runCommand(pythonPath, target, options);
587
+ return { adapter: "hermes", ok: result.ok, message: result.ok ? undefined : "Hermes plugin install failed" };
588
+ }
589
+
590
+ async function installCodexAdapter(config, options) {
591
+ await patchCodexConfig(config, options);
592
+ return { adapter: "codex", ok: true };
593
+ }
594
+
595
+ async function configureAdapters(config, options) {
596
+ const results = [];
597
+ for (const adapter of config.adapters) {
598
+ if (adapter === "openclaw") results.push(await installOpenClawAdapter(config, options));
599
+ if (adapter === "hermes") results.push(await installHermesAdapter(config, options));
600
+ if (adapter === "codex") results.push(await installCodexAdapter(config, options));
601
+ }
602
+ return results;
603
+ }
604
+
605
+ async function health(config, timeoutMs = 1_500) {
606
+ const controller = new AbortController();
607
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
608
+ try {
609
+ const response = await fetch(new URL("/api/v1/health", baseUrl(config)), {
610
+ headers: { accept: "application/json" },
611
+ signal: controller.signal
612
+ });
613
+ if (!response.ok) return { ok: false, status: response.status };
614
+ return { ok: true, payload: await response.json() };
615
+ } catch (error) {
616
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
617
+ } finally {
618
+ clearTimeout(timeout);
619
+ }
620
+ }
621
+
622
+ async function readRuntimeState() {
623
+ return readJson(runtimeStatePath(), null);
624
+ }
625
+
626
+ function processExists(pid) {
627
+ try {
628
+ process.kill(pid, 0);
629
+ return true;
630
+ } catch {
631
+ return false;
632
+ }
633
+ }
634
+
635
+ async function waitForHealth(config, timeoutMs = 30_000) {
636
+ const deadline = Date.now() + timeoutMs;
637
+ while (Date.now() < deadline) {
638
+ const result = await health(config);
639
+ if (result.ok) return result;
640
+ await new Promise((resolve) => setTimeout(resolve, 500));
641
+ }
642
+ return health(config);
643
+ }
644
+
645
+ function resolveOpenClawPluginRoot() {
646
+ const candidates = [require];
647
+ const installedRuntimePackageJson = path.join(runtimeInstallRoot(), "package.json");
648
+ if (fs.existsSync(installedRuntimePackageJson)) {
649
+ candidates.push(createRequire(installedRuntimePackageJson));
650
+ }
651
+
652
+ for (const candidateRequire of candidates) {
653
+ try {
654
+ const entry = candidateRequire.resolve(RUNTIME_PACKAGE);
655
+ const marker = `${path.sep}dist${path.sep}openclaw${path.sep}`;
656
+ const markerIndex = entry.indexOf(marker);
657
+ if (markerIndex > 0) return entry.slice(0, markerIndex);
658
+ return path.resolve(path.dirname(entry), "..", "..");
659
+ } catch {
660
+ // Try the next resolver.
661
+ }
662
+ }
663
+ return null;
664
+ }
665
+
666
+ async function ensurePackagedRuntimeInstalled() {
667
+ const existing = resolveOpenClawPluginRoot();
668
+ if (existing) return existing;
669
+ const installRoot = runtimeInstallRoot();
670
+ await fsp.mkdir(installRoot, { recursive: true });
671
+ const packageJsonPath = path.join(installRoot, "package.json");
672
+ if (!fs.existsSync(packageJsonPath)) {
673
+ await fsp.writeFile(
674
+ packageJsonPath,
675
+ `${JSON.stringify({ name: "forge-memory-runtime", private: true, type: "module" }, null, 2)}\n`,
676
+ "utf8"
677
+ );
678
+ }
679
+ await fsp.mkdir(path.dirname(logPath()), { recursive: true });
680
+ const out = fs.openSync(logPath(), "a");
681
+ try {
682
+ const result = spawnSync(
683
+ "npm",
684
+ ["install", `${RUNTIME_PACKAGE}@${RUNTIME_PACKAGE_VERSION}`, "--omit=dev", "--ignore-scripts", "--silent"],
685
+ {
686
+ cwd: installRoot,
687
+ stdio: ["ignore", out, out],
688
+ env: process.env
689
+ }
690
+ );
691
+ if (result.status !== 0) {
692
+ throw new Error(`Failed to install ${RUNTIME_PACKAGE}@${RUNTIME_PACKAGE_VERSION}. Check ${logPath()}.`);
693
+ }
694
+ } finally {
695
+ fs.closeSync(out);
696
+ }
697
+ const installed = resolveOpenClawPluginRoot();
698
+ if (!installed) throw new Error(`${RUNTIME_PACKAGE} installed but its runtime entry could not be resolved.`);
699
+ return installed;
700
+ }
701
+
702
+ async function startRuntime(config) {
703
+ const existing = await readRuntimeState();
704
+ if (existing?.pid && processExists(existing.pid)) {
705
+ const current = await health(config);
706
+ if (current.ok) return { ok: true, started: false, state: existing };
707
+ }
708
+
709
+ await fsp.mkdir(path.dirname(logPath()), { recursive: true });
710
+ await fsp.mkdir(path.dirname(runtimeStatePath()), { recursive: true });
711
+ await fsp.mkdir(config.dataRoot, { recursive: true });
712
+ const out = fs.openSync(logPath(), "a");
713
+ const children = [];
714
+
715
+ if (config.mode === "dev") {
716
+ if (!config.repo) throw new Error("Dev mode requires a Forge repo checkout.");
717
+ const tsx = path.join(config.repo, "node_modules", "tsx", "dist", "cli.mjs");
718
+ if (!fs.existsSync(tsx)) throw new Error(`tsx was not found at ${tsx}. Run npm install in the Forge repo.`);
719
+ const server = spawn(process.execPath, [tsx, path.join(config.repo, "server", "src", "index.ts")], {
720
+ cwd: config.repo,
721
+ detached: true,
722
+ stdio: ["ignore", out, out],
723
+ env: {
724
+ ...process.env,
725
+ HOST: "127.0.0.1",
726
+ PORT: String(config.port),
727
+ FORGE_BASE_PATH: "/forge/",
728
+ FORGE_DATA_ROOT: config.dataRoot,
729
+ FORGE_DEV_WEB_ORIGIN: `http://127.0.0.1:${config.webPort}/forge/`
730
+ }
731
+ });
732
+ server.unref();
733
+ children.push({ role: "server", pid: server.pid });
734
+ const web = spawn("npm", ["run", "dev:web", "--", "--host", "127.0.0.1", "--port", String(config.webPort)], {
735
+ cwd: config.repo,
736
+ detached: true,
737
+ stdio: ["ignore", out, out],
738
+ env: { ...process.env, FORGE_BASE_PATH: "/forge/" }
739
+ });
740
+ web.unref();
741
+ children.push({ role: "web", pid: web.pid });
742
+ } else {
743
+ const pluginRoot = await ensurePackagedRuntimeInstalled();
744
+ const entry = path.join(pluginRoot, "server", "index.js");
745
+ const child = spawn(process.execPath, [entry], {
746
+ cwd: pluginRoot,
747
+ detached: true,
748
+ stdio: ["ignore", out, out],
749
+ env: {
750
+ ...process.env,
751
+ HOST: "127.0.0.1",
752
+ PORT: String(config.port),
753
+ FORGE_BASE_PATH: "/forge/",
754
+ FORGE_DATA_ROOT: config.dataRoot
755
+ }
756
+ });
757
+ child.unref();
758
+ children.push({ role: "server", pid: child.pid });
759
+ }
760
+ fs.closeSync(out);
761
+
762
+ const state = {
763
+ mode: config.mode,
764
+ baseUrl: baseUrl(config),
765
+ webUrl: webUrl(config),
766
+ dataRoot: config.dataRoot,
767
+ logPath: logPath(),
768
+ children,
769
+ startedAt: new Date().toISOString()
770
+ };
771
+ await writeJson(runtimeStatePath(), state, { backup: false });
772
+ const result = await waitForHealth(config);
773
+ return { ok: result.ok, started: true, state, health: result };
774
+ }
775
+
776
+ async function stopRuntime() {
777
+ const state = await readRuntimeState();
778
+ if (!state?.children?.length) return { ok: true, stopped: false, message: "No forge-memory runtime state found." };
779
+ const stopped = [];
780
+ for (const child of state.children) {
781
+ if (!child?.pid || !processExists(child.pid)) continue;
782
+ process.kill(child.pid, "SIGTERM");
783
+ stopped.push(child.pid);
784
+ }
785
+ await fsp.rm(runtimeStatePath(), { force: true });
786
+ return { ok: true, stopped: stopped.length > 0, pids: stopped };
787
+ }
788
+
789
+ async function createPairing(config) {
790
+ const response = await fetch(new URL("/api/v1/health/pairing-sessions", baseUrl(config)), {
791
+ method: "POST",
792
+ headers: { "content-type": "application/json", accept: "application/json" },
793
+ body: JSON.stringify({ userId: null })
794
+ });
795
+ if (!response.ok) throw new Error(`Pairing request failed with ${response.status}`);
796
+ return response.json();
797
+ }
798
+
799
+ async function runInstall(parsed, command) {
800
+ const currentConfig = await readConfig();
801
+ const discovery = discover();
802
+ if (!parsed.flags.yes) {
803
+ printBanner();
804
+ console.log(color.dim("Discovery runs in the background. Forge UI/runtime is always installed.\n"));
805
+ }
806
+ const config = await buildInstallConfig(parsed, currentConfig, discovery, command);
807
+ const writeResult = await writeConfig(config, { dryRun: parsed.flags.dryRun });
808
+ const adapterResults = await configureAdapters(config, { dryRun: parsed.flags.dryRun });
809
+ let runtimeResult = null;
810
+ if (!parsed.flags.noStart && !parsed.flags.dryRun) {
811
+ runtimeResult = await startRuntime(config);
812
+ }
813
+ const shouldPair = parsed.flags.pairIos || (!parsed.flags.skipPairIos && (parsed.flags.yes ? true : await promptYesNo("Pair the iOS companion now?", true)));
814
+ let pairing = null;
815
+ if (shouldPair && !parsed.flags.dryRun) {
816
+ if (!runtimeResult) await startRuntime(config);
817
+ pairing = await createPairing(config);
818
+ if (pairing?.qrPayload && !parsed.flags.json) {
819
+ console.log("\nScan this QR in Forge Companion:\n");
820
+ qrcode.generate(JSON.stringify(pairing.qrPayload), { small: true });
821
+ console.log(JSON.stringify(pairing.qrPayload, null, 2));
822
+ }
823
+ }
824
+ const summary = { ok: true, config, writeResult, adapterResults, runtimeResult, pairing: Boolean(pairing) };
825
+ if (parsed.flags.json) console.log(JSON.stringify(summary, null, 2));
826
+ else {
827
+ console.log(color.green("Forge Memory configured."));
828
+ console.log(`UI: ${webUrl(config)}`);
829
+ console.log(`Data: ${config.dataRoot}`);
830
+ if (parsed.flags.dryRun) console.log(color.yellow("Dry run only; no files or adapter installs were changed."));
831
+ }
832
+ }
833
+
834
+ async function runStatus(parsed) {
835
+ const config = await readConfig();
836
+ const state = await readRuntimeState();
837
+ const currentHealth = await health(config);
838
+ const payload = {
839
+ ok: currentHealth.ok,
840
+ running: currentHealth.ok,
841
+ mode: config.mode,
842
+ baseUrl: baseUrl(config),
843
+ webUrl: webUrl(config),
844
+ dataRoot: config.dataRoot,
845
+ adapters: config.adapters,
846
+ state
847
+ };
848
+ if (parsed.flags.json) console.log(JSON.stringify(payload, null, 2));
849
+ else {
850
+ console.log(`${color.bold("Forge Memory Status")}`);
851
+ console.log(`Runtime: ${currentHealth.ok ? color.green("healthy") : color.yellow("not reachable")}`);
852
+ console.log(`Mode: ${config.mode}`);
853
+ console.log(`UI: ${webUrl(config)}`);
854
+ console.log(`Data: ${config.dataRoot}`);
855
+ console.log(`Adapters: ${config.adapters.length ? config.adapters.join(", ") : "none configured"}`);
856
+ if (state?.logPath) console.log(`Logs: ${state.logPath}`);
857
+ }
858
+ }
859
+
860
+ async function runDoctor(parsed) {
861
+ const config = await readConfig();
862
+ const discovery = discover();
863
+ const checks = [
864
+ { id: "node", ok: Number(process.versions.node.split(".")[0]) >= 22, detail: process.versions.node },
865
+ { id: "config", ok: fs.existsSync(configPath()), detail: configPath() },
866
+ { id: "dataRoot", ok: fs.existsSync(config.dataRoot), detail: config.dataRoot },
867
+ { id: "runtime", ok: (await health(config)).ok, detail: baseUrl(config) },
868
+ ...discovery.adapters.map((adapter) => ({ id: adapter.id, ok: adapter.installed, detail: adapter.status }))
869
+ ];
870
+ const payload = { ok: checks.every((check) => check.ok || ADAPTERS.includes(check.id)), checks };
871
+ if (parsed.flags.json) console.log(JSON.stringify(payload, null, 2));
872
+ else {
873
+ console.log(color.bold("Forge Memory Doctor"));
874
+ for (const check of checks) {
875
+ console.log(`${check.ok ? color.green("ok") : color.yellow("warn")} ${check.id}: ${check.detail}`);
876
+ }
877
+ }
878
+ }
879
+
880
+ async function runUi(parsed) {
881
+ const config = await readConfig();
882
+ if (!parsed.flags.noStart) await startRuntime(config);
883
+ if (parsed.flags.printUrl || parsed.flags.json) {
884
+ console.log(parsed.flags.json ? JSON.stringify({ url: webUrl(config) }, null, 2) : webUrl(config));
885
+ return;
886
+ }
887
+ await open(webUrl(config));
888
+ }
889
+
890
+ async function runPairIos(parsed) {
891
+ const config = await readConfig();
892
+ await startRuntime(config);
893
+ const pairing = await createPairing(config);
894
+ if (parsed.flags.json) {
895
+ console.log(JSON.stringify(pairing, null, 2));
896
+ return;
897
+ }
898
+ qrcode.generate(JSON.stringify(pairing.qrPayload), { small: true });
899
+ console.log(JSON.stringify(pairing.qrPayload, null, 2));
900
+ }
901
+
902
+ async function runLogs() {
903
+ if (!fs.existsSync(logPath())) {
904
+ console.log("No forge-memory runtime log found.");
905
+ return;
906
+ }
907
+ const source = await fsp.readFile(logPath(), "utf8");
908
+ console.log(source.split("\n").slice(-120).join("\n"));
909
+ }
910
+
911
+ function sha(input) {
912
+ return createHash("sha1").update(input).digest("hex").slice(0, 12);
913
+ }
914
+
915
+ async function runMcp() {
916
+ const config = await readConfig();
917
+ const server = new Server({ name: "forge-memory", version: VERSION }, { capabilities: { tools: {} } });
918
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
919
+ tools: [
920
+ {
921
+ name: "forge_memory_status",
922
+ description: "Return local Forge Memory runtime status.",
923
+ inputSchema: { type: "object", properties: {} }
924
+ },
925
+ {
926
+ name: "forge_memory_health",
927
+ description: "Check the configured Forge API health endpoint.",
928
+ inputSchema: { type: "object", properties: {} }
929
+ }
930
+ ]
931
+ }));
932
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
933
+ if (request.params.name === "forge_memory_status") {
934
+ return {
935
+ content: [
936
+ {
937
+ type: "text",
938
+ text: JSON.stringify({ baseUrl: baseUrl(config), webUrl: webUrl(config), dataRoot: config.dataRoot, identity: sha(config.dataRoot) }, null, 2)
939
+ }
940
+ ]
941
+ };
942
+ }
943
+ if (request.params.name === "forge_memory_health") {
944
+ return { content: [{ type: "text", text: JSON.stringify(await health(config), null, 2) }] };
945
+ }
946
+ throw new Error(`Unknown tool: ${request.params.name}`);
947
+ });
948
+ await server.connect(new StdioServerTransport());
949
+ }
950
+
951
+ function printHelp() {
952
+ console.log(`Forge Memory ${VERSION}
953
+
954
+ Usage:
955
+ npx forge-memory
956
+ npx forge-memory --dev
957
+ npx forge-memory configure
958
+ npx forge-memory status
959
+ npx forge-memory doctor
960
+ npx forge-memory ui
961
+ npx forge-memory restart
962
+ npx forge-memory pair-ios
963
+
964
+ Options:
965
+ --yes, -y Accept defaults/non-interactive mode
966
+ --dev Use source-backed Forge runtime and adapter links
967
+ --data-root <path> Forge data root
968
+ --adapters <list> Comma list: openclaw,hermes,codex or none
969
+ --skip-adapters Configure UI/runtime only
970
+ --skip-pair-ios Do not prompt or create iOS pairing
971
+ --no-start Configure without starting runtime
972
+ --dry-run Show actions without writing files or installing adapters
973
+ --json Print machine-readable output where supported
974
+ `);
975
+ }
976
+
977
+ async function main() {
978
+ const parsed = parseArgs(process.argv.slice(2));
979
+ if (parsed.flags.help) {
980
+ printHelp();
981
+ return;
982
+ }
983
+ if (parsed.flags.version) {
984
+ console.log(VERSION);
985
+ return;
986
+ }
987
+ switch (parsed.command) {
988
+ case "install":
989
+ case "configure":
990
+ await runInstall(parsed, parsed.command);
991
+ break;
992
+ case "status":
993
+ await runStatus(parsed);
994
+ break;
995
+ case "doctor":
996
+ await runDoctor(parsed);
997
+ break;
998
+ case "start":
999
+ console.log(JSON.stringify(await startRuntime(await readConfig()), null, 2));
1000
+ break;
1001
+ case "stop":
1002
+ console.log(JSON.stringify(await stopRuntime(), null, 2));
1003
+ break;
1004
+ case "restart":
1005
+ await stopRuntime();
1006
+ console.log(JSON.stringify(await startRuntime(await readConfig()), null, 2));
1007
+ break;
1008
+ case "ui":
1009
+ await runUi(parsed);
1010
+ break;
1011
+ case "pair-ios":
1012
+ await runPairIos(parsed);
1013
+ break;
1014
+ case "logs":
1015
+ await runLogs();
1016
+ break;
1017
+ case "mcp":
1018
+ await runMcp();
1019
+ break;
1020
+ case "help":
1021
+ printHelp();
1022
+ break;
1023
+ default:
1024
+ throw new Error(`Unknown command: ${parsed.command}`);
1025
+ }
1026
+ }
1027
+
1028
+ main().catch((error) => {
1029
+ console.error(color.red(error instanceof Error ? error.message : String(error)));
1030
+ process.exitCode = 1;
1031
+ });
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "forge-memory",
3
+ "version": "0.1.0",
4
+ "description": "Guided Forge installer and local runtime manager for the Forge UI, OpenClaw, Hermes, Codex, and iOS pairing.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "bin": {
9
+ "forge-memory": "./bin/forge-memory.mjs"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "README.md",
14
+ "package.json"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.24.2",
21
+ "open": "^10.2.0",
22
+ "qrcode-terminal": "^0.12.0",
23
+ "yaml": "^2.8.1"
24
+ },
25
+ "scripts": {
26
+ "test": "node ./tests/cli-smoke.mjs"
27
+ },
28
+ "engines": {
29
+ "node": ">=22"
30
+ }
31
+ }