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 +28 -0
- package/bin/forge-memory.mjs +1031 -0
- package/package.json +31 -0
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
|
+
}
|