n2n-nexus 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +8 -1
- package/build/cli/doctor.js +187 -0
- package/build/cli/install.js +105 -18
- package/build/client/nexus-client.js +1 -0
- package/build/daemon/server.js +6 -2
- package/build/index.js +10 -1
- package/build/storage/index.js +16 -11
- package/build/storage/projects.js +10 -31
- package/build/storage/registry.js +71 -13
- package/docs/ARCHITECTURE.md +2 -1
- package/docs/ARCHITECTURE_zh.md +2 -1
- package/docs/README_zh.md +8 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.8.1] - 2026-10-22
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `n2n-nexus install <claude|codex>` writes the `node` found on PATH when it is the running binary, instead of `process.execPath`, the resolved version directory (nvm, Homebrew Cellar, fnm, ...) that disappears when Node is upgraded. `--node <path>` chooses the path explicitly; it must exist, run and satisfy `engines.node`. `install` prints the path it wrote and warns when it is in a version directory.
|
|
10
|
+
- `n2n-nexus doctor [claude|codex]`: checks that the Node path and the entry script in the installed hook and MCP commands exist and run, and says how to fix them.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Daemon responses are decoded as streaming UTF-8; a multi-byte character split across two chunks no longer turns into U+FFFD.
|
|
15
|
+
- `rename_project` renames the directory, the manifests and the registry entry as one unit and rolls everything back when a step fails.
|
|
16
|
+
- `host_delete_project` fails, and keeps the registry entry, when the project files cannot be removed.
|
|
17
|
+
- `upload_project_asset` checks that the project exists inside the write lock, so an upload racing a delete no longer recreates the deleted project. It returns the name the asset is saved under, and a file name that sanitizes to `.` is rejected.
|
|
18
|
+
- `listen({ on: false })` ends a Stop hook that is already waiting instead of letting it run to the timeout.
|
|
19
|
+
|
|
20
|
+
### Upgrade notes
|
|
21
|
+
|
|
22
|
+
- Run `n2n-nexus install claude` and/or `n2n-nexus install codex` again so the hooks and the MCP command use a stable Node path.
|
|
23
|
+
|
|
5
24
|
## [0.8.0] - 2026-10-21
|
|
6
25
|
|
|
7
26
|
### Added
|
package/README.md
CHANGED
|
@@ -105,6 +105,9 @@ n2n-nexus install codex # ~/.codex/hooks.json (or $CODEX_HOME) + codex mcp a
|
|
|
105
105
|
|
|
106
106
|
- Codex runs new hooks only after you trust them: open Codex and run `/hooks` once.
|
|
107
107
|
- Pass `--endpoint <url>` to both commands when the daemon is not at `http://127.0.0.1:5688`.
|
|
108
|
+
- The hooks and the MCP server command use an absolute Node path, because GUI clients started from the Dock (such as Claude Desktop) do not read your shell's `PATH`. By default `install` writes the `node` found on `PATH` when it is the same file as the Node running `install` (for example `/usr/local/bin/node`, `/opt/homebrew/bin/node`, `~/.local/bin/node` or a version manager's stable entry point), and otherwise the real path of the running Node. `install` prints the path it wrote as `Node: <path>`.
|
|
109
|
+
- Pass `--node <path>` to choose the Node path yourself. It must exist, be executable and satisfy the `engines` range in package.json (`^22.14.0 || >=23.6.0`); otherwise `install` exits with code 1 and writes nothing.
|
|
110
|
+
- After upgrading Node: if `install` warned that the Node path contains a version directory (Homebrew `Cellar`, nvm, fnm, Volta, asdf, `node-v<version>`), run `n2n-nexus install <claude|codex>` again after each upgrade, or pass `--node` with a stable path. `n2n-nexus doctor [claude|codex]` checks that the Node path and the entry script in the installed hook and MCP commands still exist and run, and exits with code 1 when they do not.
|
|
108
111
|
- `n2n-nexus uninstall <claude|codex>` removes the hooks and the MCP server that `install` added and leaves the rest of the configuration alone.
|
|
109
112
|
- Supported on Linux, WSL and macOS. Other MCP clients can use every tool except direct messages.
|
|
110
113
|
|
|
@@ -191,9 +194,13 @@ n2n-nexus daemon [--port 5688] [--root ~/.n2n-nexus] [--host 127.0.0.1]
|
|
|
191
194
|
NEXUS_ENDPOINT=http://127.0.0.1:5688 n2n-nexus mcp
|
|
192
195
|
|
|
193
196
|
# Install / remove the hooks and MCP server for Claude Code or Codex
|
|
194
|
-
|
|
197
|
+
# --node <path>: the Node executable to write into the client configuration
|
|
198
|
+
n2n-nexus install <claude|codex> [--endpoint <url>] [--node <path>]
|
|
195
199
|
n2n-nexus uninstall <claude|codex>
|
|
196
200
|
|
|
201
|
+
# Check that the installed hook and MCP commands still run (exit 1 when not)
|
|
202
|
+
n2n-nexus doctor [claude|codex]
|
|
203
|
+
|
|
197
204
|
# Hook entry point (written into the client configuration by install)
|
|
198
205
|
n2n-nexus hook <claude|codex> [--endpoint <url>]
|
|
199
206
|
```
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `n2n-nexus doctor [claude|codex]`: check that the Node path and the entry
|
|
3
|
+
* script in the hook commands and in the MCP server command that `install`
|
|
4
|
+
* wrote still exist and run, and say how to fix what does not. Without a
|
|
5
|
+
* client both are checked; a client with nothing installed is only reported
|
|
6
|
+
* when it was named. Exit code 1 when a check failed.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { HOOK_CLIENTS, MCP_SERVER_NAME, isHookClient } from "../daemon/hook-contract.js";
|
|
13
|
+
import { pkg } from "../config/index.js";
|
|
14
|
+
import { InstallError, VERSIONED_NODE_DIR, configFile, isOurs, parseVersion, readConfig, satisfiesEngines } from "./install.js";
|
|
15
|
+
const ok = (text) => ({ ok: true, text });
|
|
16
|
+
const bad = (text) => ({ ok: false, text });
|
|
17
|
+
const VERSIONED_NOTE = "in a version directory that an upgrade may remove";
|
|
18
|
+
/** The Node path and the entry script of one of our hook commands, `"<node>" "<entry>" hook <client> ...`. */
|
|
19
|
+
export function parseHookCommand(command) {
|
|
20
|
+
const m = /^"([^"]+)" "([^"]+)" hook /.exec(command);
|
|
21
|
+
return m ? { node: m[1], entry: m[2] } : undefined;
|
|
22
|
+
}
|
|
23
|
+
export function checkNode(node) {
|
|
24
|
+
if (!fs.existsSync(node))
|
|
25
|
+
return bad("missing");
|
|
26
|
+
try {
|
|
27
|
+
fs.accessSync(node, fs.constants.X_OK);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return bad("not executable");
|
|
31
|
+
}
|
|
32
|
+
const probe = spawnSync(node, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
33
|
+
const version = probe.status === 0 ? parseVersion(probe.stdout) : undefined;
|
|
34
|
+
if (!version)
|
|
35
|
+
return bad("does not run");
|
|
36
|
+
const text = probe.stdout.trim();
|
|
37
|
+
if (!satisfiesEngines(text))
|
|
38
|
+
return bad(`${text} does not satisfy ${pkg.engines.node}`);
|
|
39
|
+
return ok(`${text}${VERSIONED_NODE_DIR.test(node) ? `, ${VERSIONED_NOTE}` : ""}`);
|
|
40
|
+
}
|
|
41
|
+
const checkEntry = (entry) => fs.existsSync(entry) ? ok("ok") : bad("missing");
|
|
42
|
+
/** Where the client keeps the MCP server command. */
|
|
43
|
+
export function mcpConfigFile(client) {
|
|
44
|
+
if (client === "claude") {
|
|
45
|
+
return path.join(process.env.CLAUDE_CONFIG_DIR || os.homedir(), ".claude.json");
|
|
46
|
+
}
|
|
47
|
+
return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "config.toml");
|
|
48
|
+
}
|
|
49
|
+
/** The MCP server command the client has for n2n-nexus, or undefined when not registered. */
|
|
50
|
+
export function readMcpCommand(client, file) {
|
|
51
|
+
let text;
|
|
52
|
+
try {
|
|
53
|
+
text = fs.readFileSync(file, "utf8");
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code === "ENOENT")
|
|
57
|
+
return undefined;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (client === "claude") {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(text);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new InstallError(`${file} is not valid JSON (${error.message}).`);
|
|
67
|
+
}
|
|
68
|
+
const servers = parsed?.mcpServers;
|
|
69
|
+
const server = servers?.[MCP_SERVER_NAME];
|
|
70
|
+
if (!server || typeof server.command !== "string")
|
|
71
|
+
return undefined;
|
|
72
|
+
return { command: server.command, args: Array.isArray(server.args) ? server.args.map(String) : [] };
|
|
73
|
+
}
|
|
74
|
+
// Codex: a TOML table [mcp_servers.n2n-nexus] (the key may be quoted) with command = "..." and args = [...].
|
|
75
|
+
const lines = text.split("\n");
|
|
76
|
+
const start = lines.findIndex((line) => new RegExp(`^\\s*\\[mcp_servers\\.("?)${MCP_SERVER_NAME}\\1\\]\\s*$`).test(line));
|
|
77
|
+
if (start === -1)
|
|
78
|
+
return undefined;
|
|
79
|
+
let command;
|
|
80
|
+
let args = [];
|
|
81
|
+
for (const line of lines.slice(start + 1)) {
|
|
82
|
+
if (/^\s*\[/.test(line))
|
|
83
|
+
break;
|
|
84
|
+
const c = /^\s*command\s*=\s*"((?:[^"\\]|\\.)*)"/.exec(line);
|
|
85
|
+
if (c)
|
|
86
|
+
command = JSON.parse(`"${c[1]}"`);
|
|
87
|
+
const a = /^\s*args\s*=\s*\[(.*)\]/.exec(line);
|
|
88
|
+
if (a)
|
|
89
|
+
args = [...a[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`));
|
|
90
|
+
}
|
|
91
|
+
return command === undefined ? undefined : { command, args };
|
|
92
|
+
}
|
|
93
|
+
/** Check one client; prints the report and returns whether everything passed, or undefined when nothing is installed. */
|
|
94
|
+
function doctorClient(client) {
|
|
95
|
+
const hooksFile = configFile(client);
|
|
96
|
+
const mcpFile = mcpConfigFile(client);
|
|
97
|
+
const lines = [];
|
|
98
|
+
let failed = false;
|
|
99
|
+
let versioned = false;
|
|
100
|
+
const report = (label, check) => {
|
|
101
|
+
lines.push(` ${check.ok ? "ok " : "FAIL"} ${label}: ${check.text}`);
|
|
102
|
+
if (!check.ok)
|
|
103
|
+
failed = true;
|
|
104
|
+
if (check.text.includes(VERSIONED_NOTE))
|
|
105
|
+
versioned = true;
|
|
106
|
+
};
|
|
107
|
+
const hooks = readConfig(hooksFile).hooks ?? {};
|
|
108
|
+
const commands = new Set();
|
|
109
|
+
for (const groups of Object.values(hooks)) {
|
|
110
|
+
if (!Array.isArray(groups))
|
|
111
|
+
continue;
|
|
112
|
+
for (const group of groups) {
|
|
113
|
+
const entries = group?.hooks;
|
|
114
|
+
if (!Array.isArray(entries))
|
|
115
|
+
continue;
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (isOurs(entry, client))
|
|
118
|
+
commands.add(entry.command);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const mcp = readMcpCommand(client, mcpFile);
|
|
123
|
+
if (commands.size === 0 && !mcp)
|
|
124
|
+
return undefined;
|
|
125
|
+
lines.push(`${client}`);
|
|
126
|
+
if (commands.size === 0)
|
|
127
|
+
report(`hooks in ${hooksFile}`, bad("not installed"));
|
|
128
|
+
for (const command of commands) {
|
|
129
|
+
const parsed = parseHookCommand(command);
|
|
130
|
+
if (!parsed) {
|
|
131
|
+
report(`hook command ${command}`, bad("not in the form \"<node>\" \"<entry>\" hook ..."));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
report(`hook Node ${parsed.node}`, checkNode(parsed.node));
|
|
135
|
+
report(`hook entry ${parsed.entry}`, checkEntry(parsed.entry));
|
|
136
|
+
}
|
|
137
|
+
if (!mcp) {
|
|
138
|
+
report(`MCP server ${MCP_SERVER_NAME} in ${mcpFile}`, bad("not registered"));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
report(`MCP Node ${mcp.command}`, checkNode(mcp.command));
|
|
142
|
+
const entry = mcp.args[0];
|
|
143
|
+
if (entry)
|
|
144
|
+
report(`MCP entry ${entry}`, checkEntry(entry));
|
|
145
|
+
else
|
|
146
|
+
report(`MCP command in ${mcpFile}`, bad("has no entry script argument"));
|
|
147
|
+
}
|
|
148
|
+
if (failed) {
|
|
149
|
+
lines.push(` fix: run "n2n-nexus install ${client}" again (add --node <path> to use a stable Node path such as /usr/local/bin/node).`);
|
|
150
|
+
}
|
|
151
|
+
else if (versioned) {
|
|
152
|
+
lines.push(` note: after upgrading Node, run "n2n-nexus install ${client}" again, or install with --node <path> pointing at a stable entry such as /usr/local/bin/node.`);
|
|
153
|
+
}
|
|
154
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
155
|
+
return !failed;
|
|
156
|
+
}
|
|
157
|
+
/** Parse `[claude|codex]`, run the checks and return the exit code. */
|
|
158
|
+
export function runDoctor(argv) {
|
|
159
|
+
const client = argv[0];
|
|
160
|
+
if (client !== undefined && !isHookClient(client)) {
|
|
161
|
+
process.stderr.write("Usage: n2n-nexus doctor [claude|codex]\n");
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
let failed = false;
|
|
166
|
+
for (const c of client ? [client] : HOOK_CLIENTS) {
|
|
167
|
+
const result = doctorClient(c);
|
|
168
|
+
if (result === undefined) {
|
|
169
|
+
if (client) {
|
|
170
|
+
process.stdout.write(`${c}\n FAIL nothing installed: run "n2n-nexus install ${c}".\n`);
|
|
171
|
+
failed = true;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
process.stdout.write(`${c}\n not installed\n`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else if (!result) {
|
|
178
|
+
failed = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return failed ? 1 : 0;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
process.stderr.write(`[n2n-nexus] doctor failed: ${error.message}\n`);
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
package/build/cli/install.js
CHANGED
|
@@ -1,35 +1,106 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `n2n-nexus install <claude|codex> [--endpoint <url>]` and
|
|
2
|
+
* `n2n-nexus install <claude|codex> [--endpoint <url>] [--node <path>]` and
|
|
3
3
|
* `n2n-nexus uninstall <claude|codex>`: register the hooks and the MCP server
|
|
4
4
|
* with Claude Code or Codex. Our hook entries are recognised by a command that
|
|
5
5
|
* contains both `n2n-nexus` and ` hook <client>`; everything else in the
|
|
6
6
|
* configuration file is kept as it is.
|
|
7
|
+
*
|
|
8
|
+
* The commands start with an absolute Node path: GUI clients started from the
|
|
9
|
+
* Dock do not read the shell PATH. The `node` found on PATH is preferred when it
|
|
10
|
+
* is the running binary, because process.execPath is the resolved, versioned
|
|
11
|
+
* location (nvm, Homebrew Cellar, ...) that disappears when Node is upgraded.
|
|
7
12
|
*/
|
|
8
13
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
9
14
|
import fs from "node:fs";
|
|
10
15
|
import os from "node:os";
|
|
11
16
|
import path from "node:path";
|
|
12
17
|
import { CLAUDE_PRE_TOOL_MATCHER, HOOK_STOP_TIMEOUT_SEC, HOOK_TIMEOUT_SEC, MCP_SERVER_NAME, isHookClient } from "../daemon/hook-contract.js";
|
|
18
|
+
import { pkg } from "../config/index.js";
|
|
13
19
|
import { entryScript } from "./autostart.js";
|
|
14
20
|
const EVENTS = {
|
|
15
21
|
claude: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"],
|
|
16
22
|
codex: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PostToolUse", "Stop"]
|
|
17
23
|
};
|
|
18
|
-
class InstallError extends Error {
|
|
24
|
+
export class InstallError extends Error {
|
|
19
25
|
}
|
|
20
|
-
|
|
26
|
+
/** Path segments of Node installs that are replaced on upgrade. */
|
|
27
|
+
export const VERSIONED_NODE_DIR = /\/Cellar\/|\/\.nvm\/versions\/|\/node-v\d|\/fnm\/|\/fnm_multishells\/|\/\.volta\/|\/\.asdf\/installs\//;
|
|
28
|
+
/** fnm puts a per-shell symlink directory first on PATH; it is gone when the shell ends. */
|
|
29
|
+
const PER_SHELL_NODE_DIR = /\/fnm_multishells\//;
|
|
30
|
+
function sameFile(a, b) {
|
|
31
|
+
try {
|
|
32
|
+
return fs.realpathSync(a) === fs.realpathSync(b);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The `node` on PATH when it is the running binary (its stable entry), else
|
|
40
|
+
* process.execPath. Relative PATH entries and fnm's per-shell directory are
|
|
41
|
+
* skipped: the commands must work from any directory and after the shell ends.
|
|
42
|
+
*/
|
|
43
|
+
export function stableNodePath(env = process.env) {
|
|
44
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
45
|
+
if (!path.isAbsolute(dir) || PER_SHELL_NODE_DIR.test(dir))
|
|
46
|
+
continue;
|
|
47
|
+
const candidate = path.join(dir, "node");
|
|
48
|
+
if (sameFile(candidate, process.execPath))
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
51
|
+
return process.execPath;
|
|
52
|
+
}
|
|
53
|
+
export function parseVersion(text) {
|
|
54
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(text.trim());
|
|
55
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
|
|
56
|
+
}
|
|
57
|
+
const atLeast = (v, min) => v[0] !== min[0] ? v[0] > min[0] : v[1] !== min[1] ? v[1] > min[1] : v[2] >= min[2];
|
|
58
|
+
/** Whether `version` satisfies the package's engines.node range (`^a.b.c` and `>=a.b.c` clauses joined by `||`). */
|
|
59
|
+
export function satisfiesEngines(version, range = pkg.engines.node) {
|
|
60
|
+
const v = parseVersion(version);
|
|
61
|
+
if (!v)
|
|
62
|
+
return false;
|
|
63
|
+
return range.split("||").some((clause) => {
|
|
64
|
+
const m = /^\s*(\^|>=)\s*v?(\d+\.\d+\.\d+)\s*$/.exec(clause);
|
|
65
|
+
const min = m && parseVersion(m[2]);
|
|
66
|
+
if (!m || !min)
|
|
67
|
+
throw new Error(`Unsupported engines.node clause: ${clause.trim()}`);
|
|
68
|
+
return atLeast(v, min) && (m[1] === ">=" || v[0] === min[0]);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Check a `--node` path: it must exist, run, and satisfy engines.node. Returns the absolute path. */
|
|
72
|
+
function checkNodePath(given) {
|
|
73
|
+
const node = path.resolve(given);
|
|
74
|
+
try {
|
|
75
|
+
fs.accessSync(node, fs.constants.X_OK);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new InstallError(`--node ${given}: not an executable file.`);
|
|
79
|
+
}
|
|
80
|
+
if (fs.statSync(node).isDirectory())
|
|
81
|
+
throw new InstallError(`--node ${given}: is a directory, not the node executable.`);
|
|
82
|
+
const probe = spawnSync(node, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
83
|
+
const version = probe.status === 0 ? parseVersion(probe.stdout) : undefined;
|
|
84
|
+
if (!version)
|
|
85
|
+
throw new InstallError(`--node ${given}: "${node} --version" did not print a Node version.`);
|
|
86
|
+
if (!satisfiesEngines(probe.stdout)) {
|
|
87
|
+
throw new InstallError(`--node ${given}: Node ${probe.stdout.trim()} does not satisfy ${pkg.engines.node}.`);
|
|
88
|
+
}
|
|
89
|
+
return node;
|
|
90
|
+
}
|
|
91
|
+
export function configFile(client) {
|
|
21
92
|
if (client === "claude") {
|
|
22
93
|
return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "settings.json");
|
|
23
94
|
}
|
|
24
95
|
return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "hooks.json");
|
|
25
96
|
}
|
|
26
|
-
function isOurs(entry, client) {
|
|
97
|
+
export function isOurs(entry, client) {
|
|
27
98
|
if (typeof entry !== "object" || entry === null)
|
|
28
99
|
return false;
|
|
29
100
|
const command = entry.command;
|
|
30
101
|
return typeof command === "string" && command.includes("n2n-nexus") && command.includes(` hook ${client}`);
|
|
31
102
|
}
|
|
32
|
-
function readConfig(file) {
|
|
103
|
+
export function readConfig(file) {
|
|
33
104
|
let text;
|
|
34
105
|
try {
|
|
35
106
|
text = fs.readFileSync(file, "utf8");
|
|
@@ -111,10 +182,10 @@ function mcpRemoveArgs(client) {
|
|
|
111
182
|
? ["mcp", "remove", "--scope", "user", MCP_SERVER_NAME]
|
|
112
183
|
: ["mcp", "remove", MCP_SERVER_NAME];
|
|
113
184
|
}
|
|
114
|
-
function mcpAddArgs(client, entry, endpoint) {
|
|
185
|
+
function mcpAddArgs(client, node, entry, endpoint) {
|
|
115
186
|
const env = endpoint ? [client === "claude" ? "-e" : "--env", `NEXUS_ENDPOINT=${endpoint}`] : [];
|
|
116
187
|
const scope = client === "claude" ? ["--scope", "user"] : [];
|
|
117
|
-
return ["mcp", "add", ...scope, MCP_SERVER_NAME, ...env, "--",
|
|
188
|
+
return ["mcp", "add", ...scope, MCP_SERVER_NAME, ...env, "--", node, entry, "mcp"];
|
|
118
189
|
}
|
|
119
190
|
/** Install or uninstall; prints what it did and returns the exit code. */
|
|
120
191
|
export function runInstall(options) {
|
|
@@ -128,11 +199,12 @@ export function runInstall(options) {
|
|
|
128
199
|
if (probe.error || probe.status !== 0) {
|
|
129
200
|
throw new InstallError(`The "${client}" command was not found on PATH; install ${client === "claude" ? "Claude Code" : "Codex"} first.`);
|
|
130
201
|
}
|
|
202
|
+
const node = action === "install" ? (options.node ? checkNodePath(options.node) : stableNodePath()) : undefined;
|
|
131
203
|
const file = configFile(client);
|
|
132
204
|
const config = readConfig(file);
|
|
133
205
|
const removed = removeOurEntries(config, client, file);
|
|
134
206
|
if (action === "install") {
|
|
135
|
-
const command = `"${
|
|
207
|
+
const command = `"${node}" "${entry}" hook ${client}${endpoint ? ` --endpoint ${endpoint}` : ""}`;
|
|
136
208
|
addOurEntries(config, client, command);
|
|
137
209
|
}
|
|
138
210
|
if (action === "install" || removed) {
|
|
@@ -148,10 +220,14 @@ export function runInstall(options) {
|
|
|
148
220
|
catch {
|
|
149
221
|
// Not registered yet (or already removed): nothing to undo.
|
|
150
222
|
}
|
|
151
|
-
if (action === "install") {
|
|
152
|
-
const addArgs = mcpAddArgs(client, entry, endpoint);
|
|
223
|
+
if (action === "install" && node) {
|
|
224
|
+
const addArgs = mcpAddArgs(client, node, entry, endpoint);
|
|
153
225
|
execFileSync(client, addArgs, { stdio: ["ignore", "ignore", "inherit"] });
|
|
154
226
|
process.stdout.write(`Ran: ${client} ${addArgs.join(" ")}\n`);
|
|
227
|
+
process.stdout.write(`Node: ${node}\n`);
|
|
228
|
+
if (VERSIONED_NODE_DIR.test(node)) {
|
|
229
|
+
process.stdout.write(`Note: this Node path contains a version directory that an upgrade may remove. After upgrading Node, run "n2n-nexus install ${client}" again, or install with --node <path> pointing at a stable entry such as /usr/local/bin/node.\n`);
|
|
230
|
+
}
|
|
155
231
|
if (client === "codex") {
|
|
156
232
|
process.stdout.write("Next: open Codex and run /hooks to trust the n2n-nexus hooks; until then they do not run.\n");
|
|
157
233
|
}
|
|
@@ -163,18 +239,29 @@ export function runInstall(options) {
|
|
|
163
239
|
return 1;
|
|
164
240
|
}
|
|
165
241
|
}
|
|
166
|
-
/** Parse `<claude|codex> [--endpoint <url>]` and run. */
|
|
242
|
+
/** Parse `<claude|codex> [--endpoint <url>] [--node <path>]` and run. */
|
|
167
243
|
export function runInstallCommand(action, argv) {
|
|
168
244
|
const client = argv[0];
|
|
169
245
|
if (!isHookClient(client)) {
|
|
170
|
-
process.stderr.write(`Usage: n2n-nexus ${action} <claude|codex>${action === "install" ? " [--endpoint <url>]" : ""}\n`);
|
|
246
|
+
process.stderr.write(`Usage: n2n-nexus ${action} <claude|codex>${action === "install" ? " [--endpoint <url>] [--node <path>]" : ""}\n`);
|
|
171
247
|
return 1;
|
|
172
248
|
}
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
249
|
+
const option = (name, what) => {
|
|
250
|
+
const i = argv.indexOf(name);
|
|
251
|
+
if (i === -1 || action !== "install")
|
|
252
|
+
return undefined;
|
|
253
|
+
const value = argv[i + 1];
|
|
254
|
+
if (!value || value.startsWith("--")) {
|
|
255
|
+
process.stderr.write(`${name} needs ${what}\n`);
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
return value;
|
|
259
|
+
};
|
|
260
|
+
const endpoint = option("--endpoint", "a URL");
|
|
261
|
+
if (endpoint === null)
|
|
177
262
|
return 1;
|
|
178
|
-
|
|
179
|
-
|
|
263
|
+
const node = option("--node", "a path");
|
|
264
|
+
if (node === null)
|
|
265
|
+
return 1;
|
|
266
|
+
return runInstall({ action, client, endpoint, node });
|
|
180
267
|
}
|
|
@@ -47,6 +47,7 @@ export class NexusClient {
|
|
|
47
47
|
timeout: timeoutMs
|
|
48
48
|
}, (res) => {
|
|
49
49
|
let data = "";
|
|
50
|
+
res.setEncoding("utf8");
|
|
50
51
|
const interrupted = () => reject(new NexusTransportError("Response from daemon ended before completion"));
|
|
51
52
|
res.on("aborted", interrupted);
|
|
52
53
|
res.on("error", (error) => reject(new NexusTransportError(error.message)));
|
package/build/daemon/server.js
CHANGED
|
@@ -35,7 +35,9 @@ async function toolSyncProjectAssets(args, instanceId) {
|
|
|
35
35
|
async function toolUploadAsset(args, instanceId) {
|
|
36
36
|
if (!await StorageManager.getProjectManifest(args.projectId))
|
|
37
37
|
throw new Error(`Project '${args.projectId}' not found.`);
|
|
38
|
-
|
|
38
|
+
// Must produce the final saved name: StorageManager.saveAsset's sanitizeFileName
|
|
39
|
+
// (src/storage/projects.ts) has to leave this result unchanged, so keep both rules in step.
|
|
40
|
+
const sanitized = args.fileName.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/\.\./g, "_");
|
|
39
41
|
const buf = Buffer.from(args.base64Content, "base64");
|
|
40
42
|
await StorageManager.saveAsset(args.projectId, sanitized, buf);
|
|
41
43
|
EventStore.record(instanceId, "project.asset", args.projectId, { fileName: sanitized, bytes: buf.length });
|
|
@@ -282,6 +284,7 @@ async function toolRead(args, caller) {
|
|
|
282
284
|
async function toolListen(args, caller) {
|
|
283
285
|
const self = resolveAgent(caller);
|
|
284
286
|
AgentStore.setListening(self.id, args.on);
|
|
287
|
+
wake([self.id]);
|
|
285
288
|
return { listening: args.on };
|
|
286
289
|
}
|
|
287
290
|
async function toolSetProfile(args, caller) {
|
|
@@ -354,8 +357,9 @@ function waitForMessages(sessionId, waitMs, signal) {
|
|
|
354
357
|
if (done)
|
|
355
358
|
return;
|
|
356
359
|
// SessionEnd (or listen off) during the wait: nobody will act on a block any more.
|
|
360
|
+
// stopWaiting sets idle only while still "listening", so SessionEnd stays offline.
|
|
357
361
|
if (!AgentStore.get(sessionId)?.listening)
|
|
358
|
-
return
|
|
362
|
+
return stopWaiting();
|
|
359
363
|
const text = notifyFresh(sessionId, STOP_CLOSING);
|
|
360
364
|
if (text === null)
|
|
361
365
|
return;
|
package/build/index.js
CHANGED
|
@@ -12,10 +12,13 @@ n2n-nexus v${pkg.version} — Multi-AI assistant coordination hub
|
|
|
12
12
|
USAGE:
|
|
13
13
|
n2n-nexus daemon Start the Nexus server (run once, stays alive)
|
|
14
14
|
n2n-nexus mcp Start the MCP proxy (launched by IDE automatically)
|
|
15
|
-
n2n-nexus install <claude|codex> [--endpoint <url>]
|
|
15
|
+
n2n-nexus install <claude|codex> [--endpoint <url>] [--node <path>]
|
|
16
16
|
Register the hooks and the MCP server with Claude Code or Codex
|
|
17
17
|
n2n-nexus uninstall <claude|codex>
|
|
18
18
|
Remove the hooks and the MCP server installed by install
|
|
19
|
+
n2n-nexus doctor [claude|codex]
|
|
20
|
+
Check that the Node path and entry script in the installed hook and
|
|
21
|
+
MCP commands exist and run; exit 1 and say how to fix them when not
|
|
19
22
|
n2n-nexus hook <claude|codex> [--endpoint <url>]
|
|
20
23
|
Forward a client hook event to the daemon (run by the hooks)
|
|
21
24
|
|
|
@@ -26,6 +29,8 @@ OPTIONS:
|
|
|
26
29
|
--id <id> Instance ID for MCP proxy (default: mcp-<hostname>-<cwd hash>-<IDE name>)
|
|
27
30
|
--endpoint <url> Daemon URL for hook (default: NEXUS_ENDPOINT, then http://127.0.0.1:5688);
|
|
28
31
|
install writes it into the hook and MCP commands
|
|
32
|
+
--node <path> Node executable written into the hook and MCP commands by install
|
|
33
|
+
(default: the node on PATH when it is the running one, else its real path)
|
|
29
34
|
--version, -v Show version
|
|
30
35
|
--help, -h Show this help
|
|
31
36
|
|
|
@@ -47,6 +52,10 @@ async function main() {
|
|
|
47
52
|
const { runInstallCommand } = await import("./cli/install.js");
|
|
48
53
|
process.exit(runInstallCommand(command, process.argv.slice(3)));
|
|
49
54
|
}
|
|
55
|
+
if (command === "doctor") {
|
|
56
|
+
const { runDoctor } = await import("./cli/doctor.js");
|
|
57
|
+
process.exit(runDoctor(process.argv.slice(3)));
|
|
58
|
+
}
|
|
50
59
|
if (command === "daemon") {
|
|
51
60
|
const { runDaemon } = await import("./daemon/index.js");
|
|
52
61
|
await runDaemon();
|
package/build/storage/index.js
CHANGED
|
@@ -75,13 +75,27 @@ export class StorageManager {
|
|
|
75
75
|
// --- Project Methods ---
|
|
76
76
|
static getProjectDocs(id) { return ProjectStorage.getProjectDocs(id); }
|
|
77
77
|
static saveProjectDocs(id, content) { return this.write(() => ProjectStorage.saveProjectDocs(id, content)); }
|
|
78
|
-
|
|
78
|
+
/**
|
|
79
|
+
* The existence check runs inside the write lock: writeFileAtomic creates
|
|
80
|
+
* missing directories, so an upload queued behind a delete would otherwise
|
|
81
|
+
* bring the deleted project's directory back.
|
|
82
|
+
*/
|
|
83
|
+
static saveAsset(id, fileName, content) {
|
|
84
|
+
return this.write(async () => {
|
|
85
|
+
const registry = await RegistryStorage.listRegistry();
|
|
86
|
+
if (!registry.projects[id])
|
|
87
|
+
throw new Error(`Project '${id}' not found.`);
|
|
88
|
+
return ProjectStorage.saveAsset(id, fileName, content);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Files first, index last: if the files cannot be removed, the throw leaves the registry entry in place. */
|
|
79
92
|
static deleteProject(id) {
|
|
80
93
|
return this.write(async () => {
|
|
81
94
|
await ProjectStorage.deleteProject(id);
|
|
82
95
|
await RegistryStorage.deleteProject(id);
|
|
83
96
|
});
|
|
84
97
|
}
|
|
98
|
+
/** RegistryStorage.renameProject moves the directory, manifests and registry together and rolls back on failure. */
|
|
85
99
|
static renameProject(oldId, newId) {
|
|
86
100
|
return this.write(async () => {
|
|
87
101
|
if (!await RegistryStorage.getProjectManifest(oldId))
|
|
@@ -89,16 +103,7 @@ export class StorageManager {
|
|
|
89
103
|
const renamed = await RegistryStorage.renameProject(oldId, newId);
|
|
90
104
|
if (renamed === 0)
|
|
91
105
|
throw new Error(`Project '${oldId}' could not be renamed.`);
|
|
92
|
-
|
|
93
|
-
const result = await ProjectStorage.renameProject(oldId, newId);
|
|
94
|
-
if (result === 0)
|
|
95
|
-
throw new Error(`Project '${oldId}' could not be renamed.`);
|
|
96
|
-
return result;
|
|
97
|
-
}
|
|
98
|
-
catch (error) {
|
|
99
|
-
await RegistryStorage.renameProject(newId, oldId).catch(() => { });
|
|
100
|
-
throw error;
|
|
101
|
-
}
|
|
106
|
+
return renamed;
|
|
102
107
|
});
|
|
103
108
|
}
|
|
104
109
|
// --- Doc Methods ---
|
|
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { NexusPaths } from "./paths.js";
|
|
4
4
|
import { FILE_ENCODING } from "../constants.js";
|
|
5
|
-
import { writeFileAtomic
|
|
5
|
+
import { writeFileAtomic } from "./fs-utils.js";
|
|
6
6
|
export class ProjectStorage {
|
|
7
7
|
static projectFilePath(projectId) {
|
|
8
8
|
return {
|
|
@@ -27,43 +27,22 @@ export class ProjectStorage {
|
|
|
27
27
|
}
|
|
28
28
|
static async saveAsset(projectId, fileName, content) {
|
|
29
29
|
const safeName = this.sanitizeFileName(fileName);
|
|
30
|
+
// "." would resolve to the assets directory itself and writeFileAtomic would create a file in its place.
|
|
31
|
+
if (safeName === ".")
|
|
32
|
+
throw new Error(`Invalid fileName '${fileName}': it names no file.`);
|
|
30
33
|
const paths = this.projectFilePath(projectId);
|
|
31
34
|
const assetPath = path.join(paths.assetsDir, safeName);
|
|
32
35
|
await writeFileAtomic(assetPath, content);
|
|
33
36
|
return path.relative(NexusPaths.root, assetPath);
|
|
34
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Removes the project directory. A missing directory counts as deleted
|
|
40
|
+
* (force); any other failure is thrown so the caller keeps the registry
|
|
41
|
+
* entry instead of indexing a project whose files are still on disk.
|
|
42
|
+
*/
|
|
35
43
|
static async deleteProject(projectId) {
|
|
36
44
|
const paths = this.projectFilePath(projectId);
|
|
37
|
-
|
|
38
|
-
await fs.rm(paths.projectDir, { recursive: true, force: true });
|
|
39
|
-
return 1;
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return 0;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
static async renameProject(oldId, newId) {
|
|
46
|
-
if (!oldId || !newId || oldId === newId)
|
|
47
|
-
return 0;
|
|
48
|
-
const oldPath = this.projectFilePath(oldId);
|
|
49
|
-
const newPath = this.projectFilePath(newId);
|
|
50
|
-
try {
|
|
51
|
-
await fs.access(oldPath.projectDir);
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return 0;
|
|
55
|
-
}
|
|
56
|
-
await fs.mkdir(path.dirname(newPath.projectDir), { recursive: true });
|
|
57
|
-
await fs.rename(oldPath.projectDir, newPath.projectDir);
|
|
58
|
-
try {
|
|
59
|
-
const manifestRaw = await fs.readFile(newPath.manifestPath, FILE_ENCODING);
|
|
60
|
-
const manifest = JSON.parse(manifestRaw);
|
|
61
|
-
manifest.id = newId;
|
|
62
|
-
await writeJsonAtomic(newPath.manifestPath, manifest);
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
// Manifest is optional for some flows.
|
|
66
|
-
}
|
|
45
|
+
await fs.rm(paths.projectDir, { recursive: true, force: true });
|
|
67
46
|
return 1;
|
|
68
47
|
}
|
|
69
48
|
static sanitizeFileName(fileName) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { NexusPaths } from "./paths.js";
|
|
3
|
-
import { toDict, writeJsonAtomic } from "./fs-utils.js";
|
|
3
|
+
import { toDict, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
|
|
4
4
|
const emptyRegistry = () => ({ projects: toDict() });
|
|
5
5
|
async function readRegistry() {
|
|
6
6
|
try {
|
|
@@ -233,39 +233,97 @@ export class RegistryStorage {
|
|
|
233
233
|
await writeRegistry(registry);
|
|
234
234
|
return 1;
|
|
235
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Renames a project as one unit: its directory, its own manifest id, every
|
|
238
|
+
* relation that targets it in other manifests, and the registry key.
|
|
239
|
+
*
|
|
240
|
+
* Order: directory rename, then manifest writes, then the registry write
|
|
241
|
+
* last. The registry is the index readers trust, so it only switches to
|
|
242
|
+
* the new id once everything it points at is in place. If any step
|
|
243
|
+
* throws, the completed steps are undone in reverse (manifests restored to
|
|
244
|
+
* their original bytes, directory renamed back) and the original error is
|
|
245
|
+
* rethrown. Rollback is best effort: a failure while undoing is swallowed
|
|
246
|
+
* so the caller still sees the error that caused the rollback.
|
|
247
|
+
*
|
|
248
|
+
* Returns 0 when oldId is not registered or its directory does not exist.
|
|
249
|
+
*/
|
|
236
250
|
static async renameProject(oldId, newId) {
|
|
251
|
+
if (!oldId || !newId || oldId === newId)
|
|
252
|
+
return 0;
|
|
237
253
|
const registry = await readRegistry();
|
|
238
254
|
if (!registry.projects[oldId])
|
|
239
255
|
return 0;
|
|
240
256
|
if (registry.projects[newId]) {
|
|
241
257
|
throw new Error(`Project '${newId}' already exists.`);
|
|
242
258
|
}
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
259
|
+
const oldDir = NexusPaths.projectDir(oldId);
|
|
260
|
+
const newDir = NexusPaths.projectDir(newId);
|
|
261
|
+
try {
|
|
262
|
+
await fs.access(oldDir);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
// Plan every manifest rewrite before touching disk. `original` keeps
|
|
268
|
+
// the exact bytes for rollback; `path` is where the file lives after
|
|
269
|
+
// the directory rename.
|
|
270
|
+
const rewrites = [];
|
|
247
271
|
for (const projectId of Object.keys(registry.projects)) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
272
|
+
let original;
|
|
273
|
+
try {
|
|
274
|
+
original = await fs.readFile(NexusPaths.projectManifestPath(projectId), "utf-8");
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
251
277
|
continue;
|
|
278
|
+
}
|
|
279
|
+
let manifest;
|
|
280
|
+
try {
|
|
281
|
+
manifest = JSON.parse(original);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
252
286
|
let changed = false;
|
|
253
|
-
if (projectId ===
|
|
287
|
+
if (projectId === oldId) {
|
|
254
288
|
manifest.id = newId;
|
|
255
289
|
changed = true;
|
|
256
290
|
}
|
|
257
291
|
if (Array.isArray(manifest.relations)) {
|
|
258
|
-
|
|
259
|
-
if (relation
|
|
292
|
+
manifest.relations = manifest.relations.map((relation) => {
|
|
293
|
+
if (relation?.targetId !== oldId)
|
|
260
294
|
return relation;
|
|
261
295
|
changed = true;
|
|
262
296
|
return { ...relation, targetId: newId };
|
|
263
297
|
});
|
|
264
|
-
manifest.relations = relations;
|
|
265
298
|
}
|
|
266
299
|
if (changed) {
|
|
267
|
-
|
|
300
|
+
const target = projectId === oldId ? newId : projectId;
|
|
301
|
+
rewrites.push({ path: NexusPaths.projectManifestPath(target), original, updated: manifest });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
let dirMoved = false;
|
|
305
|
+
const written = [];
|
|
306
|
+
try {
|
|
307
|
+
await fs.mkdir(NexusPaths.projectsRoot, { recursive: true });
|
|
308
|
+
await fs.rename(oldDir, newDir);
|
|
309
|
+
dirMoved = true;
|
|
310
|
+
for (const rewrite of rewrites) {
|
|
311
|
+
await writeJsonAtomic(rewrite.path, rewrite.updated);
|
|
312
|
+
written.push(rewrite);
|
|
313
|
+
}
|
|
314
|
+
const entry = registry.projects[oldId];
|
|
315
|
+
delete registry.projects[oldId];
|
|
316
|
+
registry.projects[newId] = entry;
|
|
317
|
+
await writeRegistry(registry);
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
for (const rewrite of written.reverse()) {
|
|
321
|
+
await writeFileAtomic(rewrite.path, rewrite.original).catch(() => { });
|
|
322
|
+
}
|
|
323
|
+
if (dirMoved) {
|
|
324
|
+
await fs.rename(newDir, oldDir).catch(() => { });
|
|
268
325
|
}
|
|
326
|
+
throw error;
|
|
269
327
|
}
|
|
270
328
|
return 1;
|
|
271
329
|
}
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -132,7 +132,8 @@ n2n-nexus
|
|
|
132
132
|
├─ n2n-nexus daemon Start once, keep running. Owns all data.
|
|
133
133
|
├─ n2n-nexus mcp IDE starts automatically via npx. Stateless proxy.
|
|
134
134
|
├─ n2n-nexus hook Run by Claude Code / Codex on each hook event.
|
|
135
|
-
|
|
135
|
+
├─ n2n-nexus install Write the hooks and MCP server into a client's user configuration.
|
|
136
|
+
└─ n2n-nexus doctor Check that the installed hook and MCP commands still run.
|
|
136
137
|
```
|
|
137
138
|
|
|
138
139
|
**Daemon** (started by the user, or in the background by a hook or adapter when none listens at a local endpoint):
|
package/docs/ARCHITECTURE_zh.md
CHANGED
|
@@ -132,7 +132,8 @@ n2n-nexus
|
|
|
132
132
|
├─ n2n-nexus daemon 启动一次,持续运行,持有所有数据。
|
|
133
133
|
├─ n2n-nexus mcp IDE 通过 npx 自动启动,无状态代理。
|
|
134
134
|
├─ n2n-nexus hook Claude Code / Codex 在每个 hook 事件时运行。
|
|
135
|
-
|
|
135
|
+
├─ n2n-nexus install 把 hooks 和 MCP 服务写进客户端的用户级配置。
|
|
136
|
+
└─ n2n-nexus doctor 检查已安装的 hook 和 MCP 命令是否还能运行。
|
|
136
137
|
```
|
|
137
138
|
|
|
138
139
|
**Daemon**(由用户启动;本机地址上没有 daemon 时,也会由 hook 或适配器在后台拉起):
|
package/docs/README_zh.md
CHANGED
|
@@ -114,6 +114,9 @@ n2n-nexus install codex # ~/.codex/hooks.json(或 $CODEX_HOME)+ codex mc
|
|
|
114
114
|
|
|
115
115
|
- Codex 只运行已信任的 hooks:打开 Codex,执行一次 `/hooks` 完成信任。
|
|
116
116
|
- daemon 不在 `http://127.0.0.1:5688` 时,两条命令都加上 `--endpoint <url>`。
|
|
117
|
+
- hooks 和 MCP 服务的命令里写的是 Node 的绝对路径,因为从 Dock 启动的 GUI 客户端(如 Claude 桌面版)不读取 shell 的 `PATH`。默认情况下,`PATH` 上的 `node` 与运行 `install` 的 Node 是同一个文件时,`install` 写入 `PATH` 上的路径(例如 `/usr/local/bin/node`、`/opt/homebrew/bin/node`、`~/.local/bin/node` 或版本管理工具的稳定入口),否则写入当前运行的 Node 的真实路径。`install` 会打印一行 `Node: <path>` 显示实际写入的路径。
|
|
118
|
+
- 用 `--node <path>` 手动指定 Node 路径。路径必须存在、可执行,且版本满足 package.json 的 `engines`(`^22.14.0 || >=23.6.0`),否则 `install` 以退出码 1 退出,不写任何配置。
|
|
119
|
+
- 升级 Node 后:如果 `install` 提示 Node 路径位于带版本号的目录(Homebrew `Cellar`、nvm、fnm、Volta、asdf、`node-v<版本>`),每次升级 Node 后都要重新运行 `n2n-nexus install <claude|codex>`,或用 `--node` 指定稳定路径。`n2n-nexus doctor [claude|codex]` 检查已安装的 hook 命令和 MCP 命令里的 Node 路径与入口脚本是否仍然存在、能否运行,有问题时以退出码 1 退出。
|
|
117
120
|
- `n2n-nexus uninstall <claude|codex>` 删除 `install` 写入的 hooks 和 MCP 服务,配置里的其他内容不动。
|
|
118
121
|
- 支持 Linux、WSL 和 macOS。其他 MCP 客户端可以使用除直接消息以外的全部工具。
|
|
119
122
|
|
|
@@ -234,9 +237,13 @@ n2n-nexus daemon [--port 5688] [--root ~/.n2n-nexus] [--host 127.0.0.1]
|
|
|
234
237
|
NEXUS_ENDPOINT=http://127.0.0.1:5688 n2n-nexus mcp
|
|
235
238
|
|
|
236
239
|
# 为 Claude Code 或 Codex 安装 / 删除 hooks 和 MCP 服务
|
|
237
|
-
|
|
240
|
+
# --node <path>:写进客户端配置的 Node 可执行文件路径
|
|
241
|
+
n2n-nexus install <claude|codex> [--endpoint <url>] [--node <path>]
|
|
238
242
|
n2n-nexus uninstall <claude|codex>
|
|
239
243
|
|
|
244
|
+
# 检查已安装的 hook 和 MCP 命令是否还能运行(有问题时退出码 1)
|
|
245
|
+
n2n-nexus doctor [claude|codex]
|
|
246
|
+
|
|
240
247
|
# hook 入口(由 install 写进客户端配置)
|
|
241
248
|
n2n-nexus hook <claude|codex> [--endpoint <url>]
|
|
242
249
|
```
|