Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 16 additions & 15 deletions README.md

Large diffs are not rendered by default.

19 changes: 9 additions & 10 deletions src/config/mcp-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. <zcode-home>/cli/config.json → mcp.servers (user-configured)
* 2. Enabled plugin .mcp.json files (two formats: flat and nested)
*
* `<zcode-home>` 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 {
Expand Down Expand Up @@ -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.
*
Expand All @@ -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)})`);
Expand Down Expand Up @@ -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.
Expand Down
54 changes: 18 additions & 36 deletions src/config/plugin-commands.ts
Original file line number Diff line number Diff line change
@@ -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
* (`<zcode-home>/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.
* `<zcode-home>` 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 {
Expand All @@ -55,10 +40,7 @@ function parseFrontmatter(content: string): Record<string, string> {
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;
Expand All @@ -67,15 +49,17 @@ function parseFrontmatter(content: string): Record<string, string> {
}

/**
* 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[] = [];

Expand All @@ -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;
Expand Down Expand Up @@ -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 [];
}
}
37 changes: 19 additions & 18 deletions src/config/skill-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,29 @@
* and passes the text through.
*
* Discovery sources (in priority order — first occurrence wins on name clash):
* 1. ~/.zcode/skills/&#42;/SKILL.md (user scope, ZCode native)
* 1. <zcode-home>/skills/&#42;/SKILL.md (user scope, ZCode native)
* 2. ~/.agents/skills/&#42;/SKILL.md (user scope, shared agents)
* 3. enabled plugin <cache>/skills/&#42;/SKILL.md
* 4. <cwd>/.agents/skills/&#42;/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.
* `<zcode-home>` is the ZCode data root (`~/.zcode`, or `$ZCODE_HOME` when
* set — see `zcodeHomeDir()`). Skills explicitly disabled in
* `<zcode-home>/cli/config.json` (skills map with `enable: false`, keyed by
* absolute SKILL.md path) are excluded.
*/

import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
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 {
Expand All @@ -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;

Expand Down Expand Up @@ -157,20 +157,21 @@ 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)})`);
}

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 ?? {};
Expand All @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <zcode-home>/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)
*
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 3 additions & 4 deletions src/lazy-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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. */
Expand Down
17 changes: 6 additions & 11 deletions src/tasks-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
37 changes: 30 additions & 7 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,36 @@ 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");

/**
* 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
Expand Down
Loading
Loading