From a2568ca61e0ef1ee4c7d621bae6e6abe9e16d7eb Mon Sep 17 00:00:00 2001 From: William Wang Date: Mon, 13 Jul 2026 12:29:55 +0800 Subject: [PATCH 1/7] feat: expose ZCode skill/init/plugin commands and friendly-error TUI-only commands --- src/config/plugin-commands.ts | 132 +++++++++++++ src/handlers/slash.ts | 54 +++++- src/index.ts | 19 +- src/utils.ts | 11 ++ tests/plugin-commands.test.ts | 349 ++++++++++++++++++++++++++++++++++ 5 files changed, 561 insertions(+), 4 deletions(-) create mode 100644 src/config/plugin-commands.ts create mode 100644 tests/plugin-commands.test.ts diff --git a/src/config/plugin-commands.ts b/src/config/plugin-commands.ts new file mode 100644 index 0000000..d40a1e9 --- /dev/null +++ b/src/config/plugin-commands.ts @@ -0,0 +1,132 @@ +/** + * Plugin command discovery — reads enabled plugins from `~/.zcode/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. + */ + +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { log } from "../utils.js"; + +/** Path to the ZCode CLI config (plugins, skills, mcp). */ +const CLI_CONFIG_PATH = path.join( + process.env.HOME || process.env.USERPROFILE || "~", + ".zcode", + "cli", + "config.json", +); + +/** Root of the plugin cache directory. */ +const PLUGIN_CACHE_DIR = path.join( + process.env.HOME || process.env.USERPROFILE || "~", + ".zcode", + "cli", + "plugins", + "cache", +); + +/** A slash-command entry compatible with `sendAvailableCommands`. */ +export interface PluginCommandEntry { + name: string; + description: string; + input?: { hint: string }; +} + +interface CliConfig { + plugins?: { + enabledPlugins?: Record; + }; +} + +/** Parse YAML-like frontmatter from a plugin command .md file. */ +function parseFrontmatter(content: string): Record { + const fm: Record = {}; + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return fm; + const lines = match[1]!.split(/\r?\n/); + for (const line of lines) { + const idx = line.indexOf(":"); + if (idx <= 0) continue; + 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("'")) + ) { + val = val.slice(1, -1); + } + if (key) fm[key] = val; + } + return fm; +} + +/** + * Read enabled plugin commands from `~/.zcode/cli/config.json` + 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 []; + try { + const cfg = JSON.parse(readFileSync(CLI_CONFIG_PATH, "utf8")) as CliConfig; + const enabled = cfg.plugins?.enabledPlugins ?? {}; + const entries: PluginCommandEntry[] = []; + + // enabledPlugins keys are "pluginName@marketplace" + for (const [pluginKey, enabledFlag] of Object.entries(enabled)) { + if (!enabledFlag) continue; + const atIdx = pluginKey.indexOf("@"); + if (atIdx <= 0) continue; + const pluginName = pluginKey.slice(0, atIdx); + 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); + if (!existsSync(marketDir)) continue; + const pluginDir = path.join(marketDir, pluginName); + if (!existsSync(pluginDir)) continue; + + // Find the latest version directory. + let latestVersion = ""; + for (const entry of readdirSync(pluginDir)) { + const vDir = path.join(pluginDir, entry); + if (statSync(vDir).isDirectory() && entry > latestVersion) { + latestVersion = entry; + } + } + if (!latestVersion) continue; + + const commandsDir = path.join(pluginDir, latestVersion, "commands"); + if (!existsSync(commandsDir)) continue; + + for (const file of readdirSync(commandsDir)) { + if (!file.endsWith(".md")) continue; + const cmdPath = path.join(commandsDir, file); + const content = readFileSync(cmdPath, "utf8"); + const fm = parseFrontmatter(content); + const name = file.replace(/\.md$/, ""); + const description = fm["description"] ?? ""; + if (!description) continue; + const entry: PluginCommandEntry = { name, description }; + const hint = fm["argument-hint"]; + if (hint) entry.input = { hint }; + entries.push(entry); + } + } + + 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)})`, + ); + return []; + } +} diff --git a/src/handlers/slash.ts b/src/handlers/slash.ts index 3bd13df..80d3eb4 100644 --- a/src/handlers/slash.ts +++ b/src/handlers/slash.ts @@ -6,6 +6,14 @@ * feedback `agent_message_chunk`, and return `end_turn` — never reaching the * normal turn loop. Unknown `/x` falls through to the model (extensibility). * + * Commands handled by the ZCode backend (skill/init/code-review and other + * plugin commands) are NOT intercepted here — they pass through to + * `session/send` and the backend resolves them before the model sees them. + * + * Commands that require the ZCode TUI (mcp/plugins/login/logout/new/resume/ + * locale/expert/workflow/workflows/effort/help) return a friendly error + * instead of passing raw text to the model (which would confuse it). + * * `/quota` is the exception: it does not call ZCode at all — it queries the * GLM Coding Plan usage API directly and renders the result. * @@ -25,6 +33,40 @@ import type { ZcodeAcpServer } from "../server.js"; import { sendTextChunk } from "./io.js"; import { compact, fork, rewind, steer } from "./extensions.js"; +/** + * ZCode built-in commands that require the TUI command center or interactive + * UI (selection panels, login flows, etc.). They cannot work in app-server + * mode, so we return a friendly error instead of passing raw `/cmd` text to + * the model (which would produce confusing output). + */ +const UNSUPPORTED_TUI_COMMANDS = new Set([ + "mcp", + "plugins", + "login", + "logout", + "new", + "resume", + "locale", + "expert", + "workflow", + "workflows", + "effort", + "help", +]); + +/** + * Commands resolved by the ZCode backend's `customCommandPromptResolver` or + * `executeTurn` before the model sees them. They pass through to + * `session/send` as-is. Used to decide whether to intercept or pass through. + */ +const PASSTHROUGH_COMMANDS = new Set([ + "skill", + "init", + // Plugin commands (code-review, android-dev, etc.) are also passthrough, + // but since they're dynamic we don't list them here — unknown commands + // fall through to return null (passthrough) by default. +]); + /** Try to intercept a slash command. Returns a PromptResponse when handled, null otherwise. */ export async function handleSlashCommand( server: ZcodeAcpServer, @@ -128,7 +170,17 @@ export async function handleSlashCommand( return ok(`✓ ${cmd} = ${arg}`); } default: - // Unknown /x → don't intercept, send to the model as normal text. + // Known passthrough commands (skill/init/plugin commands) → let the + // ZCode backend resolve them via customCommandPromptResolver or + // executeTurn. Don't intercept. + if (PASSTHROUGH_COMMANDS.has(cmd)) return null; + // TUI-only commands → return a friendly error instead of passing + // raw text to the model (which would confuse it). + if (UNSUPPORTED_TUI_COMMANDS.has(cmd)) { + return ok(`⚠ /${cmd} is not available in ACP mode (requires ZCode TUI)`); + } + // Truly unknown /x → don't intercept, send to the model as normal + // text (extensibility — plugin commands or future commands). return null; } } catch (e) { diff --git a/src/index.ts b/src/index.ts index 80ceb05..73ac15b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,9 +37,19 @@ import { updateRuntimeModelConfig, } from "./handlers/extensions.js"; import { sendAvailableCommandsDeferred } from "./handlers/io.js"; +import { loadPluginCommands } from "./config/plugin-commands.js"; import { ZcodeAcpServer } from "./server.js"; import { AGENT_INFO, SLASH_COMMANDS, log, warn } from "./utils.js"; +/** + * Build the full slash-command list: static bridge commands + dynamic plugin + * commands. Called once at startup (plugin commands don't change mid-session). + */ +function buildAllCommands() { + const pluginCommands = loadPluginCommands(); + return [...SLASH_COMMANDS, ...pluginCommands]; +} + async function main(): Promise { // stdout is the outbound channel to the client; stdin is inbound. const outbound = Writable.toWeb(process.stdout); @@ -48,6 +58,9 @@ async function main(): Promise { const stream = acp.ndJsonStream(outbound, inbound); const server = new ZcodeAcpServer(); + // Load plugin commands once at startup (they don't change mid-session). + const allCommands = buildAllCommands(); + /** Passthrough params parser for the ZCode-specific extension methods. */ const extParams = z.object({ sessionId: z.string() }).passthrough(); @@ -81,18 +94,18 @@ async function main(): Promise { .onRequest("initialize", (ctx) => server.initialize(ctx.params)) .onRequest("session/new", async (ctx) => { const result = await newSession(server, ctx.params); - sendAvailableCommandsDeferred(ctx.client, result.sessionId, SLASH_COMMANDS); + sendAvailableCommandsDeferred(ctx.client, result.sessionId, allCommands); return result; }) .onRequest("session/list", (ctx) => listSessions(server, ctx.params)) .onRequest("session/resume", async (ctx) => { const result = await resumeSession(server, ctx.params, ctx.client); - sendAvailableCommandsDeferred(ctx.client, ctx.params.sessionId, SLASH_COMMANDS); + sendAvailableCommandsDeferred(ctx.client, ctx.params.sessionId, allCommands); return result; }) .onRequest("session/load", async (ctx) => { const result = await loadSession(server, ctx.params, ctx.client); - sendAvailableCommandsDeferred(ctx.client, ctx.params.sessionId, SLASH_COMMANDS); + sendAvailableCommandsDeferred(ctx.client, ctx.params.sessionId, allCommands); return result; }) .onRequest("session/prompt", (ctx) => diff --git a/src/utils.ts b/src/utils.ts index 4036510..4ac12dd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -28,6 +28,11 @@ export const ZCODE_CREDS_PATH = path.join( /** * Slash commands surfaced to the editor. Each maps to a ZCode session method * that the server forwards when the user types the command. + * + * Commands handled by the bridge (compact/goal/fork/rewind/steer/model/mode/ + * thought/quota) are intercepted in `handleSlashCommand`. Commands handled by + * the ZCode backend (skill/init) and plugin commands (code-review etc.) pass + * through to `session/send` — the backend resolves them before the model. */ export const SLASH_COMMANDS = [ { name: "compact", description: "Compress conversation context (free up tokens)" }, @@ -59,6 +64,12 @@ export const SLASH_COMMANDS = [ input: { hint: "max|high|nothink" }, }, { name: "quota", description: "Show remaining usage quota (5h / weekly / MCP)" }, + { + name: "skill", + description: "List skills, or force-load one for the next prompt", + input: { hint: "[skill-name] [task]" }, + }, + { name: "init", description: "Create or update workspace AGENTS.md instructions" }, ] as const; /** Static metadata for the configOptions selects (model/mode/thought). */ diff --git a/tests/plugin-commands.test.ts b/tests/plugin-commands.test.ts new file mode 100644 index 0000000..c2aec01 --- /dev/null +++ b/tests/plugin-commands.test.ts @@ -0,0 +1,349 @@ +/** + * plugin-commands.ts + slash.ts tests — plugin command discovery and + * unsupported-command error handling. + * + * loadPluginCommands is tested by mocking node:fs to provide a synthetic + * config.json + plugin cache directory structure. + * + * handleSlashCommand's default-branch behavior is tested by checking whether + * unsupported TUI commands return a friendly error vs. unknown commands + * returning null (passthrough). + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type * as acp from "@agentclientprotocol/sdk"; + +import { ZcodeAcpServer } from "../src/server.js"; + +// --- mock fs for loadPluginCommands --- + +const mockFiles = new Map(); +const mockDirs = new Set(); + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: (p: string) => mockDirs.has(p) || mockFiles.has(p) || actual.existsSync(p), + readFileSync: (p: string) => { + if (mockFiles.has(p)) return mockFiles.get(p)!; + return actual.readFileSync(p); + }, + readdirSync: (p: string) => { + // Return mock entries if any exist in the mock store; otherwise delegate. + const entries: string[] = []; + const prefix = p + "/"; + for (const key of mockDirs) { + if (key.startsWith(prefix)) { + const rest = key.slice(prefix.length); + if (!rest.includes("/")) entries.push(rest); + } + } + for (const key of mockFiles.keys()) { + if (key.startsWith(prefix)) { + const rest = key.slice(prefix.length); + if (!rest.includes("/")) entries.push(rest); + } + } + return entries.length > 0 ? entries : actual.readdirSync(p); + }, + statSync: (p: string) => { + if (mockDirs.has(p)) + return { isDirectory: () => true } as ReturnType; + return actual.statSync(p); + }, + }; +}); + +// Import after mocks. +import { loadPluginCommands } from "../src/config/plugin-commands.js"; +import { handleSlashCommand } from "../src/handlers/slash.js"; + +function resetMocks(): void { + mockFiles.clear(); + mockDirs.clear(); +} + +/** Mock AgentContext that records notify calls. */ +function mockContext(): { cx: acp.AgentContext; sent: unknown[] } { + const sent: unknown[] = []; + const cx = { + notify(_method: string, params: { update: unknown }) { + sent.push(params.update); + return Promise.resolve(); + }, + } as unknown as acp.AgentContext; + return { cx, sent }; +} + +/** Build a minimal server for slash command tests (no backend needed). */ +function makeServer(): ZcodeAcpServer { + const s = new ZcodeAcpServer(); + return s; +} + +const SID = "sess_test"; + +afterEach(() => { + resetMocks(); +}); + +describe("loadPluginCommands", () => { + it("returns empty when config.json does not exist", () => { + resetMocks(); + // No files/dirs set up → existsSync returns false + expect(loadPluginCommands()).toEqual([]); + }); + + it("loads enabled plugin commands with description and argument-hint", () => { + resetMocks(); + + const home = process.env.HOME || "~"; + const configPath = `${home}/.zcode/cli/config.json`; + const cacheDir = `${home}/.zcode/cli/plugins/cache`; + + // Config with one enabled plugin + mockFiles.set( + configPath, + JSON.stringify({ + plugins: { + enabledPlugins: { + "code-review@claude-plugins-official": true, + "disabled-plugin@claude-plugins-official": false, + }, + }, + }), + ); + + // Plugin directory structure + 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`); + + // Plugin command file + mockFiles.set( + `${cacheDir}/claude-plugins-official/code-review/0.0.0/commands/code-review.md`, + `---\ndescription: Code review a pull request\nargument-hint: "[pr number or url]"\n---\n\nReview the PR.`, + ); + + const commands = loadPluginCommands(); + expect(commands).toHaveLength(1); + expect(commands[0]!.name).toBe("code-review"); + expect(commands[0]!.description).toBe("Code review a pull request"); + expect(commands[0]!.input).toEqual({ hint: "[pr number or url]" }); + }); + + it("skips disabled plugins", () => { + resetMocks(); + + const home = process.env.HOME || "~"; + mockFiles.set( + `${home}/.zcode/cli/config.json`, + JSON.stringify({ + plugins: { + enabledPlugins: { + "code-review@claude-plugins-official": false, + }, + }, + }), + ); + + expect(loadPluginCommands()).toEqual([]); + }); + + it("skips plugins without commands directory", () => { + resetMocks(); + + const home = process.env.HOME || "~"; + const cacheDir = `${home}/.zcode/cli/plugins/cache`; + + mockFiles.set( + `${home}/.zcode/cli/config.json`, + JSON.stringify({ + plugins: { + enabledPlugins: { + "context7@claude-plugins-official": true, + }, + }, + }), + ); + + mockDirs.add(cacheDir); + mockDirs.add(`${cacheDir}/claude-plugins-official`); + mockDirs.add(`${cacheDir}/claude-plugins-official/context7`); + mockDirs.add(`${cacheDir}/claude-plugins-official/context7/0.0.0`); + // No commands/ directory + + expect(loadPluginCommands()).toEqual([]); + }); + + it("skips command .md files without description", () => { + resetMocks(); + + const home = process.env.HOME || "~"; + const cacheDir = `${home}/.zcode/cli/plugins/cache`; + + mockFiles.set( + `${home}/.zcode/cli/config.json`, + JSON.stringify({ + plugins: { + enabledPlugins: { + "test-plugin@claude-plugins-official": true, + }, + }, + }), + ); + + mockDirs.add(cacheDir); + mockDirs.add(`${cacheDir}/claude-plugins-official`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin/1.0.0`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin/1.0.0/commands`); + + mockFiles.set( + `${cacheDir}/claude-plugins-official/test-plugin/1.0.0/commands/bad-cmd.md`, + `---\nsome-key: value\n---\n\nNo description here.`, + ); + + expect(loadPluginCommands()).toEqual([]); + }); + + it("uses the latest version directory", () => { + resetMocks(); + + const home = process.env.HOME || "~"; + const cacheDir = `${home}/.zcode/cli/plugins/cache`; + + mockFiles.set( + `${home}/.zcode/cli/config.json`, + JSON.stringify({ + plugins: { + enabledPlugins: { + "test-plugin@claude-plugins-official": true, + }, + }, + }), + ); + + mockDirs.add(cacheDir); + mockDirs.add(`${cacheDir}/claude-plugins-official`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin/1.0.0`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin/1.0.0/commands`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin/2.0.0`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin/2.0.0/commands`); + + mockFiles.set( + `${cacheDir}/claude-plugins-official/test-plugin/1.0.0/commands/old-cmd.md`, + `---\ndescription: Old version command\n---\n`, + ); + mockFiles.set( + `${cacheDir}/claude-plugins-official/test-plugin/2.0.0/commands/new-cmd.md`, + `---\ndescription: New version command\n---\n`, + ); + + const commands = loadPluginCommands(); + expect(commands).toHaveLength(1); + expect(commands[0]!.name).toBe("new-cmd"); + expect(commands[0]!.description).toBe("New version command"); + }); +}); + +describe("handleSlashCommand — unsupported TUI commands", () => { + it("returns friendly error for /mcp", async () => { + const { cx, sent } = mockContext(); + const server = makeServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "/mcp"); + expect(result).not.toBeNull(); + expect(result?.stopReason).toBe("end_turn"); + // The feedback message should mention "not available in ACP mode" + const textChunk = sent.find( + (s) => (s as { sessionUpdate?: string }).sessionUpdate === "agent_message_chunk", + ) as { content?: { text?: string } } | undefined; + expect(textChunk?.content?.text).toContain("not available in ACP mode"); + expect(textChunk?.content?.text).toContain("/mcp"); + }); + + it("returns friendly error for /plugins", async () => { + const { cx } = mockContext(); + const server = makeServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "/plugins"); + expect(result?.stopReason).toBe("end_turn"); + }); + + it("returns friendly error for /expert", async () => { + const { cx } = mockContext(); + const server = makeServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "/expert"); + expect(result?.stopReason).toBe("end_turn"); + }); + + it("returns friendly error for /login", async () => { + const { cx } = mockContext(); + const server = makeServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "/login"); + expect(result?.stopReason).toBe("end_turn"); + }); + + it("returns friendly error for /workflow", async () => { + const { cx } = mockContext(); + const server = makeServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "/workflow"); + expect(result?.stopReason).toBe("end_turn"); + }); +}); + +describe("handleSlashCommand — passthrough commands", () => { + it("returns null for /skill (backend resolves it)", async () => { + const { cx } = mockContext(); + const server = makeServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "/skill"); + expect(result).toBeNull(); + }); + + it("returns null for /skill with arguments", async () => { + const { cx } = mockContext(); + const server = makeServer(); + const result = await handleSlashCommand( + server, + cx, + SID, + SID, + "/skill diagnosing-bugs fix the login bug", + ); + expect(result).toBeNull(); + }); + + it("returns null for /init (backend resolves it)", async () => { + const { cx } = mockContext(); + const server = makeServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "/init"); + expect(result).toBeNull(); + }); + + it("returns null for unknown /x commands (extensibility)", async () => { + const { cx } = mockContext(); + const server = makeServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "/code-review 123"); + expect(result).toBeNull(); + }); + + it("returns null for /code-review (plugin command passthrough)", async () => { + const { cx } = mockContext(); + const server = makeServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "/code-review"); + expect(result).toBeNull(); + }); +}); + +describe("handleSlashCommand — non-command text", () => { + it("returns null for text not starting with /", async () => { + const { cx } = mockContext(); + const server = makeServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "hello world"); + expect(result).toBeNull(); + }); +}); From fbc47161049e74a6017b052cdf434291f7d2321a Mon Sep 17 00:00:00 2001 From: William Wang Date: Mon, 13 Jul 2026 12:48:33 +0800 Subject: [PATCH 2/7] chore: show auto-compact progress notifications to the client --- src/config/auto-compact.ts | 30 +++++++++++++++++++- tests/auto-compact.test.ts | 58 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/config/auto-compact.ts b/src/config/auto-compact.ts index f6c498c..01ce1bf 100644 --- a/src/config/auto-compact.ts +++ b/src/config/auto-compact.ts @@ -11,11 +11,14 @@ * the prompt response. */ +import { randomUUID } from "node:crypto"; + import type * as acp from "@agentclientprotocol/sdk"; import { compact } from "../handlers/extensions.js"; import type { ZcodeAcpServer } from "../server.js"; import { log, warn } from "../utils.js"; +import { sendTextChunk } from "../handlers/io.js"; /** ENV: `ZCODE_ACP_AUTO_COMPACT_THRESHOLD` — absolute token count (0 = disabled). */ export function autoCompactThreshold(): number { @@ -58,12 +61,37 @@ export async function maybeAutoCompact( if (used < threshold) return; log(`auto-compact: contextUsed=${used} >= threshold=${threshold}, compacting…`); + const msgId = randomUUID(); + await sendTextChunk( + cx, + acpSid, + `🔄 auto-compact: context usage ${used.toLocaleString()} ≥ threshold ${threshold.toLocaleString()}, compressing…`, + msgId, + ); try { // compact() handles: session/compact → waitForTurnIdle → emitInitialUsage. - await compact(server, { sessionId: acpSid }, cx); + const result = (await compact(server, { sessionId: acpSid }, cx)) as { + __lockTimeout?: boolean; + }; + if (result.__lockTimeout) { + await sendTextChunk( + cx, + acpSid, + "⚠ auto-compact timed out (300s) — backend may still be processing", + msgId, + ); + } else { + await sendTextChunk(cx, acpSid, "✓ auto-compact: context compressed", msgId); + } log("auto-compact: done"); } catch (e) { warn(`auto-compact: compact failed (${e instanceof Error ? e.message : String(e)})`); + await sendTextChunk( + cx, + acpSid, + `⚠ auto-compact failed: ${e instanceof Error ? e.message : String(e)}`, + msgId, + ); // Best-effort: never break the prompt response. } } diff --git a/tests/auto-compact.test.ts b/tests/auto-compact.test.ts index 070f851..739fd31 100644 --- a/tests/auto-compact.test.ts +++ b/tests/auto-compact.test.ts @@ -23,13 +23,20 @@ vi.mock("../src/handlers/extensions.js", () => ({ // Import AFTER mocks are registered. import { autoCompactThreshold, maybeAutoCompact } from "../src/config/auto-compact.js"; -/** Mock AgentContext that records notify calls (compact's emitInitialUsage uses it). */ -function mockContext(): acp.AgentContext { +/** Mock AgentContext that records notify calls (sendTextChunk + emitInitialUsage). */ +function mockContext(notifySpy?: ReturnType): acp.AgentContext { return { - notify: vi.fn().mockResolvedValue(undefined), + notify: notifySpy ?? vi.fn().mockResolvedValue(undefined), } as unknown as acp.AgentContext; } +/** Extract text payloads from all agent_message_chunk notifies. */ +function chunkTexts(notifySpy: ReturnType): string[] { + return notifySpy.mock.calls + .filter(([, p]) => p?.update?.sessionUpdate === "agent_message_chunk") + .map(([, p]) => p.update.content.text as string); +} + /** A fake backend whose `request` returns canned responses by method. */ interface FakeBackend { request: ReturnType; @@ -172,4 +179,49 @@ describe("maybeAutoCompact", () => { await maybeAutoCompact(server, mockContext(), "acp_1", "zc_1"); expect(compactCalls).not.toHaveBeenCalled(); }); + + it("sends progress notifications (start + done) to the client", async () => { + process.env.ZCODE_ACP_AUTO_COMPACT_THRESHOLD = "100000"; + const { server } = makeServerWithProjection(150_000); + const notifySpy = vi.fn().mockResolvedValue(undefined); + await maybeAutoCompact(server, mockContext(notifySpy), "acp_1", "zc_1"); + const texts = chunkTexts(notifySpy); + expect(texts).toHaveLength(2); + expect(texts[0]).toContain("🔄 auto-compact"); + expect(texts[0]).toContain("150,000"); + expect(texts[1]).toContain("✓ auto-compact"); + }); + + it("sends a timeout warning when __lockTimeout is true", async () => { + process.env.ZCODE_ACP_AUTO_COMPACT_THRESHOLD = "100000"; + const { server } = makeServerWithProjection(150_000); + compactMock.mockResolvedValueOnce({ __lockTimeout: true }); + const notifySpy = vi.fn().mockResolvedValue(undefined); + await maybeAutoCompact(server, mockContext(notifySpy), "acp_1", "zc_1"); + const texts = chunkTexts(notifySpy); + expect(texts).toHaveLength(2); + expect(texts[0]).toContain("🔄 auto-compact"); + expect(texts[1]).toContain("⚠ auto-compact timed out"); + }); + + it("sends an error notification when compact throws", async () => { + process.env.ZCODE_ACP_AUTO_COMPACT_THRESHOLD = "100000"; + const { server } = makeServerWithProjection(150_000); + compactMock.mockRejectedValueOnce(new Error("compact failed: backend error")); + const notifySpy = vi.fn().mockResolvedValue(undefined); + await maybeAutoCompact(server, mockContext(notifySpy), "acp_1", "zc_1"); + const texts = chunkTexts(notifySpy); + expect(texts).toHaveLength(2); + expect(texts[0]).toContain("🔄 auto-compact"); + expect(texts[1]).toContain("⚠ auto-compact failed"); + expect(texts[1]).toContain("compact failed: backend error"); + }); + + it("does not send any chunk when usage is below threshold", async () => { + process.env.ZCODE_ACP_AUTO_COMPACT_THRESHOLD = "100000"; + const { server } = makeServerWithProjection(50_000); + const notifySpy = vi.fn().mockResolvedValue(undefined); + await maybeAutoCompact(server, mockContext(notifySpy), "acp_1", "zc_1"); + expect(chunkTexts(notifySpy)).toHaveLength(0); + }); }); From cdb678b2de3c61abb1944b78beb03a3bd4e89398 Mon Sep 17 00:00:00 2001 From: William Wang Date: Mon, 13 Jul 2026 13:20:20 +0800 Subject: [PATCH 3/7] feat: expose discovered skills and MCP servers as slash commands --- src/config/mcp-discovery.ts | 194 +++++++++++++++++ src/config/skill-discovery.ts | 210 +++++++++++++++++++ src/handlers/slash.ts | 16 +- src/index.ts | 20 +- src/utils.ts | 14 +- tests/mcp-list.test.ts | 382 ++++++++++++++++++++++++++++++++++ tests/plugin-commands.test.ts | 29 +-- tests/skill-discovery.test.ts | 263 +++++++++++++++++++++++ 8 files changed, 1102 insertions(+), 26 deletions(-) create mode 100644 src/config/mcp-discovery.ts create mode 100644 src/config/skill-discovery.ts create mode 100644 tests/mcp-list.test.ts create mode 100644 tests/skill-discovery.test.ts diff --git a/src/config/mcp-discovery.ts b/src/config/mcp-discovery.ts new file mode 100644 index 0000000..e5e3401 --- /dev/null +++ b/src/config/mcp-discovery.ts @@ -0,0 +1,194 @@ +/** + * MCP server discovery — reads MCP server configurations from the same sources + * ZCode uses, so `/mcp` can show users exactly which servers are available. + * + * Sources: + * 1. ~/.zcode/cli/config.json → mcp.servers (user-configured) + * 2. Enabled plugin .mcp.json files (two formats: flat and nested) + * + * 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 path from "node:path"; +import process from "node:process"; + +import { log } from "../utils.js"; + +/** Information about a discovered MCP server. */ +export interface McpServerInfo { + name: string; + type: string; // "stdio" | "http" | "sse" | ... + command?: string; // stdio servers + args?: string[]; // stdio servers + url?: string; // http/sse servers + source: string; // "config" or "plugin: " +} + +interface CliConfig { + mcp?: { + servers?: Record; + }; + plugins?: { + enabledPlugins?: Record; + }; +} + +interface McpServerConfig { + type?: string; + command?: string; + args?: string[]; + url?: string; +} + +const HOME = process.env.HOME || process.env.USERPROFILE || "~"; +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. + * + * Best-effort: failures are logged and swallowed. + */ +export function loadMcpServers(): McpServerInfo[] { + const results: McpServerInfo[] = []; + + let config: CliConfig | null = null; + try { + if (existsSync(CLI_CONFIG_PATH)) { + config = JSON.parse(readFileSync(CLI_CONFIG_PATH, "utf8")) as CliConfig; + } + } catch (e) { + log(`mcp-discovery: config read failed (${e instanceof Error ? e.message : String(e)})`); + return results; + } + + // 1. User-configured servers from config.json. + const servers = config?.mcp?.servers ?? {}; + for (const [name, cfg] of Object.entries(servers)) { + results.push({ + name, + type: cfg.type ?? "stdio", + command: cfg.command, + args: cfg.args, + url: cfg.url, + source: "config", + }); + } + + // 2. Plugin-provided servers from .mcp.json files. + const enabledPlugins = config?.plugins?.enabledPlugins ?? {}; + for (const [pluginKey, enabledFlag] of Object.entries(enabledPlugins)) { + if (!enabledFlag) continue; + const atIdx = pluginKey.indexOf("@"); + if (atIdx <= 0) continue; + const pluginName = pluginKey.slice(0, atIdx); + const marketplace = pluginKey.slice(atIdx + 1); + + const pluginDir = path.join(PLUGIN_CACHE_DIR, marketplace, pluginName); + if (!existsSync(pluginDir)) continue; + + // Find the latest version directory. + let latestVersion = ""; + try { + for (const entry of readdirSync(pluginDir)) { + const vDir = path.join(pluginDir, entry); + if (statSync(vDir).isDirectory() && entry > latestVersion) { + latestVersion = entry; + } + } + } catch { + continue; + } + if (!latestVersion) continue; + + const mcpFile = path.join(pluginDir, latestVersion, ".mcp.json"); + if (!existsSync(mcpFile)) continue; + + try { + const mcpConfig = JSON.parse(readFileSync(mcpFile, "utf8")) as Record< + string, + Record + >; + // Two formats: + // Flat: { "server-name": { "command": "..." } } + // Nested: { "mcpServers": { "server-name": { ... } } } + const serverMap = mcpConfig["mcpServers"] ?? mcpConfig; + for (const [name, cfg] of Object.entries(serverMap)) { + results.push({ + name, + type: cfg.type ?? "stdio", + command: cfg.command, + args: cfg.args, + url: cfg.url, + source: `plugin: ${pluginName}`, + }); + } + } catch (e) { + log( + `mcp-discovery: failed to read ${mcpFile} (${e instanceof Error ? e.message : String(e)})`, + ); + } + } + + log(`mcp-discovery: found ${results.length} MCP server(s)`); + return results; +} + +/** + * Format MCP servers into a human-readable card for the `/mcp` command. + * + * Groups by source (config vs plugins) and aligns columns for readability. + */ +export function formatMcpServers(servers: McpServerInfo[]): string { + if (servers.length === 0) { + return "📡 No MCP servers configured.\nUse the ZCode desktop app to add MCP servers."; + } + + const configServers = servers.filter((s) => s.source === "config"); + const pluginServers = servers.filter((s) => s.source !== "config"); + + const lines: string[] = [`📡 MCP Servers (${servers.length})`]; + + if (configServers.length > 0) { + lines.push("", "From config.json:"); + for (const s of configServers) { + lines.push(` ${pad(s.name, 16)} ${pad(s.type, 6)} ${formatEndpoint(s)}`); + } + } + + if (pluginServers.length > 0) { + lines.push("", "From plugins:"); + for (const s of pluginServers) { + const pluginLabel = `[${s.source.replace("plugin: ", "")}]`; + lines.push(` ${pad(s.name, 16)} ${pad(s.type, 6)} ${formatEndpoint(s)} ${pluginLabel}`); + } + } + + lines.push("", "MCP tools are auto-invoked by the model when needed."); + return lines.join("\n"); +} + +/** Format the endpoint/command string for display. */ +function formatEndpoint(s: McpServerInfo): string { + if (s.url) { + try { + const u = new URL(s.url); + return u.host + u.pathname; + } catch { + return s.url; + } + } + if (s.command) { + const args = s.args?.length ? " " + s.args.join(" ") : ""; + return s.command + args; + } + return "?"; +} + +/** Pad a string to the given width for column alignment. */ +function pad(s: string, width: number): string { + return s.length >= width ? s : s + " ".repeat(width - s.length); +} diff --git a/src/config/skill-discovery.ts b/src/config/skill-discovery.ts new file mode 100644 index 0000000..754c6d6 --- /dev/null +++ b/src/config/skill-discovery.ts @@ -0,0 +1,210 @@ +/** + * Skill discovery — scans the same directories ZCode uses to discover Skills, + * reads each SKILL.md frontmatter, and returns entries suitable for the ACP + * `available_commands_update` notification. + * + * Each discovered skill becomes its own `/skill-name` slash command in the + * editor's completion menu (matching Claude Code's behaviour). The model + * resolves the invocation via its `Skill` tool — the bridge just lists them + * 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) + * 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. + */ + +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { log } from "../utils.js"; + +/** A slash-command entry compatible with `sendAvailableCommands`. */ +export interface SkillEntry { + name: string; + description: string; +} + +interface CliConfig { + skills?: Record; + plugins?: { + enabledPlugins?: Record; + }; +} + +const HOME = process.env.HOME || process.env.USERPROFILE || "~"; + +/** 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; + +/** + * Parse YAML-like frontmatter from a SKILL.md file. + * Returns a map of key→string (values have surrounding quotes stripped). + */ +function parseFrontmatter(content: string): Record { + const fm: Record = {}; + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return fm; + const lines = match[1]!.split(/\r?\n/); + for (const line of lines) { + const idx = line.indexOf(":"); + if (idx <= 0) continue; + const key = line.slice(0, idx).trim(); + let val = line.slice(idx + 1).trim(); + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + if (key) fm[key] = val; + } + return fm; +} + +/** Truncate a description to MAX_DESC_LEN, appending "…" if truncated. */ +function truncateDesc(desc: string): string { + if (desc.length <= MAX_DESC_LEN) return desc; + return desc.slice(0, MAX_DESC_LEN - 1) + "…"; +} + +/** Read the disabled skill paths from config.json (set of absolute paths). */ +function loadDisabledSkillPaths(config: CliConfig | null): Set { + const disabled = new Set(); + if (!config?.skills) return disabled; + for (const [skillPath, cfg] of Object.entries(config.skills)) { + if (cfg?.enable === false) disabled.add(skillPath); + } + return disabled; +} + +/** Scan a directory for skill subdirectories containing SKILL.md. */ +function scanSkillDir( + dir: string, + disabledPaths: Set, + results: SkillEntry[], + seen: Set, +): void { + if (!existsSync(dir)) return; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const entry of entries) { + const skillDir = path.join(dir, entry); + try { + if (!statSync(skillDir).isDirectory()) continue; + } catch { + continue; + } + const skillMd = path.join(skillDir, "SKILL.md"); + if (!existsSync(skillMd)) continue; + + // Resolve absolute path for disabled-check. + const absPath = path.resolve(skillMd); + if (disabledPaths.has(absPath)) continue; + + const content = readFileSync(skillMd, "utf8"); + const fm = parseFrontmatter(content); + const name = fm["name"] ?? entry; + const description = fm["description"] ?? ""; + if (!description) continue; + + // Skip explicitly non-user-invocable skills. + if (fm["user-invocable"] === "false") continue; + + // Prefix with $ so the editor groups skills visually (e.g. "$tdd") and + // handleSlashCommand can route by prefix. + const cmdName = `$${name}`; + if (seen.has(cmdName)) continue; + seen.add(cmdName); + results.push({ name: cmdName, description: truncateDesc(description) }); + } +} + +/** + * Discover all enabled skills from the filesystem. + * + * Returns entries suitable for `available_commands_update`. Best-effort: + * failures are logged and swallowed (returns whatever was found). + */ +export function loadSkillCommands(): SkillEntry[] { + const results: SkillEntry[] = []; + const seen = new Set(); + + // 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; + } + } 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); + + // 2. ~/.agents/skills/ + scanSkillDir(path.join(HOME, ".agents", "skills"), disabledPaths, results, seen); + + // 3. Enabled plugin skills. + const enabledPlugins = config?.plugins?.enabledPlugins ?? {}; + for (const [pluginKey, enabledFlag] of Object.entries(enabledPlugins)) { + if (!enabledFlag) continue; + const atIdx = pluginKey.indexOf("@"); + if (atIdx <= 0) continue; + const pluginName = pluginKey.slice(0, atIdx); + const marketplace = pluginKey.slice(atIdx + 1); + + const pluginDir = path.join(PLUGIN_CACHE_DIR, marketplace, pluginName); + if (!existsSync(pluginDir)) continue; + + // Find the latest version directory. + let latestVersion = ""; + try { + for (const entry of readdirSync(pluginDir)) { + const vDir = path.join(pluginDir, entry); + if (statSync(vDir).isDirectory() && entry > latestVersion) { + latestVersion = entry; + } + } + } catch { + continue; + } + if (!latestVersion) continue; + + scanSkillDir( + path.join(pluginDir, latestVersion, "skills"), + disabledPaths, + results, + seen, + ); + } + + // 4. Project-level .agents/skills/ (if exists). + scanSkillDir( + path.join(process.cwd(), ".agents", "skills"), + disabledPaths, + results, + seen, + ); + + log(`skill-discovery: loaded ${results.length} skill(s)`); + return results; +} diff --git a/src/handlers/slash.ts b/src/handlers/slash.ts index 80d3eb4..e3dfdc0 100644 --- a/src/handlers/slash.ts +++ b/src/handlers/slash.ts @@ -10,10 +10,13 @@ * plugin commands) are NOT intercepted here — they pass through to * `session/send` and the backend resolves them before the model sees them. * - * Commands that require the ZCode TUI (mcp/plugins/login/logout/new/resume/ + * Commands that require the ZCode TUI (plugins/login/logout/new/resume/ * locale/expert/workflow/workflows/effort/help) return a friendly error * instead of passing raw text to the model (which would confuse it). * + * `/mcp` lists all configured MCP servers (from config.json + plugins), + * showing the user exactly what's available without needing the TUI. + * * `/quota` is the exception: it does not call ZCode at all — it queries the * GLM Coding Plan usage API directly and renders the result. * @@ -27,6 +30,7 @@ import type * as acp from "@agentclientprotocol/sdk"; import { RequestError } from "@agentclientprotocol/sdk"; import { applyModelSwitch } from "../config/runtime-model.js"; import { emitConfigOptionUpdate } from "../config/options.js"; +import { formatMcpServers, loadMcpServers } from "../config/mcp-discovery.js"; import { formatQuota, queryQuota } from "../quota/index.js"; import { CONFIG_DISPATCH, warn } from "../utils.js"; import type { ZcodeAcpServer } from "../server.js"; @@ -40,7 +44,6 @@ import { compact, fork, rewind, steer } from "./extensions.js"; * the model (which would produce confusing output). */ const UNSUPPORTED_TUI_COMMANDS = new Set([ - "mcp", "plugins", "login", "logout", @@ -101,6 +104,11 @@ export async function handleSlashCommand( const result = await queryQuota(); return ok(formatQuota(result)); } + case "mcp": { + // Lists all configured MCP servers (from config.json + enabled plugins). + // Does not touch ZCode — reads the same config the backend auto-loads. + return ok(formatMcpServers(loadMcpServers())); + } case "compact": { const result = (await compact(server, { sessionId: acpSid }, cx)) as { __lockTimeout?: boolean; @@ -179,6 +187,10 @@ export async function handleSlashCommand( if (UNSUPPORTED_TUI_COMMANDS.has(cmd)) { return ok(`⚠ /${cmd} is not available in ACP mode (requires ZCode TUI)`); } + // $-prefixed commands are discovered Skills (e.g. /$tdd). The $ is a + // visual grouping marker for the editor's completion menu. Pass through + // as-is — the model sees /$name and resolves it via the Skill tool. + if (cmd.startsWith("$")) return null; // Truly unknown /x → don't intercept, send to the model as normal // text (extensibility — plugin commands or future commands). return null; diff --git a/src/index.ts b/src/index.ts index 73ac15b..ff8f65a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,16 +38,30 @@ import { } from "./handlers/extensions.js"; import { sendAvailableCommandsDeferred } from "./handlers/io.js"; import { loadPluginCommands } from "./config/plugin-commands.js"; +import { loadSkillCommands } from "./config/skill-discovery.js"; import { ZcodeAcpServer } from "./server.js"; import { AGENT_INFO, SLASH_COMMANDS, log, warn } from "./utils.js"; /** * Build the full slash-command list: static bridge commands + dynamic plugin - * commands. Called once at startup (plugin commands don't change mid-session). + * commands + discovered skills. Called once at startup (nothing changes + * mid-session). Deduplicates by name — static commands take priority, then + * plugins, then skills. */ function buildAllCommands() { const pluginCommands = loadPluginCommands(); - return [...SLASH_COMMANDS, ...pluginCommands]; + const skillCommands = loadSkillCommands(); + const seen = new Set(SLASH_COMMANDS.map((c) => c.name)); + const merged: Array<{ name: string; description: string; input?: { hint: string } }> = [ + ...SLASH_COMMANDS, + ]; + for (const c of [...pluginCommands, ...skillCommands]) { + if (!seen.has(c.name)) { + seen.add(c.name); + merged.push(c); + } + } + return merged; } async function main(): Promise { @@ -58,7 +72,7 @@ async function main(): Promise { const stream = acp.ndJsonStream(outbound, inbound); const server = new ZcodeAcpServer(); - // Load plugin commands once at startup (they don't change mid-session). + // Load all commands once at startup (they don't change mid-session). const allCommands = buildAllCommands(); /** Passthrough params parser for the ZCode-specific extension methods. */ diff --git a/src/utils.ts b/src/utils.ts index 4ac12dd..ade07bb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -30,9 +30,13 @@ export const ZCODE_CREDS_PATH = path.join( * that the server forwards when the user types the command. * * Commands handled by the bridge (compact/goal/fork/rewind/steer/model/mode/ - * thought/quota) are intercepted in `handleSlashCommand`. Commands handled by - * the ZCode backend (skill/init) and plugin commands (code-review etc.) pass + * thought/quota/mcp) are intercepted in `handleSlashCommand`. Commands handled + * by the ZCode backend (init) and plugin commands (code-review etc.) pass * through to `session/send` — the backend resolves them before the model. + * + * Discovered Skills (arco-design, tdd, etc.) are also appended to the command + * list at startup via `buildAllCommands()` — they pass through as normal text + * and the model resolves them via its `Skill` tool. */ export const SLASH_COMMANDS = [ { name: "compact", description: "Compress conversation context (free up tokens)" }, @@ -64,11 +68,7 @@ export const SLASH_COMMANDS = [ input: { hint: "max|high|nothink" }, }, { name: "quota", description: "Show remaining usage quota (5h / weekly / MCP)" }, - { - name: "skill", - description: "List skills, or force-load one for the next prompt", - input: { hint: "[skill-name] [task]" }, - }, + { name: "mcp", description: "List available MCP servers" }, { name: "init", description: "Create or update workspace AGENTS.md instructions" }, ] as const; diff --git a/tests/mcp-list.test.ts b/tests/mcp-list.test.ts new file mode 100644 index 0000000..200b0e9 --- /dev/null +++ b/tests/mcp-list.test.ts @@ -0,0 +1,382 @@ +/** + * mcp-discovery.ts + /mcp slash command tests — verifies that MCP servers are + * discovered from config.json and enabled plugin .mcp.json files, and that + * the /mcp command renders them as a readable card. + * + * Also verifies the /mcp slash command no longer returns "not available". + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type * as acp from "@agentclientprotocol/sdk"; +import { ZcodeAcpServer } from "../src/server.js"; + +// --- mock fs --- + +const mockFiles = new Map(); +const mockDirs = new Set(); + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: (p: string) => mockDirs.has(p) || mockFiles.has(p), + readFileSync: ((p: string, ...args: unknown[]) => { + if (mockFiles.has(p)) return mockFiles.get(p)!; + return actual.readFileSync(p, ...(args as [unknown])); + }) as typeof actual.readFileSync, + readdirSync: (p: string) => { + const entries: string[] = []; + const prefix = p + "/"; + for (const key of mockDirs) { + if (key.startsWith(prefix)) { + const rest = key.slice(prefix.length); + if (!rest.includes("/")) entries.push(rest); + } + } + for (const key of mockFiles.keys()) { + if (key.startsWith(prefix)) { + const rest = key.slice(prefix.length); + if (!rest.includes("/")) entries.push(rest); + } + } + return entries; + }, + statSync: (p: string) => { + if (mockDirs.has(p)) + return { isDirectory: () => true } as ReturnType; + return actual.statSync(p); + }, + }; +}); + +import { loadMcpServers, formatMcpServers } from "../src/config/mcp-discovery.js"; +import { handleSlashCommand } from "../src/handlers/slash.js"; + +function resetMocks(): void { + mockFiles.clear(); + mockDirs.clear(); +} + +const HOME = process.env.HOME || "~"; + +afterEach(() => { + resetMocks(); +}); + +describe("loadMcpServers", () => { + it("returns empty when config.json does not exist", () => { + resetMocks(); + expect(loadMcpServers()).toEqual([]); + }); + + it("discovers stdio servers from config.json", () => { + resetMocks(); + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ + mcp: { + servers: { + codegraph: { + type: "stdio", + command: "codegraph", + args: ["serve", "--mcp"], + }, + }, + }, + }), + ); + const servers = loadMcpServers(); + expect(servers).toHaveLength(1); + expect(servers[0]!.name).toBe("codegraph"); + expect(servers[0]!.type).toBe("stdio"); + expect(servers[0]!.command).toBe("codegraph"); + expect(servers[0]!.source).toBe("config"); + }); + + it("discovers http servers from config.json", () => { + resetMocks(); + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ + mcp: { + servers: { + devdocs: { + type: "http", + url: "https://example.com/mcp", + }, + }, + }, + }), + ); + const servers = loadMcpServers(); + expect(servers).toHaveLength(1); + expect(servers[0]!.type).toBe("http"); + expect(servers[0]!.url).toBe("https://example.com/mcp"); + }); + + it("defaults to stdio type when type is not specified", () => { + resetMocks(); + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ + mcp: { + servers: { + "no-type": { + command: "some-binary", + }, + }, + }, + }), + ); + const servers = loadMcpServers(); + expect(servers[0]!.type).toBe("stdio"); + }); + + it("discovers MCP servers from enabled plugin .mcp.json (flat format)", () => { + resetMocks(); + const cacheDir = `${HOME}/.zcode/cli/plugins/cache`; + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ + plugins: { + enabledPlugins: { + "context7@claude-plugins-official": true, + }, + }, + }), + ); + mockDirs.add(cacheDir); + mockDirs.add(`${cacheDir}/claude-plugins-official`); + mockDirs.add(`${cacheDir}/claude-plugins-official/context7`); + mockDirs.add(`${cacheDir}/claude-plugins-official/context7/0.0.0`); + mockFiles.set( + `${cacheDir}/claude-plugins-official/context7/0.0.0/.mcp.json`, + JSON.stringify({ + context7: { + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + }, + }), + ); + + const servers = loadMcpServers(); + expect(servers).toHaveLength(1); + expect(servers[0]!.name).toBe("context7"); + expect(servers[0]!.command).toBe("npx"); + expect(servers[0]!.source).toBe("plugin: context7"); + }); + + it("discovers MCP servers from enabled plugin .mcp.json (nested format)", () => { + resetMocks(); + const cacheDir = `${HOME}/.zcode/cli/plugins/cache`; + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ + plugins: { + enabledPlugins: { + "android-emulator@zcode-plugins-official": true, + }, + }, + }), + ); + mockDirs.add(cacheDir); + mockDirs.add(`${cacheDir}/zcode-plugins-official`); + mockDirs.add(`${cacheDir}/zcode-plugins-official/android-emulator`); + mockDirs.add(`${cacheDir}/zcode-plugins-official/android-emulator/0.1.0`); + mockFiles.set( + `${cacheDir}/zcode-plugins-official/android-emulator/0.1.0/.mcp.json`, + JSON.stringify({ + mcpServers: { + "android-emulator": { + command: "node", + args: ["dist/mcp/server.js"], + }, + }, + }), + ); + + const servers = loadMcpServers(); + expect(servers).toHaveLength(1); + expect(servers[0]!.name).toBe("android-emulator"); + expect(servers[0]!.command).toBe("node"); + expect(servers[0]!.source).toBe("plugin: android-emulator"); + }); + + it("skips MCP servers from disabled plugins", () => { + resetMocks(); + const cacheDir = `${HOME}/.zcode/cli/plugins/cache`; + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ + plugins: { + enabledPlugins: { + "context7@claude-plugins-official": false, + }, + }, + }), + ); + mockDirs.add(cacheDir); + mockDirs.add(`${cacheDir}/claude-plugins-official`); + mockDirs.add(`${cacheDir}/claude-plugins-official/context7`); + mockDirs.add(`${cacheDir}/claude-plugins-official/context7/0.0.0`); + mockFiles.set( + `${cacheDir}/claude-plugins-official/context7/0.0.0/.mcp.json`, + JSON.stringify({ context7: { command: "npx" } }), + ); + + expect(loadMcpServers()).toEqual([]); + }); + + it("merges servers from config.json and plugins", () => { + resetMocks(); + const cacheDir = `${HOME}/.zcode/cli/plugins/cache`; + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ + mcp: { + servers: { + codegraph: { type: "stdio", command: "codegraph" }, + }, + }, + plugins: { + enabledPlugins: { + "context7@claude-plugins-official": true, + }, + }, + }), + ); + mockDirs.add(cacheDir); + mockDirs.add(`${cacheDir}/claude-plugins-official`); + mockDirs.add(`${cacheDir}/claude-plugins-official/context7`); + mockDirs.add(`${cacheDir}/claude-plugins-official/context7/0.0.0`); + mockFiles.set( + `${cacheDir}/claude-plugins-official/context7/0.0.0/.mcp.json`, + JSON.stringify({ context7: { command: "npx" } }), + ); + + const servers = loadMcpServers(); + expect(servers).toHaveLength(2); + const names = servers.map((s) => s.name).sort(); + expect(names).toEqual(["codegraph", "context7"]); + }); +}); + +describe("formatMcpServers", () => { + it("shows a message when no servers are configured", () => { + const text = formatMcpServers([]); + expect(text).toContain("No MCP servers configured"); + }); + + it("formats config servers with name, type, and endpoint", () => { + const text = formatMcpServers([ + { + name: "codegraph", + type: "stdio", + command: "codegraph", + args: ["serve", "--mcp"], + source: "config", + }, + ]); + expect(text).toContain("MCP Servers (1)"); + expect(text).toContain("codegraph"); + expect(text).toContain("stdio"); + expect(text).toContain("codegraph serve --mcp"); + expect(text).toContain("From config.json:"); + }); + + it("formats http servers with URL host", () => { + const text = formatMcpServers([ + { + name: "devdocs", + type: "http", + url: "https://example.com/mcp", + source: "config", + }, + ]); + expect(text).toContain("example.com/mcp"); + }); + + it("formats plugin servers with plugin label", () => { + const text = formatMcpServers([ + { + name: "context7", + type: "stdio", + command: "npx", + source: "plugin: context7", + }, + ]); + expect(text).toContain("From plugins:"); + expect(text).toContain("context7"); + expect(text).toContain("[context7]"); + }); + + it("includes the auto-invoke note", () => { + const text = formatMcpServers([ + { name: "s", type: "stdio", command: "x", source: "config" }, + ]); + expect(text).toContain("auto-invoked by the model"); + }); +}); + +describe("/mcp slash command", () => { + const SID = "sess_test"; + + /** Mock AgentContext that records notify calls. */ + function mockContext(): { cx: acp.AgentContext; sent: unknown[] } { + const sent: unknown[] = []; + const cx = { + notify(_method: string, params: { update: unknown }) { + sent.push(params.update); + return Promise.resolve(); + }, + } as unknown as acp.AgentContext; + return { cx, sent }; + } + + it("returns end_turn with server list (not 'not available')", async () => { + resetMocks(); + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ + mcp: { + servers: { + codegraph: { type: "stdio", command: "codegraph", args: ["serve", "--mcp"] }, + }, + }, + }), + ); + + const { cx, sent } = mockContext(); + const server = new ZcodeAcpServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "/mcp"); + + expect(result).not.toBeNull(); + expect(result?.stopReason).toBe("end_turn"); + + const textChunk = sent.find( + (s) => (s as { sessionUpdate?: string }).sessionUpdate === "agent_message_chunk", + ) as { content?: { text?: string } } | undefined; + expect(textChunk?.content?.text).toContain("MCP Servers"); + expect(textChunk?.content?.text).toContain("codegraph"); + expect(textChunk?.content?.text).not.toContain("not available"); + }); + + it("returns end_turn with 'no servers' message when empty", async () => { + resetMocks(); + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ mcp: { servers: {} } }), + ); + + const { cx, sent } = mockContext(); + const server = new ZcodeAcpServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "/mcp"); + + expect(result?.stopReason).toBe("end_turn"); + const textChunk = sent.find( + (s) => (s as { sessionUpdate?: string }).sessionUpdate === "agent_message_chunk", + ) as { content?: { text?: string } } | undefined; + expect(textChunk?.content?.text).toContain("No MCP servers configured"); + }); +}); diff --git a/tests/plugin-commands.test.ts b/tests/plugin-commands.test.ts index c2aec01..a0c4565 100644 --- a/tests/plugin-commands.test.ts +++ b/tests/plugin-commands.test.ts @@ -253,20 +253,6 @@ describe("loadPluginCommands", () => { }); describe("handleSlashCommand — unsupported TUI commands", () => { - it("returns friendly error for /mcp", async () => { - const { cx, sent } = mockContext(); - const server = makeServer(); - const result = await handleSlashCommand(server, cx, SID, SID, "/mcp"); - expect(result).not.toBeNull(); - expect(result?.stopReason).toBe("end_turn"); - // The feedback message should mention "not available in ACP mode" - const textChunk = sent.find( - (s) => (s as { sessionUpdate?: string }).sessionUpdate === "agent_message_chunk", - ) as { content?: { text?: string } } | undefined; - expect(textChunk?.content?.text).toContain("not available in ACP mode"); - expect(textChunk?.content?.text).toContain("/mcp"); - }); - it("returns friendly error for /plugins", async () => { const { cx } = mockContext(); const server = makeServer(); @@ -337,6 +323,21 @@ describe("handleSlashCommand — passthrough commands", () => { const result = await handleSlashCommand(server, cx, SID, SID, "/code-review"); expect(result).toBeNull(); }); + + it("returns null for $-prefixed skill commands (passthrough)", async () => { + const { cx } = mockContext(); + const server = makeServer(); + // $-prefixed commands are discovered skills — pass through to the model. + const result = await handleSlashCommand(server, cx, SID, SID, "/$tdd fix the bug"); + expect(result).toBeNull(); + }); + + it("returns null for $-prefixed skill without args", async () => { + const { cx } = mockContext(); + const server = makeServer(); + const result = await handleSlashCommand(server, cx, SID, SID, "/$arco-design"); + expect(result).toBeNull(); + }); }); describe("handleSlashCommand — non-command text", () => { diff --git a/tests/skill-discovery.test.ts b/tests/skill-discovery.test.ts new file mode 100644 index 0000000..aabf49e --- /dev/null +++ b/tests/skill-discovery.test.ts @@ -0,0 +1,263 @@ +/** + * skill-discovery.ts tests — verifies that Skills are discovered from multiple + * directories, disabled skills are excluded, descriptions are truncated, and + * duplicate names are deduplicated. + * + * Uses the same node:fs mocking pattern as plugin-commands.test.ts. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +// --- mock fs --- + +const mockFiles = new Map(); +const mockDirs = new Set(); + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: (p: string) => mockDirs.has(p) || mockFiles.has(p), + readFileSync: ((p: string, ...args: unknown[]) => { + if (mockFiles.has(p)) return mockFiles.get(p)!; + return actual.readFileSync(p, ...(args as [unknown])); + }) as typeof actual.readFileSync, + readdirSync: (p: string) => { + const entries: string[] = []; + const prefix = p + "/"; + for (const key of mockDirs) { + if (key.startsWith(prefix)) { + const rest = key.slice(prefix.length); + if (!rest.includes("/")) entries.push(rest); + } + } + for (const key of mockFiles.keys()) { + if (key.startsWith(prefix)) { + const rest = key.slice(prefix.length); + if (!rest.includes("/")) entries.push(rest); + } + } + return entries; + }, + statSync: (p: string) => { + if (mockDirs.has(p)) + return { isDirectory: () => true } as ReturnType; + return actual.statSync(p); + }, + }; +}); + +import { loadSkillCommands } from "../src/config/skill-discovery.js"; + +function resetMocks(): void { + mockFiles.clear(); + mockDirs.clear(); +} + +const HOME = process.env.HOME || "~"; + +/** Helper: set up a SKILL.md file with frontmatter. */ +function setSkill(dir: string, name: string, description: string, extra = ""): void { + mockDirs.add(dir); + mockDirs.add(`${dir}/${name}`); + mockFiles.set( + `${dir}/${name}/SKILL.md`, + `---\nname: ${name}\ndescription: ${description}${extra}\n---\n\n# ${name}\n`, + ); +} + +afterEach(() => { + resetMocks(); +}); + +describe("loadSkillCommands", () => { + it("returns empty when no skill directories exist", () => { + resetMocks(); + expect(loadSkillCommands()).toEqual([]); + }); + + it("discovers skills from ~/.zcode/skills/", () => { + resetMocks(); + setSkill(`${HOME}/.zcode/skills`, "arco-design", "Arco Design React UI library reference."); + const skills = loadSkillCommands(); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe("$arco-design"); + expect(skills[0]!.description).toContain("Arco Design"); + }); + + it("discovers skills from ~/.agents/skills/", () => { + resetMocks(); + setSkill(`${HOME}/.agents/skills`, "tdd", "Test-driven development skill."); + const skills = loadSkillCommands(); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe("$tdd"); + }); + + it("discovers skills from both directories", () => { + resetMocks(); + setSkill(`${HOME}/.zcode/skills`, "arco-design", "Arco Design reference."); + setSkill(`${HOME}/.agents/skills`, "tdd", "Test-driven development."); + const skills = loadSkillCommands(); + expect(skills).toHaveLength(2); + const names = skills.map((s) => s.name).sort(); + expect(names).toEqual(["$arco-design", "$tdd"]); + }); + + it("excludes skills disabled in config.json", () => { + resetMocks(); + const skillDir = `${HOME}/.zcode/skills/my-skill`; + mockDirs.add(`${HOME}/.zcode/skills`); + mockDirs.add(skillDir); + const skillMd = `${skillDir}/SKILL.md`; + mockFiles.set(skillMd, `---\nname: my-skill\ndescription: A skill.\n---\n`); + // Also need the resolved path — path.resolve(skillMd) + const path = require("node:path"); + const resolved = path.resolve(skillMd); + + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ + skills: { + [resolved]: { enable: false }, + }, + }), + ); + + const skills = loadSkillCommands(); + expect(skills).toHaveLength(0); + }); + + it("includes skills that are not in config.json disable list", () => { + resetMocks(); + setSkill(`${HOME}/.zcode/skills`, "enabled-skill", "An enabled skill."); + // Config exists but doesn't mention this skill + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ skills: {} }), + ); + const skills = loadSkillCommands(); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe("$enabled-skill"); + }); + + it("excludes skills with user-invocable: false", () => { + resetMocks(); + setSkill( + `${HOME}/.zcode/skills`, + "internal-only", + "An internal skill.", + "\nuser-invocable: false", + ); + const skills = loadSkillCommands(); + expect(skills).toHaveLength(0); + }); + + it("truncates long descriptions to 80 characters", () => { + resetMocks(); + const longDesc = "A".repeat(120); + setSkill(`${HOME}/.zcode/skills`, "verbose-skill", longDesc); + const skills = loadSkillCommands(); + expect(skills).toHaveLength(1); + expect(skills[0]!.description.length).toBe(80); + expect(skills[0]!.description.endsWith("…")).toBe(true); + }); + + it("does not truncate short descriptions", () => { + resetMocks(); + setSkill(`${HOME}/.zcode/skills`, "short", "Short desc."); + const skills = loadSkillCommands(); + expect(skills[0]!.description).toBe("Short desc."); + }); + + it("deduplicates by name (first occurrence wins)", () => { + resetMocks(); + setSkill(`${HOME}/.zcode/skills`, "dup", "From .zcode (first)."); + setSkill(`${HOME}/.agents/skills`, "dup", "From .agents (second)."); + const skills = loadSkillCommands(); + expect(skills).toHaveLength(1); + expect(skills[0]!.description).toBe("From .zcode (first)."); + }); + + it("discovers skills from enabled plugins", () => { + resetMocks(); + const cacheDir = `${HOME}/.zcode/cli/plugins/cache`; + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ + plugins: { + enabledPlugins: { + "doc-skills@zcode-plugins-official": true, + "disabled-plugin@zcode-plugins-official": false, + }, + }, + }), + ); + mockDirs.add(cacheDir); + mockDirs.add(`${cacheDir}/zcode-plugins-official`); + mockDirs.add(`${cacheDir}/zcode-plugins-official/doc-skills`); + mockDirs.add(`${cacheDir}/zcode-plugins-official/doc-skills/1.0.0`); + mockDirs.add(`${cacheDir}/zcode-plugins-official/doc-skills/1.0.0/skills`); + setSkill( + `${cacheDir}/zcode-plugins-official/doc-skills/1.0.0/skills`, + "docx", + "DOCX document creation skill.", + ); + + const skills = loadSkillCommands(); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe("$docx"); + }); + + it("skips skills from disabled plugins", () => { + resetMocks(); + const cacheDir = `${HOME}/.zcode/cli/plugins/cache`; + mockFiles.set( + `${HOME}/.zcode/cli/config.json`, + JSON.stringify({ + plugins: { + enabledPlugins: { + "doc-skills@zcode-plugins-official": false, + }, + }, + }), + ); + mockDirs.add(cacheDir); + mockDirs.add(`${cacheDir}/zcode-plugins-official`); + mockDirs.add(`${cacheDir}/zcode-plugins-official/doc-skills`); + mockDirs.add(`${cacheDir}/zcode-plugins-official/doc-skills/1.0.0`); + mockDirs.add(`${cacheDir}/zcode-plugins-official/doc-skills/1.0.0/skills`); + setSkill( + `${cacheDir}/zcode-plugins-official/doc-skills/1.0.0/skills`, + "docx", + "DOCX skill.", + ); + + const skills = loadSkillCommands(); + expect(skills).toHaveLength(0); + }); + + it("skips SKILL.md without description", () => { + resetMocks(); + mockDirs.add(`${HOME}/.zcode/skills`); + mockDirs.add(`${HOME}/.zcode/skills/no-desc`); + mockFiles.set( + `${HOME}/.zcode/skills/no-desc/SKILL.md`, + `---\nname: no-desc\n---\n\nNo description field.`, + ); + const skills = loadSkillCommands(); + expect(skills).toHaveLength(0); + }); + + it("uses directory name when frontmatter has no name field", () => { + resetMocks(); + mockDirs.add(`${HOME}/.zcode/skills`); + mockDirs.add(`${HOME}/.zcode/skills/unnamed`); + mockFiles.set( + `${HOME}/.zcode/skills/unnamed/SKILL.md`, + `---\ndescription: Has desc but no name.\n---\n\nContent.`, + ); + const skills = loadSkillCommands(); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe("$unnamed"); + }); +}); From 0d9c97ecc31de33e9706bf6d8962841cb66292fc Mon Sep 17 00:00:00 2001 From: William Wang Date: Mon, 13 Jul 2026 15:50:58 +0800 Subject: [PATCH 4/7] fix: show Edit/Write diffs again after backend dropped toolName from result events --- AGENTS.md | 11 ++++++++ docs/agents/domain.md | 51 ++++++++++++++++++++++++++++++++++++ docs/agents/issue-tracker.md | 22 ++++++++++++++++ src/handlers/session.ts | 22 +++++++--------- 4 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..54dd4cb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Agent Instructions + +## Agent skills + +### Issue tracker + +GitHub Issues (`gh` CLI). See `docs/agents/issue-tracker.md`. + +### Domain docs + +Single-context (`CONTEXT.md` + `docs/adr/`). See `docs/agents/domain.md`. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..9fa7737 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,51 @@ +# Domain Docs + +How engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root (if it exists) — it points to one `CONTEXT.md` per context. Read each file relevant to the current topic. +- **`docs/adr/`** — read ADRs related to the area you are about to work on. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If these files don't exist, **continue silently**. Don't flag the absence; don't proactively suggest creating them. The producer skill (`/grill-with-docs`) will lazily create them when terms or decisions are actually resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (root has `CONTEXT-MAP.md`): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (issue title, refactor proposal, hypothesis, test name), use the term defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider), or there's a genuine gap (note it for `/grill-with-docs`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, call it out explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..002f187 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,22 @@ +# Issue tracker: GitHub + +This repo's issues and PRDs live in GitHub issues. All operations use the `gh` CLI. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filter comments with `jq`, and fetch labels at the same time. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'`, add `--label` and `--state` filters as needed. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +The repo is inferred from `git remote -v`; when running inside a clone, `gh` handles this automatically. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. diff --git a/src/handlers/session.ts b/src/handlers/session.ts index af5253d..97f906d 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -680,19 +680,17 @@ async function runEventTurn( await dispatchEvent(server, cx, acpSid, iev, chunkMsgId); } - // Edit/Write diff eager dispatch: on tool.updated result for Edit/Write, - // grab the structured patch from session/messages immediately (don't wait - // for turn completion — model rate-limiting could delay it indefinitely). + // Edit/Write diff eager dispatch: on tool.updated result, grab the + // structured patch from session/messages immediately (don't wait for turn + // completion — model rate-limiting could delay it indefinitely). + // + // Newer ZCode backends omit toolName on "result" events (only "scheduled" + // and "started" carry it), so we no longer filter by tool name here — + // dispatchEditDiff itself checks the tool part's display and skips + // non-file-diff tools harmlessly. if (ev.type === "tool.updated") { - const payload = ev.payload as { kind?: string; toolCallId?: string; toolName?: string }; - if ( - payload.kind === "result" && - payload.toolCallId && - (payload.toolName === "Edit" || - payload.toolName === "Write" || - payload.toolName === "edit" || - payload.toolName === "write") - ) { + const payload = ev.payload as { kind?: string; toolCallId?: string }; + if (payload.kind === "result" && payload.toolCallId) { await dispatchEditDiff( server, cx, From b02943ef3b302ba722f19f4646f15b189420a301 Mon Sep 17 00:00:00 2001 From: William Wang Date: Mon, 13 Jul 2026 17:11:26 +0800 Subject: [PATCH 5/7] fix: use semver comparison and homedir for plugin/skill/MCP discovery --- src/config/mcp-discovery.ts | 8 +++--- src/config/plugin-commands.ts | 10 +++---- src/config/skill-discovery.ts | 7 +++-- src/utils.ts | 28 +++++++++++++++++++ tests/mcp-list.test.ts | 4 ++- tests/plugin-commands.test.ts | 52 +++++++++++++++++++++++++++++++---- tests/skill-discovery.test.ts | 7 +++-- tests/utils.test.ts | 28 ++++++++++++++++++- 8 files changed, 122 insertions(+), 22 deletions(-) diff --git a/src/config/mcp-discovery.ts b/src/config/mcp-discovery.ts index e5e3401..7433cfb 100644 --- a/src/config/mcp-discovery.ts +++ b/src/config/mcp-discovery.ts @@ -12,10 +12,10 @@ */ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; import path from "node:path"; -import process from "node:process"; -import { log } from "../utils.js"; +import { compareVersions, log } from "../utils.js"; /** Information about a discovered MCP server. */ export interface McpServerInfo { @@ -43,7 +43,7 @@ interface McpServerConfig { url?: string; } -const HOME = process.env.HOME || process.env.USERPROFILE || "~"; +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"); @@ -95,7 +95,7 @@ export function loadMcpServers(): McpServerInfo[] { try { for (const entry of readdirSync(pluginDir)) { const vDir = path.join(pluginDir, entry); - if (statSync(vDir).isDirectory() && entry > latestVersion) { + if (statSync(vDir).isDirectory() && compareVersions(entry, latestVersion) > 0) { latestVersion = entry; } } diff --git a/src/config/plugin-commands.ts b/src/config/plugin-commands.ts index d40a1e9..8c61cec 100644 --- a/src/config/plugin-commands.ts +++ b/src/config/plugin-commands.ts @@ -8,14 +8,14 @@ */ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; import path from "node:path"; -import process from "node:process"; -import { log } from "../utils.js"; +import { compareVersions, log } from "../utils.js"; /** Path to the ZCode CLI config (plugins, skills, mcp). */ const CLI_CONFIG_PATH = path.join( - process.env.HOME || process.env.USERPROFILE || "~", + homedir(), ".zcode", "cli", "config.json", @@ -23,7 +23,7 @@ const CLI_CONFIG_PATH = path.join( /** Root of the plugin cache directory. */ const PLUGIN_CACHE_DIR = path.join( - process.env.HOME || process.env.USERPROFILE || "~", + homedir(), ".zcode", "cli", "plugins", @@ -97,7 +97,7 @@ export function loadPluginCommands(): PluginCommandEntry[] { let latestVersion = ""; for (const entry of readdirSync(pluginDir)) { const vDir = path.join(pluginDir, entry); - if (statSync(vDir).isDirectory() && entry > latestVersion) { + if (statSync(vDir).isDirectory() && compareVersions(entry, latestVersion) > 0) { latestVersion = entry; } } diff --git a/src/config/skill-discovery.ts b/src/config/skill-discovery.ts index 754c6d6..20b2b2e 100644 --- a/src/config/skill-discovery.ts +++ b/src/config/skill-discovery.ts @@ -19,10 +19,11 @@ */ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; import path from "node:path"; import process from "node:process"; -import { log } from "../utils.js"; +import { compareVersions, log } from "../utils.js"; /** A slash-command entry compatible with `sendAvailableCommands`. */ export interface SkillEntry { @@ -37,7 +38,7 @@ interface CliConfig { }; } -const HOME = process.env.HOME || process.env.USERPROFILE || "~"; +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"); @@ -180,7 +181,7 @@ export function loadSkillCommands(): SkillEntry[] { try { for (const entry of readdirSync(pluginDir)) { const vDir = path.join(pluginDir, entry); - if (statSync(vDir).isDirectory() && entry > latestVersion) { + if (statSync(vDir).isDirectory() && compareVersions(entry, latestVersion) > 0) { latestVersion = entry; } } diff --git a/src/utils.ts b/src/utils.ts index ade07bb..c44017b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -135,3 +135,31 @@ export function log(msg: string): void { export function warn(msg: string): void { process.stderr.write(`[zcode-acp] ${msg}\n`); } + +/** + * Compare two semver-like version strings numerically (e.g. "10.0.0" > "2.0.0"). + * Falls back to lexicographic comparison for non-numeric segments. Used by + * plugin/skill/MCP discovery to find the latest cached version directory. + * + * Returns positive if a > b, negative if a < b, 0 if equal. + */ +export function compareVersions(a: string, b: string): number { + // Empty string sorts before any version (so the first candidate always wins + // against the initial "" sentinel used by discovery loops). + if (!a && !b) return 0; + if (!a) return -1; + if (!b) return 1; + const pa = a.split("."); + const pb = b.split("."); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const na = Number(pa[i] ?? "0"); + const nb = Number(pb[i] ?? "0"); + if (Number.isNaN(na) || Number.isNaN(nb)) { + // Non-numeric segment — fall back to lexicographic. + return (pa[i] ?? "").localeCompare(pb[i] ?? ""); + } + if (na !== nb) return na - nb; + } + return 0; +} diff --git a/tests/mcp-list.test.ts b/tests/mcp-list.test.ts index 200b0e9..eded25f 100644 --- a/tests/mcp-list.test.ts +++ b/tests/mcp-list.test.ts @@ -6,6 +6,8 @@ * Also verifies the /mcp slash command no longer returns "not available". */ +import { homedir } from "node:os"; + import { afterEach, describe, expect, it, vi } from "vitest"; import type * as acp from "@agentclientprotocol/sdk"; @@ -58,7 +60,7 @@ function resetMocks(): void { mockDirs.clear(); } -const HOME = process.env.HOME || "~"; +const HOME = homedir(); afterEach(() => { resetMocks(); diff --git a/tests/plugin-commands.test.ts b/tests/plugin-commands.test.ts index a0c4565..fcf3a80 100644 --- a/tests/plugin-commands.test.ts +++ b/tests/plugin-commands.test.ts @@ -10,6 +10,8 @@ * returning null (passthrough). */ +import { homedir } from "node:os"; + import { afterEach, describe, expect, it, vi } from "vitest"; import type * as acp from "@agentclientprotocol/sdk"; @@ -99,7 +101,7 @@ describe("loadPluginCommands", () => { it("loads enabled plugin commands with description and argument-hint", () => { resetMocks(); - const home = process.env.HOME || "~"; + const home = homedir(); const configPath = `${home}/.zcode/cli/config.json`; const cacheDir = `${home}/.zcode/cli/plugins/cache`; @@ -139,7 +141,7 @@ describe("loadPluginCommands", () => { it("skips disabled plugins", () => { resetMocks(); - const home = process.env.HOME || "~"; + const home = homedir(); mockFiles.set( `${home}/.zcode/cli/config.json`, JSON.stringify({ @@ -157,7 +159,7 @@ describe("loadPluginCommands", () => { it("skips plugins without commands directory", () => { resetMocks(); - const home = process.env.HOME || "~"; + const home = homedir(); const cacheDir = `${home}/.zcode/cli/plugins/cache`; mockFiles.set( @@ -183,7 +185,7 @@ describe("loadPluginCommands", () => { it("skips command .md files without description", () => { resetMocks(); - const home = process.env.HOME || "~"; + const home = homedir(); const cacheDir = `${home}/.zcode/cli/plugins/cache`; mockFiles.set( @@ -214,7 +216,7 @@ describe("loadPluginCommands", () => { it("uses the latest version directory", () => { resetMocks(); - const home = process.env.HOME || "~"; + const home = homedir(); const cacheDir = `${home}/.zcode/cli/plugins/cache`; mockFiles.set( @@ -250,6 +252,46 @@ describe("loadPluginCommands", () => { expect(commands[0]!.name).toBe("new-cmd"); expect(commands[0]!.description).toBe("New version command"); }); + + it("uses semver comparison for multi-digit versions (10.0.0 > 2.0.0)", () => { + resetMocks(); + + const home = homedir(); + const cacheDir = `${home}/.zcode/cli/plugins/cache`; + + mockFiles.set( + `${home}/.zcode/cli/config.json`, + JSON.stringify({ + plugins: { + enabledPlugins: { + "test-plugin@claude-plugins-official": true, + }, + }, + }), + ); + + mockDirs.add(cacheDir); + mockDirs.add(`${cacheDir}/claude-plugins-official`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin/2.0.0`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin/2.0.0/commands`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin/10.0.0`); + mockDirs.add(`${cacheDir}/claude-plugins-official/test-plugin/10.0.0/commands`); + + mockFiles.set( + `${cacheDir}/claude-plugins-official/test-plugin/2.0.0/commands/old-cmd.md`, + `---\ndescription: Version 2\n---\n`, + ); + mockFiles.set( + `${cacheDir}/claude-plugins-official/test-plugin/10.0.0/commands/new-cmd.md`, + `---\ndescription: Version 10\n---\n`, + ); + + const commands = loadPluginCommands(); + expect(commands).toHaveLength(1); + expect(commands[0]!.name).toBe("new-cmd"); + expect(commands[0]!.description).toBe("Version 10"); + }); }); describe("handleSlashCommand — unsupported TUI commands", () => { diff --git a/tests/skill-discovery.test.ts b/tests/skill-discovery.test.ts index aabf49e..f017d42 100644 --- a/tests/skill-discovery.test.ts +++ b/tests/skill-discovery.test.ts @@ -6,6 +6,9 @@ * Uses the same node:fs mocking pattern as plugin-commands.test.ts. */ +import { homedir } from "node:os"; +import path from "node:path"; + import { afterEach, describe, expect, it, vi } from "vitest"; // --- mock fs --- @@ -54,7 +57,7 @@ function resetMocks(): void { mockDirs.clear(); } -const HOME = process.env.HOME || "~"; +const HOME = homedir(); /** Helper: set up a SKILL.md file with frontmatter. */ function setSkill(dir: string, name: string, description: string, extra = ""): void { @@ -110,8 +113,6 @@ describe("loadSkillCommands", () => { mockDirs.add(skillDir); const skillMd = `${skillDir}/SKILL.md`; mockFiles.set(skillMd, `---\nname: my-skill\ndescription: A skill.\n---\n`); - // Also need the resolved path — path.resolve(skillMd) - const path = require("node:path"); const resolved = path.resolve(skillMd); mockFiles.set( diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 3221d04..3c8e941 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; -import { log, warn } from "../src/utils.js"; +import { compareVersions, log, warn } from "../src/utils.js"; describe("logging", () => { const prevDebug = process.env.ZCODE_ACP_DEBUG; @@ -48,3 +48,29 @@ describe("logging", () => { expect(spy).not.toHaveBeenCalled(); }); }); + +describe("compareVersions", () => { + it("treats 10.0.0 as greater than 2.0.0 (not lexicographic)", () => { + expect(compareVersions("10.0.0", "2.0.0")).toBeGreaterThan(0); + expect(compareVersions("2.0.0", "10.0.0")).toBeLessThan(0); + }); + + it("treats 1.10.0 as greater than 1.2.0", () => { + expect(compareVersions("1.10.0", "1.2.0")).toBeGreaterThan(0); + }); + + it("returns 0 for equal versions", () => { + expect(compareVersions("1.0.0", "1.0.0")).toBe(0); + }); + + it("sorts empty string before any version (sentinel for discovery loops)", () => { + expect(compareVersions("0.0.0", "")).toBeGreaterThan(0); + expect(compareVersions("", "0.0.0")).toBeLessThan(0); + expect(compareVersions("", "")).toBe(0); + }); + + it("handles different segment counts", () => { + expect(compareVersions("1.2", "1.2.0")).toBe(0); + expect(compareVersions("1.2.1", "1.2")).toBeGreaterThan(0); + }); +}); From c2d905d22942ffd51c89268596efb74258da53d6 Mon Sep 17 00:00:00 2001 From: William Wang Date: Mon, 13 Jul 2026 17:17:15 +0800 Subject: [PATCH 6/7] docs: add triage labels and workspace instructions to AGENTS.md --- AGENTS.md | 97 ++++++++++++++++++++++++++++++++++++ docs/agents/triage-labels.md | 15 ++++++ 2 files changed, 112 insertions(+) create mode 100644 docs/agents/triage-labels.md diff --git a/AGENTS.md b/AGENTS.md index 54dd4cb..5974fc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,108 @@ # Agent Instructions +## Project overview + +**zcode-acp-server** — a Node.js bridge that connects the ZCode agent backend +(`zcode app-server --stdio`) to any ACP-compatible editor (Zed, JetBrains, …) +via JSON-RPC over stdio. Translates ACP protocol requests into ZCode session +methods and streams events back as ACP `session/update` notifications. + +## Commands + +| Task | Command | +|------|---------| +| Build | `pnpm build` | +| Typecheck | `pnpm typecheck` | +| Test (all) | `pnpm test` | +| Test (single file) | `npx vitest run tests/.test.ts` | +| Lint | `pnpm lint` | +| Format (changed files only) | `pnpm prettier --write ` | +| Smoke test | `pnpm smoke` | + +**Package manager**: pnpm. **Node**: >=22. **Module system**: ESM (`"type": "module"`). + +## Architecture + +``` +src/ +├── index.ts Entry point — wires server to stdio ACP stream +├── server.ts ZcodeAcpServer — shared state + handler registration +├── backend/ ZCode subprocess client (JSON-RPC over stdio) +│ ├── client.ts Spawns + communicates with zcode app-server +│ ├── credentials.ts Reads ~/.zcode/v2/config.json for GLM API key +│ ├── listener.ts EventStreamListener — subscribes to session/events +│ └── types.ts ZCode protocol types +├── handlers/ ACP method handlers +│ ├── session.ts session/new, session/prompt (turn loop), load, resume +│ ├── slash.ts Slash-command interception (/compact, /mcp, etc.) +│ ├── extensions.ts ZCode extensions (fork, rewind, compact, steer, …) +│ ├── dispatch.ts InternalEvent → ACP session/update dispatch +│ ├── io.ts Client notification helpers +│ └── server-requests.ts Server→client requests (permission, elicitation) +├── config/ Discovery + runtime config +│ ├── plugin-commands.ts Load plugin commands from ~/.zcode/cli/ +│ ├── skill-discovery.ts Discover Skills from filesystem +│ ├── mcp-discovery.ts Discover MCP servers from config + plugins +│ ├── auto-compact.ts Threshold-based auto-compaction +│ ├── options.ts Config options (model/mode/thought dropdowns) +│ └── runtime-model.ts Model switching overlay +├── translators/ ZCode event → ACP translation +│ ├── event-translator.ts Stream event → InternalEvent +│ ├── projection-differ.ts Snapshot diff for turn-completion reconciliation +│ └── tool-helpers.ts Diff builder, location extractor +├── interaction/ Permission, ExitPlanMode, AskUserQuestion handling +├── quota/ GLM Coding Plan usage API client (/quota command) +└── bin/quota.ts Standalone zcode-quota CLI +``` + +**Key boundary**: `backend/` talks to the ZCode subprocess. `handlers/` talks to +the ACP client (editor). `translators/` bridges the two event models. Never mix +ZCode protocol types into ACP notifications directly — always translate. + +## Conventions + +- **Logging**: use `log()` / `warn()` from `src/utils.ts`. Both write to stderr. + **Never use `console.log`** — stdout is the ACP JSON-RPC stream and any stray + output corrupts the protocol. +- **Debug logs**: `log()` is gated behind `ZCODE_ACP_DEBUG=1`. Use it for + verbose diagnostics. `warn()` is always emitted. +- **Formatting**: double quotes, semicolons, trailing commas, 100 char width. +- **Imports**: use `.js` extensions in relative imports (NodeNext resolution). + Sort imports alphabetically (ESLint `sort-imports` rule). +- **Error handling**: best-effort in event handlers — failures are logged via + `warn()`, never thrown into the event loop (would crash the bridge). +- **Tests**: mock `node:fs` with `vi.mock` and Map/Set-based fake filesystem. + See `tests/plugin-commands.test.ts` for the pattern. + +## Gotchas + +- **ZCode backend version drift**: the backend may change event payloads between + releases. When diff display or event handling breaks, check the raw backend + event with `ZCODE_ACP_DEBUG=1` before changing translator code. +- **`session/prompt` ordering**: subscribe to events BEFORE calling `session/send` + — short turns can complete before a late subscribe catches them. +- **Preempt lock**: concurrent prompts for the same session are serialized via + `withPreemptLock`. Don't bypass it — two simultaneous turns corrupt the listener. +- **AGENTS.md is workspace-scoped**: the global `~/.zcode/AGENTS.md` also exists; + this file takes precedence for this repo. + +## Docs to read before sensitive changes + +- `docs/ARCHITECTURE.md` — full architecture writeup +- `docs/PROTOCOL.md` — ACP + ZCode protocol mapping +- `docs/DEVELOPMENT.md` — dev setup and debugging guide +- `docs/TROUBLESHOOTING.md` — common issues and diagnostics + ## Agent skills ### Issue tracker GitHub Issues (`gh` CLI). See `docs/agents/issue-tracker.md`. +### Triage labels + +Five canonical roles: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`. + ### Domain docs Single-context (`CONTEXT.md` + `docs/adr/`). See `docs/agents/domain.md`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..e9669e1 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +Skills use five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill refers to a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right column to match the vocabulary you actually use. From 5103394413d4069cb12d1967420cdb2d1fa746f3 Mon Sep 17 00:00:00 2001 From: William Wang Date: Mon, 13 Jul 2026 17:21:01 +0800 Subject: [PATCH 7/7] chore: skip CI for documentation and text file changes --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bbe1ba..87c0821 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,18 @@ name: CI on: push: branches: [main] + paths-ignore: + - "**.md" + - "docs/**" + - "LICENSE" + - "*.txt" pull_request: branches: [main] + paths-ignore: + - "**.md" + - "docs/**" + - "LICENSE" + - "*.txt" permissions: contents: read