diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe92d8..9de66a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## [Unreleased] + +### Features + +* **mcp:** Grok and ZCode adapters with native formats (`.grok/config.toml`, `.zcode/mcp.json`, `~/.zcode/cli/config.json`) +* **mcp:** `layer` and `whenPathContains` so PalamHealth servers stay project-local +* **mcp:** `audit` finds PalamHealth / interactive-1Password leaks in user-global configs +* **mcp:** `sync` installs from `mcp.config.ts` only and no longer union-copies imported servers between tools + +### Bug Fixes + +* **plugins:** host capability flags match actual adapters (zcode hooks true after the ZCode hook adapter landed; antigravity no longer claims missing MCP/hooks; add cline, droid, amp, continue, gemini-cli, roo-code) +* **plugins:** install honors `layer`/`whenPathContains` instead of copying every MCP server onto a host +* **cli:** canonical `install` records tool-directory targets instead of `targets: []` +* add an MIT `LICENSE` file to match the published badge + ## [1.1.10](https://github.com/itz4blitz/ai-tools/compare/v1.1.9...v1.1.10) (2026-08-26) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54f0ae8..040be57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,7 @@ packages/ skills/ rules/ sessions/ + ui/ # interactive TUI (not a separately published engine) ``` ## Conventions diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..55758f5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 itz4blitz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ea36f96..46ea917 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next. | [Cursor](https://cursor.com) | ✓ | ✓ | ✓ | ✓ | ✓ | | [OpenCode](https://opencode.ai) | ✓ | ✓ | ✓ | ✓ | ✓ | | [Codex](https://openai.com/codex/) | ✓ | ✓ | ✓ | ✓ | ✓ | -| Grok / grok build | | | | | | -| zcode | | | | | | +| Grok / grok build | ✓ | ✓ | ✓ | ✓ | ✓ | +| zcode | ✓ | ✓ | ✓ | ✓ | ✓ | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✓ | ✓ | ✓ | ✓ | ✓ | | [Antigravity CLI](https://www.antigravity.google/product/antigravity-cli) | | | ✓ | ✓ | ✓ | | [Kiro](https://kiro.dev) | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -36,7 +36,7 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next. | [Roo Code](https://roocode.com) | | ✓ | ✓ | ✓ | ✓ | | [Windsurf](https://windsurf.com) | | ✓ | | ✓ | ✓ | -✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok and zcode adapters are not in yet. +✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok and ZCode adapters cover hooks/MCP/agents/skills/rules. ## 60-second path @@ -179,21 +179,34 @@ export default defineConfig({ ### MCP ```ts -// ai-mcp.config.ts +// mcp.config.ts import { defineConfig } from "@itz4blitz/ai-tools/mcp"; export default defineConfig({ servers: [ { + id: "filesystem", name: "filesystem", - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", "./src"], - transport: "stdio", + transport: { + type: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "./src"], + }, + layer: "project", + }, + { + id: "palamhealth", + name: "PalamHealth", + transport: { type: "stdio", command: "palamhealth-mcp" }, + layer: "project", + whenPathContains: ["PalamHealth"], }, ], }); ``` +`ai-tools mcp install --layer=project` writes only matching servers. `ai-tools mcp sync` never copies imported servers between tools. `ai-tools mcp audit` reports PalamHealth leaks in user-global configs. + ### Rules ```ts @@ -203,9 +216,10 @@ import { defineRulesConfig } from "@itz4blitz/ai-tools/rules"; export default defineRulesConfig({ rules: [ { + id: "typescript-strict", name: "typescript-strict", content: "Always use strict TypeScript. No `any` types.", - scope: "always", + scope: { type: "always" }, priority: 1, }, ], diff --git a/package.json b/package.json index ef0a9f8..52f2fa5 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "lint:fix": "oxlint --fix --tsconfig tsconfig.base.json packages/", "fmt": "oxfmt packages/", "fmt:check": "oxfmt --check packages/", - "audit": "npm audit --audit-level high", + "audit": "npm audit --omit=dev --audit-level high", "check": "npm run audit && npm run lint && npm run fmt:check && npm run typecheck && npm run test:coverage && npm run test:smoke", "release:preflight": "npm run check", "release:publish:dry": "npm publish --workspace @itz4blitz/ai-tools --access public --dry-run", diff --git a/packages/agents/src/adapters/all.ts b/packages/agents/src/adapters/all.ts index 0bd7eae..165f7b0 100644 --- a/packages/agents/src/adapters/all.ts +++ b/packages/agents/src/adapters/all.ts @@ -5,8 +5,10 @@ import "./cursor.js"; import "./cline.js"; import "./codex.js"; import "./gemini-cli.js"; +import "./grok.js"; import "./kiro.js"; import "./opencode.js"; import "./droid.js"; import "./copilot.js"; import "./roo-code.js"; +import "./zcode.js"; diff --git a/packages/agents/src/adapters/grok.test.ts b/packages/agents/src/adapters/grok.test.ts new file mode 100644 index 0000000..56f7af6 --- /dev/null +++ b/packages/agents/src/adapters/grok.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { AgentDefinition } from "../types/index.js"; + +vi.mock("./index.js", () => { + const registry = { register: vi.fn() }; + abstract class BaseAgentAdapter { + abstract readonly id: string; + abstract readonly name: string; + abstract readonly nativeSupport: boolean; + abstract readonly configDir: string; + abstract generate(agents: AgentDefinition[]): Promise; + abstract import(cwd?: string): Promise; + async detect() { + return false; + } + async install() {} + async uninstall() {} + } + return { BaseAgentAdapter, registry }; +}); + +vi.mock("node:fs", () => ({ existsSync: vi.fn(() => false) })); +vi.mock("node:fs/promises", () => ({ readFile: vi.fn(), readdir: vi.fn() })); + +import { GrokAgentAdapter } from "./grok.js"; + +describe("GrokAgentAdapter", () => { + let adapter: GrokAgentAdapter; + + beforeEach(() => { + adapter = new GrokAgentAdapter(); + }); + + it("writes markdown agents under .grok/agents", async () => { + expect(adapter.id).toBe("grok"); + expect(adapter.configDir).toBe(".grok/agents"); + const files = await adapter.generate([ + { id: "explore", name: "Explore", instructions: "Search the repo." }, + ]); + expect(files[0]?.path).toBe(".grok/agents/explore.md"); + expect(String(files[0]?.content)).toContain("# Explore"); + expect(String(files[0]?.content)).toContain("Search the repo."); + }); +}); diff --git a/packages/agents/src/adapters/grok.ts b/packages/agents/src/adapters/grok.ts new file mode 100644 index 0000000..c271c61 --- /dev/null +++ b/packages/agents/src/adapters/grok.ts @@ -0,0 +1,11 @@ +import { createMarkdownAdapter } from "./markdown-adapter.js"; + +const { Adapter: GrokAgentAdapter, adapter } = createMarkdownAdapter({ + id: "grok", + name: "Grok", + configDir: ".grok/agents", + command: "grok", +}); + +export { GrokAgentAdapter }; +export default adapter; diff --git a/packages/agents/src/adapters/opencode.test.ts b/packages/agents/src/adapters/opencode.test.ts index aee8d39..5a15a51 100644 --- a/packages/agents/src/adapters/opencode.test.ts +++ b/packages/agents/src/adapters/opencode.test.ts @@ -1,4 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { AgentDefinition } from "../types/index.js"; vi.mock("./index.js", () => { @@ -19,19 +23,17 @@ vi.mock("./index.js", () => { return { BaseAgentAdapter, registry }; }); -vi.mock("node:fs", () => ({ - existsSync: vi.fn(() => false), -})); - -vi.mock("node:fs/promises", () => ({ - readFile: vi.fn(), - readdir: vi.fn(), -})); - -import { existsSync } from "node:fs"; -import { readFile, readdir } from "node:fs/promises"; import { OpenCodeAgentAdapter } from "./opencode.js"; +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), "ai-tools-agents-opencode-")); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + describe("OpenCodeAgentAdapter", () => { let adapter: OpenCodeAgentAdapter; @@ -43,7 +45,6 @@ describe("OpenCodeAgentAdapter", () => { }; beforeEach(() => { - vi.clearAllMocks(); adapter = new OpenCodeAgentAdapter(); }); @@ -61,7 +62,9 @@ describe("OpenCodeAgentAdapter", () => { }); it("has correct configDir", () => { - expect(adapter.configDir).toBe(".opencode/agents"); + // Live OpenCode dir is agent/ (singular). Observed ~/.config/opencode/agent/crash.md + // and OpenCode binary docs: `.opencode/agent/.md`. + expect(adapter.configDir).toBe(".opencode/agent"); }); }); @@ -71,9 +74,9 @@ describe("OpenCodeAgentAdapter", () => { expect(files).toEqual([]); }); - it("generates file at correct path", async () => { + it("generates file at the live OpenCode agent path", async () => { const files = await adapter.generate([testAgent]); - expect(files[0]!.path).toBe(".opencode/agents/reviewer.md"); + expect(files[0]!.path).toBe(".opencode/agent/reviewer.md"); }); it("generates file with md format", async () => { @@ -82,29 +85,51 @@ describe("OpenCodeAgentAdapter", () => { }); }); + describe("install", () => { + it("writes markdown into .opencode/agent, not .opencode/agents", async () => { + await withTempDir(async (dir) => { + const files = await adapter.generate([testAgent]); + await adapter.install(files, dir); + + const agentPath = join(dir, ".opencode/agent/reviewer.md"); + expect(existsSync(agentPath)).toBe(true); + expect(existsSync(join(dir, ".opencode/agents/reviewer.md"))).toBe(false); + expect(await readFile(agentPath, "utf-8")).toContain("# Code Reviewer"); + }); + }); + }); + describe("import", () => { it("returns empty array when config dir does not exist", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const agents = await adapter.import("/project"); - expect(agents).toEqual([]); + await withTempDir(async (dir) => { + expect(await adapter.import(dir)).toEqual([]); + }); }); it("imports without cwd argument", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const agents = await adapter.import(); - expect(agents).toEqual([]); + await withTempDir(async (dir) => { + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir); + try { + expect(await adapter.import()).toEqual([]); + } finally { + cwdSpy.mockRestore(); + } + }); }); it("parses agent from markdown file", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["reviewer.md"] as never); - vi.mocked(readFile).mockResolvedValue( - "---\ndescription: Reviews code\n---\n\n# Code Reviewer\n\nReview.\n", - ); - - const agents = await adapter.import("/project"); - expect(agents).toHaveLength(1); - expect(agents[0]!.id).toBe("reviewer"); + await withTempDir(async (dir) => { + await mkdir(join(dir, ".opencode/agent"), { recursive: true }); + await writeFile( + join(dir, ".opencode/agent/reviewer.md"), + "---\ndescription: Reviews code\n---\n\n# Code Reviewer\n\nReview.\n", + ); + + const agents = await adapter.import(dir); + expect(agents).toHaveLength(1); + expect(agents[0]!.id).toBe("reviewer"); + expect(agents[0]!.name).toBe("Code Reviewer"); + }); }); }); }); diff --git a/packages/agents/src/adapters/opencode.ts b/packages/agents/src/adapters/opencode.ts index 67f1371..1ab99f8 100644 --- a/packages/agents/src/adapters/opencode.ts +++ b/packages/agents/src/adapters/opencode.ts @@ -3,7 +3,7 @@ import { createMarkdownAdapter } from "./markdown-adapter.js"; const { Adapter: OpenCodeAgentAdapter, adapter } = createMarkdownAdapter({ id: "opencode", name: "OpenCode", - configDir: ".opencode/agents", + configDir: ".opencode/agent", command: "opencode", }); diff --git a/packages/agents/src/adapters/zcode.test.ts b/packages/agents/src/adapters/zcode.test.ts new file mode 100644 index 0000000..0ded88b --- /dev/null +++ b/packages/agents/src/adapters/zcode.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { AgentDefinition } from "../types/index.js"; + +vi.mock("./index.js", () => { + const registry = { register: vi.fn() }; + abstract class BaseAgentAdapter { + abstract readonly id: string; + abstract readonly name: string; + abstract readonly nativeSupport: boolean; + abstract readonly configDir: string; + abstract generate(agents: AgentDefinition[]): Promise; + abstract import(cwd?: string): Promise; + async detect() { + return false; + } + async install() {} + async uninstall() {} + } + return { BaseAgentAdapter, registry }; +}); + +vi.mock("node:fs", () => ({ existsSync: vi.fn(() => false) })); +vi.mock("node:fs/promises", () => ({ readFile: vi.fn(), readdir: vi.fn() })); + +import { ZcodeAgentAdapter } from "./zcode.js"; + +describe("ZcodeAgentAdapter", () => { + it("writes markdown agents under .zcode/agents", async () => { + const adapter = new ZcodeAgentAdapter(); + expect(adapter.id).toBe("zcode"); + expect(adapter.configDir).toBe(".zcode/agents"); + const files = await adapter.generate([ + { id: "reviewer", name: "Reviewer", instructions: "Review diffs." }, + ]); + expect(files[0]?.path).toBe(".zcode/agents/reviewer.md"); + }); +}); diff --git a/packages/agents/src/adapters/zcode.ts b/packages/agents/src/adapters/zcode.ts new file mode 100644 index 0000000..9760aa1 --- /dev/null +++ b/packages/agents/src/adapters/zcode.ts @@ -0,0 +1,11 @@ +import { createMarkdownAdapter } from "./markdown-adapter.js"; + +const { Adapter: ZcodeAgentAdapter, adapter } = createMarkdownAdapter({ + id: "zcode", + name: "ZCode", + configDir: ".zcode/agents", + command: "zcode", +}); + +export { ZcodeAgentAdapter }; +export default adapter; diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..55758f5 --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 itz4blitz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cli/README.md b/packages/cli/README.md index ea36f96..46ea917 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -23,8 +23,8 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next. | [Cursor](https://cursor.com) | ✓ | ✓ | ✓ | ✓ | ✓ | | [OpenCode](https://opencode.ai) | ✓ | ✓ | ✓ | ✓ | ✓ | | [Codex](https://openai.com/codex/) | ✓ | ✓ | ✓ | ✓ | ✓ | -| Grok / grok build | | | | | | -| zcode | | | | | | +| Grok / grok build | ✓ | ✓ | ✓ | ✓ | ✓ | +| zcode | ✓ | ✓ | ✓ | ✓ | ✓ | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✓ | ✓ | ✓ | ✓ | ✓ | | [Antigravity CLI](https://www.antigravity.google/product/antigravity-cli) | | | ✓ | ✓ | ✓ | | [Kiro](https://kiro.dev) | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -36,7 +36,7 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next. | [Roo Code](https://roocode.com) | | ✓ | ✓ | ✓ | ✓ | | [Windsurf](https://windsurf.com) | | ✓ | | ✓ | ✓ | -✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok and zcode adapters are not in yet. +✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok and ZCode adapters cover hooks/MCP/agents/skills/rules. ## 60-second path @@ -179,21 +179,34 @@ export default defineConfig({ ### MCP ```ts -// ai-mcp.config.ts +// mcp.config.ts import { defineConfig } from "@itz4blitz/ai-tools/mcp"; export default defineConfig({ servers: [ { + id: "filesystem", name: "filesystem", - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", "./src"], - transport: "stdio", + transport: { + type: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "./src"], + }, + layer: "project", + }, + { + id: "palamhealth", + name: "PalamHealth", + transport: { type: "stdio", command: "palamhealth-mcp" }, + layer: "project", + whenPathContains: ["PalamHealth"], }, ], }); ``` +`ai-tools mcp install --layer=project` writes only matching servers. `ai-tools mcp sync` never copies imported servers between tools. `ai-tools mcp audit` reports PalamHealth leaks in user-global configs. + ### Rules ```ts @@ -203,9 +216,10 @@ import { defineRulesConfig } from "@itz4blitz/ai-tools/rules"; export default defineRulesConfig({ rules: [ { + id: "typescript-strict", name: "typescript-strict", content: "Always use strict TypeScript. No `any` types.", - scope: "always", + scope: { type: "always" }, priority: 1, }, ], diff --git a/packages/cli/src/canonical/commands.test.ts b/packages/cli/src/canonical/commands.test.ts index 7d89f5d..24e5567 100644 --- a/packages/cli/src/canonical/commands.test.ts +++ b/packages/cli/src/canonical/commands.test.ts @@ -45,6 +45,7 @@ import { addManagedBlock, removeManagedBlock, hasManagedBlock } from "./gitignor import { existsSync } from "node:fs"; import { rm } from "node:fs/promises"; import { cmdInit, cmdGenerate, cmdInstall, cmdStatus, cmdClean } from "./commands.js"; +import * as canonical from "./index.js"; const mockWriteConfig = vi.mocked(writeConfig); const mockIsCanonical = vi.mocked(isCanonical); @@ -92,6 +93,14 @@ function allError(): string { // ── cmdInit ───────────────────────────────────────────────── +describe("canonical barrel", () => { + it("re-exports config helpers used by the CLI", () => { + expect(canonical.writeConfig).toBe(writeConfig); + expect(canonical.readManifest).toBe(readManifest); + expect(canonical.addManagedBlock).toBe(addManagedBlock); + }); +}); + describe("cmdInit", () => { it("creates config and adds gitignore block", async () => { mockGetMode.mockResolvedValue("direct"); @@ -162,12 +171,84 @@ describe("cmdInstall", () => { it("installs all engines and updates manifest", async () => { mockIsCanonical.mockResolvedValue(true); mockReadManifest.mockResolvedValue({ version: 1, entries: [] }); + mockExistsSync.mockReturnValue(false); await cmdInstall([]); expect(mockHooksRun).toHaveBeenCalledWith(["install"]); expect(mockWriteManifest).toHaveBeenCalled(); expect(allLog()).toContain("Installed 5 engine(s)"); }); + it("replaces an existing manifest entry for the same engine", async () => { + mockIsCanonical.mockResolvedValue(true); + mockExistsSync.mockReturnValue(false); + mockReadManifest.mockResolvedValue({ + version: 1, + entries: [ + { + engine: "hooks", + id: "hooks", + canonicalPath: ".ai-tools/hooks/", + targets: [], + updatedAt: "2020-01-01T00:00:00Z", + }, + ], + }); + + await cmdInstall([]); + + const written = mockWriteManifest.mock.calls[0]?.[0] as { + entries: Array<{ engine: string; updatedAt: string }>; + }; + const hooks = written.entries.filter((entry) => entry.engine === "hooks"); + expect(hooks).toHaveLength(1); + expect(hooks[0]?.updatedAt).not.toBe("2020-01-01T00:00:00Z"); + }); + + it("continues installing after an engine throws", async () => { + mockIsCanonical.mockResolvedValue(true); + mockReadManifest.mockResolvedValue({ version: 1, entries: [] }); + mockExistsSync.mockReturnValue(false); + mockHooksRun.mockRejectedValueOnce(new Error("hooks install failed")); + mockMcpRun.mockRejectedValueOnce("mcp exploded"); + + await cmdInstall([]); + + expect(allError()).toContain("hooks install failed"); + expect(allError()).toContain("mcp exploded"); + expect(mockSkillsRun).toHaveBeenCalled(); + expect(mockWriteManifest).toHaveBeenCalled(); + }); + + it("records existing tool directories as manifest targets instead of an empty list", async () => { + mockIsCanonical.mockResolvedValue(true); + mockReadManifest.mockResolvedValue({ version: 1, entries: [] }); + mockExistsSync.mockImplementation((path: unknown) => { + const value = String(path); + return value.endsWith("/.claude") || value.endsWith("/.grok") || value.endsWith("/.zcode"); + }); + + await cmdInstall([]); + + const written = mockWriteManifest.mock.calls[0]?.[0] as { + entries: Array<{ engine: string; targets: Array<{ adapterId: string; targetPath: string }> }>; + }; + const hooksEntry = written.entries.find((entry) => entry.engine === "hooks"); + expect(hooksEntry?.targets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + adapterId: "claude-code", + targetPath: ".claude", + strategy: "transform", + status: "direct", + }), + expect.objectContaining({ adapterId: "grok", targetPath: ".grok" }), + expect.objectContaining({ adapterId: "zcode", targetPath: ".zcode" }), + ]), + ); + expect(hooksEntry?.targets).toHaveLength(3); + expect(allLog().toLowerCase()).not.toContain("canonical store"); + }); + it("respects --dry-run flag", async () => { mockIsCanonical.mockResolvedValue(true); mockReadManifest.mockResolvedValue({ version: 1, entries: [] }); diff --git a/packages/cli/src/canonical/commands.ts b/packages/cli/src/canonical/commands.ts index 270d9cb..1e111e8 100644 --- a/packages/cli/src/canonical/commands.ts +++ b/packages/cli/src/canonical/commands.ts @@ -36,18 +36,34 @@ const ENGINES: EngineModule[] = [ }, ]; -const TOOL_DIRS = [ - ".claude", - ".cursor", - ".opencode", - ".gemini", - ".cline", - ".factory", - ".codex", - ".kiro", - ".amp", +const TOOL_TARGETS: Array<{ dir: string; adapterId: string }> = [ + { dir: ".claude", adapterId: "claude-code" }, + { dir: ".cursor", adapterId: "cursor" }, + { dir: ".opencode", adapterId: "opencode" }, + { dir: ".gemini", adapterId: "gemini-cli" }, + { dir: ".cline", adapterId: "cline" }, + { dir: ".factory", adapterId: "droid" }, + { dir: ".codex", adapterId: "codex" }, + { dir: ".kiro", adapterId: "kiro" }, + { dir: ".amp", adapterId: "amp" }, + { dir: ".grok", adapterId: "grok" }, + { dir: ".zcode", adapterId: "zcode" }, + { dir: ".continue", adapterId: "continue" }, + { dir: ".roo", adapterId: "roo-code" }, + { dir: ".agents", adapterId: "antigravity-cli" }, ]; +const TOOL_DIRS = TOOL_TARGETS.map((target) => target.dir); + +function presentToolTargets(cwd: string): ManifestTarget[] { + return TOOL_TARGETS.filter((target) => existsSync(resolve(cwd, target.dir))).map((target) => ({ + adapterId: target.adapterId, + targetPath: target.dir, + strategy: "transform" as const, + status: "direct" as const, + })); +} + function hasFlag(flags: string[], flag: string): boolean { return flags.includes(flag); } @@ -80,7 +96,7 @@ export async function cmdInit(flags: string[]): Promise { console.log("Initialized .ai-tools/ in canonical mode."); console.log(""); console.log("Next steps:"); - console.log(" 1. Run: ai-tools generate (write canonical files from config)"); + console.log(" 1. Run: ai-tools generate (write engine files from configs)"); console.log(" 2. Run: ai-tools install (install to tool directories)"); console.log(" 3. Run: ai-tools status (check installation health)"); } @@ -96,7 +112,7 @@ export async function cmdGenerate(flags: string[]): Promise { return; } - console.log("Generating canonical files...\n"); + console.log("Generating engine files from configs...\n"); for (const engine of ENGINES) { try { @@ -127,10 +143,11 @@ export async function cmdInstall(flags: string[]): Promise { return; } - console.log("Installing from canonical store...\n"); + console.log("Installing engine configs into tool directories...\n"); const manifest = await readManifest(); let totalInstalled = 0; + const cwd = process.cwd(); for (const engine of ENGINES) { try { @@ -143,12 +160,11 @@ export async function cmdInstall(flags: string[]): Promise { await mod.run(installArgs); totalInstalled++; - // Record in manifest const entry: ManifestEntry = { engine: engine.name, id: engine.name, canonicalPath: `.ai-tools/${engine.name}/`, - targets: [], + targets: presentToolTargets(cwd), updatedAt: new Date().toISOString(), }; diff --git a/packages/cli/src/canonical/gitignore.test.ts b/packages/cli/src/canonical/gitignore.test.ts index eaaf679..d219da2 100644 --- a/packages/cli/src/canonical/gitignore.test.ts +++ b/packages/cli/src/canonical/gitignore.test.ts @@ -30,6 +30,12 @@ afterEach(() => { describe("addManagedBlock", () => { it("creates .gitignore with managed block when file does not exist", async () => { mockExistsSync.mockReturnValue(false); + await addManagedBlock(); + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining(".gitignore"), + expect.stringContaining("ai-tools managed"), + "utf-8", + ); await addManagedBlock("/test"); expect(mockWriteFile).toHaveBeenCalledWith( expect.stringContaining(".gitignore"), @@ -39,6 +45,8 @@ describe("addManagedBlock", () => { const written = mockWriteFile.mock.calls[0]?.[1] as string; expect(written).toContain(".claude/"); expect(written).toContain(".cursor/"); + expect(written).toContain(".grok/"); + expect(written).toContain(".zcode/"); expect(written).toContain("end ai-tools managed"); }); @@ -97,6 +105,13 @@ describe("removeManagedBlock", () => { expect(written).not.toContain("ai-tools managed"); }); + it("does nothing when the start marker is present without the end marker", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFile.mockResolvedValue("# ── ai-tools managed (canonical mode) ──\n.claude/\n"); + await removeManagedBlock("/test"); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + it("handles file with only managed block", async () => { const content = "# ── ai-tools managed (canonical mode) ──\n.claude/\n# ── end ai-tools managed ──\n"; @@ -129,7 +144,7 @@ describe("hasManagedBlock", () => { mockReadFile.mockResolvedValue( "# ── ai-tools managed (canonical mode) ──\n.claude/\n# ── end ai-tools managed ──\n", ); - const result = await hasManagedBlock("/test"); + const result = await hasManagedBlock(); expect(result).toBe(true); }); }); diff --git a/packages/cli/src/canonical/gitignore.ts b/packages/cli/src/canonical/gitignore.ts index 03f580a..ce0b2fc 100644 --- a/packages/cli/src/canonical/gitignore.ts +++ b/packages/cli/src/canonical/gitignore.ts @@ -15,6 +15,11 @@ const MANAGED_BLOCK = `${START_MARKER} .codex/ .kiro/ .amp/ +.grok/ +.zcode/ +.continue/ +.roo/ +.agents/ ${END_MARKER}`; export async function addManagedBlock(cwd?: string): Promise { diff --git a/packages/cli/src/cli/index.test.ts b/packages/cli/src/cli/index.test.ts index 1d5ea47..fa7f1fd 100644 --- a/packages/cli/src/cli/index.test.ts +++ b/packages/cli/src/cli/index.test.ts @@ -95,6 +95,23 @@ describe("run() - help output", () => { expect(output).toContain(name); } }); + + it("does not advertise unified sync as a cross-cutting command", async () => { + await run(["help"]); + const output = allLog(); + const crossCutting = output.split("CROSS-CUTTING COMMANDS:")[1]?.split("INTERACTIVE:")[0] ?? ""; + expect(crossCutting).toContain("detect"); + expect(crossCutting).not.toMatch(/\bsync\b/); + expect(output).toContain("ai-tools mcp install --layer=project"); + expect(output).toContain("ai-tools mcp sync --layer=project"); + }); + + it("does not claim canonical install uses a CanonicalStore", async () => { + await run(["help"]); + const output = allLog(); + expect(output).not.toMatch(/canonical store/i); + expect(output).toContain("Install engine configs into detected tool directories"); + }); }); // ── Unknown command ────────────────────────────────────────── @@ -219,46 +236,42 @@ describe("run() - cross-cutting detect", () => { // ── Cross-cutting sync ─────────────────────────────────────── -describe("run() - cross-cutting sync", () => { - it("calls sync on 4 engines (skips hooks and sessions)", async () => { - await run(["sync"]); +describe("run() - unified sync is decommissioned", () => { + it("refuses unified sync instead of union-copying across engines", async () => { + await expect(run(["sync"])).rejects.toThrow("process.exit(1)"); + expect(allError()).toBe( + [ + "Unified sync is disabled. It used to copy imported servers between every tool.", + "Use `ai-tools mcp sync` to install from mcp.config.ts (never copies imports; honors --layer).", + ].join("\n"), + ); + expect(exitCode).toBe(1); expect(mockHooksRun).not.toHaveBeenCalled(); + expect(mockMcpRun).not.toHaveBeenCalled(); + expect(mockSkillsRun).not.toHaveBeenCalled(); + expect(mockAgentsRun).not.toHaveBeenCalled(); + expect(mockRulesRun).not.toHaveBeenCalled(); + expect(mockPluginsRun).not.toHaveBeenCalled(); expect(mockSessionsRun).not.toHaveBeenCalled(); - expect(mockMcpRun).toHaveBeenCalledWith(["sync"]); - expect(mockSkillsRun).toHaveBeenCalledWith(["sync"]); - expect(mockAgentsRun).toHaveBeenCalledWith(["sync"]); - expect(mockRulesRun).toHaveBeenCalledWith(["sync"]); - }); - - it("prints engine headers for sync-capable engines only", async () => { - await run(["sync"]); - const output = allLog(); - expect(output).not.toContain("── hooks ──"); - expect(output).not.toContain("── sessions ──"); - expect(output).toContain("── mcp ──"); - expect(output).toContain("── skills ──"); - expect(output).toContain("── agents ──"); - expect(output).toContain("── rules ──"); }); - it("forwards flags to each engine", async () => { - await run(["sync", "--dry-run"]); - - expect(mockMcpRun).toHaveBeenCalledWith(["sync", "--dry-run"]); - expect(mockSkillsRun).toHaveBeenCalledWith(["sync", "--dry-run"]); + it("refuses unified sync even with --dry-run", async () => { + await expect(run(["sync", "--dry-run"])).rejects.toThrow("process.exit(1)"); + expect(allError()).toContain("Unified sync is disabled"); + expect(mockMcpRun).not.toHaveBeenCalled(); + expect(mockSkillsRun).not.toHaveBeenCalled(); + expect(mockAgentsRun).not.toHaveBeenCalled(); + expect(mockRulesRun).not.toHaveBeenCalled(); }); - it("catches engine errors and continues", async () => { - mockMcpRun.mockRejectedValueOnce(new Error("mcp failed")); - - await run(["sync"]); - - expect(allError()).toContain("Error: mcp failed"); - // Other engines still called - expect(mockSkillsRun).toHaveBeenCalledWith(["sync"]); - expect(mockAgentsRun).toHaveBeenCalledWith(["sync"]); - expect(mockRulesRun).toHaveBeenCalledWith(["sync"]); + it("still delegates engine-level mcp sync (config-scoped) to the mcp engine", async () => { + await run(["mcp", "sync", "--layer=project", "--dry-run"]); + expect(mockMcpRun).toHaveBeenCalledTimes(1); + expect(mockMcpRun).toHaveBeenCalledWith(["sync", "--layer=project", "--dry-run"]); + expect(mockSkillsRun).not.toHaveBeenCalled(); + expect(mockAgentsRun).not.toHaveBeenCalled(); + expect(mockRulesRun).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index d74e0f9..401d9ce 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -17,7 +17,6 @@ ENGINES: CROSS-CUTTING COMMANDS: detect Run detect across all engines - sync Run sync across supported engines (hooks excluded) help Show this help message INTERACTIVE: @@ -26,7 +25,7 @@ INTERACTIVE: CANONICAL MODE COMMANDS: init Initialize .ai-tools/ directory (canonical mode) generate Generate canonical files from engine configs - install Install from canonical store to tool directories + install Install engine configs into detected tool directories status Show installation mode and health clean Remove tool-specific directories @@ -37,12 +36,14 @@ ENGINE COMMANDS: OPTIONS: --tools Comma-separated list of tools (forwarded to engine) --dry-run Show what would happen without writing files + --layer user or project (MCP install/sync; forwarded) EXAMPLES: ${cliName} mcp detect # Detect MCP-capable tools - ${cliName} skills sync # Sync skills across tools + ${cliName} mcp install --layer=project # Install from mcp.config.ts + ${cliName} mcp sync --layer=project # Config-scoped MCP install (never copies imports) + ${cliName} mcp audit # Find PalamHealth user-global leaks ${cliName} detect # Detect across all engines - ${cliName} sync --dry-run # Sync all engines (dry run) ${cliName} plugins plan --tools=cursor,codex,opencode ${cliName} hooks init # Initialize hooks config ${cliName} init # Initialize canonical mode @@ -56,17 +57,16 @@ EXAMPLES: type EngineEntry = { name: string; pkg: string; - hasSync: boolean; }; const ENGINES: Record = { - hooks: { name: "hooks", pkg: "@itz4blitz/ai-tools-hooks/cli", hasSync: false }, - mcp: { name: "mcp", pkg: "@itz4blitz/ai-tools-mcp/cli", hasSync: true }, - skills: { name: "skills", pkg: "@itz4blitz/ai-tools-skills/cli", hasSync: true }, - agents: { name: "agents", pkg: "@itz4blitz/ai-tools-agents/cli", hasSync: true }, - rules: { name: "rules", pkg: "@itz4blitz/ai-tools-rules/cli", hasSync: true }, - plugins: { name: "plugins", pkg: "./plugins", hasSync: false }, - sessions: { name: "sessions", pkg: "@itz4blitz/ai-tools-sessions/cli", hasSync: false }, + hooks: { name: "hooks", pkg: "@itz4blitz/ai-tools-hooks/cli" }, + mcp: { name: "mcp", pkg: "@itz4blitz/ai-tools-mcp/cli" }, + skills: { name: "skills", pkg: "@itz4blitz/ai-tools-skills/cli" }, + agents: { name: "agents", pkg: "@itz4blitz/ai-tools-agents/cli" }, + rules: { name: "rules", pkg: "@itz4blitz/ai-tools-rules/cli" }, + plugins: { name: "plugins", pkg: "./plugins" }, + sessions: { name: "sessions", pkg: "@itz4blitz/ai-tools-sessions/cli" }, }; const ENGINE_NAMES = Object.keys(ENGINES); @@ -101,7 +101,13 @@ export async function run(args: string[]): Promise { return; case "sync": - await crossCutSync(args.slice(1)); + console.error( + "Unified sync is disabled. It used to copy imported servers between every tool.", + ); + console.error( + "Use `ai-tools mcp sync` to install from mcp.config.ts (never copies imports; honors --layer).", + ); + process.exit(1); return; case "init": @@ -159,18 +165,3 @@ async function crossCutDetect(flags: string[]): Promise { } } } - -async function crossCutSync(flags: string[]): Promise { - for (const name of ENGINE_NAMES) { - const engine = ENGINES[name]; - if (!engine || !engine.hasSync) continue; - - console.log(`\n── ${engine.name} ──`); - try { - const mod = await loadEngine(engine.pkg); - await mod.run(["sync", ...flags]); - } catch (err) { - console.error(` Error: ${err instanceof Error ? err.message : String(err)}`); - } - } -} diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts new file mode 100644 index 0000000..b1ba4f3 --- /dev/null +++ b/packages/cli/src/index.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { ENGINE_NAMES } from "./index.js"; + +describe("ENGINE_NAMES", () => { + it("lists every engine the unified CLI actually routes", () => { + expect([...ENGINE_NAMES]).toEqual([ + "hooks", + "mcp", + "skills", + "agents", + "rules", + "plugins", + "sessions", + ]); + }); +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6bca61f..3be94b7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,7 +4,15 @@ export { buildPluginInstallPlan } from "./plugins/plan.js"; export { installPluginBundle } from "./plugins/install.js"; export { KNOWN_PLUGIN_HOSTS, getPluginHostInfo } from "./plugins/hosts.js"; -export const ENGINE_NAMES = ["hooks", "mcp", "skills", "agents", "rules", "plugins"] as const; +export const ENGINE_NAMES = [ + "hooks", + "mcp", + "skills", + "agents", + "rules", + "plugins", + "sessions", +] as const; export type EngineName = (typeof ENGINE_NAMES)[number]; export type { diff --git a/packages/cli/src/plugins/hosts.test.ts b/packages/cli/src/plugins/hosts.test.ts new file mode 100644 index 0000000..91d2f18 --- /dev/null +++ b/packages/cli/src/plugins/hosts.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { getPluginHostInfo, KNOWN_PLUGIN_HOSTS } from "./hosts.js"; +import type { PluginEngine } from "./types.js"; + +const ENGINES: PluginEngine[] = ["mcp", "skills", "rules", "agents", "hooks"]; + +/** Adapter files that actually exist under packages//src/adapters. */ +const ADAPTER_SUPPORT: Record> = { + "claude-code": { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + "claude-desktop": { mcp: true, skills: false, rules: false, agents: false, hooks: false }, + codex: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + cursor: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + opencode: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + grok: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + zcode: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + "antigravity-cli": { mcp: false, skills: true, rules: true, agents: true, hooks: false }, + windsurf: { mcp: true, skills: true, rules: true, agents: false, hooks: false }, + copilot: { mcp: true, skills: true, rules: true, agents: true, hooks: false }, + kiro: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + cline: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + droid: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + amp: { mcp: true, skills: true, rules: true, agents: false, hooks: false }, + continue: { mcp: false, skills: true, rules: true, agents: false, hooks: false }, + "gemini-cli": { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + "roo-code": { mcp: true, skills: true, rules: true, agents: true, hooks: false }, +}; + +describe("KNOWN_PLUGIN_HOSTS", () => { + it("lists every adapter-backed host", () => { + expect(Object.keys(KNOWN_PLUGIN_HOSTS).toSorted()).toEqual( + Object.keys(ADAPTER_SUPPORT).toSorted(), + ); + }); + + it("sets nativeEngineSupport to match real adapters, not wishful flags", () => { + for (const [id, expected] of Object.entries(ADAPTER_SUPPORT)) { + const host = KNOWN_PLUGIN_HOSTS[id]; + expect(host, `missing host ${id}`).toBeDefined(); + expect(host?.nativeEngineSupport).toEqual(expected); + } + }); + + it("does not claim antigravity MCP/hooks without adapters", () => { + expect(KNOWN_PLUGIN_HOSTS["antigravity-cli"]?.nativeEngineSupport.mcp).toBe(false); + expect(KNOWN_PLUGIN_HOSTS["antigravity-cli"]?.nativeEngineSupport.hooks).toBe(false); + }); + + it("claims zcode hooks because packages/hooks registers a zcode adapter", () => { + expect(KNOWN_PLUGIN_HOSTS.zcode?.nativeEngineSupport.hooks).toBe(true); + }); + + it("fills every PluginEngine key on each host", () => { + for (const host of Object.values(KNOWN_PLUGIN_HOSTS)) { + for (const engine of ENGINES) { + expect(typeof host.nativeEngineSupport[engine]).toBe("boolean"); + } + } + }); +}); + +describe("getPluginHostInfo", () => { + it("returns researched metadata for known hosts", () => { + expect(getPluginHostInfo("grok").name).toBe("Grok"); + expect(getPluginHostInfo("droid").nativeEngineSupport.hooks).toBe(true); + }); + + it("returns all-false support for unknown hosts instead of inventing adapters", () => { + const host = getPluginHostInfo("not-a-real-tool"); + expect(host).toEqual({ + id: "not-a-real-tool", + name: "not-a-real-tool", + kind: "cli", + nativeEngineSupport: { mcp: false, skills: false, rules: false, agents: false, hooks: false }, + supportsInteractiveApps: false, + supportsNativeBundles: false, + notes: "No researched host metadata is available for this tool yet.", + }); + }); +}); diff --git a/packages/cli/src/plugins/hosts.ts b/packages/cli/src/plugins/hosts.ts index e01e123..e80a2d2 100644 --- a/packages/cli/src/plugins/hosts.ts +++ b/packages/cli/src/plugins/hosts.ts @@ -58,15 +58,35 @@ export const KNOWN_PLUGIN_HOSTS: Record = { notes: "OpenCode ships as a terminal app, desktop app, and IDE extension with first-class MCP support plus a native plugin system.", }, + grok: { + id: "grok", + name: "Grok", + kind: "cli", + nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + supportsInteractiveApps: false, + supportsNativeBundles: true, + notes: + "Grok reads project `.grok/config.toml` and user `~/.grok/config.toml`. MCP lives under [mcp_servers]. PalamHealth servers must be project-layer.", + }, + zcode: { + id: "zcode", + name: "ZCode", + kind: "cli", + nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + supportsInteractiveApps: false, + supportsNativeBundles: false, + notes: + "ZCode user MCP is ~/.zcode/cli/config.json (mcp.servers). Project MCP is .zcode/mcp.json. Hooks live in .zcode/config.json and .zcode/hooks.json. Do not put PalamHealth in the user file.", + }, "antigravity-cli": { id: "antigravity-cli", name: "Antigravity CLI", kind: "hybrid", - nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + nativeEngineSupport: { mcp: false, skills: true, rules: true, agents: true, hooks: false }, supportsInteractiveApps: true, supportsNativeBundles: true, notes: - "Antigravity CLI supports workspace .agents customizations and native plugins that can bundle skills, agents, rules, MCP servers, and hooks.", + "Antigravity CLI adapters cover workspace .agents skills, agents, and rules. There is no MCP or hooks adapter.", }, windsurf: { id: "windsurf", @@ -97,6 +117,61 @@ export const KNOWN_PLUGIN_HOSTS: Record = { supportsNativeBundles: false, notes: "Kiro exposes multiple config surfaces that ai-tools can target directly.", }, + cline: { + id: "cline", + name: "Cline", + kind: "desktop", + nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + supportsInteractiveApps: false, + supportsNativeBundles: false, + notes: "Cline adapters cover hooks, MCP, agents, skills, and rules.", + }, + droid: { + id: "droid", + name: "Factory Droid", + kind: "cli", + nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + supportsInteractiveApps: false, + supportsNativeBundles: false, + notes: + "Factory Droid adapters write .factory configs for hooks, MCP, agents, skills, and rules.", + }, + amp: { + id: "amp", + name: "Amp", + kind: "cli", + nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: false, hooks: false }, + supportsInteractiveApps: false, + supportsNativeBundles: false, + notes: "Amp adapters cover MCP, skills, and rules. There is no agents or hooks adapter.", + }, + continue: { + id: "continue", + name: "Continue", + kind: "desktop", + nativeEngineSupport: { mcp: false, skills: true, rules: true, agents: false, hooks: false }, + supportsInteractiveApps: false, + supportsNativeBundles: false, + notes: "Continue adapters cover skills and rules only.", + }, + "gemini-cli": { + id: "gemini-cli", + name: "Gemini CLI", + kind: "cli", + nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: true }, + supportsInteractiveApps: false, + supportsNativeBundles: false, + notes: "Gemini CLI adapters cover hooks, MCP, agents, skills, and rules.", + }, + "roo-code": { + id: "roo-code", + name: "Roo Code", + kind: "desktop", + nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: false }, + supportsInteractiveApps: false, + supportsNativeBundles: false, + notes: "Roo Code adapters cover MCP, agents, skills, and rules. There is no hooks adapter.", + }, }; export function getPluginHostInfo(id: PluginToolId): PluginHostInfo { diff --git a/packages/cli/src/plugins/install.test.ts b/packages/cli/src/plugins/install.test.ts index cab2833..14667f7 100644 --- a/packages/cli/src/plugins/install.test.ts +++ b/packages/cli/src/plugins/install.test.ts @@ -141,6 +141,311 @@ describe("installPluginBundle", () => { ]); }); + it("installs only project-layer MCP servers whose whenPathContains matches cwd", async () => { + const mcpAdapter = adapter([{ path: ".codex/mcp.json", content: "{}", format: "json" }]); + loadEngineRegistries.mockResolvedValue({ + mcp: { get: () => mcpAdapter }, + skills: { get: () => undefined }, + rules: { get: () => undefined }, + agents: { get: () => undefined }, + hooks: { get: () => undefined }, + }); + const cwdSpy = vi + .spyOn(process, "cwd") + .mockReturnValue("/home/blitz/Development/PremierStudio/web"); + const { installPluginBundle } = await import("./install.js"); + + const result = await installPluginBundle( + { + ...plugin, + mcpServers: [ + { + id: "github", + name: "GitHub", + transport: { type: "stdio", command: "gh-mcp" }, + }, + { + id: "palamhealth", + name: "PalamHealth", + transport: { type: "stdio", command: "palamhealth-mcp" }, + layer: "project", + whenPathContains: ["PalamHealth"], + }, + { + id: "unifi", + name: "UniFi", + transport: { type: "stdio", command: "unifi-mcp" }, + layer: "user", + }, + ], + }, + { tools: ["codex"] }, + ); + + cwdSpy.mockRestore(); + expect(mcpAdapter.generate).toHaveBeenCalledWith([expect.objectContaining({ id: "github" })]); + expect(mcpAdapter.generate.mock.calls[0]?.[0]).toHaveLength(1); + expect(result.installed).toHaveLength(1); + }); + + it("installs PalamHealth servers only when cwd contains a required path fragment", async () => { + const mcpAdapter = adapter([{ path: ".codex/mcp.json", content: "{}", format: "json" }]); + loadEngineRegistries.mockResolvedValue({ + mcp: { get: () => mcpAdapter }, + skills: { get: () => undefined }, + rules: { get: () => undefined }, + agents: { get: () => undefined }, + hooks: { get: () => undefined }, + }); + const cwdSpy = vi + .spyOn(process, "cwd") + .mockReturnValue("/home/blitz/Development/PalamHealth/PalamHealth"); + const { installPluginBundle } = await import("./install.js"); + + await installPluginBundle( + { + ...plugin, + mcpServers: [ + { + id: "palamhealth", + name: "PalamHealth", + transport: { type: "stdio", command: "palamhealth-mcp" }, + layer: "project", + whenPathContains: ["PalamHealth", "palamhealth"], + }, + ], + }, + { tools: ["codex"] }, + ); + + cwdSpy.mockRestore(); + expect(mcpAdapter.generate).toHaveBeenCalledWith([ + expect.objectContaining({ id: "palamhealth" }), + ]); + }); + + it("skips MCP install instead of writing an empty config when every server is filtered out", async () => { + const mcpAdapter = adapter([{ path: ".codex/mcp.json", content: "{}", format: "json" }]); + loadEngineRegistries.mockResolvedValue({ + mcp: { get: () => mcpAdapter }, + skills: { get: () => undefined }, + rules: { get: () => undefined }, + agents: { get: () => undefined }, + hooks: { get: () => undefined }, + }); + const cwdSpy = vi + .spyOn(process, "cwd") + .mockReturnValue("/home/blitz/Development/PremierStudio/web"); + const { installPluginBundle } = await import("./install.js"); + + const result = await installPluginBundle( + { + ...plugin, + mcpServers: [ + { + id: "palamhealth", + name: "PalamHealth", + transport: { type: "stdio", command: "palamhealth-mcp" }, + layer: "project", + whenPathContains: ["PalamHealth"], + }, + ], + }, + { tools: ["codex"] }, + ); + + cwdSpy.mockRestore(); + expect(mcpAdapter.generate).not.toHaveBeenCalled(); + expect(mcpAdapter.install).not.toHaveBeenCalled(); + expect(result.installed).toEqual([]); + }); + + it("installs rules, agents, and hooks when those engines are ready", async () => { + const rulesAdapter = adapter([{ path: ".codex/rules.md", content: "tdd", format: "md" }]); + const agentsAdapter = adapter([{ path: ".codex/agents/qa.md", content: "qa", format: "md" }]); + const hooksAdapter = adapter([{ path: ".codex/hooks.json", content: "{}", format: "json" }]); + buildPluginInstallPlan.mockResolvedValue({ + ...plan, + requestedEngines: ["rules", "agents", "hooks"], + targets: [ + { + ...readyTarget, + engines: [ + { + engine: "rules", + requested: true, + nativeHostSupport: true, + adapterAvailable: true, + detected: true, + status: "ready", + }, + { + engine: "agents", + requested: true, + nativeHostSupport: true, + adapterAvailable: true, + detected: true, + status: "ready", + }, + { + engine: "hooks", + requested: true, + nativeHostSupport: true, + adapterAvailable: true, + detected: true, + status: "ready", + }, + ], + }, + ], + }); + loadEngineRegistries.mockResolvedValue({ + mcp: { get: () => undefined }, + skills: { get: () => undefined }, + rules: { get: () => rulesAdapter }, + agents: { get: () => agentsAdapter }, + hooks: { get: () => hooksAdapter }, + }); + const { installPluginBundle } = await import("./install.js"); + + const result = await installPluginBundle({ + ...plugin, + rules: [{ id: "tdd", name: "TDD", content: "Test first.", scope: { type: "always" } }], + agents: [{ id: "qa", name: "QA", instructions: "Find gaps." }], + }); + + expect(result.failed).toEqual([]); + expect(result.installed.map((item) => item.engine)).toEqual(["rules", "agents", "hooks"]); + expect(rulesAdapter.generate).toHaveBeenCalled(); + expect(agentsAdapter.generate).toHaveBeenCalled(); + expect(hooksAdapter.generate).toHaveBeenCalled(); + }); + + it("records a missing adapter for every engine", async () => { + buildPluginInstallPlan.mockResolvedValue({ + ...plan, + requestedEngines: ["mcp", "skills", "rules", "agents", "hooks"], + targets: [ + { + ...readyTarget, + engines: ["mcp", "skills", "rules", "agents", "hooks"].map((engine) => ({ + engine, + requested: true, + nativeHostSupport: true, + adapterAvailable: true, + detected: true, + status: "ready", + })), + }, + ], + }); + loadEngineRegistries.mockResolvedValue({ + mcp: { get: () => undefined }, + skills: { get: () => undefined }, + rules: { get: () => undefined }, + agents: { get: () => undefined }, + hooks: { get: () => undefined }, + }); + const { installPluginBundle } = await import("./install.js"); + + const result = await installPluginBundle(plugin); + + expect(result.installed).toEqual([]); + expect(result.failed.map((item) => item.error)).toEqual([ + "Missing MCP adapter for codex", + "Missing skills adapter for codex", + "Missing rules adapter for codex", + "Missing agents adapter for codex", + "Missing hooks adapter for codex", + ]); + }); + + it("records missing adapters as failures without aborting later engines", async () => { + const hooksAdapter = adapter([{ path: ".codex/hooks.json", content: "{}", format: "json" }]); + buildPluginInstallPlan.mockResolvedValue({ + ...plan, + requestedEngines: ["rules", "hooks"], + targets: [ + { + ...readyTarget, + engines: [ + { + engine: "rules", + requested: true, + nativeHostSupport: true, + adapterAvailable: true, + detected: true, + status: "ready", + }, + { + engine: "hooks", + requested: true, + nativeHostSupport: true, + adapterAvailable: true, + detected: true, + status: "ready", + }, + ], + }, + ], + }); + loadEngineRegistries.mockResolvedValue({ + mcp: { get: () => undefined }, + skills: { get: () => undefined }, + rules: { get: () => undefined }, + agents: { get: () => undefined }, + hooks: { get: () => hooksAdapter }, + }); + const { installPluginBundle } = await import("./install.js"); + + const result = await installPluginBundle(plugin); + + expect(result.failed).toEqual([ + { + toolId: "codex", + toolName: "Codex", + engine: "rules", + error: "Missing rules adapter for codex", + }, + ]); + expect(result.installed).toEqual([ + { + toolId: "codex", + toolName: "Codex", + engine: "hooks", + filePaths: [".codex/hooks.json"], + }, + ]); + }); + + it("stringifies non-Error install failures", async () => { + const mcpAdapter = { + id: "codex", + detect: vi.fn(), + generate: vi + .fn() + .mockResolvedValue([{ path: ".codex/mcp.json", content: "{}", format: "json" }]), + install: vi.fn().mockRejectedValue("disk full"), + }; + loadEngineRegistries.mockResolvedValue({ + mcp: { get: () => mcpAdapter }, + skills: { get: () => undefined }, + rules: { get: () => undefined }, + agents: { get: () => undefined }, + hooks: { get: () => undefined }, + }); + const { installPluginBundle } = await import("./install.js"); + + const result = await installPluginBundle(plugin); + + expect(result.failed).toContainEqual({ + toolId: "codex", + toolName: "Codex", + engine: "mcp", + error: "disk full", + }); + }); + it("records install failures without aborting the bundle", async () => { const mcpAdapter = adapter([{ path: ".codex/mcp.json", content: "{}", format: "json" }], true); const skillsAdapter = adapter([ diff --git a/packages/cli/src/plugins/install.ts b/packages/cli/src/plugins/install.ts index 0b53452..3471f13 100644 --- a/packages/cli/src/plugins/install.ts +++ b/packages/cli/src/plugins/install.ts @@ -6,12 +6,28 @@ import type { InstallPluginOptions, InstallPluginResult, PluginEngine, + PluginMCPServerDefinition, } from "./types.js"; +type PluginLayer = NonNullable; + function uniquePaths(paths: string[]): string[] { return [...new Set(paths)]; } +function filterPluginServers( + servers: PluginMCPServerDefinition[], + ctx: { layer: PluginLayer; cwd: string }, +): PluginMCPServerDefinition[] { + return servers.filter((server) => { + const layer = server.layer ?? "project"; + if (layer !== ctx.layer) return false; + const fragments = server.whenPathContains ?? []; + if (fragments.length === 0) return true; + return fragments.some((fragment) => ctx.cwd.includes(fragment)); + }); +} + async function installEngine( registries: Awaited>, plugin: AiPluginDefinition, @@ -22,7 +38,12 @@ async function installEngine( case "mcp": { const adapter = registries.mcp.get(toolId); if (!adapter) throw new Error(`Missing MCP adapter for ${toolId}`); - const files = await adapter.generate(plugin.mcpServers ?? []); + const servers = filterPluginServers(plugin.mcpServers ?? [], { + layer: "project", + cwd: process.cwd(), + }); + if (servers.length === 0) return []; + const files = await adapter.generate(servers); await adapter.install(files); return files.map((file) => file.path); } @@ -77,6 +98,7 @@ export async function installPluginBundle( const filePaths = uniquePaths( await installEngine(registries, plugin, engine.engine, target.toolId), ); + if (filePaths.length === 0) continue; installed.push({ toolId: target.toolId, toolName: target.toolName, diff --git a/packages/cli/src/plugins/plan.test.ts b/packages/cli/src/plugins/plan.test.ts index e0fb325..be81aa2 100644 --- a/packages/cli/src/plugins/plan.test.ts +++ b/packages/cli/src/plugins/plan.test.ts @@ -152,4 +152,59 @@ describe("buildPluginInstallPlan", () => { const target = plan.targets[0]; expect(target?.engines.find((item) => item.engine === "agents")?.status).toBe("ready"); }); + + it("marks zcode hooks as adapter-missing until the hooks registry lists zcode", async () => { + const plan = await buildPluginInstallPlan( + { + id: "cert-coach", + name: "Certification Coach", + version: "0.1.0", + hooks: [ + { + id: "audit", + name: "Audit", + events: ["prompt:submit"], + phase: "before", + handler: async () => {}, + }, + ], + }, + { + tools: ["zcode"], + force: true, + detectedToolsOverride: ["zcode"], + }, + ); + + expect(plan.targets[0]?.host.nativeEngineSupport.hooks).toBe(true); + expect(plan.targets[0]?.engines.find((item) => item.engine === "hooks")?.status).toBe( + "adapter-missing", + ); + }); + + it("marks antigravity-cli MCP as host-unsupported because no MCP adapter exists", async () => { + const plan = await buildPluginInstallPlan( + { + id: "cert-coach", + name: "Certification Coach", + version: "0.1.0", + mcpServers: [ + { + id: "cert-coach", + name: "Certification Coach", + transport: { type: "stdio", command: "npx" }, + }, + ], + }, + { + tools: ["antigravity-cli"], + force: true, + detectedToolsOverride: ["antigravity-cli"], + }, + ); + + expect(plan.targets[0]?.engines.find((item) => item.engine === "mcp")?.status).toBe( + "host-unsupported", + ); + }); }); diff --git a/packages/cli/src/plugins/types.ts b/packages/cli/src/plugins/types.ts index 78f2c42..423c4a7 100644 --- a/packages/cli/src/plugins/types.ts +++ b/packages/cli/src/plugins/types.ts @@ -1,6 +1,7 @@ export type PluginMCPTransport = | { type: "stdio"; command: string; args?: string[]; env?: Record } - | { type: "sse"; url: string; headers?: Record }; + | { type: "sse"; url: string; headers?: Record } + | { type: "http"; url: string; headers?: Record }; export type PluginMCPServerDefinition = { id: string; @@ -9,6 +10,8 @@ export type PluginMCPServerDefinition = { transport: PluginMCPTransport; enabled?: boolean; tags?: string[]; + layer?: "user" | "project"; + whenPathContains?: string[]; }; export type PluginSkillDefinition = { diff --git a/packages/hooks/src/adapters/all.ts b/packages/hooks/src/adapters/all.ts index 611660c..2896d83 100644 --- a/packages/hooks/src/adapters/all.ts +++ b/packages/hooks/src/adapters/all.ts @@ -5,5 +5,7 @@ import "./cline.js"; import "./cursor.js"; import "./droid.js"; import "./gemini-cli.js"; +import "./grok.js"; import "./kiro.js"; import "./opencode.js"; +import "./zcode.js"; diff --git a/packages/hooks/src/adapters/claude-compat.test.ts b/packages/hooks/src/adapters/claude-compat.test.ts new file mode 100644 index 0000000..e452251 --- /dev/null +++ b/packages/hooks/src/adapters/claude-compat.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; +import { + asRecord, + buildCompatEvent, + collectNativeEvents, + isBlockingNativeEvent, + normalizeNativeEvent, + parseHookGroups, + payloadCwd, + payloadEventName, + payloadTimestamp, + payloadToolInput, + phaseForNativeEvent, + stringField, +} from "./claude-compat.js"; +import type { HookDefinition } from "../types/index.js"; + +describe("claude-compat helpers", () => { + it("normalizes snake_case, camelCase, and PascalCase event names", () => { + expect(normalizeNativeEvent("pre_tool_use")).toBe("PreToolUse"); + expect(normalizeNativeEvent("PreToolUse")).toBe("PreToolUse"); + expect(normalizeNativeEvent("userPromptSubmit")).toBe("UserPromptSubmit"); + expect(normalizeNativeEvent("")).toBe(""); + expect(normalizeNativeEvent("_pre__tool_use")).toBe("PreToolUse"); + expect(payloadEventName({ hook_event_name: "session_start" })).toBe("SessionStart"); + expect(payloadEventName({ hookEventName: "" }, "session_start")).toBe("SessionStart"); + expect(payloadEventName({}, "session_start")).toBe("SessionStart"); + }); + + it("coerces payload fields and timestamps", () => { + expect(asRecord(null)).toEqual({}); + expect(asRecord([1])).toEqual({}); + expect(asRecord({ a: 1 })).toEqual({ a: 1 }); + expect(stringField(3, "fallback")).toBe("fallback"); + expect(payloadTimestamp({ timestamp: 42 })).toBe(42); + expect(payloadTimestamp({ timestamp: "2020-01-01T00:00:00.000Z" })).toBe( + Date.parse("2020-01-01T00:00:00.000Z"), + ); + expect(payloadTimestamp({ timestamp: "not-a-date" })).toBeTypeOf("number"); + expect(payloadToolInput({ tool_input: { command: "x" } })).toEqual({ command: "x" }); + expect(payloadToolInput({})).toEqual({}); + expect(payloadCwd({ workspaceRoot: "/ws" })).toBe("/ws"); + expect(phaseForNativeEvent("PostToolUse")).toBe("after"); + expect(isBlockingNativeEvent("PermissionRequest")).toBe(true); + }); + + it("builds shell:before from Grok run_terminal_command PreToolUse", () => { + const event = buildCompatEvent( + "PreToolUse", + { + toolName: "run_terminal_command", + toolInput: { command: "npm test" }, + cwd: "/tmp/proj", + }, + "grok", + ); + expect(event?.type).toBe("shell:before"); + if (event?.type === "shell:before") { + expect(event.command).toBe("npm test"); + expect(event.cwd).toBe("/tmp/proj"); + } + }); + + it("builds file:write / file:edit / mcp:before from tool names", () => { + expect( + buildCompatEvent( + "PreToolUse", + { toolName: "Write", toolInput: { file_path: "a.ts" } }, + "zcode", + )?.type, + ).toBe("file:write"); + expect( + buildCompatEvent( + "PreToolUse", + { toolName: "search_replace", toolInput: { path: "a.ts" } }, + "grok", + )?.type, + ).toBe("file:edit"); + const mcp = buildCompatEvent( + "PreToolUse", + { toolName: "linear__save_issue", toolInput: { title: "x" } }, + "grok", + ); + expect(mcp?.type).toBe("mcp:before"); + if (mcp?.type === "mcp:before") { + expect(mcp.server).toBe("linear"); + expect(mcp.method).toBe("save_issue"); + } + }); + + it("collects sorted unique native events", () => { + const hooks: HookDefinition[] = [ + { + id: "a", + name: "a", + events: ["file:write", "shell:before"], + phase: "before", + handler: async (_ctx, next) => { + await next(); + }, + }, + ]; + expect(collectNativeEvents(hooks, () => ["PreToolUse", "PreToolUse"])).toEqual(["PreToolUse"]); + }); + + it("parses matcher groups into hook definitions", async () => { + const defs = parseHookGroups( + [{ hooks: [{ type: "command", command: "echo hi", statusMessage: "hi" }] }], + "PreToolUse", + () => ["shell:before"], + "zcode", + ); + expect(defs).toHaveLength(1); + expect(defs[0]?.id).toBe("zcode-PreToolUse-0-0"); + expect(defs[0]?.name).toBe("echo hi"); + expect(defs[0]?.description).toBe("hi"); + expect(isBlockingNativeEvent("PreToolUse")).toBe(true); + expect(isBlockingNativeEvent("PostToolUse")).toBe(false); + const ran = { value: false }; + await defs[0]!.handler( + { + event: { + type: "shell:before", + command: "echo hi", + cwd: "/tmp", + timestamp: 0, + metadata: {}, + }, + tool: { name: "zcode", version: "1.0" }, + cwd: "/tmp", + state: new Map(), + results: [], + startedAt: 0, + }, + async () => { + ran.value = true; + }, + ); + expect(ran.value).toBe(true); + }); + + it("skips malformed matcher groups", () => { + expect(parseHookGroups({}, "PreToolUse", () => ["shell:before"], "x")).toEqual([]); + expect( + parseHookGroups( + [{ hooks: [{ type: "http", url: "https://x" }] }], + "PreToolUse", + () => ["shell:before"], + "x", + ), + ).toEqual([]); + expect( + parseHookGroups([{ matcher: "Bash" }], "PreToolUse", () => ["shell:before"], "x"), + ).toEqual([]); + expect(parseHookGroups([{ hooks: [{ command: "echo" }] }], "Nope", () => [], "x")).toEqual([]); + }); + + it("builds the remaining lifecycle and tool events", () => { + expect(buildCompatEvent("SessionStart", { cwd: "/tmp" }, "grok")?.type).toBe("session:start"); + expect(buildCompatEvent("SessionEnd", {}, "grok")?.type).toBe("session:end"); + expect(buildCompatEvent("UserPromptSubmit", { prompt: "hi" }, "grok")?.type).toBe( + "prompt:submit", + ); + expect( + buildCompatEvent("UserPromptSubmit", { tool_input: { prompt: "from-input" } }, "zcode"), + ).toMatchObject({ type: "prompt:submit", prompt: "from-input" }); + expect( + buildCompatEvent( + "PreToolUse", + { toolName: "run_terminal_command", toolInput: { command: "ls", cwd: "/x" } }, + "grok", + )?.type, + ).toBe("shell:before"); + expect( + buildCompatEvent( + "PreToolUse", + { toolName: "MultiEdit", toolInput: { path: "a.ts" } }, + "zcode", + )?.type, + ).toBe("file:edit"); + expect( + buildCompatEvent( + "PreToolUse", + { toolName: "delete_file", toolInput: { path: "a.ts" } }, + "grok", + )?.type, + ).toBe("file:delete"); + expect( + buildCompatEvent( + "PreToolUse", + { toolName: "ApplyPatch", toolInput: { path: "a.ts" } }, + "zcode", + )?.type, + ).toBe("file:edit"); + expect( + buildCompatEvent("Notification", { notificationType: "idle_prompt" }, "grok")?.type, + ).toBe("notification"); + expect( + buildCompatEvent( + "PermissionRequest", + { toolName: "Bash", toolInput: { command: "ls" } }, + "zcode", + )?.type, + ).toBe("shell:before"); + expect( + buildCompatEvent("PreToolUse", { toolName: "Delete", toolInput: { path: "a.ts" } }, "zcode") + ?.type, + ).toBe("file:delete"); + expect( + buildCompatEvent( + "PreToolUse", + { toolName: "read_file", toolInput: { target_file: "a.ts" } }, + "grok", + )?.type, + ).toBe("file:read"); + expect( + buildCompatEvent( + "PreToolUse", + { toolName: "Edit", toolInput: { oldContent: "a", newContent: "b" } }, + "zcode", + )?.type, + ).toBe("file:edit"); + expect(buildCompatEvent("PreToolUse", {}, "grok")?.type).toBe("tool:before"); + const mcp = buildCompatEvent( + "PreToolUse", + { + toolName: "CallMcpTool", + toolInput: { server: "linear", method: "save", params: { id: 1 } }, + }, + "grok", + ); + expect(mcp?.type).toBe("mcp:before"); + expect( + buildCompatEvent( + "PostToolUse", + { + toolName: "linear__save_issue", + toolResult: { server: "linear", method: "save_issue" }, + }, + "grok", + )?.type, + ).toBe("mcp:after"); + expect( + buildCompatEvent( + "PostToolUse", + { + toolName: "run_terminal_cmd", + toolInput: { command: "ls" }, + tool_response: { exitCode: 1, stdout: "out", stderr: "err" }, + }, + "grok", + )?.type, + ).toBe("shell:after"); + expect( + buildCompatEvent( + "PostToolUse", + { toolName: "Bash", toolInput: { command: "ls" }, toolResult: { stdout: "ok" } }, + "zcode", + )?.type, + ).toBe("shell:after"); + expect(buildCompatEvent("PostToolUse", { toolName: "__", toolResult: [] }, "grok")?.type).toBe( + "mcp:after", + ); + expect(buildCompatEvent("PostToolUse", {}, "zcode")?.type).toBe("tool:after"); + expect(buildCompatEvent("PostToolUseFailure", { toolName: "Read" }, "zcode")?.type).toBe( + "tool:after", + ); + expect(buildCompatEvent("Stop", { lastAssistantMessage: "done" }, "zcode")?.type).toBe( + "prompt:response", + ); + expect(buildCompatEvent("Stop", {}, "zcode")?.type).toBe("session:end"); + const unnamed = buildCompatEvent("PreToolUse", { toolName: "__" }, "grok"); + expect(unnamed?.type).toBe("mcp:before"); + }); +}); diff --git a/packages/hooks/src/adapters/claude-compat.ts b/packages/hooks/src/adapters/claude-compat.ts new file mode 100644 index 0000000..ee71dc8 --- /dev/null +++ b/packages/hooks/src/adapters/claude-compat.ts @@ -0,0 +1,372 @@ +import type { HookDefinition, HookEvent, HookEventType } from "../types/index.js"; + +export type CompatPayload = { + hookEventName?: string; + hook_event_name?: string; + toolName?: string; + tool_name?: string; + toolInput?: Record; + tool_input?: Record; + toolResult?: unknown; + tool_response?: unknown; + cwd?: string; + workspaceRoot?: string; + prompt?: string; + timestamp?: string | number; + [key: string]: unknown; +}; + +export type NativeCommandHook = { + type?: string; + command?: string; + url?: string; + timeout?: number; + statusMessage?: string; +}; + +export type NativeHookGroup = { + matcher?: string; + hooks?: NativeCommandHook[]; +}; + +export function collectNativeEvents( + hooks: HookDefinition[], + mapEvent: (event: HookEventType) => string[], +): string[] { + const events = new Set(); + for (const hook of hooks) { + for (const event of hook.events) { + for (const native of mapEvent(event)) events.add(native); + } + } + return [...events].toSorted(); +} + +export function normalizeNativeEvent(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ""; + if (trimmed.includes("_")) { + return trimmed + .split("_") + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(""); + } + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); +} + +export function payloadEventName(payload: CompatPayload, envEvent?: string): string { + const raw = + (typeof payload.hookEventName === "string" && payload.hookEventName) || + (typeof payload.hook_event_name === "string" && payload.hook_event_name) || + envEvent || + ""; + return normalizeNativeEvent(raw); +} + +export function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +export function stringField(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +export function payloadTimestamp(payload: CompatPayload): number { + if (typeof payload.timestamp === "number" && Number.isFinite(payload.timestamp)) { + return payload.timestamp; + } + if (typeof payload.timestamp === "string") { + const parsed = Date.parse(payload.timestamp); + if (Number.isFinite(parsed)) return parsed; + } + return Date.now(); +} + +export function payloadToolName(payload: CompatPayload): string { + return stringField(payload.toolName) || stringField(payload.tool_name); +} + +export function payloadToolInput(payload: CompatPayload): Record { + if (payload.toolInput !== undefined) return asRecord(payload.toolInput); + if (payload.tool_input !== undefined) return asRecord(payload.tool_input); + return {}; +} + +export function payloadCwd(payload: CompatPayload): string { + return stringField(payload.cwd) || stringField(payload.workspaceRoot) || process.cwd(); +} + +export function isMcpToolName(toolName: string): boolean { + return toolName.includes("__") || toolName === "CallMcpTool" || toolName === "use_tool"; +} + +export function buildCompatEvent( + nativeEvent: string, + payload: CompatPayload, + tool: string, +): HookEvent | null { + const timestamp = payloadTimestamp(payload); + const metadata: Record = { nativeEvent, payload }; + const cwd = payloadCwd(payload); + const toolName = payloadToolName(payload); + const toolInput = payloadToolInput(payload); + const toolResult = payload.toolResult ?? payload.tool_response ?? {}; + + switch (nativeEvent) { + case "SessionStart": + return { + type: "session:start", + tool, + version: "1.0", + workingDirectory: cwd, + timestamp, + metadata, + }; + case "SessionEnd": + return { + type: "session:end", + tool, + duration: 0, + timestamp, + metadata, + }; + case "UserPromptSubmit": + return { + type: "prompt:submit", + prompt: stringField(payload.prompt) || stringField(toolInput.prompt), + timestamp, + metadata, + }; + case "Notification": + return { + type: "notification", + level: "info", + message: stringField(payload.message) || stringField(payload.notificationType), + timestamp, + metadata, + }; + case "PreToolUse": + case "PermissionRequest": + return resolvePreToolUse(toolName, toolInput, cwd, timestamp, metadata); + case "PostToolUse": + case "PostToolUseFailure": + return resolvePostToolUse(toolName, toolInput, toolResult, cwd, timestamp, metadata); + case "Stop": + if (typeof payload.lastAssistantMessage === "string" && payload.lastAssistantMessage) { + return { + type: "prompt:response", + response: payload.lastAssistantMessage, + model: tool, + tokens: { input: 0, output: 0 }, + timestamp, + metadata, + }; + } + return { + type: "session:end", + tool, + duration: 0, + timestamp, + metadata, + }; + default: + return null; + } +} + +export function isBlockingNativeEvent(nativeEvent: string): boolean { + return ( + nativeEvent === "PreToolUse" || + nativeEvent === "UserPromptSubmit" || + nativeEvent === "PermissionRequest" + ); +} + +export function phaseForNativeEvent(nativeEvent: string): "before" | "after" { + if ( + nativeEvent === "PreToolUse" || + nativeEvent === "UserPromptSubmit" || + nativeEvent === "PermissionRequest" || + nativeEvent === "SessionStart" + ) { + return "before"; + } + return "after"; +} + +export function parseHookGroups( + groups: unknown, + nativeEvent: string, + mapNativeEvent: (native: string) => HookEventType[], + source: string, +): HookDefinition[] { + if (!Array.isArray(groups)) return []; + const events = mapNativeEvent(nativeEvent); + if (events.length === 0) return []; + const phase = phaseForNativeEvent(nativeEvent); + const definitions: HookDefinition[] = []; + + groups.forEach((group, groupIndex) => { + const typed = asRecord(group); + const hooks = typed.hooks; + if (!Array.isArray(hooks)) return; + hooks.forEach((hook, hookIndex) => { + const commandHook = asRecord(hook); + const command = stringField(commandHook.command); + if (!command) return; + definitions.push({ + id: `${source}-${nativeEvent}-${groupIndex}-${hookIndex}`, + name: command, + description: stringField(commandHook.statusMessage) || `${source} ${nativeEvent}`, + events, + phase, + handler: async (_ctx, next) => { + await next(); + }, + }); + }); + }); + + return definitions; +} + +function resolvePreToolUse( + toolName: string, + toolInput: Record, + cwd: string, + timestamp: number, + metadata: Record, +): HookEvent { + if (isMcpToolName(toolName)) { + const parts = toolName.split("__"); + return { + type: "mcp:before", + server: stringField(toolInput.server) || parts[0] || "unknown", + method: stringField(toolInput.method) || parts[1] || toolName, + params: toolInput.params !== undefined ? asRecord(toolInput.params) : toolInput, + timestamp, + metadata, + }; + } + + switch (toolName) { + case "Bash": + case "run_terminal_command": + case "run_terminal_cmd": + return { + type: "shell:before", + command: stringField(toolInput.command), + cwd: stringField(toolInput.cwd, cwd), + timestamp, + metadata, + }; + case "Write": + return { + type: "file:write", + path: filePathFrom(toolInput), + content: stringField(toolInput.content), + timestamp, + metadata, + }; + case "Edit": + case "MultiEdit": + case "search_replace": + case "ApplyPatch": + return { + type: "file:edit", + path: filePathFrom(toolInput), + oldContent: stringField(toolInput.old_string) || stringField(toolInput.oldContent), + newContent: stringField(toolInput.new_string) || stringField(toolInput.newContent), + timestamp, + metadata, + }; + case "Delete": + case "delete_file": + return { + type: "file:delete", + path: filePathFrom(toolInput), + timestamp, + metadata, + }; + case "Read": + case "read_file": + return { + type: "file:read", + path: filePathFrom(toolInput), + timestamp, + metadata, + }; + default: + return { + type: "tool:before", + toolName: toolName || "unknown", + input: toolInput, + timestamp, + metadata, + }; + } +} + +function resolvePostToolUse( + toolName: string, + toolInput: Record, + toolResult: unknown, + cwd: string, + timestamp: number, + metadata: Record, +): HookEvent { + if (isMcpToolName(toolName)) { + const parts = toolName.split("__"); + return { + type: "mcp:after", + server: stringField(asRecord(toolResult).server) || parts[0] || "unknown", + method: stringField(asRecord(toolResult).method) || parts[1] || toolName, + params: toolInput, + result: toolResult, + duration: 0, + timestamp, + metadata, + }; + } + + if ( + toolName === "Bash" || + toolName === "run_terminal_command" || + toolName === "run_terminal_cmd" + ) { + const result = asRecord(toolResult); + return { + type: "shell:after", + command: stringField(toolInput.command), + cwd: stringField(toolInput.cwd, cwd), + exitCode: typeof result.exitCode === "number" ? result.exitCode : 0, + stdout: stringField(result.stdout), + stderr: stringField(result.stderr), + duration: 0, + timestamp, + metadata, + }; + } + + return { + type: "tool:after", + toolName: toolName || "unknown", + input: toolInput, + output: toolResult, + duration: 0, + timestamp, + metadata, + }; +} + +function filePathFrom(toolInput: Record): string { + return ( + stringField(toolInput.file_path) || + stringField(toolInput.path) || + stringField(toolInput.target_file) + ); +} diff --git a/packages/hooks/src/adapters/grok-runner.test.ts b/packages/hooks/src/adapters/grok-runner.test.ts new file mode 100644 index 0000000..15269ca --- /dev/null +++ b/packages/hooks/src/adapters/grok-runner.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseCompatPayload, readStdinText, runGrokHook } from "./grok-runner.js"; +import type { AiHooksConfig, HookDefinition } from "../types/index.js"; + +const temps: string[] = []; + +class NonErrorFailure { + toString(): string { + return "non-error"; + } +} + +async function* stdinChunks() { + yield Buffer.from('{"hookEventName":"'); + yield 'SessionStart"}'; +} + +afterEach(async () => { + await Promise.all(temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), "grok-runner-")); + temps.push(dir); + return dir; +} + +function markerHook(markerPath: string): HookDefinition { + return { + id: "write-marker", + name: "write-marker", + events: ["shell:before"], + phase: "before", + handler: async (ctx, next) => { + if (ctx.event.type === "shell:before") { + await writeFile(markerPath, ctx.event.command, "utf-8"); + } + await next(); + }, + }; +} + +function blockHook(): HookDefinition { + return { + id: "block-rm", + name: "block-rm", + events: ["shell:before"], + phase: "before", + handler: async (ctx, next) => { + if (ctx.event.type === "shell:before" && ctx.event.command.includes("rm -rf /")) { + ctx.results.push({ blocked: true, reason: "Dangerous command" }); + return; + } + await next(); + }, + }; +} + +describe("runGrokHook", () => { + it("runs a configured hook that writes a file when Grok invokes the runner", async () => { + const dir = await tempDir(); + const marker = join(dir, "ran.txt"); + const config: AiHooksConfig = { hooks: [markerHook(marker)] }; + + const code = await runGrokHook({ + config, + stdin: JSON.stringify({ + hookEventName: "pre_tool_use", + toolName: "run_terminal_command", + toolInput: { command: "echo from-grok" }, + cwd: dir, + }), + }); + + expect(code).toBe(0); + expect(await readFile(marker, "utf-8")).toBe("echo from-grok"); + }); + + it("loads ai-hooks.config.js and runs its hook when no config is passed", async () => { + const dir = await tempDir(); + const marker = join(dir, "from-config.txt"); + await writeFile( + join(dir, "ai-hooks.config.js"), + `import { writeFile } from "node:fs/promises"; +export default { + hooks: [{ + id: "from-file", + name: "from-file", + events: ["shell:before"], + phase: "before", + handler: async (ctx, next) => { + if (ctx.event.type === "shell:before") { + await writeFile(${JSON.stringify(marker)}, ctx.event.command, "utf8"); + } + await next(); + }, + }], +}; +`, + "utf-8", + ); + + const code = await runGrokHook({ + cwd: dir, + stdin: JSON.stringify({ + hookEventName: "PreToolUse", + toolName: "Bash", + tool_input: { command: "echo loaded" }, + }), + }); + + expect(code).toBe(0); + expect(await readFile(marker, "utf-8")).toBe("echo loaded"); + }); + + it("denies a blocking PreToolUse hook with Grok decision JSON and exit 2", async () => { + const chunks: string[] = []; + const code = await runGrokHook({ + config: { hooks: [blockHook()] }, + stdin: JSON.stringify({ + hookEventName: "pre_tool_use", + toolName: "run_terminal_command", + toolInput: { command: "rm -rf /" }, + }), + stdout: { write: (chunk: string) => chunks.push(chunk) }, + }); + + expect(code).toBe(2); + expect(JSON.parse(chunks.join(""))).toEqual({ + decision: "deny", + reason: "Dangerous command", + }); + }); + + it("uses GROK_HOOK_EVENT when stdin omits hookEventName", async () => { + const dir = await tempDir(); + const marker = join(dir, "env.txt"); + const code = await runGrokHook({ + config: { hooks: [markerHook(marker)] }, + env: { ...process.env, GROK_HOOK_EVENT: "pre_tool_use" }, + stdin: JSON.stringify({ + toolName: "run_terminal_command", + toolInput: { command: "echo env" }, + }), + }); + expect(code).toBe(0); + expect(await readFile(marker, "utf-8")).toBe("echo env"); + }); + + it("fails open on errors instead of blocking", async () => { + const err: string[] = []; + const code = await runGrokHook({ + cwd: "/tmp/ai-hooks-missing-config-dir", + stdin: JSON.stringify({ hookEventName: "SessionStart" }), + stderr: { write: (chunk: string) => err.push(chunk) }, + }); + expect(code).toBe(0); + expect(err.join("")).toContain("[ai-hooks] Error:"); + }); + + it("exits 0 for unrecognized events", async () => { + const code = await runGrokHook({ + config: { hooks: [] }, + stdin: JSON.stringify({ hookEventName: "NotARealEvent" }), + }); + expect(code).toBe(0); + }); + + it("reads streamed stdin chunks and treats invalid JSON as empty", async () => { + expect(await readStdinText(stdinChunks())).toBe('{"hookEventName":"SessionStart"}'); + expect(parseCompatPayload("{nope")).toEqual({}); + expect(parseCompatPayload("")).toEqual({}); + }); + + it("reads process.stdin when no stdin override is provided", async () => { + const original = process.stdin; + Object.defineProperty(process, "stdin", { + configurable: true, + value: (async function* () { + yield '{"hookEventName":"SessionEnd"}'; + })(), + }); + try { + expect(await runGrokHook({ config: { hooks: [] } })).toBe(0); + } finally { + Object.defineProperty(process, "stdin", { configurable: true, value: original }); + } + }); + + it("uses the default deny reason and fail-opens on non-Error throws", async () => { + const chunks: string[] = []; + const code = await runGrokHook({ + config: { + hooks: [ + { + id: "block", + name: "block", + events: ["prompt:submit"], + phase: "before", + handler: async (ctx, next) => { + void next; + ctx.results.push({ blocked: true }); + }, + }, + ], + }, + stdin: JSON.stringify({ hookEventName: "UserPromptSubmit", prompt: "hi" }), + stdout: { + write: () => { + throw new NonErrorFailure(); + }, + }, + stderr: { write: (chunk: string) => chunks.push(chunk) }, + }); + expect(code).toBe(0); + expect(chunks.join("")).toContain("non-error"); + }); + + it("emits the default deny reason when a blocking hook omits one", async () => { + const out: string[] = []; + const code = await runGrokHook({ + config: { + hooks: [ + { + id: "block", + name: "block", + events: ["prompt:submit"], + phase: "before", + handler: async (ctx, next) => { + void next; + ctx.results.push({ blocked: true }); + }, + }, + ], + }, + stdin: JSON.stringify({ hookEventName: "UserPromptSubmit", prompt: "hi" }), + stdout: { write: (chunk: string) => out.push(chunk) }, + }); + expect(code).toBe(2); + expect(JSON.parse(out.join(""))).toEqual({ + decision: "deny", + reason: "Blocked by ai-hooks", + }); + }); +}); diff --git a/packages/hooks/src/adapters/grok-runner.ts b/packages/hooks/src/adapters/grok-runner.ts new file mode 100644 index 0000000..17224bd --- /dev/null +++ b/packages/hooks/src/adapters/grok-runner.ts @@ -0,0 +1,75 @@ +import { loadConfig } from "../config/index.js"; +import { HookEngine } from "../runtime/index.js"; +import type { AiHooksConfig } from "../types/index.js"; +import { + buildCompatEvent, + isBlockingNativeEvent, + payloadEventName, + type CompatPayload, +} from "./claude-compat.js"; + +export type HookRunnerIO = { + stdin?: string | AsyncIterable; + env?: NodeJS.ProcessEnv; + cwd?: string; + config?: AiHooksConfig; + stdout?: { write(chunk: string): unknown }; + stderr?: { write(chunk: string): unknown }; +}; + +export async function readStdinText( + stdin?: string | AsyncIterable, +): Promise { + if (typeof stdin === "string") return stdin; + const stream = stdin ?? process.stdin; + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + return Buffer.concat(chunks).toString("utf-8"); +} + +export function parseCompatPayload(raw: string): CompatPayload { + try { + return JSON.parse(raw || "{}") as CompatPayload; + } catch { + return {}; + } +} + +/** + * Execute configured ai-hooks for a Grok command hook. + * Grok sends JSON on stdin and honors {"decision":"deny"} plus exit 2. + */ +export async function runGrokHook(io: HookRunnerIO = {}): Promise { + const env = io.env ?? process.env; + const stdout = io.stdout ?? process.stdout; + const stderr = io.stderr ?? process.stderr; + + try { + const raw = await readStdinText(io.stdin); + const payload = parseCompatPayload(raw); + const nativeEvent = payloadEventName(payload, env.GROK_HOOK_EVENT); + const event = buildCompatEvent(nativeEvent, payload, "grok"); + if (!event) return 0; + + const config = io.config ?? (await loadConfig(undefined, io.cwd)); + const engine = new HookEngine(config); + const results = await engine.emit(event, { name: "grok", version: "1.0" }); + const blocked = results.find((result) => result.blocked); + if (blocked && isBlockingNativeEvent(nativeEvent)) { + stdout.write( + JSON.stringify({ + decision: "deny", + reason: blocked.reason ?? "Blocked by ai-hooks", + }), + ); + return 2; + } + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + stderr.write(`[ai-hooks] Error: ${message}\n`); + return 0; + } +} diff --git a/packages/hooks/src/adapters/grok.test.ts b/packages/hooks/src/adapters/grok.test.ts new file mode 100644 index 0000000..1357bb0 --- /dev/null +++ b/packages/hooks/src/adapters/grok.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { HookContext, HookDefinition, HookEventType } from "../index.js"; + +vi.mock("./index.js", async () => { + const { BaseAdapter } = await import("./base.js"); + return { BaseAdapter, registry: { register: vi.fn() } }; +}); + +vi.mock("node:child_process", () => ({ + execFile: vi.fn((_cmd: string, _args: string[], cb: (error: Error | null) => void) => { + cb(new Error("not found")); + }), +})); + +import { execFile } from "node:child_process"; +import { GrokAdapter } from "./grok.js"; + +function makeHandler() { + return async (ctx: HookContext, next: () => Promise) => { + void ctx; + await next(); + }; +} + +const shellHook: HookDefinition = { + id: "audit", + name: "audit", + events: ["shell:before" as HookEventType], + phase: "before", + handler: makeHandler(), +}; + +describe("GrokAdapter", () => { + let adapter: GrokAdapter; + let tmp: string; + let previousCwd: string; + + beforeEach(async () => { + vi.clearAllMocks(); + adapter = new GrokAdapter(); + tmp = await mkdtemp(join(tmpdir(), "grok-hooks-")); + previousCwd = process.cwd(); + process.chdir(tmp); + }); + + afterEach(async () => { + process.chdir(previousCwd); + await rm(tmp, { recursive: true, force: true }); + }); + + it("uses Grok identity and Claude-compatible events", () => { + expect(adapter.id).toBe("grok"); + expect(adapter.name).toBe("Grok"); + expect(adapter.mapEvent("tool:before")).toEqual(["PreToolUse"]); + expect(adapter.mapEvent("session:end")).toEqual(["SessionEnd"]); + expect(adapter.mapNativeEvent("SessionStart")).toEqual(["session:start"]); + }); + + it("writes .grok/hooks JSON Grok actually loads plus a HookEngine runner", async () => { + const files = await adapter.generate([shellHook]); + await adapter.install(files); + + expect(files.map((file) => file.path)).toEqual([ + ".grok/hooks/ai-tools.json", + ".grok/hooks/ai-tools-runner.js", + ]); + + const jsonPath = join(tmp, ".grok/hooks/ai-tools.json"); + const runnerPath = join(tmp, ".grok/hooks/ai-tools-runner.js"); + expect(existsSync(jsonPath)).toBe(true); + expect(existsSync(runnerPath)).toBe(true); + + expect(JSON.parse(await readFile(jsonPath, "utf-8"))).toEqual({ + hooks: { + PreToolUse: [ + { + hooks: [{ type: "command", command: "node ai-tools-runner.js", timeout: 10 }], + }, + ], + }, + }); + + const runner = await readFile(runnerPath, "utf-8"); + expect(runner.startsWith("#!/usr/bin/env node\n")).toBe(true); + expect(runner).toContain('import { runGrokHook } from "@itz4blitz/ai-tools-hooks"'); + expect(runner).toContain("process.exit(await runGrokHook())"); + expect(runner).not.toContain("process.stderr.write('ai-tools grok hook runner"); + expect(runner).not.toMatch(/process\.exit\(0\);\s*$/); + }); + + it("maps multiple universal events onto Grok native names without duplicates", async () => { + const files = await adapter.generate([ + { + id: "multi", + name: "multi", + events: ["session:start", "shell:before", "file:write", "notification"], + phase: "before", + handler: makeHandler(), + }, + ]); + const json = JSON.parse(files[0]!.content) as { hooks: Record }; + expect(Object.keys(json.hooks).toSorted()).toEqual([ + "Notification", + "PreToolUse", + "SessionStart", + ]); + expect(json.hooks.PreToolUse).toHaveLength(1); + }); + + it("detects a .grok directory even without the grok binary", async () => { + expect(await adapter.detect()).toBe(false); + await mkdir(join(tmp, ".grok")); + expect(await adapter.detect()).toBe(true); + }); + + it("detects the grok command when present", async () => { + vi.mocked(execFile).mockImplementation(((_cmd, _args, cb) => { + (cb as (error: Error | null) => void)(null); + }) as typeof execFile); + expect(await adapter.detect()).toBe(true); + }); + + it("imports command hooks from .grok/hooks/*.json", async () => { + await mkdir(join(tmp, ".grok/hooks"), { recursive: true }); + await writeFile( + join(tmp, ".grok/hooks/dcg.json"), + `${JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "/usr/bin/dcg", timeout: 5 }], + }, + ], + }, + })}\n`, + "utf-8", + ); + const imported = await adapter.import(tmp); + expect(imported).toHaveLength(1); + expect(imported[0]?.id).toBe("grok-PreToolUse-0-0"); + expect(imported[0]?.name).toBe("/usr/bin/dcg"); + expect(imported[0]?.events).toContain("shell:before"); + expect(imported[0]?.phase).toBe("before"); + }); + + it("skips invalid JSON while importing Grok hooks", async () => { + await mkdir(join(tmp, ".grok/hooks"), { recursive: true }); + await writeFile(join(tmp, ".grok/hooks/broken.json"), "{not json", "utf-8"); + await writeFile(join(tmp, ".grok/hooks/notes.txt"), "ignore me", "utf-8"); + await writeFile(join(tmp, ".grok/hooks/empty.json"), "{}\n", "utf-8"); + expect(await adapter.import(tmp)).toEqual([]); + }); + + it("imports from process.cwd when no directory is passed", async () => { + expect(await adapter.import()).toEqual([]); + expect(adapter.mapEvent("unknown:event" as HookEventType)).toEqual([]); + expect(adapter.mapNativeEvent("NotAThing")).toEqual([]); + }); + + it("uninstalls generated hook files from the project", async () => { + await adapter.install(await adapter.generate([shellHook])); + expect(existsSync(join(tmp, ".grok/hooks/ai-tools.json"))).toBe(true); + await adapter.uninstall(); + expect(existsSync(join(tmp, ".grok/hooks/ai-tools.json"))).toBe(false); + expect(existsSync(join(tmp, ".grok/hooks/ai-tools-runner.js"))).toBe(false); + }); +}); diff --git a/packages/hooks/src/adapters/grok.ts b/packages/hooks/src/adapters/grok.ts new file mode 100644 index 0000000..43370f4 --- /dev/null +++ b/packages/hooks/src/adapters/grok.ts @@ -0,0 +1,180 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { BaseAdapter, registry } from "./index.js"; +import { collectNativeEvents, parseHookGroups, type NativeHookGroup } from "./claude-compat.js"; +import type { + AdapterCapabilities, + GeneratedConfig, + HookDefinition, + HookEventType, +} from "../types/index.js"; + +/** + * Event mapping: ai-hooks universal events -> Grok native hooks. + * Grok's JSON format is Claude-compatible and is loaded from + * `/.grok/hooks/*.json` (and `~/.grok/hooks/*.json`). + */ +const EVENT_MAP: Record = { + "session:start": ["SessionStart"], + "session:end": ["SessionEnd"], + "prompt:submit": ["UserPromptSubmit"], + "prompt:response": [], + "tool:before": ["PreToolUse"], + "tool:after": ["PostToolUse"], + "file:read": ["PreToolUse"], + "file:write": ["PreToolUse"], + "file:edit": ["PreToolUse"], + "file:delete": ["PreToolUse"], + "shell:before": ["PreToolUse"], + "shell:after": ["PostToolUse"], + "mcp:before": ["PreToolUse"], + "mcp:after": ["PostToolUse"], + notification: ["Notification"], +}; + +const REVERSE_MAP: Record = { + SessionStart: ["session:start"], + SessionEnd: ["session:end"], + UserPromptSubmit: ["prompt:submit"], + PreToolUse: [ + "tool:before", + "file:read", + "file:write", + "file:edit", + "file:delete", + "shell:before", + "mcp:before", + ], + PostToolUse: ["tool:after", "shell:after", "mcp:after"], + Notification: ["notification"], +}; + +const HOOKS_JSON = ".grok/hooks/ai-tools.json"; +const RUNNER_JS = ".grok/hooks/ai-tools-runner.js"; +const RUNNER_COMMAND = "node ai-tools-runner.js"; + +class GrokAdapter extends BaseAdapter { + readonly id = "grok"; + readonly name = "Grok"; + readonly version = "1.0"; + + readonly capabilities: AdapterCapabilities = { + beforeHooks: true, + afterHooks: true, + mcp: true, + configFile: true, + supportedEvents: [ + "session:start", + "session:end", + "prompt:submit", + "tool:before", + "tool:after", + "file:read", + "file:write", + "file:edit", + "file:delete", + "shell:before", + "shell:after", + "mcp:before", + "mcp:after", + "notification", + ], + blockableEvents: ["prompt:submit", "tool:before", "shell:before", "mcp:before"], + }; + + async detect(): Promise { + const hasCommand = await this.commandExists("grok"); + const hasDir = existsSync(resolve(process.cwd(), ".grok")); + return hasCommand || hasDir; + } + + async generate(hooks: HookDefinition[]): Promise { + const nativeEvents = collectNativeEvents(hooks, (event) => this.mapEvent(event)); + const hooksTable: Record = {}; + for (const native of nativeEvents) { + hooksTable[native] = [ + { + hooks: [{ type: "command", command: RUNNER_COMMAND, timeout: 10 }], + }, + ]; + } + + return [ + { + path: HOOKS_JSON, + content: `${JSON.stringify({ hooks: hooksTable }, null, 2)}\n`, + format: "json", + }, + { + path: RUNNER_JS, + content: this.generateRunner(), + format: "js", + }, + ]; + } + + async import(cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + const hooksDir = resolve(dir, ".grok/hooks"); + if (!existsSync(hooksDir)) return []; + const entries = await readdir(hooksDir); + const imported: HookDefinition[] = []; + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const fullPath = resolve(hooksDir, entry); + let parsed: { hooks?: Record }; + try { + parsed = JSON.parse(await readFile(fullPath, "utf-8")) as { + hooks?: Record; + }; + } catch { + continue; + } + for (const [nativeEvent, groups] of Object.entries(parsed.hooks ?? {})) { + imported.push( + ...parseHookGroups(groups, nativeEvent, (event) => this.mapNativeEvent(event), "grok"), + ); + } + } + return imported; + } + + mapEvent(event: HookEventType): string[] { + return EVENT_MAP[event] ?? []; + } + + mapNativeEvent(nativeEvent: string): HookEventType[] { + return REVERSE_MAP[nativeEvent] ?? []; + } + + async uninstall(): Promise { + await this.removeFile(HOOKS_JSON); + await this.removeFile(RUNNER_JS); + } + + private generateRunner(): string { + return `#!/usr/bin/env node +/** + * ai-hooks runner for Grok. + * Generated by: ai-hooks generate + * + * Grok sends the event as JSON on stdin (GROK_HOOK_EVENT is also set). + * PreToolUse / UserPromptSubmit can block via {"decision":"deny"} on stdout. + * + * DO NOT EDIT - regenerate with: ai-hooks generate + */ +import { runGrokHook } from "@itz4blitz/ai-tools-hooks"; + +const PROJECT_ROOT = ${JSON.stringify(resolve(process.cwd()))}; +process.chdir(PROJECT_ROOT); + +process.exit(await runGrokHook()); +`; + } +} + +const adapter = new GrokAdapter(); +registry.register(adapter); +export { GrokAdapter }; +export default adapter; diff --git a/packages/hooks/src/adapters/zcode-runner.test.ts b/packages/hooks/src/adapters/zcode-runner.test.ts new file mode 100644 index 0000000..e225c2e --- /dev/null +++ b/packages/hooks/src/adapters/zcode-runner.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runZcodeHook } from "./zcode-runner.js"; +import type { AiHooksConfig, HookDefinition } from "../types/index.js"; + +const temps: string[] = []; + +class NonErrorFailure { + toString(): string { + return "non-error"; + } +} + +afterEach(async () => { + await Promise.all(temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), "zcode-runner-")); + temps.push(dir); + return dir; +} + +function markerHook(markerPath: string): HookDefinition { + return { + id: "write-marker", + name: "write-marker", + events: ["shell:before"], + phase: "before", + handler: async (ctx, next) => { + if (ctx.event.type === "shell:before") { + await writeFile(markerPath, ctx.event.command, "utf-8"); + } + await next(); + }, + }; +} + +describe("runZcodeHook", () => { + it("runs a configured hook that writes a file when ZCode invokes the runner", async () => { + const dir = await tempDir(); + const marker = join(dir, "ran.txt"); + const config: AiHooksConfig = { hooks: [markerHook(marker)] }; + + const code = await runZcodeHook({ + config, + stdin: JSON.stringify({ + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "echo from-zcode" }, + cwd: dir, + }), + }); + + expect(code).toBe(0); + expect(await readFile(marker, "utf-8")).toBe("echo from-zcode"); + }); + + it("blocks PreToolUse with exit 2 and stderr, leaving stdout empty", async () => { + const err: string[] = []; + const out: string[] = []; + const config: AiHooksConfig = { + hooks: [ + { + id: "block", + name: "block", + events: ["shell:before"], + phase: "before", + handler: async (ctx, next) => { + void next; + ctx.results.push({ blocked: true, reason: "Blocked by policy" }); + }, + }, + ], + }; + + const code = await runZcodeHook({ + config, + stdin: JSON.stringify({ + hookEventName: "PreToolUse", + toolName: "Bash", + toolInput: { command: "rm -rf /" }, + }), + stdout: { write: (chunk: string) => out.push(chunk) }, + stderr: { write: (chunk: string) => err.push(chunk) }, + }); + + expect(code).toBe(2); + expect(out.join("")).toBe(""); + expect(err.join("")).toBe("Blocked by policy"); + }); + + it("fails open on missing config", async () => { + const err: string[] = []; + const code = await runZcodeHook({ + cwd: "/tmp/ai-hooks-missing-zcode-config", + stdin: JSON.stringify({ hookEventName: "SessionStart" }), + stderr: { write: (chunk: string) => err.push(chunk) }, + }); + expect(code).toBe(0); + expect(err.join("")).toContain("[ai-hooks] Error:"); + }); + + it("loads ai-hooks.config.js when no config is passed", async () => { + const dir = await tempDir(); + const marker = join(dir, "from-config.txt"); + await writeFile( + join(dir, "ai-hooks.config.js"), + `import { writeFile } from "node:fs/promises"; +export default { + hooks: [{ + id: "from-file", + name: "from-file", + events: ["session:start"], + phase: "before", + handler: async (ctx, next) => { + await writeFile(${JSON.stringify(marker)}, ctx.event.type, "utf8"); + await next(); + }, + }], +}; +`, + "utf-8", + ); + const code = await runZcodeHook({ + cwd: dir, + env: { ...process.env, ZCODE_HOOK_EVENT: "session_start" }, + stdin: "{}", + }); + expect(code).toBe(0); + expect(await readFile(marker, "utf-8")).toBe("session:start"); + }); + + it("uses the default block reason and ignores unrecognized events", async () => { + const err: string[] = []; + const blocked = await runZcodeHook({ + config: { + hooks: [ + { + id: "block", + name: "block", + events: ["tool:before"], + phase: "before", + handler: async (ctx, next) => { + void next; + ctx.results.push({ blocked: true }); + }, + }, + ], + }, + env: { ...process.env, CLAUDE_HOOK_EVENT: "PermissionRequest" }, + stdin: JSON.stringify({ toolName: "Grep", toolInput: { pattern: "x" } }), + stderr: { write: (chunk: string) => err.push(chunk) }, + }); + expect(blocked).toBe(2); + expect(err.join("")).toBe("Blocked by ai-hooks"); + expect( + await runZcodeHook({ + config: { hooks: [] }, + stdin: JSON.stringify({ hookEventName: "Nope" }), + }), + ).toBe(0); + }); + + it("fail-opens when a non-Error is thrown", async () => { + const err: string[] = []; + const code = await runZcodeHook({ + config: { + hooks: [ + { + id: "block", + name: "block", + events: ["shell:before"], + phase: "before", + handler: async (ctx, next) => { + void next; + ctx.results.push({ blocked: true }); + }, + }, + ], + }, + stdin: JSON.stringify({ + hookEventName: "PreToolUse", + toolName: "Bash", + toolInput: { command: "ls" }, + }), + stderr: { + write: (chunk: string) => { + if (chunk.startsWith("[ai-hooks]")) err.push(chunk); + else throw new NonErrorFailure(); + }, + }, + }); + expect(code).toBe(0); + expect(err.join("")).toContain("non-error"); + }); +}); diff --git a/packages/hooks/src/adapters/zcode-runner.ts b/packages/hooks/src/adapters/zcode-runner.ts new file mode 100644 index 0000000..d3137e8 --- /dev/null +++ b/packages/hooks/src/adapters/zcode-runner.ts @@ -0,0 +1,36 @@ +import { loadConfig } from "../config/index.js"; +import { HookEngine } from "../runtime/index.js"; +import { buildCompatEvent, isBlockingNativeEvent, payloadEventName } from "./claude-compat.js"; +import { parseCompatPayload, readStdinText, type HookRunnerIO } from "./grok-runner.js"; + +/** + * Execute configured ai-hooks for a ZCode command hook. + * ZCode blocks PreToolUse / PermissionRequest / UserPromptSubmit with exit 2. + * stdout is omitted on block because ZCode's JSON schema is strict. + */ +export async function runZcodeHook(io: HookRunnerIO = {}): Promise { + const env = io.env ?? process.env; + const stderr = io.stderr ?? process.stderr; + + try { + const raw = await readStdinText(io.stdin); + const payload = parseCompatPayload(raw); + const nativeEvent = payloadEventName(payload, env.ZCODE_HOOK_EVENT ?? env.CLAUDE_HOOK_EVENT); + const event = buildCompatEvent(nativeEvent, payload, "zcode"); + if (!event) return 0; + + const config = io.config ?? (await loadConfig(undefined, io.cwd)); + const engine = new HookEngine(config); + const results = await engine.emit(event, { name: "zcode", version: "1.0" }); + const blocked = results.find((result) => result.blocked); + if (blocked && isBlockingNativeEvent(nativeEvent)) { + stderr.write(blocked.reason ?? "Blocked by ai-hooks"); + return 2; + } + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + stderr.write(`[ai-hooks] Error: ${message}\n`); + return 0; + } +} diff --git a/packages/hooks/src/adapters/zcode.test.ts b/packages/hooks/src/adapters/zcode.test.ts new file mode 100644 index 0000000..2264b20 --- /dev/null +++ b/packages/hooks/src/adapters/zcode.test.ts @@ -0,0 +1,335 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { HookContext, HookDefinition, HookEventType } from "../index.js"; + +const { mockHome } = vi.hoisted(() => ({ mockHome: { value: "" } })); + +vi.mock("./index.js", async () => { + const { BaseAdapter } = await import("./base.js"); + return { BaseAdapter, registry: { register: vi.fn() } }; +}); + +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => mockHome.value || actual.homedir(), + }; +}); + +vi.mock("node:child_process", () => ({ + execFile: vi.fn((_cmd: string, _args: string[], cb: (error: Error | null) => void) => { + cb(new Error("not found")); + }), +})); + +import { execFile } from "node:child_process"; +import { ZcodeAdapter } from "./zcode.js"; + +function makeHandler() { + return async (ctx: HookContext, next: () => Promise) => { + void ctx; + await next(); + }; +} + +const shellHook: HookDefinition = { + id: "guard", + name: "guard", + events: ["shell:before" as HookEventType], + phase: "before", + handler: makeHandler(), +}; + +const RUNNER_COMMAND = `node "\${ZCODE_PROJECT_DIR}/.zcode/hooks/ai-tools-runner.js"`; + +describe("ZcodeAdapter", () => { + let adapter: ZcodeAdapter; + let tmp: string; + let home: string; + let previousCwd: string; + + beforeEach(async () => { + vi.clearAllMocks(); + tmp = await mkdtemp(join(tmpdir(), "zcode-hooks-")); + home = await mkdtemp(join(tmpdir(), "zcode-home-")); + mockHome.value = home; + adapter = new ZcodeAdapter(); + previousCwd = process.cwd(); + process.chdir(tmp); + }); + + afterEach(async () => { + process.chdir(previousCwd); + mockHome.value = ""; + await rm(tmp, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + }); + + it("uses ZCode identity and the seven native events", () => { + expect(adapter.id).toBe("zcode"); + expect(adapter.name).toBe("ZCode"); + expect(adapter.mapEvent("shell:before")).toEqual(["PreToolUse"]); + expect(adapter.mapEvent("session:end")).toEqual(["Stop"]); + expect(adapter.mapEvent("notification")).toEqual([]); + expect(adapter.mapNativeEvent("UserPromptSubmit")).toEqual(["prompt:submit"]); + expect(adapter.mapNativeEvent("PermissionRequest")).toEqual(["tool:before"]); + expect(adapter.userConfigPath).toBe(join(home, ".zcode/cli/config.json")); + expect(adapter.userHooksPath).toBe(join(home, ".zcode/hooks.json")); + }); + + it("writes .zcode/hooks.json and .zcode/config.json in the formats ZCode loads", async () => { + const files = await adapter.generate([shellHook]); + await adapter.install(files); + + expect(files.map((file) => file.path)).toEqual([ + ".zcode/hooks.json", + ".zcode/config.json", + ".zcode/hooks/ai-tools-runner.js", + ]); + + const hookEntry = { + type: "command", + command: RUNNER_COMMAND, + timeout: 10, + statusMessage: "ai-hooks", + }; + + expect(JSON.parse(await readFile(join(tmp, ".zcode/hooks.json"), "utf-8"))).toEqual({ + version: 1, + hooks: { + PreToolUse: [{ hooks: [hookEntry] }], + }, + }); + + expect(JSON.parse(await readFile(join(tmp, ".zcode/config.json"), "utf-8"))).toEqual({ + hooks: { + enabled: true, + events: { + PreToolUse: [{ hooks: [hookEntry] }], + }, + }, + }); + + const runner = await readFile(join(tmp, ".zcode/hooks/ai-tools-runner.js"), "utf-8"); + expect(runner.startsWith("#!/usr/bin/env node\n")).toBe(true); + expect(runner).toContain('import { runZcodeHook } from "@itz4blitz/ai-tools-hooks"'); + expect(runner).toContain("process.exit(await runZcodeHook())"); + expect(runner).not.toMatch(/process\.exit\(0\);\s*$/); + }); + + it("merges generated hooks into an existing .zcode/config.json without dropping other keys", async () => { + await mkdir(join(tmp, ".zcode"), { recursive: true }); + await writeFile( + join(tmp, ".zcode/config.json"), + `${JSON.stringify({ + model: "keep-me", + hooks: { + enabled: false, + events: { + Stop: [ + { + hooks: [{ type: "command", command: "echo custom", timeout: 5 }], + }, + ], + }, + }, + })}\n`, + "utf-8", + ); + + await adapter.install(await adapter.generate([shellHook])); + const config = JSON.parse(await readFile(join(tmp, ".zcode/config.json"), "utf-8")) as { + model: string; + hooks: { enabled: boolean; events: Record }; + }; + expect(config.model).toBe("keep-me"); + expect(config.hooks.enabled).toBe(true); + expect(config.hooks.events.Stop).toHaveLength(1); + expect(config.hooks.events.PreToolUse).toHaveLength(1); + }); + + it("detects a project .zcode directory", async () => { + expect(await adapter.detect()).toBe(false); + await mkdir(join(tmp, ".zcode")); + expect(await adapter.detect()).toBe(true); + }); + + it("detects user ~/.zcode/hooks.json without a zcode binary", async () => { + await mkdir(join(home, ".zcode"), { recursive: true }); + await writeFile(join(home, ".zcode/hooks.json"), "{}\n", "utf-8"); + expect(await adapter.detect()).toBe(true); + }); + + it("detects the zcode command", async () => { + vi.mocked(execFile).mockImplementation(((_cmd, _args, cb) => { + (cb as (error: Error | null) => void)(null); + }) as typeof execFile); + expect(await adapter.detect()).toBe(true); + }); + + it("imports workspace hooks.json and config.json events", async () => { + await mkdir(join(tmp, ".zcode"), { recursive: true }); + await writeFile( + join(tmp, ".zcode/hooks.json"), + `${JSON.stringify({ + version: 1, + hooks: { + SessionStart: [{ hooks: [{ type: "command", command: "echo session", timeout: 30 }] }], + }, + })}\n`, + "utf-8", + ); + await writeFile( + join(tmp, ".zcode/config.json"), + `${JSON.stringify({ + hooks: { + enabled: true, + events: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "/usr/bin/dcg", timeout: 5 }], + }, + ], + }, + }, + })}\n`, + "utf-8", + ); + + const imported = await adapter.import(tmp); + expect(imported.map((hook) => hook.name).toSorted()).toEqual(["/usr/bin/dcg", "echo session"]); + const session = imported.find((hook) => hook.name === "echo session"); + expect(session?.events).toEqual(["session:start"]); + expect(session?.phase).toBe("before"); + const pre = imported.find((hook) => hook.name === "/usr/bin/dcg"); + expect(pre?.events).toContain("shell:before"); + }); + + it("imports user ~/.zcode/hooks.json and cli/config.json hooks.events", async () => { + await mkdir(join(home, ".zcode/cli"), { recursive: true }); + await writeFile( + join(home, ".zcode/hooks.json"), + `${JSON.stringify({ + version: 1, + hooks: { + Stop: [{ matcher: "", hooks: [{ type: "command", command: "echo stop", timeout: 20 }] }], + }, + })}\n`, + "utf-8", + ); + await writeFile( + join(home, ".zcode/cli/config.json"), + `${JSON.stringify({ + model: "keep-me", + hooks: { + enabled: true, + events: { + UserPromptSubmit: [ + { matcher: "", hooks: [{ type: "command", command: "echo prompt", timeout: 20 }] }, + ], + }, + }, + })}\n`, + "utf-8", + ); + + const imported = await adapter.importUser(); + expect(imported.map((hook) => hook.name).toSorted()).toEqual(["echo prompt", "echo stop"]); + expect(imported.find((hook) => hook.name === "echo prompt")?.events).toEqual(["prompt:submit"]); + expect(imported.find((hook) => hook.name === "echo stop")?.events).toContain("session:end"); + }); + + it("imports zcode.json workspace config and ignores invalid JSON", async () => { + await writeFile( + join(tmp, "zcode.json"), + `${JSON.stringify({ + hooks: { + enabled: true, + events: { + PostToolUse: [{ hooks: [{ type: "command", command: "echo after" }] }], + }, + }, + })}\n`, + "utf-8", + ); + await mkdir(join(tmp, ".zcode"), { recursive: true }); + await writeFile(join(tmp, ".zcode/config.json"), "[1,2,3]", "utf-8"); + await writeFile(join(tmp, ".zcode/hooks.json"), "{not-json", "utf-8"); + const imported = await adapter.import(); + expect(imported.map((hook) => hook.name)).toEqual(["echo after"]); + expect(imported[0]?.phase).toBe("after"); + }); + + it("detects user ~/.zcode/cli/config.json", async () => { + await mkdir(join(home, ".zcode/cli"), { recursive: true }); + await writeFile(join(home, ".zcode/cli/config.json"), "{}\n", "utf-8"); + expect(await adapter.detect()).toBe(true); + }); + + it("maps unknown events to empty arrays and imports nothing from missing user files", async () => { + expect(adapter.mapEvent("unknown:event" as HookEventType)).toEqual([]); + expect(adapter.mapNativeEvent("NotAThing")).toEqual([]); + expect(await adapter.importUser()).toEqual([]); + }); + + it("does not rewrite missing workspace config on uninstall", async () => { + await adapter.uninstall(); + expect(existsSync(join(tmp, ".zcode/config.json"))).toBe(false); + }); + + it("preserves non-command matcher groups while replacing ai-hooks entries", async () => { + await mkdir(join(tmp, ".zcode"), { recursive: true }); + await writeFile( + join(tmp, ".zcode/config.json"), + `${JSON.stringify({ + hooks: { + events: { + PreToolUse: "not-an-array", + Stop: [{ matcher: "x" }, { hooks: [{ type: "command", command: RUNNER_COMMAND }] }], + }, + }, + })}\n`, + "utf-8", + ); + await adapter.install(await adapter.generate([shellHook])); + const config = JSON.parse(await readFile(join(tmp, ".zcode/config.json"), "utf-8")) as { + hooks: { events: Record }; + }; + expect(config.hooks.events.PreToolUse).toHaveLength(1); + expect(config.hooks.events.Stop).toHaveLength(1); + }); + + it("uninstalls generated files and strips only ai-hooks entries from config.json", async () => { + await mkdir(join(tmp, ".zcode"), { recursive: true }); + await writeFile( + join(tmp, ".zcode/config.json"), + `${JSON.stringify({ + model: "keep-me", + hooks: { + enabled: true, + events: { + Stop: [{ hooks: [{ type: "command", command: "echo custom" }] }], + }, + }, + })}\n`, + "utf-8", + ); + await adapter.install(await adapter.generate([shellHook])); + await adapter.uninstall(); + + expect(existsSync(join(tmp, ".zcode/hooks.json"))).toBe(false); + expect(existsSync(join(tmp, ".zcode/hooks/ai-tools-runner.js"))).toBe(false); + const config = JSON.parse(await readFile(join(tmp, ".zcode/config.json"), "utf-8")) as { + model: string; + hooks: { events: Record }; + }; + expect(config.model).toBe("keep-me"); + expect(config.hooks.events.Stop).toHaveLength(1); + expect(config.hooks.events.PreToolUse).toBeUndefined(); + }); +}); diff --git a/packages/hooks/src/adapters/zcode.ts b/packages/hooks/src/adapters/zcode.ts new file mode 100644 index 0000000..51ad080 --- /dev/null +++ b/packages/hooks/src/adapters/zcode.ts @@ -0,0 +1,309 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; +import { BaseAdapter, registry } from "./index.js"; +import { collectNativeEvents, parseHookGroups, type NativeHookGroup } from "./claude-compat.js"; +import type { + AdapterCapabilities, + GeneratedConfig, + HookDefinition, + HookEventType, +} from "../types/index.js"; + +/** + * ZCode loads configuration-file hooks from: + * - workspace `/.zcode/config.json` (or `zcode.json`) → hooks.events + * - user `~/.zcode/cli/config.json` → hooks.events (requires hooks.enabled) + * - user `~/.zcode/hooks.json` → { version, hooks } (Claude-compatible) + * + * Supported events: SessionStart, UserPromptSubmit, PreToolUse, + * PermissionRequest, PostToolUse, PostToolUseFailure, Stop. + */ +const EVENT_MAP: Record = { + "session:start": ["SessionStart"], + "session:end": ["Stop"], + "prompt:submit": ["UserPromptSubmit"], + "prompt:response": ["Stop"], + "tool:before": ["PreToolUse"], + "tool:after": ["PostToolUse"], + "file:read": ["PreToolUse"], + "file:write": ["PreToolUse"], + "file:edit": ["PreToolUse"], + "file:delete": ["PreToolUse"], + "shell:before": ["PreToolUse"], + "shell:after": ["PostToolUse"], + "mcp:before": ["PreToolUse"], + "mcp:after": ["PostToolUse"], + notification: [], +}; + +const REVERSE_MAP: Record = { + SessionStart: ["session:start"], + UserPromptSubmit: ["prompt:submit"], + PreToolUse: [ + "tool:before", + "file:read", + "file:write", + "file:edit", + "file:delete", + "shell:before", + "mcp:before", + ], + PermissionRequest: ["tool:before"], + PostToolUse: ["tool:after", "shell:after", "mcp:after"], + PostToolUseFailure: ["tool:after"], + Stop: ["session:end", "prompt:response"], +}; + +const HOOKS_JSON = ".zcode/hooks.json"; +const CONFIG_JSON = ".zcode/config.json"; +const RUNNER_JS = ".zcode/hooks/ai-tools-runner.js"; +const RUNNER_COMMAND = `node "\${ZCODE_PROJECT_DIR}/.zcode/hooks/ai-tools-runner.js"`; + +class ZcodeAdapter extends BaseAdapter { + readonly id = "zcode"; + readonly name = "ZCode"; + readonly version = "1.0"; + readonly userConfigPath = `${homedir()}/.zcode/cli/config.json`; + readonly userHooksPath = `${homedir()}/.zcode/hooks.json`; + + readonly capabilities: AdapterCapabilities = { + beforeHooks: true, + afterHooks: true, + mcp: true, + configFile: true, + supportedEvents: [ + "session:start", + "session:end", + "prompt:submit", + "prompt:response", + "tool:before", + "tool:after", + "file:read", + "file:write", + "file:edit", + "file:delete", + "shell:before", + "shell:after", + "mcp:before", + "mcp:after", + ], + blockableEvents: [ + "prompt:submit", + "tool:before", + "file:write", + "file:edit", + "file:delete", + "shell:before", + "mcp:before", + ], + }; + + async detect(): Promise { + if (await this.commandExists("zcode")) return true; + if (existsSync(resolve(process.cwd(), ".zcode"))) return true; + if (existsSync(this.userConfigPath) || existsSync(this.userHooksPath)) return true; + return false; + } + + async generate(hooks: HookDefinition[]): Promise { + const nativeEvents = collectNativeEvents(hooks, (event) => this.mapEvent(event)); + const groups = this.buildGroups(nativeEvents); + const config = await this.mergeWorkspaceConfig(groups); + + return [ + { + path: HOOKS_JSON, + content: `${JSON.stringify({ version: 1, hooks: groups }, null, 2)}\n`, + format: "json", + }, + { + path: CONFIG_JSON, + content: `${JSON.stringify(config, null, 2)}\n`, + format: "json", + }, + { + path: RUNNER_JS, + content: this.generateRunner(), + format: "js", + }, + ]; + } + + async import(cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + const imported: HookDefinition[] = []; + imported.push( + ...(await this.importHooksFile(resolve(dir, HOOKS_JSON), "zcode")), + ...(await this.importConfigFile(resolve(dir, CONFIG_JSON), "zcode")), + ...(await this.importConfigFile(resolve(dir, "zcode.json"), "zcode")), + ); + return imported; + } + + async importUser(): Promise { + return [ + ...(await this.importHooksFile(this.userHooksPath, "zcode-user")), + ...(await this.importConfigFile(this.userConfigPath, "zcode-user")), + ]; + } + + mapEvent(event: HookEventType): string[] { + return EVENT_MAP[event] ?? []; + } + + mapNativeEvent(nativeEvent: string): HookEventType[] { + return REVERSE_MAP[nativeEvent] ?? []; + } + + async uninstall(): Promise { + await this.removeFile(HOOKS_JSON); + await this.removeFile(RUNNER_JS); + await this.stripWorkspaceConfig(); + } + + private buildGroups(nativeEvents: string[]): Record { + const groups: Record = {}; + for (const native of nativeEvents) { + groups[native] = [ + { + hooks: [ + { + type: "command", + command: RUNNER_COMMAND, + timeout: 10, + statusMessage: "ai-hooks", + }, + ], + }, + ]; + } + return groups; + } + + private generateRunner(): string { + return `#!/usr/bin/env node +/** + * ai-hooks runner for ZCode. + * Generated by: ai-hooks generate + * + * ZCode sends the event as JSON on stdin. Blocking events deny with exit 2. + * + * DO NOT EDIT - regenerate with: ai-hooks generate + */ +import { runZcodeHook } from "@itz4blitz/ai-tools-hooks"; + +const PROJECT_ROOT = ${JSON.stringify(resolve(process.cwd()))}; +process.chdir(PROJECT_ROOT); + +process.exit(await runZcodeHook()); +`; + } + + private async mergeWorkspaceConfig( + groups: Record, + ): Promise> { + const existing = await this.readJsonRecord(resolve(process.cwd(), CONFIG_JSON)); + const existingHooks = asObject(existing.hooks); + const existingEvents = asObject(existingHooks.events); + const mergedEvents: Record = { ...existingEvents }; + + for (const [event, entries] of Object.entries(existingEvents)) { + mergedEvents[event] = stripAiHooksGroups(entries); + } + for (const [event, entries] of Object.entries(groups)) { + const current = Array.isArray(mergedEvents[event]) ? mergedEvents[event] : []; + mergedEvents[event] = [...current, ...entries]; + } + + return { + ...existing, + hooks: { + ...existingHooks, + enabled: true, + events: mergedEvents, + }, + }; + } + + private async stripWorkspaceConfig(): Promise { + const fullPath = resolve(process.cwd(), CONFIG_JSON); + const existing = await this.readJsonRecord(fullPath); + if (Object.keys(existing).length === 0) return; + const existingHooks = asObject(existing.hooks); + const existingEvents = asObject(existingHooks.events); + const stripped: Record = {}; + for (const [event, entries] of Object.entries(existingEvents)) { + const kept = stripAiHooksGroups(entries); + if (kept.length > 0) stripped[event] = kept; + } + const next = { + ...existing, + hooks: { + ...existingHooks, + events: stripped, + }, + }; + await this.writeJsonFile(CONFIG_JSON, next); + } + + private async importHooksFile(fullPath: string, source: string): Promise { + const data = await this.readJsonRecord(fullPath); + return this.definitionsFromEvents(asObject(data.hooks), source); + } + + private async importConfigFile(fullPath: string, source: string): Promise { + const data = await this.readJsonRecord(fullPath); + const hooks = asObject(data.hooks); + return this.definitionsFromEvents(asObject(hooks.events), source); + } + + private definitionsFromEvents(events: Record, source: string): HookDefinition[] { + const imported: HookDefinition[] = []; + for (const [nativeEvent, groups] of Object.entries(events)) { + imported.push( + ...parseHookGroups(groups, nativeEvent, (event) => this.mapNativeEvent(event), source), + ); + } + return imported; + } + + private async readJsonRecord(fullPath: string): Promise> { + if (!existsSync(fullPath)) return {}; + try { + const parsed: unknown = JSON.parse(await readFile(fullPath, "utf-8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return {}; + } + return {}; + } +} + +function asObject(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +function stripAiHooksGroups(entries: unknown): NativeHookGroup[] { + if (!Array.isArray(entries)) return []; + return entries.filter((entry) => { + const group = asObject(entry); + const hooks = group.hooks; + if (!Array.isArray(hooks)) return true; + return !hooks.some((hook) => { + const command = asObject(hook).command; + return typeof command === "string" && command.includes("ai-tools-runner.js"); + }); + }) as NativeHookGroup[]; +} + +const adapter = new ZcodeAdapter(); +registry.register(adapter); +export { ZcodeAdapter }; +export default adapter; diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 6ff9aba..e222a7f 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -15,6 +15,8 @@ export { executeChain, HookTimeoutError } from "./runtime/index.js"; // Adapter system export { registry } from "./adapters/index.js"; export { BaseAdapter } from "./adapters/index.js"; +export { runGrokHook } from "./adapters/grok-runner.js"; +export { runZcodeHook } from "./adapters/zcode-runner.js"; // Built-in hooks export { diff --git a/packages/hooks/src/types/adapter.ts b/packages/hooks/src/types/adapter.ts index d3287d4..2229f9d 100644 --- a/packages/hooks/src/types/adapter.ts +++ b/packages/hooks/src/types/adapter.ts @@ -84,6 +84,11 @@ export interface Adapter { * Map a tool's native event name back to universal event type(s). */ mapNativeEvent(nativeEvent: string): HookEventType[]; + + /** + * Import native hook configs from a project directory into universal definitions. + */ + import?(cwd?: string): Promise; } /** diff --git a/packages/mcp/CLAUDE.md b/packages/mcp/CLAUDE.md index 39146cc..c34b437 100644 --- a/packages/mcp/CLAUDE.md +++ b/packages/mcp/CLAUDE.md @@ -6,10 +6,12 @@ MCP (Model Context Protocol) server configuration management. Simpler than hooks `MCPServerDefinition`: `id`, `name`, `description?`, `transport`, `enabled?`, `tags?` -Two transport types: +Two transport types (plus `http`, which adapters map to native URL/remote forms): - `{ type: "stdio", command: string, args?: string[], env?: Record }` -- `{ type: "sse", url: string, headers?: Record }` +- `{ type: "sse" | "http", url: string, headers?: Record }` + +Servers may set `layer: "user" | "project"` (default project) and `whenPathContains` so PalamHealth MCPs install only in PalamHealth repos. `ai-mcp audit` scans user-global configs for interactive `op-run-palamhealth` and PalamHealth servers that leaked out of project scope. Config: `MCPConfig { servers: MCPServerDefinition[] }` diff --git a/packages/mcp/src/adapters/all.ts b/packages/mcp/src/adapters/all.ts index b5671b5..d8ad24a 100644 --- a/packages/mcp/src/adapters/all.ts +++ b/packages/mcp/src/adapters/all.ts @@ -8,7 +8,9 @@ import "./copilot.js"; import "./cursor.js"; import "./droid.js"; import "./gemini-cli.js"; +import "./grok.js"; import "./kiro.js"; import "./opencode.js"; import "./roo-code.js"; import "./windsurf.js"; +import "./zcode.js"; diff --git a/packages/mcp/src/adapters/base.ts b/packages/mcp/src/adapters/base.ts index 9ab6c16..ec50819 100644 --- a/packages/mcp/src/adapters/base.ts +++ b/packages/mcp/src/adapters/base.ts @@ -1,13 +1,15 @@ import type { MCPServerDefinition, GeneratedFile } from "../types/index.js"; import { existsSync } from "node:fs"; import { readFile, writeFile, mkdir, rm } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { dirname, isAbsolute, resolve } from "node:path"; export abstract class BaseMCPAdapter { abstract readonly id: string; abstract readonly name: string; abstract readonly nativeSupport: boolean; abstract readonly configPath: string; + /** Absolute user-global config path when this host has one. */ + readonly userConfigPath?: string; /** Whether this adapter can use symlinks ("symlink") or needs file transformation ("transform"). */ readonly installStrategy: "symlink" | "transform" = "transform"; @@ -18,6 +20,12 @@ export abstract class BaseMCPAdapter { abstract generate(servers: MCPServerDefinition[]): Promise; abstract import(cwd?: string): Promise; + /** Read the user-global config. Defaults to import(homedir()). */ + async importUser(): Promise { + const { homedir } = await import("node:os"); + return this.import(homedir()); + } + async detect(cwd?: string): Promise { const dir = cwd ?? process.cwd(); const hasConfig = existsSync(resolve(dir, this.configPath)); @@ -39,7 +47,7 @@ export abstract class BaseMCPAdapter { async install(files: GeneratedFile[], cwd?: string): Promise { const dir = cwd ?? process.cwd(); for (const file of files) { - const fullPath = resolve(dir, file.path); + const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path); await mkdir(dirname(fullPath), { recursive: true }); await writeFile(fullPath, file.content, "utf-8"); } diff --git a/packages/mcp/src/adapters/claude-code.test.ts b/packages/mcp/src/adapters/claude-code.test.ts index 089d167..a181093 100644 --- a/packages/mcp/src/adapters/claude-code.test.ts +++ b/packages/mcp/src/adapters/claude-code.test.ts @@ -16,7 +16,8 @@ vi.mock("node:fs/promises", () => ({ })); import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; import { ClaudeCodeMCPAdapter } from "./claude-code.js"; import type { MCPServerDefinition } from "../types/index.js"; @@ -39,6 +40,9 @@ describe("ClaudeCodeMCPAdapter", () => { it("has correct name", () => expect(adapter.name).toBe("Claude Code")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); it("has correct config path", () => expect(adapter.configPath).toBe(".mcp.json")); + it("uses ~/.claude.json for user MCP, not ~/.mcp.json", () => { + expect(adapter.userConfigPath).toBe(`${homedir()}/.claude.json`); + }); }); describe("generate", () => { @@ -60,9 +64,24 @@ describe("ClaudeCodeMCPAdapter", () => { }; const files = await adapter.generate([sseServer]); const parsed = JSON.parse(files[0]!.content); + expect(parsed.mcpServers["sse-server"].type).toBe("sse"); expect(parsed.mcpServers["sse-server"].url).toBe("http://localhost:3000"); }); + it("emits type http for HTTP remotes", async () => { + const httpServer: MCPServerDefinition = { + id: "http-server", + name: "HTTP Server", + transport: { type: "http", url: "https://mcp.linear.app/mcp" }, + }; + const files = await adapter.generate([httpServer]); + const parsed = JSON.parse(files[0]!.content) as { + mcpServers: Record; + }; + expect(parsed.mcpServers["http-server"]?.type).toBe("http"); + expect(parsed.mcpServers["http-server"]?.url).toBe("https://mcp.linear.app/mcp"); + }); + it("handles empty servers array", async () => { const files = await adapter.generate([]); const parsed = JSON.parse(files[0]!.content); @@ -133,5 +152,61 @@ describe("ClaudeCodeMCPAdapter", () => { const result = await adapter.import(); expect(result).toEqual([]); }); + + it("imports user MCP from ~/.claude.json mcpServers", async () => { + vi.mocked(existsSync).mockImplementation((path) => String(path).endsWith(".claude.json")); + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ + theme: "keep-me", + mcpServers: { + screenpipe: { command: "bun", args: ["x", "screenpipe-mcp@latest"] }, + }, + }), + ); + const result = await adapter.importUser(); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("screenpipe"); + expect(result[0]!.transport.type).toBe("stdio"); + }); + + it("round-trips type http from host files", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ + mcpServers: { docs: { type: "http", url: "https://code.claude.com/docs/mcp" } }, + }), + ); + const result = await adapter.import("/test"); + expect(result[0]!.transport.type).toBe("http"); + }); + }); + + describe("install merge", () => { + it("does not clobber unrelated ~/.claude.json keys", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ theme: "dark", mcpServers: { keep: { command: "old" } } }), + ); + await adapter.install( + [ + { + path: `${homedir()}/.claude.json`, + content: JSON.stringify({ + mcpServers: { github: { command: "gh-mcp", args: [] } }, + }), + format: "json", + }, + ], + "/tmp", + ); + const written = JSON.parse(String(vi.mocked(writeFile).mock.calls[0]?.[1])) as { + theme: string; + mcpServers: Record; + }; + expect(written.theme).toBe("dark"); + expect(written.mcpServers.keep?.command).toBe("old"); + expect(written.mcpServers.github?.command).toBe("gh-mcp"); + expect(vi.mocked(mkdir)).toHaveBeenCalled(); + }); }); }); diff --git a/packages/mcp/src/adapters/claude-code.ts b/packages/mcp/src/adapters/claude-code.ts index 7b9aecf..f5a3699 100644 --- a/packages/mcp/src/adapters/claude-code.ts +++ b/packages/mcp/src/adapters/claude-code.ts @@ -1,37 +1,30 @@ +import { homedir } from "node:os"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; import { BaseMCPAdapter } from "./base.js"; import { registry } from "./registry.js"; -import type { MCPServerDefinition, GeneratedFile } from "../types/index.js"; -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import type { GeneratedFile, MCPServerDefinition } from "../types/index.js"; +import { + encodeMcpServersFile, + fromMcpServersMap, + mergeMcpServersJson, + type McpServersMap, +} from "./mcp-servers-json.js"; class ClaudeCodeMCPAdapter extends BaseMCPAdapter { readonly id = "claude-code"; readonly name = "Claude Code"; readonly nativeSupport = true; readonly configPath = ".mcp.json"; + readonly userConfigPath = `${homedir()}/.claude.json`; readonly command = "claude"; async generate(servers: MCPServerDefinition[]): Promise { - const mcpServers: Record = {}; - for (const server of servers) { - if (server.transport.type === "stdio") { - mcpServers[server.id] = { - command: server.transport.command, - args: server.transport.args ?? [], - env: server.transport.env ?? {}, - }; - } else { - mcpServers[server.id] = { - url: server.transport.url, - headers: server.transport.headers ?? {}, - }; - } - } return [ { path: this.configPath, - content: JSON.stringify({ mcpServers }, null, 2) + "\n", + content: encodeMcpServersFile(servers), format: "json", }, ]; @@ -39,36 +32,30 @@ class ClaudeCodeMCPAdapter extends BaseMCPAdapter { async import(cwd?: string): Promise { const dir = cwd ?? process.cwd(); - const fullPath = resolve(dir, this.configPath); - if (!existsSync(fullPath)) return []; - const raw = await readFile(fullPath, "utf-8"); - const data = JSON.parse(raw) as { mcpServers?: Record> }; - const servers: MCPServerDefinition[] = []; - for (const [id, config] of Object.entries(data.mcpServers ?? {})) { - if (config.command) { - servers.push({ - id, - name: id, - transport: { - type: "stdio", - command: config.command as string, - args: config.args as string[] | undefined, - env: config.env as Record | undefined, - }, - }); - } else if (config.url) { - servers.push({ - id, - name: id, - transport: { - type: "sse", - url: config.url as string, - headers: config.headers as Record | undefined, - }, - }); + return this.readMcpServers(resolve(dir, this.configPath)); + } + + override async importUser(): Promise { + return this.readMcpServers(this.userConfigPath); + } + + override async install(files: GeneratedFile[], cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + for (const file of files) { + const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path); + await mkdir(dirname(fullPath), { recursive: true }); + let content = file.content; + if (existsSync(fullPath)) { + content = mergeMcpServersJson(await readFile(fullPath, "utf-8"), file.content); } + await writeFile(fullPath, content, "utf-8"); } - return servers; + } + + private async readMcpServers(fullPath: string): Promise { + if (!existsSync(fullPath)) return []; + const data = JSON.parse(await readFile(fullPath, "utf-8")) as { mcpServers?: McpServersMap }; + return fromMcpServersMap(data.mcpServers ?? {}); } } diff --git a/packages/mcp/src/adapters/codex.test.ts b/packages/mcp/src/adapters/codex.test.ts index b2f8a63..dc58bec 100644 --- a/packages/mcp/src/adapters/codex.test.ts +++ b/packages/mcp/src/adapters/codex.test.ts @@ -38,18 +38,17 @@ describe("CodexMCPAdapter", () => { it("has correct id", () => expect(adapter.id).toBe("codex")); it("has correct name", () => expect(adapter.name).toBe("Codex")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); - it("has correct config path", () => expect(adapter.configPath).toBe(".codex/mcp.json")); + it("has correct config path", () => expect(adapter.configPath).toBe(".codex/config.toml")); }); describe("generate", () => { - it("generates valid JSON config", async () => { + it("generates native Codex TOML", async () => { const files = await adapter.generate([testServer]); expect(files).toHaveLength(1); - expect(files[0]!.path).toBe(".codex/mcp.json"); - expect(files[0]!.format).toBe("json"); - const parsed = JSON.parse(files[0]!.content); - expect(parsed.mcpServers["test-server"]).toBeDefined(); - expect(parsed.mcpServers["test-server"].command).toBe("npx"); + expect(files[0]!.path).toBe(".codex/config.toml"); + expect(files[0]!.format).toBe("toml"); + expect(files[0]!.content).toContain("[mcp_servers.test-server]"); + expect(files[0]!.content).toContain('command = "npx"'); }); it("handles SSE transport", async () => { @@ -59,21 +58,38 @@ describe("CodexMCPAdapter", () => { transport: { type: "sse", url: "http://localhost:3000" }, }; const files = await adapter.generate([sseServer]); - const parsed = JSON.parse(files[0]!.content); - expect(parsed.mcpServers["sse-server"].url).toBe("http://localhost:3000"); + expect(files[0]!.content).toContain("[mcp_servers.sse-server]"); + expect(files[0]!.content).toContain('url = "http://localhost:3000"'); + }); + + it("writes http remotes with url and http_headers", async () => { + const httpServer: MCPServerDefinition = { + id: "figma", + name: "figma", + transport: { + type: "http", + url: "https://mcp.figma.com/mcp", + headers: { "X-Figma-Region": "us-east-1" }, + }, + }; + const files = await adapter.generate([httpServer]); + expect(files[0]!.format).toBe("toml"); + expect(files[0]!.content).toContain("[mcp_servers.figma]"); + expect(files[0]!.content).toContain('url = "https://mcp.figma.com/mcp"'); + expect(files[0]!.content).toContain("[mcp_servers.figma.http_headers]"); + expect(files[0]!.content).toContain('X-Figma-Region = "us-east-1"'); }); it("handles empty servers array", async () => { const files = await adapter.generate([]); - const parsed = JSON.parse(files[0]!.content); - expect(Object.keys(parsed.mcpServers)).toHaveLength(0); + expect(files[0]!.content).toBe(""); }); it("handles multiple servers", async () => { const servers = [testServer, { ...testServer, id: "server-2", name: "Server 2" }]; const files = await adapter.generate(servers); - const parsed = JSON.parse(files[0]!.content); - expect(Object.keys(parsed.mcpServers)).toHaveLength(2); + expect(files[0]!.content).toContain("[mcp_servers.test-server]"); + expect(files[0]!.content).toContain("[mcp_servers.server-2]"); }); it("generates with optional fields undefined", async () => { @@ -83,9 +99,8 @@ describe("CodexMCPAdapter", () => { transport: { type: "stdio", command: "node" }, }; const files = await adapter.generate([server]); - const parsed = JSON.parse(files[0]!.content); - expect(parsed.mcpServers["minimal"].args).toEqual([]); - expect(parsed.mcpServers["minimal"].env).toEqual({}); + expect(files[0]!.content).toContain('command = "node"'); + expect(files[0]!.content).not.toContain("args ="); }); }); @@ -98,11 +113,13 @@ describe("CodexMCPAdapter", () => { it("imports stdio servers from config", async () => { vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue( - JSON.stringify({ - mcpServers: { "my-server": { command: "npx", args: ["-y", "test"], env: { A: "1" } } }, - }), - ); + vi.mocked(readFile).mockResolvedValue(`[mcp_servers.my-server] +command = "npx" +args = ["-y", "test"] + +[mcp_servers.my-server.env] +A = "1" +`); const result = await adapter.import("/test"); expect(result).toHaveLength(1); expect(result[0]!.id).toBe("my-server"); @@ -111,19 +128,17 @@ describe("CodexMCPAdapter", () => { it("imports SSE servers from config", async () => { vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue( - JSON.stringify({ - mcpServers: { sse: { url: "http://localhost:3000" } }, - }), - ); + vi.mocked(readFile).mockResolvedValue(`[mcp_servers.sse] +url = "http://localhost:3000" +`); const result = await adapter.import("/test"); expect(result).toHaveLength(1); - expect(result[0]!.transport.type).toBe("sse"); + expect(result[0]!.transport.type).toBe("http"); }); it("imports with missing servers key", async () => { vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue(JSON.stringify({})); + vi.mocked(readFile).mockResolvedValue("[ui]\nyolo = true\n"); const result = await adapter.import("/test"); expect(result).toEqual([]); }); diff --git a/packages/mcp/src/adapters/codex.ts b/packages/mcp/src/adapters/codex.ts index 0b44607..a1d1f5e 100644 --- a/packages/mcp/src/adapters/codex.ts +++ b/packages/mcp/src/adapters/codex.ts @@ -1,38 +1,26 @@ +import { homedir } from "node:os"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; import { BaseMCPAdapter } from "./base.js"; import { registry } from "./registry.js"; -import type { MCPServerDefinition, GeneratedFile } from "../types/index.js"; -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import type { GeneratedFile, MCPServerDefinition } from "../types/index.js"; +import { encodeMcpToml, mergeMcpToml, parseMcpToml } from "../toml-mcp.js"; class CodexMCPAdapter extends BaseMCPAdapter { readonly id = "codex"; readonly name = "Codex"; readonly nativeSupport = true; - readonly configPath = ".codex/mcp.json"; + readonly configPath = ".codex/config.toml"; + readonly userConfigPath = `${homedir()}/.codex/config.toml`; readonly command = "codex"; async generate(servers: MCPServerDefinition[]): Promise { - const mcpServers: Record = {}; - for (const server of servers) { - if (server.transport.type === "stdio") { - mcpServers[server.id] = { - command: server.transport.command, - args: server.transport.args ?? [], - env: server.transport.env ?? {}, - }; - } else { - mcpServers[server.id] = { - url: server.transport.url, - headers: server.transport.headers ?? {}, - }; - } - } return [ { path: this.configPath, - content: JSON.stringify({ mcpServers }, null, 2) + "\n", - format: "json", + content: encodeMcpToml(servers, { headerTable: "http_headers" }), + format: "toml", }, ]; } @@ -41,34 +29,31 @@ class CodexMCPAdapter extends BaseMCPAdapter { const dir = cwd ?? process.cwd(); const fullPath = resolve(dir, this.configPath); if (!existsSync(fullPath)) return []; - const raw = await readFile(fullPath, "utf-8"); - const data = JSON.parse(raw) as { mcpServers?: Record> }; - const servers: MCPServerDefinition[] = []; - for (const [id, config] of Object.entries(data.mcpServers ?? {})) { - if (config.command) { - servers.push({ - id, - name: id, - transport: { - type: "stdio", - command: config.command as string, - args: config.args as string[] | undefined, - env: config.env as Record | undefined, - }, - }); - } else if (config.url) { - servers.push({ - id, - name: id, - transport: { - type: "sse", - url: config.url as string, - headers: config.headers as Record | undefined, - }, - }); + return parseMcpToml(await readFile(fullPath, "utf-8")); + } + + override async importUser(): Promise { + if (!existsSync(this.userConfigPath)) return []; + return parseMcpToml(await readFile(this.userConfigPath, "utf-8")); + } + + override async install(files: GeneratedFile[], cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + for (const file of files) { + const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path); + const managedIds = parseMcpToml(file.content).map((server) => server.id); + let content = file.content; + if (existsSync(fullPath)) { + content = mergeMcpToml( + await readFile(fullPath, "utf-8"), + parseMcpToml(file.content), + managedIds, + { headerTable: "http_headers" }, + ); } + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content.endsWith("\n") ? content : `${content}\n`, "utf-8"); } - return servers; } } diff --git a/packages/mcp/src/adapters/cursor.test.ts b/packages/mcp/src/adapters/cursor.test.ts index 9db78ab..d4aeba5 100644 --- a/packages/mcp/src/adapters/cursor.test.ts +++ b/packages/mcp/src/adapters/cursor.test.ts @@ -39,6 +39,9 @@ describe("CursorMCPAdapter", () => { it("has correct name", () => expect(adapter.name).toBe("Cursor")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); it("has correct config path", () => expect(adapter.configPath).toBe(".cursor/mcp.json")); + it("has user config path ~/.cursor/mcp.json", () => { + expect(adapter.userConfigPath).toMatch(/\.cursor\/mcp\.json$/); + }); }); describe("generate", () => { @@ -60,9 +63,31 @@ describe("CursorMCPAdapter", () => { }; const files = await adapter.generate([sseServer]); const parsed = JSON.parse(files[0]!.content); + expect(parsed.mcpServers["sse-server"].type).toBe("sse"); expect(parsed.mcpServers["sse-server"].url).toBe("http://localhost:3000"); }); + it("emits type http for HTTP remotes", async () => { + const httpServer: MCPServerDefinition = { + id: "http-server", + name: "HTTP Server", + transport: { + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer x" }, + }, + }; + const files = await adapter.generate([httpServer]); + const parsed = JSON.parse(files[0]!.content) as { + mcpServers: Record>; + }; + expect(parsed.mcpServers["http-server"]).toEqual({ + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer x" }, + }); + }); + it("handles empty servers array", async () => { const files = await adapter.generate([]); const parsed = JSON.parse(files[0]!.content); @@ -121,6 +146,23 @@ describe("CursorMCPAdapter", () => { expect(result[0]!.transport.type).toBe("sse"); }); + it("round-trips type http when the host file includes it", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ + mcpServers: { + linear: { type: "http", url: "https://mcp.linear.app/mcp", headers: { A: "1" } }, + }, + }), + ); + const result = await adapter.import("/test"); + expect(result[0]!.transport).toEqual({ + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { A: "1" }, + }); + }); + it("imports with missing servers key", async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFile).mockResolvedValue(JSON.stringify({})); diff --git a/packages/mcp/src/adapters/cursor.ts b/packages/mcp/src/adapters/cursor.ts index 5d4e791..5603ff2 100644 --- a/packages/mcp/src/adapters/cursor.ts +++ b/packages/mcp/src/adapters/cursor.ts @@ -1,37 +1,30 @@ +import { homedir } from "node:os"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; import { BaseMCPAdapter } from "./base.js"; import { registry } from "./registry.js"; -import type { MCPServerDefinition, GeneratedFile } from "../types/index.js"; -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import type { GeneratedFile, MCPServerDefinition } from "../types/index.js"; +import { + encodeMcpServersFile, + fromMcpServersMap, + mergeMcpServersJson, + type McpServersMap, +} from "./mcp-servers-json.js"; class CursorMCPAdapter extends BaseMCPAdapter { readonly id = "cursor"; readonly name = "Cursor"; readonly nativeSupport = true; readonly configPath = ".cursor/mcp.json"; + readonly userConfigPath = `${homedir()}/.cursor/mcp.json`; readonly command = "cursor"; async generate(servers: MCPServerDefinition[]): Promise { - const mcpServers: Record = {}; - for (const server of servers) { - if (server.transport.type === "stdio") { - mcpServers[server.id] = { - command: server.transport.command, - args: server.transport.args ?? [], - env: server.transport.env ?? {}, - }; - } else { - mcpServers[server.id] = { - url: server.transport.url, - headers: server.transport.headers ?? {}, - }; - } - } return [ { path: this.configPath, - content: JSON.stringify({ mcpServers }, null, 2) + "\n", + content: encodeMcpServersFile(servers), format: "json", }, ]; @@ -39,36 +32,26 @@ class CursorMCPAdapter extends BaseMCPAdapter { async import(cwd?: string): Promise { const dir = cwd ?? process.cwd(); - const fullPath = resolve(dir, this.configPath); - if (!existsSync(fullPath)) return []; - const raw = await readFile(fullPath, "utf-8"); - const data = JSON.parse(raw) as { mcpServers?: Record> }; - const servers: MCPServerDefinition[] = []; - for (const [id, config] of Object.entries(data.mcpServers ?? {})) { - if (config.command) { - servers.push({ - id, - name: id, - transport: { - type: "stdio", - command: config.command as string, - args: config.args as string[] | undefined, - env: config.env as Record | undefined, - }, - }); - } else if (config.url) { - servers.push({ - id, - name: id, - transport: { - type: "sse", - url: config.url as string, - headers: config.headers as Record | undefined, - }, - }); + return this.readMcpServers(resolve(dir, this.configPath)); + } + + override async install(files: GeneratedFile[], cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + for (const file of files) { + const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path); + await mkdir(dirname(fullPath), { recursive: true }); + let content = file.content; + if (existsSync(fullPath)) { + content = mergeMcpServersJson(await readFile(fullPath, "utf-8"), file.content); } + await writeFile(fullPath, content, "utf-8"); } - return servers; + } + + private async readMcpServers(fullPath: string): Promise { + if (!existsSync(fullPath)) return []; + const data = JSON.parse(await readFile(fullPath, "utf-8")) as { mcpServers?: McpServersMap }; + return fromMcpServersMap(data.mcpServers ?? {}); } } diff --git a/packages/mcp/src/adapters/grok.test.ts b/packages/mcp/src/adapters/grok.test.ts new file mode 100644 index 0000000..9f3eacc --- /dev/null +++ b/packages/mcp/src/adapters/grok.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { homedir } from "node:os"; + +vi.mock("./registry.js", () => ({ + registry: { register: vi.fn() }, +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), +})); + +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + rm: vi.fn(), +})); + +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { GrokMCPAdapter } from "./grok.js"; +import type { MCPServerDefinition } from "../types/index.js"; + +describe("GrokMCPAdapter", () => { + let adapter: GrokMCPAdapter; + const stdio: MCPServerDefinition = { + id: "context7", + name: "context7", + transport: { + type: "stdio", + command: "/home/blitz/.local/bin/op-mcp-run-grok", + args: ["npx", "-y", "@upstash/context7-mcp"], + env: { CONTEXT7_API_KEY: "op://mcp-secrets/Context7 API Key/password" }, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + adapter = new GrokMCPAdapter(); + }); + + it("uses Grok native TOML paths", () => { + expect(adapter.id).toBe("grok"); + expect(adapter.name).toBe("Grok"); + expect(adapter.configPath).toBe(".grok/config.toml"); + expect(adapter.userConfigPath).toBe(`${homedir()}/.grok/config.toml`); + expect(adapter.command).toBe("grok"); + }); + + it("generates [mcp_servers] TOML, not JSON", async () => { + const files = await adapter.generate([stdio]); + expect(files[0]?.path).toBe(".grok/config.toml"); + expect(files[0]?.format).toBe("toml"); + expect(files[0]?.content).toContain("[mcp_servers.context7]"); + expect(files[0]?.content).toContain("[mcp_servers.context7.env]"); + expect(files[0]?.content).toContain('command = "/home/blitz/.local/bin/op-mcp-run-grok"'); + expect(files[0]?.content).toContain('args = ["npx", "-y", "@upstash/context7-mcp"]'); + expect(files[0]?.content).not.toContain("mcpServers"); + }); + + it("generates url remotes with headers", async () => { + const files = await adapter.generate([ + { + id: "linear", + name: "linear", + transport: { + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }, + }, + ]); + expect(files[0]?.content).toContain("[mcp_servers.linear]"); + expect(files[0]?.content).toContain('url = "https://mcp.linear.app/mcp"'); + expect(files[0]?.content).toContain("[mcp_servers.linear.headers]"); + expect(files[0]?.content).toContain('Authorization = "Bearer token"'); + }); + + it("generates quoted tables for names with spaces", async () => { + const files = await adapter.generate([ + { + id: "GWS - Premier Studio", + name: "GWS - Premier Studio", + transport: { type: "stdio", command: "/bin/gws-mcp-premier-studio" }, + }, + ]); + expect(files[0]?.content).toContain('[mcp_servers."GWS - Premier Studio"]'); + }); + + it("imports nothing when neither config exists", async () => { + vi.mocked(existsSync).mockReturnValue(false); + expect(await adapter.import("/tmp/project")).toEqual([]); + }); + + it("imports project TOML servers", async () => { + vi.mocked(existsSync).mockImplementation((path) => String(path).endsWith(".grok/config.toml")); + vi.mocked(readFile).mockResolvedValue(`[mcp_servers.github] +command = "gh-mcp" +`); + const servers = await adapter.import("/tmp/project"); + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("github"); + expect(servers[0]?.transport).toEqual({ type: "stdio", command: "gh-mcp" }); + }); + + it("merges into an existing user config instead of replacing it", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue(`[ui]\nyolo = false\n`); + const files = await adapter.generate([stdio]); + await adapter.install(files, "/tmp/project"); + const written = String(vi.mocked(writeFile).mock.calls[0]?.[1]); + expect(written).toContain("[ui]"); + expect(written).toContain("yolo = false"); + expect(written).toContain("[mcp_servers.context7]"); + expect(vi.mocked(mkdir)).toHaveBeenCalled(); + }); +}); diff --git a/packages/mcp/src/adapters/grok.ts b/packages/mcp/src/adapters/grok.ts new file mode 100644 index 0000000..a7fa7a4 --- /dev/null +++ b/packages/mcp/src/adapters/grok.ts @@ -0,0 +1,63 @@ +import { homedir } from "node:os"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { BaseMCPAdapter } from "./base.js"; +import { registry } from "./registry.js"; +import type { GeneratedFile, MCPServerDefinition } from "../types/index.js"; +import { encodeMcpToml, mergeMcpToml, parseMcpToml } from "../toml-mcp.js"; + +class GrokMCPAdapter extends BaseMCPAdapter { + readonly id = "grok"; + readonly name = "Grok"; + readonly nativeSupport = true; + readonly configPath = ".grok/config.toml"; + readonly userConfigPath = `${homedir()}/.grok/config.toml`; + readonly command = "grok"; + + async generate(servers: MCPServerDefinition[]): Promise { + return [ + { + path: this.configPath, + content: encodeMcpToml(servers), + format: "toml", + }, + ]; + } + + async import(cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + return this.readToml(resolve(dir, this.configPath)); + } + + override async importUser(): Promise { + return this.readToml(this.userConfigPath); + } + + override async install(files: GeneratedFile[], cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + for (const file of files) { + const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path); + const managedIds = parseMcpToml(file.content).map((server) => server.id); + let content = file.content; + if (existsSync(fullPath)) { + const existing = await readFile(fullPath, "utf-8"); + content = mergeMcpToml(existing, parseMcpToml(file.content), managedIds); + } + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content.endsWith("\n") ? content : `${content}\n`, "utf-8"); + } + } + + private async readToml(fullPath: string): Promise { + if (!existsSync(fullPath)) return []; + const raw = await readFile(fullPath, "utf-8"); + return parseMcpToml(raw); + } +} + +const adapter = new GrokMCPAdapter(); +registry.register(adapter); + +export { GrokMCPAdapter }; +export default adapter; diff --git a/packages/mcp/src/adapters/mcp-servers-json.test.ts b/packages/mcp/src/adapters/mcp-servers-json.test.ts new file mode 100644 index 0000000..8d1b5f0 --- /dev/null +++ b/packages/mcp/src/adapters/mcp-servers-json.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + encodeMcpServersFile, + fromMcpServersMap, + mergeMcpServersJson, + toMcpServersMap, +} from "./mcp-servers-json.js"; +import type { MCPServerDefinition } from "../types/index.js"; + +const stdio: MCPServerDefinition = { + id: "github", + name: "github", + transport: { type: "stdio", command: "gh-mcp", args: [], env: { A: "1" } }, +}; + +const http: MCPServerDefinition = { + id: "linear", + name: "linear", + transport: { type: "http", url: "https://mcp.linear.app/mcp", headers: { Authorization: "x" } }, +}; + +describe("mcp-servers-json", () => { + it("encodes http/sse type and round-trips through JSON", () => { + const encoded = encodeMcpServersFile([stdio, http]); + const parsed = JSON.parse(encoded) as { mcpServers: Record> }; + expect(parsed.mcpServers.linear?.type).toBe("http"); + expect(fromMcpServersMap(parsed.mcpServers).map((s) => s.transport.type)).toEqual([ + "stdio", + "http", + ]); + }); + + it("imports url without type as sse", () => { + const servers = fromMcpServersMap({ hosted: { url: "https://example.com/sse" } }); + expect(servers[0]?.transport).toEqual({ type: "sse", url: "https://example.com/sse" }); + }); + + it("skips entries that have neither command nor url", () => { + expect(fromMcpServersMap({ broken: { type: "http" } })).toEqual([]); + }); + + it("merges mcpServers without clobbering sibling keys", () => { + const merged = mergeMcpServersJson( + JSON.stringify({ theme: "dark", mcpServers: { keep: { command: "old" } } }), + encodeMcpServersFile([stdio]), + ); + const parsed = JSON.parse(merged) as { + theme: string; + mcpServers: Record; + }; + expect(parsed.theme).toBe("dark"); + expect(parsed.mcpServers.keep?.command).toBe("old"); + expect(parsed.mcpServers.github?.command).toBe("gh-mcp"); + }); + + it("creates mcpServers when the existing file lacks a valid map", () => { + const merged = mergeMcpServersJson( + JSON.stringify({ theme: "dark", mcpServers: 1 }), + encodeMcpServersFile([stdio]), + ); + const parsed = JSON.parse(merged) as { theme: string; mcpServers: Record }; + expect(parsed.theme).toBe("dark"); + expect(parsed.mcpServers.github).toBeDefined(); + }); + + it("omits invalid args/env/headers on import", () => { + const servers = fromMcpServersMap({ + github: { command: "gh-mcp", args: [1, 2], env: "nope" }, + linear: { type: "http", url: "https://x", headers: ["nope"] }, + }); + expect(servers[0]?.transport).toEqual({ type: "stdio", command: "gh-mcp" }); + expect(servers[1]?.transport).toEqual({ type: "http", url: "https://x" }); + }); + + it("exposes toMcpServersMap for stdio defaults", () => { + expect( + toMcpServersMap([{ id: "x", name: "x", transport: { type: "stdio", command: "npx" } }]), + ).toEqual({ + x: { command: "npx", args: [], env: {} }, + }); + }); + + it("round-trips explicit sse type", () => { + const sse: MCPServerDefinition = { + id: "events", + name: "events", + transport: { type: "sse", url: "http://localhost:3000/sse" }, + }; + const map = toMcpServersMap([sse]); + expect(map.events?.type).toBe("sse"); + expect(fromMcpServersMap(map)[0]?.transport.type).toBe("sse"); + }); + + it("merges when generated JSON has no mcpServers key", () => { + const merged = mergeMcpServersJson(JSON.stringify({ theme: "dark" }), "{}"); + expect(JSON.parse(merged)).toEqual({ theme: "dark", mcpServers: {} }); + }); +}); diff --git a/packages/mcp/src/adapters/mcp-servers-json.ts b/packages/mcp/src/adapters/mcp-servers-json.ts new file mode 100644 index 0000000..81810ba --- /dev/null +++ b/packages/mcp/src/adapters/mcp-servers-json.ts @@ -0,0 +1,73 @@ +import type { MCPServerDefinition, MCPTransport } from "../types/index.js"; + +export type McpServersMap = Record>; + +function isStringRecord(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + return Object.values(value).every((item) => typeof item === "string"); +} + +function stringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + if (!value.every((item) => typeof item === "string")) return undefined; + return value; +} + +export function toMcpServersMap(servers: MCPServerDefinition[]): McpServersMap { + const mcpServers: McpServersMap = {}; + for (const server of servers) { + if (server.transport.type === "stdio") { + mcpServers[server.id] = { + command: server.transport.command, + args: server.transport.args ?? [], + env: server.transport.env ?? {}, + }; + } else { + mcpServers[server.id] = { + type: server.transport.type, + url: server.transport.url, + headers: server.transport.headers ?? {}, + }; + } + } + return mcpServers; +} + +export function fromMcpServersMap(mcpServers: McpServersMap): MCPServerDefinition[] { + const servers: MCPServerDefinition[] = []; + for (const [id, config] of Object.entries(mcpServers)) { + if (typeof config.command === "string") { + const transport: Extract = { + type: "stdio", + command: config.command, + }; + const args = stringArray(config.args); + if (args) transport.args = args; + if (isStringRecord(config.env)) transport.env = config.env; + servers.push({ id, name: id, transport }); + } else if (typeof config.url === "string") { + const type = config.type === "http" ? "http" : "sse"; + const transport: Extract = { type, url: config.url }; + if (isStringRecord(config.headers)) transport.headers = config.headers; + servers.push({ id, name: id, transport }); + } + } + return servers; +} + +export function encodeMcpServersFile(servers: MCPServerDefinition[]): string { + return `${JSON.stringify({ mcpServers: toMcpServersMap(servers) }, null, 2)}\n`; +} + +export function mergeMcpServersJson(existingRaw: string, generatedRaw: string): string { + const existing = JSON.parse(existingRaw) as Record; + const generated = JSON.parse(generatedRaw) as { mcpServers?: McpServersMap }; + const current = + existing.mcpServers !== null && + typeof existing.mcpServers === "object" && + !Array.isArray(existing.mcpServers) + ? (existing.mcpServers as McpServersMap) + : {}; + existing.mcpServers = { ...current, ...generated.mcpServers }; + return `${JSON.stringify(existing, null, 2)}\n`; +} diff --git a/packages/mcp/src/adapters/opencode.test.ts b/packages/mcp/src/adapters/opencode.test.ts index 0a3c14b..6f1819e 100644 --- a/packages/mcp/src/adapters/opencode.test.ts +++ b/packages/mcp/src/adapters/opencode.test.ts @@ -38,18 +38,20 @@ describe("OpenCodeMCPAdapter", () => { it("has correct id", () => expect(adapter.id).toBe("opencode")); it("has correct name", () => expect(adapter.name).toBe("OpenCode")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); - it("has correct config path", () => expect(adapter.configPath).toBe(".opencode/mcp.json")); + it("has correct config path", () => expect(adapter.configPath).toBe("opencode.json")); }); describe("generate", () => { - it("generates valid JSON config", async () => { + it("generates native OpenCode mcp entries", async () => { const files = await adapter.generate([testServer]); expect(files).toHaveLength(1); - expect(files[0]!.path).toBe(".opencode/mcp.json"); + expect(files[0]!.path).toBe("opencode.json"); expect(files[0]!.format).toBe("json"); - const parsed = JSON.parse(files[0]!.content); - expect(parsed.mcpServers["test-server"]).toBeDefined(); - expect(parsed.mcpServers["test-server"].command).toBe("npx"); + const parsed = JSON.parse(files[0]!.content) as { + mcp: Record; + }; + expect(parsed.mcp["test-server"]?.type).toBe("local"); + expect(parsed.mcp["test-server"]?.command[0]).toBe("npx"); }); it("handles SSE transport", async () => { @@ -59,21 +61,45 @@ describe("OpenCodeMCPAdapter", () => { transport: { type: "sse", url: "http://localhost:3000" }, }; const files = await adapter.generate([sseServer]); - const parsed = JSON.parse(files[0]!.content); - expect(parsed.mcpServers["sse-server"].url).toBe("http://localhost:3000"); + const parsed = JSON.parse(files[0]!.content) as { + mcp: Record; + }; + expect(parsed.mcp["sse-server"]?.type).toBe("remote"); + expect(parsed.mcp["sse-server"]?.url).toBe("http://localhost:3000"); + }); + + it("emits headers on remote MCP entries", async () => { + const httpServer: MCPServerDefinition = { + id: "linear", + name: "linear", + transport: { + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }, + }; + const files = await adapter.generate([httpServer]); + const parsed = JSON.parse(files[0]!.content) as { + mcp: Record }>; + }; + expect(parsed.mcp.linear).toMatchObject({ + type: "remote", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }); }); it("handles empty servers array", async () => { const files = await adapter.generate([]); - const parsed = JSON.parse(files[0]!.content); - expect(Object.keys(parsed.mcpServers)).toHaveLength(0); + const parsed = JSON.parse(files[0]!.content) as { mcp: Record }; + expect(Object.keys(parsed.mcp)).toHaveLength(0); }); it("handles multiple servers", async () => { const servers = [testServer, { ...testServer, id: "server-2", name: "Server 2" }]; const files = await adapter.generate(servers); - const parsed = JSON.parse(files[0]!.content); - expect(Object.keys(parsed.mcpServers)).toHaveLength(2); + const parsed = JSON.parse(files[0]!.content) as { mcp: Record }; + expect(Object.keys(parsed.mcp)).toHaveLength(2); }); it("generates with optional fields undefined", async () => { @@ -83,9 +109,11 @@ describe("OpenCodeMCPAdapter", () => { transport: { type: "stdio", command: "node" }, }; const files = await adapter.generate([server]); - const parsed = JSON.parse(files[0]!.content); - expect(parsed.mcpServers["minimal"].args).toEqual([]); - expect(parsed.mcpServers["minimal"].env).toEqual({}); + const parsed = JSON.parse(files[0]!.content) as { + mcp: Record }>; + }; + expect(parsed.mcp["minimal"]?.command).toEqual(["node"]); + expect(parsed.mcp["minimal"]?.environment).toEqual({}); }); }); @@ -100,7 +128,13 @@ describe("OpenCodeMCPAdapter", () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFile).mockResolvedValue( JSON.stringify({ - mcpServers: { "my-server": { command: "npx", args: ["-y", "test"], env: { A: "1" } } }, + mcp: { + "my-server": { + type: "local", + command: ["npx", "-y", "test"], + environment: { A: "1" }, + }, + }, }), ); const result = await adapter.import("/test"); @@ -113,12 +147,33 @@ describe("OpenCodeMCPAdapter", () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFile).mockResolvedValue( JSON.stringify({ - mcpServers: { sse: { url: "http://localhost:3000" } }, + mcp: { sse: { type: "remote", url: "http://localhost:3000" } }, }), ); const result = await adapter.import("/test"); expect(result).toHaveLength(1); - expect(result[0]!.transport.type).toBe("sse"); + expect(result[0]!.transport.type).toBe("http"); + }); + + it("imports remote headers", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ + mcp: { + linear: { + type: "remote", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }, + }, + }), + ); + const result = await adapter.import("/test"); + expect(result[0]!.transport).toEqual({ + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }); }); it("imports with missing servers key", async () => { diff --git a/packages/mcp/src/adapters/opencode.ts b/packages/mcp/src/adapters/opencode.ts index cfa1365..1a1795c 100644 --- a/packages/mcp/src/adapters/opencode.ts +++ b/packages/mcp/src/adapters/opencode.ts @@ -1,37 +1,34 @@ +import { homedir } from "node:os"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; import { BaseMCPAdapter } from "./base.js"; import { registry } from "./registry.js"; -import type { MCPServerDefinition, GeneratedFile } from "../types/index.js"; -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import type { GeneratedFile, MCPServerDefinition, MCPTransport } from "../types/index.js"; + +type OpenCodeEntry = { + type?: string; + command?: string[]; + url?: string; + enabled?: boolean; + environment?: Record; + headers?: Record; + oauth?: Record; +}; class OpenCodeMCPAdapter extends BaseMCPAdapter { readonly id = "opencode"; readonly name = "OpenCode"; readonly nativeSupport = true; - readonly configPath = ".opencode/mcp.json"; + readonly configPath = "opencode.json"; + readonly userConfigPath = `${homedir()}/.config/opencode/opencode.json`; readonly command = "opencode"; async generate(servers: MCPServerDefinition[]): Promise { - const mcpServers: Record = {}; - for (const server of servers) { - if (server.transport.type === "stdio") { - mcpServers[server.id] = { - command: server.transport.command, - args: server.transport.args ?? [], - env: server.transport.env ?? {}, - }; - } else { - mcpServers[server.id] = { - url: server.transport.url, - headers: server.transport.headers ?? {}, - }; - } - } return [ { path: this.configPath, - content: JSON.stringify({ mcpServers }, null, 2) + "\n", + content: `${JSON.stringify({ mcp: toOpenCodeMcp(servers) }, null, 2)}\n`, format: "json", }, ]; @@ -39,37 +36,94 @@ class OpenCodeMCPAdapter extends BaseMCPAdapter { async import(cwd?: string): Promise { const dir = cwd ?? process.cwd(); - const fullPath = resolve(dir, this.configPath); + return this.readOpenCode(resolve(dir, this.configPath)); + } + + override async importUser(): Promise { + return this.readOpenCode(this.userConfigPath); + } + + override async install(files: GeneratedFile[], cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + for (const file of files) { + const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path); + await mkdir(dirname(fullPath), { recursive: true }); + if (existsSync(fullPath)) { + const existing = JSON.parse(await readFile(fullPath, "utf-8")) as { + mcp?: Record; + }; + const generated = JSON.parse(file.content) as { mcp?: Record }; + existing.mcp = { ...existing.mcp, ...generated.mcp }; + await writeFile(fullPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8"); + continue; + } + await writeFile(fullPath, file.content, "utf-8"); + } + } + + private async readOpenCode(fullPath: string): Promise { if (!existsSync(fullPath)) return []; - const raw = await readFile(fullPath, "utf-8"); - const data = JSON.parse(raw) as { mcpServers?: Record> }; - const servers: MCPServerDefinition[] = []; - for (const [id, config] of Object.entries(data.mcpServers ?? {})) { - if (config.command) { - servers.push({ - id, - name: id, - transport: { - type: "stdio", - command: config.command as string, - args: config.args as string[] | undefined, - env: config.env as Record | undefined, - }, - }); - } else if (config.url) { - servers.push({ - id, - name: id, - transport: { - type: "sse", - url: config.url as string, - headers: config.headers as Record | undefined, - }, - }); + const data = JSON.parse(await readFile(fullPath, "utf-8")) as { + mcp?: Record; + }; + return fromOpenCodeMcp(data.mcp ?? {}); + } +} + +function toOpenCodeMcp(servers: MCPServerDefinition[]): Record { + const mcp: Record = {}; + for (const server of servers) { + if (server.transport.type === "stdio") { + mcp[server.id] = { + type: "local", + command: [server.transport.command, ...(server.transport.args ?? [])], + enabled: server.enabled !== false, + environment: server.transport.env ?? {}, + }; + } else { + const entry: OpenCodeEntry = { + type: "remote", + url: server.transport.url, + enabled: server.enabled !== false, + }; + if (server.transport.headers && Object.keys(server.transport.headers).length > 0) { + entry.headers = server.transport.headers; } + mcp[server.id] = entry; + } + } + return mcp; +} + +function fromOpenCodeMcp(mcp: Record): MCPServerDefinition[] { + const servers: MCPServerDefinition[] = []; + for (const [id, entry] of Object.entries(mcp)) { + if ((entry.type === "local" || entry.command) && entry.command && entry.command.length > 0) { + const [command, ...args] = entry.command; + if (command === undefined) continue; + servers.push({ + id, + name: id, + transport: { + type: "stdio", + command, + args, + env: entry.environment, + }, + enabled: entry.enabled, + }); + } else if (entry.url) { + const transport: Extract = { type: "http", url: entry.url }; + if (entry.headers) transport.headers = entry.headers; + servers.push({ + id, + name: id, + transport, + enabled: entry.enabled, + }); } - return servers; } + return servers; } const adapter = new OpenCodeMCPAdapter(); diff --git a/packages/mcp/src/adapters/roundtrip.test.ts b/packages/mcp/src/adapters/roundtrip.test.ts new file mode 100644 index 0000000..88dc10f --- /dev/null +++ b/packages/mcp/src/adapters/roundtrip.test.ts @@ -0,0 +1,400 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ClaudeCodeMCPAdapter } from "./claude-code.js"; +import { CodexMCPAdapter } from "./codex.js"; +import { CursorMCPAdapter } from "./cursor.js"; +import { GrokMCPAdapter } from "./grok.js"; +import { OpenCodeMCPAdapter } from "./opencode.js"; +import { ZcodeMCPAdapter } from "./zcode.js"; +import type { MCPServerDefinition } from "../types/index.js"; + +const stdio: MCPServerDefinition = { + id: "github", + name: "github", + transport: { + type: "stdio", + command: "/home/blitz/.local/bin/gh-mcp", + args: ["--verbose"], + env: { TOKEN: "secret" }, + }, +}; + +const http: MCPServerDefinition = { + id: "linear", + name: "linear", + transport: { + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }, +}; + +const sse: MCPServerDefinition = { + id: "events", + name: "events", + transport: { + type: "sse", + url: "http://localhost:3000/sse", + }, +}; + +describe("MCP adapter filesystem round-trips", () => { + const dirs: string[] = []; + + afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + async function scratch(): Promise { + const dir = await mkdtemp(join(tmpdir(), "ai-tools-mcp-")); + dirs.push(dir); + return dir; + } + + describe("Cursor .cursor/mcp.json", () => { + it("writes mcpServers with type http/sse and round-trips them", async () => { + const cwd = await scratch(); + const adapter = new CursorMCPAdapter(); + await adapter.install(await adapter.generate([stdio, http, sse]), cwd); + const parsed = JSON.parse(await readFile(join(cwd, ".cursor/mcp.json"), "utf-8")) as { + mcpServers: Record>; + }; + expect(parsed.mcpServers.github).toMatchObject({ + command: "/home/blitz/.local/bin/gh-mcp", + args: ["--verbose"], + env: { TOKEN: "secret" }, + }); + expect(parsed.mcpServers.linear).toEqual({ + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }); + expect(parsed.mcpServers.events).toMatchObject({ + type: "sse", + url: "http://localhost:3000/sse", + }); + const imported = await adapter.import(cwd); + expect(imported.find((s) => s.id === "linear")?.transport).toEqual(http.transport); + expect(imported.find((s) => s.id === "events")?.transport.type).toBe("sse"); + expect(imported.find((s) => s.id === "github")?.transport).toMatchObject({ + type: "stdio", + command: "/home/blitz/.local/bin/gh-mcp", + args: ["--verbose"], + env: { TOKEN: "secret" }, + }); + }); + + it("imports host files that omit type", async () => { + const cwd = await scratch(); + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + join(cwd, ".cursor/mcp.json"), + `${JSON.stringify({ + mcpServers: { + github: { command: "gh-mcp", args: [] }, + hosted: { url: "https://mcp.example.com/sse" }, + }, + })}\n`, + "utf-8", + ); + const imported = await new CursorMCPAdapter().import(cwd); + expect(imported.find((s) => s.id === "github")?.transport.type).toBe("stdio"); + expect(imported.find((s) => s.id === "hosted")?.transport).toMatchObject({ + type: "sse", + url: "https://mcp.example.com/sse", + }); + }); + }); + + describe("Claude Code .mcp.json / ~/.claude.json", () => { + it("writes project .mcp.json mcpServers and round-trips type", async () => { + const cwd = await scratch(); + const adapter = new ClaudeCodeMCPAdapter(); + expect(adapter.configPath).toBe(".mcp.json"); + expect(adapter.userConfigPath).toMatch(/\.claude\.json$/); + await adapter.install(await adapter.generate([stdio, http, sse]), cwd); + const parsed = JSON.parse(await readFile(join(cwd, ".mcp.json"), "utf-8")) as { + mcpServers: Record>; + }; + expect(parsed.mcpServers.linear).toMatchObject({ + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }); + expect(parsed.mcpServers.events).toMatchObject({ + type: "sse", + url: "http://localhost:3000/sse", + }); + const imported = await adapter.import(cwd); + expect(imported.find((s) => s.id === "linear")?.transport.type).toBe("http"); + expect(imported.find((s) => s.id === "events")?.transport.type).toBe("sse"); + }); + + it("merges mcpServers into existing user settings without clobbering other keys", async () => { + const cwd = await scratch(); + const userFile = join(cwd, ".claude.json"); + await writeFile( + userFile, + `${JSON.stringify( + { + theme: "dark", + numStartups: 9, + mcpServers: { keep: { command: "keep-me" } }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const adapter = new ClaudeCodeMCPAdapter(); + const files = await adapter.generate([http]); + files[0]!.path = userFile; + await adapter.install(files, cwd); + const parsed = JSON.parse(await readFile(userFile, "utf-8")) as { + theme: string; + numStartups: number; + mcpServers: Record>; + }; + expect(parsed.theme).toBe("dark"); + expect(parsed.numStartups).toBe(9); + expect(parsed.mcpServers.keep).toEqual({ command: "keep-me" }); + expect(parsed.mcpServers.linear).toMatchObject({ + type: "http", + url: "https://mcp.linear.app/mcp", + }); + }); + + it("imports project files that omit type", async () => { + const cwd = await scratch(); + await writeFile( + join(cwd, ".mcp.json"), + `${JSON.stringify({ + mcpServers: { hosted: { url: "https://mcp.example.com/mcp" } }, + })}\n`, + "utf-8", + ); + const imported = await new ClaudeCodeMCPAdapter().import(cwd); + expect(imported[0]?.transport).toMatchObject({ + type: "sse", + url: "https://mcp.example.com/mcp", + }); + }); + }); + + describe("ZCode .zcode/config.json mcp.servers", () => { + it("writes { mcp: { servers } } and never a fake mcp.json", async () => { + const cwd = await scratch(); + const adapter = new ZcodeMCPAdapter(); + expect(adapter.configPath).toBe(".zcode/config.json"); + await adapter.install(await adapter.generate([stdio, http, sse]), cwd); + const raw = await readFile(join(cwd, ".zcode/config.json"), "utf-8"); + expect(raw).not.toContain("mcpServers"); + const parsed = JSON.parse(raw) as { + mcp: { servers: Record> }; + }; + expect(parsed.mcp.servers.github).toMatchObject({ + type: "stdio", + command: "/home/blitz/.local/bin/gh-mcp", + args: ["--verbose"], + env: { TOKEN: "secret" }, + }); + expect(parsed.mcp.servers.linear).toEqual({ + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }); + expect(parsed.mcp.servers.events).toMatchObject({ + type: "sse", + url: "http://localhost:3000/sse", + }); + const imported = await adapter.import(cwd); + expect(imported.find((s) => s.id === "linear")?.transport).toEqual(http.transport); + expect(imported.find((s) => s.id === "events")?.transport.type).toBe("sse"); + }); + + it("merges mcp.servers into existing config without clobbering other keys", async () => { + const cwd = await scratch(); + const userFile = join(cwd, "cli-config.json"); + await writeFile( + userFile, + `${JSON.stringify({ model: "keep-me", mcp: { servers: { keep: { type: "stdio", command: "old" } } } })}\n`, + "utf-8", + ); + const adapter = new ZcodeMCPAdapter(); + const files = await adapter.generate([stdio]); + files[0]!.path = userFile; + await adapter.install(files, cwd); + const parsed = JSON.parse(await readFile(userFile, "utf-8")) as { + model: string; + mcp: { servers: Record }; + }; + expect(parsed.model).toBe("keep-me"); + expect(parsed.mcp.servers.keep?.command).toBe("old"); + expect(parsed.mcp.servers.github?.type).toBe("stdio"); + expect(parsed.mcp.servers.github?.command).toBe("/home/blitz/.local/bin/gh-mcp"); + }); + + it("imports host files that omit type", async () => { + const cwd = await scratch(); + await mkdir(join(cwd, ".zcode"), { recursive: true }); + await writeFile( + join(cwd, ".zcode/config.json"), + `${JSON.stringify({ + mcp: { + servers: { + github: { command: "gh-mcp", args: [] }, + hosted: { url: "https://mcp.example.com/mcp" }, + }, + }, + })}\n`, + "utf-8", + ); + const imported = await new ZcodeMCPAdapter().import(cwd); + expect(imported.find((s) => s.id === "github")?.transport.type).toBe("stdio"); + expect(imported.find((s) => s.id === "hosted")?.transport).toMatchObject({ + type: "http", + url: "https://mcp.example.com/mcp", + }); + }); + }); + + describe("Grok TOML ~/.grok/config.toml [mcp_servers]", () => { + it("writes TOML that preserves command/args/env/url", async () => { + const cwd = await scratch(); + const adapter = new GrokMCPAdapter(); + await adapter.install(await adapter.generate([stdio, http]), cwd); + const raw = await readFile(join(cwd, ".grok/config.toml"), "utf-8"); + expect(raw).toContain("[mcp_servers.github]"); + expect(raw).toContain('command = "/home/blitz/.local/bin/gh-mcp"'); + expect(raw).toContain('args = ["--verbose"]'); + expect(raw).toContain("[mcp_servers.github.env]"); + expect(raw).toContain('TOKEN = "secret"'); + expect(raw).toContain("[mcp_servers.linear]"); + expect(raw).toContain('url = "https://mcp.linear.app/mcp"'); + expect(raw).not.toContain("mcpServers"); + const imported = await adapter.import(cwd); + expect(imported.find((s) => s.id === "github")?.transport).toEqual(stdio.transport); + expect(imported.find((s) => s.id === "linear")?.transport).toMatchObject({ + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }); + }); + + it("Grok merge keeps unrelated tables", async () => { + const cwd = await scratch(); + const adapter = new GrokMCPAdapter(); + await mkdir(join(cwd, ".grok"), { recursive: true }); + await writeFile(join(cwd, ".grok/config.toml"), "[ui]\nyolo = true\n", "utf-8"); + await adapter.install(await adapter.generate([stdio]), cwd); + const raw = await readFile(join(cwd, ".grok/config.toml"), "utf-8"); + expect(raw).toContain("[ui]"); + expect(raw).toContain("yolo = true"); + expect(raw).toContain("[mcp_servers.github]"); + }); + }); + + describe("Codex TOML ~/.codex/config.toml [mcp_servers]", () => { + it("writes native TOML not JSON and round-trips command/args/env/url", async () => { + const cwd = await scratch(); + const adapter = new CodexMCPAdapter(); + await adapter.install(await adapter.generate([stdio, http]), cwd); + const raw = await readFile(join(cwd, ".codex/config.toml"), "utf-8"); + expect(raw.startsWith("{")).toBe(false); + expect(raw).toContain("[mcp_servers.github]"); + expect(raw).toContain('command = "/home/blitz/.local/bin/gh-mcp"'); + expect(raw).toContain('args = ["--verbose"]'); + expect(raw).toContain("[mcp_servers.github.env]"); + expect(raw).toContain('TOKEN = "secret"'); + expect(raw).toContain('url = "https://mcp.linear.app/mcp"'); + const imported = await adapter.import(cwd); + expect(imported.find((s) => s.id === "github")?.transport).toEqual(stdio.transport); + expect(imported.find((s) => s.id === "linear")?.transport).toMatchObject({ + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }); + }); + + it("merges into existing Codex config without dropping model", async () => { + const cwd = await scratch(); + const adapter = new CodexMCPAdapter(); + await mkdir(join(cwd, ".codex"), { recursive: true }); + await writeFile(join(cwd, ".codex/config.toml"), 'model = "gpt-5"\n', "utf-8"); + await adapter.install(await adapter.generate([stdio, http]), cwd); + const raw = await readFile(join(cwd, ".codex/config.toml"), "utf-8"); + expect(raw).toContain('model = "gpt-5"'); + expect(raw).toContain("[mcp_servers.github]"); + expect(raw).toContain("[mcp_servers.linear.http_headers]"); + expect(raw).toContain('url = "https://mcp.linear.app/mcp"'); + }); + }); + + describe("OpenCode opencode.json mcp local/remote", () => { + it("writes mcp local command arrays and remote url+headers", async () => { + const cwd = await scratch(); + const adapter = new OpenCodeMCPAdapter(); + await adapter.install(await adapter.generate([stdio, http]), cwd); + const parsed = JSON.parse(await readFile(join(cwd, "opencode.json"), "utf-8")) as { + mcp: Record>; + }; + expect(parsed.mcp.github).toMatchObject({ + type: "local", + command: ["/home/blitz/.local/bin/gh-mcp", "--verbose"], + environment: { TOKEN: "secret" }, + }); + expect(parsed.mcp.linear).toMatchObject({ + type: "remote", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }); + const imported = await adapter.import(cwd); + expect(imported.find((s) => s.id === "github")?.transport).toMatchObject({ + type: "stdio", + command: "/home/blitz/.local/bin/gh-mcp", + args: ["--verbose"], + env: { TOKEN: "secret" }, + }); + expect(imported.find((s) => s.id === "linear")?.transport).toEqual(http.transport); + }); + + it("merges mcp into existing opencode.json without clobbering model", async () => { + const cwd = await scratch(); + const adapter = new OpenCodeMCPAdapter(); + await writeFile( + join(cwd, "opencode.json"), + `${JSON.stringify({ model: "keep-me", mcp: { keep: { type: "local", command: ["old"] } } })}\n`, + "utf-8", + ); + await adapter.install(await adapter.generate([stdio]), cwd); + const parsed = JSON.parse(await readFile(join(cwd, "opencode.json"), "utf-8")) as { + model: string; + mcp: Record; + }; + expect(parsed.model).toBe("keep-me"); + expect(parsed.mcp.keep?.command).toEqual(["old"]); + expect(parsed.mcp.github?.type).toBe("local"); + }); + + it("imports host files that omit type", async () => { + const cwd = await scratch(); + await writeFile( + join(cwd, "opencode.json"), + `${JSON.stringify({ + mcp: { + github: { command: ["gh-mcp"] }, + hosted: { url: "https://mcp.example.com/mcp" }, + }, + })}\n`, + "utf-8", + ); + const imported = await new OpenCodeMCPAdapter().import(cwd); + expect(imported.find((s) => s.id === "github")?.transport.type).toBe("stdio"); + expect(imported.find((s) => s.id === "hosted")?.transport).toMatchObject({ + type: "http", + url: "https://mcp.example.com/mcp", + }); + }); + }); +}); diff --git a/packages/mcp/src/adapters/zcode.test.ts b/packages/mcp/src/adapters/zcode.test.ts new file mode 100644 index 0000000..8210369 --- /dev/null +++ b/packages/mcp/src/adapters/zcode.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { homedir } from "node:os"; + +vi.mock("./registry.js", () => ({ + registry: { register: vi.fn() }, +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), +})); + +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + rm: vi.fn(), +})); + +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { ZcodeMCPAdapter } from "./zcode.js"; +import type { MCPServerDefinition } from "../types/index.js"; + +describe("ZcodeMCPAdapter", () => { + let adapter: ZcodeMCPAdapter; + const stdio: MCPServerDefinition = { + id: "github", + name: "github", + transport: { type: "stdio", command: "/home/blitz/.local/bin/gh-mcp", args: [] }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + adapter = new ZcodeMCPAdapter(); + }); + + it("uses ZCode user and project paths", () => { + expect(adapter.id).toBe("zcode"); + expect(adapter.name).toBe("ZCode"); + expect(adapter.configPath).toBe(".zcode/config.json"); + expect(adapter.userConfigPath).toBe(`${homedir()}/.zcode/cli/config.json`); + expect(adapter.command).toBe("zcode"); + }); + + it("detects ZCode from the user config even without a zcode binary", async () => { + vi.mocked(existsSync).mockImplementation((path) => String(path).endsWith("cli/config.json")); + expect(await adapter.detect("/tmp/empty")).toBe(true); + }); + + it("generates project { mcp: { servers } } JSON, not mcpServers", async () => { + const files = await adapter.generate([stdio]); + expect(files[0]?.path).toBe(".zcode/config.json"); + const parsed = JSON.parse(files[0]!.content) as { + mcp: { servers: Record }; + }; + expect(parsed.mcp.servers.github?.type).toBe("stdio"); + expect(parsed.mcp.servers.github?.command).toBe("/home/blitz/.local/bin/gh-mcp"); + expect(parsed.mcp.servers.github?.args).toEqual([]); + expect(files[0]!.content).not.toContain("mcpServers"); + }); + + it("emits type http/sse for remotes", async () => { + const files = await adapter.generate([ + { + id: "linear", + name: "linear", + transport: { + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer x" }, + }, + }, + { + id: "events", + name: "events", + transport: { type: "sse", url: "http://localhost:3000/sse" }, + }, + ]); + const parsed = JSON.parse(files[0]!.content) as { + mcp: { servers: Record> }; + }; + expect(parsed.mcp.servers.linear).toEqual({ + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer x" }, + }); + expect(parsed.mcp.servers.events).toMatchObject({ + type: "sse", + url: "http://localhost:3000/sse", + }); + }); + + it("imports user mcp.servers without clobbering other config keys", async () => { + vi.mocked(existsSync).mockImplementation((path) => String(path).endsWith("config.json")); + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ + model: "keep-me", + mcp: { + servers: { + palamhealth: { + type: "stdio", + command: "/home/blitz/.local/bin/palamhealth-mcp-stdio", + args: [], + env: { OP_ACCOUNT: "palamhealth.1password.com" }, + }, + }, + }, + }), + ); + const servers = await adapter.importUser(); + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("palamhealth"); + expect(servers[0]?.transport.type).toBe("stdio"); + }); + + it("merges generated servers into existing user config.json", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ model: "keep-me", mcp: { servers: {} } }), + ); + const files = await adapter.generate([stdio]); + files[0]!.path = `${homedir()}/.zcode/cli/config.json`; + await adapter.install(files, "/tmp"); + const written = JSON.parse(String(vi.mocked(writeFile).mock.calls[0]?.[1])) as { + model: string; + mcp: { servers: Record }; + }; + expect(written.model).toBe("keep-me"); + expect(written.mcp.servers.github?.type).toBe("stdio"); + expect(written.mcp.servers.github?.command).toBe("/home/blitz/.local/bin/gh-mcp"); + expect(vi.mocked(mkdir)).toHaveBeenCalled(); + }); +}); diff --git a/packages/mcp/src/adapters/zcode.ts b/packages/mcp/src/adapters/zcode.ts new file mode 100644 index 0000000..f0c9929 --- /dev/null +++ b/packages/mcp/src/adapters/zcode.ts @@ -0,0 +1,138 @@ +import { homedir } from "node:os"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { BaseMCPAdapter } from "./base.js"; +import { registry } from "./registry.js"; +import type { GeneratedFile, MCPServerDefinition, MCPTransport } from "../types/index.js"; + +type ZcodeServer = { + type?: string; + command?: string; + args?: string[]; + env?: Record; + url?: string; + headers?: Record; + timeoutMs?: number; +}; + +class ZcodeMCPAdapter extends BaseMCPAdapter { + readonly id = "zcode"; + readonly name = "ZCode"; + readonly nativeSupport = true; + readonly configPath = ".zcode/config.json"; + readonly userConfigPath = `${homedir()}/.zcode/cli/config.json`; + readonly command = "zcode"; + + override async detect(cwd?: string): Promise { + if (await super.detect(cwd)) return true; + return existsSync(this.userConfigPath); + } + + async generate(servers: MCPServerDefinition[]): Promise { + return [ + { + path: this.configPath, + content: `${JSON.stringify({ mcp: { servers: toZcodeServers(servers) } }, null, 2)}\n`, + format: "json", + }, + ]; + } + + async import(cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + return this.readZcode(resolve(dir, this.configPath)); + } + + override async importUser(): Promise { + return this.readZcode(this.userConfigPath); + } + + override async install(files: GeneratedFile[], cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + for (const file of files) { + const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path); + await mkdir(dirname(fullPath), { recursive: true }); + if (existsSync(fullPath)) { + const existing = JSON.parse(await readFile(fullPath, "utf-8")) as { + mcp?: { servers?: Record }; + }; + const generated = JSON.parse(file.content) as { + mcp?: { servers?: Record }; + }; + existing.mcp = { + ...existing.mcp, + servers: { ...existing.mcp?.servers, ...generated.mcp?.servers }, + }; + await writeFile(fullPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8"); + continue; + } + await writeFile(fullPath, file.content, "utf-8"); + } + } + + private async readZcode(fullPath: string): Promise { + if (!existsSync(fullPath)) return []; + const data = JSON.parse(await readFile(fullPath, "utf-8")) as { + mcp?: { servers?: Record }; + }; + return fromZcodeServers(data.mcp?.servers ?? {}); + } +} + +function toZcodeServers(servers: MCPServerDefinition[]): Record { + const out: Record = {}; + for (const server of servers) { + out[server.id] = toZcodeServer(server); + } + return out; +} + +function toZcodeServer(server: MCPServerDefinition): ZcodeServer { + if (server.transport.type === "stdio") { + const spec: ZcodeServer = { + type: "stdio", + command: server.transport.command, + args: server.transport.args ?? [], + }; + if (server.transport.env && Object.keys(server.transport.env).length > 0) { + spec.env = server.transport.env; + } + return spec; + } + const spec: ZcodeServer = { + type: server.transport.type, + url: server.transport.url, + }; + if (server.transport.headers && Object.keys(server.transport.headers).length > 0) { + spec.headers = server.transport.headers; + } + return spec; +} + +function fromZcodeServers(servers: Record): MCPServerDefinition[] { + const out: MCPServerDefinition[] = []; + for (const [id, spec] of Object.entries(servers)) { + if (typeof spec.command === "string") { + const transport: Extract = { + type: "stdio", + command: spec.command, + }; + if (spec.args) transport.args = spec.args; + if (spec.env) transport.env = spec.env; + out.push({ id, name: id, transport }); + } else if (typeof spec.url === "string") { + const type = spec.type === "sse" ? "sse" : "http"; + const transport: Extract = { type, url: spec.url }; + if (spec.headers) transport.headers = spec.headers; + out.push({ id, name: id, transport }); + } + } + return out; +} + +const adapter = new ZcodeMCPAdapter(); +registry.register(adapter); + +export { ZcodeMCPAdapter }; +export default adapter; diff --git a/packages/mcp/src/audit.test.ts b/packages/mcp/src/audit.test.ts new file mode 100644 index 0000000..4973112 --- /dev/null +++ b/packages/mcp/src/audit.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { auditServers } from "./audit.js"; +import type { MCPServerDefinition } from "./types/index.js"; + +describe("auditServers", () => { + it("flags interactive op-run-palamhealth but not the service-account wrapper", () => { + const interactive: MCPServerDefinition = { + id: "outline", + name: "outline", + transport: { + type: "stdio", + command: "/home/blitz/.local/bin/op-run-palamhealth", + args: ["npx", "mcp-remote", "https://docs.palamhealth.dev/mcp"], + }, + }; + const headless: MCPServerDefinition = { + id: "outline-sa", + name: "outline-sa", + transport: { + type: "stdio", + command: "/home/blitz/.local/bin/op-run-palamhealth-sa", + args: ["npx"], + }, + }; + const findings = [ + ...auditServers("cursor", [interactive], "user"), + ...auditServers("cursor", [headless], "user"), + ]; + expect(findings.some((f) => f.kind === "interactive-palamhealth-account")).toBe(true); + expect( + findings.filter( + (f) => f.server === "outline-sa" && f.kind === "interactive-palamhealth-account", + ), + ).toEqual([]); + }); + + it("flags PalamHealth servers in user-global configs, not project configs", () => { + const palam: MCPServerDefinition = { + id: "palamhealth", + name: "palamhealth", + transport: { type: "stdio", command: "/home/blitz/.local/bin/palamhealth-mcp-stdio" }, + }; + expect(auditServers("zcode", [palam], "user").map((f) => f.kind)).toContain( + "palamhealth-in-user-config", + ); + expect(auditServers("zcode", [palam], "project").map((f) => f.kind)).not.toContain( + "palamhealth-in-user-config", + ); + }); + + it("flags PalamHealth Linear registered as generic linear", () => { + const linear: MCPServerDefinition = { + id: "linear", + name: "linear", + transport: { type: "stdio", command: "/home/blitz/.local/bin/linear-mcp-palamhealth" }, + }; + expect(auditServers("cursor", [linear], "user").map((f) => f.kind)).toContain( + "misnamed-palamhealth-linear", + ); + }); + + it("flags OP_ACCOUNT=palamhealth.1password.com", () => { + const server: MCPServerDefinition = { + id: "linear-palamhealth", + name: "linear-palamhealth", + transport: { + type: "stdio", + command: "/home/blitz/.local/bin/op-run-palamhealth", + env: { OP_ACCOUNT: "palamhealth.1password.com" }, + }, + }; + const kinds = auditServers("zcode", [server], "user").map((f) => f.kind); + expect(kinds).toContain("interactive-palamhealth-account"); + expect(kinds).toContain("palamhealth-in-user-config"); + }); +}); diff --git a/packages/mcp/src/audit.ts b/packages/mcp/src/audit.ts new file mode 100644 index 0000000..ca8293b --- /dev/null +++ b/packages/mcp/src/audit.ts @@ -0,0 +1,66 @@ +import type { MCPServerDefinition } from "./types/index.js"; + +export type AuditKind = + | "interactive-palamhealth-account" + | "palamhealth-in-user-config" + | "misnamed-palamhealth-linear"; + +export type AuditFinding = { + tool: string; + server: string; + kind: AuditKind; + detail: string; +}; + +const INTERACTIVE_WRAPPER = "op-run-palamhealth"; +const PALAMHEALTH_ACCOUNT = "palamhealth.1password.com"; +const PALAMHEALTH_LINEAR = "linear-mcp-palamhealth"; +const PALAMHEALTH_IDS = new Set(["palamhealth", "outline", "linear-palamhealth"]); + +export function auditServers( + tool: string, + servers: MCPServerDefinition[], + source: "user" | "project" = "user", +): AuditFinding[] { + const findings: AuditFinding[] = []; + for (const server of servers) { + const command = server.transport.type === "stdio" ? server.transport.command : ""; + const env = server.transport.type === "stdio" ? (server.transport.env ?? {}) : {}; + const usesSa = command.endsWith("op-run-palamhealth-sa") || command === "op-run-palamhealth-sa"; + const usesInteractive = + !usesSa && (command.endsWith(`/${INTERACTIVE_WRAPPER}`) || command === INTERACTIVE_WRAPPER); + if (usesInteractive || env.OP_ACCOUNT === PALAMHEALTH_ACCOUNT) { + findings.push({ + tool, + server: server.id, + kind: "interactive-palamhealth-account", + detail: `${server.id} uses interactive PalamHealth 1Password (${INTERACTIVE_WRAPPER} or OP_ACCOUNT=${PALAMHEALTH_ACCOUNT})`, + }); + } + + const url = "url" in server.transport ? server.transport.url : ""; + const isPalamProduct = + PALAMHEALTH_IDS.has(server.id) || + url.includes("palamhealth.dev") || + command.includes("palamhealth-mcp") || + command.includes(PALAMHEALTH_LINEAR); + if (source === "user" && isPalamProduct) { + findings.push({ + tool, + server: server.id, + kind: "palamhealth-in-user-config", + detail: `${server.id} looks like PalamHealth MCP in a global/user config`, + }); + } + + if (server.id === "linear" && command.includes(PALAMHEALTH_LINEAR)) { + findings.push({ + tool, + server: server.id, + kind: "misnamed-palamhealth-linear", + detail: "PalamHealth Linear is registered as generic id `linear`", + }); + } + } + return findings; +} diff --git a/packages/mcp/src/cli/index.test.ts b/packages/mcp/src/cli/index.test.ts index 8585239..938ea3e 100644 --- a/packages/mcp/src/cli/index.test.ts +++ b/packages/mcp/src/cli/index.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { BaseMCPAdapter } from "../adapters/base.js"; import type { MCPServerDefinition, GeneratedFile } from "../types/index.js"; @@ -56,9 +59,62 @@ function makeAdapter(overrides: Partial = {}): BaseMCPAdapter { vi.fn<(files: GeneratedFile[], cwd?: string) => Promise>().mockResolvedValue(undefined), uninstall: overrides.uninstall ?? vi.fn<(cwd?: string) => Promise>().mockResolvedValue(undefined), + importUser: + overrides.importUser ?? vi.fn<() => Promise>().mockResolvedValue([]), + userConfigPath: overrides.userConfigPath, } as unknown as BaseMCPAdapter; } +function stdioServer(id: string, extra: Partial = {}): MCPServerDefinition { + return { + id, + name: extra.name ?? id, + transport: extra.transport ?? { type: "stdio", command: "npx", args: ["-y", id] }, + ...extra, + }; +} + +function encodedMcpContent(servers: MCPServerDefinition[]): string { + return JSON.stringify( + { + mcpServers: Object.fromEntries( + servers.map((server) => [ + server.id, + { + command: server.transport.type === "stdio" ? server.transport.command : "url", + }, + ]), + ), + }, + null, + 2, + ); +} + +function encodeGenerate( + path: string, +): ReturnType Promise>> { + return vi + .fn<(servers: MCPServerDefinition[]) => Promise>() + .mockImplementation(async (servers) => [ + { path, content: encodedMcpContent(servers), format: "json" }, + ]); +} + +async function withConfig( + servers: MCPServerDefinition[], + fn: (configPath: string) => Promise, +): Promise { + const dir = mkdtempSync(join(tmpdir(), "ai-mcp-cli-")); + const configPath = join(dir, "mcp.config.mjs"); + writeFileSync(configPath, `export default ${JSON.stringify({ servers }, null, 2)};\n`, "utf-8"); + try { + await fn(configPath); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + let logOutput: string[]; let errorOutput: string[]; let warnOutput: string[]; @@ -149,11 +205,22 @@ describe("run() - help output", () => { "import", "sync", "export", + "audit", "help", ]) { expect(output).toContain(cmd); } }); + + it("describes sync as config-scoped and never copying imports", async () => { + await run(["help"]); + const output = allLog(); + expect(output).toContain("never copies imports"); + expect(output).toContain("--layer"); + expect(output).toContain("user or project"); + expect(output).not.toContain("union"); + expect(output).not.toMatch(/sync[^\n]*across/i); + }); }); describe("run() - unknown command", () => { @@ -217,7 +284,9 @@ describe("run() - generate command", () => { const adapter = makeAdapter({ id: "claude-code", name: "Claude Code" }); mockRegistryDetectAll.mockResolvedValue([adapter]); - await run(["generate"]); + await withConfig([stdioServer("github")], async (configPath) => { + await run(["generate", `--config=${configPath}`]); + }); expect(allLog()).toContain("Generating MCP configs for 1 tool(s)"); expect(allLog()).toContain("Generated: .test/mcp.json"); expect(allLog()).toContain("Done!"); @@ -227,7 +296,9 @@ describe("run() - generate command", () => { const adapter = makeAdapter({ id: "claude-code", name: "Claude Code" }); mockRegistryDetectAll.mockResolvedValue([adapter]); - await run(["generate", "--dry-run"]); + await withConfig([stdioServer("github")], async (configPath) => { + await run(["generate", `--config=${configPath}`, "--dry-run"]); + }); expect(allLog()).toContain("[dry-run] Would write: .test/mcp.json"); expect(mockWriteFile).not.toHaveBeenCalled(); expect(mockMkdir).not.toHaveBeenCalled(); @@ -240,7 +311,9 @@ describe("run() - generate command", () => { return undefined; }); - await run(["generate", "--tools=claude-code"]); + await withConfig([stdioServer("github")], async (configPath) => { + await run(["generate", `--config=${configPath}`, "--tools=claude-code"]); + }); expect(allLog()).toContain("Generating MCP configs for 1 tool(s)"); expect(mockRegistryDetectAll).not.toHaveBeenCalled(); }); @@ -252,6 +325,24 @@ describe("run() - generate command", () => { expect(allWarn()).toContain('Warning: Unknown adapter "nonexistent"'); expect(allLog()).toContain("No AI tools detected"); }); + + it("refuses to generate an empty server list that would wipe tool configs", async () => { + const generateFn = encodeGenerate(".cursor/mcp.json"); + const adapter = makeAdapter({ + id: "cursor", + name: "Cursor", + generate: generateFn, + }); + mockRegistryDetectAll.mockResolvedValue([adapter]); + + await expect(run(["generate"])).rejects.toThrow("process.exit(1)"); + expect(allError()).toContain( + "No servers selected from mcp.config.ts. Refusing to write an empty MCP config.", + ); + expect(exitCode).toBe(1); + expect(generateFn).not.toHaveBeenCalled(); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); }); describe("run() - install command", () => { @@ -274,7 +365,9 @@ describe("run() - install command", () => { mockRegistryDetectAll.mockResolvedValue([adapter]); - await run(["install"]); + await withConfig([stdioServer("github")], async (configPath) => { + await run(["install", `--config=${configPath}`]); + }); expect(installFn).toHaveBeenCalled(); expect(allLog()).toContain("Installing MCP servers into 1 tool(s)"); expect(allLog()).toContain("\u2713 Claude Code"); @@ -296,7 +389,9 @@ describe("run() - install command", () => { mockRegistryDetectAll.mockResolvedValue([adapter]); - await run(["install", "--dry-run"]); + await withConfig([stdioServer("github")], async (configPath) => { + await run(["install", `--config=${configPath}`, "--dry-run"]); + }); expect(installFn).not.toHaveBeenCalled(); expect(allLog()).toContain("[dry-run] Would install: .mcp.json"); }); @@ -335,10 +430,75 @@ describe("run() - install command", () => { return undefined; }); - await run(["install", "--tools=kiro", "--force"]); + await withConfig([stdioServer("github")], async (configPath) => { + await run(["install", `--config=${configPath}`, "--tools=kiro", "--force"]); + }); expect(installFn).toHaveBeenCalled(); expect(allLog()).toContain("Installing MCP servers into 1 tool(s)"); }); + + it("honors --layer and whenPathContains from mcp.config.ts like sync", async () => { + const palamhealth = stdioServer("palamhealth", { + layer: "project", + whenPathContains: ["PalamHealth"], + transport: { type: "stdio", command: "/home/blitz/.local/bin/op-run-palamhealth" }, + }); + const github = stdioServer("github", { + layer: "project", + transport: { type: "stdio", command: "npx" }, + }); + const generateFn = encodeGenerate(".mcp.json"); + const installFn = vi + .fn<(files: GeneratedFile[], cwd?: string) => Promise>() + .mockResolvedValue(undefined); + const adapter = makeAdapter({ + id: "claude-code", + name: "Claude Code", + generate: generateFn, + install: installFn, + }); + mockRegistryDetectAll.mockResolvedValue([adapter]); + const cwdSpy = vi + .spyOn(process, "cwd") + .mockReturnValue("/home/blitz/Development/PremierStudio/web"); + + try { + await withConfig([palamhealth, github], async (configPath) => { + await run(["install", `--config=${configPath}`, "--layer=project"]); + }); + } finally { + cwdSpy.mockRestore(); + } + + expect(generateFn.mock.calls[0]![0]!.map((s) => s.id)).toEqual(["github"]); + expect(installFn).toHaveBeenCalledWith( + [{ path: ".mcp.json", content: encodedMcpContent([github]), format: "json" }], + "/home/blitz/Development/PremierStudio/web", + ); + expect(installFn.mock.calls[0]![0]![0]!.content).not.toContain("palamhealth"); + }); + + it("refuses to install an empty server list that would wipe tool configs", async () => { + const generateFn = encodeGenerate(".cursor/mcp.json"); + const installFn = vi + .fn<(files: GeneratedFile[], cwd?: string) => Promise>() + .mockResolvedValue(undefined); + const adapter = makeAdapter({ + id: "cursor", + name: "Cursor", + generate: generateFn, + install: installFn, + }); + mockRegistryDetectAll.mockResolvedValue([adapter]); + + await expect(run(["install"])).rejects.toThrow("process.exit(1)"); + expect(allError()).toContain( + "No servers selected from mcp.config.ts. Refusing to write an empty MCP config.", + ); + expect(exitCode).toBe(1); + expect(generateFn).not.toHaveBeenCalled(); + expect(installFn).not.toHaveBeenCalled(); + }); }); describe("run() - export command", () => { @@ -410,64 +570,374 @@ describe("run() - import command", () => { }); describe("run() - sync command", () => { - it("syncs servers across detected tools", async () => { - const servers: MCPServerDefinition[] = [ - { - id: "test", - name: "test", - transport: { type: "stdio", command: "npx", args: ["-y", "test"] }, - }, - ]; + const palamhealth = stdioServer("palamhealth", { + layer: "project", + whenPathContains: ["PalamHealth"], + transport: { + type: "stdio", + command: "/home/blitz/.local/bin/op-run-palamhealth", + }, + }); + const github = stdioServer("github", { + layer: "project", + transport: { type: "stdio", command: "npx", args: ["-y", "github-mcp"] }, + }); + const unifi = stdioServer("unifi", { + layer: "user", + transport: { type: "stdio", command: "unifi-mcp" }, + }); + + it("prints message when no tools detected", async () => { + mockRegistryDetectAll.mockResolvedValue([]); + + await run(["sync"]); + expect(allLog()).toContain("No AI tools detected"); + expect(allLog()).toContain("--tools"); + }); + + it("refuses to copy imported servers when mcp.config.ts is missing", async () => { const importFn = vi .fn<(cwd?: string) => Promise>() - .mockResolvedValue(servers); + .mockResolvedValue([palamhealth]); + const generateFn = encodeGenerate(".zcode/mcp.json"); const installFn = vi .fn<(files: GeneratedFile[], cwd?: string) => Promise>() .mockResolvedValue(undefined); - const adapter1 = makeAdapter({ - id: "claude-code", - name: "Claude Code", + const zcode = makeAdapter({ + id: "zcode", + name: "ZCode", import: importFn, + generate: generateFn, install: installFn, }); - const adapter2 = makeAdapter({ + const cursor = makeAdapter({ id: "cursor", name: "Cursor", + import: vi.fn<(cwd?: string) => Promise>().mockResolvedValue([]), + generate: encodeGenerate(".cursor/mcp.json"), install: installFn, }); - mockRegistryDetectAll.mockResolvedValue([adapter1, adapter2]); + mockRegistryDetectAll.mockResolvedValue([zcode, cursor]); - await run(["sync"]); - expect(allLog()).toContain("Syncing MCP servers across 2 tool(s)"); + await expect(run(["sync"])).rejects.toThrow("process.exit(1)"); + expect(allError()).toContain( + "No servers to sync from mcp.config.ts. Refusing to copy imported servers between tools.", + ); + expect(allError()).toContain( + "Add servers to the config (or pass --config) and use install/sync from that list.", + ); + expect(allLog()).not.toContain("Sync complete!"); + expect(exitCode).toBe(1); + expect(importFn).not.toHaveBeenCalled(); + expect(generateFn).not.toHaveBeenCalled(); + expect(installFn).not.toHaveBeenCalled(); + }); + + it("refuses when the config exists but selects no servers", async () => { + const importFn = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValue([palamhealth]); + const generateFn = encodeGenerate(".cursor/mcp.json"); + const installFn = vi + .fn<(files: GeneratedFile[], cwd?: string) => Promise>() + .mockResolvedValue(undefined); + const adapter = makeAdapter({ + id: "cursor", + name: "Cursor", + import: importFn, + generate: generateFn, + install: installFn, + }); + mockRegistryDetectAll.mockResolvedValue([adapter]); + + await withConfig([], async (configPath) => { + await expect(run(["sync", `--config=${configPath}`])).rejects.toThrow("process.exit(1)"); + }); + + expect(allError()).toContain("Refusing to copy imported servers between tools"); + expect(exitCode).toBe(1); + expect(importFn).not.toHaveBeenCalled(); + expect(generateFn).not.toHaveBeenCalled(); + expect(installFn).not.toHaveBeenCalled(); + }); + + it("installs only configured server IDs and never imports from other tools", async () => { + const zcodeGenerate = encodeGenerate(".zcode/mcp.json"); + const cursorGenerate = encodeGenerate(".cursor/mcp.json"); + const zcodeInstall = vi + .fn<(files: GeneratedFile[], cwd?: string) => Promise>() + .mockResolvedValue(undefined); + const cursorInstall = vi + .fn<(files: GeneratedFile[], cwd?: string) => Promise>() + .mockResolvedValue(undefined); + const zcodeImport = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValue([palamhealth, github]); + const cursorImport = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValue([]); + + const zcode = makeAdapter({ + id: "zcode", + name: "ZCode", + import: zcodeImport, + generate: zcodeGenerate, + install: zcodeInstall, + }); + const cursor = makeAdapter({ + id: "cursor", + name: "Cursor", + import: cursorImport, + generate: cursorGenerate, + install: cursorInstall, + }); + mockRegistryDetectAll.mockResolvedValue([zcode, cursor]); + + const cwdSpy = vi + .spyOn(process, "cwd") + .mockReturnValue("/home/blitz/Development/PremierStudio/web"); + + try { + await withConfig([github], async (configPath) => { + await run(["sync", `--config=${configPath}`, "--layer=project"]); + }); + } finally { + cwdSpy.mockRestore(); + } + + expect(zcodeImport).not.toHaveBeenCalled(); + expect(cursorImport).not.toHaveBeenCalled(); + expect(zcodeGenerate.mock.calls.map((call) => call[0]!.map((s) => s.id))).toEqual([["github"]]); + expect(cursorGenerate.mock.calls.map((call) => call[0]!.map((s) => s.id))).toEqual([ + ["github"], + ]); + + const expectedFiles: GeneratedFile[] = [ + { path: ".zcode/mcp.json", content: encodedMcpContent([github]), format: "json" }, + ]; + const expectedCursorFiles: GeneratedFile[] = [ + { path: ".cursor/mcp.json", content: encodedMcpContent([github]), format: "json" }, + ]; + expect(zcodeInstall).toHaveBeenCalledTimes(1); + expect(zcodeInstall).toHaveBeenCalledWith( + expectedFiles, + "/home/blitz/Development/PremierStudio/web", + ); + expect(cursorInstall).toHaveBeenCalledTimes(1); + expect(cursorInstall).toHaveBeenCalledWith( + expectedCursorFiles, + "/home/blitz/Development/PremierStudio/web", + ); + expect(encodedMcpContent([github])).toBe( + JSON.stringify({ mcpServers: { github: { command: "npx" } } }, null, 2), + ); + expect(allLog()).toContain("Installing 1 configured server(s) onto 2 tool(s)"); + expect(allLog()).toContain("\u2713 ZCode"); + expect(allLog()).toContain("\u2713 Cursor"); expect(allLog()).toContain("Sync complete!"); }); - it("respects --dry-run", async () => { - const servers: MCPServerDefinition[] = [ + it("honors --layer=project and drops user-layer servers", async () => { + const generateFn = encodeGenerate(".cursor/mcp.json"); + const installFn = vi + .fn<(files: GeneratedFile[], cwd?: string) => Promise>() + .mockResolvedValue(undefined); + const adapter = makeAdapter({ + id: "cursor", + name: "Cursor", + generate: generateFn, + install: installFn, + import: vi.fn<(cwd?: string) => Promise>().mockResolvedValue([unifi]), + }); + mockRegistryDetectAll.mockResolvedValue([adapter]); + + await withConfig([github, unifi], async (configPath) => { + await run(["sync", `--config=${configPath}`, "--layer=project"]); + }); + + expect(generateFn.mock.calls[0]![0]!.map((s) => s.id)).toEqual(["github"]); + expect(installFn).toHaveBeenCalledWith( + [{ path: ".cursor/mcp.json", content: encodedMcpContent([github]), format: "json" }], + process.cwd(), + ); + expect(adapter.import).not.toHaveBeenCalled(); + }); + + it("honors --layer=user, writes userConfigPath, and skips project servers", async () => { + const generateFn = encodeGenerate(".cursor/mcp.json"); + const installFn = vi + .fn<(files: GeneratedFile[], cwd?: string) => Promise>() + .mockResolvedValue(undefined); + const adapter = makeAdapter({ + id: "cursor", + name: "Cursor", + userConfigPath: "/tmp/fake-home/.cursor/mcp.json", + generate: generateFn, + install: installFn, + }); + mockRegistryDetectAll.mockResolvedValue([adapter]); + + await withConfig([github, unifi], async (configPath) => { + await run(["sync", `--config=${configPath}`, "--layer=user"]); + }); + + expect(generateFn.mock.calls[0]![0]!.map((s) => s.id)).toEqual(["unifi"]); + expect(installFn).toHaveBeenCalledTimes(1); + expect(installFn.mock.calls[0]![0]).toEqual([ { - id: "test", - name: "test", - transport: { type: "stdio", command: "npx", args: ["-y", "test"] }, + path: "/tmp/fake-home/.cursor/mcp.json", + content: encodedMcpContent([unifi]), + format: "json", }, - ]; + ]); + expect(installFn.mock.calls[0]![1]).toBe(homedir()); + expect(installFn.mock.calls[0]![0]![0]!.path).not.toBe(`${homedir()}/.cursor/mcp.json`); + expect(installFn.mock.calls[0]![0]![0]!.path).not.toBe(`${homedir()}/.zcode/mcp.json`); + expect(installFn.mock.calls[0]![0]![0]!.path).not.toBe(`${homedir()}/.claude/mcp.json`); + expect(installFn.mock.calls[0]![0]![0]!.path).not.toBe(`${homedir()}/.grok/mcp.json`); + }); + + it("honors whenPathContains and keeps PalamHealth servers in PalamHealth cwd", async () => { + const generateFn = encodeGenerate(".claude/mcp.json"); + const installFn = vi + .fn<(files: GeneratedFile[], cwd?: string) => Promise>() + .mockResolvedValue(undefined); + const adapter = makeAdapter({ + id: "claude-code", + name: "Claude Code", + generate: generateFn, + install: installFn, + }); + mockRegistryDetectAll.mockResolvedValue([adapter]); + const cwdSpy = vi + .spyOn(process, "cwd") + .mockReturnValue("/home/blitz/Development/PalamHealth/PalamHealth"); + + try { + await withConfig([palamhealth, github], async (configPath) => { + await run(["sync", `--config=${configPath}`, "--layer=project"]); + }); + } finally { + cwdSpy.mockRestore(); + } + + expect(generateFn.mock.calls[0]![0]!.map((s) => s.id)).toEqual(["palamhealth", "github"]); + expect(installFn).toHaveBeenCalledWith( + [ + { + path: ".claude/mcp.json", + content: encodedMcpContent([palamhealth, github]), + format: "json", + }, + ], + "/home/blitz/Development/PalamHealth/PalamHealth", + ); + }); + + it("honors whenPathContains and refuses PalamHealth leak into PremierStudio cwd", async () => { + const generateFn = encodeGenerate(".claude/mcp.json"); + const installFn = vi + .fn<(files: GeneratedFile[], cwd?: string) => Promise>() + .mockResolvedValue(undefined); const importFn = vi .fn<(cwd?: string) => Promise>() - .mockResolvedValue(servers); + .mockResolvedValue([palamhealth]); + const adapter = makeAdapter({ + id: "claude-code", + name: "Claude Code", + generate: generateFn, + install: installFn, + import: importFn, + }); + mockRegistryDetectAll.mockResolvedValue([adapter]); + const cwdSpy = vi + .spyOn(process, "cwd") + .mockReturnValue("/home/blitz/Development/PremierStudio/web"); + + try { + await withConfig([palamhealth], async (configPath) => { + await expect(run(["sync", `--config=${configPath}`, "--layer=project"])).rejects.toThrow( + "process.exit(1)", + ); + }); + } finally { + cwdSpy.mockRestore(); + } + + expect(allError()).toContain("Refusing to copy imported servers between tools"); + expect(importFn).not.toHaveBeenCalled(); + expect(generateFn).not.toHaveBeenCalled(); + expect(installFn).not.toHaveBeenCalled(); + expect(exitCode).toBe(1); + }); + + it("installs un-gated project servers in PremierStudio while dropping path-gated PalamHealth", async () => { + const generateFn = encodeGenerate(".claude/mcp.json"); const installFn = vi .fn<(files: GeneratedFile[], cwd?: string) => Promise>() .mockResolvedValue(undefined); - const adapter1 = makeAdapter({ + const adapter = makeAdapter({ id: "claude-code", name: "Claude Code", - import: importFn, + generate: generateFn, install: installFn, }); + mockRegistryDetectAll.mockResolvedValue([adapter]); + const cwdSpy = vi + .spyOn(process, "cwd") + .mockReturnValue("/home/blitz/Development/PremierStudio/web"); + + try { + await withConfig([palamhealth, github], async (configPath) => { + await run(["sync", `--config=${configPath}`]); + }); + } finally { + cwdSpy.mockRestore(); + } - mockRegistryDetectAll.mockResolvedValue([adapter1]); + expect(generateFn.mock.calls[0]![0]!.map((s) => s.id)).toEqual(["github"]); + expect(installFn).toHaveBeenCalledWith( + [{ path: ".claude/mcp.json", content: encodedMcpContent([github]), format: "json" }], + "/home/blitz/Development/PremierStudio/web", + ); + expect(encodedMcpContent([github])).not.toContain("palamhealth"); + expect(encodedMcpContent([github])).not.toContain("op-run-palamhealth"); + }); + + it("respects --dry-run from config and does not install or import", async () => { + const generateFn = encodeGenerate(".cursor/mcp.json"); + const installFn = vi + .fn<(files: GeneratedFile[], cwd?: string) => Promise>() + .mockResolvedValue(undefined); + const importFn = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValue([palamhealth]); + const adapter = makeAdapter({ + id: "cursor", + name: "Cursor", + generate: generateFn, + install: installFn, + import: importFn, + }); + mockRegistryDetectAll.mockResolvedValue([adapter]); + + await withConfig([github], async (configPath) => { + await run(["sync", `--config=${configPath}`, "--dry-run"]); + }); - await run(["sync", "--dry-run"]); expect(installFn).not.toHaveBeenCalled(); + expect(importFn).not.toHaveBeenCalled(); + expect(generateFn.mock.calls[0]![0]!.map((s) => s.id)).toEqual(["github"]); + expect(allLog()).toContain("[dry-run] Would write: .cursor/mcp.json"); + expect(allLog()).toContain("Sync complete!"); + }); + + it("throws when --config points at a missing file", async () => { + mockRegistryDetectAll.mockResolvedValue([makeAdapter()]); + await expect(run(["sync", "--config=/tmp/does-not-exist-mcp.config.mjs"])).rejects.toThrow( + "Config file not found: /tmp/does-not-exist-mcp.config.mjs", + ); }); }); @@ -479,7 +949,9 @@ describe("run() - flag parsing", () => { return undefined; }); - await run(["generate", "--tools=claude-code"]); + await withConfig([stdioServer("github")], async (configPath) => { + await run(["generate", `--config=${configPath}`, "--tools=claude-code"]); + }); expect(mockRegistryGet).toHaveBeenCalledWith("claude-code"); expect(mockRegistryDetectAll).not.toHaveBeenCalled(); }); @@ -501,7 +973,9 @@ describe("run() - flag parsing", () => { return undefined; }); - await run(["generate", "--tools=kiro", "--force"]); + await withConfig([stdioServer("github")], async (configPath) => { + await run(["generate", `--config=${configPath}`, "--tools=kiro", "--force"]); + }); expect(allLog()).toContain("Generating MCP configs for 1 tool(s)"); }); }); @@ -518,7 +992,9 @@ describe("run() - error propagation", () => { mockRegistryDetectAll.mockResolvedValue([adapter]); - await expect(run(["generate"])).rejects.toThrow("generate failed"); + await withConfig([stdioServer("github")], async (configPath) => { + await expect(run(["generate", `--config=${configPath}`])).rejects.toThrow("generate failed"); + }); }); it("propagates adapter install errors", async () => { @@ -532,6 +1008,48 @@ describe("run() - error propagation", () => { mockRegistryDetectAll.mockResolvedValue([adapter]); - await expect(run(["install"])).rejects.toThrow("install failed"); + await withConfig([stdioServer("github")], async (configPath) => { + await expect(run(["install", `--config=${configPath}`])).rejects.toThrow("install failed"); + }); + }); +}); + +describe("run() - audit command", () => { + it("reports a clean scan", async () => { + const adapter = makeAdapter({ + id: "cursor", + name: "Cursor", + importUser: vi.fn<() => Promise>().mockResolvedValue([ + { + id: "github", + name: "github", + transport: { type: "stdio", command: "gh-mcp" }, + }, + ]), + }); + mockRegistryGetAll.mockReturnValue([adapter]); + await run(["audit"]); + expect(allLog()).toContain("No PalamHealth user-global MCP leaks found."); + }); + + it("exits 1 when PalamHealth is in a user-global config", async () => { + const adapter = makeAdapter({ + id: "zcode", + name: "ZCode", + importUser: vi.fn<() => Promise>().mockResolvedValue([ + { + id: "outline", + name: "outline", + transport: { + type: "stdio", + command: "/home/blitz/.local/bin/op-run-palamhealth", + }, + }, + ]), + }); + mockRegistryGetAll.mockReturnValue([adapter]); + await expect(run(["audit"])).rejects.toThrow("process.exit(1)"); + expect(allLog()).toContain("interactive-palamhealth-account"); + expect(exitCode).toBe(1); }); }); diff --git a/packages/mcp/src/cli/index.ts b/packages/mcp/src/cli/index.ts index a093254..035b468 100644 --- a/packages/mcp/src/cli/index.ts +++ b/packages/mcp/src/cli/index.ts @@ -1,6 +1,9 @@ +import { homedir } from "node:os"; import { registry } from "../adapters/registry.js"; import type { BaseMCPAdapter } from "../adapters/base.js"; import type { MCPServerDefinition, MCPConfig, GeneratedFile } from "../types/index.js"; +import { filterServers } from "../scope.js"; +import { auditServers } from "../audit.js"; // Import all adapters to register them import "../adapters/all.js"; @@ -17,20 +20,24 @@ COMMANDS: generate Generate MCP configs for detected/specified tools install Generate and install MCP servers into detected tools import Import MCP servers from an existing tool's config - sync Sync MCP servers across all detected tools + sync Install servers from mcp.config.ts onto detected tools (never copies imports) export Export MCP server definitions as JSON + audit Find PalamHealth / interactive-1Password leaks in user-global configs help Show this help message OPTIONS: --tools Comma-separated list of tools (e.g., --tools=claude-code,cursor) --dry-run Show what would be generated without writing files --force Skip detection checks for --tools (install even if tool not found) + --layer user or project (default: project). Controls which servers install. EXAMPLES: ai-mcp detect # See which AI tools support MCP ai-mcp generate # Generate configs for all detected tools ai-mcp install --tools=claude-code # Install MCP servers for Claude Code only - ai-mcp sync # Sync MCP servers across all tools + ai-mcp install --layer=project # Project-scoped install from mcp.config.ts + ai-mcp sync --layer=project # Config-scoped install (never copies imports) + ai-mcp audit # Find PalamHealth leaks in user-global configs ai-mcp import --tools=claude-code # Import servers from Claude Code config ai-mcp export # Export all MCP servers as JSON `; @@ -40,6 +47,7 @@ type Flags = { config?: string; dryRun?: boolean; force?: boolean; + layer?: "user" | "project"; }; export async function run(args: string[]): Promise { @@ -68,6 +76,9 @@ export async function run(args: string[]): Promise { case "export": await cmdExport(flags); break; + case "audit": + await cmdAudit(flags); + break; case "help": case "--help": case "-h": @@ -148,11 +159,12 @@ async function cmdGenerate(flags: Flags): Promise { } const config = await loadConfig(flags.config); + const servers = requireConfiguredServers(selectServers(config.servers, flags), "write"); console.log(`Generating MCP configs for ${adapters.length} tool(s)...\n`); for (const adapter of adapters) { - const files = await adapter.generate(config.servers); + const files = await adapter.generate(servers); for (const file of files) { if (flags.dryRun) { @@ -179,18 +191,25 @@ async function cmdInstall(flags: Flags): Promise { } const config = await loadConfig(flags.config); + const servers = requireConfiguredServers(selectServers(config.servers, flags), "write"); + const layer = flags.layer ?? "project"; console.log(`Installing MCP servers into ${adapters.length} tool(s)...\n`); for (const adapter of adapters) { - const files = await adapter.generate(config.servers); + const files = await adapter.generate(servers); + if (layer === "user" && adapter.userConfigPath) { + for (const file of files) { + file.path = adapter.userConfigPath; + } + } if (flags.dryRun) { for (const file of files) { console.log(` [dry-run] Would install: ${file.path}`); } } else { - await adapter.install(files); + await adapter.install(files, layer === "user" ? homedir() : process.cwd()); console.log(` \u2713 ${adapter.name}`); } } @@ -220,31 +239,28 @@ async function cmdSync(flags: Flags): Promise { return; } - console.log(`Syncing MCP servers across ${adapters.length} tool(s)...\n`); - - // Collect all unique servers from all detected tools - const allServers = new Map(); - for (const adapter of adapters) { - const servers = await adapter.import(); - for (const server of servers) { - if (!allServers.has(server.id)) { - allServers.set(server.id, server); - } - } - } + const config = await loadConfig(flags.config); + const servers = requireConfiguredServers(selectServers(config.servers, flags), "sync"); - const servers = [...allServers.values()]; + console.log( + `Installing ${servers.length} configured server(s) onto ${adapters.length} tool(s)...\n`, + ); - // Write to all adapters + const layer = flags.layer ?? "project"; for (const adapter of adapters) { const files = await adapter.generate(servers); + if (layer === "user" && adapter.userConfigPath) { + for (const file of files) { + file.path = adapter.userConfigPath; + } + } if (flags.dryRun) { for (const file of files) { console.log(` [dry-run] Would write: ${file.path}`); } } else { - await adapter.install(files); - console.log(` \u2713 ${adapter.name} (${servers.length} servers)`); + await adapter.install(files, layer === "user" ? homedir() : process.cwd()); + console.log(` \u2713 ${adapter.name}`); } } @@ -272,6 +288,29 @@ async function cmdExport(flags: Flags): Promise { console.log(JSON.stringify([...allServers.values()], null, 2)); } +async function cmdAudit(flags: Flags): Promise { + const adapters = flags.tools + ? await resolveAdapters({ ...flags, force: true }) + : registry.getAll(); + + const findings = []; + for (const adapter of adapters) { + const servers = await adapter.importUser(); + findings.push(...auditServers(adapter.id, servers, "user")); + } + + if (findings.length === 0) { + console.log("No PalamHealth user-global MCP leaks found."); + return; + } + + console.log(`Found ${findings.length} MCP leak(s):\n`); + for (const finding of findings) { + console.log(` [${finding.kind}] ${finding.tool}/${finding.server}: ${finding.detail}`); + } + process.exit(1); +} + // ── Helpers ───────────────────────────────────────────────── function parseFlags(args: string[]): Flags { @@ -286,6 +325,9 @@ function parseFlags(args: string[]): Flags { flags.dryRun = true; } else if (arg === "--force") { flags.force = true; + } else if (arg.startsWith("--layer=")) { + const layer = arg.slice(8); + if (layer === "user" || layer === "project") flags.layer = layer; } } @@ -332,6 +374,32 @@ async function loadConfig(configPath?: string): Promise { return mod.default as MCPConfig; } +function selectServers(servers: MCPServerDefinition[], flags: Flags): MCPServerDefinition[] { + return filterServers(servers, { + layer: flags.layer ?? "project", + cwd: process.cwd(), + }); +} + +function requireConfiguredServers( + servers: MCPServerDefinition[], + mode: "sync" | "write", +): MCPServerDefinition[] { + if (servers.length > 0) return servers; + if (mode === "sync") { + console.error( + "No servers to sync from mcp.config.ts. Refusing to copy imported servers between tools.", + ); + } else { + console.error("No servers selected from mcp.config.ts. Refusing to write an empty MCP config."); + } + console.error( + "Add servers to the config (or pass --config) and use install/sync from that list.", + ); + process.exit(1); + return servers; +} + async function writeFiles(files: GeneratedFile[]): Promise { const { writeFile, mkdir } = await import("node:fs/promises"); const { dirname, resolve } = await import("node:path"); diff --git a/packages/mcp/src/config/define.test.ts b/packages/mcp/src/config/define.test.ts index c55749c..87e412a 100644 --- a/packages/mcp/src/config/define.test.ts +++ b/packages/mcp/src/config/define.test.ts @@ -33,6 +33,8 @@ describe("defineConfig", () => { transport: { type: "stdio", command: "node", args: ["server.js"], env: { KEY: "val" } }, enabled: true, tags: ["dev", "test"], + layer: "project", + whenPathContains: ["PalamHealth"], }, ], }; @@ -40,5 +42,7 @@ describe("defineConfig", () => { expect(result.servers[0]!.id).toBe("my-server"); expect(result.servers[0]!.description).toBe("A test server"); expect(result.servers[0]!.tags).toEqual(["dev", "test"]); + expect(result.servers[0]!.layer).toBe("project"); + expect(result.servers[0]!.whenPathContains).toEqual(["PalamHealth"]); }); }); diff --git a/packages/mcp/src/scope.test.ts b/packages/mcp/src/scope.test.ts new file mode 100644 index 0000000..9da780d --- /dev/null +++ b/packages/mcp/src/scope.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { filterServers } from "./scope.js"; +import type { MCPServerDefinition } from "./types/index.js"; + +const stdio = (id: string, extra: Partial = {}): MCPServerDefinition => ({ + id, + name: id, + transport: { type: "stdio", command: "npx" }, + ...extra, +}); + +describe("filterServers", () => { + it("defaults missing layer to project", () => { + const servers = [stdio("github")]; + expect( + filterServers(servers, { layer: "project", cwd: "/home/blitz/app" }).map((s) => s.id), + ).toEqual(["github"]); + expect(filterServers(servers, { layer: "user", cwd: "/home/blitz/app" })).toEqual([]); + }); + + it("keeps user-layer servers only for user installs", () => { + const servers = [stdio("unifi", { layer: "user" }), stdio("repo-lint", { layer: "project" })]; + expect(filterServers(servers, { layer: "user", cwd: "/tmp" }).map((s) => s.id)).toEqual([ + "unifi", + ]); + expect(filterServers(servers, { layer: "project", cwd: "/tmp" }).map((s) => s.id)).toEqual([ + "repo-lint", + ]); + }); + + it("restricts PalamHealth servers to matching project paths", () => { + const servers = [ + stdio("palamhealth", { layer: "project", whenPathContains: ["PalamHealth"] }), + stdio("outline", { layer: "project", whenPathContains: ["PalamHealth"] }), + stdio("github", { layer: "project" }), + ]; + const inPh = filterServers(servers, { + layer: "project", + cwd: "/home/blitz/Development/PalamHealth/PalamHealth", + }).map((s) => s.id); + expect(inPh).toEqual(["palamhealth", "outline", "github"]); + + const inPremier = filterServers(servers, { + layer: "project", + cwd: "/home/blitz/Development/PremierStudio/web", + }).map((s) => s.id); + expect(inPremier).toEqual(["github"]); + }); + + it("never installs path-gated servers into the user layer", () => { + const servers = [stdio("palamhealth", { layer: "project", whenPathContains: ["PalamHealth"] })]; + expect( + filterServers(servers, { + layer: "user", + cwd: "/home/blitz/Development/PalamHealth/PalamHealth", + }), + ).toEqual([]); + }); + + it("matches any fragment in whenPathContains", () => { + const servers = [ + stdio("ph", { layer: "project", whenPathContains: ["PalamHealth", "palamhealth"] }), + ]; + expect( + filterServers(servers, { layer: "project", cwd: "/tmp/palamhealth-scratch" }).map( + (s) => s.id, + ), + ).toEqual(["ph"]); + }); +}); diff --git a/packages/mcp/src/scope.ts b/packages/mcp/src/scope.ts new file mode 100644 index 0000000..eb32d4a --- /dev/null +++ b/packages/mcp/src/scope.ts @@ -0,0 +1,19 @@ +import type { MCPLayer, MCPServerDefinition } from "./types/index.js"; + +export type InstallContext = { + layer: MCPLayer; + cwd: string; +}; + +export function filterServers( + servers: MCPServerDefinition[], + ctx: InstallContext, +): MCPServerDefinition[] { + return servers.filter((server) => { + const layer = server.layer ?? "project"; + if (layer !== ctx.layer) return false; + const fragments = server.whenPathContains ?? []; + if (fragments.length === 0) return true; + return fragments.some((fragment) => ctx.cwd.includes(fragment)); + }); +} diff --git a/packages/mcp/src/toml-mcp.test.ts b/packages/mcp/src/toml-mcp.test.ts new file mode 100644 index 0000000..aedf4bf --- /dev/null +++ b/packages/mcp/src/toml-mcp.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { + encodeMcpToml, + mergeMcpToml, + parseMcpToml, + stripMcpTomlTables, + tomlKey, +} from "./toml-mcp.js"; +import type { MCPServerDefinition } from "./types/index.js"; + +const stdio: MCPServerDefinition = { + id: "context7", + name: "context7", + transport: { + type: "stdio", + command: "/home/blitz/.local/bin/op-mcp-run-grok", + args: ["npx", "-y", "@upstash/context7-mcp"], + env: { CONTEXT7_API_KEY: "op://mcp-secrets/Context7 API Key/password" }, + }, +}; + +const spaced: MCPServerDefinition = { + id: "GWS - Premier Studio", + name: "GWS - Premier Studio", + transport: { type: "stdio", command: "/home/blitz/.local/bin/gws-mcp-premier-studio" }, +}; + +const remote: MCPServerDefinition = { + id: "linear", + name: "linear", + transport: { + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token" }, + }, +}; + +describe("tomlKey", () => { + it("leaves bare keys unquoted", () => { + expect(tomlKey("palamhealth")).toBe("palamhealth"); + }); + + it("quotes names with spaces", () => { + expect(tomlKey("GWS - Premier Studio")).toBe('"GWS - Premier Studio"'); + }); +}); + +describe("encodeMcpToml / parseMcpToml", () => { + it("round-trips stdio servers with env tables", () => { + const encoded = encodeMcpToml([stdio]); + expect(encoded).toContain("[mcp_servers.context7]"); + expect(encoded).toContain("[mcp_servers.context7.env]"); + expect(encoded).toContain('command = "/home/blitz/.local/bin/op-mcp-run-grok"'); + expect(parseMcpToml(encoded)).toEqual([ + { + id: "context7", + name: "context7", + transport: stdio.transport, + enabled: undefined, + }, + ]); + }); + + it("round-trips quoted table names", () => { + const encoded = encodeMcpToml([spaced]); + expect(encoded).toContain('[mcp_servers."GWS - Premier Studio"]'); + expect(parseMcpToml(encoded)[0]?.id).toBe("GWS - Premier Studio"); + }); + + it("round-trips http servers with headers", () => { + const encoded = encodeMcpToml([remote]); + expect(encoded).toContain('url = "https://mcp.linear.app/mcp"'); + expect(parseMcpToml(encoded)[0]?.transport).toEqual(remote.transport); + }); + + it("parses inline env and headers tables from host files", () => { + const parsed = parseMcpToml(`[mcp_servers.filesystem] +command = "npx" +args = ["-y", "server"] +env = { API_KEY = "secret", OTHER = "x" } + +[mcp_servers.linear] +url = "https://mcp.linear.app/mcp" +headers = { "Authorization" = "Bearer token", "x-mcp-session-id" = "{{session_id}}" } +`); + expect(parsed.find((s) => s.id === "filesystem")?.transport).toEqual({ + type: "stdio", + command: "npx", + args: ["-y", "server"], + env: { API_KEY: "secret", OTHER: "x" }, + }); + expect(parsed.find((s) => s.id === "linear")?.transport).toEqual({ + type: "http", + url: "https://mcp.linear.app/mcp", + headers: { Authorization: "Bearer token", "x-mcp-session-id": "{{session_id}}" }, + }); + }); + + it("parses Codex http_headers as transport headers", () => { + const parsed = parseMcpToml(`[mcp_servers.figma] +url = "https://mcp.figma.com/mcp" +http_headers = { "X-Figma-Region" = "us-east-1" } +`); + expect(parsed[0]?.transport).toEqual({ + type: "http", + url: "https://mcp.figma.com/mcp", + headers: { "X-Figma-Region": "us-east-1" }, + }); + }); + + it("parses nested http_headers tables", () => { + const parsed = parseMcpToml(`[mcp_servers.figma] +url = "https://mcp.figma.com/mcp" + +[mcp_servers.figma.http_headers] +X-Figma-Region = "us-east-1" +`); + expect(parsed[0]?.transport).toEqual({ + type: "http", + url: "https://mcp.figma.com/mcp", + headers: { "X-Figma-Region": "us-east-1" }, + }); + }); + + it("parses empty inline env without treating it as a command env", () => { + const parsed = parseMcpToml(`[mcp_servers.x] +command = "x" +env = {} +`); + expect(parsed[0]?.transport).toEqual({ type: "stdio", command: "x" }); + }); + + it("encodes Codex HTTP remotes with http_headers tables", () => { + const encoded = encodeMcpToml([remote], { headerTable: "http_headers" }); + expect(encoded).toContain("[mcp_servers.linear.http_headers]"); + expect(encoded).toContain('Authorization = "Bearer token"'); + expect(parseMcpToml(encoded)[0]?.transport).toEqual(remote.transport); + }); + + it("encodes enabled = false", () => { + const encoded = encodeMcpToml([{ ...stdio, enabled: false }]); + expect(encoded).toContain("enabled = false"); + expect(parseMcpToml(encoded)[0]?.enabled).toBe(false); + }); + + it("returns empty string for no servers", () => { + expect(encodeMcpToml([])).toBe(""); + expect(parseMcpToml("")).toEqual([]); + }); +}); + +describe("mergeMcpToml", () => { + it("preserves unrelated tables and replaces managed MCP servers", () => { + const existing = `[ui] +yolo = false + +[mcp_servers.palamhealth] +command = "/old/palamhealth" + +[mcp_servers.unifi] +command = "/home/blitz/.local/bin/uvx" +`; + const merged = mergeMcpToml( + existing, + [{ id: "github", name: "github", transport: { type: "stdio", command: "gh-mcp" } }], + ["palamhealth", "github"], + ); + expect(merged).toContain("[ui]"); + expect(merged).toContain("yolo = false"); + expect(merged).toContain("[mcp_servers.unifi]"); + expect(merged).toContain("[mcp_servers.github]"); + expect(merged).not.toContain("palamhealth"); + }); + + it("strips mcp_servers tables from mixed files", () => { + const stripped = stripMcpTomlTables(`[ui]\nyolo = true\n\n[mcp_servers.x]\ncommand = "x"\n`); + expect(stripped).toContain("[ui]"); + expect(stripped).not.toContain("mcp_servers"); + }); + + it("re-encodes Codex merges with http_headers", () => { + const merged = mergeMcpToml('model = "gpt-5"\n', [remote], ["linear"], { + headerTable: "http_headers", + }); + expect(merged).toContain('model = "gpt-5"'); + expect(merged).toContain("[mcp_servers.linear.http_headers]"); + expect(merged).not.toContain("[mcp_servers.linear.headers]"); + }); +}); diff --git a/packages/mcp/src/toml-mcp.ts b/packages/mcp/src/toml-mcp.ts new file mode 100644 index 0000000..2b69bf8 --- /dev/null +++ b/packages/mcp/src/toml-mcp.ts @@ -0,0 +1,258 @@ +import type { MCPServerDefinition, MCPTransport } from "./types/index.js"; + +const BARE_KEY = /^[A-Za-z0-9_-]+$/; + +export function tomlKey(id: string): string { + if (BARE_KEY.test(id)) return id; + return `"${id.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +function tomlString(value: string): string { + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +function tomlArray(values: string[]): string { + return `[${values.map(tomlString).join(", ")}]`; +} + +function isUrlTransport( + transport: MCPTransport, +): transport is Extract { + return transport.type === "sse" || transport.type === "http"; +} + +export type EncodeMcpTomlOptions = { + /** Nested table name for HTTP headers. Grok uses `headers`; Codex uses `http_headers`. */ + headerTable?: "headers" | "http_headers"; +}; + +export function encodeMcpToml( + servers: MCPServerDefinition[], + options: EncodeMcpTomlOptions = {}, +): string { + const headerTable = options.headerTable ?? "headers"; + const chunks: string[] = []; + for (const server of servers) { + const key = tomlKey(server.id); + const lines: string[] = [`[mcp_servers.${key}]`]; + if (server.transport.type === "stdio") { + lines.push(`command = ${tomlString(server.transport.command)}`); + if (server.transport.args && server.transport.args.length > 0) { + lines.push(`args = ${tomlArray(server.transport.args)}`); + } + if (server.enabled === false) lines.push("enabled = false"); + const env = server.transport.env ?? {}; + const envKeys = Object.keys(env); + if (envKeys.length > 0) { + lines.push(""); + lines.push(`[mcp_servers.${key}.env]`); + for (const envKey of envKeys) { + const envValue = env[envKey]; + if (envValue === undefined) continue; + lines.push(`${tomlKey(envKey)} = ${tomlString(envValue)}`); + } + } + } else if (isUrlTransport(server.transport)) { + lines.push(`url = ${tomlString(server.transport.url)}`); + if (server.enabled === false) lines.push("enabled = false"); + const headers = server.transport.headers ?? {}; + const headerKeys = Object.keys(headers); + if (headerKeys.length > 0) { + lines.push(""); + lines.push(`[mcp_servers.${key}.${headerTable}]`); + for (const headerKey of headerKeys) { + const headerValue = headers[headerKey]; + if (headerValue === undefined) continue; + lines.push(`${tomlKey(headerKey)} = ${tomlString(headerValue)}`); + } + } + } + chunks.push(lines.join("\n")); + } + return chunks.length === 0 ? "" : `${chunks.join("\n\n")}\n`; +} + +type Table = Record; + +function unquoteTomlKey(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\"); + } + return trimmed; +} + +function parseTomlValue(raw: string): string | boolean | string[] { + const value = raw.trim(); + if (value === "true") return true; + if (value === "false") return false; + if (value.startsWith("[") && value.endsWith("]")) { + const inner = value.slice(1, -1).trim(); + if (inner === "") return []; + return inner.split(",").map((part) => { + const item = part.trim(); + if (item.startsWith('"') && item.endsWith('"')) { + return item.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\"); + } + return item; + }); + } + if (value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\"); + } + return value; +} + +const INLINE_PAIR = + /(?:("(?:\\.|[^"\\])*")|([A-Za-z0-9_-]+))\s*=\s*(("(?:\\.|[^"\\])*")|true|false|[^,}]+)/g; + +function parseInlineTable(raw: string): Record | undefined { + const value = raw.trim(); + if (!value.startsWith("{") || !value.endsWith("}")) return undefined; + const inner = value.slice(1, -1).trim(); + const out: Record = {}; + if (inner === "") return out; + for (const match of inner.matchAll(INLINE_PAIR)) { + const rawKey = match[1] ?? match[2]; + const rawVal = match[3]; + if (rawKey === undefined || rawVal === undefined) continue; + const parsed = parseTomlValue(rawVal.trim()); + if (typeof parsed === "string") out[unquoteTomlKey(rawKey)] = parsed; + } + return out; +} + +const TABLE_HEADER = /^\[([^\]]+)\]\s*$/; + +export function parseMcpToml(source: string): MCPServerDefinition[] { + const tables = new Map(); + let current: string | null = null; + + for (const rawLine of source.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line === "" || line.startsWith("#")) continue; + const header = TABLE_HEADER.exec(line); + if (header) { + current = header[1] ?? null; + if (current && current.startsWith("mcp_servers.") && !tables.has(current)) { + tables.set(current, {}); + } + continue; + } + if (!current || !current.startsWith("mcp_servers.")) continue; + const eq = line.indexOf("="); + if (eq === -1) continue; + const key = unquoteTomlKey(line.slice(0, eq)); + const rawValue = line.slice(eq + 1); + if (key === "env" || key === "headers" || key === "http_headers") { + const inline = parseInlineTable(rawValue); + if (inline) { + const nested = `${current}.${key}`; + const existing = tables.get(nested) ?? {}; + for (const [inlineKey, inlineValue] of Object.entries(inline)) { + existing[inlineKey] = inlineValue; + } + tables.set(nested, existing); + continue; + } + } + const value = parseTomlValue(rawValue); + const table = tables.get(current); + if (!table) continue; + table[key] = value; + } + + const servers: MCPServerDefinition[] = []; + for (const [header, table] of tables) { + const parts = splitHeader(header); + if (parts.length !== 2) continue; + const id = parts[1]; + if (id === undefined) continue; + const envTable = tables.get(`${header}.env`) ?? {}; + const headerTable = { + ...tables.get(`${header}.headers`), + ...tables.get(`${header}.http_headers`), + }; + const command = typeof table.command === "string" ? table.command : undefined; + const url = typeof table.url === "string" ? table.url : undefined; + const enabled = table.enabled === false ? false : undefined; + if (command) { + const args = Array.isArray(table.args) ? table.args : undefined; + const env = stringRecord(envTable); + const transport: Extract = { type: "stdio", command }; + if (args) transport.args = args; + if (Object.keys(env).length > 0) transport.env = env; + const def: MCPServerDefinition = { id, name: id, transport }; + if (enabled === false) def.enabled = false; + servers.push(def); + } else if (url) { + const headers = stringRecord(headerTable); + const transport: Extract = { type: "http", url }; + if (Object.keys(headers).length > 0) transport.headers = headers; + const def: MCPServerDefinition = { id, name: id, transport }; + if (enabled === false) def.enabled = false; + servers.push(def); + } + } + return servers; +} + +function splitHeader(header: string): string[] { + // mcp_servers.foo / mcp_servers."GWS - Premier Studio" / mcp_servers.foo.env + if (!header.startsWith("mcp_servers.")) return []; + const rest = header.slice("mcp_servers.".length); + if (rest.startsWith('"')) { + const end = rest.indexOf('"', 1); + if (end === -1) return ["mcp_servers", rest]; + const id = rest.slice(1, end); + const suffix = rest.slice(end + 1); + if (suffix.startsWith(".")) return ["mcp_servers", id, suffix.slice(1)]; + return ["mcp_servers", id]; + } + const dot = rest.indexOf("."); + if (dot === -1) return ["mcp_servers", rest]; + return ["mcp_servers", rest.slice(0, dot), rest.slice(dot + 1)]; +} + +function stringRecord(table: Table): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(table)) { + if (typeof value === "string") out[key] = value; + } + return out; +} + +export function stripMcpTomlTables(source: string): string { + const lines = source.split(/\r?\n/); + const kept: string[] = []; + let skipping = false; + for (const line of lines) { + const trimmed = line.trim(); + const header = TABLE_HEADER.exec(trimmed); + if (header) { + skipping = (header[1] ?? "").startsWith("mcp_servers"); + } + if (!skipping) kept.push(line); + } + return kept + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd(); +} + +export function mergeMcpToml( + existing: string, + servers: MCPServerDefinition[], + managedIds: string[], + options: EncodeMcpTomlOptions = {}, +): string { + const current = parseMcpToml(existing); + const managed = new Set(managedIds); + const kept = current.filter((server) => !managed.has(server.id)); + const next = [...kept, ...servers]; + const rest = stripMcpTomlTables(existing); + const encoded = encodeMcpToml(next, options); + if (rest.trim() === "") return encoded; + if (encoded === "") return `${rest}\n`; + return `${rest}\n\n${encoded}`; +} diff --git a/packages/mcp/src/types/config.ts b/packages/mcp/src/types/config.ts index d7f59f2..e544852 100644 --- a/packages/mcp/src/types/config.ts +++ b/packages/mcp/src/types/config.ts @@ -7,5 +7,5 @@ export type MCPConfig = { export type GeneratedFile = { path: string; content: string; - format: "json" | "yaml" | "jsonc"; + format: "json" | "yaml" | "jsonc" | "toml"; }; diff --git a/packages/mcp/src/types/definition.ts b/packages/mcp/src/types/definition.ts index e336fe3..4b3ea1e 100644 --- a/packages/mcp/src/types/definition.ts +++ b/packages/mcp/src/types/definition.ts @@ -1,6 +1,9 @@ export type MCPTransport = | { type: "stdio"; command: string; args?: string[]; env?: Record } - | { type: "sse"; url: string; headers?: Record }; + | { type: "sse"; url: string; headers?: Record } + | { type: "http"; url: string; headers?: Record }; + +export type MCPLayer = "user" | "project"; export type MCPServerDefinition = { id: string; @@ -9,4 +12,8 @@ export type MCPServerDefinition = { transport: MCPTransport; enabled?: boolean; tags?: string[]; + /** Where this server is installed. Missing layer means project. */ + layer?: MCPLayer; + /** Project-layer servers install only when cwd contains one of these fragments. */ + whenPathContains?: string[]; }; diff --git a/packages/mcp/src/types/index.ts b/packages/mcp/src/types/index.ts index d4014a8..e78aabe 100644 --- a/packages/mcp/src/types/index.ts +++ b/packages/mcp/src/types/index.ts @@ -1,2 +1,2 @@ -export type { MCPTransport, MCPServerDefinition } from "./definition.js"; +export type { MCPTransport, MCPServerDefinition, MCPLayer } from "./definition.js"; export type { MCPConfig, GeneratedFile } from "./config.js"; diff --git a/packages/rules/src/adapters/all.ts b/packages/rules/src/adapters/all.ts index 8a4d428..136ad96 100644 --- a/packages/rules/src/adapters/all.ts +++ b/packages/rules/src/adapters/all.ts @@ -4,6 +4,7 @@ import "./cursor.js"; import "./cline.js"; import "./codex.js"; import "./gemini-cli.js"; +import "./grok.js"; import "./amp.js"; import "./kiro.js"; import "./opencode.js"; @@ -12,3 +13,4 @@ import "./copilot.js"; import "./windsurf.js"; import "./roo-code.js"; import "./continue.js"; +import "./zcode.js"; diff --git a/packages/rules/src/adapters/cursor.test.ts b/packages/rules/src/adapters/cursor.test.ts index 9877457..0d22fed 100644 --- a/packages/rules/src/adapters/cursor.test.ts +++ b/packages/rules/src/adapters/cursor.test.ts @@ -1,26 +1,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; vi.mock("./registry.js", () => ({ registry: { register: vi.fn() }, })); -vi.mock("node:fs", () => ({ - existsSync: vi.fn(() => false), -})); - -vi.mock("node:fs/promises", () => ({ - readFile: vi.fn(), - readdir: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), - rm: vi.fn(), -})); - -import { existsSync } from "node:fs"; -import { readFile, readdir } from "node:fs/promises"; import { CursorRuleAdapter } from "./cursor.js"; import type { RuleDefinition } from "../types/index.js"; +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), "ai-tools-rules-cursor-")); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + describe("CursorRuleAdapter", () => { let adapter: CursorRuleAdapter; @@ -49,7 +48,6 @@ describe("CursorRuleAdapter", () => { }; beforeEach(() => { - vi.clearAllMocks(); adapter = new CursorRuleAdapter(); }); @@ -61,28 +59,28 @@ describe("CursorRuleAdapter", () => { }); describe("generate", () => { - it("uses subdirectory pattern with RULE.md", async () => { + it("uses flat *.mdc files Cursor actually loads", async () => { const files = await adapter.generate([testRule]); expect(files).toHaveLength(1); - expect(files[0].path).toBe(".cursor/rules/typescript/RULE.md"); - expect(files[0].format).toBe("md"); + expect(files[0]?.path).toBe(".cursor/rules/typescript.mdc"); + expect(files[0]?.format).toBe("md"); }); it("includes frontmatter with alwaysApply false for glob scope", async () => { const files = await adapter.generate([testRule]); - expect(files[0].content).toContain("alwaysApply: false"); - expect(files[0].content).toContain('- "*.ts"'); + expect(files[0]?.content).toContain("alwaysApply: false"); + expect(files[0]?.content).toContain('- "*.ts"'); }); it("includes alwaysApply true for always scope", async () => { const files = await adapter.generate([alwaysRule]); - expect(files[0].content).toContain("alwaysApply: true"); + expect(files[0]?.content).toContain("alwaysApply: true"); }); it("includes alwaysApply false for manual scope", async () => { const files = await adapter.generate([manualRule]); - expect(files[0].content).toContain("alwaysApply: false"); - expect(files[0].content).not.toContain("globs:"); + expect(files[0]?.content).toContain("alwaysApply: false"); + expect(files[0]?.content).not.toContain("globs:"); }); it("generates rule without description", async () => { @@ -93,9 +91,9 @@ describe("CursorRuleAdapter", () => { scope: { type: "always" }, }; const files = await adapter.generate([noDescRule]); - expect(files[0].content).not.toContain("description:"); - expect(files[0].content).toContain("alwaysApply: true"); - expect(files[0].content).toContain("Content only."); + expect(files[0]?.content).not.toContain("description:"); + expect(files[0]?.content).toContain("alwaysApply: true"); + expect(files[0]?.content).toContain("Content only."); }); it("handles empty rules array", async () => { @@ -103,90 +101,124 @@ describe("CursorRuleAdapter", () => { expect(files).toHaveLength(0); }); - it("generates multiple files for multiple rules", async () => { + it("generates multiple flat mdc files for multiple rules", async () => { const files = await adapter.generate([testRule, alwaysRule]); expect(files).toHaveLength(2); - expect(files[0].path).toBe(".cursor/rules/typescript/RULE.md"); - expect(files[1].path).toBe(".cursor/rules/general/RULE.md"); + expect(files[0]?.path).toBe(".cursor/rules/typescript.mdc"); + expect(files[1]?.path).toBe(".cursor/rules/general.mdc"); + }); + }); + + describe("install", () => { + it("writes flat .mdc files, not nested RULE.md", async () => { + await withTempDir(async (dir) => { + const files = await adapter.generate([testRule, alwaysRule]); + await adapter.install(files, dir); + + const mdcPath = join(dir, ".cursor/rules/typescript.mdc"); + expect(existsSync(mdcPath)).toBe(true); + expect(existsSync(join(dir, ".cursor/rules/general.mdc"))).toBe(true); + expect(existsSync(join(dir, ".cursor/rules/typescript/RULE.md"))).toBe(false); + expect(await readFile(mdcPath, "utf-8")).toContain("alwaysApply: false"); + }); }); }); describe("import", () => { it("returns empty when dir missing", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const result = await adapter.import("/test"); - expect(result).toEqual([]); + await withTempDir(async (dir) => { + expect(await adapter.import(join(dir, "missing"))).toEqual([]); + }); }); - it("imports rules from subdirectories", async () => { - vi.mocked(existsSync).mockImplementation((p) => { - const path = String(p); - return path.includes(".cursor/rules") || path.includes("RULE.md"); + it("imports rules from flat .mdc files", async () => { + await withTempDir(async (dir) => { + await mkdir(join(dir, ".cursor/rules"), { recursive: true }); + await writeFile( + join(dir, ".cursor/rules/typescript.mdc"), + '---\ndescription: TS rules\nalwaysApply: false\nglobs:\n - "*.ts"\n---\n\nUse strict TS.', + ); + + const result = await adapter.import(dir); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe("typescript"); + expect(result[0]?.description).toBe("TS rules"); + expect(result[0]?.scope).toEqual({ type: "glob", patterns: ["*.ts"] }); }); - vi.mocked(readdir).mockResolvedValue(["typescript"] as never); - vi.mocked(readFile).mockResolvedValue( - '---\ndescription: TS rules\nalwaysApply: false\nglobs:\n - "*.ts"\n---\n\nUse strict TS.', - ); - - const result = await adapter.import("/test"); - expect(result).toHaveLength(1); - expect(result[0].id).toBe("typescript"); - expect(result[0].description).toBe("TS rules"); - expect(result[0].scope).toEqual({ type: "glob", patterns: ["*.ts"] }); }); it("parses alwaysApply true as always scope", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["general"] as never); - vi.mocked(readFile).mockResolvedValue( - "---\ndescription: General\nalwaysApply: true\n---\n\nBe concise.", - ); - - const result = await adapter.import("/test"); - expect(result).toHaveLength(1); - expect(result[0].scope).toEqual({ type: "always" }); + await withTempDir(async (dir) => { + await mkdir(join(dir, ".cursor/rules"), { recursive: true }); + await writeFile( + join(dir, ".cursor/rules/general.mdc"), + "---\ndescription: General\nalwaysApply: true\n---\n\nBe concise.", + ); + + const result = await adapter.import(dir); + expect(result).toHaveLength(1); + expect(result[0]?.scope).toEqual({ type: "always" }); + }); }); it("parses alwaysApply false without globs as manual scope", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["manual"] as never); - vi.mocked(readFile).mockResolvedValue( - "---\ndescription: Manual\nalwaysApply: false\n---\n\nManual rule.", - ); - - const result = await adapter.import("/test"); - expect(result).toHaveLength(1); - expect(result[0].scope).toEqual({ type: "manual" }); + await withTempDir(async (dir) => { + await mkdir(join(dir, ".cursor/rules"), { recursive: true }); + await writeFile( + join(dir, ".cursor/rules/manual.mdc"), + "---\ndescription: Manual\nalwaysApply: false\n---\n\nManual rule.", + ); + + const result = await adapter.import(dir); + expect(result).toHaveLength(1); + expect(result[0]?.scope).toEqual({ type: "manual" }); + }); }); it("uses process.cwd() when cwd is not provided", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const result = await adapter.import(); - expect(result).toEqual([]); + await withTempDir(async (dir) => { + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir); + try { + expect(await adapter.import()).toEqual([]); + } finally { + cwdSpy.mockRestore(); + } + }); }); it("imports rule without frontmatter", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["plain"] as never); - vi.mocked(readFile).mockResolvedValue("Just plain content."); - - const result = await adapter.import("/test"); - expect(result).toHaveLength(1); - expect(result[0].id).toBe("plain"); - expect(result[0].content).toBe(""); - expect(result[0].scope).toEqual({ type: "always" }); - }); - - it("skips entries without RULE.md", async () => { - vi.mocked(existsSync).mockImplementation((p) => { - const path = String(p); - if (path.includes("RULE.md")) return false; - return path.includes(".cursor/rules"); + await withTempDir(async (dir) => { + await mkdir(join(dir, ".cursor/rules"), { recursive: true }); + await writeFile(join(dir, ".cursor/rules/plain.mdc"), "Just plain content."); + + const result = await adapter.import(dir); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe("plain"); + expect(result[0]?.content).toBe(""); + expect(result[0]?.scope).toEqual({ type: "always" }); + }); + }); + + it("ignores unclosed frontmatter", async () => { + await withTempDir(async (dir) => { + await mkdir(join(dir, ".cursor/rules"), { recursive: true }); + await writeFile(join(dir, ".cursor/rules/broken.mdc"), "---\ndescription: no closer\n"); + + const result = await adapter.import(dir); + expect(result[0]?.id).toBe("broken"); + expect(result[0]?.content).toBe(""); }); - vi.mocked(readdir).mockResolvedValue(["norule"] as never); + }); + + it("skips non-mdc entries including nested RULE.md leftovers", async () => { + await withTempDir(async (dir) => { + await mkdir(join(dir, ".cursor/rules/norule"), { recursive: true }); + await writeFile(join(dir, ".cursor/rules/norule/RULE.md"), "ignored"); + await writeFile(join(dir, ".cursor/rules/readme.md"), "ignored markdown"); - const result = await adapter.import("/test"); - expect(result).toEqual([]); + const result = await adapter.import(dir); + expect(result).toEqual([]); + }); }); }); }); diff --git a/packages/rules/src/adapters/cursor.ts b/packages/rules/src/adapters/cursor.ts index e64ab43..aa7cc0f 100644 --- a/packages/rules/src/adapters/cursor.ts +++ b/packages/rules/src/adapters/cursor.ts @@ -3,7 +3,7 @@ import { registry } from "./registry.js"; import type { RuleDefinition, GeneratedFile } from "../types/index.js"; import { existsSync } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; -import { resolve } from "node:path"; +import { basename, resolve } from "node:path"; export class CursorRuleAdapter extends BaseRuleAdapter { readonly id = "cursor"; @@ -14,7 +14,7 @@ export class CursorRuleAdapter extends BaseRuleAdapter { async generate(rules: RuleDefinition[]): Promise { return rules.map((rule) => ({ - path: `${this.configDir}/${rule.id}/RULE.md`, + path: `${this.configDir}/${rule.id}.mdc`, content: this.formatRule(rule), format: "md" as const, })); @@ -29,10 +29,9 @@ export class CursorRuleAdapter extends BaseRuleAdapter { const rules: RuleDefinition[] = []; for (const entry of entries) { - const rulePath = resolve(rulesDir, entry, "RULE.md"); - if (!existsSync(rulePath)) continue; - const content = await readFile(rulePath, "utf-8"); - rules.push(this.parseRule(entry, content)); + if (!entry.endsWith(".mdc")) continue; + const content = await readFile(resolve(rulesDir, entry), "utf-8"); + rules.push(this.parseRule(basename(entry, ".mdc"), content)); } return rules; diff --git a/packages/rules/src/adapters/grok.test.ts b/packages/rules/src/adapters/grok.test.ts new file mode 100644 index 0000000..1e8610e --- /dev/null +++ b/packages/rules/src/adapters/grok.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("./registry.js", () => ({ registry: { register: vi.fn() } })); +vi.mock("node:fs", () => ({ existsSync: vi.fn(() => false) })); +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn(), + readdir: vi.fn(), +})); + +import { GrokRuleAdapter } from "./grok.js"; + +describe("GrokRuleAdapter", () => { + it("writes markdown rules under .grok/rules", async () => { + const adapter = new GrokRuleAdapter(); + expect(adapter.id).toBe("grok"); + expect(adapter.configDir).toBe(".grok/rules"); + const files = await adapter.generate([ + { id: "ts-strict", name: "TypeScript strict", content: "No any.", scope: { type: "always" } }, + ]); + expect(files[0]?.path).toBe(".grok/rules/ts-strict.md"); + expect(files[0]?.content).toBe("# TypeScript strict\n\nNo any.\n"); + }); +}); diff --git a/packages/rules/src/adapters/grok.ts b/packages/rules/src/adapters/grok.ts new file mode 100644 index 0000000..55f7fb8 --- /dev/null +++ b/packages/rules/src/adapters/grok.ts @@ -0,0 +1,14 @@ +import { SimpleMarkdownRuleAdapter } from "./simple-adapter.js"; +import { registry } from "./registry.js"; + +export class GrokRuleAdapter extends SimpleMarkdownRuleAdapter { + readonly id = "grok"; + readonly name = "Grok"; + readonly nativeSupport = true; + readonly configDir = ".grok/rules"; + readonly command = "grok"; +} + +const adapter = new GrokRuleAdapter(); +registry.register(adapter); +export default adapter; diff --git a/packages/rules/src/adapters/zcode.test.ts b/packages/rules/src/adapters/zcode.test.ts new file mode 100644 index 0000000..ced7840 --- /dev/null +++ b/packages/rules/src/adapters/zcode.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("./registry.js", () => ({ registry: { register: vi.fn() } })); +vi.mock("node:fs", () => ({ existsSync: vi.fn(() => false) })); +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn(), + readdir: vi.fn(), +})); + +import { ZcodeRuleAdapter } from "./zcode.js"; + +describe("ZcodeRuleAdapter", () => { + it("writes markdown rules under .zcode/rules", async () => { + const adapter = new ZcodeRuleAdapter(); + expect(adapter.id).toBe("zcode"); + expect(adapter.configDir).toBe(".zcode/rules"); + const files = await adapter.generate([ + { id: "style", name: "Style", content: "Be terse.", scope: { type: "always" } }, + ]); + expect(files[0]?.path).toBe(".zcode/rules/style.md"); + }); +}); diff --git a/packages/rules/src/adapters/zcode.ts b/packages/rules/src/adapters/zcode.ts new file mode 100644 index 0000000..d029501 --- /dev/null +++ b/packages/rules/src/adapters/zcode.ts @@ -0,0 +1,14 @@ +import { SimpleMarkdownRuleAdapter } from "./simple-adapter.js"; +import { registry } from "./registry.js"; + +export class ZcodeRuleAdapter extends SimpleMarkdownRuleAdapter { + readonly id = "zcode"; + readonly name = "ZCode"; + readonly nativeSupport = true; + readonly configDir = ".zcode/rules"; + readonly command = "zcode"; +} + +const adapter = new ZcodeRuleAdapter(); +registry.register(adapter); +export default adapter; diff --git a/packages/rules/src/cli/index.test.ts b/packages/rules/src/cli/index.test.ts index d638613..b3b974c 100644 --- a/packages/rules/src/cli/index.test.ts +++ b/packages/rules/src/cli/index.test.ts @@ -27,6 +27,10 @@ vi.mock("node:fs/promises", () => ({ mkdir: (...args: unknown[]) => mockMkdir(...args), })); +vi.mock("node:fs", () => ({ + existsSync: () => false, +})); + import { run } from "./index.js"; function makeAdapter(overrides: Partial = {}): BaseRuleAdapter { diff --git a/packages/sessions/package.json b/packages/sessions/package.json index 9ed666d..585901b 100644 --- a/packages/sessions/package.json +++ b/packages/sessions/package.json @@ -47,9 +47,6 @@ "test": "vitest run", "clean": "node -e \"const { rmSync } = require('node:fs'); rmSync('dist', { recursive: true, force: true });\"" }, - "dependencies": { - "yaml": "^2.9.0" - }, "engines": { "node": ">=22.0.0" } diff --git a/packages/sessions/src/cli/index.test.ts b/packages/sessions/src/cli/index.test.ts index 24eac97..5af7ce9 100644 --- a/packages/sessions/src/cli/index.test.ts +++ b/packages/sessions/src/cli/index.test.ts @@ -126,6 +126,22 @@ describe("run() - help output", () => { await run([]); expect(allLog()).toContain("ai-sessions"); }); + + it("lists only implemented commands and options", async () => { + await run(["help"]); + const output = allLog(); + expect(output).toContain("detect"); + expect(output).toContain("list"); + expect(output).toContain("context"); + expect(output).toContain("handoff"); + expect(output).toContain("scan"); + expect(output).toContain("--tool"); + expect(output).toContain("--limit"); + expect(output).toContain("--since"); + expect(output).toContain("--to"); + expect(output).not.toContain("--verbose"); + expect(output).not.toContain("canonical"); + }); }); // -- Unknown command -- diff --git a/packages/sessions/src/cli/index.ts b/packages/sessions/src/cli/index.ts index e85e5da..93b4006 100644 --- a/packages/sessions/src/cli/index.ts +++ b/packages/sessions/src/cli/index.ts @@ -24,7 +24,6 @@ OPTIONS: --limit Limit number of results (e.g., --limit=10) --since Filter by date (e.g., --since=2025-01-01) --to Target tool for handoff (e.g., --to=codex) - --verbose Show detailed output EXAMPLES: ai-sessions detect # See which AI tools have sessions @@ -41,7 +40,6 @@ type Flags = { limit?: number; since?: string; to?: string; - verbose?: boolean; }; export async function run(args: string[]): Promise { @@ -237,8 +235,6 @@ function parseFlags(args: string[]): Flags { flags.since = arg.slice(8); } else if (arg.startsWith("--to=")) { flags.to = arg.slice(5); - } else if (arg === "--verbose") { - flags.verbose = true; } } diff --git a/packages/sessions/src/config/define.ts b/packages/sessions/src/config/define.ts deleted file mode 100644 index 4c16ac0..0000000 --- a/packages/sessions/src/config/define.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { SessionsConfig } from "../types/index.js"; - -export function defineConfig(config: SessionsConfig): SessionsConfig { - return config; -} diff --git a/packages/sessions/src/config/index.ts b/packages/sessions/src/config/index.ts deleted file mode 100644 index ee71dfd..0000000 --- a/packages/sessions/src/config/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { defineConfig } from "./define.js"; diff --git a/packages/sessions/src/index.ts b/packages/sessions/src/index.ts index 043fe81..fd31168 100644 --- a/packages/sessions/src/index.ts +++ b/packages/sessions/src/index.ts @@ -3,9 +3,6 @@ export type { UnifiedSession, SessionContext, SessionFilter, - SessionsConfig, - GeneratedFile, } from "./types/index.js"; -export { defineConfig } from "./config/index.js"; export { BaseSessionAdapter } from "./adapters/base.js"; export { registry } from "./adapters/registry.js"; diff --git a/packages/sessions/src/types/config.ts b/packages/sessions/src/types/config.ts deleted file mode 100644 index f091693..0000000 --- a/packages/sessions/src/types/config.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type SessionsConfig = { - cachePath?: string; - maxAge?: number; - tools?: string[]; -}; - -export type GeneratedFile = { - path: string; - content: string; - format: string; -}; diff --git a/packages/sessions/src/types/index.ts b/packages/sessions/src/types/index.ts index 6e715a2..e2c6f32 100644 --- a/packages/sessions/src/types/index.ts +++ b/packages/sessions/src/types/index.ts @@ -4,4 +4,3 @@ export type { SessionContext, SessionFilter, } from "./definition.js"; -export type { SessionsConfig, GeneratedFile } from "./config.js"; diff --git a/packages/sessions/src/utils/slug.test.ts b/packages/sessions/src/utils/slug.test.ts deleted file mode 100644 index fe5e531..0000000 --- a/packages/sessions/src/utils/slug.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import { slugify, sessionSlug } from "./slug.js"; - -describe("slugify", () => { - it("converts to lowercase", () => { - expect(slugify("Hello World")).toBe("hello-world"); - }); - - it("replaces spaces with hyphens", () => { - expect(slugify("foo bar baz")).toBe("foo-bar-baz"); - }); - - it("removes special characters", () => { - expect(slugify("hello@world!")).toBe("helloworld"); - }); - - it("replaces underscores with hyphens", () => { - expect(slugify("hello_world")).toBe("hello-world"); - }); - - it("trims leading and trailing hyphens", () => { - expect(slugify("--hello--")).toBe("hello"); - }); - - it("trims whitespace", () => { - expect(slugify(" hello ")).toBe("hello"); - }); - - it("handles empty string", () => { - expect(slugify("")).toBe(""); - }); - - it("handles path-like strings", () => { - expect(slugify("my-project")).toBe("my-project"); - }); -}); - -describe("sessionSlug", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("includes tool name", () => { - vi.spyOn(Date, "now").mockReturnValue(1000); - const slug = sessionSlug("claude"); - expect(slug).toMatch(/^claude-/); - }); - - it("includes slugified project path", () => { - vi.spyOn(Date, "now").mockReturnValue(1000); - const slug = sessionSlug("claude", "/home/user/my-project"); - expect(slug).toContain("my-project"); - expect(slug).toMatch(/^claude-my-project-/); - }); - - it("includes timestamp component", () => { - vi.spyOn(Date, "now").mockReturnValue(1000); - const slug = sessionSlug("codex"); - expect(slug).toBe(`codex-${(1000).toString(36)}`); - }); - - it("handles project paths with special characters", () => { - vi.spyOn(Date, "now").mockReturnValue(1000); - const slug = sessionSlug("gemini", "/home/user/My Project!"); - expect(slug).toContain("my-project"); - }); -}); diff --git a/packages/sessions/src/utils/slug.ts b/packages/sessions/src/utils/slug.ts deleted file mode 100644 index 0e83456..0000000 --- a/packages/sessions/src/utils/slug.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Generate a URL-safe slug from a string. - */ -export function slugify(input: string): string { - return input - .toLowerCase() - .trim() - .replace(/[^\w\s-]/g, "") - .replace(/[\s_]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - -/** - * Generate a session slug from tool + project path. - */ -export function sessionSlug(tool: string, projectPath?: string): string { - const parts = [tool]; - if (projectPath) { - parts.push(slugify(projectPath.replace(/^.*\//, ""))); - } - parts.push(Date.now().toString(36)); - return parts.join("-"); -} diff --git a/packages/skills/src/adapters/all.ts b/packages/skills/src/adapters/all.ts index 8a4d428..136ad96 100644 --- a/packages/skills/src/adapters/all.ts +++ b/packages/skills/src/adapters/all.ts @@ -4,6 +4,7 @@ import "./cursor.js"; import "./cline.js"; import "./codex.js"; import "./gemini-cli.js"; +import "./grok.js"; import "./amp.js"; import "./kiro.js"; import "./opencode.js"; @@ -12,3 +13,4 @@ import "./copilot.js"; import "./windsurf.js"; import "./roo-code.js"; import "./continue.js"; +import "./zcode.js"; diff --git a/packages/skills/src/adapters/amp.test.ts b/packages/skills/src/adapters/amp.test.ts index 7813310..3f9c1dc 100644 --- a/packages/skills/src/adapters/amp.test.ts +++ b/packages/skills/src/adapters/amp.test.ts @@ -41,7 +41,10 @@ describe("AmpSkillAdapter", () => { it("has correct id", () => expect(adapter.id).toBe("amp")); it("has correct name", () => expect(adapter.name).toBe("Amp")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); - it("has correct config dir", () => expect(adapter.configDir).toBe(".amp/prompts")); + it("has correct config dir", () => { + // Amp has no SKILL.md loader here (no ~/.amp/skills). Native format is `.amp/prompts/*.md`. + expect(adapter.configDir).toBe(".amp/prompts"); + }); }); describe("generate", () => { diff --git a/packages/skills/src/adapters/claude-code.test.ts b/packages/skills/src/adapters/claude-code.test.ts index 3526870..18279b6 100644 --- a/packages/skills/src/adapters/claude-code.test.ts +++ b/packages/skills/src/adapters/claude-code.test.ts @@ -1,26 +1,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; vi.mock("./registry.js", () => ({ registry: { register: vi.fn() }, })); -vi.mock("node:fs", () => ({ - existsSync: vi.fn(() => false), -})); - -vi.mock("node:fs/promises", () => ({ - readFile: vi.fn(), - readdir: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), - rm: vi.fn(), -})); - -import { existsSync } from "node:fs"; -import { readFile, readdir } from "node:fs/promises"; import { ClaudeCodeSkillAdapter } from "./claude-code.js"; import type { SkillDefinition } from "../types/index.js"; +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), "ai-tools-skills-claude-")); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + describe("ClaudeCodeSkillAdapter", () => { let adapter: ClaudeCodeSkillAdapter; @@ -33,7 +32,6 @@ describe("ClaudeCodeSkillAdapter", () => { }; beforeEach(() => { - vi.clearAllMocks(); adapter = new ClaudeCodeSkillAdapter(); }); @@ -41,21 +39,21 @@ describe("ClaudeCodeSkillAdapter", () => { it("has correct id", () => expect(adapter.id).toBe("claude-code")); it("has correct name", () => expect(adapter.name).toBe("Claude Code")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); - it("has correct config dir", () => expect(adapter.configDir).toBe(".claude/commands")); + it("has correct config dir", () => expect(adapter.configDir).toBe(".claude/skills")); }); describe("generate", () => { - it("generates one file per skill", async () => { + it("writes SKILL.md under .claude/skills//", async () => { const files = await adapter.generate([testSkill]); expect(files).toHaveLength(1); - expect(files[0]?.path).toBe(".claude/commands/review.md"); + expect(files[0]?.path).toBe(".claude/skills/review/SKILL.md"); expect(files[0]?.format).toBe("md"); }); - it("formats skill with name heading and content", async () => { + it("formats Agent Skills frontmatter plus body", async () => { const files = await adapter.generate([testSkill]); - expect(files[0]?.content).toContain("# Code Review"); - expect(files[0]?.content).toContain("Review code for best practices"); + expect(files[0]?.content).toContain("name: review"); + expect(files[0]?.content).toContain("description: Review code for best practices"); expect(files[0]?.content).toContain("Security issues"); }); @@ -68,62 +66,103 @@ describe("ClaudeCodeSkillAdapter", () => { const skills = [testSkill, { ...testSkill, id: "debug", name: "Debug" }]; const files = await adapter.generate(skills); expect(files).toHaveLength(2); + expect(files[1]?.path).toBe(".claude/skills/debug/SKILL.md"); }); - it("handles skill without description", async () => { + it("falls back to skill name when description is missing", async () => { const skill: SkillDefinition = { id: "test", name: "Test", content: "Content here" }; const files = await adapter.generate([skill]); - expect(files[0]?.content).toBe("# Test\n\nContent here\n"); + expect(files[0]?.content).toBe("---\nname: test\ndescription: Test\n---\n\nContent here\n"); + }); + }); + + describe("install", () => { + it("writes SKILL.md into a temp project, not commands/", async () => { + await withTempDir(async (dir) => { + const files = await adapter.generate([testSkill]); + await adapter.install(files, dir); + + const skillPath = join(dir, ".claude/skills/review/SKILL.md"); + expect(existsSync(skillPath)).toBe(true); + expect(existsSync(join(dir, ".claude/commands/review.md"))).toBe(false); + expect(await readFile(skillPath, "utf-8")).toContain("name: review"); + }); }); }); describe("import", () => { it("returns empty array when dir does not exist", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const result = await adapter.import("/test"); - expect(result).toEqual([]); + await withTempDir(async (dir) => { + expect(await adapter.import(join(dir, "missing"))).toEqual([]); + }); }); - it("imports skills from markdown files", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["review.md"] as never); - vi.mocked(readFile).mockResolvedValue("# Code Review\n\nReview the code"); - const result = await adapter.import("/test"); - expect(result).toHaveLength(1); - expect(result[0]?.id).toBe("review"); - expect(result[0]?.name).toBe("Code Review"); + it("imports SKILL.md directories Claude actually loads", async () => { + await withTempDir(async (dir) => { + const skillDir = join(dir, ".claude/skills/review"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + "---\nname: review\ndescription: Review the code\n---\n\nReview the code\n", + ); + + const result = await adapter.import(dir); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe("review"); + expect(result[0]?.name).toBe("review"); + }); }); - it("skips non-markdown files", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["readme.txt", "skill.md"] as never); - vi.mocked(readFile).mockResolvedValue("# Skill\n\nContent"); - const result = await adapter.import("/test"); - expect(result).toHaveLength(1); + it("skips files and folders without SKILL.md", async () => { + await withTempDir(async (dir) => { + const skillsDir = join(dir, ".claude/skills"); + await mkdir(join(skillsDir, "empty"), { recursive: true }); + await writeFile(join(skillsDir, "readme.txt"), "not a skill"); + await mkdir(join(skillsDir, "skill"), { recursive: true }); + await writeFile(join(skillsDir, "skill", "SKILL.md"), "# Skill\n\nContent"); + + const result = await adapter.import(dir); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe("skill"); + }); }); - it("imports skill without heading", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["plain.md"] as never); - vi.mocked(readFile).mockResolvedValue("Just content, no heading"); - const result = await adapter.import("/test"); - expect(result[0]?.name).toBe("plain"); - expect(result[0]?.content).toBe("Just content, no heading"); + it("imports skill without frontmatter", async () => { + await withTempDir(async (dir) => { + const skillDir = join(dir, ".claude/skills/plain"); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), "Just content, no heading"); + + const result = await adapter.import(dir); + expect(result[0]?.name).toBe("plain"); + expect(result[0]?.content).toBe("Just content, no heading"); + }); }); - it("imports skill with heading but no blank line after", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["test.md"] as never); - vi.mocked(readFile).mockResolvedValue("# My Skill\nDirect content"); - const result = await adapter.import("/test"); - expect(result[0]?.name).toBe("My Skill"); - expect(result[0]?.content).toBe("Direct content"); + it("ignores frontmatter lines without a colon", async () => { + await withTempDir(async (dir) => { + const skillDir = join(dir, ".claude/skills/odd"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + "---\nname: odd\nnot-a-pair\ndescription: Odd skill\n---\n\nBody\n", + ); + + const result = await adapter.import(dir); + expect(result[0]?.name).toBe("odd"); + expect(result[0]?.description).toBe("Odd skill"); + }); }); - it("imports without cwd argument", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const result = await adapter.import(); - expect(result).toEqual([]); + it("imports without cwd argument using process.cwd()", async () => { + await withTempDir(async (dir) => { + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir); + try { + expect(await adapter.import()).toEqual([]); + } finally { + cwdSpy.mockRestore(); + } + }); }); }); }); diff --git a/packages/skills/src/adapters/claude-code.ts b/packages/skills/src/adapters/claude-code.ts index 844cc84..e0c95e8 100644 --- a/packages/skills/src/adapters/claude-code.ts +++ b/packages/skills/src/adapters/claude-code.ts @@ -3,62 +3,67 @@ import { registry } from "./registry.js"; import type { SkillDefinition, GeneratedFile } from "../types/index.js"; import { existsSync } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; -import { resolve, basename } from "node:path"; +import { resolve } from "node:path"; class ClaudeCodeSkillAdapter extends BaseSkillAdapter { readonly id = "claude-code"; readonly name = "Claude Code"; readonly nativeSupport = true; - readonly configDir = ".claude/commands"; + readonly configDir = ".claude/skills"; readonly command = "claude"; async generate(skills: SkillDefinition[]): Promise { return skills.map((skill) => ({ - path: `${this.configDir}/${skill.id}.md`, - content: this.formatSkill(skill), + path: `${this.configDir}/${skill.id}/SKILL.md`, + content: formatSkill(skill), format: "md" as const, })); } async import(cwd?: string): Promise { - const dir = cwd ?? process.cwd(); - const skillsDir = resolve(dir, this.configDir); - if (!existsSync(skillsDir)) return []; - - const files = await readdir(skillsDir); - const skills: SkillDefinition[] = []; + return importSkillMd(this.configDir, cwd); + } +} - for (const file of files) { - if (!file.endsWith(".md")) continue; - const content = await readFile(resolve(skillsDir, file), "utf-8"); - const id = basename(file, ".md"); - skills.push(this.parseSkill(id, content)); - } +function formatSkill(skill: SkillDefinition): string { + const description = skill.description ?? skill.name; + return `---\nname: ${skill.id}\ndescription: ${description}\n---\n\n${skill.content}\n`; +} - return skills; +function parseSkill(id: string, raw: string): SkillDefinition { + const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw.trim()); + if (!match) { + return { id, name: id, content: raw.trim() }; } - - private formatSkill(skill: SkillDefinition): string { - let md = `# ${skill.name}\n\n`; - if (skill.description) md += `${skill.description}\n\n`; - md += skill.content + "\n"; - return md; + const frontmatter = match[1] ?? ""; + const body = (match[2] ?? "").trim(); + let name = id; + let description: string | undefined; + for (const line of frontmatter.split("\n")) { + const colon = line.indexOf(":"); + if (colon === -1) continue; + const key = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (key === "name") name = value; + if (key === "description") description = value; } + return { id, name, description, content: body }; +} - private parseSkill(id: string, raw: string): SkillDefinition { - const lines = raw.trim().split("\n"); - let name = id; - let contentStart = 0; - - if (lines[0]?.startsWith("# ")) { - name = lines[0].slice(2).trim(); - contentStart = 1; - if (lines[contentStart]?.trim() === "") contentStart++; - } +async function importSkillMd(configDir: string, cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + const skillsDir = resolve(dir, configDir); + if (!existsSync(skillsDir)) return []; - const content = lines.slice(contentStart).join("\n").trim(); - return { id, name, content }; + const entries = await readdir(skillsDir, { withFileTypes: true }); + const skills: SkillDefinition[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillPath = resolve(skillsDir, entry.name, "SKILL.md"); + if (!existsSync(skillPath)) continue; + skills.push(parseSkill(entry.name, await readFile(skillPath, "utf-8"))); } + return skills; } const adapter = new ClaudeCodeSkillAdapter(); diff --git a/packages/skills/src/adapters/cline.test.ts b/packages/skills/src/adapters/cline.test.ts index df497fb..824175d 100644 --- a/packages/skills/src/adapters/cline.test.ts +++ b/packages/skills/src/adapters/cline.test.ts @@ -41,7 +41,10 @@ describe("ClineSkillAdapter", () => { it("has correct id", () => expect(adapter.id).toBe("cline")); it("has correct name", () => expect(adapter.name).toBe("Cline")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); - it("has correct config dir", () => expect(adapter.configDir).toBe(".cline/prompts")); + it("has correct config dir", () => { + // Cline has no SKILL.md loader here (no ~/.cline/skills). Native format is `.cline/prompts/*.md`. + expect(adapter.configDir).toBe(".cline/prompts"); + }); }); describe("generate", () => { diff --git a/packages/skills/src/adapters/codex.test.ts b/packages/skills/src/adapters/codex.test.ts index 1d52639..79c8218 100644 --- a/packages/skills/src/adapters/codex.test.ts +++ b/packages/skills/src/adapters/codex.test.ts @@ -1,26 +1,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; vi.mock("./registry.js", () => ({ registry: { register: vi.fn() }, })); -vi.mock("node:fs", () => ({ - existsSync: vi.fn(() => false), -})); - -vi.mock("node:fs/promises", () => ({ - readFile: vi.fn(), - readdir: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), - rm: vi.fn(), -})); - -import { existsSync } from "node:fs"; -import { readFile, readdir } from "node:fs/promises"; import { CodexSkillAdapter } from "./codex.js"; import type { SkillDefinition } from "../types/index.js"; +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), "ai-tools-skills-codex-")); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + describe("CodexSkillAdapter", () => { let adapter: CodexSkillAdapter; @@ -33,7 +32,6 @@ describe("CodexSkillAdapter", () => { }; beforeEach(() => { - vi.clearAllMocks(); adapter = new CodexSkillAdapter(); }); @@ -41,21 +39,21 @@ describe("CodexSkillAdapter", () => { it("has correct id", () => expect(adapter.id).toBe("codex")); it("has correct name", () => expect(adapter.name).toBe("Codex")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); - it("has correct config dir", () => expect(adapter.configDir).toBe(".codex/prompts")); + it("has correct config dir", () => expect(adapter.configDir).toBe(".codex/skills")); }); describe("generate", () => { - it("generates one file per skill", async () => { + it("writes SKILL.md under .codex/skills//", async () => { const files = await adapter.generate([testSkill]); expect(files).toHaveLength(1); - expect(files[0]?.path).toBe(".codex/prompts/review.md"); + expect(files[0]?.path).toBe(".codex/skills/review/SKILL.md"); expect(files[0]?.format).toBe("md"); }); - it("formats skill with name heading and content", async () => { + it("formats Agent Skills frontmatter plus body", async () => { const files = await adapter.generate([testSkill]); - expect(files[0]?.content).toContain("# Code Review"); - expect(files[0]?.content).toContain("Review code for best practices"); + expect(files[0]?.content).toContain("name: review"); + expect(files[0]?.content).toContain("description: Review code for best practices"); expect(files[0]?.content).toContain("Security issues"); }); @@ -68,53 +66,102 @@ describe("CodexSkillAdapter", () => { const skills = [testSkill, { ...testSkill, id: "debug", name: "Debug" }]; const files = await adapter.generate(skills); expect(files).toHaveLength(2); + expect(files[0]?.path).toBe(".codex/skills/review/SKILL.md"); }); - it("handles skill without description", async () => { + it("falls back to skill name when description is missing", async () => { const skill: SkillDefinition = { id: "test", name: "Test", content: "Content here" }; const files = await adapter.generate([skill]); - expect(files[0]?.content).toBe("# Test\n\nContent here\n"); + expect(files[0]?.content).toBe("---\nname: test\ndescription: Test\n---\n\nContent here\n"); + }); + }); + + describe("install", () => { + it("writes SKILL.md into a temp project, not prompts/", async () => { + await withTempDir(async (dir) => { + const files = await adapter.generate([testSkill]); + await adapter.install(files, dir); + + const skillPath = join(dir, ".codex/skills/review/SKILL.md"); + expect(existsSync(skillPath)).toBe(true); + expect(existsSync(join(dir, ".codex/prompts/review.md"))).toBe(false); + expect(await readFile(skillPath, "utf-8")).toContain("name: review"); + }); }); }); describe("import", () => { it("returns empty array when dir does not exist", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const result = await adapter.import("/test"); - expect(result).toEqual([]); + await withTempDir(async (dir) => { + expect(await adapter.import(join(dir, "missing"))).toEqual([]); + }); }); - it("imports skills from markdown files", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["review.md"] as never); - vi.mocked(readFile).mockResolvedValue("# Code Review\n\nReview the code"); - const result = await adapter.import("/test"); - expect(result).toHaveLength(1); - expect(result[0]?.id).toBe("review"); - expect(result[0]?.name).toBe("Code Review"); + it("imports SKILL.md directories Codex actually loads", async () => { + await withTempDir(async (dir) => { + const skillDir = join(dir, ".codex/skills/review"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + "---\nname: review\ndescription: Review the code\n---\n\nReview the code\n", + ); + + const result = await adapter.import(dir); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe("review"); + expect(result[0]?.name).toBe("review"); + }); }); - it("skips non-markdown files", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["readme.txt", "skill.md"] as never); - vi.mocked(readFile).mockResolvedValue("# Skill\n\nContent"); - const result = await adapter.import("/test"); - expect(result).toHaveLength(1); + it("skips files and folders without SKILL.md", async () => { + await withTempDir(async (dir) => { + const skillsDir = join(dir, ".codex/skills"); + await mkdir(join(skillsDir, "empty"), { recursive: true }); + await writeFile(join(skillsDir, "readme.txt"), "not a skill"); + await mkdir(join(skillsDir, "skill"), { recursive: true }); + await writeFile(join(skillsDir, "skill", "SKILL.md"), "# Skill\n\nContent"); + + const result = await adapter.import(dir); + expect(result).toHaveLength(1); + }); + }); + + it("imports skill without frontmatter", async () => { + await withTempDir(async (dir) => { + const skillDir = join(dir, ".codex/skills/plain"); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), "Just content, no heading"); + + const result = await adapter.import(dir); + expect(result[0]?.name).toBe("plain"); + expect(result[0]?.content).toBe("Just content, no heading"); + }); }); - it("imports skill without heading", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["plain.md"] as never); - vi.mocked(readFile).mockResolvedValue("Just content, no heading"); - const result = await adapter.import("/test"); - expect(result[0]?.name).toBe("plain"); - expect(result[0]?.content).toBe("Just content, no heading"); + it("ignores frontmatter lines without a colon", async () => { + await withTempDir(async (dir) => { + const skillDir = join(dir, ".codex/skills/odd"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + "---\nname: odd\nnot-a-pair\ndescription: Odd skill\n---\n\nBody\n", + ); + + const result = await adapter.import(dir); + expect(result[0]?.name).toBe("odd"); + expect(result[0]?.description).toBe("Odd skill"); + }); }); - it("imports without cwd argument", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const result = await adapter.import(); - expect(result).toEqual([]); + it("imports without cwd argument using process.cwd()", async () => { + await withTempDir(async (dir) => { + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir); + try { + expect(await adapter.import()).toEqual([]); + } finally { + cwdSpy.mockRestore(); + } + }); }); }); }); diff --git a/packages/skills/src/adapters/codex.ts b/packages/skills/src/adapters/codex.ts index 0a97bf8..76042dc 100644 --- a/packages/skills/src/adapters/codex.ts +++ b/packages/skills/src/adapters/codex.ts @@ -3,62 +3,67 @@ import { registry } from "./registry.js"; import type { SkillDefinition, GeneratedFile } from "../types/index.js"; import { existsSync } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; -import { resolve, basename } from "node:path"; +import { resolve } from "node:path"; class CodexSkillAdapter extends BaseSkillAdapter { readonly id = "codex"; readonly name = "Codex"; readonly nativeSupport = true; - readonly configDir = ".codex/prompts"; + readonly configDir = ".codex/skills"; readonly command = "codex"; async generate(skills: SkillDefinition[]): Promise { return skills.map((skill) => ({ - path: `${this.configDir}/${skill.id}.md`, - content: this.formatSkill(skill), + path: `${this.configDir}/${skill.id}/SKILL.md`, + content: formatSkill(skill), format: "md" as const, })); } async import(cwd?: string): Promise { - const dir = cwd ?? process.cwd(); - const skillsDir = resolve(dir, this.configDir); - if (!existsSync(skillsDir)) return []; - - const files = await readdir(skillsDir); - const skills: SkillDefinition[] = []; + return importSkillMd(this.configDir, cwd); + } +} - for (const file of files) { - if (!file.endsWith(".md")) continue; - const content = await readFile(resolve(skillsDir, file), "utf-8"); - const id = basename(file, ".md"); - skills.push(this.parseSkill(id, content)); - } +function formatSkill(skill: SkillDefinition): string { + const description = skill.description ?? skill.name; + return `---\nname: ${skill.id}\ndescription: ${description}\n---\n\n${skill.content}\n`; +} - return skills; +function parseSkill(id: string, raw: string): SkillDefinition { + const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw.trim()); + if (!match) { + return { id, name: id, content: raw.trim() }; } - - private formatSkill(skill: SkillDefinition): string { - let md = `# ${skill.name}\n\n`; - if (skill.description) md += `${skill.description}\n\n`; - md += skill.content + "\n"; - return md; + const frontmatter = match[1] ?? ""; + const body = (match[2] ?? "").trim(); + let name = id; + let description: string | undefined; + for (const line of frontmatter.split("\n")) { + const colon = line.indexOf(":"); + if (colon === -1) continue; + const key = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (key === "name") name = value; + if (key === "description") description = value; } + return { id, name, description, content: body }; +} - private parseSkill(id: string, raw: string): SkillDefinition { - const lines = raw.trim().split("\n"); - let name = id; - let contentStart = 0; - - if (lines[0]?.startsWith("# ")) { - name = lines[0].slice(2).trim(); - contentStart = 1; - if (lines[contentStart]?.trim() === "") contentStart++; - } +async function importSkillMd(configDir: string, cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + const skillsDir = resolve(dir, configDir); + if (!existsSync(skillsDir)) return []; - const content = lines.slice(contentStart).join("\n").trim(); - return { id, name, content }; + const entries = await readdir(skillsDir, { withFileTypes: true }); + const skills: SkillDefinition[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillPath = resolve(skillsDir, entry.name, "SKILL.md"); + if (!existsSync(skillPath)) continue; + skills.push(parseSkill(entry.name, await readFile(skillPath, "utf-8"))); } + return skills; } const adapter = new CodexSkillAdapter(); diff --git a/packages/skills/src/adapters/continue.test.ts b/packages/skills/src/adapters/continue.test.ts index e22a1e1..30e06c4 100644 --- a/packages/skills/src/adapters/continue.test.ts +++ b/packages/skills/src/adapters/continue.test.ts @@ -41,7 +41,10 @@ describe("ContinueSkillAdapter", () => { it("has correct id", () => expect(adapter.id).toBe("continue")); it("has correct name", () => expect(adapter.name).toBe("Continue")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); - it("has correct config dir", () => expect(adapter.configDir).toBe(".continue/prompts")); + it("has correct config dir", () => { + // Continue has no SKILL.md loader here (no ~/.continue/skills). Native format is `.continue/prompts/*.md`. + expect(adapter.configDir).toBe(".continue/prompts"); + }); }); describe("generate", () => { diff --git a/packages/skills/src/adapters/cursor.test.ts b/packages/skills/src/adapters/cursor.test.ts index 7840b3a..b94c793 100644 --- a/packages/skills/src/adapters/cursor.test.ts +++ b/packages/skills/src/adapters/cursor.test.ts @@ -1,26 +1,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; vi.mock("./registry.js", () => ({ registry: { register: vi.fn() }, })); -vi.mock("node:fs", () => ({ - existsSync: vi.fn(() => false), -})); - -vi.mock("node:fs/promises", () => ({ - readFile: vi.fn(), - readdir: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), - rm: vi.fn(), -})); - -import { existsSync } from "node:fs"; -import { readFile, readdir } from "node:fs/promises"; import { CursorSkillAdapter } from "./cursor.js"; import type { SkillDefinition } from "../types/index.js"; +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), "ai-tools-skills-cursor-")); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + describe("CursorSkillAdapter", () => { let adapter: CursorSkillAdapter; @@ -33,7 +32,6 @@ describe("CursorSkillAdapter", () => { }; beforeEach(() => { - vi.clearAllMocks(); adapter = new CursorSkillAdapter(); }); @@ -41,21 +39,21 @@ describe("CursorSkillAdapter", () => { it("has correct id", () => expect(adapter.id).toBe("cursor")); it("has correct name", () => expect(adapter.name).toBe("Cursor")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); - it("has correct config dir", () => expect(adapter.configDir).toBe(".cursor/prompts")); + it("has correct config dir", () => expect(adapter.configDir).toBe(".cursor/skills")); }); describe("generate", () => { - it("generates one file per skill", async () => { + it("writes SKILL.md under .cursor/skills//", async () => { const files = await adapter.generate([testSkill]); expect(files).toHaveLength(1); - expect(files[0]?.path).toBe(".cursor/prompts/review.md"); + expect(files[0]?.path).toBe(".cursor/skills/review/SKILL.md"); expect(files[0]?.format).toBe("md"); }); - it("formats skill with name heading and content", async () => { + it("formats Agent Skills frontmatter plus body", async () => { const files = await adapter.generate([testSkill]); - expect(files[0]?.content).toContain("# Code Review"); - expect(files[0]?.content).toContain("Review code for best practices"); + expect(files[0]?.content).toContain("name: review"); + expect(files[0]?.content).toContain("description: Review code for best practices"); expect(files[0]?.content).toContain("Security issues"); }); @@ -67,63 +65,116 @@ describe("CursorSkillAdapter", () => { it("handles multiple skills", async () => { const skills = [testSkill, { ...testSkill, id: "debug", name: "Debug" }]; const files = await adapter.generate(skills); - expect(files).toHaveLength(2); + expect(files.map((file) => file.path)).toEqual([ + ".cursor/skills/review/SKILL.md", + ".cursor/skills/debug/SKILL.md", + ]); }); - it("handles skill without description", async () => { + it("falls back to skill name when description is missing", async () => { const skill: SkillDefinition = { id: "test", name: "Test", content: "Content here" }; const files = await adapter.generate([skill]); - expect(files[0]?.content).toBe("# Test\n\nContent here\n"); + expect(files[0]?.content).toBe("---\nname: test\ndescription: Test\n---\n\nContent here\n"); + }); + }); + + describe("install", () => { + it("writes SKILL.md into a temp project, not prompts/", async () => { + await withTempDir(async (dir) => { + const files = await adapter.generate([testSkill]); + await adapter.install(files, dir); + + const skillPath = join(dir, ".cursor/skills/review/SKILL.md"); + expect(existsSync(skillPath)).toBe(true); + expect(existsSync(join(dir, ".cursor/prompts/review.md"))).toBe(false); + const written = await readFile(skillPath, "utf-8"); + expect(written).toContain("name: review"); + expect(written).toContain("Security issues"); + }); }); }); describe("import", () => { it("returns empty array when dir does not exist", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const result = await adapter.import("/test"); - expect(result).toEqual([]); + await withTempDir(async (dir) => { + expect(await adapter.import(join(dir, "missing"))).toEqual([]); + }); }); - it("imports skills from markdown files", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["review.md"] as never); - vi.mocked(readFile).mockResolvedValue("# Code Review\n\nReview the code"); - const result = await adapter.import("/test"); - expect(result).toHaveLength(1); - expect(result[0]?.id).toBe("review"); - expect(result[0]?.name).toBe("Code Review"); + it("imports SKILL.md directories Cursor actually loads", async () => { + await withTempDir(async (dir) => { + const skillDir = join(dir, ".cursor/skills/review"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + "---\nname: review\ndescription: Review the code\n---\n\nReview the code\n", + ); + + const result = await adapter.import(dir); + expect(result).toEqual([ + { + id: "review", + name: "review", + description: "Review the code", + content: "Review the code", + }, + ]); + }); }); - it("skips non-markdown files", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["readme.txt", "skill.md"] as never); - vi.mocked(readFile).mockResolvedValue("# Skill\n\nContent"); - const result = await adapter.import("/test"); - expect(result).toHaveLength(1); + it("skips files and folders without SKILL.md", async () => { + await withTempDir(async (dir) => { + const skillsDir = join(dir, ".cursor/skills"); + await mkdir(join(skillsDir, "empty"), { recursive: true }); + await mkdir(join(skillsDir, "valid"), { recursive: true }); + await writeFile(join(skillsDir, "readme.txt"), "not a skill"); + await writeFile( + join(skillsDir, "valid", "SKILL.md"), + "---\nname: valid\ndescription: ok\n---\n\nBody\n", + ); + + const result = await adapter.import(dir); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe("valid"); + }); }); - it("imports skill without heading", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["plain.md"] as never); - vi.mocked(readFile).mockResolvedValue("Just content, no heading"); - const result = await adapter.import("/test"); - expect(result[0]?.name).toBe("plain"); - expect(result[0]?.content).toBe("Just content, no heading"); + it("imports skill without frontmatter", async () => { + await withTempDir(async (dir) => { + const skillDir = join(dir, ".cursor/skills/plain"); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), "Just content, no heading"); + + const result = await adapter.import(dir); + expect(result[0]?.name).toBe("plain"); + expect(result[0]?.content).toBe("Just content, no heading"); + }); }); - it("imports skill with heading but no blank line after", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["test.md"] as never); - vi.mocked(readFile).mockResolvedValue("# My Skill\nDirect content"); - const result = await adapter.import("/test"); - expect(result[0]?.name).toBe("My Skill"); - expect(result[0]?.content).toBe("Direct content"); + it("ignores frontmatter lines without a colon", async () => { + await withTempDir(async (dir) => { + const skillDir = join(dir, ".cursor/skills/odd"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + "---\nname: odd\nnot-a-pair\ndescription: Odd skill\n---\n\nBody\n", + ); + + const result = await adapter.import(dir); + expect(result[0]?.name).toBe("odd"); + expect(result[0]?.description).toBe("Odd skill"); + }); }); - it("imports without cwd argument", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const result = await adapter.import(); - expect(result).toEqual([]); + it("imports without cwd argument using process.cwd()", async () => { + await withTempDir(async (dir) => { + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir); + try { + expect(await adapter.import()).toEqual([]); + } finally { + cwdSpy.mockRestore(); + } + }); }); }); }); diff --git a/packages/skills/src/adapters/cursor.ts b/packages/skills/src/adapters/cursor.ts index 9b80e88..be14ffb 100644 --- a/packages/skills/src/adapters/cursor.ts +++ b/packages/skills/src/adapters/cursor.ts @@ -3,62 +3,67 @@ import { registry } from "./registry.js"; import type { SkillDefinition, GeneratedFile } from "../types/index.js"; import { existsSync } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; -import { resolve, basename } from "node:path"; +import { resolve } from "node:path"; class CursorSkillAdapter extends BaseSkillAdapter { readonly id = "cursor"; readonly name = "Cursor"; readonly nativeSupport = true; - readonly configDir = ".cursor/prompts"; + readonly configDir = ".cursor/skills"; readonly command = "cursor"; async generate(skills: SkillDefinition[]): Promise { return skills.map((skill) => ({ - path: `${this.configDir}/${skill.id}.md`, - content: this.formatSkill(skill), + path: `${this.configDir}/${skill.id}/SKILL.md`, + content: formatSkill(skill), format: "md" as const, })); } async import(cwd?: string): Promise { - const dir = cwd ?? process.cwd(); - const skillsDir = resolve(dir, this.configDir); - if (!existsSync(skillsDir)) return []; - - const files = await readdir(skillsDir); - const skills: SkillDefinition[] = []; + return importSkillMd(this.configDir, cwd); + } +} - for (const file of files) { - if (!file.endsWith(".md")) continue; - const content = await readFile(resolve(skillsDir, file), "utf-8"); - const id = basename(file, ".md"); - skills.push(this.parseSkill(id, content)); - } +function formatSkill(skill: SkillDefinition): string { + const description = skill.description ?? skill.name; + return `---\nname: ${skill.id}\ndescription: ${description}\n---\n\n${skill.content}\n`; +} - return skills; +function parseSkill(id: string, raw: string): SkillDefinition { + const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw.trim()); + if (!match) { + return { id, name: id, content: raw.trim() }; } - - private formatSkill(skill: SkillDefinition): string { - let md = `# ${skill.name}\n\n`; - if (skill.description) md += `${skill.description}\n\n`; - md += skill.content + "\n"; - return md; + const frontmatter = match[1] ?? ""; + const body = (match[2] ?? "").trim(); + let name = id; + let description: string | undefined; + for (const line of frontmatter.split("\n")) { + const colon = line.indexOf(":"); + if (colon === -1) continue; + const key = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (key === "name") name = value; + if (key === "description") description = value; } + return { id, name, description, content: body }; +} - private parseSkill(id: string, raw: string): SkillDefinition { - const lines = raw.trim().split("\n"); - let name = id; - let contentStart = 0; - - if (lines[0]?.startsWith("# ")) { - name = lines[0].slice(2).trim(); - contentStart = 1; - if (lines[contentStart]?.trim() === "") contentStart++; - } +async function importSkillMd(configDir: string, cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + const skillsDir = resolve(dir, configDir); + if (!existsSync(skillsDir)) return []; - const content = lines.slice(contentStart).join("\n").trim(); - return { id, name, content }; + const entries = await readdir(skillsDir, { withFileTypes: true }); + const skills: SkillDefinition[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillPath = resolve(skillsDir, entry.name, "SKILL.md"); + if (!existsSync(skillPath)) continue; + skills.push(parseSkill(entry.name, await readFile(skillPath, "utf-8"))); } + return skills; } const adapter = new CursorSkillAdapter(); diff --git a/packages/skills/src/adapters/droid.test.ts b/packages/skills/src/adapters/droid.test.ts index 632c9eb..a7daf6f 100644 --- a/packages/skills/src/adapters/droid.test.ts +++ b/packages/skills/src/adapters/droid.test.ts @@ -41,7 +41,10 @@ describe("DroidSkillAdapter", () => { it("has correct id", () => expect(adapter.id).toBe("droid")); it("has correct name", () => expect(adapter.name).toBe("Droid")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); - it("has correct config dir", () => expect(adapter.configDir).toBe(".factory/prompts")); + it("has correct config dir", () => { + // Factory Droid has no SKILL.md loader here (no ~/.factory/skills). Native format is `.factory/prompts/*.md`. + expect(adapter.configDir).toBe(".factory/prompts"); + }); }); describe("generate", () => { diff --git a/packages/skills/src/adapters/gemini-cli.test.ts b/packages/skills/src/adapters/gemini-cli.test.ts index 146dd04..7d0cec8 100644 --- a/packages/skills/src/adapters/gemini-cli.test.ts +++ b/packages/skills/src/adapters/gemini-cli.test.ts @@ -41,7 +41,11 @@ describe("GeminiCliSkillAdapter", () => { it("has correct id", () => expect(adapter.id).toBe("gemini-cli")); it("has correct name", () => expect(adapter.name).toBe("Gemini CLI")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); - it("has correct config dir", () => expect(adapter.configDir).toBe(".gemini/prompts")); + it("has correct config dir", () => { + // Gemini CLI custom prompts live at `.gemini/prompts/*.md` (observed in this repo). + // Agent Skills, if used, go through `.agents/skills//SKILL.md` (antigravity-cli). + expect(adapter.configDir).toBe(".gemini/prompts"); + }); }); describe("generate", () => { diff --git a/packages/skills/src/adapters/grok.test.ts b/packages/skills/src/adapters/grok.test.ts new file mode 100644 index 0000000..2f14a6e --- /dev/null +++ b/packages/skills/src/adapters/grok.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("./registry.js", () => ({ + registry: { register: vi.fn() }, +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), +})); + +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn(), + readdir: vi.fn(), +})); + +import { existsSync } from "node:fs"; +import { readFile, readdir } from "node:fs/promises"; +import { GrokSkillAdapter } from "./grok.js"; + +describe("GrokSkillAdapter", () => { + let adapter: GrokSkillAdapter; + + beforeEach(() => { + vi.clearAllMocks(); + adapter = new GrokSkillAdapter(); + }); + + it("writes SKILL.md directories Grok actually scans", async () => { + expect(adapter.id).toBe("grok"); + expect(adapter.configDir).toBe(".grok/skills"); + const files = await adapter.generate([ + { + id: "commit", + name: "commit", + description: "Make commits", + content: "Use conventional commits.", + }, + ]); + expect(files[0]?.path).toBe(".grok/skills/commit/SKILL.md"); + expect(files[0]?.content).toContain("name: commit"); + expect(files[0]?.content).toContain("Use conventional commits."); + }); + + it("imports SKILL.md directories", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readdir).mockResolvedValue([{ name: "commit", isDirectory: () => true }] as never); + vi.mocked(readFile).mockResolvedValue( + "---\nname: commit\ndescription: Make commits\n---\n\nUse conventional commits.\n", + ); + const skills = await adapter.import("/tmp/proj"); + expect(skills).toEqual([ + { + id: "commit", + name: "commit", + description: "Make commits", + content: "Use conventional commits.", + }, + ]); + }); + + it("returns empty when the skills dir is missing", async () => { + vi.mocked(existsSync).mockReturnValue(false); + expect(await adapter.import("/tmp/proj")).toEqual([]); + }); +}); diff --git a/packages/skills/src/adapters/grok.ts b/packages/skills/src/adapters/grok.ts new file mode 100644 index 0000000..d7648e6 --- /dev/null +++ b/packages/skills/src/adapters/grok.ts @@ -0,0 +1,69 @@ +import { BaseSkillAdapter } from "./base.js"; +import { registry } from "./registry.js"; +import type { SkillDefinition, GeneratedFile } from "../types/index.js"; +import { existsSync } from "node:fs"; +import { readFile, readdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +class GrokSkillAdapter extends BaseSkillAdapter { + readonly id = "grok"; + readonly name = "Grok"; + readonly nativeSupport = true; + readonly configDir = ".grok/skills"; + readonly command = "grok"; + + async generate(skills: SkillDefinition[]): Promise { + return skills.map((skill) => ({ + path: `${this.configDir}/${skill.id}/SKILL.md`, + content: formatGrokSkill(skill), + format: "md" as const, + })); + } + + async import(cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + const skillsDir = resolve(dir, this.configDir); + if (!existsSync(skillsDir)) return []; + + const entries = await readdir(skillsDir, { withFileTypes: true }); + const skills: SkillDefinition[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillPath = resolve(skillsDir, entry.name, "SKILL.md"); + if (!existsSync(skillPath)) continue; + const raw = await readFile(skillPath, "utf-8"); + skills.push(parseGrokSkill(entry.name, raw)); + } + return skills; + } +} + +function formatGrokSkill(skill: SkillDefinition): string { + const description = skill.description ?? skill.name; + return `---\nname: ${skill.name}\ndescription: ${description}\n---\n\n${skill.content}\n`; +} + +function parseGrokSkill(id: string, raw: string): SkillDefinition { + const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw.trim()); + if (!match) { + return { id, name: id, content: raw.trim() }; + } + const frontmatter = match[1] ?? ""; + const body = (match[2] ?? "").trim(); + let name = id; + let description: string | undefined; + for (const line of frontmatter.split("\n")) { + const colon = line.indexOf(":"); + if (colon === -1) continue; + const key = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (key === "name") name = value; + if (key === "description") description = value; + } + return { id, name, description, content: body }; +} + +const adapter = new GrokSkillAdapter(); +registry.register(adapter); +export { GrokSkillAdapter }; +export default adapter; diff --git a/packages/skills/src/adapters/kiro.test.ts b/packages/skills/src/adapters/kiro.test.ts index e7f1e2c..8ff9769 100644 --- a/packages/skills/src/adapters/kiro.test.ts +++ b/packages/skills/src/adapters/kiro.test.ts @@ -41,7 +41,10 @@ describe("KiroSkillAdapter", () => { it("has correct id", () => expect(adapter.id).toBe("kiro")); it("has correct name", () => expect(adapter.name).toBe("Kiro")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); - it("has correct config dir", () => expect(adapter.configDir).toBe(".kiro/prompts")); + it("has correct config dir", () => { + // Kiro has no SKILL.md loader here (no ~/.kiro/skills). Native format is `.kiro/prompts/*.md`. + expect(adapter.configDir).toBe(".kiro/prompts"); + }); }); describe("generate", () => { diff --git a/packages/skills/src/adapters/opencode.test.ts b/packages/skills/src/adapters/opencode.test.ts index 4c9c0a2..c4b262b 100644 --- a/packages/skills/src/adapters/opencode.test.ts +++ b/packages/skills/src/adapters/opencode.test.ts @@ -1,26 +1,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; vi.mock("./registry.js", () => ({ registry: { register: vi.fn() }, })); -vi.mock("node:fs", () => ({ - existsSync: vi.fn(() => false), -})); - -vi.mock("node:fs/promises", () => ({ - readFile: vi.fn(), - readdir: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), - rm: vi.fn(), -})); - -import { existsSync } from "node:fs"; -import { readFile, readdir } from "node:fs/promises"; import { OpenCodeSkillAdapter } from "./opencode.js"; import type { SkillDefinition } from "../types/index.js"; +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), "ai-tools-skills-opencode-")); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + describe("OpenCodeSkillAdapter", () => { let adapter: OpenCodeSkillAdapter; @@ -33,7 +32,6 @@ describe("OpenCodeSkillAdapter", () => { }; beforeEach(() => { - vi.clearAllMocks(); adapter = new OpenCodeSkillAdapter(); }); @@ -41,21 +39,21 @@ describe("OpenCodeSkillAdapter", () => { it("has correct id", () => expect(adapter.id).toBe("opencode")); it("has correct name", () => expect(adapter.name).toBe("OpenCode")); it("has native support", () => expect(adapter.nativeSupport).toBe(true)); - it("has correct config dir", () => expect(adapter.configDir).toBe(".opencode/prompts")); + it("has correct config dir", () => expect(adapter.configDir).toBe(".opencode/skills")); }); describe("generate", () => { - it("generates one file per skill", async () => { + it("writes SKILL.md under .opencode/skills//", async () => { const files = await adapter.generate([testSkill]); expect(files).toHaveLength(1); - expect(files[0]?.path).toBe(".opencode/prompts/review.md"); + expect(files[0]?.path).toBe(".opencode/skills/review/SKILL.md"); expect(files[0]?.format).toBe("md"); }); - it("formats skill with name heading and content", async () => { + it("formats Agent Skills frontmatter plus body", async () => { const files = await adapter.generate([testSkill]); - expect(files[0]?.content).toContain("# Code Review"); - expect(files[0]?.content).toContain("Review code for best practices"); + expect(files[0]?.content).toContain("name: review"); + expect(files[0]?.content).toContain("description: Review code for best practices"); expect(files[0]?.content).toContain("Security issues"); }); @@ -70,51 +68,100 @@ describe("OpenCodeSkillAdapter", () => { expect(files).toHaveLength(2); }); - it("handles skill without description", async () => { + it("falls back to skill name when description is missing", async () => { const skill: SkillDefinition = { id: "test", name: "Test", content: "Content here" }; const files = await adapter.generate([skill]); - expect(files[0]?.content).toBe("# Test\n\nContent here\n"); + expect(files[0]?.content).toBe("---\nname: test\ndescription: Test\n---\n\nContent here\n"); + }); + }); + + describe("install", () => { + it("writes SKILL.md into a temp project, not prompts/ or command/", async () => { + await withTempDir(async (dir) => { + const files = await adapter.generate([testSkill]); + await adapter.install(files, dir); + + const skillPath = join(dir, ".opencode/skills/review/SKILL.md"); + expect(existsSync(skillPath)).toBe(true); + expect(existsSync(join(dir, ".opencode/prompts/review.md"))).toBe(false); + expect(existsSync(join(dir, ".opencode/command/review.md"))).toBe(false); + expect(await readFile(skillPath, "utf-8")).toContain("name: review"); + }); }); }); describe("import", () => { it("returns empty array when dir does not exist", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const result = await adapter.import("/test"); - expect(result).toEqual([]); + await withTempDir(async (dir) => { + expect(await adapter.import(join(dir, "missing"))).toEqual([]); + }); }); - it("imports skills from markdown files", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["review.md"] as never); - vi.mocked(readFile).mockResolvedValue("# Code Review\n\nReview the code"); - const result = await adapter.import("/test"); - expect(result).toHaveLength(1); - expect(result[0]?.id).toBe("review"); - expect(result[0]?.name).toBe("Code Review"); + it("imports SKILL.md directories OpenCode actually loads", async () => { + await withTempDir(async (dir) => { + const skillDir = join(dir, ".opencode/skills/review"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + "---\nname: review\ndescription: Review the code\n---\n\nReview the code\n", + ); + + const result = await adapter.import(dir); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe("review"); + expect(result[0]?.name).toBe("review"); + }); }); - it("skips non-markdown files", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["readme.txt", "skill.md"] as never); - vi.mocked(readFile).mockResolvedValue("# Skill\n\nContent"); - const result = await adapter.import("/test"); - expect(result).toHaveLength(1); + it("skips files and folders without SKILL.md", async () => { + await withTempDir(async (dir) => { + const skillsDir = join(dir, ".opencode/skills"); + await mkdir(join(skillsDir, "empty"), { recursive: true }); + await writeFile(join(skillsDir, "readme.txt"), "not a skill"); + await mkdir(join(skillsDir, "skill"), { recursive: true }); + await writeFile(join(skillsDir, "skill", "SKILL.md"), "# Skill\n\nContent"); + + const result = await adapter.import(dir); + expect(result).toHaveLength(1); + }); + }); + + it("imports skill without frontmatter", async () => { + await withTempDir(async (dir) => { + const skillDir = join(dir, ".opencode/skills/plain"); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), "Just content, no heading"); + + const result = await adapter.import(dir); + expect(result[0]?.name).toBe("plain"); + expect(result[0]?.content).toBe("Just content, no heading"); + }); }); - it("imports skill without heading", async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readdir).mockResolvedValue(["plain.md"] as never); - vi.mocked(readFile).mockResolvedValue("Just content, no heading"); - const result = await adapter.import("/test"); - expect(result[0]?.name).toBe("plain"); - expect(result[0]?.content).toBe("Just content, no heading"); + it("ignores frontmatter lines without a colon", async () => { + await withTempDir(async (dir) => { + const skillDir = join(dir, ".opencode/skills/odd"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + "---\nname: odd\nnot-a-pair\ndescription: Odd skill\n---\n\nBody\n", + ); + + const result = await adapter.import(dir); + expect(result[0]?.name).toBe("odd"); + expect(result[0]?.description).toBe("Odd skill"); + }); }); - it("imports without cwd argument", async () => { - vi.mocked(existsSync).mockReturnValue(false); - const result = await adapter.import(); - expect(result).toEqual([]); + it("imports without cwd argument using process.cwd()", async () => { + await withTempDir(async (dir) => { + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir); + try { + expect(await adapter.import()).toEqual([]); + } finally { + cwdSpy.mockRestore(); + } + }); }); }); }); diff --git a/packages/skills/src/adapters/opencode.ts b/packages/skills/src/adapters/opencode.ts index 399dd75..e0549db 100644 --- a/packages/skills/src/adapters/opencode.ts +++ b/packages/skills/src/adapters/opencode.ts @@ -3,62 +3,67 @@ import { registry } from "./registry.js"; import type { SkillDefinition, GeneratedFile } from "../types/index.js"; import { existsSync } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; -import { resolve, basename } from "node:path"; +import { resolve } from "node:path"; class OpenCodeSkillAdapter extends BaseSkillAdapter { readonly id = "opencode"; readonly name = "OpenCode"; readonly nativeSupport = true; - readonly configDir = ".opencode/prompts"; + readonly configDir = ".opencode/skills"; readonly command = "opencode"; async generate(skills: SkillDefinition[]): Promise { return skills.map((skill) => ({ - path: `${this.configDir}/${skill.id}.md`, - content: this.formatSkill(skill), + path: `${this.configDir}/${skill.id}/SKILL.md`, + content: formatSkill(skill), format: "md" as const, })); } async import(cwd?: string): Promise { - const dir = cwd ?? process.cwd(); - const skillsDir = resolve(dir, this.configDir); - if (!existsSync(skillsDir)) return []; - - const files = await readdir(skillsDir); - const skills: SkillDefinition[] = []; + return importSkillMd(this.configDir, cwd); + } +} - for (const file of files) { - if (!file.endsWith(".md")) continue; - const content = await readFile(resolve(skillsDir, file), "utf-8"); - const id = basename(file, ".md"); - skills.push(this.parseSkill(id, content)); - } +function formatSkill(skill: SkillDefinition): string { + const description = skill.description ?? skill.name; + return `---\nname: ${skill.id}\ndescription: ${description}\n---\n\n${skill.content}\n`; +} - return skills; +function parseSkill(id: string, raw: string): SkillDefinition { + const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw.trim()); + if (!match) { + return { id, name: id, content: raw.trim() }; } - - private formatSkill(skill: SkillDefinition): string { - let md = `# ${skill.name}\n\n`; - if (skill.description) md += `${skill.description}\n\n`; - md += skill.content + "\n"; - return md; + const frontmatter = match[1] ?? ""; + const body = (match[2] ?? "").trim(); + let name = id; + let description: string | undefined; + for (const line of frontmatter.split("\n")) { + const colon = line.indexOf(":"); + if (colon === -1) continue; + const key = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (key === "name") name = value; + if (key === "description") description = value; } + return { id, name, description, content: body }; +} - private parseSkill(id: string, raw: string): SkillDefinition { - const lines = raw.trim().split("\n"); - let name = id; - let contentStart = 0; - - if (lines[0]?.startsWith("# ")) { - name = lines[0].slice(2).trim(); - contentStart = 1; - if (lines[contentStart]?.trim() === "") contentStart++; - } +async function importSkillMd(configDir: string, cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + const skillsDir = resolve(dir, configDir); + if (!existsSync(skillsDir)) return []; - const content = lines.slice(contentStart).join("\n").trim(); - return { id, name, content }; + const entries = await readdir(skillsDir, { withFileTypes: true }); + const skills: SkillDefinition[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillPath = resolve(skillsDir, entry.name, "SKILL.md"); + if (!existsSync(skillPath)) continue; + skills.push(parseSkill(entry.name, await readFile(skillPath, "utf-8"))); } + return skills; } const adapter = new OpenCodeSkillAdapter(); diff --git a/packages/skills/src/adapters/zcode.test.ts b/packages/skills/src/adapters/zcode.test.ts new file mode 100644 index 0000000..550627c --- /dev/null +++ b/packages/skills/src/adapters/zcode.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("./registry.js", () => ({ + registry: { register: vi.fn() }, +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), +})); + +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn(), + readdir: vi.fn(), +})); + +import { existsSync } from "node:fs"; +import { readFile, readdir } from "node:fs/promises"; +import { ZcodeSkillAdapter } from "./zcode.js"; + +describe("ZcodeSkillAdapter", () => { + let adapter: ZcodeSkillAdapter; + + beforeEach(() => { + vi.clearAllMocks(); + adapter = new ZcodeSkillAdapter(); + }); + + it("writes SKILL.md directories", async () => { + expect(adapter.id).toBe("zcode"); + expect(adapter.configDir).toBe(".zcode/skills"); + const files = await adapter.generate([{ id: "ship", name: "ship", content: "Ship it." }]); + expect(files[0]?.path).toBe(".zcode/skills/ship/SKILL.md"); + expect(files[0]?.content).toContain("name: ship"); + }); + + it("imports SKILL.md directories", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readdir).mockResolvedValue([{ name: "ship", isDirectory: () => true }] as never); + vi.mocked(readFile).mockResolvedValue("---\nname: ship\ndescription: ship\n---\n\nShip it.\n"); + const skills = await adapter.import("/tmp/proj"); + expect(skills[0]?.id).toBe("ship"); + expect(skills[0]?.content).toBe("Ship it."); + }); +}); diff --git a/packages/skills/src/adapters/zcode.ts b/packages/skills/src/adapters/zcode.ts new file mode 100644 index 0000000..f89b5b2 --- /dev/null +++ b/packages/skills/src/adapters/zcode.ts @@ -0,0 +1,65 @@ +import { BaseSkillAdapter } from "./base.js"; +import { registry } from "./registry.js"; +import type { SkillDefinition, GeneratedFile } from "../types/index.js"; +import { existsSync } from "node:fs"; +import { readFile, readdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +class ZcodeSkillAdapter extends BaseSkillAdapter { + readonly id = "zcode"; + readonly name = "ZCode"; + readonly nativeSupport = true; + readonly configDir = ".zcode/skills"; + readonly command = "zcode"; + + async generate(skills: SkillDefinition[]): Promise { + return skills.map((skill) => ({ + path: `${this.configDir}/${skill.id}/SKILL.md`, + content: formatSkill(skill), + format: "md" as const, + })); + } + + async import(cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + const skillsDir = resolve(dir, this.configDir); + if (!existsSync(skillsDir)) return []; + const entries = await readdir(skillsDir, { withFileTypes: true }); + const skills: SkillDefinition[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillPath = resolve(skillsDir, entry.name, "SKILL.md"); + if (!existsSync(skillPath)) continue; + skills.push(parseSkill(entry.name, await readFile(skillPath, "utf-8"))); + } + return skills; + } +} + +function formatSkill(skill: SkillDefinition): string { + const description = skill.description ?? skill.name; + return `---\nname: ${skill.name}\ndescription: ${description}\n---\n\n${skill.content}\n`; +} + +function parseSkill(id: string, raw: string): SkillDefinition { + const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw.trim()); + if (!match) return { id, name: id, content: raw.trim() }; + const frontmatter = match[1] ?? ""; + const body = (match[2] ?? "").trim(); + let name = id; + let description: string | undefined; + for (const line of frontmatter.split("\n")) { + const colon = line.indexOf(":"); + if (colon === -1) continue; + const key = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (key === "name") name = value; + if (key === "description") description = value; + } + return { id, name, description, content: body }; +} + +const adapter = new ZcodeSkillAdapter(); +registry.register(adapter); +export { ZcodeSkillAdapter }; +export default adapter; diff --git a/packages/ui/src/cli/index.test.ts b/packages/ui/src/cli/index.test.ts index 93cd38b..380c5e1 100644 --- a/packages/ui/src/cli/index.test.ts +++ b/packages/ui/src/cli/index.test.ts @@ -57,7 +57,8 @@ describe("parseFlags()", () => { const flags = parseFlags(["--help"]); expect(flags.help).toBe(true); expect(flags.noPty).toBe(false); - expect(flags.detach).toBe(false); + expect(flags.dev).toBe(false); + expect("detach" in flags).toBe(false); }); it("parses -h flag", () => { @@ -69,24 +70,28 @@ describe("parseFlags()", () => { const flags = parseFlags(["--no-pty"]); expect(flags.noPty).toBe(true); expect(flags.help).toBe(false); + expect(flags.dev).toBe(false); }); - it("parses --detach flag", () => { - const flags = parseFlags(["--detach"]); - expect(flags.detach).toBe(true); + it("parses --dev flag", () => { + const flags = parseFlags(["--dev"]); + expect(flags.dev).toBe(true); + expect(flags.noPty).toBe(false); }); it("parses multiple flags", () => { - const flags = parseFlags(["--no-pty", "--detach"]); + const flags = parseFlags(["--no-pty", "--dev"]); expect(flags.noPty).toBe(true); - expect(flags.detach).toBe(true); + expect(flags.dev).toBe(true); + expect("detach" in flags).toBe(false); }); it("returns all false for no flags", () => { const flags = parseFlags([]); expect(flags.help).toBe(false); expect(flags.noPty).toBe(false); - expect(flags.detach).toBe(false); + expect(flags.dev).toBe(false); + expect("detach" in flags).toBe(false); }); }); @@ -107,14 +112,12 @@ describe("run() - help output", () => { expect(allLog()).toContain("ai-tools-tui"); }); - it("help includes --no-pty option", async () => { + it("help includes --no-pty and --dev options", async () => { await run(["--help"]); - expect(allLog()).toContain("--no-pty"); - }); - - it("help includes --detach option", async () => { - await run(["--help"]); - expect(allLog()).toContain("--detach"); + const output = allLog(); + expect(output).toContain("--no-pty"); + expect(output).toContain("--dev"); + expect(output).not.toContain("--detach"); }); }); @@ -132,15 +135,6 @@ describe("run() - no-pty mode", () => { }); }); -// -- run() - detach mode -- - -describe("run() - detach mode", () => { - it("prints not yet implemented message", async () => { - await run(["--detach"]); - expect(allLog()).toContain("not yet implemented"); - }); -}); - // -- run() - default TUI mode -- describe("run() - default mode", () => { diff --git a/packages/ui/src/cli/index.ts b/packages/ui/src/cli/index.ts index 36a8710..9971df3 100644 --- a/packages/ui/src/cli/index.ts +++ b/packages/ui/src/cli/index.ts @@ -8,7 +8,6 @@ USAGE: OPTIONS: --help, -h Show this help message --no-pty Disable PTY embedding (use simple output mode) - --detach Run in background --dev Hot State-Preserving Reload (HSR) mode — run alongside \`npm run dev\` (tsup --watch) in a separate terminal. Edits to any view file are live-swapped without losing state. @@ -35,7 +34,6 @@ KEYBINDINGS: export type CliFlags = { help: boolean; noPty: boolean; - detach: boolean; dev: boolean; }; @@ -43,7 +41,6 @@ export function parseFlags(args: string[]): CliFlags { return { help: args.includes("--help") || args.includes("-h"), noPty: args.includes("--no-pty"), - detach: args.includes("--detach"), dev: args.includes("--dev"), }; } @@ -109,11 +106,6 @@ export async function run(args: string[]): Promise { return; } - if (flags.detach) { - console.log("Detach mode is not yet implemented."); - return; - } - if (!process.stdout.isTTY) { const { tools, statusBar } = await loadTextModeData(); console.log("AI Tools Dashboard\n"); diff --git a/packages/ui/src/command-palette.test.ts b/packages/ui/src/command-palette.test.ts deleted file mode 100644 index 158a718..0000000 --- a/packages/ui/src/command-palette.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, it, expect } from "vitest"; -import type { ToolInfo } from "./types.js"; -import type { SessionRow } from "./widgets/session-browser.js"; -import { - getActionCommands, - getSessionCommands, - getToolCommands, - getAllCommands, - filterCommands, -} from "./command-palette.js"; - -function makeTool(overrides: Partial = {}): ToolInfo { - return { - id: "claude", - name: "Claude Code", - command: "claude", - status: "available", - sessionCount: 5, - ...overrides, - }; -} - -function makeSession(overrides: Partial = {}): SessionRow { - return { - id: "s1", - tool: "claude", - toolName: "Claude Code", - title: "Fix auth bug", - messageCount: 10, - updatedAt: "2025-01-15T10:00:00Z", - ...overrides, - }; -} - -describe("getActionCommands", () => { - it("returns a non-empty array of actions", () => { - const commands = getActionCommands(); - expect(commands.length).toBeGreaterThan(0); - }); - - it("all items have category action", () => { - const commands = getActionCommands(); - for (const cmd of commands) { - expect(cmd.category).toBe("action"); - } - }); - - it("includes generate config command", () => { - const commands = getActionCommands(); - expect(commands.some((c) => c.id === "generate")).toBe(true); - }); - - it("includes install config command", () => { - const commands = getActionCommands(); - expect(commands.some((c) => c.id === "install")).toBe(true); - }); - - it("includes quit command", () => { - const commands = getActionCommands(); - expect(commands.some((c) => c.id === "quit")).toBe(true); - }); - - it("includes help command", () => { - const commands = getActionCommands(); - expect(commands.some((c) => c.id === "help")).toBe(true); - }); - - it("includes theme command", () => { - const commands = getActionCommands(); - expect(commands.some((c) => c.id === "theme")).toBe(true); - }); - - it("all items have unique ids", () => { - const commands = getActionCommands(); - const ids = commands.map((c) => c.id); - expect(new Set(ids).size).toBe(ids.length); - }); -}); - -describe("getSessionCommands", () => { - it("returns empty for no sessions", () => { - expect(getSessionCommands([])).toEqual([]); - }); - - it("creates command for each session", () => { - const sessions = [makeSession({ id: "s1" }), makeSession({ id: "s2" })]; - const commands = getSessionCommands(sessions); - expect(commands).toHaveLength(2); - }); - - it("prefixes id with session-", () => { - const commands = getSessionCommands([makeSession({ id: "abc" })]); - expect(commands[0]!.id).toBe("session-abc"); - }); - - it("includes tool name in label", () => { - const commands = getSessionCommands([makeSession({ tool: "claude" })]); - expect(commands[0]!.label).toContain("[claude]"); - }); - - it("includes session title in label", () => { - const commands = getSessionCommands([makeSession({ title: "My Session" })]); - expect(commands[0]!.label).toContain("My Session"); - }); - - it("all items have category session", () => { - const commands = getSessionCommands([makeSession()]); - for (const cmd of commands) { - expect(cmd.category).toBe("session"); - } - }); -}); - -describe("getToolCommands", () => { - it("returns empty for no tools", () => { - expect(getToolCommands([])).toEqual([]); - }); - - it("only includes available tools", () => { - const tools = [ - makeTool({ id: "a", status: "available" }), - makeTool({ id: "b", status: "not-installed" }), - ]; - const commands = getToolCommands(tools); - expect(commands).toHaveLength(1); - expect(commands[0]!.id).toBe("tool-a"); - }); - - it("prefixes id with tool-", () => { - const commands = getToolCommands([makeTool({ id: "claude" })]); - expect(commands[0]!.id).toBe("tool-claude"); - }); - - it("uses tool name as label", () => { - const commands = getToolCommands([makeTool({ name: "Claude Code" })]); - expect(commands[0]!.label).toBe("Claude Code"); - }); - - it("all items have category tool", () => { - const commands = getToolCommands([makeTool()]); - for (const cmd of commands) { - expect(cmd.category).toBe("tool"); - } - }); - - it("filters out running tools", () => { - const commands = getToolCommands([makeTool({ status: "running" })]); - expect(commands).toHaveLength(0); - }); - - it("filters out stopped tools", () => { - const commands = getToolCommands([makeTool({ status: "stopped" })]); - expect(commands).toHaveLength(0); - }); -}); - -describe("getAllCommands", () => { - it("combines all sources", () => { - const sessions = [makeSession()]; - const tools = [makeTool()]; - const all = getAllCommands(sessions, tools); - const actionCount = getActionCommands().length; - expect(all.length).toBe(actionCount + 1 + 1); // actions + 1 session + 1 tool - }); - - it("actions come first", () => { - const all = getAllCommands([makeSession()], [makeTool()]); - expect(all[0]!.category).toBe("action"); - }); - - it("works with empty inputs", () => { - const all = getAllCommands([], []); - expect(all.length).toBe(getActionCommands().length); - }); -}); - -describe("filterCommands", () => { - it("returns all items for empty query", () => { - const items = getActionCommands(); - expect(filterCommands(items, "")).toEqual(items); - }); - - it("filters by substring match", () => { - const items = getActionCommands(); - const result = filterCommands(items, "Config"); - expect(result.length).toBeGreaterThan(0); - for (const item of result) { - expect(item.label.toLowerCase()).toContain("config"); - } - }); - - it("is case-insensitive", () => { - const items = getActionCommands(); - const lower = filterCommands(items, "config"); - const upper = filterCommands(items, "CONFIG"); - expect(lower).toEqual(upper); - }); - - it("returns empty for no matches", () => { - const items = getActionCommands(); - expect(filterCommands(items, "zzzznonexistent")).toEqual([]); - }); - - it("matches partial strings", () => { - const items = getActionCommands(); - const result = filterCommands(items, "Gen"); - expect(result.some((c) => c.label.includes("Generate"))).toBe(true); - }); -}); diff --git a/packages/ui/src/command-palette.ts b/packages/ui/src/command-palette.ts deleted file mode 100644 index c9a4d99..0000000 --- a/packages/ui/src/command-palette.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { ToolInfo } from "./types.js"; -import type { SessionRow } from "./widgets/session-browser.js"; - -export type CommandItem = { - id: string; - label: string; - category: "action" | "session" | "tool"; -}; - -/** - * Get static action commands for the command palette. - */ -export function getActionCommands(): CommandItem[] { - return [ - { id: "launch-claude", label: "Launch Claude Code", category: "action" }, - { id: "launch-codex", label: "Launch Codex", category: "action" }, - { id: "launch-gemini", label: "Launch Gemini CLI", category: "action" }, - { id: "generate", label: "Generate Config", category: "action" }, - { id: "install", label: "Install Config", category: "action" }, - { id: "refresh", label: "Refresh Sessions", category: "action" }, - { id: "theme", label: "Switch Theme", category: "action" }, - { id: "help", label: "Show Help", category: "action" }, - { id: "quit", label: "Quit", category: "action" }, - ]; -} - -/** - * Build command items from session list. - */ -export function getSessionCommands(sessions: SessionRow[]): CommandItem[] { - return sessions.map((s) => ({ - id: `session-${s.id}`, - label: `[${s.tool}] ${s.title}`, - category: "session" as const, - })); -} - -/** - * Build command items from available tools. - */ -export function getToolCommands(tools: ToolInfo[]): CommandItem[] { - return tools - .filter((t) => t.status === "available") - .map((t) => ({ - id: `tool-${t.id}`, - label: t.name, - category: "tool" as const, - })); -} - -/** - * Get all command items from all sources. - */ -export function getAllCommands(sessions: SessionRow[], tools: ToolInfo[]): CommandItem[] { - return [...getActionCommands(), ...getSessionCommands(sessions), ...getToolCommands(tools)]; -} - -/** - * Filter command items by query (case-insensitive substring match). - */ -export function filterCommands(items: CommandItem[], query: string): CommandItem[] { - if (!query) return items; - const lower = query.toLowerCase(); - return items.filter((item) => item.label.toLowerCase().includes(lower)); -} diff --git a/packages/ui/src/commands/config-sync.test.ts b/packages/ui/src/commands/config-sync.test.ts index 3ebbe10..64e1895 100644 --- a/packages/ui/src/commands/config-sync.test.ts +++ b/packages/ui/src/commands/config-sync.test.ts @@ -13,7 +13,7 @@ vi.mock("node:path", () => ({ resolve: (...args: string[]) => args.join("/"), })); -import { triggerGenerate, triggerInstall, getCanonicalStatus } from "./config-sync.js"; +import { triggerGenerate, triggerInstall, triggerSync, getCanonicalStatus } from "./config-sync.js"; import { execFile } from "node:child_process"; import { existsSync, readdirSync } from "node:fs"; @@ -133,6 +133,84 @@ describe("triggerInstall()", () => { }); }); +// -- triggerSync -- + +describe("triggerSync()", () => { + it("calls npx ai-tools mcp sync, not union sync or empty install", async () => { + (execFile as unknown as ReturnType).mockImplementation( + ( + cmd: string, + args: string[], + opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + void cmd; + void args; + void opts; + cb(null, "Sync complete!", ""); + }, + ); + + const result = await triggerSync(); + expect(result).toBe("Sync complete!"); + expect(execFile).toHaveBeenCalledTimes(1); + expect(execFile).toHaveBeenCalledWith( + "npx", + ["ai-tools", "mcp", "sync"], + expect.objectContaining({ cwd: process.cwd() }), + expect.any(Function), + ); + expect(execFile).not.toHaveBeenCalledWith( + "npx", + ["ai-tools", "sync"], + expect.anything(), + expect.any(Function), + ); + expect(execFile).not.toHaveBeenCalledWith( + "npx", + ["ai-tools", "mcp", "install"], + expect.anything(), + expect.any(Function), + ); + }); + + it("rejects on error with stderr", async () => { + (execFile as unknown as ReturnType).mockImplementation( + ( + cmd: string, + args: string[], + opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + void cmd; + void args; + void opts; + cb(new Error("sync failed"), "", "MCP install error"); + }, + ); + + await expect(triggerSync()).rejects.toThrow("MCP install error"); + }); + + it("rejects with error message when stderr is empty", async () => { + (execFile as unknown as ReturnType).mockImplementation( + ( + cmd: string, + args: string[], + opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + void cmd; + void args; + void opts; + cb(new Error("command failed"), "", ""); + }, + ); + + await expect(triggerSync()).rejects.toThrow("command failed"); + }); +}); + // -- getCanonicalStatus -- describe("getCanonicalStatus()", () => { diff --git a/packages/ui/src/commands/config-sync.ts b/packages/ui/src/commands/config-sync.ts index 88672b8..0392792 100644 --- a/packages/ui/src/commands/config-sync.ts +++ b/packages/ui/src/commands/config-sync.ts @@ -23,11 +23,11 @@ export async function triggerInstall(): Promise { } /** - * Trigger the canonical sync command. - * Returns a promise that resolves with stdout or rejects with stderr. + * Trigger config-scoped MCP sync for detected tools. + * Refuses when mcp.config.ts selects no servers (never copies imports). */ export async function triggerSync(): Promise { - return execCommand("npx", ["ai-tools", "sync"]); + return execCommand("npx", ["ai-tools", "mcp", "sync"]); } /** diff --git a/packages/ui/src/design-system.test.ts b/packages/ui/src/design-system.test.ts deleted file mode 100644 index 8f26fc8..0000000 --- a/packages/ui/src/design-system.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - TABLES, - getBadgeStyle, - getButtonStyle, - getContainerConfig, - getStatusConfig, - selectionBackground, - textStyle, - zebraBackground, -} from "./design-system.js"; - -describe("design-system", () => { - it("returns theme-aware container styles for every variant", () => { - for (const variant of ["default", "elevated", "overlay", "card"] as const) { - const config = getContainerConfig(variant); - expect(config.bg).toEqual(expect.objectContaining({ r: expect.any(Number) })); - expect(config.borderColor).toEqual(expect.objectContaining({ g: expect.any(Number) })); - expect(config.fg).toEqual(expect.objectContaining({ b: expect.any(Number) })); - } - }); - - it("returns button styles for every variant", () => { - expect(getButtonStyle("primary").bold).toBe(true); - expect(getButtonStyle("secondary").bold).toBe(false); - expect(getButtonStyle("ghost").bold).toBe(false); - expect(getButtonStyle("danger").bold).toBe(true); - }); - - it("returns badge styles for every variant", () => { - for (const variant of ["success", "warning", "error", "info", "neutral"] as const) { - const style = getBadgeStyle(variant); - expect(style.fg.r).toEqual(expect.any(Number)); - expect(style.bg.b).toEqual(expect.any(Number)); - } - }); - - it("normalizes status aliases into semantic configs", () => { - expect(getStatusConfig("configured")).toMatchObject({ label: "healthy" }); - expect(getStatusConfig("stopped")).toMatchObject({ label: "warning", icon: "\u25D0" }); - expect(getStatusConfig("not-installed")).toMatchObject({ label: "error" }); - expect(getStatusConfig("anything-else")).toMatchObject({ label: "unknown" }); - }); - - it("returns text hierarchy styles", () => { - expect(textStyle("primary").bold).toBe(true); - expect(textStyle("secondary").fg).toBeDefined(); - expect(textStyle("tertiary").fg).toBeDefined(); - expect(textStyle("muted").italic).toBe(true); - }); - - it("returns deterministic selection and zebra backgrounds", () => { - expect(selectionBackground({ r: 10, g: 20, b: 30 })).toEqual( - expect.objectContaining({ - r: expect.any(Number), - g: expect.any(Number), - b: expect.any(Number), - }), - ); - expect(selectionBackground()).toEqual(expect.objectContaining({ r: expect.any(Number) })); - expect(zebraBackground(true)).toEqual(expect.objectContaining({ r: expect.any(Number) })); - expect(zebraBackground(false)).toBeUndefined(); - }); - - it("keeps table presets stable for core views", () => { - expect(TABLES.sessions.columns.map((column) => column.key)).toEqual([ - "tool", - "title", - "messages", - "updated", - ]); - expect(TABLES.tools.rowHeight).toBe(3); - expect(TABLES.config.selection).toBe(false); - }); -}); diff --git a/packages/ui/src/design-system.ts b/packages/ui/src/design-system.ts deleted file mode 100644 index 2666699..0000000 --- a/packages/ui/src/design-system.ts +++ /dev/null @@ -1,398 +0,0 @@ -/** - * ai-tools Design System - * - * A comprehensive design system for the TUI that ensures: - * 1. Consistent colors everywhere (no hardcoded blacks) - * 2. Proper layout sizing - * 3. Smart container borders - * 4. Unified component styling - * - * All color-producing functions read from the active theme context - * so they respond correctly to light, dimmed, nord, etc. themes. - */ - -import type { RgbColor } from "./theme.js"; -import { - // Static default tokens (re-exported for backward compat) - BRAND, - STATUS, - SURFACE, - TEXT, - - // Theme-aware helpers - getActiveTokens, - - // Powerline (now functions) - PL_HEALTH_BG_OK, - PL_HEALTH_BG_WARN, - PL_HEALTH_BG_ERR, - PL_HINTS_BG, - - // Helper functions - dimColor, - tintBg, - lerpColor, - glowColor, - - // Icon system - getIconChar, -} from "./theme.js"; - -export { - BRAND, - STATUS, - SURFACE, - TEXT, - dimColor, - tintBg, - lerpColor, - glowColor, - PL_HEALTH_BG_OK, - PL_HEALTH_BG_WARN, - PL_HEALTH_BG_ERR, - PL_HINTS_BG, -}; - -// ───────────────────────────────────────────────────────────── -// CONTAINER SYSTEM -// ───────────────────────────────────────────────────────────── - -export type ContainerVariant = "default" | "elevated" | "overlay" | "card"; - -export type BorderStyle = "none" | "subtle" | "accent" | "active"; - -export type ContainerConfig = { - /** Background color */ - bg: RgbColor; - /** Border color */ - borderColor: RgbColor; - /** Text color */ - fg: RgbColor; - /** Secondary text color */ - fgSecondary: RgbColor; - /** Tertiary text color */ - fgTertiary: RgbColor; -}; - -/** - * Get container configuration based on variant. - * Reads from the active theme so light/dark/nord etc. are all correct. - */ -export function getContainerConfig(variant: ContainerVariant): ContainerConfig { - const tokens = getActiveTokens(); - switch (variant) { - case "elevated": - return { - bg: tokens.surface.elevated, - borderColor: tokens.border.subtle, - fg: tokens.text.primary, - fgSecondary: tokens.text.secondary, - fgTertiary: tokens.text.tertiary, - }; - case "overlay": - return { - bg: tokens.surface.overlay, - borderColor: tokens.border.default, - fg: tokens.text.primary, - fgSecondary: tokens.text.secondary, - fgTertiary: tokens.text.tertiary, - }; - case "card": - return { - bg: tokens.surface.card, - borderColor: tokens.border.subtle, - fg: tokens.text.primary, - fgSecondary: tokens.text.secondary, - fgTertiary: tokens.text.tertiary, - }; - case "default": - default: - return { - bg: tokens.surface.base, - borderColor: tokens.border.subtle, - fg: tokens.text.primary, - fgSecondary: tokens.text.secondary, - fgTertiary: tokens.text.tertiary, - }; - } -} - -// ───────────────────────────────────────────────────────────── -// COMPONENT STYLES -// ───────────────────────────────────────────────────────────── - -export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"; - -export type BadgeVariant = "success" | "warning" | "error" | "info" | "neutral"; - -export type ProgressVariant = "blocks" | "line" | "gradient"; - -export interface ButtonStyle { - fg: RgbColor; - bg: RgbColor; - bold: boolean; -} - -export interface BadgeStyle { - fg: RgbColor; - bg: RgbColor; -} - -/** - * Get button styles based on variant. - * Theme-aware: tintBg reads from the active theme's surface. - */ -export function getButtonStyle(variant: ButtonVariant): ButtonStyle { - const tokens = getActiveTokens(); - switch (variant) { - case "primary": - return { - fg: tokens.text.primary, - bg: tintBg(tokens.brand.base, 0.2), - bold: true, - }; - case "secondary": - return { - fg: tokens.text.secondary, - bg: tokens.surface.elevated, - bold: false, - }; - case "ghost": - return { - fg: tokens.text.secondary, - bg: tokens.surface.base, - bold: false, - }; - case "danger": - return { - fg: tokens.status.error, - bg: tintBg(tokens.status.error, 0.15), - bold: true, - }; - } -} - -/** - * Get badge styles based on variant. - * Theme-aware: uses active tokens for colors and tintBg for backgrounds. - */ -export function getBadgeStyle(variant: BadgeVariant): BadgeStyle { - const tokens = getActiveTokens(); - switch (variant) { - case "success": - return { fg: tokens.status.success, bg: tintBg(tokens.status.success, 0.15) }; - case "warning": - return { fg: tokens.status.warning, bg: tintBg(tokens.status.warning, 0.15) }; - case "error": - return { fg: tokens.status.error, bg: tintBg(tokens.status.error, 0.15) }; - case "info": - return { fg: tokens.status.info, bg: tintBg(tokens.status.info, 0.15) }; - case "neutral": - return { fg: tokens.text.secondary, bg: tokens.surface.elevated }; - } -} - -// ───────────────────────────────────────────────────────────── -// TABLE SYSTEM -// ───────────────────────────────────────────────────────────── - -export type TableConfig = { - /** Column configurations */ - columns: ColumnConfig[]; - /** Row height */ - rowHeight: number; - /** Header height */ - headerHeight: number; - /** Zebra striping enabled */ - zebraStriping: boolean; - /** Selection enabled */ - selection: boolean; -}; - -export type ColumnConfig = { - /** Column key/ID */ - key: string; - /** Display label */ - label: string; - /** Minimum width */ - minWidth: number; - /** Maximum width */ - maxWidth?: number; - /** Flex grow factor */ - flexGrow?: number; - /** Text alignment */ - align?: "left" | "center" | "right"; -}; - -/** - * Default table configurations - */ -export const TABLES = { - sessions: { - columns: [ - { key: "tool", label: "Tool", minWidth: 14 }, - { key: "title", label: "Title", minWidth: 30, flexGrow: 1 }, - { key: "messages", label: "Msgs", minWidth: 6, align: "right" as const }, - { key: "updated", label: "Updated", minWidth: 12, align: "right" as const }, - ], - rowHeight: 1, - headerHeight: 1, - zebraStriping: true, - selection: true, - }, - tools: { - columns: [ - { key: "name", label: "Tool", minWidth: 20, flexGrow: 1 }, - { key: "status", label: "Status", minWidth: 14 }, - { key: "sessions", label: "Sessions", minWidth: 10, align: "right" as const }, - ], - rowHeight: 3, - headerHeight: 0, - zebraStriping: false, - selection: true, - }, - config: { - columns: [ - { key: "engine", label: "Engine", minWidth: 20, flexGrow: 1 }, - { key: "status", label: "Status", minWidth: 20 }, - ], - rowHeight: 1, - headerHeight: 1, - zebraStriping: true, - selection: false, - }, -}; - -// ───────────────────────────────────────────────────────────── -// STATUS COLORS -// ───────────────────────────────────────────────────────────── - -export interface StatusConfig { - color: RgbColor; - bg: RgbColor; - icon: string; - label: string; -} - -/** - * Get status configuration with theme-aware colors. - * Powerline background getters (PL_HEALTH_BG_*) are now functions - * that derive from the active theme. - */ -export function getStatusConfig(status: string): StatusConfig { - const s = status.toLowerCase(); - const tokens = getActiveTokens(); - - if (s === "healthy" || s === "ok" || s === "available" || s === "configured") { - return { - color: tokens.status.success, - bg: PL_HEALTH_BG_OK(), - icon: getIconChar("status.active"), - label: "healthy", - }; - } - - if (s === "stale" || s === "warning" || s === "stopped") { - return { - color: tokens.status.warning, - bg: PL_HEALTH_BG_WARN(), - icon: "\u25D0", - label: "warning", - }; - } - - if (s === "error" || s === "unhealthy" || s === "not-installed") { - return { - color: tokens.status.error, - bg: PL_HEALTH_BG_ERR(), - icon: getIconChar("status.error"), - label: "error", - }; - } - - // Default/neutral - return { - color: tokens.text.secondary, - bg: PL_HINTS_BG(), - icon: getIconChar("status.pending"), - label: "unknown", - }; -} - -// ───────────────────────────────────────────────────────────── -// ICONS & SYMBOLS -// ───────────────────────────────────────────────────────────── - -export const ICONS = { - // Status indicators - success: getIconChar("status.success"), - error: getIconChar("status.error"), - warning: getIconChar("status.warning"), - info: getIconChar("status.info"), - - // Navigation - selected: getIconChar("select.selected"), - unselected: getIconChar("select.unselected"), - collapsed: getIconChar("select.collapsed"), - expanded: getIconChar("select.expanded"), - - // Actions - launch: getIconChar("action.launch"), - kill: getIconChar("status.stopped"), - refresh: getIconChar("action.refresh"), - search: getIconChar("action.search"), - close: getIconChar("nav.close"), - back: "\u21B5", - - // Misc - bullet: getIconChar("ui.bullet"), - diamond: getIconChar("brand.logo"), - star: "\u2605", - folder: getIconChar("session.folder"), - file: getIconChar("session.file"), -}; - -// ───────────────────────────────────────────────────────────── -// HELPER FUNCTIONS -// ───────────────────────────────────────────────────────────── - -/** - * Create a consistent text style with proper hierarchy. - * Theme-aware: reads from active tokens. - */ -export function textStyle(variant: "primary" | "secondary" | "tertiary" | "muted"): { - fg: RgbColor; - bold?: boolean; - italic?: boolean; -} { - const tokens = getActiveTokens(); - switch (variant) { - case "primary": - return { fg: tokens.text.primary, bold: true }; - case "secondary": - return { fg: tokens.text.secondary }; - case "tertiary": - return { fg: tokens.text.tertiary }; - case "muted": - return { fg: tokens.text.tertiary, italic: true }; - } -} - -/** - * Get selection background with brand tint. - * Theme-aware: tintBg uses the active theme's surface. - */ -export function selectionBackground(brandColor?: RgbColor): RgbColor { - if (brandColor) { - return tintBg(brandColor, 0.22); - } - return getActiveTokens().surface.subtle; -} - -/** - * Get zebra stripe background. - * Theme-aware: reads from active tokens. - */ -export function zebraBackground(isOdd: boolean): RgbColor | undefined { - return isOdd ? getActiveTokens().surface.elevated : undefined; -} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index afc319e..7354f35 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -45,25 +45,6 @@ export type { ThemeName, UiPreferences } from "./preferences.js"; export { launchTool, buildLaunchOptions, buildHandoffLaunchOptions } from "./launcher.js"; export type { LaunchOptions, LaunchResult, SpawnFn } from "./launcher.js"; -// Spawn strategies (tmux wrapper for embedded tools) -export { - checkSpawnStrategies, - getRecommendedStrategy, - spawnTool, - isTmuxRecommended, -} from "./spawn/index.js"; -export type { SpawnStrategy, SpawnResult, SpawnConfig } from "./spawn/index.js"; - -// Command palette -export { - getActionCommands, - getSessionCommands, - getToolCommands, - getAllCommands, - filterCommands, -} from "./command-palette.js"; -export type { CommandItem } from "./command-palette.js"; - // View modules export { renderToolsView, getToolsKeyHints } from "./views/tools.js"; export type { ToolsViewState } from "./views/tools.js"; diff --git a/packages/ui/src/preferences.ts b/packages/ui/src/preferences.ts index 795cf20..4ac2869 100644 --- a/packages/ui/src/preferences.ts +++ b/packages/ui/src/preferences.ts @@ -231,10 +231,10 @@ export const DEFAULT_KEYBINDINGS: KeybindingDef[] = [ }, { id: "config-sync", - label: "Sync config", + label: "Install MCP servers", defaultKey: "s", context: "config", - description: "Sync generated configs into target locations", + description: "Run ai-tools mcp sync from mcp.config.ts for detected tools", }, { id: "config-detect", diff --git a/packages/ui/src/runtime/action-executor.test.ts b/packages/ui/src/runtime/action-executor.test.ts index 716e74d..16b215d 100644 --- a/packages/ui/src/runtime/action-executor.test.ts +++ b/packages/ui/src/runtime/action-executor.test.ts @@ -272,7 +272,7 @@ describe("config action handlers", () => { expect(app.getState().configLastAction).toEqual({ type: "sync", result: "success", - message: "Config synced", + message: "MCP servers installed", }); }); @@ -289,7 +289,7 @@ describe("config action handlers", () => { expect(app.getState().configLastAction).toEqual({ type: "sync", result: "error", - message: "Config sync failed", + message: "MCP install failed", }); }); diff --git a/packages/ui/src/runtime/action-executor.ts b/packages/ui/src/runtime/action-executor.ts index ba7b95a..8bb6f08 100644 --- a/packages/ui/src/runtime/action-executor.ts +++ b/packages/ui/src/runtime/action-executor.ts @@ -466,7 +466,7 @@ export function createActionExecutor({ "sync-config": async () => { app.update((s) => ({ ...s, - toasts: addToast(s.toasts, "info", "Syncing config..."), + toasts: addToast(s.toasts, "info", "Installing MCP servers..."), })); try { const { triggerSync } = await import("../commands/config-sync.js"); @@ -474,14 +474,14 @@ export function createActionExecutor({ await reloadConfigData(app); app.update((s) => ({ ...s, - configLastAction: { type: "sync", result: "success", message: "Config synced" }, - toasts: addToast(s.toasts, "success", "Config synced"), + configLastAction: { type: "sync", result: "success", message: "MCP servers installed" }, + toasts: addToast(s.toasts, "success", "MCP servers installed"), })); } catch { app.update((s) => ({ ...s, - configLastAction: { type: "sync", result: "error", message: "Config sync failed" }, - toasts: addToast(s.toasts, "error", "Config sync failed"), + configLastAction: { type: "sync", result: "error", message: "MCP install failed" }, + toasts: addToast(s.toasts, "error", "MCP install failed"), })); } }, diff --git a/packages/ui/src/runtime/state.ts b/packages/ui/src/runtime/state.ts index 7efb31b..d153784 100644 --- a/packages/ui/src/runtime/state.ts +++ b/packages/ui/src/runtime/state.ts @@ -54,8 +54,6 @@ export type HandoffState = { }; export type OverlayState = { - commandPaletteOpen: boolean; - commandPaletteQuery: string; helpOpen: boolean; }; diff --git a/packages/ui/src/runtime/terminal-command-executor.test.ts b/packages/ui/src/runtime/terminal-command-executor.test.ts new file mode 100644 index 0000000..37219a9 --- /dev/null +++ b/packages/ui/src/runtime/terminal-command-executor.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi } from "vitest"; +import type { App } from "@rezi-ui/core"; +import type { PaneManager } from "../terminal/manager.js"; +import type { CommandAction } from "../terminal/input.js"; +import type { TuiState } from "../tui.js"; +import { createInitialTuiState } from "../tui.js"; +import { createTerminalCommandExecutor } from "./terminal-command-executor.js"; + +function makeState(overrides: Partial = {}): TuiState { + return { + ...createInitialTuiState(), + loading: { tools: false, sessions: false, config: false }, + toasts: [], + runningTools: [], + sessions: [], + ...overrides, + }; +} + +function makeApp(initial: TuiState = makeState()) { + let state = initial; + const app = { + update: vi.fn((updater: (current: TuiState) => TuiState) => { + state = updater(state); + }), + } as unknown as App; + return { + app, + current: () => state, + }; +} + +function makePaneManager(overrides: Partial = {}): PaneManager { + return { + focusPane: vi.fn().mockReturnValue(true), + getState: vi.fn().mockReturnValue({ + panes: [], + activePaneIndex: 0, + }), + closePane: vi.fn().mockReturnValue(true), + getPaneCount: vi.fn().mockReturnValue(0), + nextPane: vi.fn(), + prevPane: vi.fn(), + writeToActivePane: vi.fn(), + scrollActivePane: vi.fn(), + ...overrides, + } as unknown as PaneManager; +} + +function runAction( + cmd: CommandAction, + opts?: { state?: TuiState; paneManager?: PaneManager }, +): { state: () => TuiState; paneManager: PaneManager } { + const { app, current } = makeApp(opts?.state ?? makeState()); + const paneManager = opts?.paneManager ?? makePaneManager(); + createTerminalCommandExecutor({ app, paneManager })(cmd); + return { state: current, paneManager }; +} + +describe("createTerminalCommandExecutor", () => { + it("switch-tab focuses the pane and stays in terminal mode", () => { + const { state, paneManager } = runAction({ type: "switch-tab", index: 2 }); + expect(paneManager.focusPane).toHaveBeenCalledWith(2); + expect(state().inputMode).toBe("terminal"); + expect(state().toasts).toEqual([]); + }); + + it("switch-tab toasts when the tab does not exist", () => { + const paneManager = makePaneManager({ focusPane: vi.fn().mockReturnValue(false) }); + const { state } = runAction({ type: "switch-tab", index: 3 }, { paneManager }); + expect(state().inputMode).toBe("terminal"); + expect(state().toasts).toEqual([ + expect.objectContaining({ type: "error", message: "No tab 4" }), + ]); + }); + + it("new-pane returns to the tools dashboard", () => { + const { state } = runAction({ type: "new-pane" }); + expect(state().view).toBe("tools"); + expect(state().inputMode).toBe("dashboard"); + expect(state().toasts).toEqual([ + expect.objectContaining({ + type: "info", + message: "Select a tool to open in a new pane", + }), + ]); + }); + + it("close-pane with no remaining panes clears running tools", () => { + const paneManager = makePaneManager({ + getState: vi.fn().mockReturnValue({ + panes: [{ status: "running", pid: 11, toolId: "claude" }], + activePaneIndex: 0, + }), + closePane: vi.fn().mockReturnValue(true), + getPaneCount: vi.fn().mockReturnValue(0), + }); + const { state } = runAction( + { type: "close-pane" }, + { + paneManager, + state: makeState({ + runningTools: [{ toolId: "claude", pid: 11, startedAt: "now" }], + }), + }, + ); + expect(paneManager.closePane).toHaveBeenCalledWith(0); + expect(state().view).toBe("tools"); + expect(state().inputMode).toBe("dashboard"); + expect(state().runningTools).toEqual([]); + }); + + it("close-pane with remaining panes keeps matching running tools", () => { + const paneManager = makePaneManager({ + getState: vi + .fn() + .mockReturnValueOnce({ + panes: [ + { status: "running", pid: 11, toolId: "claude" }, + { status: "running", pid: 22, toolId: "cursor" }, + ], + activePaneIndex: 0, + }) + .mockReturnValue({ + panes: [{ status: "running", pid: 22, toolId: "cursor" }], + activePaneIndex: 0, + }), + closePane: vi.fn().mockReturnValue(true), + getPaneCount: vi.fn().mockReturnValue(1), + }); + const { state } = runAction( + { type: "close-pane" }, + { + paneManager, + state: makeState({ + runningTools: [ + { toolId: "claude", pid: 11, startedAt: "a" }, + { toolId: "cursor", pid: 22, startedAt: "b" }, + ], + }), + }, + ); + expect(state().inputMode).toBe("terminal"); + expect(state().runningTools).toEqual([{ toolId: "cursor", pid: 22, startedAt: "b" }]); + }); + + it("close-pane does nothing when closePane fails", () => { + const paneManager = makePaneManager({ + getState: vi.fn().mockReturnValue({ panes: [], activePaneIndex: 0 }), + closePane: vi.fn().mockReturnValue(false), + }); + const { state } = runAction( + { type: "close-pane" }, + { paneManager, state: makeState({ view: "sessions" }) }, + ); + expect(state().view).toBe("sessions"); + }); + + it("next-tab and prev-tab cycle panes", () => { + const paneManager = makePaneManager(); + const next = runAction({ type: "next-tab" }, { paneManager }); + expect(paneManager.nextPane).toHaveBeenCalledTimes(1); + expect(next.state().inputMode).toBe("terminal"); + + const prev = runAction({ type: "prev-tab" }, { paneManager }); + expect(paneManager.prevPane).toHaveBeenCalledTimes(1); + expect(prev.state().inputMode).toBe("terminal"); + }); + + it("handoff preselects a session matching the active pane tool", () => { + const paneManager = makePaneManager({ + getState: vi.fn().mockReturnValue({ + panes: [{ toolId: "cursor" }], + activePaneIndex: 0, + }), + }); + const { state } = runAction( + { type: "handoff" }, + { + paneManager, + state: makeState({ + sessions: [ + { + id: "s1", + tool: "claude", + toolName: "Claude", + title: "one", + messageCount: 1, + updatedAt: "t", + }, + { + id: "s2", + tool: "cursor", + toolName: "Cursor", + title: "two", + messageCount: 2, + updatedAt: "t", + }, + ], + }), + }, + ); + expect(state().view).toBe("handoff"); + expect(state().inputMode).toBe("dashboard"); + expect(state().handoffStep).toBe(0); + expect(state().handoffSessionId).toBeNull(); + expect(state().handoffTargetTool).toBeNull(); + expect(state().handoffPreview).toBeNull(); + expect(state().selectedSessionIndex).toBe(1); + expect(state().selectedTargetIndex).toBe(0); + }); + + it("handoff defaults to session 0 when the pane has no matching session", () => { + const paneManager = makePaneManager({ + getState: vi.fn().mockReturnValue({ panes: [], activePaneIndex: 0 }), + }); + const { state } = runAction({ type: "handoff" }, { paneManager }); + expect(state().selectedSessionIndex).toBe(0); + }); + + it("dashboard, send-leader, cancel, help, and scrollback update state", () => { + const dash = runAction({ type: "dashboard" }); + expect(dash.state().view).toBe("tools"); + expect(dash.state().inputMode).toBe("dashboard"); + + const leader = runAction({ type: "send-leader" }); + expect(leader.paneManager.writeToActivePane).toHaveBeenCalledWith("\x01"); + expect(leader.state().inputMode).toBe("terminal"); + + const cancel = runAction({ type: "cancel" }); + expect(cancel.state().inputMode).toBe("terminal"); + + const help = runAction({ type: "help" }); + expect(help.state().toasts).toEqual([ + expect.objectContaining({ + type: "info", + message: "Ctrl+A then: c:New x:Close n/p:Nav d:Dash h:Handoff", + }), + ]); + + const up = runAction({ type: "scrollback-up" }); + expect(up.paneManager.scrollActivePane).toHaveBeenCalledWith(-10); + const down = runAction({ type: "scrollback-down" }); + expect(down.paneManager.scrollActivePane).toHaveBeenCalledWith(10); + }); +}); diff --git a/packages/ui/src/spawn/index.ts b/packages/ui/src/spawn/index.ts deleted file mode 100644 index a841b92..0000000 --- a/packages/ui/src/spawn/index.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Spawn strategy - chooses between tmux wrapper and direct PTY spawning. - * - * This provides a unified interface for spawning tools, with automatic - * fallback based on platform and tmux availability. - */ - -import { - checkTmuxAvailable, - getTmuxInstallInstructions, - createTmuxManager, - TmuxManager, -} from "../tmux/index.js"; -import type { TmuxPane, TmuxManager as TmuxManagerInstance } from "../tmux/index.js"; -import type { PtyManager } from "../widgets/terminal-pane.js"; - -export type ToolProcess = { - type: "tmux" | "pty"; - pid?: number; - kill: (signal?: string) => void; - onExit?: (callback: (exit: { exitCode: number; signal?: number }) => void) => void; -}; - -export type SpawnStrategy = "tmux" | "pty"; - -export type SpawnResult = { - strategy: SpawnStrategy; - toolProcess?: ToolProcess; - tmuxPane?: TmuxPane; - tmuxManager?: TmuxManagerInstance; - success: boolean; - error?: string; -}; - -/** - * Check what spawn strategies are available on this system. - */ -export async function checkSpawnStrategies(): Promise<{ - tmux: { available: boolean; version?: string; error?: string }; - pty: { available: boolean }; -}> { - const tmuxCheck = await checkTmuxAvailable(); - - let ptyAvailable = false; - try { - const pty = await import("node-pty"); - ptyAvailable = typeof pty.spawn === "function"; - } catch { - ptyAvailable = false; - } - - return { - tmux: tmuxCheck, - pty: { available: ptyAvailable }, - }; -} - -/** - * Get recommended spawn strategy based on platform and availability. - */ -export async function getRecommendedStrategy(): Promise<{ - strategy: SpawnStrategy; - reason: string; - tmuxInstallHint?: string; -}> { - const strategies = await checkSpawnStrategies(); - - // Prefer tmux on supported platforms - if (TmuxManager.isSupported() && strategies.tmux.available) { - return { - strategy: "tmux", - reason: "tmux provides better fullscreen app handling", - }; - } - - // Fall back to direct PTY - if (strategies.pty.available) { - if (!TmuxManager.isSupported()) { - return { - strategy: "pty", - reason: "tmux not supported on this platform (Windows), using direct PTY", - }; - } - if (!strategies.tmux.available) { - return { - strategy: "pty", - reason: "tmux not installed, using direct PTY", - tmuxInstallHint: getTmuxInstallInstructions(), - }; - } - } - - // No options available - return { - strategy: "pty", - reason: "No spawn strategy available", - tmuxInstallHint: TmuxManager.isSupported() ? getTmuxInstallInstructions() : undefined, - }; -} - -/** - * SpawnConfig - configuration for spawning a tool. - */ -export type SpawnConfig = { - toolId: string; - command: string; - args?: string[]; - cwd?: string; - env?: Record; - cols?: number; - rows?: number; - sessionName?: string; -}; - -/** - * Spawn a tool using the recommended strategy. - */ -export async function spawnTool(config: SpawnConfig, ptyManager: PtyManager): Promise { - const recommended = await getRecommendedStrategy(); - - switch (recommended.strategy) { - case "tmux": - return spawnWithTmux(config); - case "pty": - default: - return spawnWithPty(config, ptyManager); - } -} - -/** - * Spawn a tool using tmux (recommended for best compatibility). - */ -async function spawnWithTmux(config: SpawnConfig): Promise { - const socketName = "ai-tools-" + Math.random().toString(36).substring(2, 8); - const sessionName = config.sessionName || `tool-${config.toolId}-${Date.now()}`; - const tmux = createTmuxManager(socketName); - - try { - const pane = await tmux.spawnSession(config.command, { - sessionName, - paneTitle: config.toolId, - cwd: config.cwd, - env: config.env, - }); - - return { - strategy: "tmux", - success: true, - tmuxPane: pane, - tmuxManager: tmux, - toolProcess: { - type: "tmux", - kill: () => { - tmux.killSession(sessionName).catch(() => { - // Ignore errors on kill - }); - }, - }, - }; - } catch (error) { - return { - strategy: "tmux", - success: false, - error: error instanceof Error ? error.message : String(error), - tmuxManager: tmux, - }; - } -} - -/** - * Spawn a tool using direct PTY (fallback when tmux unavailable). - */ -async function spawnWithPty(config: SpawnConfig, ptyManager: PtyManager): Promise { - try { - const pty = await ptyManager.createPty(config.command, config.args ?? [], { - cols: config.cols ?? 80, - rows: config.rows ?? 24, - cwd: config.cwd, - env: config.env, - }); - - return { - strategy: "pty", - success: true, - toolProcess: { - type: "pty", - pid: pty.pid, - kill: pty.kill, - onExit: pty.onExit, - }, - }; - } catch (error) { - return { - strategy: "pty", - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } -} - -/** - * Check if tmux is recommended for the current environment. - */ -export async function isTmuxRecommended(): Promise { - const recommended = await getRecommendedStrategy(); - return recommended.strategy === "tmux"; -} diff --git a/packages/ui/src/theme/icons.test.ts b/packages/ui/src/theme/icons.test.ts new file mode 100644 index 0000000..4c1ad22 --- /dev/null +++ b/packages/ui/src/theme/icons.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { STATUS, TOOLS } from "./tokens.js"; +import { + getIcon, + getIconChar, + getStatusIcon, + getToolColor, + getToolIcon, + getViewIcon, + iconSpan, + makeIconProps, +} from "./icons.js"; + +describe("getIcon / getIconChar", () => { + it("returns a registered icon and its fallback character", () => { + const icon = getIcon("status.success"); + expect(icon.glyph.length).toBeGreaterThan(0); + expect(icon.fallback.length).toBeGreaterThan(0); + expect(getIconChar("status.success")).toBe(icon.glyph); + expect(getIconChar("status.success", true)).toBe(icon.fallback); + }); + + it("falls back to a question mark for unknown names", () => { + expect(getIcon("not.a.real.icon" as never)).toEqual({ + glyph: "?", + fallback: "[?]", + width: 1, + }); + }); +}); + +describe("getToolIcon / getToolColor", () => { + it.each([ + ["claude", "tool.claude", TOOLS.claude], + ["claude-code", "tool.claude", TOOLS.claude], + ["codex", "tool.codex", TOOLS.codex], + ["gemini-cli", "tool.gemini", TOOLS.gemini], + ["cursor", "tool.cursor", TOOLS.cursor], + ["opencode", "tool.opencode", TOOLS.opencode], + ["amp", "tool.amp", TOOLS.amp], + ["cline", "tool.cline", TOOLS.cline], + ["zcode", "tool.generic", STATUS.neutral], + ] as const)("%s maps to %s", (id, icon, color) => { + expect(getToolIcon(id)).toBe(icon); + expect(getToolColor(id)).toEqual(color); + }); +}); + +describe("getViewIcon / getStatusIcon", () => { + it("maps every view", () => { + expect(getViewIcon("tools")).toBe("nav.tools"); + expect(getViewIcon("sessions")).toBe("nav.sessions"); + expect(getViewIcon("handoff")).toBe("nav.handoff"); + expect(getViewIcon("config")).toBe("nav.config"); + expect(getViewIcon("terminal")).toBe("nav.terminal"); + }); + + it("maps every status", () => { + expect(getStatusIcon("success")).toBe("status.success"); + expect(getStatusIcon("error")).toBe("status.error"); + expect(getStatusIcon("warning")).toBe("status.warning"); + expect(getStatusIcon("info")).toBe("status.info"); + expect(getStatusIcon("pending")).toBe("status.pending"); + expect(getStatusIcon("running")).toBe("status.running"); + expect(getStatusIcon("stopped")).toBe("status.stopped"); + expect(getStatusIcon("stale")).toBe("status.stale"); + expect(getStatusIcon("unknown")).toBe("status.unknown"); + }); +}); + +describe("makeIconProps / iconSpan", () => { + it("uses glyph by default and fallback when requested", () => { + const icon = getIcon("status.info"); + expect(makeIconProps("status.info")[0]).toBe(icon.glyph); + expect(makeIconProps("status.info", undefined, true)[0]).toBe(icon.fallback); + }); + + it("passes style through, defaulting to empty", () => { + expect(makeIconProps("status.info")[1]).toEqual({ style: {}, fallback: false }); + expect(makeIconProps("status.info", { bold: true })[1]).toEqual({ + style: { bold: true }, + fallback: false, + }); + }); + + it("builds a rich-text span with optional color", () => { + const icon = getIcon("status.error"); + expect(iconSpan("status.error")).toEqual({ text: icon.glyph, style: undefined }); + expect(iconSpan("status.error", STATUS.error, true)).toEqual({ + text: icon.fallback, + style: { fg: STATUS.error, bold: true }, + }); + }); +}); diff --git a/packages/ui/src/theme/spacing.test.ts b/packages/ui/src/theme/spacing.test.ts new file mode 100644 index 0000000..7e328a4 --- /dev/null +++ b/packages/ui/src/theme/spacing.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { margin, pad, spacingValue } from "./spacing.js"; + +describe("spacingValue", () => { + it("returns the numeric scale for each token", () => { + expect(spacingValue("none")).toBe(0); + expect(spacingValue("xs")).toBe(1); + expect(spacingValue("md")).toBe(2); + expect(spacingValue("2xl")).toBe(6); + }); +}); + +describe("pad", () => { + it("uses all-sides padding when provided", () => { + expect(pad("md")).toEqual({ p: "md" }); + }); + + it("uses horizontal and vertical tokens independently", () => { + expect(pad(undefined, "sm", "lg")).toEqual({ px: "sm", py: "lg" }); + }); + + it("omits missing axis tokens", () => { + expect(pad()).toEqual({}); + expect(pad(undefined, "xs")).toEqual({ px: "xs" }); + expect(pad(undefined, undefined, "xl")).toEqual({ py: "xl" }); + }); +}); + +describe("margin", () => { + it("uses all-sides margin when provided", () => { + expect(margin("lg")).toEqual({ m: "lg" }); + }); + + it("uses horizontal and vertical tokens independently", () => { + expect(margin(undefined, "md", "sm")).toEqual({ mx: "md", my: "sm" }); + }); + + it("omits missing axis tokens", () => { + expect(margin()).toEqual({}); + expect(margin(undefined, "xl")).toEqual({ mx: "xl" }); + expect(margin(undefined, undefined, "none")).toEqual({ my: "none" }); + }); +}); diff --git a/packages/ui/src/tmux/index.ts b/packages/ui/src/tmux/index.ts deleted file mode 100644 index 5ed41e0..0000000 --- a/packages/ui/src/tmux/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - TmuxManager, - createTmuxManager, - checkTmuxAvailable, - getTmuxInstallInstructions, - type TmuxSpawnOptions, - type TmuxPane, - type TmuxResult, -} from "./manager.js"; diff --git a/packages/ui/src/tmux/manager.ts b/packages/ui/src/tmux/manager.ts deleted file mode 100644 index 2731d0b..0000000 --- a/packages/ui/src/tmux/manager.ts +++ /dev/null @@ -1,377 +0,0 @@ -/** - * TmuxManager — wraps tmux for terminal multiplexing. - * - * Instead of spawning tools directly in node-pty, we spawn them inside - * tmux panes. This gives us: - * - Proper handling of fullscreen apps (alternate screen) - * - Built-in pane management (split, zoom, resize) - * - Session persistence - * - Battle-tested compatibility with all interactive tools - */ - -import { spawn } from "node:child_process"; -import { platform } from "node:os"; - -export type TmuxSpawnOptions = { - sessionName: string; - paneTitle?: string; - cwd?: string; - env?: Record; -}; - -export type TmuxPane = { - sessionName: string; - windowIndex: number; - paneId: string; - pid?: number; -}; - -/** - * Result of a tmux command execution. - */ -export type TmuxResult = { - success: boolean; - output: string; - exitCode: number; -}; - -/** - * Check if tmux is available on the system. - */ -export async function checkTmuxAvailable(): Promise<{ - available: boolean; - version?: string; - error?: string; -}> { - return new Promise((resolve) => { - const proc = spawn("tmux", ["-V"], { shell: true }); - let output = ""; - - proc.stdout?.on("data", (data) => { - output += data.toString(); - }); - - proc.on("close", (code) => { - if (code === 0 && output.trim().startsWith("tmux")) { - const version = output.trim().split(" ")[1]; - resolve({ available: true, version }); - } else { - resolve({ available: false, error: "tmux not found" }); - } - }); - - proc.on("error", () => { - resolve({ available: false, error: "failed to run tmux" }); - }); - }); -} - -/** - * Get platform-specific install instructions. - */ -export function getTmuxInstallInstructions(): string { - const os = platform(); - switch (os) { - case "darwin": - return "brew install tmux"; - case "linux": - return "apt install tmux # or: yum install tmux, or: pacman -S tmux"; - case "win32": - return "Windows: Use WSL or install tmux via Git Bash/MSYS2"; - default: - return "Install tmux via your system's package manager"; - } -} - -/** - * Execute a tmux command and return the result. - */ -function tmuxExec(args: string[]): Promise { - return new Promise((resolve) => { - const proc = spawn("tmux", args, { shell: true }); - let stdout = ""; - let stderr = ""; - - proc.stdout?.on("data", (data) => { - stdout += data.toString(); - }); - - proc.stderr?.on("data", (data) => { - stderr += data.toString(); - }); - - proc.on("close", (code) => { - resolve({ - success: code === 0, - output: stdout + stderr, - exitCode: code ?? 1, - }); - }); - - proc.on("error", (err) => { - resolve({ - success: false, - output: err.message, - exitCode: 1, - }); - }); - }); -} - -/** - * TmuxManager handles creating and managing tmux sessions and panes. - */ -export class TmuxManager { - private socketName: string; - private baseSessionName: string; - - /** - * @param socketName - Unique name for the tmux socket (e.g., "ai-tools") - */ - constructor(socketName: string = "ai-tools") { - this.socketName = socketName; - this.baseSessionName = "ai-wrapper"; - } - - /** - * Run a tmux command with the socket name. - */ - private async run(args: string[]): Promise { - return tmuxExec(["-L", this.socketName, ...args]); - } - - /** - * Kill any existing session with this socket and start fresh. - */ - async reset(): Promise { - await this.run(["kill-session", "-t", this.baseSessionName]).catch(() => { - // Ignore errors if session doesn't exist - }); - } - - /** - * Create a new session with a running command in the first pane. - * Returns the window index and pane ID. - */ - async spawnSession(command: string, options: TmuxSpawnOptions): Promise { - const { sessionName, paneTitle, cwd } = options; - - // Build the command - wrap in bash to ensure shell expansion - const cmd = cwd ? `cd "${cwd}" && ${command}` : command; - - // Create new session with command running - // -d: don't attach (detached) - // -s: session name - // -n: window name - const result = await this.run([ - "new-session", - "-d", - "-s", - sessionName, - "-n", - paneTitle || "main", - "--", - cmd, - ]); - - if (!result.success) { - throw new Error(`Failed to create tmux session: ${result.output}`); - } - - // Get the window index (usually 0 for first window) - const listResult = await this.run(["list-windows", "-t", sessionName, "-F", "#{window_index}"]); - const windowIndex = parseInt(listResult.output.trim() || "0", 10); - - // Get the pane ID - const paneResult = await this.run(["list-panes", "-t", sessionName, "-F", "#{pane_id}"]); - const paneId = paneResult.output.trim(); - - return { - sessionName, - windowIndex, - paneId, - }; - } - - /** - * Split a pane horizontally and run a command. - */ - async splitPane(sessionName: string, command: string, cwd?: string): Promise { - const cmd = cwd ? `cd "${cwd}" && ${command}` : command; - - // -h: horizontal split - // -t: target session - const result = await this.run(["split-window", "-h", "-t", sessionName, "--", cmd]); - - if (!result.success) { - throw new Error(`Failed to split tmux pane: ${result.output}`); - } - - // Get the new pane ID - const paneResult = await this.run(["list-panes", "-t", sessionName, "-F", "#{pane_id}"]); - const panes = paneResult.output.trim().split("\n"); - const paneId = panes[panes.length - 1]; - - // Get window index - const windowResult = await this.run([ - "list-windows", - "-t", - sessionName, - "-F", - "#{window_index}", - ]); - const windowIndex = parseInt(windowResult.output.trim(), 10); - - return { - sessionName, - windowIndex, - paneId: paneId ?? "", - }; - } - - /** - * Send keys to a pane (simulate input). - */ - async sendKeys(sessionName: string, keys: string): Promise { - await this.run(["send-keys", "-t", sessionName, keys]); - } - - /** - * Send a command to run in a pane. - */ - async sendCommand(sessionName: string, command: string): Promise { - // Split the command to handle newlines properly - const lines = command.split("\n"); - for (const line of lines) { - await this.run(["send-keys", "-t", sessionName, line, "Enter"]); - } - } - - /** - * Zoom a pane (toggle fullscreen). - */ - async toggleZoom(sessionName: string, windowIndex: number = 0): Promise { - const target = `${sessionName}:${windowIndex}`; - await this.run(["resize-pane", "-Z", "-t", target]); - - // Check if we're now zoomed or unzoomed - const statusResult = await this.run(["list-panes", "-t", target, "-F", "#{pane_in_mode}"]); - return statusResult.output.trim() === "1"; - } - - /** - * Resize a pane. - */ - async resizePane( - sessionName: string, - direction: "U" | "D" | "L" | "R", - amount: number = 5, - ): Promise { - await this.run([ - "resize-pane", - `-${direction.toLowerCase()}`, - "-t", - sessionName, - "-p", - String(amount), - ]); - } - - /** - * Select a pane. - */ - async selectPane( - sessionName: string, - direction: "left" | "right" | "up" | "down", - ): Promise { - const dirMap = { left: "L", right: "R", up: "U", down: "D" }; - await this.run(["select-pane", "-t", sessionName, `-${dirMap[direction]}`]); - } - - /** - * Kill a session. - */ - async killSession(sessionName: string): Promise { - await this.run(["kill-session", "-t", sessionName]); - } - - /** - * Attach to a session (take over the terminal). - * This will replace your current process. - */ - async attachSession(sessionName: string): Promise { - const proc = spawn("tmux", ["-L", this.socketName, "attach-session", "-t", sessionName], { - stdio: "inherit", - shell: true, - }); - - return new Promise((_resolve, reject) => { - proc.on("error", reject); - proc.on("close", (code) => { - reject(new Error(`tmux exited with code ${code}`)); - }); - }); - } - - /** - * List all sessions using this socket. - */ - async listSessions(): Promise { - const result = await this.run(["list-sessions"]); - if (!result.success || !result.output.trim()) { - return []; - } - return result.output - .trim() - .split("\n") - .map((line): string => { - const parts = line.split(":"); - return parts[0] ?? ""; - }); - } - - /** - * Get session info. - */ - async getSessionInfo(sessionName: string): Promise<{ - windows: number; - panes: number; - attached: boolean; - } | null> { - const result = await this.run([ - "list-session", - "-F", - "#{session_windows}:#{session_panes}:#{session_attached}", - "-t", - sessionName, - ]); - if (!result.success) { - return null; - } - - const parts = result.output.trim().split(":"); - const windows = parts[0] ?? "0"; - const panes = parts[1] ?? "0"; - const attached = parts[2] ?? "0"; - return { - windows: parseInt(windows, 10), - panes: parseInt(panes, 10), - attached: attached === "1", - }; - } - - /** - * Check if running on a platform that supports tmux. - */ - static isSupported(): boolean { - const os = platform(); - return os === "darwin" || os === "linux"; - } -} - -/** - * Create a default tmux manager instance. - */ -export function createTmuxManager(socketName?: string): TmuxManager { - return new TmuxManager(socketName); -} diff --git a/packages/ui/src/tui.ts b/packages/ui/src/tui.ts index 1d8328a..3178fe4 100644 --- a/packages/ui/src/tui.ts +++ b/packages/ui/src/tui.ts @@ -70,8 +70,6 @@ export function createInitialTuiState(): TuiState { handoffTargetTool: null, handoffPreview: null, selectedTargetIndex: 0, - commandPaletteOpen: false, - commandPaletteQuery: "", helpOpen: false, settingsOpen: false, settingsMenu: createSettingsMenuState("dark"), diff --git a/packages/ui/src/views/config.test.ts b/packages/ui/src/views/config.test.ts index 27bad90..5b6ce5f 100644 --- a/packages/ui/src/views/config.test.ts +++ b/packages/ui/src/views/config.test.ts @@ -185,7 +185,7 @@ describe("renderConfigView", () => { expect(String(vnode.props?.title)).toContain("g:Generate"); expect(String(vnode.props?.title)).toContain("i:Install"); expect(String(vnode.props?.title)).toContain("r:Refresh"); - expect(String(vnode.props?.title)).toContain("s:Sync"); + expect(String(vnode.props?.title)).toContain("s:MCP install"); expect(String(vnode.props?.title)).toContain("d:Detect"); }); @@ -382,7 +382,7 @@ describe("renderConfigView", () => { const texts = allText(vnode); expect(texts.some((t) => t.includes(":Generate"))).toBe(true); expect(texts.some((t) => t.includes(":Install"))).toBe(true); - expect(texts.some((t) => t.includes(":Sync"))).toBe(true); + expect(texts.some((t) => t.includes(":MCP install"))).toBe(true); expect(texts.some((t) => t.includes(":Detect"))).toBe(true); expect(texts.some((t) => t.includes(":Refresh"))).toBe(true); expect(texts.some((t) => t.includes(":$EDITOR"))).toBe(true); @@ -396,7 +396,7 @@ describe("getConfigKeyHints", () => { const hints = getConfigKeyHints({}); expect(hints).toContain("g:Generate"); expect(hints).toContain("i:Install"); - expect(hints).toContain("s:Sync"); + expect(hints).toContain("s:MCP install"); expect(hints).toContain("d:Detect"); expect(hints).toContain("r:Refresh"); expect(hints).toContain("e:$EDITOR"); @@ -405,7 +405,7 @@ describe("getConfigKeyHints", () => { it("respects key overrides", () => { const hints = getConfigKeyHints({ "config-generate": "G", "config-sync": "S" }); expect(hints).toContain("G:Generate"); - expect(hints).toContain("S:Sync"); + expect(hints).toContain("S:MCP install"); // Non-overridden keys keep defaults expect(hints).toContain("i:Install"); }); diff --git a/packages/ui/src/views/config.ts b/packages/ui/src/views/config.ts index 03becc2..8ae7c06 100644 --- a/packages/ui/src/views/config.ts +++ b/packages/ui/src/views/config.ts @@ -75,7 +75,7 @@ function buildConfigTitle(overrides: Record): string { const s = resolveKeyHint("config-sync", overrides, "s"); const d = resolveKeyHint("config-detect", overrides, "d"); const r = resolveKeyHint("config-refresh", overrides, "r"); - return ` ${getIconChar("nav.config")} Config [${g}:Generate ${i}:Install ${s}:Sync ${d}:Detect ${r}:Refresh] `; + return ` ${getIconChar("nav.config")} Config [${g}:Generate ${i}:Install ${s}:MCP install ${d}:Detect ${r}:Refresh] `; } /** @@ -324,7 +324,7 @@ export function renderConfigView(ui: UiKit, state: ConfigViewState): T { [ ["config-generate", "Generate", "g"], ["config-install", "Install", "i"], - ["config-sync", "Sync", "s"], + ["config-sync", "MCP install", "s"], ["config-detect", "Detect", "d"], ["config-refresh", "Refresh", "r"], ["config-editor", "$EDITOR", "e"], @@ -370,7 +370,7 @@ export function getConfigKeyHints(overrides: Record): string { [ ["config-generate", "Generate", "g"], ["config-install", "Install", "i"], - ["config-sync", "Sync", "s"], + ["config-sync", "MCP install", "s"], ["config-detect", "Detect", "d"], ["config-refresh", "Refresh", "r"], ["config-editor", "$EDITOR", "e"], diff --git a/packages/ui/src/views/help.test.ts b/packages/ui/src/views/help.test.ts index 1e9f9d2..39a361e 100644 --- a/packages/ui/src/views/help.test.ts +++ b/packages/ui/src/views/help.test.ts @@ -77,6 +77,19 @@ describe("renderHelpOverlay", () => { expect(header).toBeTruthy(); }); + it("describes s as MCP install, not union sync", () => { + const vnode = renderHelpOverlay(mockUi) as { + children: [{ children: Array<{ content?: string; children?: Array<{ content?: string }> }> }]; + }; + const col = vnode.children[0]!; + const allText = col.children.flatMap((c) => + "children" in c && Array.isArray(c.children) ? c.children : [c], + ); + const mcpHint = allText.find((c) => c.content?.includes("Install MCP servers")); + expect(mcpHint).toBeTruthy(); + expect(allText.some((c) => c.content?.includes("Sync config"))).toBe(false); + }); + it("includes quit keybinding", () => { const vnode = renderHelpOverlay(mockUi) as { children: [{ children: Array<{ content: string }> }]; diff --git a/packages/ui/src/views/help.ts b/packages/ui/src/views/help.ts index b371dfb..77b7f84 100644 --- a/packages/ui/src/views/help.ts +++ b/packages/ui/src/views/help.ts @@ -87,7 +87,7 @@ export function renderHelpOverlay( sectionHeader(ui, getIconChar("nav.config"), "Config"), hint(ui, "g", "Generate config"), hint(ui, "i", "Install config"), - hint(ui, "s", "Sync config"), + hint(ui, "s", "Install MCP servers"), hint(ui, "d", "Detect engines"), hint(ui, "r", "Refresh status"), hint(ui, "e", "Open in $EDITOR"), diff --git a/packages/ui/src/widgets/config-dashboard.test.ts b/packages/ui/src/widgets/config-dashboard.test.ts index dd108df..8f30c30 100644 --- a/packages/ui/src/widgets/config-dashboard.test.ts +++ b/packages/ui/src/widgets/config-dashboard.test.ts @@ -2,15 +2,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("node:fs", () => ({ existsSync: vi.fn().mockReturnValue(false), + readFileSync: vi.fn(), })); vi.mock("node:path", () => ({ resolve: (...args: string[]) => args.join("/"), })); -import { getEngineStatus, formatEngineRow } from "./config-dashboard.js"; -import type { EngineStatus } from "./config-dashboard.js"; -import { existsSync } from "node:fs"; +import { + computeManifestHealth, + formatEngineRow, + getConfigDashboardData, + getEngineStatus, + getToolDeployments, +} from "./config-dashboard.js"; +import type { EngineStatus, ToolDeployment } from "./config-dashboard.js"; +import { existsSync, readFileSync } from "node:fs"; beforeEach(() => { vi.clearAllMocks(); @@ -73,6 +80,118 @@ describe("getEngineStatus()", () => { const configured = statuses.filter((s) => s.configured); expect(configured).toHaveLength(2); }); + + it("marks engine as configured from .ai-tools/ when cwd has no config", async () => { + (existsSync as ReturnType).mockImplementation((path: unknown) => { + const value = String(path); + return value.endsWith("/.ai-tools") || value.includes("/.ai-tools/ai-rules.config.ts"); + }); + + const statuses = await getEngineStatus(); + const rules = statuses.find((s) => s.engine === "rules"); + expect(rules?.configured).toBe(true); + expect(rules?.configPath).toContain("/.ai-tools/ai-rules.config.ts"); + }); + + it("marks engine as configured from .ai-tools/.json", async () => { + (existsSync as ReturnType).mockImplementation((path: unknown) => { + const value = String(path); + return value.endsWith("/.ai-tools") || value.endsWith("/.ai-tools/mcp.json"); + }); + + const statuses = await getEngineStatus(); + const mcp = statuses.find((s) => s.engine === "mcp"); + expect(mcp?.configured).toBe(true); + expect(mcp?.configPath).toContain("/.ai-tools/mcp.json"); + }); +}); + +describe("getToolDeployments()", () => { + it("returns [] when the manifest is missing", async () => { + (existsSync as ReturnType).mockReturnValue(false); + expect(await getToolDeployments()).toEqual([]); + }); + + it("returns [] when the manifest is invalid JSON", async () => { + (existsSync as ReturnType).mockReturnValue(true); + (readFileSync as ReturnType).mockReturnValue("{not json"); + expect(await getToolDeployments()).toEqual([]); + }); + + it("flattens manifest targets into deployments", async () => { + (existsSync as ReturnType).mockReturnValue(true); + (readFileSync as ReturnType).mockReturnValue( + JSON.stringify({ + version: 1, + entries: [ + { + engine: "mcp", + id: "github", + canonicalPath: ".ai-tools/mcp.json", + updatedAt: "t", + targets: [ + { + adapterId: "cursor", + targetPath: ".cursor/mcp.json", + strategy: "transform", + status: "direct", + }, + { + adapterId: "zcode", + targetPath: ".zcode/config.json", + strategy: "symlink", + status: "stale", + }, + ], + }, + ], + }), + ); + + expect(await getToolDeployments()).toEqual([ + { + adapterId: "cursor", + targetPath: ".cursor/mcp.json", + strategy: "transform", + status: "direct", + }, + { + adapterId: "zcode", + targetPath: ".zcode/config.json", + strategy: "symlink", + status: "stale", + }, + ]); + }); +}); + +describe("computeManifestHealth()", () => { + it("counts linked, direct, stale, and missing targets", async () => { + (existsSync as ReturnType).mockReturnValue(true); + const deployments: ToolDeployment[] = [ + { adapterId: "a", targetPath: "a", strategy: "transform", status: "linked" }, + { adapterId: "b", targetPath: "b", strategy: "transform", status: "direct" }, + { adapterId: "c", targetPath: "c", strategy: "symlink", status: "stale" }, + { adapterId: "d", targetPath: "d", strategy: "symlink", status: "missing" }, + ]; + expect(await computeManifestHealth(deployments)).toEqual({ + exists: true, + entryCount: 4, + linkedCount: 2, + staleCount: 1, + missingCount: 1, + }); + }); +}); + +describe("getConfigDashboardData()", () => { + it("reports error when canonical mode has no manifest", async () => { + (existsSync as ReturnType).mockReturnValue(false); + const data = await getConfigDashboardData("canonical"); + expect(data.configHealth).toBe("error"); + expect(data.manifestHealth.exists).toBe(false); + expect(data.deployments).toEqual([]); + }); }); // -- formatEngineRow -- diff --git a/scripts/cli-publish-assets.js b/scripts/cli-publish-assets.js index c4f6fe5..f996af4 100644 --- a/scripts/cli-publish-assets.js +++ b/scripts/cli-publish-assets.js @@ -10,4 +10,9 @@ export function stageCliPublishAssets(repoRoot) { const cliRoot = join(repoRoot, "packages", "cli"); mkdirSync(cliRoot, { recursive: true }); cpSync(readmeSource, join(cliRoot, "README.md")); + + const licenseSource = join(repoRoot, "LICENSE"); + if (existsSync(licenseSource)) { + cpSync(licenseSource, join(cliRoot, "LICENSE")); + } } diff --git a/scripts/smoke-package.mjs b/scripts/smoke-package.mjs index 7c0cafa..ceee517 100644 --- a/scripts/smoke-package.mjs +++ b/scripts/smoke-package.mjs @@ -94,6 +94,12 @@ try { const help = await run(bin, ["help"], { cwd: fixture, capture: true }); assertIncludes(help.stdout, "Unified CLI for ai-tools engines"); + assertIncludes(help.stdout, "--layer"); + assertIncludes(help.stdout, "sessions"); + + const mcpHelp = await run(bin, ["mcp", "help"], { cwd: fixture, capture: true }); + assertIncludes(mcpHelp.stdout, "audit"); + assertIncludes(mcpHelp.stdout, "--layer"); await writeFile( join(fixture, "ai-plugin.config.mjs"), @@ -145,7 +151,7 @@ try { "plugins", "plan", "--config=ai-plugin.config.mjs", - "--tools=codex,opencode,claude-code,antigravity-cli", + "--tools=codex,opencode,claude-code,antigravity-cli,grok,zcode", "--force", ], { cwd: fixture, capture: true }, @@ -155,6 +161,8 @@ try { assertIncludes(plan.stdout, "OpenCode"); assertIncludes(plan.stdout, "Claude Code"); assertIncludes(plan.stdout, "Antigravity CLI"); + assertIncludes(plan.stdout, "Grok"); + assertIncludes(plan.stdout, "ZCode"); assertIncludes(plan.stdout, "agents: ready"); const installDryRun = await run( diff --git a/vitest.config.ts b/vitest.config.ts index 36b98a2..4e9752e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,7 +33,12 @@ export default defineConfig({ ], exclude: [ "packages/*/src/**/*.test.ts", - "packages/*/src/**/index.ts", // barrel re-exports + "packages/*/src/index.ts", // package barrels + "packages/*/src/adapters/index.ts", + "packages/*/src/store/index.ts", + "packages/*/src/config/index.ts", + "packages/*/src/hooks/index.ts", + "packages/*/src/types/index.ts", "packages/*/src/adapters/all.ts", // side-effect registrations "packages/*/src/config/define.ts", // identity helpers exercised by engine config tests "packages/hooks/src/types/adapter.ts", // type-only @@ -52,13 +57,12 @@ export default defineConfig({ "packages/sessions/src/types/*.ts", "packages/sessions/src/cli/bin.ts", "packages/ui/src/types.ts", + "packages/ui/src/runtime/types.ts", // type-only + "packages/ui/src/runtime/state.ts", // type-only + "packages/ui/src/terminal/index.ts", // barrel re-exports "packages/ui/src/cli/bin.ts", "packages/ui/src/test-helpers.ts", // test-only utilities "packages/ui/src/view.ts", // TUI entry-point shim - "packages/ui/src/tui.ts", // interactive renderer covered by smoke/manual TUI checks - "packages/ui/src/tmux/**", // tmux integration requires a real terminal session - "packages/ui/src/runtime/action-executor.ts", // interactive action orchestration - "packages/ui/src/runtime/shell-command-executor.ts", // shell side effects covered by smoke/manual checks "**/dist/**", ], },