nexo-brain 5.2.1 → 5.3.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.2.1",
3
+ "version": "5.3.1",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -87,6 +87,15 @@ Versions `3.1.7` through `3.2.0` close the recent-memory gap:
87
87
  - when even that misses, NEXO now exposes raw transcript fallback tools for Claude Code and Codex session stores
88
88
  - NEXO can now inspect itself through a live system catalog derived from canonical sources instead of relying only on stale docs or operator memory
89
89
 
90
+ Version `5.3.1` normalizes packaged npm installs so they behave like packaged npm installs: `nexo update` now keeps the runtime anchored to `~/.nexo`, refreshes packaged bootstrap/client artifacts after upgrade, avoids repo-only release-artifact drift in installed runtimes, and keeps personal scripts on the canonical packaged path.
91
+
92
+ Version `5.3.0` adds `nexo uninstall` — a CLI command that cleanly separates runtime from user data. It stops all crons, removes the MCP server config, and preserves databases, learnings, and personal scripts for safe reinstall.
93
+
94
+ Version `5.2.1` fixes the Deep Sleep datetime regression and closes the decision-to-outcome feedback gap:
95
+
96
+ - `_parse_any_datetime` in `apply_findings.py` now strips timezone info before comparison, fixing the offset-aware/offset-naive crash that was breaking Deep Sleep verification work.
97
+ - `cortex_decide()` now auto-creates a `decision_outcome` when none is linked yet, so the outcome-checker cron can verify real decisions instead of leaving the loop open.
98
+
90
99
  Version `5.2.0` closes two focused gaps in the Cortex layer that were left open by the v5.1 audit — the high-stakes response-contract detector was English-only, and the `nexo-cortex-cycle` cron was writing a quality snapshot that no reader ever consumed:
91
100
 
92
101
  - `HIGH_STAKES_KEYWORDS_ES` adds ~45 Spanish keywords to the high-stakes detector with accented and unaccented variants, so a goal written in Spanish (`migrar la base de datos de producción`) trips the same gate as its English twin.
package/bin/nexo-brain.js CHANGED
@@ -2210,15 +2210,31 @@ async function main() {
2210
2210
  "#!/usr/bin/env bash",
2211
2211
  "set -euo pipefail",
2212
2212
  "",
2213
- `NEXO_HOME="${NEXO_HOME}"`,
2213
+ `RUNTIME_HOME="${NEXO_HOME}"`,
2214
+ 'NEXO_HOME="$RUNTIME_HOME"',
2214
2215
  'export NEXO_HOME',
2215
- 'export NEXO_CODE="${NEXO_CODE:-$NEXO_HOME}"',
2216
+ 'resolve_code_dir() {',
2217
+ ' if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/cli.py" ]; then',
2218
+ ' printf \'%s\\n\' "${NEXO_CODE%/}"',
2219
+ ' return 0',
2220
+ ' fi',
2221
+ ' if [ -f "$NEXO_HOME/cli.py" ]; then',
2222
+ ' printf \'%s\\n\' "$NEXO_HOME"',
2223
+ ' return 0',
2224
+ ' fi',
2225
+ ' printf \'%s\\n\' "$NEXO_HOME"',
2226
+ '}',
2227
+ 'NEXO_CODE="$(resolve_code_dir)"',
2228
+ 'export NEXO_CODE',
2216
2229
  'resolve_python() {',
2217
2230
  ' local candidates=()',
2218
2231
  ' local candidate=""',
2219
2232
  ' if [ -n "${NEXO_RUNTIME_PYTHON:-}" ]; then candidates+=("$NEXO_RUNTIME_PYTHON"); fi',
2220
2233
  ' if [ -n "${NEXO_PYTHON:-}" ]; then candidates+=("$NEXO_PYTHON"); fi',
2221
- ' candidates+=("$NEXO_HOME/.venv/bin/python3" "$NEXO_HOME/.venv/bin/python")',
2234
+ ' candidates+=("$NEXO_CODE/.venv/bin/python3" "$NEXO_CODE/.venv/bin/python")',
2235
+ ' if [ "$NEXO_CODE" != "$NEXO_HOME" ]; then',
2236
+ ' candidates+=("$NEXO_HOME/.venv/bin/python3" "$NEXO_HOME/.venv/bin/python")',
2237
+ ' fi',
2222
2238
  ' case "$(uname -s)" in',
2223
2239
  ' Darwin) candidates+=("/opt/homebrew/bin/python3" "/usr/local/bin/python3") ;;',
2224
2240
  ' *) candidates+=("/usr/local/bin/python3" "/usr/bin/python3") ;;',
@@ -2246,7 +2262,17 @@ async function main() {
2246
2262
  ' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2',
2247
2263
  ' exit 1',
2248
2264
  'fi',
2249
- 'exec "$PYTHON" "$NEXO_HOME/cli.py" "$@"',
2265
+ 'CLI_PY="$NEXO_CODE/cli.py"',
2266
+ 'if [ ! -f "$CLI_PY" ] && [ -f "$NEXO_HOME/cli.py" ]; then',
2267
+ ' NEXO_CODE="$NEXO_HOME"',
2268
+ ' export NEXO_CODE',
2269
+ ' CLI_PY="$NEXO_HOME/cli.py"',
2270
+ 'fi',
2271
+ 'if [ ! -f "$CLI_PY" ]; then',
2272
+ ' echo "NEXO CLI not found under $NEXO_HOME. Run nexo-brain or nexo update to repair the installation." >&2',
2273
+ ' exit 1',
2274
+ 'fi',
2275
+ 'exec "$PYTHON" "$CLI_PY" "$@"',
2250
2276
  "",
2251
2277
  ].join("\n");
2252
2278
  const runtimeCliPath = path.join(NEXO_HOME, "bin", "nexo");
package/bin/nexo.js CHANGED
@@ -12,7 +12,44 @@ const fs = require("fs");
12
12
  const os = require("os");
13
13
  const path = require("path");
14
14
 
15
- const NEXO_HOME = process.env.NEXO_HOME || path.join(os.homedir(), ".nexo");
15
+ function resolveNexoHome(rawValue) {
16
+ const homeDir = os.homedir();
17
+ const managedHome = path.join(homeDir, ".nexo");
18
+ const legacyHome = path.join(homeDir, "claude");
19
+ const candidate = rawValue || managedHome;
20
+
21
+ if (candidate === managedHome) return managedHome;
22
+ if (candidate === legacyHome) return fs.existsSync(managedHome) ? managedHome : candidate;
23
+
24
+ try {
25
+ if (fs.existsSync(managedHome) && fs.realpathSync.native(candidate) === fs.realpathSync.native(managedHome)) {
26
+ return managedHome;
27
+ }
28
+ } catch {}
29
+
30
+ return candidate;
31
+ }
32
+
33
+ const NEXO_HOME = resolveNexoHome(process.env.NEXO_HOME);
34
+ const NEXO_CODE = resolveCodeDir();
35
+
36
+ function resolveCodeDir() {
37
+ const envCode = process.env.NEXO_CODE;
38
+ if (envCode && fs.existsSync(path.join(envCode, "cli.py"))) {
39
+ return envCode;
40
+ }
41
+ const repoCandidate = path.join(__dirname, "..", "src", "cli.py");
42
+ if (fs.existsSync(repoCandidate)) {
43
+ return path.join(__dirname, "..", "src");
44
+ }
45
+ if (fs.existsSync(path.join(NEXO_HOME, "cli.py"))) {
46
+ return NEXO_HOME;
47
+ }
48
+ if (fs.existsSync(path.join(NEXO_HOME, "claude", "cli.py"))) {
49
+ return path.join(NEXO_HOME, "claude");
50
+ }
51
+ return NEXO_HOME;
52
+ }
16
53
 
17
54
  function pythonSupportsModule(candidate, moduleName) {
18
55
  if (!candidate) return false;
@@ -23,7 +60,7 @@ function pythonSupportsModule(candidate, moduleName) {
23
60
  env: {
24
61
  ...process.env,
25
62
  NEXO_HOME,
26
- NEXO_CODE: path.join(__dirname, "..", "src"),
63
+ NEXO_CODE,
27
64
  },
28
65
  });
29
66
  return result.status === 0;
@@ -36,6 +73,8 @@ function findPython() {
36
73
  const candidates = [
37
74
  process.env.NEXO_RUNTIME_PYTHON,
38
75
  process.env.NEXO_PYTHON,
76
+ path.join(NEXO_CODE, ".venv", "bin", "python3"),
77
+ path.join(NEXO_CODE, ".venv", "bin", "python"),
39
78
  path.join(NEXO_HOME, ".venv", "bin", "python3"),
40
79
  path.join(NEXO_HOME, ".venv", "bin", "python"),
41
80
  process.platform === "darwin" ? "/opt/homebrew/bin/python3" : "",
@@ -55,10 +94,7 @@ function findPython() {
55
94
  }
56
95
 
57
96
  function findCliPy() {
58
- const repoCandidate = path.join(__dirname, "..", "src", "cli.py");
59
- const installedCandidate = path.join(NEXO_HOME, "cli.py");
60
- if (fs.existsSync(repoCandidate)) return repoCandidate;
61
- return installedCandidate;
97
+ return path.join(NEXO_CODE, "cli.py");
62
98
  }
63
99
 
64
100
  const python = findPython();
@@ -75,7 +111,7 @@ const result = spawnSync(python, [cliPy, ...process.argv.slice(2)], {
75
111
  env: {
76
112
  ...process.env,
77
113
  NEXO_HOME,
78
- NEXO_CODE: path.join(__dirname, "..", "src"),
114
+ NEXO_CODE,
79
115
  },
80
116
  });
81
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.2.1",
3
+ "version": "5.3.1",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -67,6 +67,7 @@
67
67
  "bin/nexo-brain.js",
68
68
  "bin/nexo.js",
69
69
  "bin/postinstall.js",
70
+ "scripts/sync_release_artifacts.py",
70
71
  "src/",
71
72
  "community/",
72
73
  "!src/**/__pycache__",
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ ROOT_PACKAGE_JSON = ROOT / "package.json"
12
+ CLAUDE_PLUGIN_JSON = ROOT / ".claude-plugin" / "plugin.json"
13
+ CLAWHUB_SKILL_MD = ROOT / "clawhub-skill" / "SKILL.md"
14
+ OPENCLAW_PACKAGE_JSON = ROOT / "openclaw-plugin" / "package.json"
15
+ OPENCLAW_MCP_BRIDGE = ROOT / "openclaw-plugin" / "src" / "mcp-bridge.ts"
16
+
17
+
18
+ def fail(message: str) -> None:
19
+ raise SystemExit(f"[sync-release-artifacts] {message}")
20
+
21
+
22
+ def load_json(path: Path) -> dict:
23
+ return json.loads(path.read_text())
24
+
25
+
26
+ def dump_json(path: Path, payload: dict) -> None:
27
+ path.write_text(json.dumps(payload, indent=2) + "\n")
28
+
29
+
30
+ def sync_json_version(path: Path, expected_version: str, label: str) -> bool:
31
+ payload = load_json(path)
32
+ if payload.get("version") == expected_version:
33
+ return False
34
+ payload["version"] = expected_version
35
+ dump_json(path, payload)
36
+ print(f"[sync-release-artifacts] synced {label} version -> {expected_version}")
37
+ return True
38
+
39
+
40
+ def sync_clawhub_skill(skill_path: Path, expected_version: str) -> bool:
41
+ text = skill_path.read_text()
42
+ updated = text
43
+
44
+ updated = re.sub(
45
+ r"(?m)^version:\s*[^\n]+$",
46
+ f"version: {expected_version}",
47
+ updated,
48
+ count=1,
49
+ )
50
+ updated = updated.replace("~/.nexo/src/server.py", "~/.nexo/server.py")
51
+
52
+ if updated == text:
53
+ return False
54
+
55
+ skill_path.write_text(updated)
56
+ print(f"[sync-release-artifacts] synced ClawHub skill -> {expected_version}")
57
+ return True
58
+
59
+
60
+ def sync_openclaw_bridge(bridge_path: Path, expected_version: str) -> bool:
61
+ text = bridge_path.read_text()
62
+ updated = text
63
+
64
+ updated = updated.replace(
65
+ 'resolve(this.config.nexoHome, "src", "server.py")',
66
+ 'resolve(this.config.nexoHome, "server.py")',
67
+ )
68
+ updated = re.sub(
69
+ r'clientInfo:\s*\{\s*name:\s*"openclaw-memory-nexo-brain",\s*version:\s*"[^"]+"\s*\}',
70
+ f'clientInfo: {{ name: "openclaw-memory-nexo-brain", version: "{expected_version}" }}',
71
+ updated,
72
+ )
73
+
74
+ if updated == text:
75
+ return False
76
+
77
+ bridge_path.write_text(updated)
78
+ print(f"[sync-release-artifacts] synced OpenClaw bridge -> {expected_version}")
79
+ return True
80
+
81
+
82
+ def main() -> None:
83
+ parser = argparse.ArgumentParser(description="Keep release-facing integration artifacts in sync.")
84
+ parser.add_argument("--release-version", help="Expected release version (must match root package.json).")
85
+ parser.add_argument("--check", action="store_true", help="Fail if any artifact would change.")
86
+ args = parser.parse_args()
87
+
88
+ root_package = load_json(ROOT_PACKAGE_JSON)
89
+ root_version = root_package.get("version")
90
+ if not root_version:
91
+ fail("root package.json is missing version")
92
+
93
+ if args.release_version and args.release_version != root_version:
94
+ fail(
95
+ f"release version {args.release_version} does not match root package.json version {root_version}"
96
+ )
97
+
98
+ original_payloads = {
99
+ CLAUDE_PLUGIN_JSON: CLAUDE_PLUGIN_JSON.read_text(),
100
+ CLAWHUB_SKILL_MD: CLAWHUB_SKILL_MD.read_text(),
101
+ OPENCLAW_PACKAGE_JSON: OPENCLAW_PACKAGE_JSON.read_text(),
102
+ OPENCLAW_MCP_BRIDGE: OPENCLAW_MCP_BRIDGE.read_text(),
103
+ }
104
+
105
+ changed = []
106
+ if sync_json_version(CLAUDE_PLUGIN_JSON, root_version, "Claude plugin"):
107
+ changed.append(".claude-plugin/plugin.json")
108
+ if sync_clawhub_skill(CLAWHUB_SKILL_MD, root_version):
109
+ changed.append("clawhub-skill/SKILL.md")
110
+ if sync_json_version(OPENCLAW_PACKAGE_JSON, root_version, "OpenClaw package"):
111
+ changed.append("openclaw-plugin/package.json")
112
+ if sync_openclaw_bridge(OPENCLAW_MCP_BRIDGE, root_version):
113
+ changed.append("openclaw-plugin/src/mcp-bridge.ts")
114
+
115
+ if args.check:
116
+ for path, text in original_payloads.items():
117
+ path.write_text(text)
118
+ if changed:
119
+ fail("artifacts out of sync: " + ", ".join(changed))
120
+ print("[sync-release-artifacts] OK")
121
+ return
122
+
123
+ if not changed:
124
+ print("[sync-release-artifacts] already in sync")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
@@ -17,7 +17,9 @@ import sys
17
17
  import time
18
18
  from pathlib import Path
19
19
 
20
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
20
+ from runtime_home import export_resolved_nexo_home, managed_nexo_home
21
+
22
+ NEXO_HOME = export_resolved_nexo_home()
21
23
  DATA_DIR = NEXO_HOME / "data"
22
24
  DATA_DIR.mkdir(parents=True, exist_ok=True)
23
25
 
@@ -148,15 +150,42 @@ def _runtime_cli_wrapper_text() -> str:
148
150
  return (
149
151
  "#!/usr/bin/env bash\n"
150
152
  "set -euo pipefail\n\n"
151
- f'NEXO_HOME="{NEXO_HOME}"\n'
153
+ f'DEFAULT_NEXO_HOME="{managed_nexo_home()}"\n'
154
+ 'RUNTIME_HOME="${NEXO_HOME:-$DEFAULT_NEXO_HOME}"\n'
155
+ 'if [ "$RUNTIME_HOME" = "${HOME}/claude" ] && [ -e "$DEFAULT_NEXO_HOME" ]; then\n'
156
+ ' RUNTIME_HOME="$DEFAULT_NEXO_HOME"\n'
157
+ 'fi\n'
158
+ 'if [ -e "$RUNTIME_HOME" ] && [ -e "$DEFAULT_NEXO_HOME" ]; then\n'
159
+ ' RESOLVED_RUNTIME="$(cd "$RUNTIME_HOME" 2>/dev/null && pwd -P || true)"\n'
160
+ ' RESOLVED_DEFAULT="$(cd "$DEFAULT_NEXO_HOME" 2>/dev/null && pwd -P || true)"\n'
161
+ ' if [ -n "$RESOLVED_RUNTIME" ] && [ "$RESOLVED_RUNTIME" = "$RESOLVED_DEFAULT" ]; then\n'
162
+ ' RUNTIME_HOME="$DEFAULT_NEXO_HOME"\n'
163
+ ' fi\n'
164
+ 'fi\n'
165
+ 'NEXO_HOME="$RUNTIME_HOME"\n'
152
166
  'export NEXO_HOME\n'
153
- 'export NEXO_CODE="${NEXO_CODE:-$NEXO_HOME}"\n'
167
+ 'resolve_code_dir() {\n'
168
+ ' if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/cli.py" ]; then\n'
169
+ ' printf \'%s\\n\' "${NEXO_CODE%/}"\n'
170
+ ' return 0\n'
171
+ ' fi\n'
172
+ ' if [ -f "$NEXO_HOME/cli.py" ]; then\n'
173
+ ' printf \'%s\\n\' "$NEXO_HOME"\n'
174
+ ' return 0\n'
175
+ ' fi\n'
176
+ ' printf \'%s\\n\' "$NEXO_HOME"\n'
177
+ '}\n'
178
+ 'NEXO_CODE="$(resolve_code_dir)"\n'
179
+ 'export NEXO_CODE\n'
154
180
  'resolve_python() {\n'
155
181
  ' local candidates=()\n'
156
182
  ' local candidate=""\n'
157
183
  ' if [ -n "${NEXO_RUNTIME_PYTHON:-}" ]; then candidates+=("$NEXO_RUNTIME_PYTHON"); fi\n'
158
184
  ' if [ -n "${NEXO_PYTHON:-}" ]; then candidates+=("$NEXO_PYTHON"); fi\n'
159
- ' candidates+=("$NEXO_HOME/.venv/bin/python3" "$NEXO_HOME/.venv/bin/python")\n'
185
+ ' candidates+=("$NEXO_CODE/.venv/bin/python3" "$NEXO_CODE/.venv/bin/python")\n'
186
+ ' if [ "$NEXO_CODE" != "$NEXO_HOME" ]; then\n'
187
+ ' candidates+=("$NEXO_HOME/.venv/bin/python3" "$NEXO_HOME/.venv/bin/python")\n'
188
+ ' fi\n'
160
189
  ' case "$(uname -s)" in\n'
161
190
  ' Darwin) candidates+=("/opt/homebrew/bin/python3" "/usr/local/bin/python3") ;;\n'
162
191
  ' *) candidates+=("/usr/local/bin/python3" "/usr/bin/python3") ;;\n'
@@ -184,7 +213,17 @@ def _runtime_cli_wrapper_text() -> str:
184
213
  ' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2\n'
185
214
  ' exit 1\n'
186
215
  'fi\n'
187
- 'exec "$PYTHON" "$NEXO_HOME/cli.py" "$@"\n'
216
+ 'CLI_PY="$NEXO_CODE/cli.py"\n'
217
+ 'if [ ! -f "$CLI_PY" ] && [ -f "$NEXO_HOME/cli.py" ]; then\n'
218
+ ' NEXO_CODE="$NEXO_HOME"\n'
219
+ ' export NEXO_CODE\n'
220
+ ' CLI_PY="$NEXO_HOME/cli.py"\n'
221
+ 'fi\n'
222
+ 'if [ ! -f "$CLI_PY" ]; then\n'
223
+ ' echo "NEXO CLI not found under $NEXO_HOME. Run nexo-brain or nexo update to repair the installation." >&2\n'
224
+ ' exit 1\n'
225
+ 'fi\n'
226
+ 'exec "$PYTHON" "$CLI_PY" "$@"\n'
188
227
  )
189
228
 
190
229
 
@@ -1451,9 +1490,11 @@ def _resolve_sync_source() -> tuple[Path | None, Path | None]:
1451
1490
  if (
1452
1491
  not same_as_runtime
1453
1492
  and (NEXO_CODE / "db").is_dir()
1454
- and (NEXO_CODE.parent / "package.json").is_file()
1455
1493
  ):
1456
- return NEXO_CODE, NEXO_CODE.parent
1494
+ if (NEXO_CODE.parent / "package.json").is_file():
1495
+ return NEXO_CODE, NEXO_CODE.parent
1496
+ if (NEXO_CODE / "package.json").is_file():
1497
+ return NEXO_CODE, NEXO_CODE
1457
1498
 
1458
1499
  version_source = _runtime_version_source()
1459
1500
  if version_source:
@@ -1583,7 +1624,21 @@ def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
1583
1624
  backup_dir = NEXO_HOME / "backups" / f"runtime-tree-{timestamp}"
1584
1625
  backup_dir.mkdir(parents=True, exist_ok=True)
1585
1626
 
1586
- code_dirs = ["hooks", "plugins", "db", "cognitive", "dashboard", "rules", "crons", "scripts", "doctor", "skills-core"]
1627
+ code_dirs = [
1628
+ "hooks",
1629
+ "plugins",
1630
+ "db",
1631
+ "cognitive",
1632
+ "dashboard",
1633
+ "rules",
1634
+ "crons",
1635
+ "scripts",
1636
+ "doctor",
1637
+ "skills",
1638
+ "skills-core",
1639
+ "skills-runtime",
1640
+ "templates",
1641
+ ]
1587
1642
  flat_files = _runtime_flat_files(dest)
1588
1643
  for name in code_dirs:
1589
1644
  src = dest / name
@@ -1636,6 +1691,9 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1636
1691
  script_conflicts: list[dict[str, str]] = []
1637
1692
  installed_script_classes = _installed_scripts_classification(dest)
1638
1693
 
1694
+ for dirname in ("bin", "skills", "skills-core", "skills-runtime", "templates"):
1695
+ (dest / dirname).mkdir(parents=True, exist_ok=True)
1696
+
1639
1697
  _emit_progress(progress_fn, "Copying core packages...")
1640
1698
  for pkg in packages:
1641
1699
  pkg_src = src_dir / pkg
@@ -16,6 +16,7 @@ from client_preferences import (
16
16
  normalize_client_key,
17
17
  normalize_client_preferences,
18
18
  )
19
+ from runtime_home import resolve_nexo_home
19
20
 
20
21
  def _resolve_templates_dir(module_file: str | os.PathLike[str]) -> Path:
21
22
  module_dir = Path(module_file).resolve().parent
@@ -60,7 +61,7 @@ def _user_home() -> Path:
60
61
 
61
62
 
62
63
  def _default_nexo_home() -> Path:
63
- return Path(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo"))).expanduser()
64
+ return resolve_nexo_home(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo")))
64
65
 
65
66
 
66
67
  def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
@@ -115,6 +116,18 @@ def _build_user_block(user_payload: str, template_text: str) -> str:
115
116
  return f"{USER_START}\n{user_payload.rstrip()}\n{USER_END}"
116
117
 
117
118
 
119
+ def _extract_user_payload(text: str) -> str:
120
+ block = _extract_block(text, USER_START, USER_END)
121
+ if not block:
122
+ return ""
123
+ payload = block
124
+ if payload.startswith(USER_START):
125
+ payload = payload[len(USER_START):]
126
+ if payload.endswith(USER_END):
127
+ payload = payload[: -len(USER_END)]
128
+ return payload.strip("\n")
129
+
130
+
118
131
  def _legacy_user_payload(existing_text: str) -> str:
119
132
  cleaned = re.sub(r"<!--\s*nexo-[^-]+-[^-]+-version:\s*[\d.]+\s*-->\s*", "", existing_text)
120
133
  if "<!-- nexo:start:" in cleaned:
@@ -146,6 +159,33 @@ def _legacy_user_payload(existing_text: str) -> str:
146
159
  return cleaned.strip()
147
160
 
148
161
 
162
+ def _home_alias(path: Path, user_home: Path) -> str:
163
+ try:
164
+ relative = path.expanduser().relative_to(user_home.expanduser())
165
+ except Exception:
166
+ return str(path.expanduser())
167
+ return "~/" + relative.as_posix()
168
+
169
+
170
+ def _migrate_legacy_nexo_home_references(user_payload: str, *, nexo_home: Path, user_home: Path) -> str:
171
+ if not user_payload.strip():
172
+ return user_payload
173
+ legacy_home = user_home.expanduser() / "claude"
174
+ target_home = nexo_home.expanduser()
175
+ if legacy_home == target_home:
176
+ return user_payload
177
+
178
+ replacements = [
179
+ (str(legacy_home), str(target_home)),
180
+ (_home_alias(legacy_home, user_home), _home_alias(target_home, user_home)),
181
+ ]
182
+ updated = user_payload
183
+ for old, new in replacements:
184
+ if old and new and old != new:
185
+ updated = updated.replace(old, new)
186
+ return updated
187
+
188
+
149
189
  def _target_path(client: str, *, user_home: Path | None = None) -> Path:
150
190
  spec = BOOTSTRAP_SPECS[client]
151
191
  home = user_home or _user_home()
@@ -243,12 +283,23 @@ def sync_client_bootstrap(
243
283
 
244
284
  existing = target_path.read_text()
245
285
  if CORE_START in existing and CORE_END in existing and USER_START in existing and USER_END in existing:
246
- user_block = _extract_block(existing, USER_START, USER_END) or _extract_block(rendered, USER_START, USER_END)
286
+ user_payload = _extract_user_payload(existing) or _extract_user_payload(rendered)
287
+ user_payload = _migrate_legacy_nexo_home_references(
288
+ user_payload,
289
+ nexo_home=nexo_home_path,
290
+ user_home=home_path,
291
+ )
292
+ user_block = _build_user_block(user_payload, rendered)
247
293
  updated = _replace_block(existing, CORE_START, CORE_END, rendered_core)
248
294
  updated = _replace_block(updated, USER_START, USER_END, user_block)
249
295
  action = "updated" if updated != existing else "unchanged"
250
296
  else:
251
297
  legacy_user = _legacy_user_payload(existing)
298
+ legacy_user = _migrate_legacy_nexo_home_references(
299
+ legacy_user,
300
+ nexo_home=nexo_home_path,
301
+ user_home=home_path,
302
+ )
252
303
  updated = _replace_block(rendered, USER_START, USER_END, _build_user_block(legacy_user, rendered))
253
304
  action = "migrated"
254
305