From 485b52dcf9e92477173c83e6b440b80091d0e24c Mon Sep 17 00:00:00 2001 From: Jonathan Ellis Date: Mon, 14 Sep 2026 08:17:03 -0500 Subject: [PATCH 1/2] feat: honor ZCODE_HOME for the config and lazy-session store paths Both the credentials/provider config and the lazy-session alias store were pinned to `~/.zcode`, so a bridge could not be pointed at an isolated ZCode install (a second account, a container mount, a test fixture) without moving the user's real home. `zcodeHomeDir()` in utils.ts is now the single place that resolves the data root: `ZCODE_HOME` when set, else `/.zcode`. `ZCODE_CREDS_PATH` and the lazy store's `storePath()` both build on it. Default behaviour is unchanged when the variable is unset. `ZCODE_CREDS_PATH` stays a module-level const, so `ZCODE_HOME` must be set before the process starts; the store path is resolved per call as before. The hermetic test setup deletes `ZCODE_HOME` for the same reason it redirects HOME: an exported value would point the suite at a real store. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DLQ3W2owia4SJBvzxSqvVc --- README.md | 1 + src/lazy-sessions.ts | 7 +++---- src/utils.ts | 24 +++++++++++++++++------- tests/lazy-sessions.test.ts | 14 ++++++++++++++ tests/setup/hermetic-home.ts | 5 +++++ tests/utils.test.ts | 32 +++++++++++++++++++++++++++++++- 6 files changed, 71 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 576afb2..c45bf3d 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ most setups need no `ZCODE_BIN` at all — set it only for custom installs: | ---------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ZCODE_BIN` | _(auto-discovered)_ | Path to the ZCode CLI binary or its `.cjs` entry. Resolution order: this variable → `zcode` on `PATH` → the desktop-app bundle | | `ZCODE_NODE` | _(discovered)_ | Explicit Node binary to run `ZCODE_BIN` with (must support `node:sqlite`) | +| `ZCODE_HOME` | `~/.zcode` | Directory that replaces `~/.zcode` as the ZCode data root. Both the credentials/provider config (`/v2/config.json`) and the lazy-session alias store (`/v2/acp-lazy-sessions.json`) are read from it. Set it before starting the bridge — the credentials path is resolved once at startup. | | `ZCODE_MODEL` | _(from config)_ | Override the active model id | | `ZCODE_BASE_URL` | _(from config)_ | Override the provider base URL | | `ZCODE_ACP_AUTO_COMPACT_THRESHOLD` | _(unset)_ | Absolute token count that triggers automatic context compaction. After each successful turn (`end_turn`), if `contextUsed >= threshold`, the server invokes `session/compact` to free up context before the next prompt. Set to `0` or leave unset to disable (default). Example: `240000` triggers compaction at 240K tokens. The compaction target itself is decided by the ZCode backend. | diff --git a/src/lazy-sessions.ts b/src/lazy-sessions.ts index 1be620e..01cf5d1 100644 --- a/src/lazy-sessions.ts +++ b/src/lazy-sessions.ts @@ -35,7 +35,7 @@ import { import path from "node:path"; import process from "node:process"; -import { warn } from "./utils.js"; +import { warn, zcodeHomeDir } from "./utils.js"; /** Placeholder alias record persisted in the store. */ export interface LazySessionRecord { @@ -51,10 +51,9 @@ const STORE_FILENAME = "acp-lazy-sessions.json"; /** NEVER-USED placeholders expire after 30 days; materialized records never do. */ const TTL_MS = 30 * 24 * 60 * 60 * 1000; -/** Resolved at call time so tests can stub HOME without re-importing. */ +/** Resolved at call time so tests can stub HOME/ZCODE_HOME without re-importing. */ function storePath(): string { - const home = process.env.HOME || process.env.USERPROFILE || "~"; - return path.join(home, ".zcode", "v2", STORE_FILENAME); + return path.join(zcodeHomeDir(), "v2", STORE_FILENAME); } /** Parse and validate the table. No side effects — never rewrites the file. */ diff --git a/src/utils.ts b/src/utils.ts index a959c53..c627efd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -35,13 +35,23 @@ export const AGENT_INFO = { version: PACKAGE_VERSION, } as const; -/** Path to the ZCode v2 config (credentials + provider/model metadata). */ -export const ZCODE_CREDS_PATH = path.join( - process.env.HOME || process.env.USERPROFILE || "~", - ".zcode", - "v2", - "config.json", -); +/** + * Root of the ZCode data directory. `ZCODE_HOME` replaces `~/.zcode` outright, + * so a bridge can run against an isolated ZCode install (a second account, a + * container mount, a test fixture) without touching the user's real one. + * Resolved at call time so a caller can change the env before reading. + */ +export function zcodeHomeDir(): string { + const explicit = process.env.ZCODE_HOME; + if (explicit) return explicit; + return path.join(process.env.HOME || process.env.USERPROFILE || "~", ".zcode"); +} + +/** + * Path to the ZCode v2 config (credentials + provider/model metadata). + * Module-level snapshot: `ZCODE_HOME` must be set before the process starts. + */ +export const ZCODE_CREDS_PATH = path.join(zcodeHomeDir(), "v2", "config.json"); /** * Slash commands surfaced to the editor. Each maps to a ZCode session method diff --git a/tests/lazy-sessions.test.ts b/tests/lazy-sessions.test.ts index b1600dc..1cba0e3 100644 --- a/tests/lazy-sessions.test.ts +++ b/tests/lazy-sessions.test.ts @@ -57,6 +57,7 @@ beforeEach(() => { mockDirs.clear(); mockMtimes.clear(); vi.stubEnv("HOME", "/fake-home"); + vi.stubEnv("ZCODE_HOME", ""); }); afterEach(() => { @@ -64,6 +65,19 @@ afterEach(() => { }); describe("lazy session alias store", () => { + it("writes under ZCODE_HOME when it is set, ignoring HOME", () => { + vi.stubEnv("ZCODE_HOME", "/custom-zcode"); + + rememberLazySession("acp_env", "/tmp/ws"); + + expect(mockFiles.has("/custom-zcode/v2/acp-lazy-sessions.json")).toBe(true); + expect(mockFiles.has(STORE)).toBe(false); + expect(lookupLazySession("acp_env")).toEqual({ + cwd: "/tmp/ws", + createdAt: expect.any(Number), + }); + }); + it("records a placeholder at session/new and reads it back", () => { rememberLazySession("acp_1", "/tmp/ws"); diff --git a/tests/setup/hermetic-home.ts b/tests/setup/hermetic-home.ts index 4ca902e..109240c 100644 --- a/tests/setup/hermetic-home.ts +++ b/tests/setup/hermetic-home.ts @@ -12,6 +12,10 @@ * mid-run cannot drop the suite back onto the real HOME. A per-test * vi.stubEnv("HOME", …) still overrides this default for that test. * + * ZCODE_HOME is DELETED for the same reason as HOME is redirected: it + * overrides the ZCode data root outright, so a developer with it exported + * would point the suite back at a real store. + * * The serve-origin markers are DELETED the same way: a vitest run started * from inside an incubated TUI inherits them, and the session-close handler * reading them would treat the test worker as that TUI's bridge — signalling @@ -26,6 +30,7 @@ import { afterAll } from "vitest"; const home = mkdtempSync(path.join(tmpdir(), "zacp-test-home-")); process.env.HOME = home; +delete process.env.ZCODE_HOME; delete process.env.ZCODE_ACP_REMOTE_ORIGIN; delete process.env.ZCODE_ACP_TUI_CLI_PID; diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 3c8e941..e0d566c 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -5,9 +5,11 @@ * `ZCODE_ACP_DEBUG=1` is set; `warn()` always emits (perceivable failures). */ +import path from "node:path"; + import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; -import { compareVersions, log, warn } from "../src/utils.js"; +import { ZCODE_CREDS_PATH, compareVersions, log, warn, zcodeHomeDir } from "../src/utils.js"; describe("logging", () => { const prevDebug = process.env.ZCODE_ACP_DEBUG; @@ -74,3 +76,31 @@ describe("compareVersions", () => { expect(compareVersions("1.2.1", "1.2")).toBeGreaterThan(0); }); }); + +describe("zcodeHomeDir", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("defaults to .zcode under the user home", () => { + vi.stubEnv("ZCODE_HOME", ""); + vi.stubEnv("HOME", "/fake-home"); + expect(zcodeHomeDir()).toBe(path.join("/fake-home", ".zcode")); + }); + + it("uses ZCODE_HOME verbatim when set", () => { + vi.stubEnv("HOME", "/fake-home"); + vi.stubEnv("ZCODE_HOME", "/custom-zcode"); + expect(zcodeHomeDir()).toBe("/custom-zcode"); + }); + + it("ZCODE_CREDS_PATH follows ZCODE_HOME (module-level const, so re-imported)", async () => { + // The const is snapshotted at import time; re-import with the env set. + expect(ZCODE_CREDS_PATH.endsWith(path.join("v2", "config.json"))).toBe(true); + vi.stubEnv("ZCODE_HOME", "/custom-zcode"); + vi.resetModules(); + const fresh = await import("../src/utils.js"); + expect(fresh.ZCODE_CREDS_PATH).toBe(path.join("/custom-zcode", "v2", "config.json")); + }); +}); From a8ac07a949be554bcfd0fe7a1664450cc2c5fb2e Mon Sep 17 00:00:00 2001 From: Jonathan Ellis Date: Tue, 15 Sep 2026 12:27:10 +0000 Subject: [PATCH 2/2] fix: route every ~/.zcode reader through zcodeHomeDir() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the ZCODE_HOME override: the discovery modules still built the real ~/.zcode from module-level homedir() constants, so an isolated data root split the bridge in two — credentials and lazy sessions followed ZCODE_HOME while plugins, skills, MCP servers, locale detection and the workspace list kept reading the user's real home. zcodeHomeDir() is now the single resolver everywhere it claims to be: - skill-discovery / mcp-discovery / plugin-commands: the CLI-config and plugin-cache path constants become per-call zcodeCliConfigPath() / zcodePluginCacheDir() helpers in utils.ts (deduplicating the three identical copies); the user-skills scan reads /skills - i18n: the desktop-app settings read follows the override - tasks-index: the workspace exclusion rejects the override root, not the real ~/.zcode, when ZCODE_HOME is set ~/.agents/skills stays on the real home — it is a cross-agent convention, not part of the ZCode data root. README's ZCODE_HOME row now lists every follower. Per-call resolution (not module snapshots) means tests can stub the env per case; each affected module gains a ZCODE_HOME test that proves the real home is ignored while the override is honored. --- README.md | 32 +++++----- src/config/mcp-discovery.ts | 19 +++--- src/config/plugin-commands.ts | 54 ++++++----------- src/config/skill-discovery.ts | 37 ++++++------ src/i18n.ts | 8 ++- src/tasks-index.ts | 17 ++---- src/utils.ts | 13 +++++ tests/i18n.test.ts | 27 ++++++++- tests/mcp-list.test.ts | 30 ++++++++++ tests/plugin-commands.test.ts | 57 +++++++++++++++++- tests/skill-discovery.test.ts | 19 ++++++ tests/tasks-index-workspaces.test.ts | 87 +++++++++++++++++++++++----- 12 files changed, 287 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index c45bf3d..2aec0df 100644 --- a/README.md +++ b/README.md @@ -124,22 +124,22 @@ most setups need no `ZCODE_BIN` at all — set it only for custom installs: ## Environment variables -| Variable | Default | Purpose | -| ---------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ZCODE_BIN` | _(auto-discovered)_ | Path to the ZCode CLI binary or its `.cjs` entry. Resolution order: this variable → `zcode` on `PATH` → the desktop-app bundle | -| `ZCODE_NODE` | _(discovered)_ | Explicit Node binary to run `ZCODE_BIN` with (must support `node:sqlite`) | -| `ZCODE_HOME` | `~/.zcode` | Directory that replaces `~/.zcode` as the ZCode data root. Both the credentials/provider config (`/v2/config.json`) and the lazy-session alias store (`/v2/acp-lazy-sessions.json`) are read from it. Set it before starting the bridge — the credentials path is resolved once at startup. | -| `ZCODE_MODEL` | _(from config)_ | Override the active model id | -| `ZCODE_BASE_URL` | _(from config)_ | Override the provider base URL | -| `ZCODE_ACP_AUTO_COMPACT_THRESHOLD` | _(unset)_ | Absolute token count that triggers automatic context compaction. After each successful turn (`end_turn`), if `contextUsed >= threshold`, the server invokes `session/compact` to free up context before the next prompt. Set to `0` or leave unset to disable (default). Example: `240000` triggers compaction at 240K tokens. The compaction target itself is decided by the ZCode backend. | -| `ZCODE_ACP_DEBUG` | _(unset)_ | Set to `1` to enable verbose diagnostic logs (event flow, probe loops, status updates). Default is quiet — only warnings (backend pipe errors, command/permission failures, lock timeouts) are emitted. Enable this when diagnosing bridge issues; the logs appear in `Zed.log` prefixed with `[zcode-acp]`. | -| `ZCODE_ACP_REMOTE` | _(unset)_ | Set to `1` to enable [remote access](#remote-access) — serve the same sessions to additional ACP clients over WebSocket. | -| `ZCODE_ACP_REMOTE_TOKEN` | _(unset)_ | Auth token for remote access. **Mandatory** when `ZCODE_ACP_REMOTE=1`; remote stays disabled without it. | -| `ZCODE_ACP_HUB_PORT` | `8377` | Port of the machine-level hub daemon. Map exactly this one port in your tunnel. | -| `ZCODE_ACP_HUB_HOST` | `127.0.0.1` | Hub bind address. `0.0.0.0` exposes a token-only, unencrypted surface — only for a containerized tunnel agent on a private interface (see [Remote Access](#remote-access)). | -| `ZCODE_ACP_REMOTE_PORT` | `8378` | First loopback port for the bridge's ACP endpoint. Each bridge (each editor window) auto-increments to the next free port. | -| `ZCODE_ACP_SANDBOX` | _(unset)_ | Set to `1` to confine the agent's file writes with a macOS Seatbelt sandbox globally; per-project, set `"enabled": true` in `/.zcode/acp/sandbox.json` instead (see [Sandbox](#sandbox)). | -| `ZCODE_ACP_LANG` | _(inherited)_ | Language of the bridge's user-facing strings (popups, status/hint lines, command menu descriptions): `zh` or `en`. When unset, the bridge inherits the ZCode app's language (`localePreference`/`locale` in `~/.zcode/v2/setting.json`), then falls back to the `LC_ALL`/`LC_MESSAGES`/`LANG` locale, defaulting to English. | +| Variable | Default | Purpose | +| ---------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ZCODE_BIN` | _(auto-discovered)_ | Path to the ZCode CLI binary or its `.cjs` entry. Resolution order: this variable → `zcode` on `PATH` → the desktop-app bundle | +| `ZCODE_NODE` | _(discovered)_ | Explicit Node binary to run `ZCODE_BIN` with (must support `node:sqlite`) | +| `ZCODE_HOME` | `~/.zcode` | Directory that replaces `~/.zcode` as the ZCode data root. Everything the bridge reads from there follows it: credentials/provider config (`/v2/config.json`), the lazy-session alias store (`/v2/acp-lazy-sessions.json`), the desktop app's settings and tasks index (`/v2/setting.json`, `/v2/tasks-index.sqlite`), user skills (`/skills`), and the CLI config with plugin/skill/MCP enablement (`/cli/config.json` + plugin cache). Set it before starting the bridge — the credentials and tasks-index paths are resolved once at startup. | +| `ZCODE_MODEL` | _(from config)_ | Override the active model id | +| `ZCODE_BASE_URL` | _(from config)_ | Override the provider base URL | +| `ZCODE_ACP_AUTO_COMPACT_THRESHOLD` | _(unset)_ | Absolute token count that triggers automatic context compaction. After each successful turn (`end_turn`), if `contextUsed >= threshold`, the server invokes `session/compact` to free up context before the next prompt. Set to `0` or leave unset to disable (default). Example: `240000` triggers compaction at 240K tokens. The compaction target itself is decided by the ZCode backend. | +| `ZCODE_ACP_DEBUG` | _(unset)_ | Set to `1` to enable verbose diagnostic logs (event flow, probe loops, status updates). Default is quiet — only warnings (backend pipe errors, command/permission failures, lock timeouts) are emitted. Enable this when diagnosing bridge issues; the logs appear in `Zed.log` prefixed with `[zcode-acp]`. | +| `ZCODE_ACP_REMOTE` | _(unset)_ | Set to `1` to enable [remote access](#remote-access) — serve the same sessions to additional ACP clients over WebSocket. | +| `ZCODE_ACP_REMOTE_TOKEN` | _(unset)_ | Auth token for remote access. **Mandatory** when `ZCODE_ACP_REMOTE=1`; remote stays disabled without it. | +| `ZCODE_ACP_HUB_PORT` | `8377` | Port of the machine-level hub daemon. Map exactly this one port in your tunnel. | +| `ZCODE_ACP_HUB_HOST` | `127.0.0.1` | Hub bind address. `0.0.0.0` exposes a token-only, unencrypted surface — only for a containerized tunnel agent on a private interface (see [Remote Access](#remote-access)). | +| `ZCODE_ACP_REMOTE_PORT` | `8378` | First loopback port for the bridge's ACP endpoint. Each bridge (each editor window) auto-increments to the next free port. | +| `ZCODE_ACP_SANDBOX` | _(unset)_ | Set to `1` to confine the agent's file writes with a macOS Seatbelt sandbox globally; per-project, set `"enabled": true` in `/.zcode/acp/sandbox.json` instead (see [Sandbox](#sandbox)). | +| `ZCODE_ACP_LANG` | _(inherited)_ | Language of the bridge's user-facing strings (popups, status/hint lines, command menu descriptions): `zh` or `en`. When unset, the bridge inherits the ZCode app's language (`localePreference`/`locale` in `~/.zcode/v2/setting.json`), then falls back to the `LC_ALL`/`LC_MESSAGES`/`LANG` locale, defaulting to English. | ## Sandbox diff --git a/src/config/mcp-discovery.ts b/src/config/mcp-discovery.ts index 2b166e1..0bd22e3 100644 --- a/src/config/mcp-discovery.ts +++ b/src/config/mcp-discovery.ts @@ -3,20 +3,22 @@ * ZCode uses, so `/mcp` can show users exactly which servers are available. * * Sources: - * 1. ~/.zcode/cli/config.json → mcp.servers (user-configured) + * 1. /cli/config.json → mcp.servers (user-configured) * 2. Enabled plugin .mcp.json files (two formats: flat and nested) * + * `` is the ZCode data root (`~/.zcode`, or `$ZCODE_HOME` when + * set — see `zcodeHomeDir()`). + * * The ZCode backend loads these automatically and exposes their tools to the * model. This module is purely informational — it lists what's configured so * users know what's available without needing the TUI. */ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { homedir } from "node:os"; import path from "node:path"; import { messages } from "../i18n.js"; -import { compareVersions, log } from "../utils.js"; +import { compareVersions, log, zcodeCliConfigPath, zcodePluginCacheDir } from "../utils.js"; /** Information about a discovered MCP server. */ export interface McpServerInfo { @@ -44,10 +46,6 @@ interface McpServerConfig { url?: string; } -const HOME = homedir(); -const CLI_CONFIG_PATH = path.join(HOME, ".zcode", "cli", "config.json"); -const PLUGIN_CACHE_DIR = path.join(HOME, ".zcode", "cli", "plugins", "cache"); - /** * Discover all MCP servers from config.json and enabled plugins. * @@ -58,8 +56,9 @@ export function loadMcpServers(): McpServerInfo[] { let config: CliConfig | null = null; try { - if (existsSync(CLI_CONFIG_PATH)) { - config = JSON.parse(readFileSync(CLI_CONFIG_PATH, "utf8")) as CliConfig; + const cliConfigPath = zcodeCliConfigPath(); + if (existsSync(cliConfigPath)) { + config = JSON.parse(readFileSync(cliConfigPath, "utf8")) as CliConfig; } } catch (e) { log(`mcp-discovery: config read failed (${e instanceof Error ? e.message : String(e)})`); @@ -88,7 +87,7 @@ export function loadMcpServers(): McpServerInfo[] { const pluginName = pluginKey.slice(0, atIdx); const marketplace = pluginKey.slice(atIdx + 1); - const pluginDir = path.join(PLUGIN_CACHE_DIR, marketplace, pluginName); + const pluginDir = path.join(zcodePluginCacheDir(), marketplace, pluginName); if (!existsSync(pluginDir)) continue; // Find the latest version directory. diff --git a/src/config/plugin-commands.ts b/src/config/plugin-commands.ts index 8c61cec..75e6da7 100644 --- a/src/config/plugin-commands.ts +++ b/src/config/plugin-commands.ts @@ -1,34 +1,19 @@ /** - * Plugin command discovery — reads enabled plugins from `~/.zcode/cli/config.json` - * and scans their `commands/*.md` frontmatter to build slash-command entries. + * Plugin command discovery — reads enabled plugins from the ZCode CLI config + * (`/cli/config.json`) and scans their `commands/*.md` frontmatter + * to build slash-command entries. * - * Plugin commands (e.g. `/code-review`) are resolved by the ZCode backend's - * `customCommandPromptResolver` before the model sees them, so they work in - * app-server mode without bridge interception. + * `` is the ZCode data root (`~/.zcode`, or `$ZCODE_HOME` when + * set — see `zcodeHomeDir()`). Plugin commands (e.g. `/code-review`) are + * resolved by the ZCode backend's `customCommandPromptResolver` before the + * model sees them, so they work in app-server mode without bridge + * interception. */ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { homedir } from "node:os"; import path from "node:path"; -import { compareVersions, log } from "../utils.js"; - -/** Path to the ZCode CLI config (plugins, skills, mcp). */ -const CLI_CONFIG_PATH = path.join( - homedir(), - ".zcode", - "cli", - "config.json", -); - -/** Root of the plugin cache directory. */ -const PLUGIN_CACHE_DIR = path.join( - homedir(), - ".zcode", - "cli", - "plugins", - "cache", -); +import { compareVersions, log, zcodeCliConfigPath, zcodePluginCacheDir } from "../utils.js"; /** A slash-command entry compatible with `sendAvailableCommands`. */ export interface PluginCommandEntry { @@ -55,10 +40,7 @@ function parseFrontmatter(content: string): Record { const key = line.slice(0, idx).trim(); // Strip surrounding quotes from the value (YAML scalar style). let val = line.slice(idx + 1).trim(); - if ( - (val.startsWith('"') && val.endsWith('"')) || - (val.startsWith("'") && val.endsWith("'")) - ) { + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } if (key) fm[key] = val; @@ -67,15 +49,17 @@ function parseFrontmatter(content: string): Record { } /** - * Read enabled plugin commands from `~/.zcode/cli/config.json` + the plugin - * cache directory. Returns entries suitable for `available_commands_update`. + * Read enabled plugin commands from the CLI config + the plugin cache + * directory. Returns entries suitable for `available_commands_update`. * * Best-effort: failures are logged and swallowed (returns []). */ export function loadPluginCommands(): PluginCommandEntry[] { - if (!existsSync(CLI_CONFIG_PATH) || !existsSync(PLUGIN_CACHE_DIR)) return []; + const cliConfigPath = zcodeCliConfigPath(); + const pluginCacheDir = zcodePluginCacheDir(); + if (!existsSync(cliConfigPath) || !existsSync(pluginCacheDir)) return []; try { - const cfg = JSON.parse(readFileSync(CLI_CONFIG_PATH, "utf8")) as CliConfig; + const cfg = JSON.parse(readFileSync(cliConfigPath, "utf8")) as CliConfig; const enabled = cfg.plugins?.enabledPlugins ?? {}; const entries: PluginCommandEntry[] = []; @@ -88,7 +72,7 @@ export function loadPluginCommands(): PluginCommandEntry[] { const marketplace = pluginKey.slice(atIdx + 1); // Scan all versions of this plugin in the cache (use latest found). - const marketDir = path.join(PLUGIN_CACHE_DIR, marketplace); + const marketDir = path.join(pluginCacheDir, marketplace); if (!existsSync(marketDir)) continue; const pluginDir = path.join(marketDir, pluginName); if (!existsSync(pluginDir)) continue; @@ -124,9 +108,7 @@ export function loadPluginCommands(): PluginCommandEntry[] { log(`plugin-commands: loaded ${entries.length} plugin command(s)`); return entries; } catch (e) { - log( - `plugin-commands: load failed (${e instanceof Error ? e.message : String(e)})`, - ); + log(`plugin-commands: load failed (${e instanceof Error ? e.message : String(e)})`); return []; } } diff --git a/src/config/skill-discovery.ts b/src/config/skill-discovery.ts index 03c301b..37d036e 100644 --- a/src/config/skill-discovery.ts +++ b/src/config/skill-discovery.ts @@ -9,13 +9,15 @@ * and passes the text through. * * Discovery sources (in priority order — first occurrence wins on name clash): - * 1. ~/.zcode/skills/*/SKILL.md (user scope, ZCode native) + * 1. /skills/*/SKILL.md (user scope, ZCode native) * 2. ~/.agents/skills/*/SKILL.md (user scope, shared agents) * 3. enabled plugin /skills/*/SKILL.md * 4. /.agents/skills/*/SKILL.md (project scope) * - * Skills explicitly disabled in `~/.zcode/cli/config.json` (skills map with - * `enable: false`, keyed by absolute SKILL.md path) are excluded. + * `` is the ZCode data root (`~/.zcode`, or `$ZCODE_HOME` when + * set — see `zcodeHomeDir()`). Skills explicitly disabled in + * `/cli/config.json` (skills map with `enable: false`, keyed by + * absolute SKILL.md path) are excluded. */ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; @@ -23,7 +25,13 @@ import { homedir } from "node:os"; import path from "node:path"; import process from "node:process"; -import { compareVersions, log } from "../utils.js"; +import { + compareVersions, + log, + zcodeCliConfigPath, + zcodeHomeDir, + zcodePluginCacheDir, +} from "../utils.js"; /** A slash-command entry compatible with `sendAvailableCommands`. */ export interface SkillEntry { @@ -39,14 +47,6 @@ interface CliConfig { }; } -const HOME = homedir(); - -/** Path to the ZCode CLI config (skills enable/disable, plugins, mcp). */ -const CLI_CONFIG_PATH = path.join(HOME, ".zcode", "cli", "config.json"); - -/** Root of the plugin cache directory. */ -const PLUGIN_CACHE_DIR = path.join(HOME, ".zcode", "cli", "plugins", "cache"); - /** Max description length before truncation with ellipsis. */ const MAX_DESC_LEN = 80; @@ -157,8 +157,9 @@ export function loadSkillCommands(): SkillEntry[] { // Load config for disabled-skills list + enabled-plugins list. let config: CliConfig | null = null; try { - if (existsSync(CLI_CONFIG_PATH)) { - config = JSON.parse(readFileSync(CLI_CONFIG_PATH, "utf8")) as CliConfig; + const cliConfigPath = zcodeCliConfigPath(); + if (existsSync(cliConfigPath)) { + config = JSON.parse(readFileSync(cliConfigPath, "utf8")) as CliConfig; } } catch (e) { log(`skill-discovery: config read failed (${e instanceof Error ? e.message : String(e)})`); @@ -166,11 +167,11 @@ export function loadSkillCommands(): SkillEntry[] { const disabledPaths = loadDisabledSkillPaths(config); - // 1. ~/.zcode/skills/ - scanSkillDir(path.join(HOME, ".zcode", "skills"), disabledPaths, results, seen); + // 1. User skills under the ZCode data root. + scanSkillDir(path.join(zcodeHomeDir(), "skills"), disabledPaths, results, seen); // 2. ~/.agents/skills/ - scanSkillDir(path.join(HOME, ".agents", "skills"), disabledPaths, results, seen); + scanSkillDir(path.join(homedir(), ".agents", "skills"), disabledPaths, results, seen); // 3. Enabled plugin skills. const enabledPlugins = config?.plugins?.enabledPlugins ?? {}; @@ -181,7 +182,7 @@ export function loadSkillCommands(): SkillEntry[] { const pluginName = pluginKey.slice(0, atIdx); const marketplace = pluginKey.slice(atIdx + 1); - const pluginDir = path.join(PLUGIN_CACHE_DIR, marketplace, pluginName); + const pluginDir = path.join(zcodePluginCacheDir(), marketplace, pluginName); if (!existsSync(pluginDir)) continue; // Find the latest version directory. diff --git a/src/i18n.ts b/src/i18n.ts index 20cb279..6f11d78 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -6,7 +6,8 @@ * accepted, case-insensitive) * 2. The ZCode desktop app's language choice — `localePreference` (explicit * user pick), falling back to `locale` (effective), in - * ~/.zcode/v2/setting.json; absent when the app was never installed + * /v2/setting.json (the ZCode data root — `~/.zcode`, or + * `$ZCODE_HOME` when set); absent when the app was never installed * 3. LC_ALL / LC_MESSAGES / LANG — POSIX locale sniff ("zh*" → zh) * 4. English (the project ships bilingual READMEs; international default) * @@ -17,9 +18,10 @@ */ import { readFileSync } from "node:fs"; -import os from "node:os"; import path from "node:path"; +import { zcodeHomeDir } from "./utils.js"; + export type Lang = "en" | "zh"; export function resolveLanguage(env: NodeJS.ProcessEnv = process.env): Lang { @@ -54,7 +56,7 @@ function appLocale(): string | undefined { appLocaleRead = true; let locale: string | undefined; try { - const raw = readFileSync(path.join(os.homedir(), ".zcode", "v2", "setting.json"), "utf8"); + const raw = readFileSync(path.join(zcodeHomeDir(), "v2", "setting.json"), "utf8"); // Editors saving UTF-8 with a BOM leave \uFEFF in the string; JSON.parse // rejects it, which would silently fall the bridge back to English. const bomless = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw; diff --git a/src/tasks-index.ts b/src/tasks-index.ts index 0327570..a0ff7d2 100644 --- a/src/tasks-index.ts +++ b/src/tasks-index.ts @@ -17,11 +17,11 @@ */ import { existsSync, readFileSync, statSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import path from "node:path"; import { DEFAULT_MODEL_ID } from "./config/options.js"; -import { warn, ZCODE_CREDS_PATH } from "./utils.js"; +import { warn, zcodeHomeDir, ZCODE_CREDS_PATH } from "./utils.js"; // Precise DatabaseSync constructor type from @types/node, captured without a // runtime import (type position only). node:sqlite's API is prepared-statement @@ -342,18 +342,13 @@ export interface KnownWorkspace { * Whether a recorded workspace path may be offered for remote session * creation. Excludes: degenerate roots, system temp trees (macOS /tmp is a * symlink to /private/tmp — both spellings; $TMPDIR lives under /var/folders), - * and ~/.zcode itself (the config home, not a project). The directory must - * still exist — a moved/deleted project disappears from the list. + * and the ZCode data root itself (the config home, not a project). The + * directory must still exist — a moved/deleted project disappears from the + * list. */ export function isSelectableWorkspace(p: string): boolean { if (!p || p === "/") return false; - const excluded = [ - "/tmp", - "/private/tmp", - "/var/folders", - tmpdir(), - path.join(homedir(), ".zcode"), - ]; + const excluded = ["/tmp", "/private/tmp", "/var/folders", tmpdir(), zcodeHomeDir()]; for (const ex of excluded) { if (p === ex || p.startsWith(ex + path.sep)) return false; } diff --git a/src/utils.ts b/src/utils.ts index c627efd..1b43d1f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -53,6 +53,19 @@ export function zcodeHomeDir(): string { */ export const ZCODE_CREDS_PATH = path.join(zcodeHomeDir(), "v2", "config.json"); +/** + * Path to the ZCode CLI config (skills/plugins/MCP enablement). Per call, so + * discovery follows a `ZCODE_HOME` change made after import (tests). + */ +export function zcodeCliConfigPath(): string { + return path.join(zcodeHomeDir(), "cli", "config.json"); +} + +/** Root of the ZCode plugin cache directory (per call — see above). */ +export function zcodePluginCacheDir(): string { + return path.join(zcodeHomeDir(), "cli", "plugins", "cache"); +} + /** * Slash commands surfaced to the editor. Each maps to a ZCode session method * that the server forwards when the user types the command. diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index 25d448a..e479423 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -1,17 +1,29 @@ /** * i18n resolution tests: ZCODE_ACP_LANG override → ZCode app settings - * (~/.zcode/v2/setting.json, mocked fs) → POSIX locale sniff → English - * default, plus table completeness (no empty entry in either language). + * (/v2/setting.json, mocked fs — `~/.zcode` or `$ZCODE_HOME`) → + * POSIX locale sniff → English default, plus table completeness (no empty + * entry in either language). */ +import os from "node:os"; +import path from "node:path"; + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Fake settings-file content; null = file absent (ENOENT). let settingJson: string | null = null; +/** The settings path the bridge should consult for the CURRENT env — exact + * match, so a ZCODE_HOME test can serve content only at the overridden root + * (a suffix match would also serve the real `~/.zcode` spelling). */ +function expectedSettingsPath(): string { + const root = process.env.ZCODE_HOME ?? path.join(os.homedir(), ".zcode"); + return path.join(root, "v2", "setting.json"); +} + vi.mock("node:fs", () => ({ readFileSync: (p: unknown) => { - if (settingJson !== null && String(p).endsWith(".zcode/v2/setting.json")) { + if (settingJson !== null && String(p) === expectedSettingsPath()) { return settingJson; } throw new Error("ENOENT (fake)"); @@ -85,6 +97,15 @@ describe("resolveLanguage", () => { expect(resolveLanguage({})).toBe("zh"); // null value must not bust the cache }); + it("reads the app settings file from ZCODE_HOME when set", async () => { + vi.stubEnv("ZCODE_HOME", "/alt-zcode-home"); + const { resolveLanguage } = await freshModule(); + settingJson = JSON.stringify({ localePreference: "zh-CN" }); + // The mock serves content ONLY at /alt-zcode-home/v2/setting.json — the + // real ~/.zcode spelling stays absent, so "zh" proves the override. + expect(resolveLanguage({ LANG: "en_US" })).toBe("zh"); + }); + it("falls through a missing or malformed app settings file", async () => { const { resolveLanguage } = await freshModule(); settingJson = "{not json"; diff --git a/tests/mcp-list.test.ts b/tests/mcp-list.test.ts index c80d7d8..bcc38fd 100644 --- a/tests/mcp-list.test.ts +++ b/tests/mcp-list.test.ts @@ -103,6 +103,36 @@ describe("loadMcpServers", () => { expect(servers[0]!.source).toBe("config"); }); + it("reads the CLI config from ZCODE_HOME when set (real home ignored)", () => { + resetMocks(); + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ + mcp: { + servers: { + realHomeServer: { type: "stdio", command: "real" }, + }, + }, + }), + ); + const altRoot = `${HOME}/zcode-alt-home`; + mockFiles.set( + `${altRoot}/cli/config.json`, + JSON.stringify({ + mcp: { + servers: { + altHomeServer: { type: "http", url: "https://example.com/mcp" }, + }, + }, + }), + ); + vi.stubEnv("ZCODE_HOME", altRoot); + const servers = loadMcpServers(); + expect(servers).toHaveLength(1); + expect(servers[0]!.name).toBe("altHomeServer"); + expect(servers[0]!.url).toBe("https://example.com/mcp"); + }); + it("discovers http servers from config.json", () => { resetMocks(); mockFiles.set( diff --git a/tests/plugin-commands.test.ts b/tests/plugin-commands.test.ts index fcf3a80..be4f0c1 100644 --- a/tests/plugin-commands.test.ts +++ b/tests/plugin-commands.test.ts @@ -11,6 +11,7 @@ */ import { homedir } from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -51,8 +52,7 @@ vi.mock("node:fs", async () => { return entries.length > 0 ? entries : actual.readdirSync(p); }, statSync: (p: string) => { - if (mockDirs.has(p)) - return { isDirectory: () => true } as ReturnType; + if (mockDirs.has(p)) return { isDirectory: () => true } as ReturnType; return actual.statSync(p); }, }; @@ -156,6 +156,59 @@ describe("loadPluginCommands", () => { expect(loadPluginCommands()).toEqual([]); }); + it("reads the CLI config and plugin cache from ZCODE_HOME when set", () => { + resetMocks(); + + // An isolated data root that shares nothing with the real ~/.zcode: + // config + plugin cache there must be discovered, the real home ignored. + const altRoot = path.join(homedir(), "zcode-alt-home"); + const configPath = `${altRoot}/cli/config.json`; + const cacheDir = `${altRoot}/cli/plugins/cache`; + + // Real-home plugin of the same name — must NOT be picked up. + const realCache = `${homedir()}/.zcode/cli/plugins/cache`; + mockFiles.set( + `${homedir()}/.zcode/cli/config.json`, + JSON.stringify({ + plugins: { enabledPlugins: { "decoy@claude-plugins-official": true } }, + }), + ); + mockDirs.add(realCache); + mockDirs.add(`${realCache}/claude-plugins-official`); + mockDirs.add(`${realCache}/claude-plugins-official/decoy`); + mockDirs.add(`${realCache}/claude-plugins-official/decoy/0.0.0`); + mockDirs.add(`${realCache}/claude-plugins-official/decoy/0.0.0/commands`); + mockFiles.set( + `${realCache}/claude-plugins-official/decoy/0.0.0/commands/decoy.md`, + `---\ndescription: Real-home decoy command\n---\n`, + ); + + mockFiles.set( + configPath, + JSON.stringify({ + plugins: { enabledPlugins: { "code-review@claude-plugins-official": true } }, + }), + ); + mockDirs.add(cacheDir); + mockDirs.add(`${cacheDir}/claude-plugins-official`); + mockDirs.add(`${cacheDir}/claude-plugins-official/code-review`); + mockDirs.add(`${cacheDir}/claude-plugins-official/code-review/0.0.0`); + mockDirs.add(`${cacheDir}/claude-plugins-official/code-review/0.0.0/commands`); + mockFiles.set( + `${cacheDir}/claude-plugins-official/code-review/0.0.0/commands/code-review.md`, + `---\ndescription: Code review a pull request\n---\n`, + ); + + vi.stubEnv("ZCODE_HOME", altRoot); + try { + const commands = loadPluginCommands(); + expect(commands).toHaveLength(1); + expect(commands[0]!.name).toBe("code-review"); + } finally { + vi.unstubAllEnvs(); + } + }); + it("skips plugins without commands directory", () => { resetMocks(); diff --git a/tests/skill-discovery.test.ts b/tests/skill-discovery.test.ts index 8a10651..fc0bb61 100644 --- a/tests/skill-discovery.test.ts +++ b/tests/skill-discovery.test.ts @@ -274,4 +274,23 @@ describe("loadSkillCommands", () => { expect(skills).toHaveLength(1); expect(skills[0]!.input).toEqual({ hint: "What is the next session for?" }); }); + + it("reads user skills and the CLI config from ZCODE_HOME when set", () => { + resetMocks(); + + // Skills under an isolated data root must be honored; the real ~/.zcode + // must be ignored entirely. + const altRoot = path.join(HOME, "zcode-alt-home"); + setSkill(`${altRoot}/skills`, "alt-skill", "Lives in the alternate data root."); + setSkill(`${HOME}/.zcode/skills`, "real-home-skill", "Lives in the real home."); + + vi.stubEnv("ZCODE_HOME", altRoot); + try { + const skills = loadSkillCommands(); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe("$alt-skill"); + } finally { + vi.unstubAllEnvs(); + } + }); }); diff --git a/tests/tasks-index-workspaces.test.ts b/tests/tasks-index-workspaces.test.ts index 7329212..e7ce858 100644 --- a/tests/tasks-index-workspaces.test.ts +++ b/tests/tasks-index-workspaces.test.ts @@ -10,7 +10,7 @@ * so directory-existence checks need no filesystem. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Row shape the SELECT aggregates over (only the columns the query reads). interface FakeRow { @@ -30,10 +30,7 @@ let realDirs: Set; vi.mock("node:sqlite", () => { class DatabaseSync { - constructor( - path: string, - _options?: { timeout?: number }, - ) { + constructor(path: string, _options?: { timeout?: number }) { openedPaths.push(path); } prepare(sql: string) { @@ -89,6 +86,16 @@ vi.mock("node:fs", async () => { import { isSelectableWorkspace, listKnownWorkspaces } from "../src/tasks-index.js"; +// The workspace exclusion uses zcodeHomeDir(), which reads HOME from the env +// (not node:os) — pin it to the same fake home the node:os mock reports. +beforeEach(() => { + vi.stubEnv("HOME", "/fake/home"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("isSelectableWorkspace", () => { beforeEach(() => { realDirs = new Set(["/Users/dev/Develop/proj-a"]); @@ -120,6 +127,16 @@ describe("isSelectableWorkspace", () => { expect(isSelectableWorkspace("/Users/dev/work/.zcode")).toBe(true); }); + it("rejects the ZCODE_HOME override root instead of ~/.zcode when set", () => { + vi.stubEnv("ZCODE_HOME", "/alt/data-root"); + realDirs.add("/alt/data-root"); + realDirs.add("/fake/home/.zcode"); + expect(isSelectableWorkspace("/alt/data-root")).toBe(false); + expect(isSelectableWorkspace("/alt/data-root/skills")).toBe(false); + // With the override active, the real home's .zcode is an ordinary path. + expect(isSelectableWorkspace("/fake/home/.zcode")).toBe(true); + }); + it("rejects missing paths and non-directories", () => { expect(isSelectableWorkspace("/Users/dev/Develop/gone")).toBe(false); realDirs.add("/Users/dev/Develop/a-file"); @@ -139,9 +156,24 @@ describe("listKnownWorkspaces", () => { it("aggregates sessions per workspace, newest activity first", async () => { rows = [ - { workspace_key: "/Users/dev/Develop/proj-a", workspace_path: "/Users/dev/Develop/proj-a", deleted: 0, updated_at: 100 }, - { workspace_key: "/Users/dev/Develop/proj-a", workspace_path: "/Users/dev/Develop/proj-a", deleted: 0, updated_at: 300 }, - { workspace_key: "/Users/dev/Develop/proj-b", workspace_path: "/Users/dev/Develop/proj-b", deleted: 0, updated_at: 200 }, + { + workspace_key: "/Users/dev/Develop/proj-a", + workspace_path: "/Users/dev/Develop/proj-a", + deleted: 0, + updated_at: 100, + }, + { + workspace_key: "/Users/dev/Develop/proj-a", + workspace_path: "/Users/dev/Develop/proj-a", + deleted: 0, + updated_at: 300, + }, + { + workspace_key: "/Users/dev/Develop/proj-b", + workspace_path: "/Users/dev/Develop/proj-b", + deleted: 0, + updated_at: 200, + }, ]; const list = await listKnownWorkspaces("/fake/db.sqlite"); expect(list).toEqual([ @@ -152,11 +184,36 @@ describe("listKnownWorkspaces", () => { it("drops deleted rows, temp dirs, the config home, and vanished directories", async () => { rows = [ - { workspace_key: "/Users/dev/Develop/gone", workspace_path: "/Users/dev/Develop/gone", deleted: 0, updated_at: 900 }, - { workspace_key: "/tmp/scratch", workspace_path: "/tmp/scratch", deleted: 0, updated_at: 800 }, - { workspace_key: "/fake/home/.zcode", workspace_path: "/fake/home/.zcode", deleted: 0, updated_at: 700 }, - { workspace_key: "/Users/dev/Develop/proj-a", workspace_path: "/Users/dev/Develop/proj-a", deleted: 1, updated_at: 600 }, - { workspace_key: "/Users/dev/Develop/proj-b", workspace_path: "/Users/dev/Develop/proj-b", deleted: 0, updated_at: 500 }, + { + workspace_key: "/Users/dev/Develop/gone", + workspace_path: "/Users/dev/Develop/gone", + deleted: 0, + updated_at: 900, + }, + { + workspace_key: "/tmp/scratch", + workspace_path: "/tmp/scratch", + deleted: 0, + updated_at: 800, + }, + { + workspace_key: "/fake/home/.zcode", + workspace_path: "/fake/home/.zcode", + deleted: 0, + updated_at: 700, + }, + { + workspace_key: "/Users/dev/Develop/proj-a", + workspace_path: "/Users/dev/Develop/proj-a", + deleted: 1, + updated_at: 600, + }, + { + workspace_key: "/Users/dev/Develop/proj-b", + workspace_path: "/Users/dev/Develop/proj-b", + deleted: 0, + updated_at: 500, + }, ]; const list = await listKnownWorkspaces("/fake/db.sqlite"); // gone: not in realDirs → excluded. scratch: temp. .zcode: config home. @@ -168,7 +225,9 @@ describe("listKnownWorkspaces", () => { it("returns [] when the aggregate row shapes are malformed", async () => { // Simulate a schema-drifted row: workspace_path not a string. - rows = [{ workspace_key: 42 as unknown as string, workspace_path: "", deleted: 0, updated_at: 1 }]; + rows = [ + { workspace_key: 42 as unknown as string, workspace_path: "", deleted: 0, updated_at: 1 }, + ]; const list = await listKnownWorkspaces("/fake/db.sqlite"); expect(list).toEqual([]); });